feat(desktop): 增加原生 Vault 写入与属性导入
This commit is contained in:
@@ -63,7 +63,8 @@ describe('EditorPane file switching', () => {
|
||||
wrapper = mount(EditorPane, { attachTo: document.body })
|
||||
await nextTick()
|
||||
|
||||
const textarea = wrapper.get('textarea')
|
||||
await vi.waitFor(() => expect(wrapper!.find('.cm-content').exists()).toBe(true))
|
||||
const textarea = wrapper.get('.cm-content')
|
||||
expect(textarea.attributes('spellcheck')).toBe('true')
|
||||
expect(textarea.attributes('lang')).toBe('en')
|
||||
expect(textarea.attributes('aria-label')).toBe('Markdown source editor')
|
||||
|
||||
@@ -1,36 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, ref, watch } from 'vue'
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import EditorScrollButtons from './EditorScrollButtons.vue'
|
||||
const SourceMarkdownEditor = defineAsyncComponent(() => import('./SourceMarkdownEditor.vue'))
|
||||
const VisualMarkdownEditor = defineAsyncComponent(() => import('./VisualMarkdownEditor.vue'))
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const themeStore = useThemeStore()
|
||||
const sourceEditor = ref<HTMLTextAreaElement | null>(null)
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
watch(() => editorStore.headingRequest, request => {
|
||||
const input = sourceEditor.value
|
||||
if (!request || !input || request.path !== editorStore.currentFilePath) return
|
||||
input.focus()
|
||||
input.setSelectionRange(request.offset, request.offset)
|
||||
const lines = input.value.slice(0, request.offset).split('\n').length - 1
|
||||
input.scrollTop = lines * (parseFloat(getComputedStyle(input).lineHeight) || 24)
|
||||
})
|
||||
function updateContent(event: Event) {
|
||||
editorStore.updateContent((event.target as HTMLTextAreaElement).value)
|
||||
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="editor-scroll-pane">
|
||||
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${editorStore.contentRevision}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
|
||||
:initial-content="editorStore.content" />
|
||||
<textarea v-else ref="sourceEditor" class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
|
||||
:lang="settingsStore.language" :aria-label="settingsStore.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器'" @input="updateContent" />
|
||||
<SourceMarkdownEditor v-else :key="`${editorStore.currentFilePath ?? 'empty'}:${editorStore.contentRevision}`" :initial-content="editorStore.content" />
|
||||
<EditorScrollButtons :container="container" :content="editorStore.content" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import SourceMarkdownEditor from './SourceMarkdownEditor.vue'
|
||||
import { executeEditorCommand } from '@/services/editorCommandService'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import * as workspace from '@/services/workspaceService'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
const original = '***\ntitle: 中文\ntags: [一, 二, 一]\ncustom: [1, false]\n---\n# 正文\n'
|
||||
beforeEach(async () => {
|
||||
localStorage.clear(); setActivePinia(createPinia())
|
||||
vi.spyOn(workspace, 'readFileContent').mockResolvedValue(original)
|
||||
vi.spyOn(workspace, 'getNoteId').mockResolvedValue('note-fixture')
|
||||
vi.spyOn(workspace, 'saveFileContent').mockResolvedValue()
|
||||
const store = useEditorStore(); store.setMode('source'); await store.loadFile('/fixture.md')
|
||||
wrapper = mount(SourceMarkdownEditor, { props: { initialContent: store.content }, attachTo: document.body })
|
||||
})
|
||||
afterEach(() => { wrapper?.unmount(); useEditorStore().closeFile(); vi.restoreAllMocks() })
|
||||
|
||||
it('完整属性转换是一笔可撤销重做的源码事务', async () => {
|
||||
const store = useEditorStore()
|
||||
expect(await executeEditorCommand('editor.import-note-properties')).toEqual({ ok: true })
|
||||
expect(store.content).toMatch(/^---\n/)
|
||||
const converted = store.content
|
||||
expect(store.saveStatus).toBe('dirty')
|
||||
expect(await executeEditorCommand('editor.undo')).toEqual({ ok: true })
|
||||
expect(store.content).toBe(original)
|
||||
expect(await executeEditorCommand('editor.redo')).toEqual({ ok: true })
|
||||
expect(store.content).toBe(converted)
|
||||
})
|
||||
|
||||
it('保存失败保留转换后的内存正文,仍可撤销', async () => {
|
||||
const store = useEditorStore()
|
||||
vi.mocked(workspace.saveFileContent).mockRejectedValue(new Error('fixture disk full'))
|
||||
await executeEditorCommand('editor.import-note-properties')
|
||||
await store.save()
|
||||
expect(store.saveStatus).toBe('save_failed')
|
||||
expect(store.content).toMatch(/^---/)
|
||||
await executeEditorCommand('editor.undo')
|
||||
expect(store.content).toBe(original)
|
||||
})
|
||||
|
||||
it('冲突文档禁用命令,保持原始内容', async () => {
|
||||
const store = useEditorStore(); store.saveStatus = 'conflict'
|
||||
expect(await executeEditorCommand('editor.import-note-properties')).toMatchObject({ ok: false, reason: 'unavailable' })
|
||||
expect(store.content).toBe(original)
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { EditorState, Compartment } from '@codemirror/state'
|
||||
import { EditorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
import { defaultKeymap, history, historyKeymap, isolateHistory, undo, redo } from '@codemirror/commands'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { registerEditorCommands } from '@/services/editorCommandService'
|
||||
import { previewPropertyImport, type PropertyChoices, type PropertyConflict } from './importProperties'
|
||||
import AppDialog from '@/components/common/AppDialog.vue'
|
||||
|
||||
const props = defineProps<{ initialContent: string }>()
|
||||
const editor = useEditorStore(), settings = useSettingsStore()
|
||||
const root = ref<HTMLElement | null>(null), error = ref('')
|
||||
const conflicts = ref<PropertyConflict[]>([]), choices = ref<PropertyChoices>({})
|
||||
const proofing = new Compartment()
|
||||
let view: EditorView | undefined, dispose: (() => void) | undefined
|
||||
let pending: { content: string; path: string | null; from: number; to: number; state: EditorState } | undefined
|
||||
function attributes() {
|
||||
return EditorView.contentAttributes.of({ spellcheck: String(settings.spellCheck), lang: settings.language,
|
||||
'aria-label': settings.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器' })
|
||||
}
|
||||
function available() { return !!view && !!editor.currentFilePath && !['conflict', 'external_changed'].includes(editor.saveStatus) }
|
||||
function importProperties() {
|
||||
if (!available() || !view) return { ok: false as const, reason: 'unavailable' as const }
|
||||
error.value = ''; choices.value = {}
|
||||
const selection = view.state.selection.main
|
||||
pending = { content: view.state.doc.toString(), path: editor.currentFilePath, from: selection.from, to: selection.to, state: view.state }
|
||||
try {
|
||||
const preview = previewPropertyImport(pending.content, selection)
|
||||
if (preview.conflicts.length) conflicts.value = preview.conflicts
|
||||
else applyImport()
|
||||
return { ok: true as const }
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : String(reason)
|
||||
pending = undefined
|
||||
return { ok: false as const, reason: 'failed' as const }
|
||||
}
|
||||
}
|
||||
function applyImport() {
|
||||
if (!pending || !view) return
|
||||
// 弹窗期间文档或路径改变就取消,不能把旧预览写入新笔记或新版本。
|
||||
if (editor.currentFilePath !== pending.path || !view.state.doc.eq(pending.state.doc) || editor.content !== pending.content || !available()) {
|
||||
pending = undefined; conflicts.value = []; error.value = '文档已经变化,请重新导入。'; return
|
||||
}
|
||||
try {
|
||||
const result = previewPropertyImport(pending.content, { from: pending.from, to: pending.to }, choices.value)
|
||||
if (result.content === null) return
|
||||
if (result.content !== pending.content) {
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: result.content }, annotations: isolateHistory.of('full') })
|
||||
}
|
||||
pending = undefined; conflicts.value = []; view.focus()
|
||||
} catch (reason) { error.value = reason instanceof Error ? reason.message : String(reason) }
|
||||
}
|
||||
onMounted(() => {
|
||||
view = new EditorView({ parent: root.value!, state: EditorState.create({ doc: props.initialContent, extensions: [
|
||||
history(), keymap.of([...defaultKeymap, ...historyKeymap]), lineNumbers(), markdown(), proofing.of(attributes()),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of(update => {
|
||||
if (update.docChanged) { editor.updateContent(update.state.doc.toString()); editor.scheduleAutoSave(settings.autoSaveInterval) }
|
||||
}),
|
||||
EditorView.theme({ '&': { height: '100%', color: 'var(--color-text-primary)', backgroundColor: 'var(--color-background-primary)' },
|
||||
'.cm-scroller': { fontFamily: 'var(--font-editor-mono)', fontSize: 'var(--font-editor-size)', overflow: 'auto' },
|
||||
'.cm-gutters': { backgroundColor: 'var(--color-background-secondary)', color: 'var(--color-text-secondary)', border: 'none' },
|
||||
'.cm-content': { padding: '24px 8px', minHeight: '100%' } }),
|
||||
] }) })
|
||||
dispose = registerEditorCommands({ available, handlers: {
|
||||
'editor.import-note-properties': importProperties,
|
||||
'editor.undo': () => undo(view!) ? { ok: true } : { ok: false, reason: 'unavailable' },
|
||||
'editor.redo': () => redo(view!) ? { ok: true } : { ok: false, reason: 'unavailable' },
|
||||
} })
|
||||
})
|
||||
watch(() => [settings.spellCheck, settings.language], () => view?.dispatch({ effects: proofing.reconfigure(attributes()) }))
|
||||
watch(() => editor.headingRequest, request => {
|
||||
if (!view || !request || request.path !== editor.currentFilePath) return
|
||||
const offset = Math.min(view.state.doc.length, request.offset)
|
||||
view.dispatch({ selection: { anchor: offset }, effects: EditorView.scrollIntoView(offset, { y: 'start' }) }); view.focus()
|
||||
})
|
||||
onBeforeUnmount(() => { dispose?.(); view?.destroy(); pending = undefined })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="source-container">
|
||||
<div class="source-actions"><button class="btn" :disabled="!editor.currentFilePath || ['conflict', 'external_changed'].includes(editor.saveStatus)" @click="importProperties">导入为笔记属性…</button></div>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<div ref="root" class="source-code" />
|
||||
<AppDialog v-if="conflicts.length" label="属性冲突预览" @close="conflicts = []; pending = undefined">
|
||||
<h2>选择要保留的属性</h2>
|
||||
<div v-for="conflict in conflicts" :key="conflict.key">
|
||||
<strong>{{ conflict.key }}</strong><pre>已有:{{ conflict.current }}
|
||||
导入:{{ conflict.incoming }}</pre>
|
||||
<label>保留哪一侧 <select v-model="choices[conflict.key]"><option disabled value="">请选择</option><option value="current">已有属性</option><option value="incoming">导入属性</option></select></label>
|
||||
</div>
|
||||
<button class="btn btn-primary" :disabled="conflicts.some(item => !choices[item.key])" @click="applyImport">作为一次编辑应用</button>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.source-container { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
.source-code { flex: 1; min-height: 0; overflow: hidden; }
|
||||
.source-actions { padding: var(--space-sm); border-bottom: 1px solid var(--color-border-subtle); }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseDocument } from 'yaml'
|
||||
import { previewPropertyImport } from './importProperties'
|
||||
|
||||
describe('笔记属性导入事务预览', () => {
|
||||
it('历史格式规范化,保留复杂字段、顺序标签与行为配置', () => {
|
||||
const source = '***\ntitle: 中文\ntags: [笔记, "空 格", 笔记]\nembedding_local_only: true\ncustom:\n nested: [1, false]\n---\n# 正文'
|
||||
const result = previewPropertyImport(source).content!
|
||||
expect(result.startsWith('---\n')).toBe(true)
|
||||
expect(result).toContain('embedding_local_only: true')
|
||||
expect(result).toContain('nested: [ 1, false ]')
|
||||
expect(result).toContain('# 正文')
|
||||
expect(previewPropertyImport(result).content).toBe(result)
|
||||
})
|
||||
it('字段冲突必须明确选择,未选时无候选内容', () => {
|
||||
const source = '---\ntitle: 原标题\n---\n\n***\ntitle: 新标题\ntags: a,b\n---\n正文'
|
||||
const from = source.indexOf('***'), to = source.lastIndexOf('正文')
|
||||
expect(previewPropertyImport(source, { from, to }).content).toBeNull()
|
||||
const result = previewPropertyImport(source, { from, to }, { title: 'incoming' }).content!
|
||||
expect(result).toContain('title: 新标题')
|
||||
expect(result.match(/^---$/gm)).toHaveLength(2)
|
||||
})
|
||||
it('普通分隔线、正文和代码不能误判', () => {
|
||||
for (const source of ['---\n普通正文\n---', '正文: 值', '***\nother: body\n---']) expect(() => previewPropertyImport(source)).toThrow()
|
||||
const source = '```yaml\n---\ntitle: example\n---\n```'
|
||||
expect(() => previewPropertyImport(source, { from: 8, to: source.length - 3 })).toThrow('代码块')
|
||||
})
|
||||
it('单块锚点保持别名语义;非法重复键不改内容', () => {
|
||||
const source = '---\ntitle: 标题\ncustom: &value [1, 2]\ncopy: *value\n---\n正文'
|
||||
const output = previewPropertyImport(source).content!
|
||||
const yaml = output.split('---')[1]!
|
||||
const value = parseDocument(yaml).toJS()
|
||||
expect(value.copy).toEqual([1, 2])
|
||||
expect(() => previewPropertyImport('---\ntitle: a\ntitle: b\n---')).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
/** 保留 YAML 节点、未知字段及类型;有歧义时只返回预览,不改正文。 */
|
||||
import { isMap, isScalar, isSeq, parseDocument, type Document } from 'yaml'
|
||||
|
||||
export interface PropertyConflict { key: string; current: string; incoming: string }
|
||||
export interface ImportPreview { content: string | null; conflicts: PropertyConflict[] }
|
||||
export type PropertyChoices = Record<string, 'current' | 'incoming'>
|
||||
|
||||
function block(source: string) {
|
||||
const match = source.match(/^\uFEFF?(---|\*\*\*)[ \t]*\r?\n([\s\S]*?)\r?\n(?:---+|\.\.\.)[ \t]*(?:\r?\n|$)/)
|
||||
if (!match) return null
|
||||
const document = parseDocument(match[2]!, { uniqueKeys: true })
|
||||
if (document.errors.length || document.warnings.length || !isMap(document.contents)) throw new Error('属性 YAML 无法无损处理,请保留原文在源码中修改。')
|
||||
if (match[1] === '***' && !document.has('title') && !document.has('tags')) return null
|
||||
return { prefix: match[0], document }
|
||||
}
|
||||
|
||||
function normalizeTags(document: Document) {
|
||||
if (!document.has('tags')) return
|
||||
const previous = document.get('tags', true)
|
||||
let values: string[]
|
||||
if (isScalar(previous) && typeof previous.value === 'string') values = previous.value.split(/[,,]/).map(value => value.trim()).filter(Boolean)
|
||||
else if (isScalar(previous) && previous.value === null) values = []
|
||||
else if (isSeq(previous) && previous.items.every(item => isScalar(item) && typeof item.value === 'string' && !item.anchor)) values = previous.items.map(item => String((item as { value: string }).value))
|
||||
else throw new Error('标签结构不支持无损转换,已保留原文。')
|
||||
const replacement = document.createNode([...new Set(values)])
|
||||
if (isScalar(previous) || isSeq(previous)) {
|
||||
replacement.anchor = previous.anchor; replacement.comment = previous.comment; replacement.commentBefore = previous.commentBefore
|
||||
}
|
||||
document.set('tags', replacement)
|
||||
}
|
||||
|
||||
export function previewPropertyImport(source: string, selection?: { from: number; to: number }, choices: PropertyChoices = {}): ImportPreview {
|
||||
if (source.length > 5 * 1024 * 1024) throw new Error('文档过大,请缩小属性选区。')
|
||||
const selected = selection && selection.from !== selection.to ? selection : undefined
|
||||
const start = selected?.from ?? 0, end = selected?.to ?? source.length
|
||||
if (start < 0 || end > source.length || start >= end) throw new Error('选区无效')
|
||||
// 选区必须从完整行开始,且不能位于代码围栏内。
|
||||
if (start && source[start - 1] !== '\n') throw new Error('请选择完整属性块')
|
||||
let fence: string | null = null
|
||||
for (const line of source.slice(0, start).split(/\r?\n/)) {
|
||||
const marker = line.match(/^ {0,3}(`{3,}|~{3,})/)
|
||||
if (marker) {
|
||||
if (!fence) fence = marker[1]!
|
||||
else if (marker[1]![0] === fence[0] && marker[1]!.length >= fence.length) fence = null
|
||||
}
|
||||
}
|
||||
if (fence) throw new Error('代码块中的文本不会作为笔记属性导入')
|
||||
const incoming = block(source.slice(start, end))
|
||||
if (!incoming) throw new Error('未识别到完整的标准或历史属性块')
|
||||
if (selected && source.slice(start + incoming.prefix.length, end).trim()) throw new Error('选区包含属性块以外的正文')
|
||||
const existing = start > 0 ? block(source) : null
|
||||
if (existing && start < existing.prefix.length) throw new Error('选区与已有属性块重叠')
|
||||
const document = existing ? existing.document.clone() : incoming.document.clone()
|
||||
const conflicts: PropertyConflict[] = []
|
||||
if (existing) {
|
||||
// 跨文档别名的归属不明确,不能在合并时悄悄改变指向。
|
||||
if (/[&*][\w-]+/.test(incoming.document.toString()) || /[&*][\w-]+/.test(existing.document.toString())) throw new Error('含 YAML 锚点的多个属性块请先在源码中合并')
|
||||
for (const pair of (incoming.document.contents as NonNullable<typeof incoming.document.contents> & { items: { key: unknown }[] }).items) {
|
||||
if (!isScalar(pair.key) || typeof pair.key.value !== 'string') throw new Error('属性键必须是字符串')
|
||||
const key = pair.key.value
|
||||
const node = incoming.document.get(key, true)
|
||||
if (document.has(key) && JSON.stringify(document.get(key)) !== JSON.stringify(incoming.document.get(key))) {
|
||||
conflicts.push({ key, current: String(document.get(key, true)), incoming: String(node) })
|
||||
if (!choices[key]) continue
|
||||
if (choices[key] === 'current') continue
|
||||
}
|
||||
document.set(key, node)
|
||||
}
|
||||
}
|
||||
if (conflicts.some(conflict => !choices[conflict.key])) return { content: null, conflicts }
|
||||
normalizeTags(document)
|
||||
// 校验别名引用数量和最终文档,无法解析时不返回候选正文。
|
||||
document.toJS({ maxAliasCount: 50 })
|
||||
const body = existing
|
||||
? source.slice(existing.prefix.length, start) + source.slice(start + incoming.prefix.length)
|
||||
: source.slice(0, start) + source.slice(start + incoming.prefix.length)
|
||||
const newline = source.includes('\r\n') ? '\r\n' : '\n'
|
||||
const prefix = `---\n${document.toString()}---\n`.replace(/\n/g, newline)
|
||||
return { content: (source.startsWith('\uFEFF') ? '\uFEFF' : '') + prefix + body.replace(/^\uFEFF/, ''), conflicts }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { ArrowRight, Document, Folder, FolderOpened, Moon, Sunny } from '@elemen
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { ApiErrorClass } from '@/services/apiClient'
|
||||
import { isDesktop } from '@/services/platform/desktop'
|
||||
|
||||
const router = useRouter()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -28,7 +29,7 @@ async function initializeVault() {
|
||||
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) {
|
||||
if (!isDesktop() && settingsStore.restoreLastVault && lastVaultPath) {
|
||||
await openVault(lastVaultPath)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +51,7 @@ async function openVault(path: string) {
|
||||
}
|
||||
|
||||
async function openFolderPicker() {
|
||||
if (isDesktop()) { await openVault(''); return }
|
||||
const configured = workspaceStore.recentVaults[0]
|
||||
if (configured) await openVault(configured.path)
|
||||
}
|
||||
@@ -70,7 +72,7 @@ async function openFolderPicker() {
|
||||
<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>
|
||||
<p class="card-desc">{{ isDesktop() ? t('桌面预览:选择本地目录;AI 与同步尚未接通。', 'Desktop preview: choose a local folder. AI and sync are not connected yet.') : t('Web 联调模式连接 AI Core 当前配置的 Vault', 'Web development mode connects to the Vault configured in AI Core') }}</p>
|
||||
|
||||
<div v-if="workspaceStore.recentVaults.length" class="recent-vaults">
|
||||
<div class="section-label">{{ t('最近打开', 'Recently opened') }}</div>
|
||||
@@ -93,8 +95,8 @@ async function openFolderPicker() {
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading || !workspaceStore.recentVaults.length">
|
||||
<AppIcon :icon="FolderOpened" /> {{ t('打开后端 Vault', 'Open backend Vault') }}
|
||||
<button class="btn btn-primary" @click="openFolderPicker" :disabled="isLoading || (!isDesktop() && !workspaceStore.recentVaults.length)">
|
||||
<AppIcon :icon="FolderOpened" /> {{ isDesktop() ? t('选择本地 Vault', 'Choose local Vault') : t('打开后端 Vault', 'Open backend Vault') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSettingsStore } from './stores/settings'
|
||||
import { watch } from 'vue'
|
||||
import { appLocale } from './i18n'
|
||||
import { updateDocumentTitle } from './router'
|
||||
import { installDesktopLifecycle } from './services/platform/lifecycle'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
@@ -27,3 +28,4 @@ watch(() => settingsStore.spellCheck, (enabled) => {
|
||||
}, { immediate: true })
|
||||
|
||||
app.mount('#app')
|
||||
void installDesktopLifecycle()
|
||||
|
||||
@@ -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.'))
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SaveStatus } from '@/contracts'
|
||||
import * as workspaceService from '@/services/workspaceService'
|
||||
import { t } from '@/i18n'
|
||||
import { ApiErrorClass } from '@/services/apiClient'
|
||||
import { DesktopError } from '@/services/platform/desktop'
|
||||
|
||||
export const useEditorStore = defineStore('editor', () => {
|
||||
const mode = ref<'wysiwyg' | 'source'>('wysiwyg')
|
||||
@@ -71,7 +72,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
lastSavedAt.value = new Date().toISOString()
|
||||
}
|
||||
} catch (error) {
|
||||
if (currentFilePath.value === targetPath) saveStatus.value = error instanceof ApiErrorClass && error.code === 'NOTE_CONTENT_CONFLICT' ? 'conflict' : 'save_failed'
|
||||
if (currentFilePath.value === targetPath) saveStatus.value = (error instanceof ApiErrorClass && error.code === 'NOTE_CONTENT_CONFLICT') || (error instanceof DesktopError && error.code === 'REVISION_CONFLICT') ? 'conflict' : 'save_failed'
|
||||
} finally {
|
||||
pendingSave = null
|
||||
if (currentFilePath.value === targetPath && saveStatus.value === 'dirty') scheduleAutoSave()
|
||||
|
||||
Reference in New Issue
Block a user