agent-Specialization/static/src/components/panels/GitChangesPanel.vue

225 lines
8.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<aside class="git-changes-panel" :style="{ width: width + 'px' }">
<header class="git-changes-panel__header">
<div class="git-changes-panel__title">
<span class="git-changes-panel__branch">{{ branchLabel }}</span>
<span class="git-changes-panel__arrow"></span>
<span class="git-changes-panel__upstream">{{ upstreamLabel }}</span>
</div>
<div class="git-changes-panel__summary">
<span class="git-changes-panel__add">+{{ additions }}</span>
<span class="git-changes-panel__del">-{{ deletions }}</span>
<button type="button" class="git-changes-panel__close" aria-label="关闭 Git 变更面板" @click="$emit('close')">
×
</button>
</div>
</header>
<div class="git-changes-panel__body">
<div v-if="loading && !diff" class="git-changes-panel__empty">正在加载 Git 变更…</div>
<div v-else-if="error && !files.length" class="git-changes-panel__empty git-changes-panel__empty--error">{{ error }}</div>
<div v-else-if="!files.length" class="git-changes-panel__empty">当前没有未提交变更</div>
<section v-for="file in files" v-else :key="file.path" class="git-change-file">
<div class="git-change-file__header">
<span class="git-change-file__title">
<span class="git-change-file__open-wrap">
<button
type="button"
class="git-change-file__open-btn"
:class="{ open: openMenuPath === file.path || appsLoadingPath === file.path }"
:disabled="!hostMode"
:title="hostMode ? '用应用打开文件' : 'Docker 模式不可用'"
@click.stop.prevent="toggleOpenMenu(file.path)"
>
<img class="git-change-file__open-icon" :src="filePenIcon" alt="" aria-hidden="true" />
<span class="git-change-file__open-caret" aria-hidden="true"></span>
</button>
<div
v-if="openMenuPath === file.path"
class="git-change-file__open-menu"
@click.stop
>
<div v-if="openAppsError" class="git-change-file__open-empty">{{ openAppsError }}</div>
<button
v-for="app in openApps"
v-else
:key="app.id"
type="button"
class="git-change-file__open-option"
@click.stop.prevent="openFileWithApp(file.path, app.id)"
>
<img
class="git-change-file__open-option-icon"
:src="app.icon_url || filePenIcon"
alt=""
aria-hidden="true"
/>
<span>{{ app.label }}</span>
</button>
<div v-if="!appsLoadingPath && !openAppsError && !openApps.length" class="git-change-file__open-empty">
未检测到可用应用
</div>
</div>
</span>
<span class="git-change-file__path">{{ file.path }}</span>
<button
v-if="canResetContext(file.path)"
type="button"
class="git-change-file__reset"
@click.stop.prevent="handleResetContext(file.path)"
>
恢复
</button>
</span>
<span class="git-change-file__stats">
<span class="git-changes-panel__add">+{{ file.additions || 0 }}</span>
<span class="git-changes-panel__del">-{{ file.deletions || 0 }}</span>
</span>
</div>
<div class="git-change-file__diff">
<div class="git-change-file__diff-inner">
<template v-for="(hunk, hunkIndex) in file.hunks" :key="`${file.path}-${hunkIndex}`">
<button
v-if="hunk.before_hidden > 0"
type="button"
class="git-change-file__fold"
@click.stop.prevent="handleExpandContext(file.path, hunk.before_fold_key)"
>
<span class="git-change-file__fold-content">
<span class="git-change-file__fold-icon git-change-file__fold-icon--up" aria-hidden="true"></span>
<span>{{ hunk.before_hidden }} 行未编辑的内容</span>
</span>
</button>
<div
v-for="(line, lineIndex) in hunk.lines"
:key="`${file.path}-${hunkIndex}-${lineIndex}`"
class="git-change-line"
:class="{
'git-change-line--add': line.kind === 'add',
'git-change-line--delete': line.kind === 'delete'
}"
>
<span class="git-change-line__num">{{ formatLineNumber(line) }}</span>
<span class="git-change-line__marker">{{ lineMarker(line.kind) }}</span>
<span class="git-change-line__text">{{ line.text }}</span>
</div>
</template>
<button
v-if="file.after_hidden > 0"
type="button"
class="git-change-file__fold"
@click.stop.prevent="handleExpandContext(file.path, file.after_fold_key || 'after')"
>
<span class="git-change-file__fold-content">
<span class="git-change-file__fold-icon git-change-file__fold-icon--down" aria-hidden="true"></span>
<span>{{ file.after_hidden }} 行未编辑的内容</span>
</span>
</button>
</div>
</div>
</section>
</div>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { ref } from 'vue';
defineOptions({ name: 'GitChangesPanel' });
const props = defineProps<{
width: number;
loading?: boolean;
error?: string;
hostMode?: boolean;
foldContexts?: Record<string, Record<string, number>>;
diff: Record<string, any> | null;
}>();
const emit = defineEmits<{
(event: 'close'): void;
(event: 'expand-context', payload: { path: string; foldKey: string }): void;
(event: 'reset-context', path: string): void;
}>();
const branchLabel = computed(() => String(props.diff?.branch || 'HEAD'));
const upstreamLabel = computed(() => String(props.diff?.upstream || '未设置云端分支'));
const additions = computed(() => Math.max(0, Number(props.diff?.additions || 0)));
const deletions = computed(() => Math.max(0, Number(props.diff?.deletions || 0)));
const files = computed(() => (Array.isArray(props.diff?.files) ? props.diff.files : []));
const canResetContext = (path: string) => {
const fileMap = props.foldContexts?.[path];
return !!fileMap && Object.values(fileMap).some((value) => Number(value || 0) > 0);
};
const filePenIcon = new URL('../../../icons/file-pen.svg', import.meta.url).href;
const openMenuPath = ref('');
const openApps = ref<Array<{ id: string; label: string; icon_url?: string }>>([]);
const openAppsError = ref('');
const appsLoadingPath = ref('');
const formatLineNumber = (line: Record<string, any>) => {
const rawValue = line?.kind === 'delete' ? line.old_line : line?.new_line;
const num = Number(rawValue);
return Number.isFinite(num) && num > 0 ? String(num) : '';
};
const lineMarker = (kind: string) => {
if (kind === 'add') return '+';
if (kind === 'delete') return '-';
return ' ';
};
const handleExpandContext = (path: string, foldKey: string) => {
emit('expand-context', { path, foldKey });
};
const handleResetContext = (path: string) => {
emit('reset-context', path);
};
const toggleOpenMenu = async (path: string) => {
if (!props.hostMode) return;
if (openMenuPath.value === path) {
openMenuPath.value = '';
return;
}
openMenuPath.value = '';
openApps.value = [];
openAppsError.value = '';
appsLoadingPath.value = path;
try {
const resp = await fetch(`/api/project/file-open-apps?path=${encodeURIComponent(path)}`);
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '检测应用失败');
}
openApps.value = Array.isArray(payload?.data?.apps) ? payload.data.apps : [];
openMenuPath.value = path;
} catch (error: any) {
openAppsError.value = error?.message || '检测应用失败';
openMenuPath.value = path;
} finally {
appsLoadingPath.value = '';
}
};
const openFileWithApp = async (path: string, appId: string) => {
try {
const resp = await fetch('/api/project/open-file-with-app', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, app_id: appId })
});
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '打开文件失败');
}
openMenuPath.value = '';
} catch (error) {
openAppsError.value = error instanceof Error ? error.message : '打开文件失败';
}
};
</script>