@praxisjs/devtools 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/assets/logo.svg +9 -0
- package/assets/uno.generated.css +205 -0
- package/dist/index.d.ts +135 -0
- package/dist/index.js +1051 -0
- package/package.json +35 -0
- package/src/core/event-bus.ts +19 -0
- package/src/core/registry.ts +219 -0
- package/src/core/types.ts +45 -0
- package/src/decorators/debug.ts +161 -0
- package/src/decorators/index.ts +3 -0
- package/src/decorators/trace.ts +64 -0
- package/src/icons/ellipsis-vertical.tsx +20 -0
- package/src/icons/index.ts +6 -0
- package/src/icons/panel-bottom.tsx +19 -0
- package/src/icons/panel-left.tsx +19 -0
- package/src/icons/panel-right.tsx +19 -0
- package/src/icons/panel-top.tsx +19 -0
- package/src/icons/x.tsx +19 -0
- package/src/index.ts +20 -0
- package/src/plugins/components/components/component-detail.tsx +70 -0
- package/src/plugins/components/components/component-row.tsx +42 -0
- package/src/plugins/components/components/detail-row.tsx +22 -0
- package/src/plugins/components/components/detail-section.tsx +18 -0
- package/src/plugins/components/components/status-dot.tsx +14 -0
- package/src/plugins/components/components-tab.tsx +85 -0
- package/src/plugins/components/index.ts +9 -0
- package/src/plugins/signals/components/signal-detail.tsx +35 -0
- package/src/plugins/signals/components/signal-row.tsx +37 -0
- package/src/plugins/signals/index.ts +9 -0
- package/src/plugins/signals/signals-tab.tsx +99 -0
- package/src/plugins/timeline/components/badge.tsx +14 -0
- package/src/plugins/timeline/components/timeline-row.tsx +55 -0
- package/src/plugins/timeline/constants.ts +24 -0
- package/src/plugins/timeline/index.ts +9 -0
- package/src/plugins/timeline/timeline-tab.tsx +101 -0
- package/src/plugins/types.ts +10 -0
- package/src/ui/dev-tools.tsx +121 -0
- package/src/ui/panel.tsx +225 -0
- package/src/ui/shared/empty-state.tsx +12 -0
- package/src/ui/shared/icon-button.tsx +30 -0
- package/src/ui/shared/panel-section.tsx +18 -0
- package/src/ui/shared/search-input.tsx +18 -0
- package/src/ui/shared/side-panel.tsx +18 -0
- package/src/utils/format-time.ts +7 -0
- package/src/utils/format-value.ts +13 -0
- package/src/vite-env.d.ts +6 -0
- package/tsconfig.json +21 -0
- package/uno.config.ts +57 -0
- package/vite.config.ts +42 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { DevTools } from "@/ui/dev-tools";
|
|
2
|
+
export type { DevToolsOptions } from "@/ui/dev-tools";
|
|
3
|
+
|
|
4
|
+
export { Debug, Trace } from "@/decorators";
|
|
5
|
+
export type { DebugOptions } from "@/decorators/debug";
|
|
6
|
+
|
|
7
|
+
export { Registry } from "@/core/registry";
|
|
8
|
+
export type {
|
|
9
|
+
SignalEntry,
|
|
10
|
+
ComponentEntry,
|
|
11
|
+
TimelineEntry,
|
|
12
|
+
HistoryEntry,
|
|
13
|
+
LifecycleEvent,
|
|
14
|
+
TimelineEventType,
|
|
15
|
+
} from "@/core/types";
|
|
16
|
+
export type { DevtoolsPlugin } from "@/plugins/types";
|
|
17
|
+
|
|
18
|
+
export { SignalsPlugin } from "@/plugins/signals";
|
|
19
|
+
export { ComponentsPlugin } from "@/plugins/components";
|
|
20
|
+
export { TimelinePlugin } from "@/plugins/timeline";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { SidePanel } from "@shared/side-panel";
|
|
2
|
+
import { formatValue } from "@utils/format-value";
|
|
3
|
+
|
|
4
|
+
import { DetailRow } from "./detail-row";
|
|
5
|
+
import { DetailSection } from "./detail-section";
|
|
6
|
+
import { StatusDot } from "./status-dot";
|
|
7
|
+
|
|
8
|
+
import type { ComponentEntry, SignalEntry } from "@core/types";
|
|
9
|
+
|
|
10
|
+
export function ComponentDetail({
|
|
11
|
+
entry,
|
|
12
|
+
signals,
|
|
13
|
+
}: {
|
|
14
|
+
entry: ComponentEntry;
|
|
15
|
+
signals: SignalEntry[];
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<SidePanel>
|
|
19
|
+
<div class="px-3 py-2 border-b border-border flex items-center gap-2 bg-bg shrink-0">
|
|
20
|
+
<StatusDot status={entry.status} />
|
|
21
|
+
<span class="text-accent font-mono text-[11px] font-semibold truncate pl-1">
|
|
22
|
+
<{entry.name}>
|
|
23
|
+
</span>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<div class="flex-1 overflow-y-auto">
|
|
27
|
+
<DetailSection label="Stats">
|
|
28
|
+
<DetailRow k="renders" v={String(entry.renderCount)} />
|
|
29
|
+
<DetailRow
|
|
30
|
+
k="last render"
|
|
31
|
+
v={`${entry.lastRenderDuration.toFixed(3)}ms`}
|
|
32
|
+
/>
|
|
33
|
+
<DetailRow k="status" v={entry.status} />
|
|
34
|
+
</DetailSection>
|
|
35
|
+
|
|
36
|
+
{signals.length > 0 && (
|
|
37
|
+
<DetailSection label="State">
|
|
38
|
+
{signals.map((s) => (
|
|
39
|
+
<DetailRow
|
|
40
|
+
key={s.id}
|
|
41
|
+
k={s.label}
|
|
42
|
+
v={formatValue(s.value)}
|
|
43
|
+
signal
|
|
44
|
+
/>
|
|
45
|
+
))}
|
|
46
|
+
</DetailSection>
|
|
47
|
+
)}
|
|
48
|
+
|
|
49
|
+
{entry.lifecycle.length > 0 && (
|
|
50
|
+
<DetailSection label="Lifecycle">
|
|
51
|
+
{[...entry.lifecycle]
|
|
52
|
+
.reverse()
|
|
53
|
+
.slice(0, 20)
|
|
54
|
+
.map((ev, i) => (
|
|
55
|
+
<div
|
|
56
|
+
key={String(i)}
|
|
57
|
+
class="px-3 py-2 flex justify-between items-center border-b border-border"
|
|
58
|
+
>
|
|
59
|
+
<span class="text-[11px] text-text font-mono">{ev.hook}</span>
|
|
60
|
+
<span class="text-[10px] text-subtle tabular-nums">
|
|
61
|
+
{new Date(ev.timestamp).toLocaleTimeString()}
|
|
62
|
+
</span>
|
|
63
|
+
</div>
|
|
64
|
+
))}
|
|
65
|
+
</DetailSection>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
</SidePanel>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { StatusDot } from "./status-dot";
|
|
2
|
+
|
|
3
|
+
import type { ComponentEntry } from "@core/types";
|
|
4
|
+
|
|
5
|
+
export function ComponentRow({
|
|
6
|
+
entry,
|
|
7
|
+
selected,
|
|
8
|
+
onClick,
|
|
9
|
+
}: {
|
|
10
|
+
entry: ComponentEntry;
|
|
11
|
+
selected: () => boolean;
|
|
12
|
+
onClick: () => void;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
onClick={onClick}
|
|
17
|
+
class={() =>
|
|
18
|
+
`relative flex items-center gap-2 px-3 py-2 cursor-pointer border-b border-border transition-colors duration-100 ${
|
|
19
|
+
selected() ? "bg-selected" : "hover:bg-section"
|
|
20
|
+
}`
|
|
21
|
+
}
|
|
22
|
+
>
|
|
23
|
+
{() =>
|
|
24
|
+
selected() && (
|
|
25
|
+
<span class="absolute left-0 top-0 bottom-0 w-[2px] bg-accent rounded-r" />
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
<StatusDot status={entry.status} />
|
|
29
|
+
<span class="text-accent font-mono text-[11px] flex-1 truncate pl-1">
|
|
30
|
+
<{entry.name}>
|
|
31
|
+
</span>
|
|
32
|
+
<span class="text-muted text-[11px] tabular-nums">
|
|
33
|
+
×{entry.renderCount}
|
|
34
|
+
</span>
|
|
35
|
+
<span
|
|
36
|
+
class={`text-[10px] tabular-nums w-12 text-right ${entry.lastRenderDuration > 16 ? "text-warn" : "text-subtle"}`}
|
|
37
|
+
>
|
|
38
|
+
{entry.lastRenderDuration.toFixed(1)}ms
|
|
39
|
+
</span>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function DetailRow({
|
|
2
|
+
k,
|
|
3
|
+
v,
|
|
4
|
+
signal: isSignal,
|
|
5
|
+
}: {
|
|
6
|
+
k: string;
|
|
7
|
+
v: string;
|
|
8
|
+
signal?: boolean;
|
|
9
|
+
}) {
|
|
10
|
+
return (
|
|
11
|
+
<div class="flex justify-between items-center px-3 py-2 border-b border-border gap-3">
|
|
12
|
+
<span
|
|
13
|
+
class={`text-[11px] font-mono shrink-0 ${isSignal ? "text-accent" : "text-muted"}`}
|
|
14
|
+
>
|
|
15
|
+
{k}
|
|
16
|
+
</span>
|
|
17
|
+
<span class="text-[11px] text-text font-mono truncate text-right">
|
|
18
|
+
{v}
|
|
19
|
+
</span>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Children } from "@praxisjs/shared";
|
|
2
|
+
|
|
3
|
+
export function DetailSection({
|
|
4
|
+
label,
|
|
5
|
+
children,
|
|
6
|
+
}: {
|
|
7
|
+
label: string;
|
|
8
|
+
children: Children;
|
|
9
|
+
}) {
|
|
10
|
+
return (
|
|
11
|
+
<div class="border-b border-border">
|
|
12
|
+
<div class="px-3 py-[4px] text-[9px] text-subtle font-bold tracking-[0.12em] uppercase bg-section border-b border-border">
|
|
13
|
+
{label}
|
|
14
|
+
</div>
|
|
15
|
+
{children}
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ComponentEntry } from "@core/types";
|
|
2
|
+
|
|
3
|
+
export function StatusDot({ status }: { status: ComponentEntry["status"] }) {
|
|
4
|
+
return (
|
|
5
|
+
<span
|
|
6
|
+
class={`inline-block w-[6px] h-[6px] rounded-full shrink-0 ${status === "mounted" ? "bg-success" : "bg-subtle"}`}
|
|
7
|
+
style={
|
|
8
|
+
status === "mounted"
|
|
9
|
+
? { boxShadow: "0 0 6px rgba(14,165,122,0.7)" }
|
|
10
|
+
: undefined
|
|
11
|
+
}
|
|
12
|
+
/>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { EmptyState } from "@shared/empty-state";
|
|
2
|
+
|
|
3
|
+
import { signal, effect, peek, onMount, onUnmount } from "@praxisjs/core";
|
|
4
|
+
|
|
5
|
+
import { ComponentDetail } from "./components/component-detail";
|
|
6
|
+
import { ComponentRow } from "./components/component-row";
|
|
7
|
+
|
|
8
|
+
import type { Registry } from "@core/registry";
|
|
9
|
+
import type { ComponentEntry, SignalEntry } from "@core/types";
|
|
10
|
+
|
|
11
|
+
export function ComponentsTab({ registry }: { registry: Registry }) {
|
|
12
|
+
const components = signal<ComponentEntry[]>(registry.getComponents());
|
|
13
|
+
const selectedId = signal<string | null>(null);
|
|
14
|
+
const sigs = signal<SignalEntry[]>([]);
|
|
15
|
+
|
|
16
|
+
const stopSigEffect = effect(() => {
|
|
17
|
+
const id = selectedId();
|
|
18
|
+
sigs.set(id ? registry.getSignalsByComponent(id) : []);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
let handlers: Array<() => void> = [];
|
|
22
|
+
onMount(() => {
|
|
23
|
+
const refresh = () => {
|
|
24
|
+
components.set(registry.getComponents());
|
|
25
|
+
const id = peek(selectedId);
|
|
26
|
+
if (id) sigs.set(registry.getSignalsByComponent(id));
|
|
27
|
+
};
|
|
28
|
+
handlers = [
|
|
29
|
+
"component:registered",
|
|
30
|
+
"component:render",
|
|
31
|
+
"component:unmount",
|
|
32
|
+
"lifecycle",
|
|
33
|
+
"signal:registered",
|
|
34
|
+
"signal:changed",
|
|
35
|
+
].map((ev) => registry.bus.on(ev, refresh));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
onUnmount(() => {
|
|
39
|
+
stopSigEffect();
|
|
40
|
+
handlers.forEach((off) => {
|
|
41
|
+
off();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div class="flex h-full overflow-hidden">
|
|
47
|
+
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
|
|
48
|
+
<div class="flex items-center px-3 h-7 text-[9px] text-subtle font-bold tracking-[0.12em] uppercase border-b border-border bg-section gap-2 shrink-0">
|
|
49
|
+
<span class="flex-1">Component</span>
|
|
50
|
+
<span>Renders</span>
|
|
51
|
+
<span class="w-12 text-right">Last</span>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="flex-1 overflow-y-auto">
|
|
55
|
+
{() =>
|
|
56
|
+
components().length === 0 ? (
|
|
57
|
+
<EmptyState message="No components tracked. Add @Trace() to component classes." />
|
|
58
|
+
) : (
|
|
59
|
+
components().map((c) => (
|
|
60
|
+
<ComponentRow
|
|
61
|
+
key={c.id}
|
|
62
|
+
entry={c}
|
|
63
|
+
selected={() => selectedId() === c.id}
|
|
64
|
+
onClick={() => {
|
|
65
|
+
selectedId.update((id) => (id === c.id ? null : c.id));
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
))
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{() => {
|
|
75
|
+
const id = selectedId();
|
|
76
|
+
const entry = id
|
|
77
|
+
? (components().find((c) => c.id === id) ?? null)
|
|
78
|
+
: null;
|
|
79
|
+
return entry ? (
|
|
80
|
+
<ComponentDetail entry={entry} signals={sigs()} />
|
|
81
|
+
) : null;
|
|
82
|
+
}}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { PanelSection } from "@shared/panel-section";
|
|
2
|
+
import { SidePanel } from "@shared/side-panel";
|
|
3
|
+
import { time } from "@utils/format-time";
|
|
4
|
+
import { formatValue } from "@utils/format-value";
|
|
5
|
+
|
|
6
|
+
import type { SignalEntry } from "@core/types";
|
|
7
|
+
|
|
8
|
+
export function SignalDetail({ entry }: { entry: SignalEntry }) {
|
|
9
|
+
return (
|
|
10
|
+
<SidePanel width="260px">
|
|
11
|
+
<PanelSection label="History">
|
|
12
|
+
<div class="overflow-y-auto">
|
|
13
|
+
{[...entry.history].reverse().map((h, i) => (
|
|
14
|
+
<div
|
|
15
|
+
key={String(i)}
|
|
16
|
+
class="px-3 py-2 border-b border-border flex justify-between items-center gap-3"
|
|
17
|
+
>
|
|
18
|
+
<span class="font-mono text-[11px] text-text truncate">
|
|
19
|
+
{formatValue(h.value)}
|
|
20
|
+
</span>
|
|
21
|
+
<span class="text-[10px] text-subtle shrink-0 tabular-nums">
|
|
22
|
+
{time(h.timestamp, "ago")}
|
|
23
|
+
</span>
|
|
24
|
+
</div>
|
|
25
|
+
))}
|
|
26
|
+
{entry.history.length === 0 && (
|
|
27
|
+
<p class="px-3 py-6 text-[11px] text-subtle text-center">
|
|
28
|
+
No history yet.
|
|
29
|
+
</p>
|
|
30
|
+
)}
|
|
31
|
+
</div>
|
|
32
|
+
</PanelSection>
|
|
33
|
+
</SidePanel>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { time } from "@utils/format-time";
|
|
2
|
+
import { formatValue } from "@utils/format-value";
|
|
3
|
+
|
|
4
|
+
import type { SignalEntry } from "@core/types";
|
|
5
|
+
|
|
6
|
+
export function SignalRow({
|
|
7
|
+
entry,
|
|
8
|
+
selected,
|
|
9
|
+
onClick,
|
|
10
|
+
}: {
|
|
11
|
+
entry: SignalEntry;
|
|
12
|
+
selected: boolean;
|
|
13
|
+
onClick: () => void;
|
|
14
|
+
}) {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
onClick={onClick}
|
|
18
|
+
class={`relative grid grid-cols-[1.2fr_0.8fr_1fr_auto] items-center px-3 py-2 cursor-pointer border-b border-border transition-colors duration-100 ${
|
|
19
|
+
selected ? "bg-selected" : "hover:bg-section"
|
|
20
|
+
}`}
|
|
21
|
+
>
|
|
22
|
+
{selected && (
|
|
23
|
+
<span class="absolute left-0 top-0 bottom-0 w-[2px] bg-accent rounded-r" />
|
|
24
|
+
)}
|
|
25
|
+
<span class="text-accent font-mono text-[11px] truncate pl-1">
|
|
26
|
+
{entry.label}
|
|
27
|
+
</span>
|
|
28
|
+
<span class="text-muted text-[11px] truncate">{entry.componentName}</span>
|
|
29
|
+
<span class="text-text font-mono text-[11px] truncate">
|
|
30
|
+
{formatValue(entry.value)}
|
|
31
|
+
</span>
|
|
32
|
+
<span class="text-subtle text-[10px] text-right tabular-nums">
|
|
33
|
+
{time(entry.changedAt, "ago")}
|
|
34
|
+
</span>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { EmptyState } from "@shared/empty-state";
|
|
2
|
+
import { SearchInput } from "@shared/search-input";
|
|
3
|
+
|
|
4
|
+
import { signal, onMount, onUnmount } from "@praxisjs/core";
|
|
5
|
+
|
|
6
|
+
import { SignalDetail } from "./components/signal-detail";
|
|
7
|
+
import { SignalRow } from "./components/signal-row";
|
|
8
|
+
|
|
9
|
+
import type { Registry } from "@core/registry";
|
|
10
|
+
import type { SignalEntry } from "@core/types";
|
|
11
|
+
|
|
12
|
+
export function SignalsTab({ registry }: { registry: Registry }) {
|
|
13
|
+
const signals = signal<SignalEntry[]>(registry.getSignals());
|
|
14
|
+
const search = signal("");
|
|
15
|
+
const selectedId = signal<string | null>(null);
|
|
16
|
+
|
|
17
|
+
let handlers: Array<() => void> = [];
|
|
18
|
+
onMount(() => {
|
|
19
|
+
handlers = [
|
|
20
|
+
registry.bus.on("signal:registered", () => {
|
|
21
|
+
signals.set(registry.getSignals());
|
|
22
|
+
}),
|
|
23
|
+
registry.bus.on("signal:changed", () => {
|
|
24
|
+
signals.set(registry.getSignals());
|
|
25
|
+
}),
|
|
26
|
+
];
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
onUnmount(() => {
|
|
30
|
+
handlers.forEach((off) => {
|
|
31
|
+
off();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div class="flex h-full overflow-hidden">
|
|
37
|
+
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
|
|
38
|
+
<div class="px-3 py-2 border-b border-border bg-bg shrink-0">
|
|
39
|
+
<SearchInput
|
|
40
|
+
placeholder="Search signals…"
|
|
41
|
+
onInput={(v) => {
|
|
42
|
+
search.set(v);
|
|
43
|
+
}}
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div class="grid grid-cols-[1.2fr_0.8fr_1fr_auto] items-center px-3 h-7 text-[9px] text-subtle font-bold tracking-[0.12em] uppercase border-b border-border bg-section gap-2 shrink-0">
|
|
48
|
+
<span>Signal</span>
|
|
49
|
+
<span>Component</span>
|
|
50
|
+
<span>Value</span>
|
|
51
|
+
<span>Age</span>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="flex-1 overflow-y-auto">
|
|
55
|
+
{() => {
|
|
56
|
+
const q = search().toLowerCase();
|
|
57
|
+
const filtered =
|
|
58
|
+
q === ""
|
|
59
|
+
? signals()
|
|
60
|
+
: signals().filter(
|
|
61
|
+
(s) =>
|
|
62
|
+
s.label.toLowerCase().includes(q) ||
|
|
63
|
+
s.componentName.toLowerCase().includes(q),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (filtered.length === 0) {
|
|
67
|
+
return (
|
|
68
|
+
<EmptyState
|
|
69
|
+
message={
|
|
70
|
+
signals().length === 0
|
|
71
|
+
? "No signals tracked. Add @Debug() on top of @State() properties."
|
|
72
|
+
: "No signals match your search."
|
|
73
|
+
}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return filtered.map((s) => (
|
|
79
|
+
<SignalRow
|
|
80
|
+
key={s.id}
|
|
81
|
+
entry={s}
|
|
82
|
+
selected={selectedId() === s.id}
|
|
83
|
+
onClick={() => {
|
|
84
|
+
selectedId.update((id) => (id === s.id ? null : s.id));
|
|
85
|
+
}}
|
|
86
|
+
/>
|
|
87
|
+
));
|
|
88
|
+
}}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{() => {
|
|
93
|
+
const id = selectedId();
|
|
94
|
+
const entry = id ? (signals().find((s) => s.id === id) ?? null) : null;
|
|
95
|
+
return entry ? <SignalDetail entry={entry} /> : null;
|
|
96
|
+
}}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { TYPE_META } from "../constants";
|
|
2
|
+
|
|
3
|
+
import type { TimelineEventType } from "@core/types";
|
|
4
|
+
|
|
5
|
+
export function Badge({ type }: { type: TimelineEventType }) {
|
|
6
|
+
const meta = TYPE_META[type];
|
|
7
|
+
return (
|
|
8
|
+
<span
|
|
9
|
+
class={`text-[9px] px-[6px] py-[2px] rounded font-bold uppercase tracking-[0.07em] shrink-0 ${meta.cls}`}
|
|
10
|
+
>
|
|
11
|
+
{meta.label}
|
|
12
|
+
</span>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { time } from "@utils/format-time";
|
|
2
|
+
|
|
3
|
+
import { signal } from "@praxisjs/core";
|
|
4
|
+
|
|
5
|
+
import { Badge } from "./badge";
|
|
6
|
+
|
|
7
|
+
import type { TimelineEntry } from "@core/types";
|
|
8
|
+
|
|
9
|
+
export function TimelineRow({ entry }: { entry: TimelineEntry }) {
|
|
10
|
+
const open = signal(false);
|
|
11
|
+
const hasData = Object.keys(entry.data).length > 0;
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div class="border-b border-border">
|
|
15
|
+
<div
|
|
16
|
+
onClick={() => {
|
|
17
|
+
if (hasData) open.update((v) => !v);
|
|
18
|
+
}}
|
|
19
|
+
class={`flex items-center gap-2 px-3 py-2 transition-colors duration-100 ${
|
|
20
|
+
hasData ? "cursor-pointer hover:bg-section" : "cursor-default"
|
|
21
|
+
}`}
|
|
22
|
+
>
|
|
23
|
+
<span class="text-subtle text-[10px] w-10 shrink-0 text-right tabular-nums font-mono">
|
|
24
|
+
{time(entry.timestamp)}
|
|
25
|
+
</span>
|
|
26
|
+
<Badge type={entry.type} />
|
|
27
|
+
<span class="flex-1 text-text font-mono text-[11px] truncate">
|
|
28
|
+
{entry.label}
|
|
29
|
+
</span>
|
|
30
|
+
{hasData && (
|
|
31
|
+
<span class="text-subtle text-[11px] shrink-0 w-4 text-center">
|
|
32
|
+
{() => (open() ? "▾" : "▸")}
|
|
33
|
+
</span>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
{() =>
|
|
38
|
+
open() && hasData ? (
|
|
39
|
+
<div class="px-3 py-2 pl-[56px] font-mono text-[11px] bg-section border-t border-border">
|
|
40
|
+
{Object.entries(entry.data).map(([k, v]) => (
|
|
41
|
+
<div key={k} class="flex gap-3 py-[3px]">
|
|
42
|
+
<span class="text-accent shrink-0">{k}:</span>
|
|
43
|
+
<span class="text-muted truncate">
|
|
44
|
+
{typeof v === "object"
|
|
45
|
+
? JSON.stringify(v)
|
|
46
|
+
: String(v as unknown)}
|
|
47
|
+
</span>
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
) : null
|
|
52
|
+
}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TimelineEventType } from '../../core/types';
|
|
2
|
+
|
|
3
|
+
export type Filter = TimelineEventType | 'all';
|
|
4
|
+
|
|
5
|
+
export const TYPE_META: Record<
|
|
6
|
+
TimelineEventType,
|
|
7
|
+
{ label: string; cls: string }
|
|
8
|
+
> = {
|
|
9
|
+
'signal:change': { label: 'signal', cls: 'text-[#9b90e6] bg-[rgba(155,144,230,0.14)]' },
|
|
10
|
+
'component:render': { label: 'render', cls: 'text-[#4ade80] bg-[rgba(74,222,128,0.12)]' },
|
|
11
|
+
'component:mount': { label: 'mount', cls: 'text-[#0ea57a] bg-[rgba(14,165,122,0.14)]' },
|
|
12
|
+
'component:unmount':{ label: 'unmount', cls: 'text-[#dc2626] bg-[rgba(220,38,38,0.14)]' },
|
|
13
|
+
lifecycle: { label: 'lifecycle', cls: 'text-[#d97706] bg-[rgba(217,119,6,0.14)]' },
|
|
14
|
+
'method:call': { label: 'method', cls: 'text-[#7c6dd6] bg-[rgba(124,109,214,0.14)]' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const FILTERS: Array<{ value: Filter; label: string }> = [
|
|
18
|
+
{ value: 'all', label: 'All' },
|
|
19
|
+
{ value: 'signal:change', label: 'Signals' },
|
|
20
|
+
{ value: 'component:render', label: 'Renders' },
|
|
21
|
+
{ value: 'component:mount', label: 'Mount' },
|
|
22
|
+
{ value: 'lifecycle', label: 'Lifecycle' },
|
|
23
|
+
{ value: 'method:call', label: 'Methods' },
|
|
24
|
+
];
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { EmptyState } from "@shared/empty-state";
|
|
2
|
+
|
|
3
|
+
import { signal, onMount, onUnmount } from "@praxisjs/core";
|
|
4
|
+
|
|
5
|
+
import { TimelineRow } from "./components/timeline-row";
|
|
6
|
+
import { FILTERS, type Filter } from "./constants";
|
|
7
|
+
|
|
8
|
+
import type { Registry } from "@core/registry";
|
|
9
|
+
import type { TimelineEntry } from "@core/types";
|
|
10
|
+
|
|
11
|
+
export function TimelineTab({ registry }: { registry: Registry }) {
|
|
12
|
+
const entries = signal<TimelineEntry[]>(registry.getTimeline());
|
|
13
|
+
const filter = signal<Filter>("all");
|
|
14
|
+
const paused = signal(false);
|
|
15
|
+
|
|
16
|
+
let handlers: Array<() => void> = [];
|
|
17
|
+
onMount(() => {
|
|
18
|
+
handlers = [
|
|
19
|
+
registry.bus.on("timeline:push", () => {
|
|
20
|
+
if (!paused()) entries.set(registry.getTimeline());
|
|
21
|
+
}),
|
|
22
|
+
];
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
onUnmount(() => {
|
|
26
|
+
handlers.forEach((off) => {
|
|
27
|
+
off();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div class="flex flex-col h-full overflow-hidden">
|
|
33
|
+
<div class="flex items-center gap-[3px] px-2 py-2 border-b border-border bg-bg shrink-0 flex-wrap">
|
|
34
|
+
{FILTERS.map((f) => (
|
|
35
|
+
<button
|
|
36
|
+
key={f.value}
|
|
37
|
+
onClick={() => {
|
|
38
|
+
filter.set(f.value);
|
|
39
|
+
}}
|
|
40
|
+
class={() =>
|
|
41
|
+
filter() === f.value
|
|
42
|
+
? "text-[11px] px-2 py-[3px] rounded cursor-pointer font-sans bg-soft text-accent font-semibold"
|
|
43
|
+
: "text-[11px] px-2 py-[3px] rounded cursor-pointer font-sans text-muted hover:text-text hover:bg-section transition-colors duration-150"
|
|
44
|
+
}
|
|
45
|
+
>
|
|
46
|
+
{f.label}
|
|
47
|
+
</button>
|
|
48
|
+
))}
|
|
49
|
+
|
|
50
|
+
<div class="flex-1" />
|
|
51
|
+
|
|
52
|
+
<button
|
|
53
|
+
onClick={() => {
|
|
54
|
+
if (paused()) entries.set(registry.getTimeline());
|
|
55
|
+
paused.update((v) => !v);
|
|
56
|
+
}}
|
|
57
|
+
class={() =>
|
|
58
|
+
`text-[11px] px-2 py-[3px] rounded cursor-pointer font-sans border border-border transition-colors duration-150 ${
|
|
59
|
+
paused() ? "text-warn border-warn" : "text-muted hover:text-text"
|
|
60
|
+
}`
|
|
61
|
+
}
|
|
62
|
+
>
|
|
63
|
+
{() => (paused() ? "Resume" : "Pause")}
|
|
64
|
+
</button>
|
|
65
|
+
|
|
66
|
+
<button
|
|
67
|
+
onClick={() => {
|
|
68
|
+
entries.set([]);
|
|
69
|
+
}}
|
|
70
|
+
class="text-[11px] px-2 py-[3px] rounded cursor-pointer font-sans border border-border text-muted hover:text-text transition-colors duration-150"
|
|
71
|
+
>
|
|
72
|
+
Clear
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div class="flex-1 overflow-y-auto">
|
|
77
|
+
{() => {
|
|
78
|
+
const f = filter();
|
|
79
|
+
const filtered =
|
|
80
|
+
f === "all"
|
|
81
|
+
? entries()
|
|
82
|
+
: entries().filter(
|
|
83
|
+
(e) =>
|
|
84
|
+
e.type === f ||
|
|
85
|
+
(f === "component:mount" && e.type === "component:unmount"),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (filtered.length === 0) {
|
|
89
|
+
return (
|
|
90
|
+
<EmptyState message="No events yet. Interact with your app to see the timeline." />
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return [...filtered]
|
|
95
|
+
.reverse()
|
|
96
|
+
.map((e) => <TimelineRow key={e.id} entry={e} />);
|
|
97
|
+
}}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|