- 差异对比
+ {{ title || '差异对比' }}
· {{ fileName }}
+{{ added }}
−{{ removed }}
+
@@ -18,7 +23,7 @@
- 已保存(红) → 当前(绿)
+ {{ subtitle || '已保存(红) → 当前(绿)' }}
@@ -44,8 +49,11 @@ const props = defineProps<{
original: string
modified: string
fileName?: string | null
+ title?: string
+ subtitle?: string
+ confirmLabel?: string
}>()
-const emit = defineEmits<{ close: [] }>()
+const emit = defineEmits<{ close: []; confirm: [] }>()
interface DiffRow { type: 'same' | 'add' | 'del'; text: string; oldNo?: number; newNo?: number }
diff --git a/src/components/EditorTabs.vue b/src/components/EditorTabs.vue
index ac196c5..904466b 100644
--- a/src/components/EditorTabs.vue
+++ b/src/components/EditorTabs.vue
@@ -17,7 +17,8 @@
@dragleave="dragOverId === tab.id && (dragOverId = null)"
@drop.prevent="onDrop(tab.id)"
@dragend="onDragEnd">
-
![]()
+
![]()
{ const t = e.target as HTMLImageElement; if (!t.src.endsWith('/icons/text.svg')) t.src = '/icons/text.svg' }"/>
{{ title(tab) }}
●
{{ file.name }}
{{ file.dir }}
+
最近
@@ -47,7 +48,7 @@ interface FileItem
lower: string
}
-const props = defineProps<{ rootDir: string }>()
+const props = defineProps<{ rootDir: string; recentPaths?: string[] }>()
const emit = defineEmits<{ select: [path: string]; close: [] }>()
const query = ref('')
@@ -61,6 +62,8 @@ const setItemRef = (el: any, i: number) => {
if (el) itemRefs[i] = el
}
+const recentSet = computed(() => new Set(props.recentPaths || []))
+
const rel = (p: string) => p.startsWith(props.rootDir) ? p.slice(props.rootDir.length + 1) : p
onMounted(async () => {
@@ -82,10 +85,28 @@ onMounted(async () => {
}
})
+// 空查询时:最近打开的文件优先(按最近顺序),其余随后
+const emptyOrdered = computed
(() => {
+ const recent = props.recentPaths || []
+ if (!recent.length) {
+ return allFiles.value.slice(0, 50)
+ }
+ const byPath = new Map(allFiles.value.map(f => [f.path, f]))
+ const recentItems: FileItem[] = []
+ for (const p of recent) {
+ const f = byPath.get(p)
+ if (f) {
+ recentItems.push(f)
+ byPath.delete(p)
+ }
+ }
+ return [...recentItems, ...byPath.values()].slice(0, 50)
+})
+
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) {
- return allFiles.value.slice(0, 50)
+ return emptyOrdered.value
}
const scored = allFiles.value
.map(f => {
diff --git a/src/components/ResizablePanels.vue b/src/components/ResizablePanels.vue
index 9663c50..ab27d3d 100644
--- a/src/components/ResizablePanels.vue
+++ b/src/components/ResizablePanels.vue
@@ -27,6 +27,7 @@
diff --git a/src/components/SnippetManager.vue b/src/components/SnippetManager.vue
new file mode 100644
index 0000000..8be8051
--- /dev/null
+++ b/src/components/SnippetManager.vue
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+ 代码片段
+ · 输入前缀后按 Tab 展开({{ '$0' }} 为光标落点)
+
+
+
+
+
+
+
+
+
+
+ {{ s.prefix }}
+ {{ s.language && s.language !== '*' ? s.language : '所有语言' }}
+ {{ s.description }}
+
+
{{ s.body }}
+
+
+
+
+
还没有代码片段,在下方添加
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Terminal.vue b/src/components/Terminal.vue
new file mode 100644
index 0000000..1714bb1
--- /dev/null
+++ b/src/components/Terminal.vue
@@ -0,0 +1,233 @@
+
+
+
+
+
diff --git a/src/composables/useAiConfig.ts b/src/composables/useAiConfig.ts
index 3a82e4c..1cba74b 100644
--- a/src/composables/useAiConfig.ts
+++ b/src/composables/useAiConfig.ts
@@ -1,4 +1,5 @@
import {computed, reactive} from 'vue'
+import {kvGetJSON, kvSetJSON} from './useKvStore'
export interface AiProviderConfig
{
@@ -39,20 +40,15 @@ const buildDefault = (): AiConfig => {
const loadRaw = (): AiConfig => {
const base = buildDefault()
- try {
- const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null')
- if (saved && saved.providers) {
- base.provider = saved.provider || base.provider
- for (const key of Object.keys(base.providers)) {
- if (saved.providers[key]) {
- base.providers[key] = {...base.providers[key], ...saved.providers[key]}
- }
+ const saved = kvGetJSON(STORAGE_KEY, null)
+ if (saved && saved.providers) {
+ base.provider = saved.provider || base.provider
+ for (const key of Object.keys(base.providers)) {
+ if (saved.providers[key]) {
+ base.providers[key] = {...base.providers[key], ...saved.providers[key]}
}
}
}
- catch {
- // 忽略损坏的配置
- }
return base
}
@@ -61,7 +57,7 @@ export function useAiConfig()
const state = reactive(loadRaw())
const save = () => {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
+ kvSetJSON(STORAGE_KEY, state)
}
const reload = () => {
diff --git a/src/composables/useCodeMirrorEditor.ts b/src/composables/useCodeMirrorEditor.ts
index 0ea0da8..95112b2 100644
--- a/src/composables/useCodeMirrorEditor.ts
+++ b/src/composables/useCodeMirrorEditor.ts
@@ -75,8 +75,15 @@ import {EditorConfig} from '../types/app.ts'
import {useCodeMirrorFunctionHelp} from './useCodeMirrorFunctionHelp'
import {useCodeMirrorSpaceOmission} from './useCodeMirrorSpaceOmission.ts'
import {EditorView, keymap} from "@codemirror/view";
+import {Prec} from "@codemirror/state";
import {useCodeMirrorFontFamily} from "./useCodeMirrorFontFamily.ts";
import {diffGutterExtension} from "../editor/diffGutter";
+import {aiCompleteExtension} from "../editor/aiComplete";
+import {cursorListener} from "../editor/cursorInfo";
+import {json} from "@codemirror/lang-json";
+import {markdown} from "@codemirror/lang-markdown";
+import {yaml} from "@codemirror/lang-yaml";
+import {useSnippets} from "./useSnippets";
interface Props
{
@@ -146,6 +153,43 @@ export function useCodeMirrorEditor(props: Props)
{key: 'Mod-0', preventDefault: true, run: () => (resetFontSize(), true)}
])
+ // 代码片段:在光标前的单词等于某片段前缀时,按 Tab 展开($0 / ${0} 为光标落点)
+ const {findByPrefix} = useSnippets()
+ const expandSnippet = (view: EditorView): boolean => {
+ const {state} = view
+ const sel = state.selection.main
+ if (!sel.empty) {
+ return false
+ }
+ const lineFrom = state.doc.lineAt(sel.head).from
+ const before = state.sliceDoc(lineFrom, sel.head)
+ const m = /([A-Za-z_]\w*)$/.exec(before)
+ if (!m) {
+ return false
+ }
+ const snip = findByPrefix(m[1], props.language || '')
+ if (!snip) {
+ return false
+ }
+ const from = sel.head - m[1].length
+ let body = snip.body
+ let cursorOffset = -1
+ const ph = /\$\{0\}|\$0/.exec(body)
+ if (ph) {
+ cursorOffset = ph.index
+ body = body.slice(0, ph.index) + body.slice(ph.index + ph[0].length)
+ }
+ view.dispatch({
+ changes: {from, to: sel.head, insert: body},
+ selection: {anchor: cursorOffset >= 0 ? from + cursorOffset : from + body.length},
+ scrollIntoView: true
+ })
+ return true
+ }
+ // 高优先级拦截 Tab;未匹配片段则返回 false,回落到默认缩进
+ // 比默认高、但低于 AI 幽灵补全的 Tab(接受补全优先)
+ const snippetKeymap = Prec.high(keymap.of([{key: 'Tab', run: expandSnippet}]))
+
// 主题映射
const themeMap: Record = {
abcdef,
@@ -269,6 +313,16 @@ export function useCodeMirrorEditor(props: Props)
return StreamLanguage.define(objectiveC)
case 'objective-cpp':
return StreamLanguage.define(objectiveCpp)
+ case 'json':
+ return json()
+ case 'yaml':
+ return yaml()
+ case 'markdown':
+ return markdown()
+ case 'xml':
+ return xml()
+ case 'text':
+ return null
default:
return null
}
@@ -298,6 +352,15 @@ export function useCodeMirrorEditor(props: Props)
// 字体缩放快捷键(搜索/替换、折叠、括号匹配等由 vue-codemirror 的 basicSetup 提供)
result.push(fontSizeKeymap)
+ // 代码片段 Tab 展开
+ result.push(snippetKeymap)
+
+ // AI 幽灵补全(Tab 接受 / Esc 取消),由外部 dispatch 设置
+ result.push(aiCompleteExtension)
+
+ // 光标位置/选中长度(供状态栏显示)
+ result.push(cursorListener)
+
// Git 行内差异标记(标记数据由外部 dispatch 填充,无 git 时为空)
result.push(diffGutterExtension)
diff --git a/src/composables/useKvStore.ts b/src/composables/useKvStore.ts
new file mode 100644
index 0000000..ade0884
--- /dev/null
+++ b/src/composables/useKvStore.ts
@@ -0,0 +1,61 @@
+import {invoke} from '@tauri-apps/api/core'
+
+/**
+ * 通用键值存储,替代 localStorage,持久化到 SQLite。
+ * 启动时一次性把全部键值载入内存缓存,使读取保持同步;写入更新缓存并异步落库。
+ */
+const cache = new Map()
+let loaded = false
+
+// 在应用挂载前调用一次,载入全部键值
+export const loadKvStore = async () => {
+ if (loaded) {
+ return
+ }
+ try {
+ const all = await invoke>('kv_get_all')
+ for (const [k, v] of Object.entries(all)) {
+ cache.set(k, v)
+ }
+ }
+ catch (error) {
+ console.error('载入键值存储失败:', error)
+ }
+ loaded = true
+}
+
+// 同步读取(缓存);不存在返回 null
+export const kvGet = (key: string): string | null => {
+ return cache.has(key) ? cache.get(key)! : null
+}
+
+// 读取并 JSON 解析,失败/不存在返回默认值
+export const kvGetJSON = (key: string, fallback: T): T => {
+ const raw = kvGet(key)
+ if (raw == null) {
+ return fallback
+ }
+ try {
+ return JSON.parse(raw) as T
+ }
+ catch {
+ return fallback
+ }
+}
+
+// 写入(更新缓存 + 异步落库)
+export const kvSet = (key: string, value: string) => {
+ cache.set(key, value)
+ invoke('kv_set', {key, value}).catch(e => console.error('保存键值失败:', e))
+}
+
+// 写入对象(JSON 序列化)
+export const kvSetJSON = (key: string, value: unknown) => {
+ kvSet(key, JSON.stringify(value))
+}
+
+// 删除
+export const kvRemove = (key: string) => {
+ cache.delete(key)
+ invoke('kv_delete', {key}).catch(e => console.error('删除键值失败:', e))
+}
diff --git a/src/composables/useLanguageRegistry.ts b/src/composables/useLanguageRegistry.ts
index e021663..d3db7aa 100644
--- a/src/composables/useLanguageRegistry.ts
+++ b/src/composables/useLanguageRegistry.ts
@@ -33,19 +33,26 @@ export function useLanguageRegistry()
if (plugin.enabled === false) {
continue
}
- const ext = normalizeExt(String(plugin.extension))
- if (!ext) {
+ // 支持一个插件声明多个扩展名(逗号/空格分隔,如 yaml 的 "yaml,yml")
+ const exts = String(plugin.extension)
+ .split(/[,\s]+/)
+ .map(normalizeExt)
+ .filter(Boolean)
+ if (exts.length === 0) {
continue
}
+ // 语言 -> 扩展名取第一个作为主扩展名
if (!l2e[plugin.language]) {
- l2e[plugin.language] = ext
+ l2e[plugin.language] = exts[0]
}
- if (!e2l[ext]) {
- e2l[ext] = []
- }
- if (!e2l[ext].includes(plugin.language)) {
- e2l[ext].push(plugin.language)
+ for (const ext of exts) {
+ if (!e2l[ext]) {
+ e2l[ext] = []
+ }
+ if (!e2l[ext].includes(plugin.language)) {
+ e2l[ext].push(plugin.language)
+ }
}
}
diff --git a/src/composables/useShortcuts.ts b/src/composables/useShortcuts.ts
index a7a04f4..d2411e3 100644
--- a/src/composables/useShortcuts.ts
+++ b/src/composables/useShortcuts.ts
@@ -1,4 +1,5 @@
import {computed, ref} from 'vue'
+import {kvGetJSON, kvSetJSON} from './useKvStore'
export interface ShortcutAction
{
@@ -22,7 +23,8 @@ export const SHORTCUT_ACTIONS: ShortcutAction[] = [
{id: 'generate', label: 'AI 生成代码', default: 'Mod+K'},
{id: 'newTab', label: '新建标签', default: 'Mod+N'},
{id: 'closeTab', label: '关闭标签', default: 'Mod+W'},
- {id: 'toggleSidebar', label: '切换侧栏', default: 'Mod+B'}
+ {id: 'toggleSidebar', label: '切换侧栏', default: 'Mod+B'},
+ {id: 'toggleTerminal', label: '切换终端', default: 'Mod+`'}
]
const STORAGE_KEY = 'shortcuts'
@@ -65,17 +67,12 @@ export function useShortcuts()
const overrides = ref>({})
const load = () => {
- try {
- overrides.value = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
- }
- catch {
- overrides.value = {}
- }
+ overrides.value = kvGetJSON>(STORAGE_KEY, {})
}
load()
const persist = () => {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides.value))
+ kvSetJSON(STORAGE_KEY, overrides.value)
}
// 当前生效的绑定(默认 + 覆盖)
diff --git a/src/composables/useSnippets.ts b/src/composables/useSnippets.ts
new file mode 100644
index 0000000..8f2b3bd
--- /dev/null
+++ b/src/composables/useSnippets.ts
@@ -0,0 +1,69 @@
+import {ref} from 'vue'
+import {invoke} from '@tauri-apps/api/core'
+
+export interface Snippet
+{
+ id: string
+ prefix: string
+ body: string
+ description?: string
+ // 适用语言;空或 '*' 表示所有语言
+ language?: string
+}
+
+// 模块级共享,保证编辑器扩展与管理面板看到同一份数据
+const snippets = ref([])
+let loaded = false
+
+const genId = () => `sn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
+
+const load = async () => {
+ try {
+ snippets.value = await invoke('get_snippets')
+ }
+ catch (error) {
+ console.error('加载代码片段失败:', error)
+ snippets.value = []
+ }
+}
+
+// 应用启动时调用一次:从数据库载入
+export const initSnippets = async () => {
+ if (loaded) {
+ return
+ }
+ loaded = true
+ await load()
+}
+
+export function useSnippets()
+{
+ const add = async (s: Omit) => {
+ const snip: Snippet = {id: genId(), ...s}
+ await invoke('save_snippet', {snippet: {description: '', language: '*', ...snip}})
+ snippets.value.push(snip)
+ }
+
+ const update = async (id: string, patch: Partial) => {
+ const i = snippets.value.findIndex(x => x.id === id)
+ if (i < 0) {
+ return
+ }
+ const merged = {...snippets.value[i], ...patch}
+ await invoke('save_snippet', {snippet: {description: '', language: '*', ...merged}})
+ snippets.value[i] = merged
+ }
+
+ const remove = async (id: string) => {
+ await invoke('delete_snippet', {id})
+ snippets.value = snippets.value.filter(x => x.id !== id)
+ }
+
+ // 查找某语言下前缀完全匹配的片段(同步,供编辑器 Tab 展开)
+ const findByPrefix = (prefix: string, lang: string): Snippet | undefined =>
+ snippets.value.find(s =>
+ s.prefix === prefix && (!s.language || s.language === '*' || s.language === lang)
+ )
+
+ return {snippets, reload: load, add, update, remove, findByPrefix}
+}
diff --git a/src/composables/useUpdateManager.ts b/src/composables/useUpdateManager.ts
index 723d822..3068640 100644
--- a/src/composables/useUpdateManager.ts
+++ b/src/composables/useUpdateManager.ts
@@ -2,6 +2,7 @@ import {ref} from 'vue'
import {invoke} from '@tauri-apps/api/core'
import {listen} from '@tauri-apps/api/event'
import {CheckCircle, Download, RefreshCw, Wifi} from 'lucide-vue-next'
+import {kvSet} from './useKvStore'
interface UpdateInfo
{
@@ -210,7 +211,7 @@ export function useUpdateManager()
}
// 存储跳过的版本信息
- localStorage.setItem('skipped_version', updateInfo.value.version)
+ kvSet('skipped_version', updateInfo.value.version)
}
// 事件监听
diff --git a/src/editor/aiComplete.ts b/src/editor/aiComplete.ts
new file mode 100644
index 0000000..04d80e9
--- /dev/null
+++ b/src/editor/aiComplete.ts
@@ -0,0 +1,129 @@
+import {Decoration, EditorView, WidgetType, keymap} from '@codemirror/view'
+import {Prec, StateEffect, StateField} from '@codemirror/state'
+import {ref} from 'vue'
+
+// 当前是否有幽灵补全显示(供界面提示「Tab 接受」)
+export const ghostActive = ref(false)
+
+export interface Ghost
+{
+ text: string
+ pos: number
+}
+
+// 设置/清除幽灵补全文本
+export const setGhost = StateEffect.define()
+export const clearGhost = StateEffect.define()
+
+// 幽灵文本 widget(灰色、不可选中,支持多行)
+class GhostWidget extends WidgetType
+{
+ constructor(readonly text: string)
+ {
+ super()
+ }
+
+ eq(other: GhostWidget)
+ {
+ return other.text === this.text
+ }
+
+ toDOM()
+ {
+ const span = document.createElement('span')
+ span.className = 'cm-ai-ghost'
+ span.textContent = this.text
+ return span
+ }
+
+ ignoreEvent()
+ {
+ return true
+ }
+}
+
+// 持有当前幽灵补全:任何文档改动/光标移动都会清除,仅 setGhost 显式设置
+const ghostField = StateField.define({
+ create: () => null,
+ update(value, tr) {
+ for (const e of tr.effects) {
+ if (e.is(setGhost)) {
+ return e.value
+ }
+ if (e.is(clearGhost)) {
+ return null
+ }
+ }
+ if (tr.docChanged || tr.selection) {
+ return null
+ }
+ return value
+ },
+ provide: f => EditorView.decorations.from(f, (g) => {
+ if (!g) {
+ return Decoration.none
+ }
+ const deco = Decoration.widget({
+ widget: new GhostWidget(g.text),
+ side: 1
+ })
+ return Decoration.set([deco.range(g.pos)])
+ })
+})
+
+// 是否有幽灵补全显示
+export const hasGhost = (view: EditorView): boolean => view.state.field(ghostField, false) != null
+
+// 接受补全:插入文本并把光标移到末尾
+const acceptGhost = (view: EditorView): boolean => {
+ const g = view.state.field(ghostField, false)
+ if (!g) {
+ return false
+ }
+ view.dispatch({
+ changes: {from: g.pos, insert: g.text},
+ selection: {anchor: g.pos + g.text.length},
+ effects: clearGhost.of(null)
+ })
+ return true
+}
+
+// 取消补全
+const dismissGhost = (view: EditorView): boolean => {
+ if (!view.state.field(ghostField, false)) {
+ return false
+ }
+ view.dispatch({effects: clearGhost.of(null)})
+ return true
+}
+
+// Tab 接受、Esc 取消;无补全时返回 false 回落到默认行为
+const ghostKeymap = Prec.highest(keymap.of([
+ {key: 'Tab', run: acceptGhost},
+ {key: 'Escape', run: dismissGhost}
+]))
+
+const ghostTheme = EditorView.baseTheme({
+ '.cm-ai-ghost': {
+ opacity: '0.4',
+ color: '#8b949e',
+ whiteSpace: 'pre-wrap',
+ },
+})
+
+// 清除当前补全(外部调用,如关闭功能时)
+export const clearGhostIn = (view: EditorView | null | undefined) => {
+ if (view && hasGhost(view)) {
+ view.dispatch({effects: clearGhost.of(null)})
+ }
+}
+
+// 每次更新后同步幽灵补全显示状态到响应式信号
+const ghostWatcher = EditorView.updateListener.of((u) => {
+ const active = u.state.field(ghostField, false) != null
+ if (active !== ghostActive.value) {
+ ghostActive.value = active
+ }
+})
+
+export const aiCompleteExtension = [ghostField, ghostKeymap, ghostTheme, ghostWatcher]
diff --git a/src/editor/cursorInfo.ts b/src/editor/cursorInfo.ts
new file mode 100644
index 0000000..e8ecbb0
--- /dev/null
+++ b/src/editor/cursorInfo.ts
@@ -0,0 +1,27 @@
+import {EditorView} from '@codemirror/view'
+import {ref} from 'vue'
+
+export interface CursorInfo
+{
+ line: number
+ col: number
+ // 选中的字符数(无选中为 0)
+ selLen: number
+}
+
+// 当前光标位置/选中长度(供状态栏显示)
+export const cursorInfo = ref({line: 1, col: 1, selLen: 0})
+
+// 监听选区/文档变化,更新光标信息
+export const cursorListener = EditorView.updateListener.of((u) => {
+ if (!u.selectionSet && !u.docChanged) {
+ return
+ }
+ const sel = u.state.selection.main
+ const line = u.state.doc.lineAt(sel.head)
+ cursorInfo.value = {
+ line: line.number,
+ col: sel.head - line.from + 1,
+ selLen: Math.abs(sel.to - sel.from)
+ }
+})
diff --git a/src/main.ts b/src/main.ts
index 7229308..f365977 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2,7 +2,11 @@ import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
import ToastPlugin from './plugins/toast'
+import { loadKvStore } from './composables/useKvStore'
-createApp(App)
- .use(ToastPlugin)
- .mount('#app')
+// 先从数据库载入全部键值(替代 localStorage),再挂载应用,保证同步读取可用
+loadKvStore().finally(() => {
+ createApp(App)
+ .use(ToastPlugin)
+ .mount('#app')
+})