Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- test: migrate JavaScript unit and mutation tests to Vitest with isolated workers, V8 coverage, per-test mutation analysis, and supported Node.js release ranges.
- feat(ui): expose captured trace totals and style clickable Log severity counters as filter pills alongside History status shortcuts.
- refactor(ui): unify Request, Server, Session, Input, routing, and tabs with collapsible filters, focus states, and refreshed assets.
- feat(api): centralize typed structural payload differences and add immutable fluent toolbar item and panel construction while preserving diagnostic values, constructors, and serialized payloads.
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,39 @@ supports `ArrowUp`, `ArrowDown`, `Home`, and `End` on its resize separator.
## License

The package is released under the BSD-3-Clause license. See `LICENSE`.

## Fluent toolbar models

`ToolbarItem::create($value)` and `ToolbarPanel::create($id, $title)` start immutable configuration chains.
Their existing constructors and public readonly properties remain supported, including named arguments.

```php
use PHPForge\Debug\Toolbar\{ToolbarItem, ToolbarPanel};

$item = ToolbarItem::create('200')
->withId('status')
->withLabel('Status')
->withStatus('success')
->withTitle('Status code: 200 OK');
$panel = ToolbarPanel::create('request', 'Request')
->withIcon('request')
->withUrl('/debug/view?tag=request-1&panel=request')
->withItems([$item]);
```

Items offer `withId()`, `withLabel()`, `withIcon()`, `withStatus()`, `withTitle()`, and `withUrl()`.
Panels offer `withIcon()`, `withUrl()`, and `withItems()`. Every method returns a new instance and preserves all other
fields. Nullable options accept `null` to remove the field from JSON; `''` and `'0'` remain present. `withItems()` replaces
rather than appends metrics, preserves their order, and accepts `[]` to clear them. The default item status remains
`default`; the default panel metric list remains empty. Serialization and escaping responsibilities are unchanged.

## Structural payload comparison

`PHPForge\Debug\Comparison\PayloadDifference::between($baseline, $target)` returns an immutable result with four integer
properties: `added`, `removed`, `changed`, and `unchanged`. Arguments are captured payload arrays, or `null` for absence.
An empty array is a captured leaf, not absence. Nested `null`, `false`, integer zero, float zero, and string zero remain
distinct. Leaf paths escape `~` and `/`; list positions matter, while map insertion order does not affect the counts.

The comparison fingerprints typed leaves temporarily and retains only counts in its result. It does not alter or redact
the source payloads. Adapters retain responsibility for capture/failure precedence, state-only changes, panel ordering,
labels, and metric presentation. See the [architecture review](docs/architecture-review.md) for boundaries and follow-up work.
98 changes: 98 additions & 0 deletions src/Comparison/PayloadDifference.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace PHPForge\Debug\Comparison;

use function array_diff_key;
use function array_key_exists;
use function count;
use function hash;
use function is_array;
use function serialize;
use function str_replace;

/**
* Counts structural payload differences without retaining diagnostic values or their fingerprints.
*/
final readonly class PayloadDifference
{
private function __construct(
public int $added,
public int $removed,
public int $changed,
public int $unchanged,
) {}

/**
* Compares typed leaves at escaped paths, treating an empty array as a leaf and `null` as an absent payload.
*
* @param array<string, mixed>|null $baseline Baseline payload, or `null` when not captured.
* @param array<string, mixed>|null $target Target payload, or `null` when not captured.
*/
public static function between(array|null $baseline, array|null $target): self
{
$baselineLeaves = self::flatten($baseline);
$targetLeaves = self::flatten($target);

$changed = 0;
$unchanged = 0;

foreach ($baselineLeaves as $path => $baselineValue) {
if (!array_key_exists($path, $targetLeaves)) {
continue;
}

if ($baselineValue === $targetLeaves[$path]) {
++$unchanged;
} else {
++$changed;
}
}

return new self(
count(array_diff_key($targetLeaves, $baselineLeaves)),
count(array_diff_key($baselineLeaves, $targetLeaves)),
$changed,
$unchanged,
);
}

/**
* @param array<string, mixed>|null $payload
*
* @return array<string, string>
*/
private static function flatten(array|null $payload): array
{
$leaves = [];

if ($payload !== null) {
self::flattenValue($payload, '$', $leaves);
}

return $leaves;
}

/**
* @param array<string, string> $leaves
*/
private static function flattenValue(mixed $value, string $path, array &$leaves): void
{
if (is_array($value)) {
if ($value === []) {
$leaves[$path] = 'array:[]';
}

foreach ($value as $key => $child) {
$segment = str_replace(['~', '/'], ['~0', '~1'], (string) $key);

self::flattenValue($child, "{$path}/{$segment}", $leaves);
}

return;
}

$leaves[$path] = hash('sha256', serialize($value));
}
}
114 changes: 112 additions & 2 deletions src/Toolbar/ToolbarItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,25 @@ public function __construct(
public string|null $id = null,
) {}

/**
* Creates a metric with default presentation options.
*/
public static function create(string $value): self
{
return new self($value);
}

/**
* Returns the metric payload consumed by the toolbar runtime.
*
* @return array{value: string, status: string, label?: string, icon?: string, title?: string, url?: string,
* id?: string} Serialized metric payload.
* @return array{
* value: string,
* status: string,
* label?: string,
* icon?: string,
* title?: string, url?: string,
* id?: string
* } Serialized metric payload.
*/
public function jsonSerialize(): array
{
Expand All @@ -53,4 +67,100 @@ public function jsonSerialize(): array
static fn(string|null $value): bool => $value !== null,
);
}

/**
* Returns a copy with the specified icon.
*/
public function withIcon(string|null $icon): self
{
return new self(
value: $this->value,
label: $this->label,
icon: $icon,
status: $this->status,
title: $this->title,
url: $this->url,
id: $this->id,
);
}

/**
* Returns a copy with the specified metric ID.
*/
public function withId(string|null $id): self
{
return new self(
value: $this->value,
label: $this->label,
icon: $this->icon,
status: $this->status,
title: $this->title,
url: $this->url,
id: $id,
);
}

/**
* Returns a copy with the specified label.
*/
public function withLabel(string|null $label): self
{
return new self(
value: $this->value,
label: $label,
icon: $this->icon,
status: $this->status,
title: $this->title,
url: $this->url,
id: $this->id,
);
}

/**
* Returns a copy with the specified status.
*/
public function withStatus(string $status): self
{
return new self(
value: $this->value,
label: $this->label,
icon: $this->icon,
status: $status,
title: $this->title,
url: $this->url,
id: $this->id,
);
}

/**
* Returns a copy with the specified title.
*/
public function withTitle(string|null $title): self
{
return new self(
value: $this->value,
label: $this->label,
icon: $this->icon,
status: $this->status,
title: $title,
url: $this->url,
id: $this->id,
);
}

/**
* Returns a copy with the specified URL.
*/
public function withUrl(string|null $url): self
{
return new self(
value: $this->value,
label: $this->label,
icon: $this->icon,
status: $this->status,
title: $this->title,
url: $url,
id: $this->id,
);
}
}
52 changes: 52 additions & 0 deletions src/Toolbar/ToolbarPanel.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ public function __construct(
public array $items = [],
) {}

/**
* Creates a panel with no metrics or optional navigation.
*/
public static function create(string $id, string $title): self
{
return new self($id, $title);
}

/**
* Returns the panel payload consumed by the toolbar runtime.
*
Expand Down Expand Up @@ -57,4 +65,48 @@ public function jsonSerialize(): array
static fn(mixed $value): bool => $value !== null,
);
}

/**
* Returns a copy with the specified icon.
*/
public function withIcon(string|null $icon): self
{
return new self(
id: $this->id,
title: $this->title,
url: $this->url,
icon: $icon,
items: $this->items,
);
}

/**
* Returns a copy with the replacement metric list.
*
* @param list<ToolbarItem> $items Panel metrics in display order; `[]` removes all metrics.
*/
public function withItems(array $items): self
{
return new self(
id: $this->id,
title: $this->title,
url: $this->url,
icon: $this->icon,
items: $items,
);
}

/**
* Returns a copy with the specified URL.
*/
public function withUrl(string|null $url): self
{
return new self(
id: $this->id,
title: $this->title,
url: $url,
icon: $this->icon,
items: $this->items,
);
}
}
Loading
Loading