@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/assets/logo.svg +9 -0
  4. package/assets/uno.generated.css +205 -0
  5. package/dist/index.d.ts +135 -0
  6. package/dist/index.js +1051 -0
  7. package/package.json +35 -0
  8. package/src/core/event-bus.ts +19 -0
  9. package/src/core/registry.ts +219 -0
  10. package/src/core/types.ts +45 -0
  11. package/src/decorators/debug.ts +161 -0
  12. package/src/decorators/index.ts +3 -0
  13. package/src/decorators/trace.ts +64 -0
  14. package/src/icons/ellipsis-vertical.tsx +20 -0
  15. package/src/icons/index.ts +6 -0
  16. package/src/icons/panel-bottom.tsx +19 -0
  17. package/src/icons/panel-left.tsx +19 -0
  18. package/src/icons/panel-right.tsx +19 -0
  19. package/src/icons/panel-top.tsx +19 -0
  20. package/src/icons/x.tsx +19 -0
  21. package/src/index.ts +20 -0
  22. package/src/plugins/components/components/component-detail.tsx +70 -0
  23. package/src/plugins/components/components/component-row.tsx +42 -0
  24. package/src/plugins/components/components/detail-row.tsx +22 -0
  25. package/src/plugins/components/components/detail-section.tsx +18 -0
  26. package/src/plugins/components/components/status-dot.tsx +14 -0
  27. package/src/plugins/components/components-tab.tsx +85 -0
  28. package/src/plugins/components/index.ts +9 -0
  29. package/src/plugins/signals/components/signal-detail.tsx +35 -0
  30. package/src/plugins/signals/components/signal-row.tsx +37 -0
  31. package/src/plugins/signals/index.ts +9 -0
  32. package/src/plugins/signals/signals-tab.tsx +99 -0
  33. package/src/plugins/timeline/components/badge.tsx +14 -0
  34. package/src/plugins/timeline/components/timeline-row.tsx +55 -0
  35. package/src/plugins/timeline/constants.ts +24 -0
  36. package/src/plugins/timeline/index.ts +9 -0
  37. package/src/plugins/timeline/timeline-tab.tsx +101 -0
  38. package/src/plugins/types.ts +10 -0
  39. package/src/ui/dev-tools.tsx +121 -0
  40. package/src/ui/panel.tsx +225 -0
  41. package/src/ui/shared/empty-state.tsx +12 -0
  42. package/src/ui/shared/icon-button.tsx +30 -0
  43. package/src/ui/shared/panel-section.tsx +18 -0
  44. package/src/ui/shared/search-input.tsx +18 -0
  45. package/src/ui/shared/side-panel.tsx +18 -0
  46. package/src/utils/format-time.ts +7 -0
  47. package/src/utils/format-value.ts +13 -0
  48. package/src/vite-env.d.ts +6 -0
  49. package/tsconfig.json +21 -0
  50. package/uno.config.ts +57 -0
  51. 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
+ &lt;{entry.name}&gt;
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
+ &lt;{entry.name}&gt;
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,9 @@
1
+ import { ComponentsTab } from "./components-tab";
2
+
3
+ import type { DevtoolsPlugin } from "@plugins/types";
4
+
5
+ export const ComponentsPlugin: DevtoolsPlugin = {
6
+ id: "components",
7
+ label: "Components",
8
+ component: ComponentsTab,
9
+ };
@@ -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,9 @@
1
+ import { SignalsTab } from "./signals-tab";
2
+
3
+ import type { DevtoolsPlugin } from "@plugins/types";
4
+
5
+ export const SignalsPlugin: DevtoolsPlugin = {
6
+ id: "signals",
7
+ label: "Signals",
8
+ component: SignalsTab,
9
+ };
@@ -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,9 @@
1
+ import { TimelineTab } from "./timeline-tab";
2
+
3
+ import type { DevtoolsPlugin } from "@plugins/types";
4
+
5
+ export const TimelinePlugin: DevtoolsPlugin = {
6
+ id: "timeline",
7
+ label: "Timeline",
8
+ component: TimelineTab,
9
+ };
@@ -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
+ }