fix(phase2): restore agent streams and persist extension installations
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import Commands from './WorkspacePluginCommands.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { listPluginCommands, executePluginCommand } from '@/services/pluginService'
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/services/pluginService', () => ({ listPluginCommands: vi.fn(), executePluginCommand: vi.fn() }))
|
||||
const command = { command_id:'inspect',plugin_id:'p', title:'Inspect', enabled:true, when:['editor.has_selection'], parameters:{type:'object',properties:{}}, locations:['context_menu','toolbar'] }
|
||||
afterEach(() => { document.body.replaceChildren(); vi.clearAllMocks() })
|
||||
it('captures source selection for context commands and sends the snapshot', async () => {
|
||||
vi.mocked(listPluginCommands).mockResolvedValue([command as any])
|
||||
vi.mocked(executePluginCommand).mockResolvedValue({ effect:{type:'notification',payload:{message:'done'}} } as any)
|
||||
const pinia = createPinia(); const editor = useEditorStore(pinia); editor.currentFilePath = '/note.md'
|
||||
const wrapper = mount(Commands,{attachTo:document.body,global:{plugins:[pinia],stubs:{AppDialog:{template:'<div><slot/></div>'}}},slots:{default:'<textarea class="source">abcdef</textarea>'}})
|
||||
const input = wrapper.get('textarea').element as HTMLTextAreaElement
|
||||
input.focus(); input.setSelectionRange(1,4)
|
||||
await wrapper.get('textarea').trigger('contextmenu'); await flushPromises()
|
||||
expect(listPluginCommands).toHaveBeenCalledWith('context_menu')
|
||||
await wrapper.findAll('button').find(button=>button.text()==='Inspect')!.trigger('click')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(executePluginCommand).toHaveBeenCalledWith('inspect',{},expect.objectContaining({selection:'bcd',file_path:'/note.md'}))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('filters disabled commands and invalidates an open form when changing file', async () => {
|
||||
vi.mocked(listPluginCommands).mockResolvedValue([{...command,when:[],enabled:false} as any])
|
||||
const pinia=createPinia(); const editor=useEditorStore(pinia); editor.currentFilePath='/one.md'
|
||||
const wrapper=mount(Commands,{global:{plugins:[pinia],stubs:{AppDialog:{template:'<div><slot/></div>'}}}})
|
||||
await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(listPluginCommands).toHaveBeenCalledWith('toolbar')
|
||||
expect(wrapper.text()).not.toContain('Inspect')
|
||||
editor.currentFilePath='/two.md'; await flushPromises()
|
||||
expect(wrapper.find('section.modal').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AppDialog from '@/components/common/AppDialog.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { usePluginStore } from '@/stores/plugin'
|
||||
import { listPluginCommands, executePluginCommand } from '@/services/pluginService'
|
||||
import { applyCommandEffect, commandFields, initialArguments, coerceArgument, cleanArguments, missingRequiredFields } from '@/services/pluginCommandForm'
|
||||
import type { PluginCommand, PluginCommandContext, PluginCommandLocation } from '@/contracts'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const editor = useEditorStore(), workspace = useWorkspaceStore(), plugins = usePluginStore(), router = useRouter()
|
||||
const open = ref(false), busy = ref(false), error = ref(''), notice = ref('')
|
||||
const commands = ref<PluginCommand[]>([]), selected = ref<PluginCommand | null>(null)
|
||||
const args = ref<Record<string, unknown>>({})
|
||||
const snapshot = ref<PluginCommandContext>({ vault_id: null, note_id: null, file_path: null, selection: null })
|
||||
let revision = 0
|
||||
function capture() {
|
||||
const input = document.activeElement
|
||||
const selection = input instanceof HTMLTextAreaElement
|
||||
? input.value.slice(input.selectionStart, input.selectionEnd)
|
||||
: window.getSelection()?.toString() || ''
|
||||
snapshot.value = { vault_id: workspace.hasVault ? workspace.vaultId : null, note_id: editor.currentNoteId, file_path: editor.currentFilePath, selection: selection || null }
|
||||
}
|
||||
function available(command: PluginCommand) {
|
||||
const context = snapshot.value
|
||||
return command.enabled && command.when.every(condition => ({
|
||||
'workspace.has_vault': Boolean(context.vault_id), 'editor.has_note': Boolean(context.note_id), 'editor.has_selection': Boolean(context.selection),
|
||||
})[condition])
|
||||
}
|
||||
async function show(location: PluginCommandLocation) {
|
||||
const version = ++revision
|
||||
open.value = true; error.value = ''; selected.value = null; commands.value = []
|
||||
try {
|
||||
const result = await listPluginCommands(location)
|
||||
if (version === revision) commands.value = result.filter(available)
|
||||
} catch (reason) { if (version === revision) error.value = String(reason) }
|
||||
}
|
||||
function contextMenu(event: MouseEvent) {
|
||||
if (!(event.target instanceof Element) || !event.target.closest('.ProseMirror, .source')) return
|
||||
event.preventDefault(); capture(); void show('context_menu')
|
||||
}
|
||||
function choose(command: PluginCommand) { selected.value = command; args.value = initialArguments(command) }
|
||||
function close() { if (!busy.value) { open.value = false; revision++ } }
|
||||
watch(() => editor.currentFilePath, () => { open.value = false; revision++ })
|
||||
watch(() => plugins.plugins, () => { if (!busy.value) close() }, { deep: true })
|
||||
const fields = computed(() => selected.value ? commandFields(selected.value) : [])
|
||||
async function run() {
|
||||
const command = selected.value
|
||||
if (!command || busy.value || !available(command)) return
|
||||
if (snapshot.value.file_path !== editor.currentFilePath) { close(); return }
|
||||
if (missingRequiredFields(command, args.value).length) { error.value = t('请填写必填参数', 'Complete required fields'); return }
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
// Runtime rechecks enabled state, schema, when conditions and permissions.
|
||||
const result = await executePluginCommand(command.command_id, cleanArguments(args.value), { ...snapshot.value })
|
||||
await applyCommandEffect(result.effect, {
|
||||
navigate: path => router.push(path),
|
||||
refresh: async scope => {
|
||||
if (scope === 'workspace') await workspace.refreshFileTree()
|
||||
else if (scope === 'plugins') await plugins.loadPlugins()
|
||||
else if (scope === 'commands') commands.value = (await listPluginCommands()).filter(available)
|
||||
else { plugins.selectPlugin(command.plugin_id); await router.push('/extensions/plugins') }
|
||||
},
|
||||
notify: value => { notice.value = value },
|
||||
})
|
||||
open.value = false
|
||||
} catch (reason) { error.value = reason instanceof Error ? reason.message : String(reason) }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workspace-plugin-host" @contextmenu="contextMenu">
|
||||
<div class="workspace-plugin-toolbar" role="toolbar" :aria-label="t('扩展工具栏', 'Extension toolbar')">
|
||||
<button class="button-secondary" @pointerdown.prevent="capture" @click="event => { if (!event.detail) capture(); show('toolbar') }">{{ t('扩展命令', 'Extension commands') }}</button>
|
||||
<span v-if="notice" role="status">{{ notice }}</span>
|
||||
</div>
|
||||
<slot />
|
||||
<AppDialog v-if="open" :label="t('扩展命令', 'Extension commands')" :dismissible="!busy" @close="close">
|
||||
<section class="modal">
|
||||
<div class="section-head"><h2>{{ t('扩展命令', 'Extension commands') }}</h2><button class="button-secondary" :disabled="busy" @click="close">{{ t('关闭', 'Close') }}</button></div>
|
||||
<p v-if="error" class="error-banner" role="alert">{{ error }}</p>
|
||||
<div class="workspace-command-list">
|
||||
<button v-for="command in commands" :key="command.command_id" class="button-secondary" :disabled="busy" :aria-pressed="selected?.command_id === command.command_id" @click="choose(command)">{{ command.title }}</button>
|
||||
<p v-if="!commands.length">{{ t('当前上下文没有可用的扩展命令。', 'No extension commands are available in this context.') }}</p>
|
||||
</div>
|
||||
<form v-if="selected" @submit.prevent="run">
|
||||
<p>{{ selected.description }}</p>
|
||||
<label v-for="field in fields" :key="field.key" class="form-field">
|
||||
<span>{{ field.title }}{{ field.required ? ' *' : '' }}</span>
|
||||
<select v-if="field.enum || field.type === 'boolean'" class="select" :value="String(args[field.key] ?? '')" @change="args[field.key] = coerceArgument(field, ($event.target as HTMLSelectElement).value)">
|
||||
<option value="">{{ t('请选择', 'Select') }}</option><option v-for="value in field.enum || ['false', 'true']" :key="value" :value="value">{{ value }}</option>
|
||||
</select>
|
||||
<input v-else class="input" :required="field.required" :type="['number','integer'].includes(field.type) ? 'number' : 'text'" :value="String(args[field.key] ?? '')" @input="args[field.key] = coerceArgument(field, ($event.target as HTMLInputElement).value)" />
|
||||
<small>{{ field.description }}</small>
|
||||
</label>
|
||||
<button class="button-primary" :disabled="busy">{{ t('执行', 'Run') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-plugin-host { display: contents; }
|
||||
.workspace-plugin-toolbar { display: flex; gap: var(--space-sm); align-items: center; padding: var(--space-xs) var(--space-md); background: var(--color-background-secondary); border-bottom: 1px solid var(--color-border-default); }
|
||||
.workspace-plugin-toolbar span { overflow-wrap: anywhere; font-size: var(--font-size-sm); }
|
||||
.workspace-command-list, form { display: grid; gap: var(--space-sm); margin-block: var(--space-md); }
|
||||
.section-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); }
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import EditorHeader from '@/features/editor/EditorHeader.vue'
|
||||
import EditorPane from '@/features/editor/EditorPane.vue'
|
||||
import WorkspacePluginCommands from './WorkspacePluginCommands.vue'
|
||||
import { EditPen } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { t } from '@/i18n'
|
||||
@@ -13,7 +14,7 @@ const workspaceStore = useWorkspaceStore()
|
||||
<div class="workspace-view">
|
||||
<template v-if="workspaceStore.activeFilePath">
|
||||
<EditorHeader />
|
||||
<EditorPane />
|
||||
<WorkspacePluginCommands><EditorPane /></WorkspacePluginCommands>
|
||||
</template>
|
||||
<div v-else class="empty-workspace">
|
||||
<div class="empty-content">
|
||||
|
||||
Reference in New Issue
Block a user