874 lines
37 KiB
TypeScript
874 lines
37 KiB
TypeScript
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\nФайлов: ${prev.file_count} → ${curr.file_count} (${diffFiles >= 0 ? '+' : ''}${diffFiles})\nПапок: ${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\nФайлов: ${prev.file_count} → ${curr.file_count} (${diffFiles >= 0 ? '+' : ''}${diffFiles})\nПапок: ${prev.dir_count} → ${curr.dir_count} (${diffDirs >= 0 ? '+' : ''}${diffDirs})\nПроблем: ${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>
|
||
);
|
||
}
|