Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ name: Deploy docs to GitHub Pages

on:
push:
# 限定分支触发,排除标签:发布打的 tag 会触发部署,但 github-pages
# 环境保护规则不允许从 tag 部署,导致 deploy 被拒。
branches: [ '**' ]
paths: [ 'docs/**', '.github/workflows/docs.yml' ]
workflow_dispatch:

Expand Down
55 changes: 51 additions & 4 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@
<DebugPanel/>

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

<!-- .gitignore 模板 -->
Expand Down Expand Up @@ -463,6 +463,7 @@
<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>
<button v-if="canBlame || editorCtx.lsp" class="w-full text-left px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer" @click="aiFixDiagnostics">{{ t('aiCode.title.fix') }}</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') }}
Expand Down Expand Up @@ -510,7 +511,7 @@ import {debounce} from 'lodash-es'
import {formatDocument, formatSelection, renameSymbol} from 'codemirror-languageserver'
import {runGotoDefinition, lspSupportsLanguage, triggerCodeActions, applyCodeAction, formatDocumentAsync} from './editor/lspExtension'
import {dapSupportsLanguage} from './debug/dapClient'
import {ArrowDownAZ, ArrowUpAZ, CaseLower, CaseUpper, ChevronRight, Code2, CornerDownRight, Eraser, Eye, FolderOpen, GitBranch, GitCompare, History, ListChecks, ListTree, Maximize2, Minimize2, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Play, Plus, Save, Search, Settings as SettingsIcon, Sparkles, Sun, Terminal as TerminalIcon, WrapText, X} from 'lucide-vue-next'
import {ArrowDownAZ, ArrowUpAZ, Bookmark, CaseLower, CaseUpper, ChevronRight, Code2, CornerDownRight, Eraser, Eye, FoldVertical, FolderOpen, GitBranch, GitCompare, History, ListChecks, ListTree, Maximize2, Minimize2, Monitor, Moon, PanelBottom, PanelLeft, PanelRight, Play, Plus, Save, Search, Settings as SettingsIcon, Sparkles, Sun, Terminal as TerminalIcon, UnfoldVertical, WrapText, X} from 'lucide-vue-next'
import {ExecutionResult, LayoutMode, SplitDirection} from './types/app.ts'
import AppHeader from './components/AppHeader.vue'
import CodeEditor from './components/CodeEditor.vue'
Expand Down Expand Up @@ -541,6 +542,9 @@ import {useFileManager} from './composables/useFileManager'
import {useLanguageRegistry} from './composables/useLanguageRegistry'
import {useWorkspace} from './composables/useWorkspace'
import {useTextCommands} from './composables/useTextCommands'
import {useBookmarks} from './composables/useBookmarks'
import {foldAll, unfoldAll, matchBrackets} from '@codemirror/language'
import {diagnostics} from './editor/lspDiagnostics'
import {useGitPermalink} from './composables/useGitPermalink'
import {useRevealInTree} from './composables/useRevealInTree'
import {useWorkspaceRoots} from './composables/useWorkspaceRoots'
Expand Down Expand Up @@ -1117,6 +1121,9 @@ const editorView = shallowRef<any>(null)
// ===== 文本变换命令(排序行/大小写/去重/去行尾空白)=====
const {transformSelectionOrLine, sortLines, removeDuplicateLines, trimTrailingWhitespace} = useTextCommands(editorView)

// ===== 行书签(切换/上一处/下一处/清空,按文件记忆)=====
const {toggleBookmark, nextBookmark, prevBookmark, clearBookmarks} = useBookmarks(editorView, currentFilePath)

// 复制为 Markdown 代码块(选区或全文,带语言围栏)
const copyAsMarkdown = async () => {
const view = editorView.value
Expand Down Expand Up @@ -1168,6 +1175,23 @@ const convertIndentation = (toTabs: boolean) => {
toast.success(t('app.indentConverted'))
}

// 转到匹配括号:取光标前后的括号,跳到其配对处
const goToMatchingBracket = () => {
const view = editorView.value
if (!view) {
return
}
const pos = view.state.selection.main.head
const m = matchBrackets(view.state, pos, -1) || matchBrackets(view.state, pos, 1)
if (m && m.matched && m.end) {
view.dispatch({selection: {anchor: m.end.from}, scrollIntoView: true})
view.focus()
}
else {
toast.info(t('app.noMatchingBracket'))
}
}

// AI 自然语言生成 / 选区改写
const showGenerate = ref(false)
const generateSelection = ref('')
Expand Down Expand Up @@ -1398,7 +1422,21 @@ const runTests = async () => {
}

// C2:对选区(无选区则整篇)执行 AI 操作:解释 / 重构 / 生成测试
const aiCodeCtx = ref<{action: 'explain' | 'refactor' | 'test'; code: string; from: number; to: number} | null>(null)
const aiCodeCtx = ref<{action: 'explain' | 'refactor' | 'test' | 'fix'; code: string; from: number; to: number; diagnostics?: string} | null>(null)
// AI 修复诊断:把当前文件的 LSP 诊断交给 AI 修复整篇
const aiFixDiagnostics = () => {
closeEditorCtx()
const view = editorView.value
if (!view) {
return
}
if (!diagnostics.value.length) {
toast.info(t('app.noDiagnostics'))
return
}
const diagText = diagnostics.value.map(d => `[${d.severity}] L${d.line}:${d.col} ${d.message}`).join('\n')
aiCodeCtx.value = {action: 'fix', code: view.state.doc.toString(), from: 0, to: view.state.doc.length, diagnostics: diagText}
}
const aiCodeAction = (action: 'explain' | 'refactor' | 'test') => {
closeEditorCtx()
const view = editorView.value
Expand Down Expand Up @@ -2154,7 +2192,8 @@ const shortcutDispatch: Record<string, () => void> = {
reopenClosed: () => handleReopenClosed(),
toggleSidebar: () => toggleSidebar(),
toggleTerminal: () => toggleTerminal(),
toggleWordWrap: () => toggleWordWrap()
toggleWordWrap: () => toggleWordWrap(),
toggleBookmark: () => toggleBookmark()
}

// 切换自动换行(即时生效并随编辑器配置持久化)
Expand Down Expand Up @@ -2205,6 +2244,7 @@ const paletteCommands = computed<PaletteCommand[]>(() => [
{id: 'explainCode', label: t('command.explainCode'), icon: Sparkles, run: () => explainCode()},
{id: 'generateTests', label: t('command.generateTests'), icon: Sparkles, run: () => generateTests()},
{id: 'formatWithAi', label: t('command.formatWithAi'), icon: Sparkles, run: () => formatWithAi()},
{id: 'aiFixDiagnostics', label: t('command.aiFixDiagnostics'), icon: Sparkles, run: () => aiFixDiagnostics()},
{id: 'history', label: t('command.history'), icon: History, run: () => { showHistory.value = true }},
{id: 'diff', label: t('command.diff'), icon: GitCompare, run: () => openDiff()},
{id: 'compareClipboard', label: t('command.compareClipboard'), icon: GitCompare, run: () => compareWithClipboard()},
Expand All @@ -2227,6 +2267,13 @@ const paletteCommands = computed<PaletteCommand[]>(() => [
{id: 'copyAsMarkdown', label: t('command.copyAsMarkdown'), group: t('command.groupText'), icon: Code2, run: () => copyAsMarkdown()},
{id: 'indentToSpaces', label: t('command.indentToSpaces'), group: t('command.groupText'), icon: Eraser, run: () => convertIndentation(false)},
{id: 'indentToTabs', label: t('command.indentToTabs'), group: t('command.groupText'), icon: Eraser, run: () => convertIndentation(true)},
{id: 'foldAll', label: t('command.foldAll'), group: t('command.groupCode'), icon: FoldVertical, run: () => { if (editorView.value) foldAll(editorView.value) }},
{id: 'unfoldAll', label: t('command.unfoldAll'), group: t('command.groupCode'), icon: UnfoldVertical, run: () => { if (editorView.value) unfoldAll(editorView.value) }},
{id: 'goToMatchingBracket', label: t('command.goToMatchingBracket'), group: t('command.groupCode'), icon: Code2, run: () => goToMatchingBracket()},
{id: 'toggleBookmark', label: t('command.toggleBookmark'), group: t('command.groupBookmark'), icon: Bookmark, hint: hintOf('toggleBookmark'), run: () => toggleBookmark()},
{id: 'nextBookmark', label: t('command.nextBookmark'), group: t('command.groupBookmark'), icon: Bookmark, run: () => nextBookmark()},
{id: 'prevBookmark', label: t('command.prevBookmark'), group: t('command.groupBookmark'), icon: Bookmark, run: () => prevBookmark()},
{id: 'clearBookmarks', label: t('command.clearBookmarks'), group: t('command.groupBookmark'), icon: Bookmark, run: () => clearBookmarks()},
{id: 'toggleAutoReveal', label: t('command.toggleAutoReveal'), icon: FolderOpen, run: () => toggleAutoReveal()},
{id: 'toggleSidebar', label: t('command.toggleSidebar'), icon: PanelLeft, hint: hintOf('toggleSidebar'), run: () => toggleSidebar()},
{id: 'toggleZen', label: t('command.toggleZen'), icon: Minimize2, run: () => toggleZen()},
Expand Down
14 changes: 11 additions & 3 deletions src/components/AiCodeAction.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<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 === 'refactor' || action === 'fix')" 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>
Expand All @@ -37,7 +37,7 @@ 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 props = defineProps<{ language: string; code: string; action: 'explain' | 'refactor' | 'test' | 'fix'; diagnostics?: string }>()
const emit = defineEmits<{ replace: [code: string]; insert: [code: string]; close: [] }>()

const toast = useToast()
Expand All @@ -62,9 +62,17 @@ const systemFor = (): string => {
return `你是代码助手。重构给定的 ${lang} 代码以提升可读性与质量,保持行为不变。只输出重构后的完整代码,不要解释,不要使用 Markdown 代码块标记。`
case 'test':
return `你是测试工程师。为给定的 ${lang} 代码生成单元测试。只输出测试代码,不要解释,不要使用 Markdown 代码块标记。`
case 'fix':
return `你是代码助手。修复给定 ${lang} 代码中的错误与警告(用户消息附带诊断信息),保持其余行为不变。只输出修复后的完整代码,不要解释,不要使用 Markdown 代码块标记。`
}
}

// 'fix' 把诊断附在用户消息里
const userContent = (): string =>
props.action === 'fix' && props.diagnostics
? `${props.code}\n\n--- 待修复的诊断 ---\n${props.diagnostics}`
: props.code

const run = async () => {
reload()
if (!active.value.apiKey) {
Expand All @@ -80,7 +88,7 @@ const run = async () => {
apiKey: active.value.apiKey,
model: active.value.model,
system: systemFor(),
messages: [{role: 'user', content: props.code}]
messages: [{role: 'user', content: userContent()}]
})
result.value = props.action === 'explain' ? reply.trim() : stripFences(reply)
}
Expand Down
75 changes: 75 additions & 0 deletions src/composables/useBookmarks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// 行书签:按文件记忆书签行,提供切换/上一处/下一处/清空,并在切换文件时同步到编辑器。
import {ref, watch, type Ref} from 'vue'
import {setBookmarks} from '../editor/bookmark'

export function useBookmarks(editorView: Ref<any>, currentFilePath: Ref<string | null>) {
// 按文件路径记忆书签行号(1-based);未命名文件用空串作 key
const map = ref<Record<string, number[]>>({})
const keyOf = () => currentFilePath.value ?? ''
const current = () => map.value[keyOf()] ?? []

// 把当前文件的书签派发到编辑器
const apply = () => {
editorView.value?.dispatch({effects: setBookmarks.of(current())})
}

const cursorLine = (view: any): number => view.state.doc.lineAt(view.state.selection.main.head).number
const gotoLine = (view: any, ln: number) => {
const pos = view.state.doc.line(ln).from
view.dispatch({selection: {anchor: pos}, scrollIntoView: true})
view.focus()
}

const toggleBookmark = () => {
const view = editorView.value
if (!view) {
return
}
const ln = cursorLine(view)
const key = keyOf()
const arr = map.value[key] ? [...map.value[key]] : []
const i = arr.indexOf(ln)
if (i >= 0) {
arr.splice(i, 1)
}
else {
arr.push(ln)
}
map.value = {...map.value, [key]: arr}
apply()
}

const nextBookmark = () => {
const view = editorView.value
const arr = [...current()].sort((a, b) => a - b)
if (!view || !arr.length) {
return
}
const ln = cursorLine(view)
gotoLine(view, arr.find(l => l > ln) ?? arr[0])
}

const prevBookmark = () => {
const view = editorView.value
const arr = [...current()].sort((a, b) => a - b)
if (!view || !arr.length) {
return
}
const ln = cursorLine(view)
gotoLine(view, [...arr].reverse().find(l => l < ln) ?? arr[arr.length - 1])
}

const clearBookmarks = () => {
const key = keyOf()
if (map.value[key]?.length) {
map.value = {...map.value, [key]: []}
apply()
}
}

// 切换文件 / 编辑器重挂时同步当前文件的书签
watch(currentFilePath, () => apply())
watch(editorView, () => apply())

return {toggleBookmark, nextBookmark, prevBookmark, clearBookmarks}
}
4 changes: 4 additions & 0 deletions src/composables/useCodeMirrorEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import {useCodeMirrorSpaceOmission} from './useCodeMirrorSpaceOmission.ts'
import {EditorView, keymap} from "@codemirror/view";
import {showMinimap} from "@replit/codemirror-minimap";
import {indentationMarkers} from "@replit/codemirror-indentation-markers";
import {bookmarkExtension} from "../editor/bookmark";
import {stickyScroll} from "../editor/stickyScroll";
import {breakpointExtension} from "../editor/breakpointGutter";
import {debugHover} from "../editor/debugHover";
Expand Down Expand Up @@ -703,6 +704,9 @@ export function useCodeMirrorEditor(props: Props)
result.push(indentationMarkers({hideFirstIndent: true, highlightActiveBlock: true}))
}

// 书签:行高亮,数据由 App 按当前文件 dispatch
result.push(bookmarkExtension)

// 断点 gutter + 调试悬停求值(仅可调试语言)
if (dapSupportsLanguage(props.language)) {
result.push(breakpointExtension((line) => debug.toggleBreakpoint(props.filePath ?? null, line)))
Expand Down
3 changes: 2 additions & 1 deletion src/composables/useShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ const SHORTCUT_DEFS: { id: string; default: string }[] = [
{id: 'reopenClosed', default: 'Mod+Shift+T'},
{id: 'toggleSidebar', default: 'Mod+B'},
{id: 'toggleTerminal', default: 'Mod+`'},
{id: 'toggleWordWrap', default: 'Alt+Z'}
{id: 'toggleWordWrap', default: 'Alt+Z'},
{id: 'toggleBookmark', default: 'Mod+Alt+K'}
]

const STORAGE_KEY = 'shortcuts'
Expand Down
35 changes: 35 additions & 0 deletions src/editor/bookmark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// 书签:按行高亮(左侧强调条 + 淡底色)。书签数据由外部(App)按当前文件 dispatch 填充。
import {Decoration, EditorView, type DecorationSet} from '@codemirror/view'
import {StateEffect, StateField} from '@codemirror/state'

// 由外部派发:设置当前文件的书签行号(1-based)
export const setBookmarks = StateEffect.define<number[]>()

const bookmarkLine = Decoration.line({class: 'cm-bookmark-line'})

export const bookmarkField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
for (const e of tr.effects) {
if (e.is(setBookmarks)) {
const ranges = e.value
.filter(ln => ln >= 1 && ln <= tr.state.doc.lines)
.sort((a, b) => a - b)
.map(ln => bookmarkLine.range(tr.state.doc.line(ln).from))
return Decoration.set(ranges, true)
}
}
// 文档变化时让标记跟随行偏移
return deco.map(tr.changes)
},
provide: f => EditorView.decorations.from(f)
})

const bookmarkTheme = EditorView.baseTheme({
'.cm-bookmark-line': {
backgroundColor: 'rgba(99, 102, 241, 0.10)',
boxShadow: 'inset 3px 0 0 #6366f1'
}
})

export const bookmarkExtension = [bookmarkField, bookmarkTheme]
18 changes: 16 additions & 2 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,7 @@
"explainCode": "AI explain code (selection or all)",
"generateTests": "AI generate tests (selection or all)",
"formatWithAi": "AI format code",
"aiFixDiagnostics": "AI fix diagnostics",
"history": "Run history",
"diff": "Diff (current vs saved)",
"preview": "Live preview (Markdown / HTML)",
Expand All @@ -694,6 +695,15 @@
"sendToTerminal": "Send selection to terminal",
"reopenClosed": "Reopen closed tab",
"revealInTree": "Reveal in file tree",
"foldAll": "Fold all",
"unfoldAll": "Unfold all",
"goToMatchingBracket": "Go to matching bracket",
"groupCode": "Code",
"toggleBookmark": "Toggle bookmark",
"nextBookmark": "Next bookmark",
"prevBookmark": "Previous bookmark",
"clearBookmarks": "Clear bookmarks in file",
"groupBookmark": "Bookmarks",
"copyPermalink": "Copy remote permalink (current line)",
"openPermalink": "Open remote link in browser (current line)",
"sortLinesAsc": "Sort lines ascending",
Expand Down Expand Up @@ -738,7 +748,8 @@
"reopenClosed": "Reopen closed tab",
"toggleSidebar": "Toggle sidebar",
"toggleTerminal": "Toggle terminal",
"toggleWordWrap": "Toggle word wrap"
"toggleWordWrap": "Toggle word wrap",
"toggleBookmark": "Toggle bookmark"
},
"view": {
"clear": "Clear",
Expand Down Expand Up @@ -1118,7 +1129,8 @@
"title": {
"explain": "AI: Explain code",
"refactor": "AI: Refactor code",
"test": "AI: Generate tests"
"test": "AI: Generate tests",
"fix": "AI: Fix diagnostics"
},
"thinking": "AI is thinking…",
"replace": "Replace selection",
Expand Down Expand Up @@ -1327,6 +1339,8 @@
"pathCopied": "Path copied",
"copiedMarkdown": "Copied as Markdown code block",
"indentConverted": "Indentation converted",
"noMatchingBracket": "No matching bracket at cursor",
"noDiagnostics": "No diagnostics in the current file",
"clipboardEmpty": "Clipboard is empty",
"clipboardReadFailed": "Failed to read clipboard: ",
"copyFailed": "Copy failed: ",
Expand Down
Loading
Loading