@kitlangton/motel 0.1.1 → 0.1.2

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.
@@ -5,6 +5,12 @@ import type { TraceItem, TraceSummaryItem } from "../domain.ts"
5
5
  import { otelServerInstructions } from "../instructions.ts"
6
6
  import { copyToClipboard, traceUiUrl, webUiUrl } from "./format.ts"
7
7
  import {
8
+ activeAttrKeyAtom,
9
+ activeAttrValueAtom,
10
+ attrFacetStateAtom,
11
+ attrPickerIndexAtom,
12
+ attrPickerInputAtom,
13
+ attrPickerModeAtom,
8
14
  autoRefreshAtom,
9
15
  collapsedSpanIdsAtom,
10
16
  detailViewAtom,
@@ -22,6 +28,7 @@ import {
22
28
  type TraceSortMode,
23
29
  traceStateAtom,
24
30
  } from "./state.ts"
31
+ import { filterFacets } from "./AttrFilterModal.tsx"
25
32
  import { G_PREFIX_TIMEOUT_MS } from "./theme.ts"
26
33
  import { cycleThemeName, themeLabel } from "./theme.ts"
27
34
  import { getVisibleSpans } from "./Waterfall.tsx"
@@ -65,6 +72,12 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
65
72
  const [filterMode, setFilterMode] = useAtom(filterModeAtom)
66
73
  const [filterText, setFilterText] = useAtom(filterTextAtom)
67
74
  const [traceSort, setTraceSort] = useAtom(traceSortAtom)
75
+ const [pickerMode, setPickerMode] = useAtom(attrPickerModeAtom)
76
+ const [pickerInput, setPickerInput] = useAtom(attrPickerInputAtom)
77
+ const [pickerIndex, setPickerIndex] = useAtom(attrPickerIndexAtom)
78
+ const [attrFacets] = useAtom(attrFacetStateAtom)
79
+ const [activeAttrKey, setActiveAttrKey] = useAtom(activeAttrKeyAtom)
80
+ const [activeAttrValue, setActiveAttrValue] = useAtom(activeAttrValueAtom)
68
81
 
69
82
  const pendingGRef = useRef(false)
70
83
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -73,12 +86,12 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
73
86
  const spanNavActive = detailView !== "service-logs" && selectedSpanIndex !== null
74
87
  const serviceLogNavActive = detailView === "service-logs"
75
88
 
76
- const stateRef = useRef({ traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, ...params })
89
+ const stateRef = useRef({ traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, ...params })
77
90
  // Keep the keyboard handler's state mirror in sync before the next paint.
78
91
  // OpenTUI's own effect-event helper uses useLayoutEffect for this same reason:
79
92
  // rapid repeated keypresses can otherwise observe stale selection state.
80
93
  useLayoutEffect(() => {
81
- stateRef.current = { traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, ...params }
94
+ stateRef.current = { traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, ...params }
82
95
  })
83
96
 
84
97
  const clearPendingG = () => {
@@ -230,7 +243,8 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
230
243
  if (s.serviceLogState.data.length === 0) return 0
231
244
  return Math.max(0, Math.min(current + direction * serviceLogPageSize, s.serviceLogState.data.length - 1))
232
245
  })
