papayu/desktop/ui/src/pages/Tasks.tsx
2026-01-29 12:21:43 +03:00

874 lines
37 KiB
TypeScript
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.

import { useState, useRef, useEffect } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event';
import {
MessageSquare,
RotateCcw,
Trash2,
FolderOpen,
FolderPlus,
File,
Download,
FileDown,
User,
Bot,
Info,
RefreshCw,
GitCompare,
History,
X,
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { analyzeProject, type AnalyzeReport, type Action, type ApplyResult, type UndoResult, type PreviewResult, type DiffItem } from '../lib/analyze';
import { animateFadeInUp } from '../lib/anime-utils';
type Message =
| { role: 'user'; text: string }
| { role: 'system'; text: string }
| { role: 'assistant'; text: string }
| { role: 'assistant'; report: AnalyzeReport; error?: string };
type HistoryItem = {
path: string;
ts: number;
projectType?: string;
risk?: string;
issueCount?: number;
summary?: string;
report: AnalyzeReport;
};
const UNDO_SYSTEM_MESSAGE = 'Последнее действие отменено.';
const HISTORY_MAX = 20;
export function Tasks() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [lastReport, setLastReport] = useState<AnalyzeReport | null>(null);
const [lastPath, setLastPath] = useState<string | null>(null);
const [previousReport, setPreviousReport] = useState<AnalyzeReport | null>(null);
const [history, setHistory] = useState<HistoryItem[]>([]);
const [historyOpen, setHistoryOpen] = useState(false);
const [selectedActions, setSelectedActions] = useState<Record<string, boolean>>({});
const [undoAvailable, setUndoAvailable] = useState(false);
const [pendingPreview, setPendingPreview] = useState<{
path: string;
actions: Action[];
diffs: DiffItem[];
} | null>(null);
const [isPreviewing, setIsPreviewing] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const messagesListRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
if (messages.length === 0) return;
const t = setTimeout(() => {
const last = messagesListRef.current?.querySelector('.message-item-anime:last-child');
if (last) animateFadeInUp(last, { duration: 500 });
}, 50);
return () => clearTimeout(t);
}, [messages.length]);
useEffect(() => {
const unlisten = listen<string>('analyze_progress', (e) => {
if (e.payload) {
setMessages((prev) => [...prev, { role: 'system', text: e.payload }]);
}
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
const handleClearChat = () => {
setMessages([]);
};
const handleUndo = () => {
if (messages.length === 0) return;
setMessages((prev) => {
const next = [...prev];
while (next.length > 0) {
const last = next[next.length - 1];
next.pop();
if (last.role === 'user') break;
}
next.push({ role: 'system', text: UNDO_SYSTEM_MESSAGE });
return next;
});
};
const handleSend = () => {
if (!input.trim()) return;
setMessages((prev) => [...prev, { role: 'user', text: input.trim() }]);
setInput('');
setTimeout(() => {
setMessages((prev) => [
...prev,
{ role: 'assistant', text: 'Ответ ИИ агента будет отображаться здесь. Результаты действий агента подключаются к backend.' },
]);
}, 500);
};
const runAnalysis = async (pathStr: string) => {
setIsAnalyzing(true);
setMessages((prev) => [
...prev,
{ role: 'user', text: `Проанализируй проект: ${pathStr}` },
{ role: 'assistant', text: 'Индексирую файлы…' },
]);
try {
const report = await analyzeProject(pathStr);
setPreviousReport(lastReport);
setLastReport(report);
setLastPath(pathStr);
const init: Record<string, boolean> = {};
(report.actions ?? []).forEach((a) => { init[a.id] = true; });
setSelectedActions(init);
setUndoAvailable(false);
setPendingPreview(null);
setHistory((prev) => {
const item: HistoryItem = {
path: report.path ?? pathStr,
ts: Date.now(),
projectType: report.structure?.project_type,
risk: report.project_context?.risk_level,
issueCount: report.findings?.length ?? 0,
summary: report.narrative?.slice(0, 80) + (report.narrative?.length > 80 ? '…' : ''),
report,
};
const next = [item, ...prev].slice(0, HISTORY_MAX);
return next;
});
setMessages((prev) => {
const next = [...prev];
for (let i = next.length - 1; i >= 0; i--) {
if (next[i].role === 'assistant' && 'text' in next[i]) {
next[i] = { role: 'assistant', report };
break;
}
}
return next;
});
} catch (e) {
const errMsg = e instanceof Error ? e.message : String(e);
setMessages((prev) => {
const next = [...prev];
for (let i = next.length - 1; i >= 0; i--) {
if (next[i].role === 'assistant' && 'text' in next[i]) {
next[i] = { role: 'assistant', report: {} as AnalyzeReport, error: errMsg };
break;
}
}
return next;
});
} finally {
setIsAnalyzing(false);
}
};
const handlePickFolderAndAnalyze = async () => {
const selected = await open({ directory: true, multiple: false });
if (!selected) return;
await runAnalysis(selected);
};
const handlePickFileAndAnalyze = async () => {
const selected = await open({ directory: false, multiple: false });
if (!selected) return;
const pathStr = typeof selected === 'string' ? selected : selected[0] ?? '';
if (!pathStr) return;
const parentDir = pathStr.replace(/[/\\][^/\\]+$/, '') || pathStr;
await runAnalysis(parentDir);
};
const handlePickFoldersAndAnalyze = async () => {
const selected = await open({ directory: true, multiple: true });
if (!selected) return;
const paths = Array.isArray(selected) ? selected : [selected];
if (paths.length === 0) return;
if (paths.length > 1) {
setMessages((prev) => [...prev, { role: 'system', text: `Выбрано папок: ${paths.length}. Анализирую первую.` }]);
}
await runAnalysis(paths[0]);
};
const handleRepeatAnalysis = () => {
if (lastPath) runAnalysis(lastPath);
};
const handleCompareWithPrevious = () => {
if (!lastReport || !previousReport) return;
const curr = lastReport.stats;
const prev = previousReport.stats;
const diffFiles = curr.file_count - prev.file_count;
const diffDirs = curr.dir_count - prev.dir_count;
const text =
diffFiles === 0 && diffDirs === 0
? 'Предыдущий и текущий отчёт совпадают по числу файлов и папок.'
: `Сравнение с предыдущим анализом:\n\айлов: ${prev.file_count}${curr.file_count} (${diffFiles >= 0 ? '+' : ''}${diffFiles})\апок: ${prev.dir_count}${curr.dir_count} (${diffDirs >= 0 ? '+' : ''}${diffDirs})\n\nТип тогда: ${previousReport.structure?.project_type ?? '—'}\nТип сейчас: ${lastReport.structure?.project_type ?? '—'}`;
setMessages((p) => [...p, { role: 'assistant', text }]);
};
const handleCompareWithHistory = (item: HistoryItem) => {
if (!lastReport) return;
const curr = lastReport.stats;
const prev = item.report.stats;
const diffFiles = curr.file_count - prev.file_count;
const diffDirs = curr.dir_count - prev.dir_count;
const text = `Сравнение с историей (${new Date(item.ts).toLocaleString('ru-RU')}):\n\айлов: ${prev.file_count}${curr.file_count} (${diffFiles >= 0 ? '+' : ''}${diffFiles})\апок: ${prev.dir_count}${curr.dir_count} (${diffDirs >= 0 ? '+' : ''}${diffDirs})\роблем: ${item.issueCount ?? 0}${lastReport.findings?.length ?? 0}\n\nТип тогда: ${item.projectType ?? '—'}\nТип сейчас: ${lastReport.structure?.project_type ?? '—'}\nРиск тогда: ${item.risk ?? '—'}\nРиск сейчас: ${lastReport.project_context?.risk_level ?? '—'}`;
setMessages((p) => [...p, { role: 'assistant', text }]);
setHistoryOpen(false);
};
const handleDownloadReport = (report: AnalyzeReport) => {
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'papa-yu-report.json';
a.click();
URL.revokeObjectURL(url);
};
const handleDownloadMD = (report: AnalyzeReport) => {
const md = report.report_md ?? report.narrative ?? '';
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'papa-yu-report.md';
a.click();
URL.revokeObjectURL(url);
};
const pushSystem = (text: string) => {
setMessages((p) => [...p, { role: 'system', text }]);
};
const pushAssistant = (text: string) => {
setMessages((p) => [...p, { role: 'assistant', text }]);
};
function clip(s: string, n = 1200) {
if (!s) return '';
return s.length > n ? s.slice(0, n) + '\n…(обрезано)…' : s;
}
function renderPreviewText(diffs: DiffItem[]) {
const lines: string[] = [];
lines.push('Вот что изменится:\n\n');
diffs.forEach((d, i) => {
lines.push(`${i + 1}. ${d.summary}`);
if (d.kind === 'create' || d.kind === 'update') {
if (d.before != null) {
lines.push(`— До:\n\`\`\`\n${clip(d.before)}\n\`\`\``);
}
if (d.after != null) {
lines.push(`— После:\n\`\`\`\n${clip(d.after)}\n\`\`\``);
}
}
if (d.kind === 'delete' && d.before != null) {
lines.push(`— Будет удалено содержимое:\n\`\`\`\n${clip(d.before)}\n\`\`\``);
}
lines.push('');
});
lines.push('Если всё выглядит правильно — нажмите «Применить». Иначе — «Отмена».');
return lines.join('\n');
}
const handlePreview = async (projectPath: string, actions: Action[]) => {
const selected = actions.filter((a) => selectedActions[a.id]);
if (!selected.length) return;
setIsPreviewing(true);
try {
const res = await invoke<PreviewResult>('preview_actions', {
payload: { path: projectPath, actions: selected },
});
setIsPreviewing(false);
if (!res.ok) {
pushSystem('Не удалось сформировать предпросмотр изменений.');
return;
}
setPendingPreview({ path: projectPath, actions: selected, diffs: res.diffs });
pushSystem('Подготовил предпросмотр изменений.');
pushAssistant(renderPreviewText(res.diffs));
} catch (e) {
setIsPreviewing(false);
pushSystem(String(e ?? 'Ошибка предпросмотра.'));
}
};
const handleApplyPending = async () => {
if (!pendingPreview) return;
const { path, actions } = pendingPreview;
try {
const res = await invoke<ApplyResult>('apply_actions', {
payload: { path, actions },
});
if (res.ok) {
pushSystem('Изменения применены.');
setUndoAvailable(true);
} else {
pushSystem(res.error ?? 'Изменения не применены. Откат выполнен.');
setUndoAvailable(false);
}
} catch (e) {
pushSystem(String(e ?? 'Ошибка применения.'));
setUndoAvailable(false);
}
setPendingPreview(null);
};
const handleCancelPending = () => {
if (!pendingPreview) return;
setPendingPreview(null);
pushSystem('Предпросмотр отменён. Ничего не изменено.');
};
const handleUndoLast = async (projectPath: string) => {
try {
const res = await invoke<UndoResult>('undo_last', { path: projectPath });
if (res.ok) {
pushSystem('Откат выполнен.');
setUndoAvailable(false);
} else {
pushSystem(res.error ?? 'Откат недоступен.');
}
} catch (e) {
pushSystem(String(e ?? 'Ошибка отката.'));
}
};
// handleApplyActions removed: Apply goes through Preview → handleApplyPending
return (
<div className="min-h-screen flex flex-col bg-background">
<div className="p-4 border-b flex items-center justify-between flex-wrap gap-2 shrink-0 bg-card/30">
<img src={`${import.meta.env.BASE_URL}logo-papa-yu.png`} alt="PAPA YU" className="h-8 md:h-9 w-auto object-contain" />
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={handlePickFolderAndAnalyze}
disabled={isAnalyzing}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-primary/50 text-primary text-sm font-medium hover:bg-primary/10 disabled:opacity-50"
title="Выбрать одну папку проекта"
>
<FolderOpen className="w-4 h-4" />
Выбрать папку
</button>
<button
onClick={handlePickFileAndAnalyze}
disabled={isAnalyzing}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-primary/50 text-primary text-sm font-medium hover:bg-primary/10 disabled:opacity-50"
title="Выбрать файл — будет проанализирована родительская папка"
>
<File className="w-4 h-4" />
Выбрать файл
</button>
<button
onClick={handlePickFoldersAndAnalyze}
disabled={isAnalyzing}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-primary/50 text-primary text-sm font-medium hover:bg-primary/10 disabled:opacity-50"
title="Выбрать несколько папок (анализ первой)"
>
<FolderPlus className="w-4 h-4" />
Выбрать папки
</button>
{lastPath && (
<button
onClick={handleRepeatAnalysis}
disabled={isAnalyzing}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium hover:bg-muted disabled:opacity-50"
title="Повторить анализ последней папки"
>
<RefreshCw className="w-4 h-4" />
Повтори анализ
</button>
)}
{lastReport && previousReport && (
<button
onClick={handleCompareWithPrevious}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium hover:bg-muted"
title="Сравнить с предыдущим отчётом"
>
<GitCompare className="w-4 h-4" />
Сравнить с предыдущим
</button>
)}
<button
onClick={() => setHistoryOpen((o) => !o)}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium hover:bg-muted"
title="История анализов"
>
<History className="w-4 h-4" />
История
</button>
<button
onClick={handleClearChat}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium hover:bg-muted"
>
<Trash2 className="w-4 h-4" />
Очистка чата
</button>
<button
onClick={handleUndo}
disabled={messages.length === 0}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
<RotateCcw className="w-4 h-4" />
Откат
</button>
</div>
</div>
<div ref={containerRef} className="flex-1 overflow-auto">
<div className="max-w-[900px] mx-auto px-4 py-6">
<h2 className="text-lg font-semibold text-foreground/90 mb-4">Анализ проекта</h2>
{messages.length === 0 ? (
<div className="text-center py-12 text-muted-foreground animate-fade-in">
<MessageSquare className="w-12 h-12 mx-auto mb-4 opacity-60" />
<p className="text-base mb-6">Выберите папку проекта для анализа.</p>
<div className="flex flex-wrap justify-center gap-3 mb-8">
<button
onClick={handlePickFolderAndAnalyze}
disabled={isAnalyzing}
className="inline-flex items-center gap-2 px-4 py-3 rounded-xl border-2 border-primary/50 text-primary font-medium hover:bg-primary/10 disabled:opacity-50 transition-colors"
title="Выбрать одну папку"
>
<FolderOpen className="w-5 h-5" />
Выбрать папку
</button>
<button
onClick={handlePickFileAndAnalyze}
disabled={isAnalyzing}
className="inline-flex items-center gap-2 px-4 py-3 rounded-xl border-2 border-primary/50 text-primary font-medium hover:bg-primary/10 disabled:opacity-50 transition-colors"
title="Анализ по родительской папке выбранного файла"
>
<File className="w-5 h-5" />
Выбрать файл
</button>
<button
onClick={handlePickFoldersAndAnalyze}
disabled={isAnalyzing}
className="inline-flex items-center gap-2 px-4 py-3 rounded-xl border-2 border-primary/50 text-primary font-medium hover:bg-primary/10 disabled:opacity-50 transition-colors"
title="Выбрать несколько папок"
>
<FolderPlus className="w-5 h-5" />
Выбрать папки
</button>
</div>
<p className="text-sm">Или введите путь или сообщение ниже.</p>
</div>
) : (
<div ref={messagesListRef} className="space-y-4">
{messages.map((m, i) => (
<div
key={i}
className={`message-item-anime flex gap-2 ${
m.role === 'user' ? 'justify-end' : m.role === 'system' ? 'justify-center' : 'justify-start'
}`}
>
{m.role !== 'system' && (
<div className="flex-shrink-0 mt-1 w-8 h-8 rounded-full flex items-center justify-center bg-muted/60">
{m.role === 'user' ? (
<User className="w-4 h-4 text-muted-foreground" />
) : (
<Bot className="w-4 h-4 text-muted-foreground" />
)}
</div>
)}
{m.role === 'system' && (
<div className="flex-shrink-0 mt-1 w-6 h-6 rounded-full flex items-center justify-center bg-muted/50">
<Info className="w-3 h-3 text-muted-foreground" />
</div>
)}
<div
className={`max-w-[85%] md:max-w-[75%] rounded-2xl px-4 py-3 transition-all-smooth ${
m.role === 'user'
? 'bg-primary/90 text-primary-foreground'
: m.role === 'system'
? 'bg-muted/60 text-muted-foreground text-sm'
: 'bg-card border border-border/60'
}`}
>
{m.role === 'system' && <div className="text-sm">{m.text}</div>}
{m.role === 'user' && <div className="font-medium">{m.text}</div>}
{m.role === 'assistant' && 'text' in m && (
<div className="font-medium whitespace-pre-wrap text-foreground/90">{m.text}</div>
)}
{m.role === 'assistant' && 'report' in m && m.report && (
<ReportBlock
report={m.report}
error={(m as Message & { error?: string }).error}
onDownload={handleDownloadReport}
onDownloadMD={handleDownloadMD}
isCurrentReport={lastReport === m.report}
selectedActions={selectedActions}
setSelectedActions={setSelectedActions}
undoAvailable={undoAvailable}
hasPendingPreview={!!pendingPreview}
isPreviewing={isPreviewing}
onPreview={handlePreview}
onApplyPending={handleApplyPending}
onCancelPending={handleCancelPending}
onUndo={handleUndoLast}
/>
)}
</div>
</div>
))}
</div>
)}
<div ref={messagesEndRef} />
{historyOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setHistoryOpen(false)}>
<div className="bg-card border rounded-xl shadow-lg max-w-lg w-full max-h-[80vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="p-4 border-b flex items-center justify-between">
<h3 className="font-semibold">История анализов</h3>
<button onClick={() => setHistoryOpen(false)} className="p-1 rounded hover:bg-muted"><X className="w-5 h-5" /></button>
</div>
<div className="p-4 overflow-auto max-h-[60vh] space-y-2">
{history.length === 0 ? (
<p className="text-sm text-muted-foreground">Пока нет записей. Запустите анализ папки.</p>
) : (
history.map((item, i) => (
<div key={i} className="p-3 rounded-lg border bg-background/50 text-sm space-y-1">
<p className="font-mono text-xs truncate" title={item.path}>{item.path}</p>
<p className="text-muted-foreground">{item.projectType ?? '—'} · риск {item.risk ?? '—'} · проблем {item.issueCount ?? 0}</p>
<div className="flex gap-2 mt-2">
<button onClick={() => { runAnalysis(item.path); setHistoryOpen(false); }} disabled={isAnalyzing} className="text-xs px-2 py-1 rounded border hover:bg-muted disabled:opacity-50">Повтори</button>
<button onClick={() => handleCompareWithHistory(item)} className="text-xs px-2 py-1 rounded border hover:bg-muted">Сравнить</button>
</div>
</div>
))
)}
</div>
</div>
</div>
)}
{pendingPreview && (
<PreviewDialog
diffs={pendingPreview.diffs}
onApply={handleApplyPending}
onCancel={handleCancelPending}
/>
)}
</div>
</div>
<div className="p-4 border-t shrink-0 bg-card/20">
<div className="max-w-[900px] mx-auto flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="Сообщение или путь к папке..."
className="flex-1 px-4 py-2.5 border rounded-xl bg-background focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<button
onClick={handleSend}
className="px-4 py-2.5 bg-primary text-primary-foreground rounded-xl font-medium hover:bg-primary/90"
>
Отправить
</button>
</div>
</div>
</div>
);
}
function PreviewDialog({
diffs,
onApply,
onCancel,
}: {
diffs: DiffItem[];
onApply: () => void;
onCancel: () => void;
}) {
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
const [tab, setTab] = useState<'preview' | 'verify' | 'write'>('preview');
const toggle = (i: number) => setExpanded((p) => ({ ...p, [i]: !p[i] }));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-card border rounded-xl shadow-lg max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-4 border-b flex items-center justify-between shrink-0">
<h3 className="font-semibold">Предпросмотр изменений</h3>
<button onClick={onCancel} className="p-1 rounded hover:bg-muted" aria-label="Закрыть">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex border-b shrink-0">
{(['preview', 'verify', 'write'] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${tab === t ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
>
{t === 'preview' && 'Превью'}
{t === 'verify' && 'Проверка'}
{t === 'write' && 'Написание программы'}
</button>
))}
</div>
<div className="p-4 overflow-auto flex-1 min-h-0">
{tab === 'preview' && (
<ul className="space-y-3">
{diffs.map((d, i) => (
<li key={i} className="rounded-lg border bg-background/50 overflow-hidden">
<button
type="button"
onClick={() => toggle(i)}
className="w-full px-3 py-2 text-left text-sm font-medium flex items-center justify-between hover:bg-muted/50"
>
<span className="truncate">{d.summary}</span>
<span className="text-xs text-muted-foreground ml-2">{d.kind}</span>
</button>
{expanded[i] && (
<div className="px-3 pb-3 space-y-2 text-xs font-mono bg-muted/30 border-t">
{d.before != null && (
<div>
<p className="text-muted-foreground mb-1">До:</p>
<pre className="whitespace-pre-wrap break-words max-h-40 overflow-auto rounded p-2 bg-background">{d.before}</pre>
</div>
)}
{d.after != null && (
<div>
<p className="text-muted-foreground mb-1">После:</p>
<pre className="whitespace-pre-wrap break-words max-h-40 overflow-auto rounded p-2 bg-background">{d.after}</pre>
</div>
)}
{d.kind === 'delete' && d.before == null && d.after == null && (
<p className="text-muted-foreground">Файл или каталог будет удалён.</p>
)}
</div>
)}
</li>
))}
</ul>
)}
{tab === 'verify' && (
<p className="text-sm text-muted-foreground">Проверка типов и сборки после применения будет доступна в следующей версии.</p>
)}
{tab === 'write' && (
<p className="text-sm text-muted-foreground">Написание и генерация кода по результатам проверки в разработке.</p>
)}
</div>
<div className="p-4 border-t flex gap-2 justify-end shrink-0">
<button onClick={onCancel} className="px-4 py-2 rounded-lg border font-medium hover:bg-muted">
Отмена
</button>
<button onClick={onApply} className="px-4 py-2 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90">
Применить
</button>
</div>
</div>
</div>
);
}
function PriorityBadge({ priority }: { priority: string }) {
const p = (priority || '').toLowerCase();
const style = p === 'high' ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' : p === 'medium' ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400';
const label = p === 'high' ? 'high' : p === 'medium' ? 'medium' : 'low';
return <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${style}`}>{label}</span>;
}
function ReportBlock({
report,
error,
onDownload,
onDownloadMD,
isCurrentReport,
selectedActions,
setSelectedActions,
undoAvailable,
hasPendingPreview,
isPreviewing,
onPreview,
onApplyPending,
onCancelPending,
onUndo,
}: {
report: AnalyzeReport;
error?: string;
onDownload: (r: AnalyzeReport) => void;
onDownloadMD: (r: AnalyzeReport) => void;
isCurrentReport: boolean;
selectedActions: Record<string, boolean>;
setSelectedActions: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
undoAvailable: boolean;
hasPendingPreview: boolean;
isPreviewing: boolean;
onPreview: (projectPath: string, actions: Action[]) => void;
onApplyPending: () => void;
onCancelPending: () => void;
onUndo: (projectPath: string) => void;
}) {
if (error) {
return <div className="text-sm text-destructive">Ошибка: {error}</div>;
}
const r = report as AnalyzeReport;
const hasReport = r && (r.path || r.narrative || r.findings?.length || r.recommendations?.length);
const ctx = r.project_context;
const recs = r.recommendations ?? [];
const actions = r.actions ?? [];
return (
<div className="text-sm space-y-4">
{hasReport && (
<>
{r.narrative && (
<div className="whitespace-pre-wrap text-foreground/90 leading-relaxed">{r.narrative}</div>
)}
{ctx && (ctx.stack?.length || ctx.maturity || ctx.risk_level) && (
<div className="rounded-lg bg-muted/40 px-3 py-2">
<p className="text-xs font-semibold text-muted-foreground mb-1">Контекст проекта</p>
<p className="text-foreground/90">
{[ctx.stack?.join(', '), ctx.maturity, ctx.risk_level && `риск ${ctx.risk_level}`].filter(Boolean).join(' · ')}
</p>
</div>
)}
{r.structure && (r.structure.project_type || r.structure.architecture) && (
<div className="rounded-lg bg-muted/40 px-3 py-2 space-y-1">
{r.structure.project_type && (
<p>
<span className="font-medium text-muted-foreground">Тип проекта:</span>{' '}
{r.structure.project_type}
</p>
)}
{r.structure.architecture && (
<p>
<span className="font-medium text-muted-foreground">Архитектура:</span>{' '}
{r.structure.architecture}
</p>
)}
</div>
)}
{r.findings?.length > 0 && (
<div>
<p className="text-xs font-semibold text-muted-foreground mb-1">Находки</p>
<ul className="list-disc list-inside space-y-0.5">
{r.findings.slice(0, 10).map((f, i) => (
<li key={i}>
<span className={f.severity === 'high' ? 'text-destructive' : ''}>{f.title}</span>
{f.details && `${f.details}`}
</li>
))}
</ul>
</div>
)}
{recs.length > 0 && (
<div>
<p className="text-xs font-semibold text-muted-foreground mb-1">Топ-рекомендации</p>
<ul className="space-y-1.5">
{recs.slice(0, 5).map((rec, i) => (
<li key={i} className="flex items-start gap-2">
<PriorityBadge priority={rec.priority ?? 'medium'} />
<span>
<span className="font-medium">{rec.title}</span>
{(rec.effort || rec.impact) && (
<span className="text-muted-foreground text-xs ml-1">
(effort: {rec.effort ?? '—'}, impact: {rec.impact ?? '—'})
</span>
)}
</span>
</li>
))}
</ul>
</div>
)}
{isCurrentReport && actions.length > 0 && (
<div className="rounded-lg bg-muted/40 px-3 py-2 space-y-2">
<p className="text-xs font-semibold text-muted-foreground">Исправления</p>
<ul className="space-y-1.5">
{actions.map((a) => (
<li key={a.id} className="flex items-center gap-2">
<input
type="checkbox"
id={`action-${a.id}`}
checked={selectedActions[a.id] !== false}
onChange={() => setSelectedActions((prev) => ({ ...prev, [a.id]: !prev[a.id] }))}
className="rounded border-border"
/>
<label htmlFor={`action-${a.id}`} className="cursor-pointer">
<span className="font-medium">{a.title}</span>
<span className="text-muted-foreground text-xs ml-1"> {a.path}</span>
</label>
</li>
))}
</ul>
<div className="flex gap-2 flex-wrap">
{!hasPendingPreview ? (
<button
type="button"
onClick={() => onPreview(r.path, actions)}
disabled={isPreviewing}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border bg-primary/10 text-primary text-sm font-medium hover:bg-primary/20 disabled:opacity-50"
>
{isPreviewing ? 'Готовлю предпросмотр…' : 'Предпросмотр изменений'}
</button>
) : (
<>
<button
type="button"
onClick={onApplyPending}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border bg-primary/10 text-primary text-sm font-medium hover:bg-primary/20"
>
Применить
</button>
<button
type="button"
onClick={onCancelPending}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border bg-background/80 text-sm font-medium hover:bg-muted"
>
Отмена
</button>
</>
)}
{undoAvailable && (
<button
type="button"
onClick={() => onUndo(r.path)}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border bg-background/80 text-sm font-medium hover:bg-muted"
>
Откатить изменения
</button>
)}
</div>
</div>
)}
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={() => onDownload(r)}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border bg-background/80 text-sm font-medium hover:bg-muted"
>
<Download className="w-4 h-4" />
Скачать JSON
</button>
{(r.report_md ?? r.narrative) && (
<button
type="button"
onClick={() => onDownloadMD(r)}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border bg-background/80 text-sm font-medium hover:bg-muted"
>
<FileDown className="w-4 h-4" />
Скачать MD
</button>
)}
</div>
</>
)}
</div>
);
}