@ddd-ts/event-tree-viewer 0.0.0-eventviz.10
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/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/cli.mjs +20 -0
- package/index.html +13 -0
- package/package.json +67 -0
- package/src/App.tsx +153 -0
- package/src/application/trpc-client.ts +6 -0
- package/src/application/use-direction.ts +18 -0
- package/src/application/use-domains.ts +9 -0
- package/src/application/use-expansion.ts +42 -0
- package/src/application/use-filters.ts +78 -0
- package/src/application/use-graph.ts +38 -0
- package/src/application/use-reveal.ts +26 -0
- package/src/application/use-selection.ts +15 -0
- package/src/application/use-settings.ts +84 -0
- package/src/application/use-view-mode.ts +14 -0
- package/src/assets/fonts/Monor_Regular.otf +0 -0
- package/src/assets/fonts/Supreme-Variable.woff2 +0 -0
- package/src/assets/fonts/Supreme-VariableItalic.woff2 +0 -0
- package/src/assets/fonts/monor-bold.otf +0 -0
- package/src/assets/react.svg +1 -0
- package/src/cli.ts +29 -0
- package/src/components/direction-toggle.tsx +28 -0
- package/src/components/domain-header.tsx +44 -0
- package/src/components/export-dialog.tsx +164 -0
- package/src/components/filter-bar.tsx +17 -0
- package/src/components/header.tsx +37 -0
- package/src/components/inspector.tsx +183 -0
- package/src/components/kind-filter.tsx +70 -0
- package/src/components/node-badge.tsx +19 -0
- package/src/components/node-name.tsx +66 -0
- package/src/components/settings-menu.tsx +147 -0
- package/src/components/ui/badge.tsx +52 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/card.tsx +103 -0
- package/src/components/ui/checkbox.tsx +28 -0
- package/src/components/ui/dialog.tsx +108 -0
- package/src/components/ui/input.tsx +20 -0
- package/src/components/ui/popover.tsx +88 -0
- package/src/components/ui/scroll-area.tsx +54 -0
- package/src/components/ui/select.tsx +88 -0
- package/src/components/ui/separator.tsx +23 -0
- package/src/components/ui/toggle-group.tsx +89 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/view-switcher.tsx +28 -0
- package/src/components/views/graph-view.tsx +1203 -0
- package/src/components/views/list-view.tsx +109 -0
- package/src/components/views/tree-view.tsx +485 -0
- package/src/domain/cypher-export.ts +66 -0
- package/src/domain/direction.ts +1 -0
- package/src/domain/domain-grouping.ts +217 -0
- package/src/domain/edge.ts +37 -0
- package/src/domain/filter.ts +21 -0
- package/src/domain/flatten-tree.ts +167 -0
- package/src/domain/graph.ts +42 -0
- package/src/domain/node.ts +28 -0
- package/src/domain/roots.ts +18 -0
- package/src/domain/traversal.ts +60 -0
- package/src/index.css +205 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +16 -0
- package/src/server/router.ts +87 -0
- package/src/server/vite-plugin.ts +99 -0
- package/vite.config.ts +36 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { styleText } from "node:util"
|
|
5
|
+
import { createServer } from "vite"
|
|
6
|
+
|
|
7
|
+
const dirname =
|
|
8
|
+
typeof __dirname === "undefined"
|
|
9
|
+
? path.dirname(new URL(import.meta.url).pathname)
|
|
10
|
+
: __dirname
|
|
11
|
+
|
|
12
|
+
const vite = await createServer({
|
|
13
|
+
root: path.resolve(dirname, "../"),
|
|
14
|
+
configFile: path.resolve(dirname, "../vite.config.ts"),
|
|
15
|
+
logLevel: "error",
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const server = await vite.listen(20031)
|
|
19
|
+
|
|
20
|
+
const SCAN_ROOT = path.resolve(process.env.EVENTVIZ_SCAN_ROOT ?? process.cwd())
|
|
21
|
+
|
|
22
|
+
console.log(styleText(["bold", "underline"], "Event Tree Viewer"))
|
|
23
|
+
console.log(` Status ${styleText("green", "running")}`)
|
|
24
|
+
console.log(
|
|
25
|
+
` URL ${styleText("cyan", server.resolvedUrls?.local[0] ?? "unknown")}`
|
|
26
|
+
)
|
|
27
|
+
console.log(` Root ${styleText("yellow", SCAN_ROOT)}`)
|
|
28
|
+
console.log()
|
|
29
|
+
console.log(styleText("dim", "Press Ctrl+C to stop the server"))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
|
2
|
+
import type { Direction } from "@/domain/direction"
|
|
3
|
+
import type { DirectionApi } from "@/application/use-direction"
|
|
4
|
+
|
|
5
|
+
const OPTIONS: { value: Direction; label: string }[] = [
|
|
6
|
+
{ value: "forward", label: "Forward" },
|
|
7
|
+
{ value: "reverse", label: "Reverse" },
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
export function DirectionToggle({ direction }: { direction: DirectionApi }) {
|
|
11
|
+
return (
|
|
12
|
+
<ToggleGroup
|
|
13
|
+
value={[direction.direction]}
|
|
14
|
+
onValueChange={(next) => {
|
|
15
|
+
const picked = next[0] as Direction | undefined
|
|
16
|
+
if (picked) direction.setDirection(picked)
|
|
17
|
+
}}
|
|
18
|
+
variant="outline"
|
|
19
|
+
size="sm"
|
|
20
|
+
>
|
|
21
|
+
{OPTIONS.map((opt) => (
|
|
22
|
+
<ToggleGroupItem key={opt.value} value={opt.value}>
|
|
23
|
+
{opt.label}
|
|
24
|
+
</ToggleGroupItem>
|
|
25
|
+
))}
|
|
26
|
+
</ToggleGroup>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { CaretRightIcon } from "@phosphor-icons/react"
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
|
|
5
|
+
interface DomainHeaderProps {
|
|
6
|
+
label: string
|
|
7
|
+
expanded: boolean
|
|
8
|
+
onToggle: () => void
|
|
9
|
+
meta?: ReactNode
|
|
10
|
+
className?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function DomainHeader({
|
|
14
|
+
label,
|
|
15
|
+
expanded,
|
|
16
|
+
onToggle,
|
|
17
|
+
meta,
|
|
18
|
+
className,
|
|
19
|
+
}: DomainHeaderProps) {
|
|
20
|
+
return (
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
onClick={onToggle}
|
|
24
|
+
className={cn(
|
|
25
|
+
"flex h-full min-h-9 w-full items-center gap-2 px-1 py-2 text-left transition-colors hover:bg-muted/30",
|
|
26
|
+
className
|
|
27
|
+
)}
|
|
28
|
+
>
|
|
29
|
+
<CaretRightIcon
|
|
30
|
+
className={`size-3 shrink-0 text-muted-foreground transition-transform ${
|
|
31
|
+
expanded ? "rotate-90" : ""
|
|
32
|
+
}`}
|
|
33
|
+
/>
|
|
34
|
+
<span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
|
35
|
+
{label}
|
|
36
|
+
</span>
|
|
37
|
+
{meta && (
|
|
38
|
+
<span className="font-mono text-xs font-normal text-muted-foreground">
|
|
39
|
+
{meta}
|
|
40
|
+
</span>
|
|
41
|
+
)}
|
|
42
|
+
</button>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { useMemo, useState } from "react"
|
|
2
|
+
import {
|
|
3
|
+
CheckIcon,
|
|
4
|
+
CopyIcon,
|
|
5
|
+
DownloadIcon,
|
|
6
|
+
ExportIcon,
|
|
7
|
+
} from "@phosphor-icons/react"
|
|
8
|
+
import { Button } from "@/components/ui/button"
|
|
9
|
+
import { Checkbox } from "@/components/ui/checkbox"
|
|
10
|
+
import {
|
|
11
|
+
Dialog,
|
|
12
|
+
DialogContent,
|
|
13
|
+
DialogDescription,
|
|
14
|
+
DialogFooter,
|
|
15
|
+
DialogHeader,
|
|
16
|
+
DialogTitle,
|
|
17
|
+
DialogTrigger,
|
|
18
|
+
} from "@/components/ui/dialog"
|
|
19
|
+
import {
|
|
20
|
+
Select,
|
|
21
|
+
SelectContent,
|
|
22
|
+
SelectItem,
|
|
23
|
+
SelectTrigger,
|
|
24
|
+
SelectValue,
|
|
25
|
+
} from "@/components/ui/select"
|
|
26
|
+
import {
|
|
27
|
+
exportGraphToCypher,
|
|
28
|
+
subgraphFromVisible,
|
|
29
|
+
} from "@/domain/cypher-export"
|
|
30
|
+
import type { GraphIndex } from "@/domain/graph"
|
|
31
|
+
import type { Node } from "@/domain/node"
|
|
32
|
+
|
|
33
|
+
type Format = "cypher"
|
|
34
|
+
|
|
35
|
+
const FORMATS: { value: Format; label: string; ext: string }[] = [
|
|
36
|
+
{ value: "cypher", label: "Cypher", ext: "cypher" },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
interface ExportDialogProps {
|
|
40
|
+
index: GraphIndex
|
|
41
|
+
visibleNodes: Node[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function ExportDialog({ index, visibleNodes }: ExportDialogProps) {
|
|
45
|
+
const [open, setOpen] = useState(false)
|
|
46
|
+
const [format, setFormat] = useState<Format>("cypher")
|
|
47
|
+
const [followFilters, setFollowFilters] = useState(true)
|
|
48
|
+
const [copied, setCopied] = useState(false)
|
|
49
|
+
|
|
50
|
+
const output = useMemo(() => {
|
|
51
|
+
if (!open) return ""
|
|
52
|
+
const graph = followFilters
|
|
53
|
+
? subgraphFromVisible(index.graph, visibleNodes)
|
|
54
|
+
: index.graph
|
|
55
|
+
return exportGraphToCypher(graph)
|
|
56
|
+
}, [open, followFilters, index, visibleNodes])
|
|
57
|
+
|
|
58
|
+
const counts = useMemo(() => {
|
|
59
|
+
if (!open) return { nodes: 0, edges: 0 }
|
|
60
|
+
const graph = followFilters
|
|
61
|
+
? subgraphFromVisible(index.graph, visibleNodes)
|
|
62
|
+
: index.graph
|
|
63
|
+
return { nodes: graph.nodes.length, edges: graph.edges.length }
|
|
64
|
+
}, [open, followFilters, index, visibleNodes])
|
|
65
|
+
|
|
66
|
+
const formatMeta = FORMATS.find((f) => f.value === format) ?? FORMATS[0]
|
|
67
|
+
|
|
68
|
+
const copy = async () => {
|
|
69
|
+
try {
|
|
70
|
+
await navigator.clipboard.writeText(output)
|
|
71
|
+
setCopied(true)
|
|
72
|
+
setTimeout(() => setCopied(false), 1500)
|
|
73
|
+
} catch {
|
|
74
|
+
// ignore
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const download = () => {
|
|
79
|
+
const blob = new Blob([output], { type: "text/plain;charset=utf-8" })
|
|
80
|
+
const url = URL.createObjectURL(blob)
|
|
81
|
+
const a = document.createElement("a")
|
|
82
|
+
a.href = url
|
|
83
|
+
a.download = `event-graph.${formatMeta.ext}`
|
|
84
|
+
document.body.appendChild(a)
|
|
85
|
+
a.click()
|
|
86
|
+
document.body.removeChild(a)
|
|
87
|
+
URL.revokeObjectURL(url)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
92
|
+
<DialogTrigger
|
|
93
|
+
render={
|
|
94
|
+
<Button variant="ghost" size="icon-sm" aria-label="Export graph">
|
|
95
|
+
<ExportIcon />
|
|
96
|
+
</Button>
|
|
97
|
+
}
|
|
98
|
+
/>
|
|
99
|
+
<DialogContent className="max-w-2xl">
|
|
100
|
+
<DialogHeader>
|
|
101
|
+
<DialogTitle>Export graph</DialogTitle>
|
|
102
|
+
<DialogDescription>
|
|
103
|
+
Export the graph in a format designed for graph databases.
|
|
104
|
+
</DialogDescription>
|
|
105
|
+
</DialogHeader>
|
|
106
|
+
|
|
107
|
+
<div className="flex flex-col gap-4">
|
|
108
|
+
<div className="flex items-center justify-between gap-2">
|
|
109
|
+
<span className="text-sm">Format</span>
|
|
110
|
+
<Select<Format>
|
|
111
|
+
value={format}
|
|
112
|
+
onValueChange={(value) => {
|
|
113
|
+
if (value) setFormat(value)
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<SelectTrigger className="w-40">
|
|
117
|
+
<SelectValue>
|
|
118
|
+
{(value) =>
|
|
119
|
+
FORMATS.find((f) => f.value === value)?.label ?? ""
|
|
120
|
+
}
|
|
121
|
+
</SelectValue>
|
|
122
|
+
</SelectTrigger>
|
|
123
|
+
<SelectContent>
|
|
124
|
+
{FORMATS.map((f) => (
|
|
125
|
+
<SelectItem key={f.value} value={f.value}>
|
|
126
|
+
{f.label}
|
|
127
|
+
</SelectItem>
|
|
128
|
+
))}
|
|
129
|
+
</SelectContent>
|
|
130
|
+
</Select>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
|
134
|
+
<Checkbox
|
|
135
|
+
checked={followFilters}
|
|
136
|
+
onCheckedChange={(checked) => setFollowFilters(checked === true)}
|
|
137
|
+
/>
|
|
138
|
+
<span>Follow current filters</span>
|
|
139
|
+
</label>
|
|
140
|
+
|
|
141
|
+
<div className="flex flex-col gap-1">
|
|
142
|
+
<span className="self-end font-mono text-xs text-muted-foreground">
|
|
143
|
+
{counts.nodes} nodes · {counts.edges} edges
|
|
144
|
+
</span>
|
|
145
|
+
<pre className="max-h-72 overflow-auto bg-muted p-3 font-mono text-xs whitespace-pre-wrap text-foreground">
|
|
146
|
+
{output}
|
|
147
|
+
</pre>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<DialogFooter>
|
|
152
|
+
<Button variant="outline" size="sm" onClick={copy}>
|
|
153
|
+
{copied ? <CheckIcon /> : <CopyIcon />}
|
|
154
|
+
{copied ? "Copied" : "Copy"}
|
|
155
|
+
</Button>
|
|
156
|
+
<Button variant="default" size="sm" onClick={download}>
|
|
157
|
+
<DownloadIcon />
|
|
158
|
+
Download
|
|
159
|
+
</Button>
|
|
160
|
+
</DialogFooter>
|
|
161
|
+
</DialogContent>
|
|
162
|
+
</Dialog>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Input } from "@/components/ui/input"
|
|
2
|
+
import { KindFilter } from "@/components/kind-filter"
|
|
3
|
+
import type { FiltersApi } from "@/application/use-filters"
|
|
4
|
+
|
|
5
|
+
export function FilterBar({ filters }: { filters: FiltersApi }) {
|
|
6
|
+
return (
|
|
7
|
+
<div className="flex items-center gap-3 border-b px-6 py-3">
|
|
8
|
+
<Input
|
|
9
|
+
placeholder="Search nodes…"
|
|
10
|
+
value={filters.filter.search}
|
|
11
|
+
onChange={(e) => filters.setSearch(e.target.value)}
|
|
12
|
+
className="max-w-xs"
|
|
13
|
+
/>
|
|
14
|
+
<KindFilter filters={filters} />
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ExportDialog } from "@/components/export-dialog"
|
|
2
|
+
import { SettingsMenu } from "@/components/settings-menu"
|
|
3
|
+
import type { GraphIndex } from "@/domain/graph"
|
|
4
|
+
import type { Node } from "@/domain/node"
|
|
5
|
+
import type { SettingsApi } from "@/application/use-settings"
|
|
6
|
+
|
|
7
|
+
export function Header({
|
|
8
|
+
index,
|
|
9
|
+
visibleNodes,
|
|
10
|
+
settings,
|
|
11
|
+
}: {
|
|
12
|
+
index: GraphIndex
|
|
13
|
+
visibleNodes: Node[]
|
|
14
|
+
settings: SettingsApi
|
|
15
|
+
}) {
|
|
16
|
+
const { graph } = index
|
|
17
|
+
return (
|
|
18
|
+
<header className="flex items-center justify-between border-b px-6 py-3">
|
|
19
|
+
<div className="flex items-baseline gap-3">
|
|
20
|
+
<span className="font-display text-base font-bold tracking-tight text-foreground">
|
|
21
|
+
DDD-TS
|
|
22
|
+
</span>
|
|
23
|
+
<span className="text-muted-foreground/70">·</span>
|
|
24
|
+
<h1 className="text-sm font-medium tracking-tight text-muted-foreground">
|
|
25
|
+
Event Tree Viewer
|
|
26
|
+
</h1>
|
|
27
|
+
</div>
|
|
28
|
+
<div className="flex items-center gap-3">
|
|
29
|
+
<span className="font-mono text-xs tracking-wide text-muted-foreground">
|
|
30
|
+
{graph.nodes.length} nodes · {graph.edges.length} edges
|
|
31
|
+
</span>
|
|
32
|
+
<ExportDialog index={index} visibleNodes={visibleNodes} />
|
|
33
|
+
<SettingsMenu settings={settings} />
|
|
34
|
+
</div>
|
|
35
|
+
</header>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { useMemo } from "react"
|
|
2
|
+
import { ArrowSquareOutIcon } from "@phosphor-icons/react"
|
|
3
|
+
import { Separator } from "@/components/ui/separator"
|
|
4
|
+
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
5
|
+
import { Button } from "@/components/ui/button"
|
|
6
|
+
import { NodeBadge } from "@/components/node-badge"
|
|
7
|
+
import { nodeId, type GraphIndex, type NodeId } from "@/domain/graph"
|
|
8
|
+
import type { Node } from "@/domain/node"
|
|
9
|
+
import { edgeKind, type Edge } from "@/domain/edge"
|
|
10
|
+
import { trpc } from "@/application/trpc-client"
|
|
11
|
+
|
|
12
|
+
async function openInEditor(source: { file: string; start: number }) {
|
|
13
|
+
try {
|
|
14
|
+
await trpc.editor.open.mutate({ file: source.file, offset: source.start })
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.warn(`[open-in-editor] ${(error as Error).message}`)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface InspectorProps {
|
|
21
|
+
index: GraphIndex
|
|
22
|
+
selectedId: NodeId
|
|
23
|
+
onSelect: (id: NodeId) => void
|
|
24
|
+
onClose: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Inspector({
|
|
28
|
+
index,
|
|
29
|
+
selectedId,
|
|
30
|
+
onSelect,
|
|
31
|
+
onClose,
|
|
32
|
+
}: InspectorProps) {
|
|
33
|
+
const node = index.nodesById.get(selectedId)
|
|
34
|
+
const outgoing = useMemo(
|
|
35
|
+
() => index.outgoing.get(selectedId) ?? [],
|
|
36
|
+
[index, selectedId]
|
|
37
|
+
)
|
|
38
|
+
const incoming = useMemo(
|
|
39
|
+
() => index.incoming.get(selectedId) ?? [],
|
|
40
|
+
[index, selectedId]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if (!node) return null
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<aside className="surface-elevated flex h-full w-96 shrink-0 flex-col border-l bg-card">
|
|
47
|
+
<header className="flex shrink-0 items-center justify-between gap-2 border-b px-5 py-3">
|
|
48
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
49
|
+
<NodeBadge kind={node.type} />
|
|
50
|
+
<span className="truncate text-sm font-semibold">{node.name}</span>
|
|
51
|
+
</div>
|
|
52
|
+
<Button
|
|
53
|
+
variant="ghost"
|
|
54
|
+
size="sm"
|
|
55
|
+
onClick={onClose}
|
|
56
|
+
className="shrink-0"
|
|
57
|
+
>
|
|
58
|
+
Close
|
|
59
|
+
</Button>
|
|
60
|
+
</header>
|
|
61
|
+
|
|
62
|
+
<ScrollArea className="min-h-0 flex-1">
|
|
63
|
+
<div className="flex flex-col gap-4 px-5 py-4">
|
|
64
|
+
<Meta node={node} />
|
|
65
|
+
<Button
|
|
66
|
+
variant="outline"
|
|
67
|
+
size="sm"
|
|
68
|
+
onClick={() => openInEditor(node.source)}
|
|
69
|
+
className="w-full justify-start gap-2"
|
|
70
|
+
>
|
|
71
|
+
<ArrowSquareOutIcon />
|
|
72
|
+
Open in editor
|
|
73
|
+
</Button>
|
|
74
|
+
<Separator />
|
|
75
|
+
<Section
|
|
76
|
+
title={`Incoming (${incoming.length})`}
|
|
77
|
+
edges={incoming}
|
|
78
|
+
direction="in"
|
|
79
|
+
onSelect={onSelect}
|
|
80
|
+
/>
|
|
81
|
+
<Separator />
|
|
82
|
+
<Section
|
|
83
|
+
title={`Outgoing (${outgoing.length})`}
|
|
84
|
+
edges={outgoing}
|
|
85
|
+
direction="out"
|
|
86
|
+
onSelect={onSelect}
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
</ScrollArea>
|
|
90
|
+
</aside>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function Meta({ node }: { node: Node }) {
|
|
95
|
+
return (
|
|
96
|
+
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1.5 text-sm">
|
|
97
|
+
<dt className="text-muted-foreground">Name</dt>
|
|
98
|
+
<dd className="font-mono break-all">{node.name}</dd>
|
|
99
|
+
{"meta" in node && "alias" in node.meta && (
|
|
100
|
+
<>
|
|
101
|
+
<dt className="text-muted-foreground">Alias</dt>
|
|
102
|
+
<dd className="font-mono break-all">{node.meta.alias}</dd>
|
|
103
|
+
</>
|
|
104
|
+
)}
|
|
105
|
+
{"meta" in node && "base" in node.meta && (
|
|
106
|
+
<>
|
|
107
|
+
<dt className="text-muted-foreground">Base</dt>
|
|
108
|
+
<dd className="font-mono break-all">{node.meta.base}</dd>
|
|
109
|
+
</>
|
|
110
|
+
)}
|
|
111
|
+
<dt className="text-muted-foreground">File</dt>
|
|
112
|
+
<dd className="font-mono text-xs break-all">{node.source.file}</dd>
|
|
113
|
+
</dl>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function Section({
|
|
118
|
+
title,
|
|
119
|
+
edges,
|
|
120
|
+
direction,
|
|
121
|
+
onSelect,
|
|
122
|
+
}: {
|
|
123
|
+
title: string
|
|
124
|
+
edges: Edge[]
|
|
125
|
+
direction: "in" | "out"
|
|
126
|
+
onSelect: (id: NodeId) => void
|
|
127
|
+
}) {
|
|
128
|
+
return (
|
|
129
|
+
<section className="flex flex-col gap-2">
|
|
130
|
+
<h3 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
|
131
|
+
{title}
|
|
132
|
+
</h3>
|
|
133
|
+
{edges.length === 0 && (
|
|
134
|
+
<p className="text-sm text-muted-foreground">None</p>
|
|
135
|
+
)}
|
|
136
|
+
<ul className="flex flex-col gap-1">
|
|
137
|
+
{edges.map((edge, idx) => (
|
|
138
|
+
<li key={idx}>
|
|
139
|
+
<EdgeRow edge={edge} direction={direction} onSelect={onSelect} />
|
|
140
|
+
</li>
|
|
141
|
+
))}
|
|
142
|
+
</ul>
|
|
143
|
+
</section>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function EdgeRow({
|
|
148
|
+
edge,
|
|
149
|
+
direction,
|
|
150
|
+
onSelect,
|
|
151
|
+
}: {
|
|
152
|
+
edge: Edge
|
|
153
|
+
direction: "in" | "out"
|
|
154
|
+
onSelect: (id: NodeId) => void
|
|
155
|
+
}) {
|
|
156
|
+
const peer = direction === "in" ? edge.from : edge.to
|
|
157
|
+
const peerId = nodeId(peer.type, peer.name)
|
|
158
|
+
const dotIdx = peer.name.indexOf(".")
|
|
159
|
+
const head = dotIdx >= 0 ? peer.name.slice(0, dotIdx) : peer.name
|
|
160
|
+
const tail = dotIdx >= 0 ? peer.name.slice(dotIdx) : ""
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<Button
|
|
164
|
+
variant="ghost"
|
|
165
|
+
size="sm"
|
|
166
|
+
onClick={() => onSelect(peerId)}
|
|
167
|
+
className="h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-sm font-normal"
|
|
168
|
+
>
|
|
169
|
+
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
|
170
|
+
{edgeKind(edge)}
|
|
171
|
+
</span>
|
|
172
|
+
<NodeBadge kind={peer.type} />
|
|
173
|
+
<span className="min-w-0 truncate">
|
|
174
|
+
<span className="font-medium">{head}</span>
|
|
175
|
+
{tail && (
|
|
176
|
+
<span className="font-mono text-xs text-muted-foreground">
|
|
177
|
+
{tail}
|
|
178
|
+
</span>
|
|
179
|
+
)}
|
|
180
|
+
</span>
|
|
181
|
+
</Button>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { CaretDownIcon } from "@phosphor-icons/react"
|
|
2
|
+
import { Button } from "@/components/ui/button"
|
|
3
|
+
import { Checkbox } from "@/components/ui/checkbox"
|
|
4
|
+
import {
|
|
5
|
+
Popover,
|
|
6
|
+
PopoverContent,
|
|
7
|
+
PopoverTrigger,
|
|
8
|
+
} from "@/components/ui/popover"
|
|
9
|
+
import { NodeBadge } from "@/components/node-badge"
|
|
10
|
+
import { NODE_KINDS, type NodeKind } from "@/domain/node"
|
|
11
|
+
import type { FiltersApi } from "@/application/use-filters"
|
|
12
|
+
|
|
13
|
+
export function KindFilter({ filters }: { filters: FiltersApi }) {
|
|
14
|
+
const selected = filters.filter.kinds
|
|
15
|
+
const label = buildLabel(selected)
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<Popover>
|
|
19
|
+
<PopoverTrigger
|
|
20
|
+
render={
|
|
21
|
+
<Button variant="outline" size="sm">
|
|
22
|
+
<span>{label}</span>
|
|
23
|
+
<CaretDownIcon data-icon="inline-end" />
|
|
24
|
+
</Button>
|
|
25
|
+
}
|
|
26
|
+
/>
|
|
27
|
+
<PopoverContent align="start" className="w-56 p-1">
|
|
28
|
+
<ul className="flex flex-col">
|
|
29
|
+
{NODE_KINDS.map((kind) => (
|
|
30
|
+
<li key={kind}>
|
|
31
|
+
<KindOption
|
|
32
|
+
kind={kind}
|
|
33
|
+
checked={selected.has(kind)}
|
|
34
|
+
onToggle={() => filters.toggleKind(kind)}
|
|
35
|
+
/>
|
|
36
|
+
</li>
|
|
37
|
+
))}
|
|
38
|
+
</ul>
|
|
39
|
+
</PopoverContent>
|
|
40
|
+
</Popover>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function KindOption({
|
|
45
|
+
kind,
|
|
46
|
+
checked,
|
|
47
|
+
onToggle,
|
|
48
|
+
}: {
|
|
49
|
+
kind: NodeKind
|
|
50
|
+
checked: boolean
|
|
51
|
+
onToggle: () => void
|
|
52
|
+
}) {
|
|
53
|
+
return (
|
|
54
|
+
<label className="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm transition-colors hover:bg-muted hover:text-foreground">
|
|
55
|
+
<Checkbox checked={checked} onCheckedChange={onToggle} />
|
|
56
|
+
<NodeBadge kind={kind} />
|
|
57
|
+
<span className="capitalize">{kind}</span>
|
|
58
|
+
</label>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildLabel(selected: ReadonlySet<NodeKind>): string {
|
|
63
|
+
if (selected.size === NODE_KINDS.length) return "All kinds"
|
|
64
|
+
if (selected.size === 0) return "No kinds"
|
|
65
|
+
if (selected.size === 1) {
|
|
66
|
+
const only = [...selected][0]
|
|
67
|
+
return only[0].toUpperCase() + only.slice(1)
|
|
68
|
+
}
|
|
69
|
+
return `${selected.size} kinds`
|
|
70
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Badge } from "@/components/ui/badge"
|
|
2
|
+
import type { NodeKind } from "@/domain/node"
|
|
3
|
+
|
|
4
|
+
const STYLES: Record<NodeKind, string> = {
|
|
5
|
+
event: "bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-200",
|
|
6
|
+
command: "bg-sky-100 text-sky-900 dark:bg-sky-950 dark:text-sky-200",
|
|
7
|
+
saga: "bg-violet-100 text-violet-900 dark:bg-violet-950 dark:text-violet-200",
|
|
8
|
+
aggregate:
|
|
9
|
+
"bg-emerald-100 text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200",
|
|
10
|
+
projection: "bg-rose-100 text-rose-900 dark:bg-rose-950 dark:text-rose-200",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function NodeBadge({ kind }: { kind: NodeKind }) {
|
|
14
|
+
return (
|
|
15
|
+
<Badge variant="secondary" className={STYLES[kind]}>
|
|
16
|
+
{kind}
|
|
17
|
+
</Badge>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { DotsThreeIcon } from "@phosphor-icons/react"
|
|
2
|
+
import { isJustKind, stripDomainAffix } from "@/domain/domain-grouping"
|
|
3
|
+
|
|
4
|
+
interface NodeNameProps {
|
|
5
|
+
name: string
|
|
6
|
+
kind: string
|
|
7
|
+
domainPrefix: string
|
|
8
|
+
hide: boolean
|
|
9
|
+
allowEmpty?: boolean
|
|
10
|
+
metaClass?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function NodeName({
|
|
14
|
+
name,
|
|
15
|
+
kind,
|
|
16
|
+
domainPrefix,
|
|
17
|
+
hide,
|
|
18
|
+
allowEmpty = false,
|
|
19
|
+
metaClass = "text-xs",
|
|
20
|
+
}: NodeNameProps) {
|
|
21
|
+
const dotIdx = name.indexOf(".")
|
|
22
|
+
const head = dotIdx >= 0 ? name.slice(0, dotIdx) : name
|
|
23
|
+
const tail = dotIdx >= 0 ? name.slice(dotIdx) : ""
|
|
24
|
+
const methodTag = tail ? (
|
|
25
|
+
<span className={`font-mono ${metaClass} text-muted-foreground`}>
|
|
26
|
+
{tail}
|
|
27
|
+
</span>
|
|
28
|
+
) : null
|
|
29
|
+
|
|
30
|
+
if (!hide) {
|
|
31
|
+
return (
|
|
32
|
+
<span className="font-medium">
|
|
33
|
+
{head}
|
|
34
|
+
{methodTag}
|
|
35
|
+
</span>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
const { stripped, position } = stripDomainAffix(
|
|
39
|
+
head,
|
|
40
|
+
domainPrefix,
|
|
41
|
+
allowEmpty || tail !== ""
|
|
42
|
+
)
|
|
43
|
+
if (position === null) {
|
|
44
|
+
return (
|
|
45
|
+
<span className="font-medium">
|
|
46
|
+
{head}
|
|
47
|
+
{methodTag}
|
|
48
|
+
</span>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
const visible = isJustKind(stripped, kind) ? "" : stripped
|
|
52
|
+
const ellipsis = (
|
|
53
|
+
<DotsThreeIcon
|
|
54
|
+
className="inline size-3 align-middle text-muted-foreground"
|
|
55
|
+
aria-label={name}
|
|
56
|
+
/>
|
|
57
|
+
)
|
|
58
|
+
return (
|
|
59
|
+
<span className="font-medium" title={name}>
|
|
60
|
+
{position === "prefix" && ellipsis}
|
|
61
|
+
{visible}
|
|
62
|
+
{position === "suffix" && ellipsis}
|
|
63
|
+
{methodTag}
|
|
64
|
+
</span>
|
|
65
|
+
)
|
|
66
|
+
}
|