@eventcatalog/core 3.46.0 → 3.47.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 (39) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-FNOYJEUK.js → chunk-ALXVETEP.js} +1 -1
  6. package/dist/{chunk-DOHA5HNJ.js → chunk-GBC637Z2.js} +1 -1
  7. package/dist/{chunk-JS6IYB55.js → chunk-XYCPSZ4V.js} +1 -1
  8. package/dist/{chunk-TLLUDBO4.js → chunk-ZM5P2252.js} +1 -1
  9. package/dist/{chunk-X4AESI6E.js → chunk-ZRLFPUCO.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +1 -1
  13. package/dist/eventcatalog.js +10 -10
  14. package/dist/generate.cjs +1 -1
  15. package/dist/generate.js +3 -3
  16. package/dist/utils/cli-logger.cjs +1 -1
  17. package/dist/utils/cli-logger.js +2 -2
  18. package/eventcatalog/src/components/MDX/SchemaViewer/SchemaViewerRoot.astro +11 -1
  19. package/eventcatalog/src/components/MDX/SchemaViewer/schema-viewer-utils.spec.ts +63 -0
  20. package/eventcatalog/src/components/MDX/SchemaViewer/schema-viewer-utils.ts +20 -7
  21. package/eventcatalog/src/components/SchemaExplorer/ProtobufSchemaViewer.tsx +532 -0
  22. package/eventcatalog/src/components/SchemaExplorer/SchemaContentViewer.tsx +6 -0
  23. package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +20 -2
  24. package/eventcatalog/src/components/SchemaExplorer/SchemaExplorer.tsx +14 -5
  25. package/eventcatalog/src/components/SchemaExplorer/SchemaViewerModal.tsx +8 -2
  26. package/eventcatalog/src/components/SchemaExplorer/utils.ts +4 -0
  27. package/eventcatalog/src/pages/docs/[type]/[id]/language/_index.data.ts +19 -4
  28. package/eventcatalog/src/pages/docs/[type]/[id]/language.mdx.ts +16 -5
  29. package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +4 -2
  30. package/eventcatalog/src/stores/sidebar-store/builders/entity.ts +87 -0
  31. package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +1 -0
  32. package/eventcatalog/src/stores/sidebar-store/state.ts +15 -14
  33. package/eventcatalog/src/styles/tailwind.css +18 -0
  34. package/eventcatalog/src/utils/collections/domains.ts +39 -0
  35. package/eventcatalog/src/utils/collections/entities.ts +3 -1
  36. package/eventcatalog/src/utils/files.ts +9 -0
  37. package/eventcatalog/src/utils/node-graphs/domain-entity-map.ts +24 -12
  38. package/eventcatalog/src/utils/protobuf-schema.ts +476 -0
  39. package/package.json +3 -4
@@ -0,0 +1,532 @@
1
+ import { useState, useEffect, useRef, useMemo } from 'react';
2
+ import type { ProtobufSchema, ProtobufMessage, ProtobufEnum, ProtobufField } from '@utils/protobuf-schema';
3
+
4
+ interface ProtobufSchemaViewerProps {
5
+ schema: ProtobufSchema;
6
+ title?: string;
7
+ maxHeight?: string;
8
+ expand?: boolean | string;
9
+ search?: boolean | string;
10
+ showRequired?: boolean | string;
11
+ onOpenFullscreen?: () => void;
12
+ }
13
+
14
+ // Proto types are referenced by name (not nested inline like Avro), so we build
15
+ // a registry of every message/enum in the file to resolve field types against.
16
+ interface TypeRegistry {
17
+ messages: Map<string, ProtobufMessage>;
18
+ enums: Map<string, ProtobufEnum>;
19
+ }
20
+
21
+ function buildTypeRegistry(schema: ProtobufSchema): TypeRegistry {
22
+ const registry: TypeRegistry = { messages: new Map(), enums: new Map() };
23
+
24
+ const register = <T,>(map: Map<string, T>, qualifiedName: string, simpleName: string, value: T) => {
25
+ map.set(qualifiedName, value);
26
+ if (!map.has(simpleName)) map.set(simpleName, value);
27
+ };
28
+
29
+ const walk = (messages: ProtobufMessage[], enums: ProtobufEnum[], prefix: string) => {
30
+ for (const protoEnum of enums) {
31
+ register(registry.enums, prefix ? `${prefix}.${protoEnum.name}` : protoEnum.name, protoEnum.name, protoEnum);
32
+ }
33
+ for (const message of messages) {
34
+ const qualifiedName = prefix ? `${prefix}.${message.name}` : message.name;
35
+ register(registry.messages, qualifiedName, message.name, message);
36
+ walk(message.messages, message.enums, qualifiedName);
37
+ }
38
+ };
39
+
40
+ walk(schema.messages, schema.enums, '');
41
+ return registry;
42
+ }
43
+
44
+ // Resolve a field type name against the registry, trying the name as written,
45
+ // without the package prefix, and by its last segment (e.g. "com.example.Order" -> "Order").
46
+ // Leading dots (fully-qualified references like ".com.example.Order") are stripped first.
47
+ function resolveTypeName(typeName: string, packageName: string | undefined): string[] {
48
+ const normalized = typeName.startsWith('.') ? typeName.slice(1) : typeName;
49
+ const candidates = [normalized];
50
+ if (packageName && normalized.startsWith(`${packageName}.`)) {
51
+ candidates.push(normalized.slice(packageName.length + 1));
52
+ }
53
+ const lastSegment = normalized.split('.').pop();
54
+ if (lastSegment && lastSegment !== normalized) candidates.push(lastSegment);
55
+ return candidates;
56
+ }
57
+
58
+ function lookupType<T>(map: Map<string, T>, typeName: string, packageName?: string): T | undefined {
59
+ for (const candidate of resolveTypeName(typeName, packageName)) {
60
+ const match = map.get(candidate);
61
+ if (match) return match;
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ function formatProtobufType(field: ProtobufField): string {
67
+ if (field.map) return field.type;
68
+ return field.label ? `${field.label} ${field.type}` : field.type;
69
+ }
70
+
71
+ function countFields(messages: ProtobufMessage[]): number {
72
+ return messages.reduce((total, message) => total + message.fields.length + countFields(message.messages), 0);
73
+ }
74
+
75
+ interface ProtobufFieldRowProps {
76
+ field: ProtobufField;
77
+ level: number;
78
+ expand: boolean;
79
+ showRequired?: boolean;
80
+ registry: TypeRegistry;
81
+ packageName?: string;
82
+ ancestors: string[];
83
+ }
84
+
85
+ const ProtobufFieldRow = ({ field, level, expand, showRequired, registry, packageName, ancestors }: ProtobufFieldRowProps) => {
86
+ const [isExpanded, setIsExpanded] = useState(expand);
87
+ const indentationClass = `pl-${level * 3}`;
88
+
89
+ // The type to drill into: map values and plain types can both reference messages
90
+ const targetTypeName = field.map ? field.map.valueType : field.type;
91
+ const nestedMessage = lookupType(registry.messages, targetTypeName, packageName);
92
+ const nestedEnum = nestedMessage ? undefined : lookupType(registry.enums, targetTypeName, packageName);
93
+ const isCyclic = nestedMessage ? ancestors.includes(nestedMessage.name) : false;
94
+ const hasNested = !!nestedMessage && nestedMessage.fields.length > 0 && !isCyclic;
95
+ const isRequired = field.label === 'required';
96
+
97
+ useEffect(() => {
98
+ setIsExpanded(expand);
99
+ }, [expand]);
100
+
101
+ return (
102
+ <div className={`proto-field-container mb-1.5 border-l border-[rgb(var(--ec-page-border))] relative ${indentationClass}`}>
103
+ <div className="flex items-start space-x-1.5">
104
+ {hasNested ? (
105
+ <button
106
+ type="button"
107
+ aria-expanded={isExpanded}
108
+ onClick={() => setIsExpanded(!isExpanded)}
109
+ className="proto-field-toggle text-[rgb(var(--ec-page-text-muted))] hover:text-[rgb(var(--ec-page-text))] pt-0.5 focus:outline-hidden w-3 text-center flex-shrink-0"
110
+ >
111
+ <span className={`icon-collapsed font-mono text-xs ${isExpanded ? 'hidden' : ''}`}>&gt;</span>
112
+ <span className={`icon-expanded font-mono text-xs ${!isExpanded ? 'hidden' : ''}`}>v</span>
113
+ </button>
114
+ ) : (
115
+ <div className="w-3 h-4 flex-shrink-0" />
116
+ )}
117
+
118
+ <div className="flex-grow">
119
+ <div className="flex justify-between items-baseline">
120
+ <div>
121
+ <span className="proto-field-name font-semibold text-[rgb(var(--ec-page-text))] text-sm">{field.name}</span>
122
+ <span className="ml-1.5 text-[rgb(var(--ec-accent))] font-mono text-xs">{formatProtobufType(field)}</span>
123
+ {field.number !== undefined && (
124
+ <span className="ml-1.5 text-[rgb(var(--ec-page-text-muted))] font-mono text-xs">= {field.number}</span>
125
+ )}
126
+ {field.oneof && (
127
+ <span className="ml-1.5 text-[rgb(var(--ec-page-text-muted))] text-xs bg-[rgb(var(--ec-content-hover))] px-1 rounded">
128
+ oneof {field.oneof}
129
+ </span>
130
+ )}
131
+ </div>
132
+ {showRequired && isRequired && (
133
+ <span className="text-red-600 dark:text-red-400 text-xs ml-3 flex-shrink-0">required</span>
134
+ )}
135
+ </div>
136
+
137
+ {field.doc && <p className="text-[rgb(var(--ec-page-text-muted))] text-xs mt-0.5">{field.doc}</p>}
138
+
139
+ {/* Show enum values if the field type resolves to an enum */}
140
+ {nestedEnum && nestedEnum.values.length > 0 && (
141
+ <div className="text-xs text-[rgb(var(--ec-page-text-muted))] mt-0.5">
142
+ <span className="text-xs inline-block">Allowed values:</span>
143
+ {nestedEnum.values.map((value) => (
144
+ <span key={value.name} className="text-xs">
145
+ {' '}
146
+ <code className="bg-[rgb(var(--ec-content-hover))] px-1 rounded text-[rgb(var(--ec-page-text))] font-thin py-0.5">
147
+ {value.name}
148
+ </code>
149
+ </span>
150
+ ))}
151
+ </div>
152
+ )}
153
+
154
+ {/* Nested fields for message types */}
155
+ {hasNested && nestedMessage && (
156
+ <div className={`proto-nested-content mt-1 ${!isExpanded ? 'hidden' : ''}`}>
157
+ {nestedMessage.fields.map((nestedField) => (
158
+ <ProtobufFieldRow
159
+ key={nestedField.name}
160
+ field={nestedField}
161
+ level={level + 1}
162
+ expand={expand}
163
+ showRequired={showRequired}
164
+ registry={registry}
165
+ packageName={packageName}
166
+ ancestors={[...ancestors, nestedMessage.name]}
167
+ />
168
+ ))}
169
+ </div>
170
+ )}
171
+ </div>
172
+ </div>
173
+ </div>
174
+ );
175
+ };
176
+
177
+ // Main ProtobufSchemaViewer component
178
+ export default function ProtobufSchemaViewer({
179
+ schema,
180
+ title,
181
+ maxHeight,
182
+ expand = false,
183
+ search = true,
184
+ showRequired = false,
185
+ onOpenFullscreen,
186
+ }: ProtobufSchemaViewerProps) {
187
+ const expandBool = expand === true || expand === 'true';
188
+ const searchBool = search !== false && search !== 'false';
189
+ const showRequiredBool = showRequired === true || showRequired === 'true';
190
+
191
+ const [searchQuery, setSearchQuery] = useState('');
192
+ const [expandAll, setExpandAll] = useState(expandBool);
193
+ const [currentMatches, setCurrentMatches] = useState<HTMLElement[]>([]);
194
+ const [currentMatchIndex, setCurrentMatchIndex] = useState(-1);
195
+ const searchInputRef = useRef<HTMLInputElement>(null);
196
+ const fieldsContainerRef = useRef<HTMLDivElement>(null);
197
+
198
+ const registry = useMemo(() => (schema ? buildTypeRegistry(schema) : { messages: new Map(), enums: new Map() }), [schema]);
199
+ const totalFields = useMemo(() => (schema ? countFields(schema.messages) : 0), [schema]);
200
+
201
+ // Search functionality with highlighting
202
+ useEffect(() => {
203
+ if (!fieldsContainerRef.current) return;
204
+
205
+ const fieldContainers = fieldsContainerRef.current.querySelectorAll('.proto-field-container');
206
+ const matches: HTMLElement[] = [];
207
+
208
+ if (searchQuery === '') {
209
+ // Reset search
210
+ fieldContainers.forEach((container) => {
211
+ container.classList.remove('search-match', 'search-no-match', 'search-current-match', 'search-dimmed');
212
+ const nameEl = container.querySelector('.proto-field-name');
213
+ if (nameEl) {
214
+ nameEl.innerHTML = nameEl.textContent || '';
215
+ }
216
+ });
217
+ setCurrentMatches([]);
218
+ setCurrentMatchIndex(-1);
219
+ return;
220
+ }
221
+
222
+ const query = searchQuery.toLowerCase().trim();
223
+
224
+ fieldContainers.forEach((container) => {
225
+ const nameEl = container.querySelector('.proto-field-name');
226
+ if (!nameEl) return;
227
+
228
+ const fieldName = (nameEl.textContent || '').toLowerCase();
229
+
230
+ if (fieldName.includes(query)) {
231
+ container.classList.add('search-match');
232
+ container.classList.remove('search-dimmed');
233
+ matches.push(container as HTMLElement);
234
+
235
+ // Highlight the search term
236
+ const regex = new RegExp(`(${query})`, 'gi');
237
+ nameEl.innerHTML = (nameEl.textContent || '').replace(regex, '<mark class="bg-yellow-200 rounded px-0.5">$1</mark>');
238
+
239
+ // Expand parent containers
240
+ let parent = container.parentElement;
241
+ while (parent && parent !== fieldsContainerRef.current) {
242
+ if (parent.classList.contains('proto-nested-content') && parent.classList.contains('hidden')) {
243
+ const parentFieldContainer = parent.closest('.proto-field-container');
244
+ if (parentFieldContainer) {
245
+ const toggleBtn = parentFieldContainer.querySelector('.proto-field-toggle');
246
+ if (toggleBtn && toggleBtn.getAttribute('aria-expanded') === 'false') {
247
+ (toggleBtn as HTMLButtonElement).click();
248
+ }
249
+ }
250
+ }
251
+ // Remove dimming from parent containers
252
+ if (parent.classList.contains('proto-field-container')) {
253
+ parent.classList.remove('search-dimmed');
254
+ }
255
+ parent = parent.parentElement;
256
+ }
257
+ } else {
258
+ container.classList.remove('search-match', 'search-current-match');
259
+ container.classList.add('search-dimmed');
260
+ nameEl.innerHTML = nameEl.textContent || '';
261
+ }
262
+ });
263
+
264
+ setCurrentMatches(matches);
265
+ if (matches.length > 0) {
266
+ setCurrentMatchIndex(0);
267
+ matches[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
268
+ } else {
269
+ setCurrentMatchIndex(-1);
270
+ }
271
+ }, [searchQuery]);
272
+
273
+ // Update match highlighting
274
+ useEffect(() => {
275
+ currentMatches.forEach((match, index) => {
276
+ if (index === currentMatchIndex) {
277
+ match.classList.add('search-current-match');
278
+ } else {
279
+ match.classList.remove('search-current-match');
280
+ }
281
+ });
282
+
283
+ if (currentMatchIndex >= 0 && currentMatches[currentMatchIndex]) {
284
+ currentMatches[currentMatchIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
285
+ }
286
+ }, [currentMatchIndex, currentMatches]);
287
+
288
+ const handleExpandAll = () => {
289
+ setExpandAll(true);
290
+ };
291
+
292
+ const handleCollapseAll = () => {
293
+ setExpandAll(false);
294
+ };
295
+
296
+ const handlePrevMatch = () => {
297
+ if (currentMatchIndex > 0) {
298
+ setCurrentMatchIndex(currentMatchIndex - 1);
299
+ }
300
+ };
301
+
302
+ const handleNextMatch = () => {
303
+ if (currentMatchIndex < currentMatches.length - 1) {
304
+ setCurrentMatchIndex(currentMatchIndex + 1);
305
+ }
306
+ };
307
+
308
+ if (!schema || !Array.isArray(schema.messages)) {
309
+ return (
310
+ <div className="flex items-center justify-center p-8 text-[rgb(var(--ec-page-text-muted))]">
311
+ <p className="text-sm">Invalid Protobuf schema format</p>
312
+ </div>
313
+ );
314
+ }
315
+
316
+ const containerStyle = maxHeight
317
+ ? { maxHeight: maxHeight.includes('px') ? maxHeight : `${maxHeight}px`, minHeight: '15em' }
318
+ : {};
319
+
320
+ const heightClass = maxHeight ? '' : 'h-full';
321
+ const overflowClass = maxHeight ? 'overflow-hidden' : '';
322
+
323
+ return (
324
+ <div
325
+ className={`${heightClass} ${overflowClass} flex flex-col bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))] border border-[rgb(var(--ec-page-border))] rounded-md shadow-xs`}
326
+ style={containerStyle}
327
+ >
328
+ {/* Toolbar */}
329
+ {searchBool && (
330
+ <div className="flex-shrink-0 bg-[rgb(var(--ec-card-bg,var(--ec-page-bg)))] pt-4 px-4 mb-4 pb-3 border-b border-[rgb(var(--ec-page-border))] shadow-xs">
331
+ <div className="flex flex-col sm:flex-row gap-3">
332
+ <div className="flex-1 relative">
333
+ <input
334
+ ref={searchInputRef}
335
+ type="text"
336
+ value={searchQuery}
337
+ onChange={(e) => setSearchQuery(e.target.value)}
338
+ placeholder="Search fields..."
339
+ className="w-full px-3 py-1.5 pr-20 text-sm border border-[rgb(var(--ec-input-border))] bg-[rgb(var(--ec-input-bg))] text-[rgb(var(--ec-input-text))] placeholder:text-[rgb(var(--ec-input-placeholder))] rounded-md focus:outline-hidden focus:ring-2 focus:ring-[rgb(var(--ec-accent))] focus:border-transparent"
340
+ onKeyDown={(e) => {
341
+ if (e.key === 'Enter') {
342
+ e.preventDefault();
343
+ if (e.shiftKey) {
344
+ handlePrevMatch();
345
+ } else {
346
+ handleNextMatch();
347
+ }
348
+ }
349
+ }}
350
+ />
351
+ <div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
352
+ <button
353
+ onClick={handlePrevMatch}
354
+ disabled={currentMatches.length === 0 || currentMatchIndex <= 0}
355
+ className="p-1 text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-page-text))] disabled:opacity-30 disabled:cursor-not-allowed"
356
+ title="Previous match"
357
+ >
358
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
359
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 19l-7-7 7-7" />
360
+ </svg>
361
+ </button>
362
+ <button
363
+ onClick={handleNextMatch}
364
+ disabled={currentMatches.length === 0 || currentMatchIndex >= currentMatches.length - 1}
365
+ className="p-1 text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-page-text))] disabled:opacity-30 disabled:cursor-not-allowed"
366
+ title="Next match"
367
+ >
368
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
369
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
370
+ </svg>
371
+ </button>
372
+ </div>
373
+ </div>
374
+ <div className="flex items-center gap-2">
375
+ {onOpenFullscreen && (
376
+ <button
377
+ onClick={onOpenFullscreen}
378
+ className="px-3 py-1.5 text-xs font-medium text-[rgb(var(--ec-page-text))] bg-[rgb(var(--ec-content-hover))] rounded-md hover:bg-[rgb(var(--ec-content-active))] focus:outline-hidden focus:ring-2 focus:ring-[rgb(var(--ec-accent))]"
379
+ title="Open in fullscreen"
380
+ >
381
+ <svg
382
+ xmlns="http://www.w3.org/2000/svg"
383
+ className="h-3.5 w-3.5 inline-block"
384
+ fill="none"
385
+ viewBox="0 0 24 24"
386
+ stroke="currentColor"
387
+ >
388
+ <path
389
+ strokeLinecap="round"
390
+ strokeLinejoin="round"
391
+ strokeWidth={2}
392
+ d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
393
+ />
394
+ </svg>
395
+ </button>
396
+ )}
397
+ <button
398
+ onClick={handleExpandAll}
399
+ className="px-3 py-1.5 text-xs font-medium text-[rgb(var(--ec-page-text))] bg-[rgb(var(--ec-content-hover))] rounded-md hover:bg-[rgb(var(--ec-content-active))] focus:outline-hidden focus:ring-2 focus:ring-[rgb(var(--ec-accent))]"
400
+ >
401
+ Expand All
402
+ </button>
403
+ <button
404
+ onClick={handleCollapseAll}
405
+ className="px-3 py-1.5 text-xs font-medium text-[rgb(var(--ec-page-text))] bg-[rgb(var(--ec-content-hover))] rounded-md hover:bg-[rgb(var(--ec-content-active))] focus:outline-hidden focus:ring-2 focus:ring-[rgb(var(--ec-accent))]"
406
+ >
407
+ Collapse All
408
+ </button>
409
+ <div className="text-xs text-[rgb(var(--ec-page-text-muted))]">
410
+ {totalFields} {totalFields === 1 ? 'field' : 'fields'}
411
+ </div>
412
+ </div>
413
+ </div>
414
+ {searchQuery && (
415
+ <div className="mt-2 text-xs text-[rgb(var(--ec-page-text-muted))]">
416
+ {currentMatches.length > 0
417
+ ? `${currentMatchIndex + 1} of ${currentMatches.length} ${currentMatches.length === 1 ? 'match' : 'matches'}`
418
+ : 'No fields found'}
419
+ </div>
420
+ )}
421
+ </div>
422
+ )}
423
+
424
+ {/* Messages and enums */}
425
+ <div ref={fieldsContainerRef} className="flex-1 px-4 pb-4 overflow-auto">
426
+ {(schema.package || schema.syntax) && (
427
+ <div className="flex items-baseline gap-2 mb-4">
428
+ {schema.package && (
429
+ <>
430
+ <span className="text-sm font-medium text-[rgb(var(--ec-page-text-muted))]">Package:</span>
431
+ <span className="font-mono text-sm text-blue-600 dark:text-blue-400">{schema.package}</span>
432
+ </>
433
+ )}
434
+ {schema.syntax && <span className="font-mono text-xs text-[rgb(var(--ec-page-text-muted))]">({schema.syntax})</span>}
435
+ </div>
436
+ )}
437
+
438
+ {schema.messages.length > 0 || schema.enums.length > 0 ? (
439
+ <>
440
+ {schema.messages.map((message) => (
441
+ <div key={message.name} className="mt-4 first:mt-0">
442
+ <div className="flex items-baseline gap-2 mb-2">
443
+ <span className="text-sm font-medium text-[rgb(var(--ec-page-text-muted))]">Message:</span>
444
+ <span className="font-mono text-sm text-blue-600 dark:text-blue-400">{message.name}</span>
445
+ </div>
446
+ {message.doc && <p className="text-[rgb(var(--ec-page-text-muted))] text-xs mb-3">{message.doc}</p>}
447
+ {message.fields.length > 0 ? (
448
+ message.fields.map((field) => (
449
+ <ProtobufFieldRow
450
+ key={field.name}
451
+ field={field}
452
+ level={0}
453
+ expand={expandAll}
454
+ showRequired={showRequiredBool}
455
+ registry={registry}
456
+ packageName={schema.package}
457
+ ancestors={[message.name]}
458
+ />
459
+ ))
460
+ ) : (
461
+ <p className="text-xs text-[rgb(var(--ec-page-text-muted))] mb-2">No fields defined</p>
462
+ )}
463
+ </div>
464
+ ))}
465
+
466
+ {schema.enums.map((protoEnum) => (
467
+ <div key={protoEnum.name} className="mt-4">
468
+ <div className="flex items-baseline gap-2 mb-2">
469
+ <span className="text-sm font-medium text-[rgb(var(--ec-page-text-muted))]">Enum:</span>
470
+ <span className="font-mono text-sm text-blue-600 dark:text-blue-400">{protoEnum.name}</span>
471
+ </div>
472
+ {protoEnum.doc && <p className="text-[rgb(var(--ec-page-text-muted))] text-xs mb-2">{protoEnum.doc}</p>}
473
+ <div className="text-xs text-[rgb(var(--ec-page-text-muted))]">
474
+ <span className="text-xs inline-block">Allowed values:</span>
475
+ {protoEnum.values.map((value) => (
476
+ <span key={value.name} className="text-xs">
477
+ {' '}
478
+ <code className="bg-[rgb(var(--ec-content-hover))] px-1 rounded text-[rgb(var(--ec-page-text))] font-thin py-0.5">
479
+ {value.name}
480
+ </code>
481
+ </span>
482
+ ))}
483
+ </div>
484
+ </div>
485
+ ))}
486
+ </>
487
+ ) : (
488
+ <div className="text-center py-8 text-[rgb(var(--ec-icon-color))]">
489
+ <p className="text-sm">No messages defined</p>
490
+ </div>
491
+ )}
492
+
493
+ {searchQuery && currentMatches.length === 0 && (
494
+ <div className="text-center py-8">
495
+ <div className="text-[rgb(var(--ec-icon-color))] text-sm">
496
+ <svg className="mx-auto h-12 w-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
497
+ <path
498
+ strokeLinecap="round"
499
+ strokeLinejoin="round"
500
+ strokeWidth="2"
501
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
502
+ />
503
+ </svg>
504
+ <p>No fields match your search</p>
505
+ <p className="text-xs mt-1">Try a different search term or clear the search to see all fields</p>
506
+ </div>
507
+ </div>
508
+ )}
509
+ </div>
510
+
511
+ <style>{`
512
+ .search-dimmed {
513
+ opacity: 0.4;
514
+ transition: opacity 0.2s ease;
515
+ }
516
+
517
+ .search-match {
518
+ opacity: 1;
519
+ transition: opacity 0.2s ease;
520
+ }
521
+
522
+ .search-current-match {
523
+ background-color: rgba(59, 130, 246, 0.1);
524
+ border-left: 3px solid #3b82f6;
525
+ padding-left: 8px;
526
+ margin-left: -11px;
527
+ transition: all 0.2s ease;
528
+ }
529
+ `}</style>
530
+ </div>
531
+ );
532
+ }
@@ -5,6 +5,7 @@ import { oneLight } from 'react-syntax-highlighter/dist/cjs/styles/prism';
5
5
  import { buildUrl } from '@utils/url-builder';
6
6
  import JSONSchemaViewer from './JSONSchemaViewer';
7
7
  import AvroSchemaViewer from './AvroSchemaViewer';
8
+ import ProtobufSchemaViewer from './ProtobufSchemaViewer';
8
9
  import { getLanguageForHighlight } from './utils';
9
10
  import type { SchemaItem } from './types';
10
11
  import { useDarkMode } from './useDarkMode';
@@ -16,6 +17,7 @@ interface SchemaContentViewerProps {
16
17
  viewMode: 'code' | 'schema' | 'diff';
17
18
  parsedSchema: any;
18
19
  parsedAvroSchema?: any;
20
+ parsedProtoSchema?: any;
19
21
  onOpenFullscreen?: () => void;
20
22
  showRequired?: boolean;
21
23
  }
@@ -27,6 +29,7 @@ export default function SchemaContentViewer({
27
29
  viewMode,
28
30
  parsedSchema,
29
31
  parsedAvroSchema,
32
+ parsedProtoSchema,
30
33
  showRequired = false,
31
34
  onOpenFullscreen,
32
35
  }: SchemaContentViewerProps) {
@@ -45,6 +48,9 @@ export default function SchemaContentViewer({
45
48
  if (parsedAvroSchema) {
46
49
  return <AvroSchemaViewer schema={parsedAvroSchema} onOpenFullscreen={onOpenFullscreen} showRequired={showRequired} />;
47
50
  }
51
+ if (parsedProtoSchema) {
52
+ return <ProtobufSchemaViewer schema={parsedProtoSchema} onOpenFullscreen={onOpenFullscreen} showRequired={showRequired} />;
53
+ }
48
54
  if (parsedSchema) {
49
55
  return <JSONSchemaViewer schema={parsedSchema} onOpenFullscreen={onOpenFullscreen} />;
50
56
  }
@@ -25,6 +25,7 @@ import VersionHistoryModal from './VersionHistoryModal';
25
25
  import SchemaCodeModal from './SchemaCodeModal';
26
26
  import SchemaViewerModal from './SchemaViewerModal';
27
27
  import { copyToClipboard, downloadSchema, getSchemaTypeLabel, ICON_SPECS, extractServiceName } from './utils';
28
+ import { parseProtobufSchema } from '@utils/protobuf-schema';
28
29
  import type { SchemaItem, VersionDiff, Owner, Producer, Consumer } from './types';
29
30
 
30
31
  interface SchemaDetailsPanelProps {
@@ -136,6 +137,21 @@ export default function SchemaDetailsPanel({
136
137
  }
137
138
  }, [message.schemaContent, message.schemaExtension]);
138
139
 
140
+ // Check if this is a Protobuf schema. The extension falls back to the schema
141
+ // format when the schema has no file path, so accept both 'proto' and 'protobuf'.
142
+ const parsedProtoSchema = useMemo(() => {
143
+ const extLower = message.schemaExtension?.toLowerCase();
144
+ const isProtoSchema =
145
+ (extLower === 'proto' || extLower === 'protobuf') && message.schemaContent && message.schemaContent.trim() !== '';
146
+ if (!isProtoSchema) return null;
147
+
148
+ try {
149
+ return parseProtobufSchema(message.schemaContent ?? '');
150
+ } catch {
151
+ return null;
152
+ }
153
+ }, [message.schemaContent, message.schemaExtension]);
154
+
139
155
  const handleCopy = async () => {
140
156
  if (!message.schemaContent) return;
141
157
  const success = await copyToClipboard(message.schemaContent);
@@ -184,7 +200,7 @@ export default function SchemaDetailsPanel({
184
200
  };
185
201
  }, []);
186
202
 
187
- const hasParsedSchema = !!parsedSchema || !!parsedAvroSchema;
203
+ const hasParsedSchema = !!parsedSchema || !!parsedAvroSchema || !!parsedProtoSchema;
188
204
  // Build tabs
189
205
  const tabs: { id: string; label: string; icon: React.ReactNode }[] = [
190
206
  { id: 'code', label: 'Schema', icon: <CodeBracketIcon className="h-3.5 w-3.5" /> },
@@ -335,11 +351,12 @@ export default function SchemaDetailsPanel({
335
351
  viewMode={activeTab === 'schema' ? 'schema' : 'code'}
336
352
  parsedSchema={parsedSchema}
337
353
  parsedAvroSchema={parsedAvroSchema}
354
+ parsedProtoSchema={parsedProtoSchema}
338
355
  showRequired={true}
339
356
  onOpenFullscreen={
340
357
  activeTab === 'code'
341
358
  ? () => setIsCodeModalOpen(true)
342
- : activeTab === 'schema' && (parsedSchema || parsedAvroSchema)
359
+ : activeTab === 'schema' && (parsedSchema || parsedAvroSchema || parsedProtoSchema)
343
360
  ? () => setIsSchemaViewerModalOpen(true)
344
361
  : undefined
345
362
  }
@@ -507,6 +524,7 @@ export default function SchemaDetailsPanel({
507
524
  message={message}
508
525
  parsedSchema={parsedSchema}
509
526
  parsedAvroSchema={parsedAvroSchema}
527
+ parsedProtoSchema={parsedProtoSchema}
510
528
  />
511
529
  </div>
512
530
  );
@@ -18,6 +18,7 @@ const SCHEMA_TYPE_LABELS: Record<string, string> = {
18
18
  avro: 'Avro',
19
19
  avsc: 'Avro',
20
20
  proto: 'Protobuf',
21
+ protobuf: 'Protobuf',
21
22
  yaml: 'YAML',
22
23
  yml: 'YAML',
23
24
  xml: 'XML',
@@ -80,8 +81,16 @@ export default function SchemaExplorer({ schemas, apiAccessEnabled = false }: Sc
80
81
 
81
82
  const [showFormatFilters, setShowFormatFilters] = useState(() => {
82
83
  if (typeof window !== 'undefined') {
83
- const stored = localStorage.getItem('schemaRegistrySelectedSchemaType');
84
- return stored !== null && stored !== 'all';
84
+ const storedSchemaType = localStorage.getItem('schemaRegistrySelectedSchemaType');
85
+ if (storedSchemaType !== null && storedSchemaType !== 'all') return true;
86
+ const storedTypes = localStorage.getItem('schemaRegistrySelectedTypes');
87
+ if (storedTypes) {
88
+ try {
89
+ return JSON.parse(storedTypes).length > 0;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
85
94
  }
86
95
  return false;
87
96
  });
@@ -396,9 +405,9 @@ export default function SchemaExplorer({ schemas, apiAccessEnabled = false }: Sc
396
405
  {schemaTypes.length > 0 && (
397
406
  <button
398
407
  onClick={() => setShowFormatFilters((prev) => !prev)}
399
- aria-pressed={showFormatFilters || selectedSchemaType !== 'all'}
408
+ aria-pressed={showFormatFilters}
400
409
  className={`relative inline-flex h-9 w-9 items-center justify-center rounded-lg border transition-all ${
401
- showFormatFilters || selectedSchemaType !== 'all'
410
+ showFormatFilters || activeFilterCount > 0
402
411
  ? 'border-[rgb(var(--ec-accent)/0.5)] bg-[rgb(var(--ec-accent))] text-white shadow-[0_10px_28px_rgb(var(--ec-accent)/0.3)]'
403
412
  : 'border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-dropdown-bg))] text-[rgb(var(--ec-page-text-muted))] hover:border-[rgb(var(--ec-page-text-muted)/0.45)] hover:text-[rgb(var(--ec-page-text))]'
404
413
  }`}
@@ -413,7 +422,7 @@ export default function SchemaExplorer({ schemas, apiAccessEnabled = false }: Sc
413
422
  )}
414
423
  </div>
415
424
 
416
- {(showFormatFilters || selectedSchemaType !== 'all' || selectedTypes.size > 0) && (
425
+ {showFormatFilters && (
417
426
  <div className="flex-shrink-0 border-b border-[rgb(var(--ec-page-border))] px-4 py-3">
418
427
  <div className="rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-content-hover)/0.45)] p-3">
419
428
  <div className="mb-3 flex items-center justify-between gap-3">