diff --git a/src/Framework/ExceptionHandling/Renderers/ConsoleErrorRenderer.php b/src/Framework/ExceptionHandling/Renderers/ConsoleErrorRenderer.php index d53224eb..aa2de668 100644 --- a/src/Framework/ExceptionHandling/Renderers/ConsoleErrorRenderer.php +++ b/src/Framework/ExceptionHandling/Renderers/ConsoleErrorRenderer.php @@ -55,18 +55,28 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer ConsoleColor::RED ); - if ($exception->getPrevious()) { + $previous = $exception->getPrevious(); + while ($previous !== null) { $this->output->writeErrorLine( - " Caused by: " . $exception->getPrevious()->getMessage(), + " Caused by: " . get_class($previous) . ": " . $previous->getMessage(), ConsoleColor::YELLOW ); + $previous = $previous->getPrevious(); } $this->output->writeErrorLine(" Stack trace:", ConsoleColor::GRAY); $stackTrace = StackTrace::fromThrowable($exception); - foreach ($stackTrace->getItems() as $index => $item) { - $formattedLine = $this->formatStackTraceLine($index, $item); - $this->output->writeErrorLine(" " . $formattedLine, ConsoleColor::GRAY); + $items = $stackTrace->getItems(); + $indexWidth = strlen((string) max(count($items) - 1, 0)); + + foreach ($items as $index => $item) { + $formattedLine = $this->formatStackTraceLine($index, $item, $indexWidth); + $color = $item->isVendorFrame() ? ConsoleColor::GRAY : ConsoleColor::WHITE; + if ($index === 0) { + $color = ConsoleColor::BRIGHT_WHITE; + } + + $this->output->writeErrorLine(" " . $formattedLine, $color); } // Context information if available @@ -110,9 +120,10 @@ final readonly class ConsoleErrorRenderer implements ErrorRenderer /** * Format stack trace line with clickable file links */ - private function formatStackTraceLine(int $index, \App\Framework\ExceptionHandling\ValueObjects\StackItem $item): string + private function formatStackTraceLine(int $index, \App\Framework\ExceptionHandling\ValueObjects\StackItem $item, int $indexWidth): string { - $baseFormat = sprintf('#%d %s', $index, $item->formatForDisplay()); + $indexLabel = str_pad((string) $index, $indexWidth, ' ', STR_PAD_LEFT); + $baseFormat = sprintf('#%s %s', $indexLabel, $item->formatForDisplay()); // If PhpStorm is detected, replace file:line with clickable link if (!$this->isPhpStorm()) { diff --git a/src/Framework/ExceptionHandling/Renderers/ResponseErrorRenderer.php b/src/Framework/ExceptionHandling/Renderers/ResponseErrorRenderer.php index 3b95c820..52f482de 100644 --- a/src/Framework/ExceptionHandling/Renderers/ResponseErrorRenderer.php +++ b/src/Framework/ExceptionHandling/Renderers/ResponseErrorRenderer.php @@ -69,6 +69,7 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer ?ExceptionContextProvider $contextProvider ): HttpResponse { $statusCode = $this->getHttpStatusCode($exception); + $stackTrace = StackTrace::fromThrowable($exception); // Get user-friendly message if translator is available $context = $contextProvider?->get($exception); @@ -98,7 +99,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer if ($this->isDebugMode) { $errorData['error']['file'] = $exception->getFile(); $errorData['error']['line'] = $exception->getLine(); - $errorData['error']['trace'] = StackTrace::fromThrowable($exception)->toArray(); + $errorData['error']['trace'] = $stackTrace->toArray(); + $errorData['error']['trace_short'] = $stackTrace->formatShort(); // Add context from WeakMap if available if ($contextProvider !== null) { @@ -236,6 +238,8 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer 'file' => $exception->getFile(), 'line' => $exception->getLine(), 'trace' => $stackTrace->formatForHtml(), + 'trace_short' => $stackTrace->formatShort(), + 'trace_frames' => $stackTrace->toArray(), ]; // Add context from WeakMap if available @@ -332,6 +336,65 @@ final readonly class ResponseErrorRenderer implements ErrorRenderer font-weight: bold; color: #666; } + .stack-trace { + margin-top: 1rem; + } + .stack-trace__header { + display: flex; + align-items: center; + gap: 1rem; + } + .stack-trace__list { + margin-top: 0.5rem; + border: 1px solid #e1e3e6; + border-radius: 6px; + background: #fff; + } + .stack-frame { + border-bottom: 1px solid #f0f0f0; + padding: 0.5rem 0.75rem; + } + .stack-frame:last-child { + border-bottom: none; + } + .stack-frame__index { + display: inline-block; + min-width: 32px; + color: #666; + } + .stack-frame__call { + font-weight: 600; + } + .stack-frame__location { + color: #666; + margin-left: 0.5rem; + } + .stack-frame__code { + margin-top: 0.5rem; + } + .stack-frame[open] summary { + font-weight: 600; + } + .stack-frame summary { + list-style: none; + cursor: pointer; + } + .stack-frame summary::-webkit-details-marker { display:none; } + .stack-frame--vendor { + opacity: 0.8; + background: #fafbfc; + } + .btn-copy { + border: 1px solid #d0d4da; + background: #fff; + padding: 0.25rem 0.75rem; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + } + .btn-copy:hover { + background: #eef1f5; + }
@@ -360,6 +423,9 @@ HTML; $line = $exception->getLine(); $stackTrace = StackTrace::fromThrowable($exception); $trace = $stackTrace->formatForHtml(); // Already HTML-encoded in formatForHtml + $traceShort = htmlspecialchars($stackTrace->formatShort(), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $tracePlain = htmlspecialchars($stackTrace->formatForConsole(), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $renderedFrames = $this->renderStackTraceList($stackTrace); $contextHtml = ''; if ($contextProvider !== null) { @@ -399,11 +465,41 @@ HTML;{$trace}
+ {$trace}
+
+