From ee73d6313ea56ac4eb5931eee2b7f8fc6287c44f Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 1 Feb 2026 19:46:39 +0200 Subject: [PATCH 1/2] Added file difference viewer in activity --- .../Api/Client/Servers/FileController.php | 40 +++- .../Files/WriteFileWithDiffRequest.php | 32 +++ app/Services/Files/FileDiffService.php | 177 ++++++++++++++++ resources/lang/en/activity.php | 2 +- resources/scripts/api/routes/server/files.ts | 24 ++- .../server/files/FileEditContainer.tsx | 15 +- .../elements/activity/ActivityLogEntry.tsx | 119 +++++++---- .../elements/activity/FileDiffViewer.tsx | 200 ++++++++++++++++++ routes/api-client.php | 1 + 9 files changed, 558 insertions(+), 52 deletions(-) create mode 100644 app/Http/Requests/Api/Client/Servers/Files/WriteFileWithDiffRequest.php create mode 100644 app/Services/Files/FileDiffService.php create mode 100644 resources/scripts/elements/activity/FileDiffViewer.tsx diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index 71bd8c1355..b73f93fbcd 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -8,6 +8,7 @@ use Illuminate\Http\Response; use Illuminate\Http\JsonResponse; use Everest\Services\Nodes\NodeJWTService; +use Everest\Services\Files\FileDiffService; use Everest\Repositories\Wings\DaemonFileRepository; use Everest\Transformers\Api\Client\FileObjectTransformer; use Everest\Http\Controllers\Api\Client\ClientApiController; @@ -22,6 +23,7 @@ use Everest\Http\Requests\Api\Client\Servers\Files\DecompressFilesRequest; use Everest\Http\Requests\Api\Client\Servers\Files\GetFileContentsRequest; use Everest\Http\Requests\Api\Client\Servers\Files\WriteFileContentRequest; +use Everest\Http\Requests\Api\Client\Servers\Files\WriteFileWithDiffRequest; class FileController extends ClientApiController { @@ -30,7 +32,8 @@ class FileController extends ClientApiController */ public function __construct( private NodeJWTService $jwtService, - private DaemonFileRepository $fileRepository + private DaemonFileRepository $fileRepository, + private FileDiffService $diffService ) { parent::__construct(); } @@ -113,6 +116,41 @@ public function write(WriteFileContentRequest $request, Server $server): JsonRes return new JsonResponse([], Response::HTTP_NO_CONTENT); } + /** + * Writes the contents of the specified file to the server with diff tracking. + * This endpoint accepts JSON with original and new content to calculate diffs. + * + * @throws \Everest\Exceptions\Http\Connection\DaemonConnectionException + */ + public function writeWithDiff(WriteFileWithDiffRequest $request, Server $server): JsonResponse + { + $file = $request->input('file'); + $content = $request->input('content'); + $originalContent = $request->input('original_content', ''); + + // Write the new content to the file + $this->fileRepository->setServer($server)->putContent($file, $content); + + // Build activity log with diff information if it's a text file + $activity = Activity::event('server:file.write')->property('file', $file); + + if ($this->diffService->isTextFile($file) && $originalContent !== null) { + $diff = $this->diffService->calculateDiff($originalContent, $content, $file); + + $activity->property('diff', [ + 'additions' => $diff['additions'], + 'deletions' => $diff['deletions'], + 'hunks' => $diff['hunks'], + 'is_new_file' => $diff['is_new_file'] ?? false, + 'large_file' => $diff['large_file'] ?? false, + ]); + } + + $activity->log(); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + /** * Creates a new folder on the server. * diff --git a/app/Http/Requests/Api/Client/Servers/Files/WriteFileWithDiffRequest.php b/app/Http/Requests/Api/Client/Servers/Files/WriteFileWithDiffRequest.php new file mode 100644 index 0000000000..dfb57666d0 --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Files/WriteFileWithDiffRequest.php @@ -0,0 +1,32 @@ + 'required|string', + 'content' => 'present|string', + 'original_content' => 'nullable|string', + ]; + } +} diff --git a/app/Services/Files/FileDiffService.php b/app/Services/Files/FileDiffService.php new file mode 100644 index 0000000000..2d9c9a9897 --- /dev/null +++ b/app/Services/Files/FileDiffService.php @@ -0,0 +1,177 @@ + self::MAX_DIFF_SIZE || strlen($newContent) > self::MAX_DIFF_SIZE) { + return $this->createLargeDiffSummary($originalContent, $newContent); + } + + $originalLines = explode("\n", $originalContent); + $newLines = explode("\n", $newContent); + + $builder = new UnifiedDiffOutputBuilder( + "--- a/{$filename}\n+++ b/{$filename}\n", + true + ); + $differ = new Differ($builder); + + $diff = $differ->diff($originalContent, $newContent); + + // Calculate additions and deletions + $additions = 0; + $deletions = 0; + + $diffLines = explode("\n", $diff); + foreach ($diffLines as $line) { + if (str_starts_with($line, '+') && !str_starts_with($line, '+++')) { + $additions++; + } elseif (str_starts_with($line, '-') && !str_starts_with($line, '---')) { + $deletions++; + } + } + + // Create a summary of changes + $hunks = $this->parseHunks($diff); + + return [ + 'file' => $filename, + 'additions' => $additions, + 'deletions' => $deletions, + 'diff' => $diff, + 'hunks' => $hunks, + 'original_lines' => count($originalLines), + 'new_lines' => count($newLines), + 'is_new_file' => empty(trim($originalContent)), + ]; + } + + /** + * Create a summary for large files where detailed diff is not practical. + */ + private function createLargeDiffSummary(string $originalContent, string $newContent): array + { + $originalLines = substr_count($originalContent, "\n") + 1; + $newLines = substr_count($newContent, "\n") + 1; + + return [ + 'additions' => max(0, $newLines - $originalLines), + 'deletions' => max(0, $originalLines - $newLines), + 'diff' => null, + 'hunks' => [], + 'original_lines' => $originalLines, + 'new_lines' => $newLines, + 'large_file' => true, + ]; + } + + /** + * Parse diff output into hunks for easier frontend rendering. + */ + private function parseHunks(string $diff): array + { + $lines = explode("\n", $diff); + $hunks = []; + $currentHunk = null; + + foreach ($lines as $line) { + // Skip header lines + if (str_starts_with($line, '---') || str_starts_with($line, '+++')) { + continue; + } + + // New hunk + if (preg_match('/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/', $line, $matches)) { + if ($currentHunk !== null) { + $hunks[] = $currentHunk; + } + + $currentHunk = [ + 'old_start' => (int) $matches[1], + 'old_lines' => isset($matches[2]) ? (int) $matches[2] : 1, + 'new_start' => (int) $matches[3], + 'new_lines' => isset($matches[4]) ? (int) $matches[4] : 1, + 'context' => trim($matches[5] ?? ''), + 'changes' => [], + ]; + continue; + } + + if ($currentHunk !== null) { + $type = 'context'; + $content = $line; + + if (str_starts_with($line, '+')) { + $type = 'addition'; + $content = substr($line, 1); + } elseif (str_starts_with($line, '-')) { + $type = 'deletion'; + $content = substr($line, 1); + } elseif (str_starts_with($line, ' ')) { + $content = substr($line, 1); + } + + $currentHunk['changes'][] = [ + 'type' => $type, + 'content' => $content, + ]; + } + } + + if ($currentHunk !== null) { + $hunks[] = $currentHunk; + } + + return $hunks; + } +} diff --git a/resources/lang/en/activity.php b/resources/lang/en/activity.php index 501a1dcde6..25ad929c97 100644 --- a/resources/lang/en/activity.php +++ b/resources/lang/en/activity.php @@ -79,7 +79,7 @@ 'pull' => 'Downloaded a remote file from :url to :directory', 'rename_one' => 'Renamed :directory:files.0.from to :directory:files.0.to', 'rename_other' => 'Renamed :count files in :directory', - 'write' => 'Wrote new content to :file', + 'write' => 'Modified :file', 'upload' => 'Began a file upload', 'uploaded' => 'Uploaded :directory:file', ], diff --git a/resources/scripts/api/routes/server/files.ts b/resources/scripts/api/routes/server/files.ts index f9eee8ced9..3b295c204d 100644 --- a/resources/scripts/api/routes/server/files.ts +++ b/resources/scripts/api/routes/server/files.ts @@ -79,13 +79,23 @@ const renameFiles = (uuid: string, directory: string, files: { to: string; from: }); }; -const saveFileContents = async (uuid: string, file: string, content: string): Promise => { - await http.post(`/api/client/servers/${uuid}/files/write`, content, { - params: { file }, - headers: { - 'Content-Type': 'text/plain', - }, - }); +const saveFileContents = async (uuid: string, file: string, content: string, originalContent?: string): Promise => { + // Use the new endpoint with diff tracking when originalContent is provided + if (originalContent !== undefined) { + await http.post(`/api/client/servers/${uuid}/files/write-with-diff`, { + file, + content, + original_content: originalContent, + }); + } else { + // Fallback to the old endpoint for backward compatibility + await http.post(`/api/client/servers/${uuid}/files/write`, content, { + params: { file }, + headers: { + 'Content-Type': 'text/plain', + }, + }); + } }; const deleteFiles = (uuid: string, directory: string, files: string[]): Promise => { diff --git a/resources/scripts/components/server/files/FileEditContainer.tsx b/resources/scripts/components/server/files/FileEditContainer.tsx index 6f112fea44..61340d0768 100644 --- a/resources/scripts/components/server/files/FileEditContainer.tsx +++ b/resources/scripts/components/server/files/FileEditContainer.tsx @@ -27,6 +27,7 @@ export default () => { const { action, '*': rawFilename } = useParams<{ action: 'edit' | 'new'; '*': string }>(); const [loading, setLoading] = useState(action === 'edit'); const [content, setContent] = useState(''); + const [originalContent, setOriginalContent] = useState(''); const [modalVisible, setModalVisible] = useState(false); const [language, setLanguage] = useState(); @@ -58,7 +59,10 @@ export default () => { setLoading(true); setDirectory(dirname(filename)); getFileContents(uuid, filename) - .then(setContent) + .then(fileContent => { + setContent(fileContent); + setOriginalContent(fileContent); + }) .catch(error => { console.error(error); setError(httpErrorToHuman(error)); @@ -74,7 +78,14 @@ export default () => { setLoading(true); clearFlashes('files:view'); fetchFileContent() - .then(content => saveFileContents(uuid, name ?? filename, content)) + .then(newContent => { + // Pass original content for diff calculation (use empty string for new files) + const original = action === 'new' ? '' : originalContent; + return saveFileContents(uuid, name ?? filename, newContent, original).then(() => { + // Update original content after successful save + setOriginalContent(newContent); + }); + }) .then(() => { if (name) { navigate(`/server/${id}/files/edit/${encodePathSegments(name)}`); diff --git a/resources/scripts/elements/activity/ActivityLogEntry.tsx b/resources/scripts/elements/activity/ActivityLogEntry.tsx index 0a53f5f70a..781019df5c 100644 --- a/resources/scripts/elements/activity/ActivityLogEntry.tsx +++ b/resources/scripts/elements/activity/ActivityLogEntry.tsx @@ -5,6 +5,7 @@ import Translate from '@/elements/Translate'; import { format, formatDistanceToNowStrict } from 'date-fns'; import { ActivityLog } from '@definitions/account'; import ActivityLogMetaButton from '@/elements/activity/ActivityLogMetaButton'; +import FileDiffViewer, { FileDiff } from '@/elements/activity/FileDiffViewer'; import { FolderOpenIcon, TerminalIcon } from '@heroicons/react/solid'; import classNames from 'classnames'; import style from './style.module.css'; @@ -39,11 +40,32 @@ function wrapProperties(value: unknown): any { return value; } +function hasFileDiff(activity: ActivityLog): boolean { + return activity.event === 'server:file.write' && + activity.properties?.diff !== undefined && + typeof activity.properties.diff === 'object'; +} + +function getFileDiff(activity: ActivityLog): FileDiff | null { + if (!hasFileDiff(activity)) return null; + + const diff = activity.properties.diff as Record; + return { + file: activity.properties.file as string | undefined, + additions: (diff.additions as number) || 0, + deletions: (diff.deletions as number) || 0, + hunks: (diff.hunks as FileDiff['hunks']) || [], + is_new_file: (diff.is_new_file as boolean) || false, + large_file: (diff.large_file as boolean) || false, + }; +} + export default ({ activity, children }: Props) => { const { pathTo } = useLocationHash(); const actor = activity.relationships.actor; const properties = wrapProperties(activity.properties); const { colors } = useStoreState(state => state.theme.data!); + const fileDiff = getFileDiff(activity); return (
{
-
-
-
- - {actor?.username || 'System'} - - - - {activity.description ?? activity.event} - -
- {activity.isApi && ( - - - - )} - {activity.event.startsWith('server:sftp.') && ( - - - - )} - {children} +
+
+
+
+ + {actor?.username || 'System'} + + + + {activity.description ?? activity.event} + +
+ {activity.isApi && ( + + + + )} + {activity.event.startsWith('server:sftp.') && ( + + + + )} + {children} +
-
-

- -

-
- {activity.ip && ( - - {activity.ip} -  |  - +

+ +

+ {fileDiff && ( +
+ +{fileDiff.additions} + / + -{fileDiff.deletions} + lines changed +
)} - - {formatDistanceToNowStrict(activity.timestamp, { addSuffix: true })} - +
+ {activity.ip && ( + + {activity.ip} +  |  + + )} + + {formatDistanceToNowStrict(activity.timestamp, { addSuffix: true })} + +
+ {activity.hasAdditionalMetadata && }
- {activity.hasAdditionalMetadata && } + {fileDiff && ( +
+ +
+ )}
); diff --git a/resources/scripts/elements/activity/FileDiffViewer.tsx b/resources/scripts/elements/activity/FileDiffViewer.tsx new file mode 100644 index 0000000000..5e6c68ea93 --- /dev/null +++ b/resources/scripts/elements/activity/FileDiffViewer.tsx @@ -0,0 +1,200 @@ +import { useState } from 'react'; +import classNames from 'classnames'; +import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/solid'; + +interface DiffChange { + type: 'addition' | 'deletion' | 'context'; + content: string; +} + +interface DiffHunk { + old_start: number; + old_lines: number; + new_start: number; + new_lines: number; + context: string; + changes: DiffChange[]; +} + +export interface FileDiff { + file?: string; + additions: number; + deletions: number; + hunks: DiffHunk[]; + is_new_file?: boolean; + large_file?: boolean; +} + +interface Props { + diff: FileDiff; + filename?: string; + className?: string; +} + +const DiffStats = ({ additions, deletions }: { additions: number; deletions: number }) => ( +
+ +{additions} + -{deletions} +
+); + +interface DiffLineProps { + change: DiffChange; + lineNumber?: number; +} + +const DiffLine = ({ change, lineNumber }: DiffLineProps) => { + const getBgColor = () => { + switch (change.type) { + case 'addition': + return 'bg-green-900/30 border-l-2 border-green-500'; + case 'deletion': + return 'bg-red-900/30 border-l-2 border-red-500'; + default: + return 'bg-transparent border-l-2 border-transparent'; + } + }; + + const getLinePrefix = () => { + switch (change.type) { + case 'addition': + return '+'; + case 'deletion': + return '-'; + default: + return ' '; + } + }; + + return ( +
+ + {lineNumber ?? ''} + + + {getLinePrefix()} + +
+                {change.content || '\u00A0'}
+            
+
+ ); +}; + +const DiffHunkView = ({ hunk, index }: { hunk: DiffHunk; index: number }) => { + const [expanded, setExpanded] = useState(true); + + let oldLineNum = hunk.old_start; + let newLineNum = hunk.new_start; + + return ( +
+ + + {expanded && ( +
+ {hunk.changes.map((change, idx) => { + let lineNum: number | undefined; + + if (change.type === 'deletion') { + lineNum = oldLineNum++; + } else if (change.type === 'addition') { + lineNum = newLineNum++; + } else { + lineNum = newLineNum++; + oldLineNum++; + } + + return ; + })} +
+ )} +
+ ); +}; + +export default ({ diff, filename, className }: Props) => { + const [showDiff, setShowDiff] = useState(false); + const displayFilename = filename || diff.file || 'Unknown file'; + + if (diff.large_file) { + return ( +
+
+
+ {displayFilename} + +
+ File too large for detailed diff +
+
+ ); + } + + if (!diff.hunks || diff.hunks.length === 0) { + return ( +
+
+
+ {displayFilename} + {diff.is_new_file ? ( + New file + ) : ( + + )} +
+
+
+ ); + } + + return ( +
+ + + {showDiff && ( +
+ {diff.hunks.map((hunk, index) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/routes/api-client.php b/routes/api-client.php index 174b5b7d40..dacb0e672c 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -129,6 +129,7 @@ Route::put('/rename', [Client\Servers\FileController::class, 'rename']); Route::post('/copy', [Client\Servers\FileController::class, 'copy']); Route::post('/write', [Client\Servers\FileController::class, 'write']); + Route::post('/write-with-diff', [Client\Servers\FileController::class, 'writeWithDiff']); Route::post('/compress', [Client\Servers\FileController::class, 'compress']); Route::post('/decompress', [Client\Servers\FileController::class, 'decompress']); Route::post('/delete', [Client\Servers\FileController::class, 'delete']); From 4a98de20a0e57fee92ee1b8b81cee15a2580ff3a Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 1 Feb 2026 19:46:39 +0200 Subject: [PATCH 2/2] bug fix file diff service --- app/Services/Files/FileDiffService.php | 281 +++++++++++++++++++------ 1 file changed, 222 insertions(+), 59 deletions(-) diff --git a/app/Services/Files/FileDiffService.php b/app/Services/Files/FileDiffService.php index 2d9c9a9897..a3d7a49800 100644 --- a/app/Services/Files/FileDiffService.php +++ b/app/Services/Files/FileDiffService.php @@ -2,9 +2,6 @@ namespace Everest\Services\Files; -use SebastianBergmann\Diff\Differ; -use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder; - class FileDiffService { /** @@ -31,6 +28,11 @@ class FileDiffService */ public const MAX_DIFF_SIZE = 1048576; + /** + * Maximum number of lines for detailed diff (for performance). + */ + public const MAX_DIFF_LINES = 5000; + /** * Check if a file is a text file based on its extension. */ @@ -60,35 +62,31 @@ public function calculateDiff(string $originalContent, string $newContent, strin $originalLines = explode("\n", $originalContent); $newLines = explode("\n", $newContent); - $builder = new UnifiedDiffOutputBuilder( - "--- a/{$filename}\n+++ b/{$filename}\n", - true - ); - $differ = new Differ($builder); + // If too many lines, skip detailed diff + if (count($originalLines) > self::MAX_DIFF_LINES || count($newLines) > self::MAX_DIFF_LINES) { + return $this->createLargeDiffSummary($originalContent, $newContent); + } - $diff = $differ->diff($originalContent, $newContent); + // Calculate the diff using Myers algorithm (simplified LCS-based approach) + $hunks = $this->computeHunks($originalLines, $newLines); - // Calculate additions and deletions + // Count additions and deletions $additions = 0; $deletions = 0; - - $diffLines = explode("\n", $diff); - foreach ($diffLines as $line) { - if (str_starts_with($line, '+') && !str_starts_with($line, '+++')) { - $additions++; - } elseif (str_starts_with($line, '-') && !str_starts_with($line, '---')) { - $deletions++; + foreach ($hunks as $hunk) { + foreach ($hunk['changes'] as $change) { + if ($change['type'] === 'addition') { + $additions++; + } elseif ($change['type'] === 'deletion') { + $deletions++; + } } } - // Create a summary of changes - $hunks = $this->parseHunks($diff); - return [ 'file' => $filename, 'additions' => $additions, 'deletions' => $deletions, - 'diff' => $diff, 'hunks' => $hunks, 'original_lines' => count($originalLines), 'new_lines' => count($newLines), @@ -107,7 +105,6 @@ private function createLargeDiffSummary(string $originalContent, string $newCont return [ 'additions' => max(0, $newLines - $originalLines), 'deletions' => max(0, $originalLines - $newLines), - 'diff' => null, 'hunks' => [], 'original_lines' => $originalLines, 'new_lines' => $newLines, @@ -116,62 +113,228 @@ private function createLargeDiffSummary(string $originalContent, string $newCont } /** - * Parse diff output into hunks for easier frontend rendering. + * Compute diff hunks between two arrays of lines. */ - private function parseHunks(string $diff): array + private function computeHunks(array $oldLines, array $newLines): array { - $lines = explode("\n", $diff); - $hunks = []; - $currentHunk = null; + $lcs = $this->longestCommonSubsequence($oldLines, $newLines); + $changes = $this->buildChangeList($oldLines, $newLines, $lcs); + + return $this->groupChangesIntoHunks($changes, $oldLines, $newLines); + } - foreach ($lines as $line) { - // Skip header lines - if (str_starts_with($line, '---') || str_starts_with($line, '+++')) { - continue; + /** + * Compute the Longest Common Subsequence between two arrays. + */ + private function longestCommonSubsequence(array $old, array $new): array + { + $oldLen = count($old); + $newLen = count($new); + + // Build LCS length table + $lengths = array_fill(0, $oldLen + 1, array_fill(0, $newLen + 1, 0)); + + for ($i = 1; $i <= $oldLen; $i++) { + for ($j = 1; $j <= $newLen; $j++) { + if ($old[$i - 1] === $new[$j - 1]) { + $lengths[$i][$j] = $lengths[$i - 1][$j - 1] + 1; + } else { + $lengths[$i][$j] = max($lengths[$i - 1][$j], $lengths[$i][$j - 1]); + } } + } - // New hunk - if (preg_match('/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/', $line, $matches)) { - if ($currentHunk !== null) { - $hunks[] = $currentHunk; - } + // Backtrack to find LCS with positions + $lcs = []; + $i = $oldLen; + $j = $newLen; + + while ($i > 0 && $j > 0) { + if ($old[$i - 1] === $new[$j - 1]) { + array_unshift($lcs, ['old' => $i - 1, 'new' => $j - 1, 'line' => $old[$i - 1]]); + $i--; + $j--; + } elseif ($lengths[$i - 1][$j] > $lengths[$i][$j - 1]) { + $i--; + } else { + $j--; + } + } + + return $lcs; + } + + /** + * Build a list of changes (additions, deletions, unchanged) from the LCS. + */ + private function buildChangeList(array $oldLines, array $newLines, array $lcs): array + { + $changes = []; + $oldIdx = 0; + $newIdx = 0; + $lcsIdx = 0; - $currentHunk = [ - 'old_start' => (int) $matches[1], - 'old_lines' => isset($matches[2]) ? (int) $matches[2] : 1, - 'new_start' => (int) $matches[3], - 'new_lines' => isset($matches[4]) ? (int) $matches[4] : 1, - 'context' => trim($matches[5] ?? ''), - 'changes' => [], + while ($oldIdx < count($oldLines) || $newIdx < count($newLines)) { + if ($lcsIdx < count($lcs)) { + $lcsItem = $lcs[$lcsIdx]; + + // Add deletions (lines in old but not in LCS) + while ($oldIdx < $lcsItem['old']) { + $changes[] = [ + 'type' => 'deletion', + 'content' => $oldLines[$oldIdx], + 'old_line' => $oldIdx + 1, + 'new_line' => null, + ]; + $oldIdx++; + } + + // Add additions (lines in new but not in LCS) + while ($newIdx < $lcsItem['new']) { + $changes[] = [ + 'type' => 'addition', + 'content' => $newLines[$newIdx], + 'old_line' => null, + 'new_line' => $newIdx + 1, + ]; + $newIdx++; + } + + // Add unchanged line + $changes[] = [ + 'type' => 'context', + 'content' => $lcsItem['line'], + 'old_line' => $oldIdx + 1, + 'new_line' => $newIdx + 1, ]; - continue; + $oldIdx++; + $newIdx++; + $lcsIdx++; + } else { + // Handle remaining lines after LCS is exhausted + while ($oldIdx < count($oldLines)) { + $changes[] = [ + 'type' => 'deletion', + 'content' => $oldLines[$oldIdx], + 'old_line' => $oldIdx + 1, + 'new_line' => null, + ]; + $oldIdx++; + } + while ($newIdx < count($newLines)) { + $changes[] = [ + 'type' => 'addition', + 'content' => $newLines[$newIdx], + 'old_line' => null, + 'new_line' => $newIdx + 1, + ]; + $newIdx++; + } } + } - if ($currentHunk !== null) { - $type = 'context'; - $content = $line; - - if (str_starts_with($line, '+')) { - $type = 'addition'; - $content = substr($line, 1); - } elseif (str_starts_with($line, '-')) { - $type = 'deletion'; - $content = substr($line, 1); - } elseif (str_starts_with($line, ' ')) { - $content = substr($line, 1); - } + return $changes; + } + /** + * Group changes into hunks with context lines. + */ + private function groupChangesIntoHunks(array $changes, array $oldLines, array $newLines, int $contextLines = 3): array + { + if (empty($changes)) { + return []; + } + + $hunks = []; + $currentHunk = null; + $lastChangeIdx = -1; + + foreach ($changes as $idx => $change) { + $isChange = $change['type'] !== 'context'; + + if ($isChange) { + if ($currentHunk === null) { + // Start new hunk with context before + $contextStart = max(0, $idx - $contextLines); + $currentHunk = [ + 'old_start' => null, + 'old_lines' => 0, + 'new_start' => null, + 'new_lines' => 0, + 'context' => '', + 'changes' => [], + ]; + + // Add context lines before the change + for ($i = $contextStart; $i < $idx; $i++) { + $ctx = $changes[$i]; + if ($currentHunk['old_start'] === null && $ctx['old_line'] !== null) { + $currentHunk['old_start'] = $ctx['old_line']; + } + if ($currentHunk['new_start'] === null && $ctx['new_line'] !== null) { + $currentHunk['new_start'] = $ctx['new_line']; + } + $currentHunk['changes'][] = [ + 'type' => 'context', + 'content' => $ctx['content'], + ]; + if ($ctx['old_line'] !== null) $currentHunk['old_lines']++; + if ($ctx['new_line'] !== null) $currentHunk['new_lines']++; + } + } + + // Set start positions if not set + if ($currentHunk['old_start'] === null) { + $currentHunk['old_start'] = $change['old_line'] ?? 1; + } + if ($currentHunk['new_start'] === null) { + $currentHunk['new_start'] = $change['new_line'] ?? 1; + } + + // Add the change $currentHunk['changes'][] = [ - 'type' => $type, - 'content' => $content, + 'type' => $change['type'], + 'content' => $change['content'], ]; + if ($change['type'] === 'deletion') { + $currentHunk['old_lines']++; + } elseif ($change['type'] === 'addition') { + $currentHunk['new_lines']++; + } + + $lastChangeIdx = $idx; + } elseif ($currentHunk !== null) { + // Context line after a change + $distanceFromLastChange = $idx - $lastChangeIdx; + + if ($distanceFromLastChange <= $contextLines * 2) { + // Within context range, add to current hunk + $currentHunk['changes'][] = [ + 'type' => 'context', + 'content' => $change['content'], + ]; + $currentHunk['old_lines']++; + $currentHunk['new_lines']++; + } else { + // Too far from last change, close current hunk + // But first add trailing context + $hunks[] = $currentHunk; + $currentHunk = null; + } } } + // Don't forget the last hunk if ($currentHunk !== null) { $hunks[] = $currentHunk; } + // Ensure all hunks have valid start positions + foreach ($hunks as &$hunk) { + if ($hunk['old_start'] === null) $hunk['old_start'] = 1; + if ($hunk['new_start'] === null) $hunk['new_start'] = 1; + } + return $hunks; } }