docs: update diff-view iteration plans (phases 2-4)
Update implementation details for accept/reject, per-task scoping, and enhanced features phases with localStorage error handling and expanded specifications.
This commit is contained in:
parent
238a91f5f3
commit
373d1a722b
3 changed files with 418 additions and 31 deletions
|
|
@ -113,12 +113,140 @@ export class FileContentResolver {
|
||||||
async resolveFileContent(
|
async resolveFileContent(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string,
|
memberName: string,
|
||||||
filePath: string
|
filePath: string,
|
||||||
|
snippets: SnippetDiff[]
|
||||||
): Promise<{
|
): Promise<{
|
||||||
original: string | null;
|
original: string | null;
|
||||||
modified: string | null;
|
modified: string | null;
|
||||||
source: 'file-history' | 'snippet-reconstruction' | 'disk-current' | 'git-fallback' | 'unavailable';
|
source: 'file-history' | 'snippet-reconstruction' | 'disk-current' | 'git-fallback' | 'unavailable';
|
||||||
}>;
|
}> {
|
||||||
|
// Level 1: file-history-snapshot backup
|
||||||
|
const fromBackup = await this.tryFileHistoryBackup(teamName, memberName, filePath);
|
||||||
|
if (fromBackup) return { ...fromBackup, source: 'file-history' };
|
||||||
|
|
||||||
|
// Level 2: Snippet chain reconstruction
|
||||||
|
const fromSnippets = await this.trySnippetReconstruction(filePath, snippets);
|
||||||
|
if (fromSnippets) return { ...fromSnippets, source: 'snippet-reconstruction' };
|
||||||
|
|
||||||
|
// Level 3: Текущий файл на диске (worst case — может быть уже изменён)
|
||||||
|
try {
|
||||||
|
const currentContent = await readFile(filePath, 'utf8');
|
||||||
|
return { original: currentContent, modified: currentContent, source: 'disk-current' };
|
||||||
|
} catch {
|
||||||
|
return { original: null, modified: null, source: 'unavailable' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Level 2: Реконструкция original содержимого через обратное применение snippet chain.
|
||||||
|
*
|
||||||
|
* Алгоритм:
|
||||||
|
* 1. Читаем ТЕКУЩИЙ файл с диска (modified state)
|
||||||
|
* 2. Берём все SnippetDiff[] для этого файла (из ChangeExtractorService)
|
||||||
|
* 3. Применяем snippets в ОБРАТНОМ порядке (от последнего к первому)
|
||||||
|
* 4. Для каждого snippet: заменяем newString → oldString в текущем содержимом
|
||||||
|
* 5. Результат = "original" содержимое до всех изменений
|
||||||
|
*
|
||||||
|
* Ограничения:
|
||||||
|
* - Работает ТОЛЬКО если все snippets корректны и покрывают все изменения
|
||||||
|
* - Если какой-то snippet не найден в текущем файле → return null (fallback на Level 3)
|
||||||
|
* - Write-new тип: original = '' (пустой файл), modified = текущий файл
|
||||||
|
* - Write-update тип: невозможно восстановить original → return null
|
||||||
|
* - replaceAll: true — заменяем ВСЕ вхождения newString → oldString
|
||||||
|
*/
|
||||||
|
private async trySnippetReconstruction(
|
||||||
|
filePath: string,
|
||||||
|
snippets: SnippetDiff[]
|
||||||
|
): Promise<{ original: string; modified: string } | null> {
|
||||||
|
// Нет snippets — нечего реконструировать
|
||||||
|
if (!snippets || snippets.length === 0) return null;
|
||||||
|
|
||||||
|
// Читаем текущий файл с диска — это "modified" state (после всех изменений агента)
|
||||||
|
let currentContent: string;
|
||||||
|
try {
|
||||||
|
currentContent = await readFile(filePath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
// Файл не существует на диске — невозможно реконструировать
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modified = currentContent;
|
||||||
|
|
||||||
|
// Сортируем snippets по timestamp УБЫВАНИЯ — от последнего к первому.
|
||||||
|
// Обратный порядок нужен, потому что мы "откатываем" изменения:
|
||||||
|
// последний snippet применился последним → откатываем его первым.
|
||||||
|
const sorted = [...snippets].sort((a, b) => {
|
||||||
|
// timestamp — ISO string или epoch, сравниваем как строки (ISO) или числа
|
||||||
|
const timeA = typeof a.timestamp === 'number' ? a.timestamp : new Date(a.timestamp).getTime();
|
||||||
|
const timeB = typeof b.timestamp === 'number' ? b.timestamp : new Date(b.timestamp).getTime();
|
||||||
|
return timeB - timeA; // УБЫВАНИЕ — от нового к старому
|
||||||
|
});
|
||||||
|
|
||||||
|
// Применяем обратные замены
|
||||||
|
let content = currentContent;
|
||||||
|
|
||||||
|
for (const snippet of sorted) {
|
||||||
|
// Write-new: агент СОЗДАЛ файл с нуля.
|
||||||
|
// Original = '' (пустой), Modified = текущее содержимое.
|
||||||
|
// Не нужно reverse-apply — просто знаем, что до этого файла не было.
|
||||||
|
if (snippet.type === 'write-new') {
|
||||||
|
return { original: '', modified };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write-update: агент ПЕРЕЗАПИСАЛ файл целиком (Write без old_string).
|
||||||
|
// Невозможно восстановить предыдущее содержимое — нет old_string.
|
||||||
|
// Fallback на Level 3 (текущий диск).
|
||||||
|
if (snippet.type === 'write-update') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit / Multi-edit: у нас есть oldString и newString
|
||||||
|
// Reverse: заменяем newString → oldString
|
||||||
|
if (snippet.type === 'edit' || snippet.type === 'multi-edit') {
|
||||||
|
// Guard: пустой newString означает удаление (oldString → '').
|
||||||
|
// Reverse = вставить oldString обратно, но без позиционного контекста
|
||||||
|
// не знаем КУДА вставлять → невозможно reverse → return null.
|
||||||
|
if (!snippet.newString && snippet.oldString) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: пустой oldString означает вставку ('' → newString).
|
||||||
|
// Reverse = удалить newString из файла.
|
||||||
|
// Но если newString не уникален — опасно. Проверяем ниже.
|
||||||
|
|
||||||
|
if (snippet.replaceAll) {
|
||||||
|
// replaceAll: true — агент заменил ВСЕ вхождения oldString → newString.
|
||||||
|
// Reverse: заменяем ВСЕ вхождения newString → oldString.
|
||||||
|
if (snippet.newString && !content.includes(snippet.newString)) {
|
||||||
|
// newString не найден — chain сломана
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
content = content.replaceAll(snippet.newString, snippet.oldString);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычная замена (первое вхождение)
|
||||||
|
if (snippet.newString && !content.includes(snippet.newString)) {
|
||||||
|
// newString не найден в текущем содержимом —
|
||||||
|
// значит chain неполный или файл был изменён после агента.
|
||||||
|
// Fallback на Level 3.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заменяем ПЕРВОЕ вхождение newString → oldString
|
||||||
|
if (snippet.newString) {
|
||||||
|
const idx = content.indexOf(snippet.newString);
|
||||||
|
content =
|
||||||
|
content.slice(0, idx) +
|
||||||
|
snippet.oldString +
|
||||||
|
content.slice(idx + snippet.newString.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// content теперь содержит "original" — состояние файла ДО всех изменений агента
|
||||||
|
return { original: content, modified };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch resolve для всех файлов в changeSet.
|
* Batch resolve для всех файлов в changeSet.
|
||||||
|
|
@ -154,13 +282,27 @@ export class FileContentResolver {
|
||||||
4. **Нужная версия**: Последний snapshot ПЕРЕД первым tool_use для данного файла
|
4. **Нужная версия**: Последний snapshot ПЕРЕД первым tool_use для данного файла
|
||||||
5. **Если snapshot отсутствует**: Fallback на snippet reconstruction
|
5. **Если snapshot отсутствует**: Fallback на snippet reconstruction
|
||||||
|
|
||||||
**Snippet chain reconstruction:**
|
**Snippet chain reconstruction (Level 2 — `trySnippetReconstruction`):**
|
||||||
|
|
||||||
1. Собрать все Edit tool_use для файла в хронологическом порядке
|
Подход: **обратное применение** (reverse-apply) — начинаем с текущего файла на диске и откатываем snippets от последнего к первому.
|
||||||
2. Начать с `original = ''` (или текущий файл, если есть)
|
|
||||||
3. Для каждого Edit: `content = content.replace(old_string, new_string)`
|
1. Читаем ТЕКУЩИЙ файл с диска (= modified state после всех изменений агента)
|
||||||
4. `modified` = финальный результат, `original` = начальное состояние
|
2. Сортируем snippets по timestamp УБЫВАНИЯ (от последнего к первому)
|
||||||
5. **Проблема**: Нет гарантии что chain полный (Write без old нарушает цепочку)
|
3. Для каждого snippet в обратном порядке:
|
||||||
|
- `write-new` → original = `''`, modified = текущий файл (агент создал файл с нуля)
|
||||||
|
- `write-update` → return null (невозможно восстановить — нет oldString)
|
||||||
|
- `edit` / `multi-edit` + `replaceAll: true` → `content.replaceAll(newString, oldString)`
|
||||||
|
- `edit` / `multi-edit` → `content.replace(newString, oldString)` (первое вхождение)
|
||||||
|
- Если `newString` не найден в content → return null (chain сломана, fallback на Level 3)
|
||||||
|
4. После всех reverse-замен: `content` = original, текущий файл = modified
|
||||||
|
|
||||||
|
**Ограничения:**
|
||||||
|
- Работает ТОЛЬКО если все snippets корректны и покрывают ВСЕ изменения
|
||||||
|
- `write-update` (Write без old_string) ломает цепочку → fallback на Level 3
|
||||||
|
- Пустой `newString` (delete-операция) требует позиционный контекст → return null
|
||||||
|
- Неуникальный короткий `newString` может привести к неверной замене (но для Level 2 это приемлемо — Level 1 обычно покрывает ~85% кейсов)
|
||||||
|
|
||||||
|
Полная реализация метода — см. `trySnippetReconstruction()` в классе выше.
|
||||||
|
|
||||||
### 3. Сервис: `src/main/services/team/ReviewApplierService.ts` (NEW)
|
### 3. Сервис: `src/main/services/team/ReviewApplierService.ts` (NEW)
|
||||||
|
|
||||||
|
|
@ -550,14 +692,15 @@ async function handleGetFileContent(
|
||||||
): Promise<IpcResult<FileChangeWithContent>> {
|
): Promise<IpcResult<FileChangeWithContent>> {
|
||||||
return wrapReviewHandler('review:getFileContent', async () => {
|
return wrapReviewHandler('review:getFileContent', async () => {
|
||||||
const resolver = getContentResolver();
|
const resolver = getContentResolver();
|
||||||
const resolved = await resolver.resolveFileContent(teamName, memberName, filePath);
|
|
||||||
|
|
||||||
// ВАЖНО: resolver возвращает { original, modified, source },
|
// ВАЖНО: сначала получаем snippets из extractor — они нужны для Level 2 reconstruction
|
||||||
// но ReviewAPI.getFileContent обещает FileChangeWithContent.
|
|
||||||
// Нужно маппить в полный тип:
|
|
||||||
const extractor = getChangeExtractor();
|
const extractor = getChangeExtractor();
|
||||||
const changeSet = await extractor.getAgentChanges(teamName, memberName);
|
const changeSet = await extractor.getAgentChanges(teamName, memberName);
|
||||||
const fileSummary = changeSet.files.find(f => f.filePath === filePath);
|
const fileSummary = changeSet.files.find(f => f.filePath === filePath);
|
||||||
|
const snippets = fileSummary?.snippets ?? [];
|
||||||
|
|
||||||
|
// Передаём snippets в resolver для Level 2 (snippet chain reconstruction)
|
||||||
|
const resolved = await resolver.resolveFileContent(teamName, memberName, filePath, snippets);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filePath,
|
filePath,
|
||||||
|
|
@ -1213,28 +1356,128 @@ interface ConflictDialogProps {
|
||||||
2. "Use Agent's Original" — восстановить до-агентное состояние
|
2. "Use Agent's Original" — восстановить до-агентное состояние
|
||||||
3. "Edit Manually" — открыть CodeMirror для ручного редактирования
|
3. "Edit Manually" — открыть CodeMirror для ручного редактирования
|
||||||
|
|
||||||
|
#### `src/renderer/components/team/review/DiffErrorBoundary.tsx` (NEW)
|
||||||
|
|
||||||
|
**Задача**: React ErrorBoundary вокруг CodeMirror. CodeMirror может бросать исключения при malformed content, DOM manipulation issues, race conditions при destroy/create. ErrorBoundary перехватывает ошибки, логирует, показывает fallback с raw diff текстом.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/renderer/components/team/review/DiffErrorBoundary.tsx (NEW)
|
||||||
|
import React from 'react';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DiffErrorBoundaryProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
filePath: string;
|
||||||
|
/** Fallback: показать raw text diff */
|
||||||
|
oldString?: string;
|
||||||
|
newString?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiffErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DiffErrorBoundary extends React.Component<DiffErrorBoundaryProps, DiffErrorBoundaryState> {
|
||||||
|
state: DiffErrorBoundaryState = { hasError: false, error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): DiffErrorBoundaryState {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
// Логируем ошибку CodeMirror для диагностики
|
||||||
|
console.error('[DiffErrorBoundary] CodeMirror crash:', error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-red-400" />
|
||||||
|
<span className="text-sm font-medium text-red-300">
|
||||||
|
Ошибка отображения diff для {this.props.filePath}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-muted mb-3">
|
||||||
|
{this.state.error?.message ?? 'Unknown error'}
|
||||||
|
</p>
|
||||||
|
{this.props.onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
this.props.onRetry?.();
|
||||||
|
}}
|
||||||
|
className="text-xs px-3 py-1 rounded bg-surface-raised hover:bg-surface-overlay text-text-secondary"
|
||||||
|
>
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Raw text fallback — пользователь всё равно видит изменения */}
|
||||||
|
{(this.props.oldString || this.props.newString) && (
|
||||||
|
<details className="mt-3">
|
||||||
|
<summary className="text-xs text-text-muted cursor-pointer">
|
||||||
|
Показать raw diff
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 text-xs font-mono">
|
||||||
|
<div>
|
||||||
|
<div className="text-red-400 mb-1">— Original</div>
|
||||||
|
<pre className="p-2 bg-surface rounded overflow-auto max-h-64 whitespace-pre-wrap">
|
||||||
|
{this.props.oldString || '(empty)'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-green-400 mb-1">+ Modified</div>
|
||||||
|
<pre className="p-2 bg-surface rounded overflow-auto max-h-64 whitespace-pre-wrap">
|
||||||
|
{this.props.newString || '(empty)'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**~80 LOC**. Class component (обязательно для ErrorBoundary — React не поддерживает getDerivedStateFromError в функциональных компонентах).
|
||||||
|
|
||||||
### 9. Модификация существующих компонентов
|
### 9. Модификация существующих компонентов
|
||||||
|
|
||||||
#### `ChangeReviewDialog.tsx` (MODIFY — замена Phase 1 ReviewDiffContent)
|
#### `ChangeReviewDialog.tsx` (MODIFY — замена Phase 1 ReviewDiffContent)
|
||||||
|
|
||||||
Phase 1 использовал простой HTML-рендер. Phase 2 заменяет на `CodeMirrorDiffView`:
|
Phase 1 использовал простой HTML-рендер. Phase 2 заменяет на `CodeMirrorDiffView`, обёрнутый в `DiffErrorBoundary`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Phase 1 (удалить)
|
// Phase 1 (удалить)
|
||||||
<ReviewDiffContent snippets={selectedFile.snippets} />
|
<ReviewDiffContent snippets={selectedFile.snippets} />
|
||||||
|
|
||||||
// Phase 2 (заменить на)
|
// Phase 2 (заменить на — CodeMirror обёрнут в ErrorBoundary)
|
||||||
<CodeMirrorDiffView
|
<DiffErrorBoundary
|
||||||
original={fileContent?.originalFullContent ?? ''}
|
filePath={selectedFile.filePath}
|
||||||
modified={fileContent?.modifiedFullContent ?? ''}
|
oldString={fileContent?.originalFullContent}
|
||||||
fileName={selectedFile.relativePath}
|
newString={fileContent?.modifiedFullContent}
|
||||||
showMergeControls={true}
|
onRetry={() => refetchFileContent(selectedFile.filePath)}
|
||||||
collapseUnchanged={collapseUnchanged}
|
>
|
||||||
onHunkAccepted={(idx) => setHunkDecision(selectedFile.filePath, idx, 'accepted')}
|
<CodeMirrorDiffView
|
||||||
onHunkRejected={(idx) => setHunkDecision(selectedFile.filePath, idx, 'rejected')}
|
original={fileContent?.originalFullContent ?? ''}
|
||||||
/>
|
modified={fileContent?.modifiedFullContent ?? ''}
|
||||||
|
fileName={selectedFile.relativePath}
|
||||||
|
showMergeControls={true}
|
||||||
|
collapseUnchanged={collapseUnchanged}
|
||||||
|
onHunkAccepted={(idx) => setHunkDecision(selectedFile.filePath, idx, 'accepted')}
|
||||||
|
onHunkRejected={(idx) => setHunkDecision(selectedFile.filePath, idx, 'rejected')}
|
||||||
|
/>
|
||||||
|
</DiffErrorBoundary>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Важно**: `DiffErrorBoundary` оборачивает ТОЛЬКО `CodeMirrorDiffView`, а не весь dialog. Если CodeMirror упадёт — остальной UI (file tree, toolbar, timeline) продолжает работать. При ошибке пользователь видит raw diff text и может нажать "Попробовать снова" для пересоздания CodeMirror instance.
|
||||||
|
|
||||||
**Lazy loading file content:**
|
**Lazy loading file content:**
|
||||||
```typescript
|
```typescript
|
||||||
// При выборе файла — загрузить полное содержимое (если ещё не загружено)
|
// При выборе файла — загрузить полное содержимое (если ещё не загружено)
|
||||||
|
|
@ -1289,11 +1532,12 @@ function getFileStatusIcon(filePath: string, hunkDecisions: Record<string, HunkD
|
||||||
| `src/preload/index.ts` | MODIFY | +30 |
|
| `src/preload/index.ts` | MODIFY | +30 |
|
||||||
| `src/renderer/store/slices/changeReviewSlice.ts` | MODIFY | +200 |
|
| `src/renderer/store/slices/changeReviewSlice.ts` | MODIFY | +200 |
|
||||||
| `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | NEW | 350 |
|
| `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | NEW | 350 |
|
||||||
|
| `src/renderer/components/team/review/DiffErrorBoundary.tsx` | NEW | 80 |
|
||||||
| `src/renderer/components/team/review/ReviewToolbar.tsx` | NEW | 150 |
|
| `src/renderer/components/team/review/ReviewToolbar.tsx` | NEW | 150 |
|
||||||
| `src/renderer/components/team/review/ConflictDialog.tsx` | NEW | 180 |
|
| `src/renderer/components/team/review/ConflictDialog.tsx` | NEW | 180 |
|
||||||
| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +60 |
|
| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +60 |
|
||||||
| `src/renderer/components/team/review/ReviewFileTree.tsx` | MODIFY | +40 |
|
| `src/renderer/components/team/review/ReviewFileTree.tsx` | MODIFY | +40 |
|
||||||
| **Итого** | 4 NEW + 10 MODIFY | ~1,970 |
|
| **Итого** | 5 NEW + 10 MODIFY | ~2,050 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -521,9 +521,125 @@ private async extractFilteredChanges(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
// ... parse entry, extract content, find tool_use blocks ...
|
const trimmed = line.trim();
|
||||||
// if (shouldFilter && !allowedToolUseIds.has(block.id)) continue;
|
if (!trimmed) continue;
|
||||||
// → add to fileMap
|
|
||||||
|
let entry: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
entry = JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
continue; // Пропускаем повреждённые строки
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = typeof entry.timestamp === 'string' ? entry.timestamp : '';
|
||||||
|
|
||||||
|
// Извлекаем content (тот же паттерн что в parseBoundaries — extractContent)
|
||||||
|
const message = entry.message as Record<string, unknown> | undefined;
|
||||||
|
let content: unknown[] | null = null;
|
||||||
|
if (message && Array.isArray(message.content)) {
|
||||||
|
content = message.content;
|
||||||
|
} else if (Array.isArray(entry.content)) {
|
||||||
|
content = entry.content;
|
||||||
|
}
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
// Ищем tool_use блоки с file-modifying tools
|
||||||
|
for (const block of content) {
|
||||||
|
if (!block || typeof block !== 'object') continue;
|
||||||
|
const b = block as Record<string, unknown>;
|
||||||
|
if (b.type !== 'tool_use') continue;
|
||||||
|
|
||||||
|
const rawName = typeof b.name === 'string' ? b.name : '';
|
||||||
|
const toolName = rawName.replace(/^proxy_/, '');
|
||||||
|
|
||||||
|
// Только file-modifying tools
|
||||||
|
if (!['Edit', 'Write', 'MultiEdit'].includes(toolName)) continue;
|
||||||
|
|
||||||
|
const toolUseId = typeof b.id === 'string' ? b.id : '';
|
||||||
|
|
||||||
|
// Фильтрация по allowedToolUseIds (если scope задан)
|
||||||
|
if (shouldFilter && !allowedToolUseIds.has(toolUseId)) continue;
|
||||||
|
|
||||||
|
const input = b.input as Record<string, unknown> | undefined;
|
||||||
|
if (!input) continue;
|
||||||
|
|
||||||
|
const filePath = typeof input.file_path === 'string' ? input.file_path : '';
|
||||||
|
if (!filePath) continue;
|
||||||
|
|
||||||
|
// Инициализируем FileChangeSummary если ещё нет
|
||||||
|
if (!fileMap.has(filePath)) {
|
||||||
|
fileMap.set(filePath, {
|
||||||
|
filePath,
|
||||||
|
relativePath: filePath.split('/').slice(-3).join('/'), // Последние 3 сегмента
|
||||||
|
snippets: [],
|
||||||
|
linesAdded: 0,
|
||||||
|
linesRemoved: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const summary = fileMap.get(filePath)!;
|
||||||
|
|
||||||
|
if (toolName === 'Edit') {
|
||||||
|
const oldString = typeof input.old_string === 'string' ? input.old_string : '';
|
||||||
|
const newString = typeof input.new_string === 'string' ? input.new_string : '';
|
||||||
|
const replaceAll = input.replace_all === true;
|
||||||
|
|
||||||
|
summary.snippets.push({
|
||||||
|
toolUseId,
|
||||||
|
toolName: 'Edit',
|
||||||
|
oldString,
|
||||||
|
newString,
|
||||||
|
type: 'edit',
|
||||||
|
timestamp,
|
||||||
|
replaceAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Подсчёт строк
|
||||||
|
const addedLines = newString.split('\n').length;
|
||||||
|
const removedLines = oldString.split('\n').length;
|
||||||
|
summary.linesAdded += Math.max(0, addedLines - removedLines);
|
||||||
|
summary.linesRemoved += Math.max(0, removedLines - addedLines);
|
||||||
|
} else if (toolName === 'Write') {
|
||||||
|
const content = typeof input.content === 'string' ? input.content : '';
|
||||||
|
// Write: если файл уже встречался в fileMap — это update, иначе new
|
||||||
|
const isNew = summary.snippets.length === 0;
|
||||||
|
|
||||||
|
summary.snippets.push({
|
||||||
|
toolUseId,
|
||||||
|
toolName: 'Write',
|
||||||
|
oldString: '',
|
||||||
|
newString: content,
|
||||||
|
type: isNew ? 'write-new' : 'write-update',
|
||||||
|
timestamp,
|
||||||
|
replaceAll: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
summary.linesAdded += content.split('\n').length;
|
||||||
|
} else if (toolName === 'MultiEdit') {
|
||||||
|
const edits = Array.isArray(input.edits) ? input.edits : [];
|
||||||
|
|
||||||
|
for (const edit of edits) {
|
||||||
|
if (!edit || typeof edit !== 'object') continue;
|
||||||
|
const e = edit as Record<string, unknown>;
|
||||||
|
const oldString = typeof e.old_string === 'string' ? e.old_string : '';
|
||||||
|
const newString = typeof e.new_string === 'string' ? e.new_string : '';
|
||||||
|
|
||||||
|
summary.snippets.push({
|
||||||
|
toolUseId,
|
||||||
|
toolName: 'MultiEdit',
|
||||||
|
oldString,
|
||||||
|
newString,
|
||||||
|
type: 'multi-edit',
|
||||||
|
timestamp,
|
||||||
|
replaceAll: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addedLines = newString.split('\n').length;
|
||||||
|
const removedLines = oldString.split('\n').length;
|
||||||
|
summary.linesAdded += Math.max(0, addedLines - removedLines);
|
||||||
|
summary.linesRemoved += Math.max(0, removedLines - addedLines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
rl.close();
|
rl.close();
|
||||||
|
|
|
||||||
|
|
@ -234,8 +234,23 @@ function parseEntry(raw: string | null): ViewedStorageEntry | null {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ВАЖНО: Все localStorage операции обёрнуты в try-catch.
|
||||||
|
// QuotaExceededError возможен при переполнении (~5MB limit).
|
||||||
|
// При ошибке: логируем warning, операция no-op (viewed state теряется, не критично).
|
||||||
|
|
||||||
function saveEntry(teamName: string, scopeKey: string, entry: ViewedStorageEntry): void {
|
function saveEntry(teamName: string, scopeKey: string, entry: ViewedStorageEntry): void {
|
||||||
localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify(entry));
|
try {
|
||||||
|
localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify(entry));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[diffViewedStorage] localStorage write failed:', error);
|
||||||
|
// QuotaExceededError — попробуем очистить старые entries и retry
|
||||||
|
try {
|
||||||
|
cleanupOldViewedEntries();
|
||||||
|
localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify(entry));
|
||||||
|
} catch {
|
||||||
|
// Полный отказ — молча проглатываем, viewed state не критичен
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** M2 fix: Cleanup старых entries при переполнении */
|
/** M2 fix: Cleanup старых entries при переполнении */
|
||||||
|
|
@ -552,6 +567,9 @@ import { promisify } from 'util';
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
export class GitDiffFallback {
|
export class GitDiffFallback {
|
||||||
|
// Все git операции имеют timeout 10s — на больших repo git может зависнуть.
|
||||||
|
// При timeout execFile выбрасывает error с signal='SIGTERM', catch → return null/false/[].
|
||||||
|
|
||||||
// M3 fix: кеш isGitRepo результатов — один exec per projectPath за сессию
|
// M3 fix: кеш isGitRepo результатов — один exec per projectPath за сессию
|
||||||
private gitRepoCache = new Map<string, boolean>();
|
private gitRepoCache = new Map<string, boolean>();
|
||||||
|
|
||||||
|
|
@ -571,6 +589,7 @@ export class GitDiffFallback {
|
||||||
], {
|
], {
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
maxBuffer: 10 * 1024 * 1024, // 10MB
|
maxBuffer: 10 * 1024 * 1024, // 10MB
|
||||||
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
return stdout;
|
return stdout;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -592,7 +611,7 @@ export class GitDiffFallback {
|
||||||
const { stdout } = await execFileAsync('git', [
|
const { stdout } = await execFileAsync('git', [
|
||||||
'log', '--format=%H', '--before', timestamp,
|
'log', '--format=%H', '--before', timestamp,
|
||||||
'-1', '--', relativePath
|
'-1', '--', relativePath
|
||||||
], { cwd: projectPath });
|
], { cwd: projectPath, timeout: 10_000 });
|
||||||
return stdout.trim() || null;
|
return stdout.trim() || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -613,7 +632,7 @@ export class GitDiffFallback {
|
||||||
const relativePath = filePath.replace(projectPath + '/', '');
|
const relativePath = filePath.replace(projectPath + '/', '');
|
||||||
const { stdout } = await execFileAsync('git', [
|
const { stdout } = await execFileAsync('git', [
|
||||||
'diff', fromCommit, toCommit, '--', relativePath
|
'diff', fromCommit, toCommit, '--', relativePath
|
||||||
], { cwd: projectPath });
|
], { cwd: projectPath, timeout: 10_000 });
|
||||||
return stdout || null;
|
return stdout || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -634,7 +653,7 @@ export class GitDiffFallback {
|
||||||
'log', `--max-count=${maxCount}`,
|
'log', `--max-count=${maxCount}`,
|
||||||
'--format=%H|%aI|%s',
|
'--format=%H|%aI|%s',
|
||||||
'--', relativePath
|
'--', relativePath
|
||||||
], { cwd: projectPath });
|
], { cwd: projectPath, timeout: 10_000 });
|
||||||
|
|
||||||
return stdout.trim().split('\n')
|
return stdout.trim().split('\n')
|
||||||
.filter(line => line.includes('|'))
|
.filter(line => line.includes('|'))
|
||||||
|
|
@ -658,6 +677,7 @@ export class GitDiffFallback {
|
||||||
try {
|
try {
|
||||||
await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], {
|
await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], {
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
this.gitRepoCache.set(projectPath, true);
|
this.gitRepoCache.set(projectPath, true);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -677,6 +697,13 @@ export class GitDiffFallback {
|
||||||
// Оба используют execFile('git', ...) — одинаковый паттерн.
|
// Оба используют execFile('git', ...) — одинаковый паттерн.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Loading states для git operations:**
|
||||||
|
|
||||||
|
Git fallback может быть медленным (особенно на больших repo). В UI:
|
||||||
|
- `FileContentResolver.resolveFileContent()` уже возвращает Promise -- компонент показывает loading spinner
|
||||||
|
- Добавить `source` field в ответ, чтобы UI мог показать badge "Git fallback" / "JSONL" / "Disk"
|
||||||
|
- При timeout (10s) показать toast: "Git operation timed out. Showing current file version."
|
||||||
|
|
||||||
#### Модификация: `FileContentResolver.ts` (MODIFY — Phase 2 + Phase 4)
|
#### Модификация: `FileContentResolver.ts` (MODIFY — Phase 2 + Phase 4)
|
||||||
|
|
||||||
Добавляем git fallback как третий уровень:
|
Добавляем git fallback как третий уровень:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue