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 @@ -36,3 +36,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- fix(ui): show short class names with full hover titles, standardize Timeline dots and bars on one blue token, and remove the redundant category legend.
- 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.
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,35 @@ Current adapters:
- `yii2-extensions/debug`
- `yii3/debug`

## Request view models

Request models keep only identity data in their constructors and `::create()` factories. Use the factories to start
fluent chains without wrapping `new` in parentheses. Optional metadata is configured with `with...` methods
that return independent copies; retain the returned object or chain the calls. Read values through `get...` methods
and use `RouteInventoryView::isLive()` for inventory provenance.

```php
use PHPForge\Debug\Panel\Request\RequestHero;
use PHPForge\Debug\Panel\Request\Routing\{CurrentRouteView, RouteDefinition, RouteInventoryView};

$definition = RouteDefinition::create('orders', '/orders/{id}')
->withMethods(['GET'])
->withAction('App\\OrderAction');
$current = CurrentRouteView::create('orders')
->withDefinition($definition)
->withParameters(['id' => 42]);
$inventory = RouteInventoryView::create([$definition])
->withSource('Captured configuration')
->withLive(false);
$hero = RequestHero::create('GET', '/orders/42')
->withStatus(200, '2xx')
->withTiming('12:00:00', '3.5 ms');
```

Migration: optional constructor arguments and public properties on these four models have been replaced by the
immutable methods and getters. `RouteDefinition::fromArray()` and `toArray()` retain the existing capture schema,
including the distinction between unavailable middleware metadata (`null`) and no middleware (`[]`).

## Frontend development

The complete frontend source lives in `resources/src`. Vite produces the full-page stylesheet and runtime together
Expand Down
2 changes: 1 addition & 1 deletion resources/assets/dist/css/debug.min.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion resources/assets/dist/js/debug.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion resources/assets/dist/js/toolbar.min.js

Large diffs are not rendered by default.

38 changes: 16 additions & 22 deletions resources/src/core/debug.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { bindCopyControls } from "./clipboard.js";
import { initSectionPermalinks } from "./deep-links.js";
import { dropdownNavigationIndex } from "./dropdown.js";
import { loadPanelFeatures } from "./features.js";
import {
applyLiveFilter,
findLiveFilterTarget,
LIVE_FILTER_ANCHOR_SELECTOR,
} from "./live-filter.js";
import { initTabs } from "./tabs.js";
import {
bindThemeToggle,
Expand Down Expand Up @@ -158,29 +163,18 @@ import { requestParentToolbarDrawerClose } from "../toolbar/focus.js";
}

function updateLiveFilter(input) {
var anchor = input.closest("header, .yii-debug-section-header") || input;
var target = anchor.nextElementSibling;

while (target && !target.matches("[data-yii-debug-filter-target]")) {
target = target.nextElementSibling;
}
var target = findLiveFilterTarget(input);

if (!target) {
return;
}

var rows = target.querySelectorAll("tbody tr");
var query = input.value.trim().toLowerCase();
var visible = 0;
var anchor =
input.closest(LIVE_FILTER_ANCHOR_SELECTOR) ||
input.parentElement ||
input;

for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var matched =
query === "" || row.textContent.toLowerCase().indexOf(query) !== -1;

row.hidden = !matched;
visible += matched ? 1 : 0;
}
var result = applyLiveFilter(target, input.value);

var status = anchor.querySelector("[data-yii-debug-filter-status]");

Expand All @@ -207,7 +201,8 @@ import { requestParentToolbarDrawerClose } from "../toolbar/focus.js";
}
}

status.textContent = visible + " of " + rows.length + " rows shown.";
status.textContent =
result.visible + " of " + result.total + " " + result.unit + " shown.";
}

function hideDropdowns(except) {
Expand Down Expand Up @@ -417,10 +412,9 @@ import { requestParentToolbarDrawerClose } from "../toolbar/focus.js";
window.location.href = url.toString();
});