233
- } else if (s.spanNavActive && s.selectedTrace) {
246
+ } else if (s.spanNavActive) {
247
+ if (!s.selectedTrace) return
234
248
  const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
235
249
  setSelectedSpanIndex((current) => {
236
250
  if (visibleCount === 0) return null
@@ -257,6 +271,66 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
257
271
  useKeyboard((key) => {
258
272
  const s = $()
259
273
 
274
+ // Attribute picker modal owns the keyboard while open.
275
+ if (s.pickerMode !== "off") {
276
+ const rows = filterFacets(s.attrFacets.data, s.pickerInput)
277
+ const clampedIndex = rows.length === 0 ? 0 : Math.max(0, Math.min(s.pickerIndex, rows.length - 1))
278
+ const move = (delta: number) => {
279
+ if (rows.length === 0) return
280
+ setPickerIndex(Math.max(0, Math.min(clampedIndex + delta, rows.length - 1)))
281
+ }
282
+ if (key.name === "escape") {
283
+ setPickerMode("off")
284
+ setPickerInput("")
285
+ setPickerIndex(0)
286
+ return
287
+ }
288
+ if (key.name === "up" || (key.ctrl && key.name === "p")) { move(-1); return }
289
+ if (key.name === "down" || (key.ctrl && key.name === "n")) { move(1); return }
290
+ if (key.name === "pageup") { move(-10); return }
291
+ if (key.name === "pagedown") { move(10); return }
292
+ if (key.name === "return" || key.name === "enter") {
293
+ const row = rows[clampedIndex]
294
+ if (!row) return
295
+ if (s.pickerMode === "keys") {
296
+ // Drill from keys → values for this key.
297
+ setActiveAttrKey(row.value)
298
+ setPickerMode("values")
299
+ setPickerInput("")
300
+ setPickerIndex(0)
301
+ } else {
302
+ // Apply: activeAttrKey is already set, now pin the value.
303
+ setActiveAttrValue(row.value)
304
+ setPickerMode("off")
305
+ setPickerInput("")
306
+ setPickerIndex(0)
307
+ s.flashNotice(`Filter: ${s.activeAttrKey}=${row.value}`)
308
+ }
309
+ return
310
+ }
311
+ if (key.name === "backspace") {
312
+ if (s.pickerInput.length > 0) {
313
+ setPickerInput(s.pickerInput.slice(0, -1))
314
+ setPickerIndex(0)
315
+ return
316
+ }
317
+ // At empty input in values mode, backspace walks back to keys.
318
+ if (s.pickerMode === "values") {
319
+ setPickerMode("keys")
320
+ setActiveAttrKey(null)
321
+ setPickerIndex(0)
322
+ return
323
+ }
324
+ return
325
+ }
326
+ if (key.name.length === 1 && !key.ctrl && !key.meta) {
327
+ setPickerInput(s.pickerInput + key.name)
328
+ setPickerIndex(0)
329
+ return
330
+ }
331
+ return
332
+ }
333
+
260
334
  // Filter mode: capture text input
261
335
  if (s.filterMode) {
262
336
  if (key.name === "escape") {
@@ -365,6 +439,15 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
365
439
  setSelectedSpanIndex(null)
366
440
  return
367
441
  }
442
+ // At the trace list, `esc` clears any applied attribute filter so
443
+ // there's a clean way back to the unfiltered list without hunting
444
+ // for the picker key.
445
+ if (s.activeAttrKey || s.activeAttrValue) {
446
+ setActiveAttrKey(null)
447
+ setActiveAttrValue(null)
448
+ s.flashNotice("Cleared attribute filter")
449
+ return
450
+ }
368
451
  return
369
452
  }
370
453
  if (key.name === "return" || key.name === "enter") {
@@ -416,6 +499,15 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
416
499
  setFilterMode(true)
417
500
  return
418
501
  }
502
+ if ((key.name === "f" || key.name === "F") && !key.ctrl && !key.meta) {
503
+ // Open attribute picker at the keys step. If a filter is already
504
+ // applied, reopening lets the user refine or switch.
505
+ setPickerMode("keys")
506
+ setPickerInput("")
507
+ setPickerIndex(0)
508
+ setActiveAttrKey(null)
509
+ return
510
+ }
419
511
  if (key.name === "tab") {
420
512
  toggleServiceLogsView()
421
513
  return
@@ -431,12 +523,17 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
431
523
  if (key.name === "up" || key.name === "k") {
432
524
  if (s.serviceLogNavActive) {
433
525
  moveServiceLogBy(-1)
434
- } else if (s.spanNavActive && s.selectedTrace) {
435
- const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
436
- setSelectedSpanIndex((current) => {
437
- if (current === null || visibleCount === 0) return 0
438
- return Math.max(0, current - 1)
439
- })
526
+ } else if (s.spanNavActive) {
527
+ // Locked to span nav; never fall through to trace-list nav while
528
+ // drilled in. If the trace detail is still loading, swallow the
529
+ // key instead of silently leaking it to the trace list.
530
+ if (s.selectedTrace) {
531
+ const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
532
+ setSelectedSpanIndex((current) => {
533
+ if (current === null || visibleCount === 0) return 0
534
+ return Math.max(0, current - 1)
535
+ })
536
+ }
440
537
  } else {
441
538
  moveTraceBy(-1)
442
539
  }
@@ -445,12 +542,14 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
445
542
  if (key.name === "down" || key.name === "j") {
446
543
  if (s.serviceLogNavActive) {
447
544
  moveServiceLogBy(1)
448
- } else if (s.spanNavActive && s.selectedTrace) {
449
- const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
450
- setSelectedSpanIndex((current) => {
451
- if (current === null || visibleCount === 0) return 0
452
- return Math.min(current + 1, visibleCount - 1)
453
- })
545
+ } else if (s.spanNavActive) {
546
+ if (s.selectedTrace) {
547
+ const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
548
+ setSelectedSpanIndex((current) => {
549
+ if (current === null || visibleCount === 0) return 0
550
+ return Math.min(current + 1, visibleCount - 1)
551
+ })
552
+ }
454
553
  } else {
455
554
  moveTraceBy(1)
456
555
  }
@@ -3,7 +3,8 @@ import type { TraceSpanItem } from "../domain.ts"
3
3
  import {
4
4
  findFirstChildIndex,
5
5
  findParentIndex,
6
- getWaterfallColumns,
6
+ getWaterfallLayout,
7
+ getWaterfallSuffixMetrics,
7
8
  getVisibleSpans,
8
9
  } from "./Waterfall.tsx"
9
10
  import { resolveCollapseStep } from "./waterfallNav.ts"
@@ -130,13 +131,27 @@ describe("getVisibleSpans", () => {
130
131
  })
131
132
  })
132
133
 
133
- describe("getWaterfallColumns", () => {
134
- it("pads duration and log columns to fill the reserved width", () => {
134
+ describe("getWaterfallSuffixMetrics", () => {
135
+ it("uses the widest visible duration as the shared suffix width", () => {
136
+ const spans = [
137
+ { spanId: "a", durationMs: 1 },
138
+ { spanId: "b", durationMs: 57_000 },
139
+ { spanId: "c", durationMs: 120 },
140
+ ]
141
+ const metrics = getWaterfallSuffixMetrics(spans)
142
+ // `120ms` = 5 is the widest
143
+ expect(metrics.maxDurationWidth).toBe(5)
144
+ expect(metrics.suffixWidth).toBe(5)
145
+ })
146
+
147
+ it("layout reserves the suffix once and leaves the rest for the bar", () => {
135
148
  const contentWidth = 72
136
- const columns = getWaterfallColumns(contentWidth, 153_000, 1, 0)
137
- expect(columns.durationCell.length).toBe(columns.durationWidth)
138
- expect(columns.logCell.length).toBe(columns.logWidth)
139
- expect(columns.labelMaxWidth + 1 + columns.barWidth + 1 + columns.durationCell.length + columns.logCell.length).toBe(contentWidth)
149
+ const metrics = getWaterfallSuffixMetrics(
150
+ [{ spanId: "a", durationMs: 57_000 }, { spanId: "b", durationMs: 1 }],
151
+ )
152
+ const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, metrics.suffixWidth)
153
+ // label + 1 (gap before bar) + bar + 1 (gap before suffix) + suffix = contentWidth
154
+ expect(labelMaxWidth + 1 + barWidth + 1 + metrics.suffixWidth).toBe(contentWidth)
140
155
  })
141
156
  })
142
157