-
图表类型
+
+
图表类型
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ p.name }}
+
+
+
+
+
+
{{ aiError }}
+
+
+
+
@@ -177,9 +219,11 @@ import {computed, ref, watch} from 'vue'
import {debounce} from 'lodash-es'
import * as echarts from 'echarts/core'
import {SVGRenderer} from 'echarts/renderers'
-import {BarChart3, Copy, Download, FileDown, Image as ImageIcon, Hash, Type, X} from 'lucide-vue-next'
+import {invoke} from '@tauri-apps/api/core'
+import {BarChart3, Copy, Download, FileDown, Image as ImageIcon, Hash, RefreshCw, Sparkles, Star, Type, X} from 'lucide-vue-next'
import {useTheme} from '../../composables/useTheme'
import {kvGetJSON, kvSetJSON} from '../../composables/useKvStore'
+import {useAiConfig} from '../../composables/useAiConfig'
import {useToast} from '../../plugins/toast'
import {downloadCsv} from '../../utils/csv'
import Select from '../../ui/Select.vue'
@@ -337,28 +381,82 @@ if (typeof saved.radarFill === 'boolean') {
radarFill.value = saved.radarFill
}
-const persist = debounce(() => {
- kvSetJSON(CFG_KEY, {
- chartType: chartType.value,
- agg: agg.value,
- sortOrder: sortOrder.value,
- topN: topN.value,
- dimensions: dimensions.value,
- metrics: metrics.value,
- xField: xField.value,
- yField: yField.value,
- groupField: groupField.value,
- horizontal: horizontal.value,
- stacked: stacked.value,
- smooth: smooth.value,
- ring: ring.value,
- radarFill: radarFill.value,
- showLabel: showLabel.value
- })
-}, 300)
+const buildConfig = () => ({
+ chartType: chartType.value,
+ agg: agg.value,
+ sortOrder: sortOrder.value,
+ topN: topN.value,
+ dimensions: dimensions.value,
+ metrics: metrics.value,
+ xField: xField.value,
+ yField: yField.value,
+ groupField: groupField.value,
+ horizontal: horizontal.value,
+ stacked: stacked.value,
+ smooth: smooth.value,
+ ring: ring.value,
+ radarFill: radarFill.value,
+ showLabel: showLabel.value
+})
+// 套用配置(字段按当前列过滤)
+const applyConfig = (cfg: Record
) => {
+ if (cfg.chartType && CHART_META[cfg.chartType]) {
+ chartType.value = cfg.chartType
+ }
+ if (cfg.agg) {
+ agg.value = cfg.agg
+ }
+ if (cfg.sortOrder) {
+ sortOrder.value = cfg.sortOrder
+ }
+ if (typeof cfg.topN === 'number') {
+ topN.value = cfg.topN
+ }
+ dimensions.value = Array.isArray(cfg.dimensions) ? cfg.dimensions.filter((d: string) => props.columns.includes(d)) : []
+ metrics.value = Array.isArray(cfg.metrics) ? cfg.metrics.filter((m: string) => props.columns.includes(m)) : []
+ xField.value = cfg.xField && props.columns.includes(cfg.xField) ? cfg.xField : ''
+ yField.value = cfg.yField && props.columns.includes(cfg.yField) ? cfg.yField : ''
+ groupField.value = cfg.groupField && props.columns.includes(cfg.groupField) ? cfg.groupField : ''
+ for (const k of ['horizontal', 'stacked', 'smooth', 'ring', 'radarFill', 'showLabel'] as const) {
+ if (typeof cfg[k] === 'boolean') {
+ ({horizontal, stacked, smooth, ring, radarFill, showLabel}[k]).value = cfg[k]
+ }
+ }
+}
+
+const persist = debounce(() => kvSetJSON(CFG_KEY, buildConfig()), 300)
watch([chartType, agg, sortOrder, topN, dimensions, metrics, xField, yField, groupField,
horizontal, stacked, smooth, ring, radarFill, showLabel], persist, {deep: true})
+// ---- 命名预设 ----
+interface Preset { name: string; config: Record }
+const PRESETS_KEY = 'chart.presets'
+const presets = ref(kvGetJSON(PRESETS_KEY, []))
+const savingPreset = ref(false)
+const presetName = ref('')
+const confirmSavePreset = () => {
+ const name = presetName.value.trim()
+ if (!name) {
+ return
+ }
+ const list = presets.value.filter(p => p.name !== name)
+ list.push({name, config: buildConfig()})
+ presets.value = list
+ kvSetJSON(PRESETS_KEY, list)
+ savingPreset.value = false
+ presetName.value = ''
+}
+const applyPreset = (name: string) => {
+ const p = presets.value.find(x => x.name === name)
+ if (p) {
+ applyConfig(p.config)
+ }
+}
+const deletePreset = (name: string) => {
+ presets.value = presets.value.filter(p => p.name !== name)
+ kvSetJSON(PRESETS_KEY, presets.value)
+}
+
const {isDark} = useTheme()
const toast = useToast()
const chartHost = ref()
@@ -441,6 +539,72 @@ const runExport = (fn: () => void) => {
const fields = computed(() => props.columns.map((name, i) => ({name, numeric: isNumericColumn(props.rows, i)})))
+// ---- AI 配图:自然语言 → 图表配置 ----
+const {active: aiActive} = useAiConfig()
+const aiOpen = ref(false)
+const aiPrompt = ref('')
+const aiLoading = ref(false)
+const aiError = ref('')
+const toggleAi = () => {
+ aiOpen.value = !aiOpen.value
+ aiError.value = ''
+}
+const aiGenerate = async () => {
+ if (aiLoading.value || !aiPrompt.value.trim()) {
+ return
+ }
+ if (!aiActive.value.apiKey?.trim()) {
+ aiError.value = '未配置 AI API Key(设置 → AI)'
+ return
+ }
+ aiLoading.value = true
+ aiError.value = ''
+ try {
+ const cols = fields.value.map(f => `${f.name}:${f.numeric ? 'number' : 'text'}`).join(', ')
+ const types = Object.keys(CHART_META).join(', ')
+ const system = '你是数据可视化助手。根据可用列与用户需求,输出图表配置 JSON。\n'
+ + `可用列(name:type): ${cols}\n`
+ + `支持的图表类型(value): ${types}\n`
+ + '严格输出 JSON:{"chartType":"...","dimensions":["列名"],"metrics":["列名"],"agg":"sum|count|avg|max|min"}。'
+ + '只输出 JSON,不要解释或 Markdown。维度/指标必须使用上面的列名。'
+ const text = await invoke('ai_chat', {
+ provider: aiActive.value.provider,
+ baseUrl: aiActive.value.baseUrl,
+ apiKey: aiActive.value.apiKey,
+ model: aiActive.value.model,
+ system,
+ messages: [{role: 'user', content: aiPrompt.value.trim()}]
+ })
+ const cleaned = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/```$/i, '').trim()
+ const cfg = JSON.parse(cleaned)
+ if (cfg.chartType && CHART_META[cfg.chartType]) {
+ chartType.value = cfg.chartType
+ }
+ const dims = Array.isArray(cfg.dimensions) ? cfg.dimensions.filter((d: string) => props.columns.includes(d)) : []
+ const mets = Array.isArray(cfg.metrics) ? cfg.metrics.filter((m: string) => props.columns.includes(m)) : []
+ if (cfg.agg && (cfg.agg in AGG_LABELS)) {
+ agg.value = cfg.agg
+ }
+ if (meta.value.layout === 'scatter') {
+ xField.value = mets[0] || dims[0] || ''
+ yField.value = mets[1] || ''
+ groupField.value = dims[0] || ''
+ }
+ else {
+ dimensions.value = dims
+ metrics.value = mets
+ }
+ aiOpen.value = false
+ aiPrompt.value = ''
+ }
+ catch (e: any) {
+ aiError.value = 'AI 返回无法解析或出错:' + String(e?.message || e)
+ }
+ finally {
+ aiLoading.value = false
+ }
+}
+
// 列变化(新查询)时,剔除已不存在的字段
watch(() => props.columns, (cols) => {
dimensions.value = dimensions.value.filter(d => cols.includes(d))
diff --git a/src/components/setting/Lsp.vue b/src/components/setting/Lsp.vue
new file mode 100644
index 0000000..1fcf7ee
--- /dev/null
+++ b/src/components/setting/Lsp.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
语言服务(LSP)
+
安装后可获得精准补全、悬浮文档、跳转定义、查找引用、重命名与实时诊断
+
+
+
+
+
+
+
+
+
+
{{ s.label }}
+
{{ s.install }}
+
+
已安装
+
+
+
+
{{ logs[s.id] }}
+
+
+
+
+ 自动安装依赖本机已具备对应工具链:npm(pyright / typescript / intelephense / vscode-langservers-extracted)、rustup(rust-analyzer)、go(gopls)、Homebrew(clangd / lua-language-server)、gem(solargraph)。
+ 若安装失败,可按上方命令手动安装后点「重新检测」。安装完成后请重启应用以加载新服务器。
+
+
+
+
+
diff --git a/src/composables/useCodeMirrorEditor.ts b/src/composables/useCodeMirrorEditor.ts
index e7e7a59..3abdcb9 100644
--- a/src/composables/useCodeMirrorEditor.ts
+++ b/src/composables/useCodeMirrorEditor.ts
@@ -85,11 +85,65 @@ import {markdown} from "@codemirror/lang-markdown";
import {yaml} from "@codemirror/lang-yaml";
import {sql} from "@codemirror/lang-sql";
import {useSnippets} from "./useSnippets";
+import {createLspExtensions} from "../editor/lspExtension";
interface Props
{
modelValue: string
language?: string
+ filePath?: string | null
+ rootDir?: string | null
+}
+
+// 自定义提示框样式(悬浮文档 / 诊断 / 补全),跟随明暗主题
+function buildTooltipTheme(dark: boolean) {
+ const bg = dark ? '#1f2937' : '#ffffff'
+ const border = dark ? '#374151' : '#e5e7eb'
+ const text = dark ? '#e5e7eb' : '#1f2937'
+ const codeBg = dark ? '#111827' : '#f3f4f6'
+ const sel = dark ? '#2563eb' : '#dbeafe'
+ const selText = dark ? '#ffffff' : '#1e3a8a'
+ return EditorView.theme({
+ '.cm-tooltip': {
+ border: `1px solid ${border}`,
+ borderRadius: '8px',
+ backgroundColor: bg,
+ color: text,
+ boxShadow: dark ? '0 8px 28px rgba(0,0,0,0.5)' : '0 8px 28px rgba(0,0,0,0.14)',
+ fontSize: '12px',
+ overflow: 'hidden'
+ },
+ '.cm-tooltip.cm-tooltip-hover': {maxWidth: '480px'},
+ '.cm-tooltip-hover .cm-tooltip-section': {
+ padding: '8px 10px',
+ borderTop: `1px solid ${border}`,
+ lineHeight: '1.5'
+ },
+ '.cm-tooltip-hover .cm-tooltip-section:first-child': {borderTop: 'none'},
+ '.cm-tooltip-hover pre, .cm-tooltip-hover code': {
+ backgroundColor: codeBg,
+ borderRadius: '4px',
+ padding: '1px 4px',
+ fontFamily: 'monospace',
+ whiteSpace: 'pre-wrap'
+ },
+ '.cm-tooltip-hover pre': {padding: '8px 10px', margin: '4px 0', overflowX: 'auto'},
+ // 诊断悬浮
+ '.cm-tooltip.cm-tooltip-lint': {padding: '0'},
+ '.cm-diagnostic': {padding: '6px 10px', borderLeft: 'none', marginLeft: '0'},
+ '.cm-diagnostic-error': {borderLeft: '3px solid #ef4444'},
+ '.cm-diagnostic-warning': {borderLeft: '3px solid #f59e0b'},
+ '.cm-diagnostic-info': {borderLeft: '3px solid #3b82f6'},
+ // 自动补全
+ '.cm-tooltip-autocomplete > ul': {fontFamily: 'monospace', maxHeight: '16em'},
+ '.cm-tooltip-autocomplete > ul > li': {padding: '3px 8px'},
+ '.cm-tooltip-autocomplete > ul > li[aria-selected]': {
+ backgroundColor: sel,
+ color: selText
+ },
+ '.cm-completionIcon': {opacity: '0.7', paddingRight: '6px'},
+ '.cm-completionDetail': {color: dark ? '#9ca3af' : '#6b7280', fontStyle: 'normal'}
+ }, {dark})
}
export function useCodeMirrorEditor(props: Props)
@@ -381,6 +435,22 @@ export function useCodeMirrorEditor(props: Props)
}
}
+ // 自定义悬浮/诊断/补全提示框样式(主题适配,仅样式不改定位)
+ result.push(buildTooltipTheme(isDark.value))
+
+ // LSP 语义能力(补全/悬浮/诊断/跳转/重命名);草稿用 untitled 文档,无服务器时为 null
+ if (props.language) {
+ try {
+ const lsp = await createLspExtensions(props.language, props.filePath, props.rootDir)
+ if (lsp) {
+ result.push(lsp)
+ }
+ }
+ catch (e) {
+ console.warn('LSP 初始化失败:', e)
+ }
+ }
+
// 处理行号显示逻辑
const shouldShowLineNumbers = showLineNumbers ?? editorConfig.value?.show_line_numbers ?? false
// 如果配置为不显示行号,则添加隐藏行号的扩展
@@ -473,6 +543,11 @@ export function useCodeMirrorEditor(props: Props)
await reRenderEditor()
}, {immediate: false})
+ // 文件切换:重建扩展以切换 LSP 文档(就地重配置,避免闪烁)
+ watch(() => props.filePath, async () => {
+ await updateExtensions()
+ }, {immediate: false})
+
// 监听编辑器配置变化
watch(() => editorConfig.value?.theme, async (newTheme, oldTheme) => {
if (newTheme && newTheme !== oldTheme) {
diff --git a/src/composables/useLanguageSettings.ts b/src/composables/useLanguageSettings.ts
index afa25e3..9d4e2f5 100644
--- a/src/composables/useLanguageSettings.ts
+++ b/src/composables/useLanguageSettings.ts
@@ -33,7 +33,7 @@ export function useLanguageSettings(emit: any)
}
]
- const consoleTypes = [{label: '控制台', value: 'console'}, {label: 'Web', value: 'web'}, {label: 'JSON', value: 'json'}, {label: 'Markdown', value: 'markdown'}, {label: 'XML', value: 'xml'}, {label: 'YAML', value: 'yaml'}, {label: 'SQL 表格', value: 'sqltable'}, {label: '数据表/图表', value: 'table'}]
+ const consoleTypes = [{label: '控制台', value: 'console'}, {label: 'Web', value: 'web'}, {label: 'JSON', value: 'json'}, {label: 'Markdown', value: 'markdown'}, {label: 'XML', value: 'xml'}, {label: 'YAML', value: 'yaml'}, {label: 'SQL 表格', value: 'sqltable'}, {label: '数据表/图表', value: 'table'}, {label: 'Excel 表/图表', value: 'xlsx'}]
const {
activePlugin,
diff --git a/src/composables/useSettings.ts b/src/composables/useSettings.ts
index 3ec1e78..3a8eb2e 100644
--- a/src/composables/useSettings.ts
+++ b/src/composables/useSettings.ts
@@ -1,5 +1,5 @@
import { nextTick, ref } from 'vue'
-import { BracesIcon, CodeIcon, Database, FileText, Globe, Keyboard, Server, ShieldIcon, Sparkles } from 'lucide-vue-next'
+import { BracesIcon, CodeIcon, Cpu, Database, FileText, Globe, Keyboard, Server, ShieldIcon, Sparkles } from 'lucide-vue-next'
export function useSettings(emit: any)
{
@@ -15,6 +15,7 @@ export function useSettings(emit: any)
{ key: 'ai', label: 'AI', icon: Sparkles },
{ key: 'database', label: '数据库', icon: Server },
{ key: 'language', label: '语言', icon: BracesIcon },
+ { key: 'lsp', label: '语言服务', icon: Cpu },
{ key: 'network', label: '网络', icon: Globe },
{ key: 'cache', label: '缓存', icon: Database },
{ key: 'logs', label: '日志', icon: FileText }
diff --git a/src/editor/lspExtension.ts b/src/editor/lspExtension.ts
new file mode 100644
index 0000000..1050e73
--- /dev/null
+++ b/src/editor/lspExtension.ts
@@ -0,0 +1,111 @@
+// 根据语言/文件构建 CodeMirror LSP 扩展(补全、悬浮、诊断、跳转、重命名)
+import {invoke} from '@tauri-apps/api/core'
+import {LanguageServerClient, languageServerWithTransport} from 'codemirror-languageserver'
+import {TauriLspTransport} from './lspTransport'
+import {setLspState} from './lspStatus'
+import {lspCustomHover} from './lspHover'
+
+// CodeForge 语言 key → LSP languageId(与后端 server_cmd 对应)
+const LANGUAGE_ID: Record = {
+ python3: 'python',
+ python2: 'python',
+ python: 'python',
+ typescript: 'typescript',
+ 'typescript-nodejs': 'typescript',
+ 'typescript-browser': 'typescript',
+ 'javascript-nodejs': 'javascript',
+ 'javascript-browser': 'javascript',
+ 'javascript-jquery': 'javascript',
+ nodejs: 'javascript',
+ rust: 'rust',
+ go: 'go',
+ c: 'c',
+ cpp: 'cpp',
+ 'objective-c': 'objective-c',
+ 'objective-cpp': 'objective-cpp',
+ lua: 'lua',
+ php: 'php',
+ ruby: 'ruby',
+ html: 'html',
+ css: 'css',
+ json: 'json'
+}
+
+// 草稿(未保存)时用的文件扩展名,构造 untitled 文档 URI
+const LANGUAGE_EXT: Record = {
+ python: 'py', typescript: 'ts', javascript: 'js', rust: 'rs', go: 'go',
+ c: 'c', cpp: 'cpp', 'objective-c': 'm', 'objective-cpp': 'mm',
+ lua: 'lua', php: 'php', ruby: 'rb', html: 'html', css: 'css', json: 'json'
+}
+
+export const lspSupportsLanguage = (language?: string): boolean =>
+ !!language && language in LANGUAGE_ID
+
+const toUri = (p: string): string =>
+ 'file://' + encodeURI(p.replace(/\\/g, '/')).replace(/#/g, '%23').replace(/\?/g, '%3F')
+
+/**
+ * 构建当前文件的 LSP 扩展;语言不支持 / 无文件 / 服务器不可用时返回 null。
+ */
+export async function createLspExtensions(
+ language: string | undefined,
+ filePath: string | null | undefined,
+ rootDir?: string | null
+): Promise {
+ if (!language) {
+ return null
+ }
+ const languageId = LANGUAGE_ID[language]
+ if (!languageId) {
+ setLspState(language || '', 'off')
+ return null
+ }
+ let available = false
+ try {
+ available = await invoke('lsp_available', {language})
+ }
+ catch {
+ available = false
+ }
+ if (!available) {
+ setLspState(language, 'off')
+ return null
+ }
+ setLspState(language, 'connecting')
+ try {
+ const transport = new TauriLspTransport(language)
+ const rootUri = rootDir ? toUri(rootDir) : null
+ // 已保存文件用真实路径;草稿用 untitled 文档 URI(语言服务器按内存内容分析)
+ const documentUri = filePath
+ ? toUri(filePath)
+ : `untitled:Untitled.${LANGUAGE_EXT[languageId] || 'txt'}`
+ const workspaceFolders = rootUri ? [{uri: rootUri, name: 'workspace'}] : null
+ // 自建 client 以获取初始化完成(capabilities)信号,驱动状态栏的"索引中→就绪"
+ const client = new LanguageServerClient({
+ transport,
+ rootUri,
+ workspaceFolders,
+ documentUri,
+ languageId,
+ autoClose: true,
+ onCapabilities: () => setLspState(language, 'on'),
+ onError: () => setLspState(language, 'off'),
+ onClose: () => setLspState(language, 'off')
+ })
+ const base = languageServerWithTransport({
+ client,
+ transport,
+ rootUri,
+ workspaceFolders,
+ documentUri,
+ languageId,
+ allowHTMLContent: true,
+ autoClose: true
+ })
+ // 用自绘悬浮替代库的 hover/诊断 tooltip(定位精确到鼠标 + 主题适配)
+ return [base, lspCustomHover]
+ }
+ catch {
+ return null
+ }
+}
diff --git a/src/editor/lspHover.ts b/src/editor/lspHover.ts
new file mode 100644
index 0000000..7f52e6e
--- /dev/null
+++ b/src/editor/lspHover.ts
@@ -0,0 +1,171 @@
+// 自绘 LSP 悬浮提示:完全不依赖 CodeMirror 的 tooltip 定位,
+// 监听鼠标 → posAtCoords 定位 → 取诊断 + textDocument/hover → 在鼠标处用挂到 body 的 fixed 浮层显示。
+import {EditorView, ViewPlugin} from '@codemirror/view'
+import {forEachDiagnostic} from '@codemirror/lint'
+import {languageServerPlugin} from 'codemirror-languageserver'
+import {useTheme} from '../composables/useTheme'
+
+const offsetToPos = (doc: any, offset: number) => {
+ const line = doc.lineAt(offset)
+ return {line: line.number - 1, character: offset - line.from}
+}
+
+const SEVERITY_COLOR: Record = {
+ error: '#ef4444',
+ warning: '#f59e0b',
+ info: '#3b82f6',
+ hint: '#6b7280'
+}
+
+function makeHoverPlugin() {
+ return ViewPlugin.fromClass(class {
+ view: EditorView
+ box: HTMLElement
+ timer: any = null
+ reqId = 0
+ onMove: (e: MouseEvent) => void
+ onLeave: () => void
+ onScroll: () => void
+
+ constructor(view: EditorView) {
+ this.view = view
+ this.box = document.createElement('div')
+ this.box.className = 'cf-lsp-hover'
+ this.box.style.cssText = 'position:fixed;z-index:9999;display:none;max-width:520px;max-height:340px;overflow:auto;border-radius:8px;padding:8px 10px;font-size:12px;line-height:1.5;pointer-events:none;'
+ document.body.appendChild(this.box)
+
+ this.onMove = (e: MouseEvent) => {
+ if (this.timer) {
+ clearTimeout(this.timer)
+ }
+ this.timer = setTimeout(() => this.show(e.clientX, e.clientY), 280)
+ }
+ this.onLeave = () => {
+ if (this.timer) {
+ clearTimeout(this.timer)
+ }
+ this.hide()
+ }
+ this.onScroll = () => this.hide()
+
+ view.dom.addEventListener('mousemove', this.onMove)
+ view.dom.addEventListener('mouseleave', this.onLeave)
+ view.scrollDOM.addEventListener('scroll', this.onScroll, true)
+ }
+
+ applyTheme() {
+ const {isDark} = useTheme()
+ const dark = isDark.value
+ this.box.style.background = dark ? '#1f2937' : '#ffffff'
+ this.box.style.color = dark ? '#e5e7eb' : '#1f2937'
+ this.box.style.border = `1px solid ${dark ? '#374151' : '#e5e7eb'}`
+ this.box.style.boxShadow = dark ? '0 8px 28px rgba(0,0,0,0.5)' : '0 8px 28px rgba(0,0,0,0.14)'
+ }
+
+ async show(x: number, y: number) {
+ const pos = this.view.posAtCoords({x, y})
+ if (pos == null) {
+ this.hide()
+ return
+ }
+ // posAtCoords 会就近吸附到最近字符;用视口坐标确认鼠标确实落在该行文本上
+ const c = this.view.coordsAtPos(pos)
+ if (!c || y < c.top - 2 || y > c.bottom + 2) {
+ this.hide()
+ return
+ }
+ const line = this.view.state.doc.lineAt(pos)
+ const startC = this.view.coordsAtPos(line.from)
+ const endC = this.view.coordsAtPos(line.to)
+ if ((startC && x < startC.left - 4) || (endC && x > endC.right + 6)) {
+ this.hide()
+ return
+ }
+
+ const myReq = ++this.reqId
+ const parts: string[] = []
+
+ // 1) 该位置的诊断
+ forEachDiagnostic(this.view.state, (d, from, to) => {
+ if (pos >= from && pos <= to) {
+ const color = SEVERITY_COLOR[d.severity] || SEVERITY_COLOR.info
+ parts.push(`${escapeHtml(d.message)}
`)
+ }
+ })
+
+ // 2) LSP textDocument/hover
+ try {
+ const plugin: any = this.view.plugin(languageServerPlugin as any)
+ if (plugin?.requestHoverTooltip) {
+ const spec = await plugin.requestHoverTooltip(this.view, offsetToPos(this.view.state.doc, pos))
+ if (myReq !== this.reqId) {
+ return // 过期
+ }
+ if (spec && typeof spec.create === 'function') {
+ const built = spec.create(this.view)
+ const dom: HTMLElement | undefined = built?.dom
+ if (dom && dom.textContent && dom.textContent.trim()) {
+ parts.push(`${dom.innerHTML}
`)
+ }
+ }
+ }
+ }
+ catch {
+ // 忽略
+ }
+
+ if (myReq !== this.reqId) {
+ return
+ }
+ if (parts.length === 0) {
+ this.hide()
+ return
+ }
+
+ this.applyTheme()
+ this.box.innerHTML = parts.join('')
+ this.box.style.display = 'block'
+ // 定位到鼠标下方,越界则翻转/夹紧
+ const rect = this.box.getBoundingClientRect()
+ let left = x + 2
+ let top = y + 18
+ if (left + rect.width > window.innerWidth - 8) {
+ left = window.innerWidth - rect.width - 8
+ }
+ if (top + rect.height > window.innerHeight - 8) {
+ top = y - rect.height - 12
+ }
+ this.box.style.left = Math.max(8, left) + 'px'
+ this.box.style.top = Math.max(8, top) + 'px'
+ }
+
+ hide() {
+ this.box.style.display = 'none'
+ }
+
+ destroy() {
+ if (this.timer) {
+ clearTimeout(this.timer)
+ }
+ this.view.dom.removeEventListener('mousemove', this.onMove)
+ this.view.dom.removeEventListener('mouseleave', this.onLeave)
+ this.view.scrollDOM.removeEventListener('scroll', this.onScroll, true)
+ this.box.remove()
+ }
+ })
+}
+
+function escapeHtml(s: string): string {
+ return s.replace(/&/g, '&').replace(//g, '>')
+}
+
+// 自绘 hover + 隐藏 CodeMirror 默认的 hover / 诊断 tooltip(避免重复且定位错乱)
+export const lspCustomHover = [
+ makeHoverPlugin(),
+ EditorView.theme({
+ '.cm-tooltip.cm-tooltip-hover': {display: 'none !important'},
+ '.cm-tooltip.cm-tooltip-lint': {display: 'none !important'},
+ '.cf-lsp-hover-doc pre': {whiteSpace: 'pre-wrap', margin: '4px 0'},
+ '.cf-lsp-hover-doc code': {fontFamily: 'monospace'}
+ })
+]
diff --git a/src/editor/lspStatus.ts b/src/editor/lspStatus.ts
new file mode 100644
index 0000000..c471144
--- /dev/null
+++ b/src/editor/lspStatus.ts
@@ -0,0 +1,10 @@
+import {ref} from 'vue'
+
+// 当前编辑器语言的 LSP 状态(供状态栏显示)
+// off=未启用 connecting=启动/索引中 on=就绪
+export type LspStatus = 'off' | 'connecting' | 'on'
+export const lspState = ref<{ language: string; status: LspStatus }>({language: '', status: 'off'})
+
+export const setLspState = (language: string, status: LspStatus) => {
+ lspState.value = {language, status}
+}
diff --git a/src/editor/lspTransport.ts b/src/editor/lspTransport.ts
new file mode 100644
index 0000000..491793e
--- /dev/null
+++ b/src/editor/lspTransport.ts
@@ -0,0 +1,60 @@
+// 将 codemirror-languageserver 的 Transport 桥接到 Tauri 后端 LSP 命令/事件
+import {invoke} from '@tauri-apps/api/core'
+import {listen, type UnlistenFn} from '@tauri-apps/api/event'
+import type {Transport} from 'codemirror-languageserver'
+
+export class TauriLspTransport implements Transport {
+ private language: string
+ private msgCb: ((m: string) => void) | null = null
+ private closeCb: (() => void) | null = null
+ private errorCb: ((e: Error) => void) | null = null
+ private unlisten: UnlistenFn[] = []
+ private ready: Promise
+
+ constructor(language: string) {
+ this.language = language
+ this.ready = this.init()
+ }
+
+ private async init() {
+ const un1 = await listen<{ language: string; message: string }>('lsp:message', (e) => {
+ if (e.payload.language === this.language) {
+ this.msgCb?.(e.payload.message)
+ }
+ })
+ const un2 = await listen('lsp:exit', (e) => {
+ if (e.payload === this.language) {
+ this.closeCb?.()
+ }
+ })
+ this.unlisten = [un1, un2]
+ await invoke('lsp_start', {language: this.language})
+ }
+
+ send(message: string): void {
+ // 等服务器启动后再发,保持顺序
+ this.ready
+ .then(() => invoke('lsp_send', {language: this.language, message}))
+ .catch((e) => this.errorCb?.(new Error(String(e))))
+ }
+
+ onMessage(callback: (message: string) => void): void {
+ this.msgCb = callback
+ }
+
+ onClose(callback: () => void): void {
+ this.closeCb = callback
+ }
+
+ onError(callback: (error: Error) => void): void {
+ this.errorCb = callback
+ }
+
+ close(): void {
+ for (const u of this.unlisten) {
+ u()
+ }
+ this.unlisten = []
+ // 不停止服务器进程,保持热以便同语言其它文件复用
+ }
+}
diff --git a/src/ui/Select.vue b/src/ui/Select.vue
index 6a4710d..a953ced 100644
--- a/src/ui/Select.vue
+++ b/src/ui/Select.vue
@@ -54,7 +54,7 @@
@after-leave="$emit('after-close')">
-
+
{
let width = buttonRect.width
let maxHeight = Math.min(totalDropdownHeight, spaceBelow)
- // 如果下方空间不足且上方空间更大,显示在上方
- if (spaceBelow < 150 && spaceAbove > spaceBelow) {
+ // 下方放不下且上方空间更大时,翻转到上方显示
+ if (spaceBelow < totalDropdownHeight && spaceAbove > spaceBelow) {
top = buttonRect.top - 4 // 显示在上方,留4px间距
maxHeight = Math.min(totalDropdownHeight, spaceAbove)