// Live filter for tabular sections marked with [data-yii-debug-filter].
// The input filters its sibling [data-yii-debug-filter-target] table rows by
// case-insensitive substring against the row's text content. Hiding rows
// client-side is cheap and avoids round-trips for >100-header request panels.
// Live filter for tabular and grouped diagnostic sections.
// The input filters its associated target rows by a case-insensitive
// substring. Hiding rows client-side avoids a round-trip for dense panels.
document.addEventListener("input", function (event) {
var input = event.target;

Expand Down
5 changes: 4 additions & 1 deletion resources/src/core/deep-links.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,10 @@ export function revealDeepLink(root, locationValue, scroll) {
: null;
}

target.classList.add("yii-debug-deep-link-target");
// Tabs already indicate selection; highlighting the whole panel looks like stuck focus.
if (!target.getAttribute || target.getAttribute("role") !== "tabpanel") {
target.classList.add("yii-debug-deep-link-target");
}

if (scroll && typeof target.scrollIntoView === "function") {
target.scrollIntoView({ block: "start" });
Expand Down
113 changes: 113 additions & 0 deletions resources/src/core/live-filter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
export const LIVE_FILTER_ROW_SELECTOR = "[data-yii-debug-filter-row], tbody tr";
export const LIVE_FILTER_ANCHOR_SELECTOR =
"header, .yii-debug-section-header, .yii-debug-mini-toolbar";
const FILTER_DEFAULT_OPEN_ATTRIBUTE = "data-yii-debug-filter-default-open";
const FILTER_PREVIOUS_OPEN_ATTRIBUTE = "data-yii-debug-filter-previous-open";
const FILTER_TARGET_SELECTOR = "[data-yii-debug-filter-target]";

/**
* Finds the target controlled by a live-filter input.
*
* Most filters sit in a header immediately before their target. Disclosure
* filters instead share a body with the table they control.
*/
export function findLiveFilterTarget(input) {
var scope = input.closest(
"[data-yii-debug-filter-scope], .yii-debug-disclosure-body",
);

if (scope) {
var scopedTarget = scope.querySelector(FILTER_TARGET_SELECTOR);

if (scopedTarget) {
return scopedTarget;
}
}

var anchor = input.closest(LIVE_FILTER_ANCHOR_SELECTOR) || input;
var target = anchor.nextElementSibling;

while (target && !target.matches(FILTER_TARGET_SELECTOR)) {
target = target.nextElementSibling;
}

return target;
}

/**
* Applies one query to every searchable row in a diagnostic target.
*
* Group visibility and disclosure state are derived from the filtered rows so
* a single control can search split header lanes and collapsed server groups.
*/
export function applyLiveFilter(target, value) {
var rows = target.querySelectorAll(LIVE_FILTER_ROW_SELECTOR);
var query = value.trim().toLowerCase();
var visible = 0;

for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var matched =
query === "" || row.textContent.toLowerCase().indexOf(query) !== -1;

row.hidden = !matched;
visible += matched ? 1 : 0;
}

var groups = target.querySelectorAll("[data-yii-debug-filter-group]");

for (var groupIndex = 0; groupIndex < groups.length; groupIndex++) {
var group = groups[groupIndex];
var groupRows = group.querySelectorAll(LIVE_FILTER_ROW_SELECTOR);
var groupHasMatch = false;

for (var rowIndex = 0; rowIndex < groupRows.length; rowIndex++) {
if (!groupRows[rowIndex].hidden) {
groupHasMatch = true;
break;
}
}

group.hidden = query !== "" && !groupHasMatch;
}

var details = target.querySelectorAll("[data-yii-debug-filter-details]");

for (var detailsIndex = 0; detailsIndex < details.length; detailsIndex++) {
var disclosure = details[detailsIndex];
var previousOpen = disclosure.getAttribute(FILTER_PREVIOUS_OPEN_ATTRIBUTE);

if (query === "") {
disclosure.open =
(previousOpen ??
disclosure.getAttribute(FILTER_DEFAULT_OPEN_ATTRIBUTE)) === "true";

if (previousOpen !== null) {
disclosure.removeAttribute(FILTER_PREVIOUS_OPEN_ATTRIBUTE);
}
} else {
if (previousOpen === null) {
disclosure.setAttribute(
FILTER_PREVIOUS_OPEN_ATTRIBUTE,
disclosure.open ? "true" : "false",
);
}

if (!disclosure.hidden) {
disclosure.open = true;
}
}
}

var empty = target.querySelector("[data-yii-debug-filter-empty]");

if (empty) {
empty.hidden = query === "" || visible > 0;
}

return {
total: rows.length,
unit: target.getAttribute("data-yii-debug-filter-unit") || "rows",
visible,
};
}
Loading
Loading