Skip to content
Merged
3 changes: 3 additions & 0 deletions src-tauri/src/filesystem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1766,7 +1766,10 @@ pub async fn git_hook_read(root: String, name: String) -> Result<String, String>
}

/// 写入某钩子内容并按 executable 设置可执行权限(类 Unix)。
/// executable 仅在类 Unix 平台使用;非 Unix 下显式 allow,避免 clippy -D warnings 失败
/// (参数名需保持不变以匹配前端 Tauri 调用,故不能改名为 _executable)。
#[tauri::command]
#[cfg_attr(not(unix), allow(unused_variables))]
pub async fn git_hook_save(
root: String,
name: String,
Expand Down
47 changes: 47 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,10 @@
<!-- 调试侧栏:调用栈 + 变量 -->
<DebugPanel/>

<!-- AI 代码操作(解释/重构/生成测试) -->
<AiCodeAction v-if="aiCodeCtx" :language="currentLanguage" :code="aiCodeCtx.code" :action="aiCodeCtx.action"
@replace="onAiReplace" @insert="onAiInsert" @close="aiCodeCtx = null"/>

<!-- 运行任务 -->
<TaskRunner v-if="showTasks && rootDir" :root-dir="rootDir" @run="runTask" @close="showTasks = false"/>

Expand Down Expand Up @@ -414,6 +418,10 @@
</button>
</template>
<div v-if="editorCtx.lsp || canBlame" class="border-t border-gray-100 dark:border-gray-700 my-1"></div>
<button class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer" @click="aiCodeAction('explain')">{{ t('aiCode.title.explain') }}</button>
<button class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer" @click="aiCodeAction('refactor')">{{ t('aiCode.title.refactor') }}</button>
<button class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer" @click="aiCodeAction('test')">{{ t('aiCode.title.test') }}</button>
<div class="border-t border-gray-100 dark:border-gray-700 my-1"></div>
<button class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer" @click="sendToTerminal">
{{ t('app.sendToTerminal') }}
</button>
Expand Down Expand Up @@ -503,6 +511,7 @@ import GitPanel from './components/GitPanel.vue'
import TaskRunner from './components/TaskRunner.vue'
import DebugToolbar from './components/DebugToolbar.vue'
import DebugPanel from './components/DebugPanel.vue'
import AiCodeAction from './components/AiCodeAction.vue'
import GoToLine from './components/GoToLine.vue'
import Outline from './components/Outline.vue'
import SnippetManager from './components/SnippetManager.vue'
Expand Down Expand Up @@ -1260,6 +1269,44 @@ const runTests = async () => {
await runTask(cmd)
}

// C2:对选区(无选区则整篇)执行 AI 操作:解释 / 重构 / 生成测试
const aiCodeCtx = ref<{action: 'explain' | 'refactor' | 'test'; code: string; from: number; to: number} | null>(null)
const aiCodeAction = (action: 'explain' | 'refactor' | 'test') => {
closeEditorCtx()
const view = editorView.value
if (!view) {
return
}
const sel = view.state.selection.main
let from = sel.from
let to = sel.to
let code = sel.empty ? '' : view.state.sliceDoc(from, to)
if (!code.trim()) {
code = view.state.doc.toString()
from = 0
to = view.state.doc.length
}
if (!code.trim()) {
toast.info(t('aiCode.noCode'))
return
}
aiCodeCtx.value = {action, code, from, to}
}
const onAiReplace = (code: string) => {
const view = editorView.value
const ctx = aiCodeCtx.value
if (view && ctx) {
view.dispatch({changes: {from: ctx.from, to: ctx.to, insert: code}})
}
}
const onAiInsert = (code: string) => {
const view = editorView.value
const ctx = aiCodeCtx.value
if (view && ctx) {
view.dispatch({changes: {from: ctx.to, insert: `\n\n${code}\n`}})
}
}

// B3:发送选区(无选区则当前行)到集成终端,用于 REPL 式交互
const sendToTerminal = async () => {
closeEditorCtx()
Expand Down
48 changes: 47 additions & 1 deletion src/components/AiAssistant.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@
{{ t('chat.stop') }}
</button>
</div>
<!-- 上下文附带(C3) -->
<div class="mb-1.5 flex items-center gap-1.5">
<span class="text-[11px] text-gray-400">{{ t('chat.context') }}</span>
<button class="text-[11px] px-1.5 py-0.5 rounded border cursor-pointer transition-colors"
:class="includeFile ? 'border-blue-400 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30' : 'border-gray-300 dark:border-gray-600 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'"
:disabled="!code?.trim()" @click="includeFile = !includeFile">
{{ t('chat.ctxFile') }}
</button>
<button class="text-[11px] px-1.5 py-0.5 rounded border cursor-pointer transition-colors"
:class="includeProject ? 'border-blue-400 text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30' : 'border-gray-300 dark:border-gray-600 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'"
:disabled="!rootDir" @click="includeProject = !includeProject">
{{ t('chat.ctxProject') }}
</button>
</div>
<textarea v-model="input"
rows="2"
class="w-full text-sm border border-gray-300 dark:border-gray-600 dark:bg-gray-900 rounded px-2 py-1.5 resize-none focus:outline-none focus:border-blue-400"
Expand Down Expand Up @@ -130,6 +144,36 @@ const {saveConversation, getMessages, deleteConversation} = useAiHistory()

const messages = ref<AiMsg[]>([])
const input = ref('')
// C3:对话上下文附带(当前文件 / 项目结构)
const includeFile = ref(false)
const includeProject = ref(false)
let projectFilesCache: string[] | null = null

// 构造附带上下文的 system 提示
const buildContext = async (): Promise<string> => {
let ctx = ''
if (includeFile.value && props.code?.trim()) {
const max = 12000
const snippet = props.code.length > max ? props.code.slice(0, max) + '\n…(已截断)' : props.code
ctx += `\n\n用户当前打开的文件内容(作为上下文):\n\`\`\`${props.language}\n${snippet}\n\`\`\``
}
if (includeProject.value && props.rootDir) {
if (!projectFilesCache) {
try {
const all = await invoke<string[]>('list_files', {path: props.rootDir})
const root = props.rootDir
projectFilesCache = all.slice(0, 200).map(p => p.startsWith(root) ? p.slice(root.length).replace(/^[\\/]/, '') : p)
}
catch {
projectFilesCache = []
}
}
if (projectFilesCache.length) {
ctx += `\n\n项目文件列表(部分,作为上下文):\n${projectFilesCache.join('\n')}`
}
}
return ctx
}
const sending = ref(false)
const streamingIndex = ref(-1)
const listRef = ref<HTMLElement | null>(null)
Expand Down Expand Up @@ -205,6 +249,7 @@ onUnmounted(() => {

// 切换到不同的执行 → 切换对应对话
watch(() => props.executionId, loadForExecution)
watch(() => props.rootDir, () => { projectFilesCache = null })

const send = async (text?: string) => {
const content = (text ?? input.value).trim()
Expand All @@ -231,14 +276,15 @@ const send = async (text?: string) => {
sending.value = true
scrollToBottom()

const context = await buildContext()
try {
await invoke('ai_chat_stream', {
streamId,
provider: active.value.provider,
baseUrl: active.value.baseUrl,
apiKey: active.value.apiKey,
model: active.value.model,
system: `你是嵌入代码编辑器的编程助手。回答简洁、准确,必要时给出可运行的代码。当前编程语言:${props.language}。`,
system: `你是嵌入代码编辑器的编程助手。回答简洁、准确,必要时给出可运行的代码。当前编程语言:${props.language}。${context}`,
messages: payloadMessages
})
if (!messages.value[streamingIndex.value].content) {
Expand Down
120 changes: 120 additions & 0 deletions src/components/AiCodeAction.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<template>
<div class="fixed inset-0 z-50 flex items-start justify-center pt-16 px-6 pb-6" @click="emit('close')">
<div class="w-full max-w-[760px] max-h-full bg-white dark:bg-gray-900 dark:text-gray-100 rounded-lg shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col"
@click.stop>
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-200">
<Sparkles class="w-4 h-4 text-purple-500"/>
<span>{{ t('aiCode.title.' + action) }}</span>
</div>
<button class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-pointer" @click="emit('close')">
<X class="w-4 h-4"/>
</button>
</div>

<div class="flex-1 min-h-[160px] overflow-auto p-4">
<div v-if="loading" class="flex items-center gap-2 text-sm text-gray-400">
<Sparkles class="w-4 h-4 animate-pulse"/>{{ t('aiCode.thinking') }}
</div>
<pre v-else class="text-xs font-mono whitespace-pre-wrap break-words text-gray-800 dark:text-gray-100">{{ result }}</pre>
</div>

<div class="flex items-center justify-end gap-2 px-4 py-2.5 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
<button class="text-xs px-3 py-1.5 rounded text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer" @click="emit('close')">{{ t('aiCode.close') }}</button>
<button v-if="!loading && result" class="text-xs px-3 py-1.5 rounded text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer" @click="copy">{{ t('aiCode.copy') }}</button>
<button v-if="!loading && result && action === 'refactor'" class="text-xs px-3 py-1.5 rounded bg-blue-500 text-white hover:bg-blue-600 cursor-pointer" @click="apply('replace')">{{ t('aiCode.replace') }}</button>
<button v-if="!loading && result && action === 'test'" class="text-xs px-3 py-1.5 rounded bg-blue-500 text-white hover:bg-blue-600 cursor-pointer" @click="apply('insert')">{{ t('aiCode.insert') }}</button>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import {onMounted, ref} from 'vue'
import {invoke} from '@tauri-apps/api/core'
import {Sparkles, X} from 'lucide-vue-next'
import {useI18n} from 'vue-i18n'
import {useAiConfig} from '../composables/useAiConfig'
import {useToast} from '../plugins/toast'

const props = defineProps<{ language: string; code: string; action: 'explain' | 'refactor' | 'test' }>()
const emit = defineEmits<{ replace: [code: string]; insert: [code: string]; close: [] }>()

const toast = useToast()
const {t} = useI18n()
const {active, reload} = useAiConfig()

const loading = ref(true)
const result = ref('')

const stripFences = (text: string) => {
const trimmed = text.trim()
const m = trimmed.match(/^```[\w+-]*\n([\s\S]*?)\n?```$/)
return m ? m[1] : trimmed
}

const systemFor = (): string => {
const lang = props.language
switch (props.action) {
case 'explain':
return `你是资深工程师。用简洁中文解释给定的 ${lang} 代码:作用、关键逻辑、潜在问题。可用简短要点。`
case 'refactor':
return `你是代码助手。重构给定的 ${lang} 代码以提升可读性与质量,保持行为不变。只输出重构后的完整代码,不要解释,不要使用 Markdown 代码块标记。`
case 'test':
return `你是测试工程师。为给定的 ${lang} 代码生成单元测试。只输出测试代码,不要解释,不要使用 Markdown 代码块标记。`
}
}

const run = async () => {
reload()
if (!active.value.apiKey) {
toast.error(t('chat.needKey'))
emit('close')
return
}
loading.value = true
try {
const reply = await invoke<string>('ai_chat', {
provider: active.value.provider,
baseUrl: active.value.baseUrl,
apiKey: active.value.apiKey,
model: active.value.model,
system: systemFor(),
messages: [{role: 'user', content: props.code}]
})
result.value = props.action === 'explain' ? reply.trim() : stripFences(reply)
}
catch (error) {
toast.error(t('aiCode.failed') + error)
emit('close')
}
finally {
loading.value = false
}
}

const apply = (mode: 'replace' | 'insert') => {
if (!result.value.trim()) {
return
}
if (mode === 'replace') {
emit('replace', result.value)
}
else {
emit('insert', result.value)
}
emit('close')
}

const copy = async () => {
try {
await navigator.clipboard.writeText(result.value)
toast.success(t('aiCode.copied'))
}
catch {
// 忽略
}
}

onMounted(run)
</script>
38 changes: 36 additions & 2 deletions src/components/DebugPanel.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<template>
<div v-if="debug.status.value !== 'inactive'"
class="fixed top-0 right-0 bottom-0 z-30 w-[300px] bg-white dark:bg-gray-900 dark:text-gray-100 border-l border-gray-200 dark:border-gray-700 shadow-xl flex flex-col">
class="fixed top-0 right-0 bottom-0 z-30 bg-white dark:bg-gray-900 dark:text-gray-100 border-l border-gray-200 dark:border-gray-700 shadow-xl flex flex-col"
:style="{ width: panelWidth + 'px' }">
<!-- 左缘拖拽改宽 -->
<div class="absolute left-0 top-0 bottom-0 w-1 -ml-0.5 cursor-col-resize hover:bg-blue-500 z-50"
@mousedown="startResize"></div>
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-200">
<Bug class="w-4 h-4 text-gray-400"/>
Expand Down Expand Up @@ -95,15 +99,45 @@
</template>

<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import {computed, onBeforeUnmount, ref, watch} from 'vue'
import {Bug, ChevronRight, Square} from 'lucide-vue-next'
import {useI18n} from 'vue-i18n'
import {useDebug, type DapVariable, type Scope} from '../composables/useDebug'
import {kvGet, kvSet} from '../composables/useKvStore'
import DebugVarNode from './DebugVarNode.vue'

const {t} = useI18n()
const debug = useDebug()

// 面板宽度(左缘拖拽改宽,持久化到 KV)
const clampWidth = (w: number) => Math.max(240, Math.min(w, Math.max(240, window.innerWidth - 200)))
const panelWidth = ref(clampWidth(Number(kvGet('debug-panel-width')) || 300))
let resizeStartX = 0
let resizeStartWidth = 0
const onResize = (e: MouseEvent) => {
panelWidth.value = clampWidth(resizeStartWidth + (resizeStartX - e.clientX))
}
const stopResize = () => {
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
document.body.style.userSelect = ''
document.body.style.cursor = ''
kvSet('debug-panel-width', String(panelWidth.value))
}
const startResize = (e: MouseEvent) => {
e.preventDefault()
resizeStartX = e.clientX
resizeStartWidth = panelWidth.value
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', stopResize)
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
}
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
})

const scopes = ref<Scope[]>([])
const openScopes = ref<Set<number>>(new Set())
const scopeVars = ref<Map<number, DapVariable[]>>(new Map())
Expand Down
11 changes: 7 additions & 4 deletions src/components/DebugVarNode.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
<template>
<div>
<div class="flex items-start gap-1 px-1 py-0.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded cursor-default font-mono text-[11px] leading-5"
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded cursor-default font-mono text-[11px] leading-5"
:class="expandable ? 'cursor-pointer' : ''"
:style="{ paddingLeft: `${depth * 12 + 4}px` }"
@click="toggle">
<span class="w-3 flex-shrink-0 text-gray-400">
<span class="w-3 h-3 flex-shrink-0 flex items-center justify-center text-gray-400">
<ChevronRight v-if="expandable" class="w-3 h-3 transition-transform" :class="{ 'rotate-90': expanded }"/>
</span>
<span class="text-purple-600 dark:text-purple-300 flex-shrink-0">{{ variable.name }}</span>
<span class="text-gray-400">:</span>
<span class="text-gray-700 dark:text-gray-200 break-all">{{ variable.value }}</span>
<template v-if="variable.value">
<span class="text-gray-400">:</span>
<span class="text-gray-700 dark:text-gray-200 break-all min-w-0">{{ variable.value }}</span>
</template>
</div>
<div v-if="expanded">
<DebugVarNode v-for="(child, i) in children" :key="i" :variable="child" :depth="depth + 1"/>
Expand Down
Loading
Loading