@crvouga/atlas 0.2.0 → 0.4.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 (111) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +21 -2
  3. package/dist/reports/src/evidence-live.d.ts +6 -0
  4. package/dist/reports/src/evidence-live.js +35 -3
  5. package/dist/reports/src/evidence-types.d.ts +2 -0
  6. package/dist/reports/src/evidence-view.d.ts +15 -2
  7. package/dist/reports/src/evidence-view.js +31 -3
  8. package/dist/reports/src/export-evidence.js +2 -10
  9. package/dist/reports/src/frames.d.ts +3 -1
  10. package/dist/reports/src/frames.js +21 -0
  11. package/dist/reports/src/layer-axes.d.ts +18 -0
  12. package/dist/reports/src/layer-axes.js +51 -0
  13. package/dist/reports/src/layer-catalog.js +1 -1
  14. package/dist/reports/src/layer-merge.d.ts +1 -1
  15. package/dist/reports/src/layer-merge.js +9 -4
  16. package/dist/reports/src/layer-source.js +3 -0
  17. package/dist/reports/src/layer-types.d.ts +2 -0
  18. package/dist/reports/src/layers-route.js +2 -5
  19. package/dist/reports/src/protocol.d.ts +4 -0
  20. package/dist/reports/src/publish.d.ts +2 -0
  21. package/dist/reports/src/publish.js +6 -11
  22. package/dist/reports/src/recording-keys.js +4 -0
  23. package/dist/reports/src/report-parts.js +3 -0
  24. package/dist/reports/src/report-types.d.ts +19 -2
  25. package/dist/reports/src/source-entries.js +2 -12
  26. package/package.json +5 -4
  27. package/packages/reports/README.md +4 -2
  28. package/packages/reports/STORAGE.md +1 -1
  29. package/packages/visualizer/README.md +9 -6
  30. package/packages/visualizer/example/specs/coffee/ordering/cart.qnt +7 -0
  31. package/packages/visualizer/example/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  32. package/packages/visualizer/example/specs/coffee/ordering/drink-price.dmn +24 -0
  33. package/packages/visualizer/example/specs/coffee/ordering/loyalty.feature +7 -0
  34. package/packages/visualizer/example/specs/coffee/ordering/ordering.feature +36 -0
  35. package/packages/visualizer/example/specs/coffee/ordering/payment.als +4 -0
  36. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/cart.qnt +7 -0
  37. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  38. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/drink-price.dmn +24 -0
  39. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/loyalty.feature +7 -0
  40. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/ordering.feature +36 -0
  41. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/payment.als +4 -0
  42. package/packages/visualizer/fixtures/full/specs/coffee/ordering/cart.qnt +7 -0
  43. package/packages/visualizer/fixtures/full/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  44. package/packages/visualizer/fixtures/full/specs/coffee/ordering/drink-price.dmn +24 -0
  45. package/packages/visualizer/fixtures/full/specs/coffee/ordering/loyalty.feature +7 -0
  46. package/packages/visualizer/fixtures/full/specs/coffee/ordering/ordering.feature +36 -0
  47. package/packages/visualizer/fixtures/full/specs/coffee/ordering/payment.als +4 -0
  48. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/cart.qnt +7 -0
  49. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  50. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/drink-price.dmn +24 -0
  51. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/loyalty.feature +7 -0
  52. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/ordering.feature +36 -0
  53. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/payment.als +4 -0
  54. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/cart.qnt +7 -0
  55. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  56. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/drink-price.dmn +24 -0
  57. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/loyalty.feature +7 -0
  58. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/ordering.feature +36 -0
  59. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/payment.als +4 -0
  60. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/cart.qnt +7 -0
  61. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  62. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/drink-price.dmn +24 -0
  63. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/loyalty.feature +7 -0
  64. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/ordering.feature +36 -0
  65. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/payment.als +4 -0
  66. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/cart.qnt +7 -0
  67. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  68. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/drink-price.dmn +24 -0
  69. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/loyalty.feature +7 -0
  70. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/ordering.feature +36 -0
  71. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/payment.als +4 -0
  72. package/packages/visualizer/scripts/make-fixtures.ts +2 -0
  73. package/packages/visualizer/scripts/publish-data.ts +1 -2
  74. package/packages/visualizer/src/app/AppShell.tsx +11 -12
  75. package/packages/visualizer/src/app/gazetteer.module.css +12 -2
  76. package/packages/visualizer/src/app/gazetteer.tsx +38 -26
  77. package/packages/visualizer/src/components/Header.module.css +20 -4
  78. package/packages/visualizer/src/components/Header.tsx +7 -4
  79. package/packages/visualizer/src/data/model/build.ts +17 -5
  80. package/packages/visualizer/src/data/model/types.ts +10 -1
  81. package/packages/visualizer/src/evidence/api.ts +6 -1
  82. package/packages/visualizer/src/evidence/check-detail.tsx +35 -3
  83. package/packages/visualizer/src/evidence/evidence.module.css +20 -0
  84. package/packages/visualizer/src/evidence/marks.tsx +2 -0
  85. package/packages/visualizer/src/evidence/overview.tsx +3 -3
  86. package/packages/visualizer/src/evidence/recording-page.tsx +19 -2
  87. package/packages/visualizer/src/evidence/recording.module.css +4 -8
  88. package/packages/visualizer/src/evidence/recording.tsx +74 -83
  89. package/packages/visualizer/src/evidence/snapshot.tsx +5 -4
  90. package/packages/visualizer/src/evidence/sources-panel.tsx +88 -34
  91. package/packages/visualizer/src/evidence/spec-index.tsx +3 -11
  92. package/packages/visualizer/src/find/Finder.module.css +168 -0
  93. package/packages/visualizer/src/find/Finder.tsx +303 -0
  94. package/packages/visualizer/src/find/corpus.test.ts +77 -0
  95. package/packages/visualizer/src/find/corpus.ts +368 -0
  96. package/packages/visualizer/src/find/open.ts +51 -0
  97. package/packages/visualizer/src/find/rank.test.ts +237 -0
  98. package/packages/visualizer/src/find/rank.ts +405 -0
  99. package/packages/visualizer/src/find/semantic.test.ts +62 -0
  100. package/packages/visualizer/src/find/semantic.ts +93 -0
  101. package/packages/visualizer/src/find/semantic.worker.ts +40 -0
  102. package/packages/visualizer/src/looks/Stage.tsx +322 -0
  103. package/packages/visualizer/src/looks/collect.test.ts +158 -0
  104. package/packages/visualizer/src/looks/collect.ts +245 -0
  105. package/packages/visualizer/src/looks/stage.module.css +300 -0
  106. package/packages/visualizer/src/reachable.test.ts +1 -1
  107. package/packages/visualizer/src/state/ui-store.ts +6 -2
  108. package/packages/visualizer/src/views/SidePanel.tsx +39 -49
  109. package/packages/visualizer/vite/example-reports.ts +475 -0
  110. package/packages/visualizer/vite.config.ts +9 -1
  111. package/packages/visualizer/src/app/search-query.tsx +0 -14
@@ -2,7 +2,6 @@ import { addSummaries, NO_EVIDENCE, type SpecSummary } from '@crvouga/atlas/repo
2
2
  import { Link, useSearch } from '@tanstack/react-router';
3
3
  import { useMemo } from 'react';
4
4
 
5
- import { useSearchQuery } from '../app/search-query';
6
5
  import { folderOf, outlinePath, specFile, type SpecKind } from '../spec-kind';
7
6
  import { useUiStore, type ResultFilter } from '../state/ui-store';
8
7
  import { useEvidence } from './evidence-context';
@@ -16,15 +15,9 @@ const FILTERS: readonly { readonly id: ResultFilter; readonly label: string; rea
16
15
  { id: 'none', label: 'None', keeps: (summary) => summary.status === 'none' }
17
16
  ];
18
17
 
19
- function hit(query: string, text: string) {
20
- const needle = query.trim().toLowerCase();
21
- return needle.length === 0 || text.toLowerCase().includes(needle);
22
- }
23
-
24
18
  /** Every spec of one kind, grouped by folder, each with what the combined report holds for it. */
25
19
  export function SpecIndex({ kind }: { kind: SpecKind }) {
26
20
  const evidence = useEvidence();
27
- const { query } = useSearchQuery();
28
21
  const search = useSearch({ strict: false });
29
22
  const filter = useUiStore((s) => s.resultFilter);
30
23
  const setFilter = useUiStore((s) => s.setResultFilter);
@@ -34,9 +27,8 @@ export function SpecIndex({ kind }: { kind: SpecKind }) {
34
27
  // `titleOf` reads the outline the view was built from.
35
28
  [evidence.files, evidence.view, kind]
36
29
  );
37
- const searched = rows.filter((row) => hit(query, `${row.title} ${row.file}`));
38
30
  const keeps = FILTERS.find((entry) => entry.id === filter)!.keeps;
39
- const visible = searched.filter((row) => keeps(row.summary));
31
+ const visible = rows.filter((row) => keeps(row.summary));
40
32
  const groups = useMemo(() => {
41
33
  const byFolder = new Map<string, typeof visible>();
42
34
  for (const row of visible) byFolder.set(folderOf(row.file), [...(byFolder.get(folderOf(row.file)) ?? []), row]);
@@ -47,7 +39,7 @@ export function SpecIndex({ kind }: { kind: SpecKind }) {
47
39
  <div className={styles.filters} role="group" aria-label="Show">
48
40
  {FILTERS.map((entry) => (
49
41
  <button key={entry.id} type="button" aria-pressed={filter === entry.id} onClick={() => setFilter(entry.id)}>
50
- {entry.label} <span>{searched.filter((row) => entry.keeps(row.summary)).length}</span>
42
+ {entry.label} <span>{rows.filter((row) => entry.keeps(row.summary)).length}</span>
51
43
  </button>
52
44
  ))}
53
45
  </div>
@@ -55,7 +47,7 @@ export function SpecIndex({ kind }: { kind: SpecKind }) {
55
47
  {evidence.filesError && <p className={styles.indexNote}>The file list could not be loaded.</p>}
56
48
  {!evidence.filesPending && !evidence.filesError && visible.length === 0 && (
57
49
  <p className={styles.indexNote}>
58
- {rows.length === 0 ? `No ${kind.many} in this spec set.` : query.trim() ? 'Nothing matches.' : filter === 'failed' ? `No failing ${kind.many}.` : filter === 'results' ? `No ${kind.many} with results yet.` : `Every ${kind.one} has results.`}
50
+ {rows.length === 0 ? `No ${kind.many} in this spec set.` : filter === 'failed' ? `No failing ${kind.many}.` : filter === 'results' ? `No ${kind.many} with results yet.` : `Every ${kind.one} has results.`}
59
51
  </p>
60
52
  )}
61
53
  {groups.map(([folder, entries]) => (
@@ -0,0 +1,168 @@
1
+ .overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ z-index: 40;
5
+ background: rgba(15, 20, 25, 0.28);
6
+ }
7
+
8
+ .content {
9
+ position: fixed;
10
+ z-index: 41;
11
+ top: 12vh;
12
+ left: 50%;
13
+ width: min(40rem, calc(100vw - 1.5rem));
14
+ transform: translateX(-50%);
15
+ border: 1px solid var(--line);
16
+ border-radius: var(--radius-lg);
17
+ background: var(--bg);
18
+ box-shadow: var(--shadow-pop);
19
+ overflow: hidden;
20
+ }
21
+
22
+ .command {
23
+ display: flex;
24
+ flex-direction: column;
25
+ max-height: min(32rem, 70vh);
26
+ }
27
+
28
+ .input {
29
+ width: 100%;
30
+ height: 3.25rem;
31
+ padding: 0 1rem;
32
+ border: 0;
33
+ border-bottom: 1px solid var(--line);
34
+ background: transparent;
35
+ font-size: 1rem;
36
+ }
37
+
38
+ .input:focus {
39
+ outline: none;
40
+ }
41
+
42
+ .list {
43
+ min-height: 0;
44
+ overflow: auto;
45
+ padding: 0.35rem;
46
+ }
47
+
48
+ .empty {
49
+ margin: 0;
50
+ padding: 1.25rem 0.75rem;
51
+ color: var(--ink-3);
52
+ }
53
+
54
+ .item {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 0.65rem;
58
+ width: 100%;
59
+ padding: 0.45rem 0.6rem;
60
+ border-radius: var(--radius-sm);
61
+ cursor: pointer;
62
+ }
63
+
64
+ .item[data-selected='true'] {
65
+ background: var(--place-soft);
66
+ }
67
+
68
+ .text {
69
+ display: flex;
70
+ flex-direction: column;
71
+ min-width: 0;
72
+ flex: 1;
73
+ }
74
+
75
+ .title {
76
+ overflow: hidden;
77
+ color: var(--ink);
78
+ font-weight: 600;
79
+ text-overflow: ellipsis;
80
+ white-space: nowrap;
81
+ }
82
+
83
+ .snippet {
84
+ overflow: hidden;
85
+ color: var(--ink-3);
86
+ font-size: 0.8125rem;
87
+ text-overflow: ellipsis;
88
+ white-space: nowrap;
89
+ }
90
+
91
+ .meta {
92
+ display: flex;
93
+ align-items: center;
94
+ gap: 0.5rem;
95
+ flex: none;
96
+ max-width: 45%;
97
+ color: var(--ink-3);
98
+ font-size: 0.75rem;
99
+ }
100
+
101
+ .subtitle {
102
+ overflow: hidden;
103
+ text-overflow: ellipsis;
104
+ white-space: nowrap;
105
+ }
106
+
107
+ .kind {
108
+ flex: none;
109
+ font-weight: 600;
110
+ }
111
+
112
+ .footer {
113
+ display: flex;
114
+ gap: 1rem;
115
+ padding: 0.45rem 0.9rem;
116
+ border-top: 1px solid var(--line);
117
+ color: var(--ink-3);
118
+ font-size: 0.75rem;
119
+ }
120
+
121
+ .help {
122
+ position: fixed;
123
+ z-index: 42;
124
+ top: 18vh;
125
+ left: 50%;
126
+ width: min(26rem, calc(100vw - 1.5rem));
127
+ transform: translateX(-50%);
128
+ padding: 1.25rem 1.35rem 1rem;
129
+ border: 1px solid var(--line);
130
+ border-radius: var(--radius-lg);
131
+ background: var(--bg);
132
+ box-shadow: var(--shadow-pop);
133
+ }
134
+
135
+ .help h2 {
136
+ margin: 0 0 0.75rem;
137
+ font-size: 1rem;
138
+ }
139
+
140
+ .help ul {
141
+ margin: 0;
142
+ padding: 0;
143
+ list-style: none;
144
+ }
145
+
146
+ .help li {
147
+ display: flex;
148
+ justify-content: space-between;
149
+ gap: 1rem;
150
+ padding: 0.35rem 0;
151
+ border-top: 1px solid var(--line);
152
+ }
153
+
154
+ .help kbd {
155
+ color: var(--ink);
156
+ font-family: var(--font);
157
+ font-weight: 600;
158
+ }
159
+
160
+ .help button {
161
+ margin-top: 0.85rem;
162
+ padding: 0.35rem 0.8rem;
163
+ border: 1px solid var(--line-2);
164
+ border-radius: var(--pill);
165
+ background: var(--bg);
166
+ font-weight: 600;
167
+ cursor: pointer;
168
+ }
@@ -0,0 +1,303 @@
1
+ import { Command } from 'cmdk';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { useNavigate, useSearch } from '@tanstack/react-router';
4
+ import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
5
+
6
+ import { useAtlas } from '../app/atlas-context';
7
+ import { keys, specKey, useAdapter } from '../data/queries';
8
+ import { StatusMark } from '../evidence/marks';
9
+ import { useEvidence } from '../evidence/evidence-context';
10
+ import { kindOfFile } from '../spec-kind';
11
+ import { corpusFrom } from './corpus';
12
+ import { setFinderOpen, setHelpOpen, toggleFinder, useFinderOpen, useHelpOpen, isHelpOpen } from './open';
13
+ import { FIND_GROUPS, indexCorpus, notationOf, rankIndex, splitQuery, type FindDoc, type FindKind, type FindTarget, type Ranked } from './rank';
14
+ import { applySemantic, createWorkerEmbedder, embeddingsAvailable, embedText, textHash, type Embedder } from './semantic';
15
+ import styles from './Finder.module.css';
16
+
17
+ const KIND_WORD: Record<FindKind, string> = {
18
+ journey: 'Journey',
19
+ event: 'Action',
20
+ screen: 'Place',
21
+ spec: 'Spec',
22
+ scenario: 'Scenario',
23
+ action: 'Action',
24
+ run: 'Run'
25
+ };
26
+
27
+ const SHORTCUTS: readonly { readonly keys: string; readonly does: string }[] = [
28
+ { keys: '⌘K or Ctrl+K', does: 'Find a flow' },
29
+ { keys: '/', does: 'Find a flow' },
30
+ { keys: '↑ ↓', does: 'Move through the list' },
31
+ { keys: 'Enter', does: 'Open' },
32
+ { keys: '⌘Enter or Ctrl+Enter', does: 'Open the recording' },
33
+ { keys: 'Esc', does: 'Close' },
34
+ { keys: '?', does: 'Show these shortcuts' },
35
+ { keys: '← →', does: 'Previous and next moment' },
36
+ { keys: 'Home End', does: 'Start and end of a recording' }
37
+ ];
38
+
39
+ function commandKey() {
40
+ return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K';
41
+ }
42
+
43
+ function isTypingTarget(target: EventTarget | null) {
44
+ if (!(target instanceof HTMLElement)) return false;
45
+ const tag = target.tagName;
46
+ return target.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON' || tag === 'A';
47
+ }
48
+
49
+ function useSpecTexts(files: readonly string[]) {
50
+ const adapter = useAdapter();
51
+ const client = useQueryClient();
52
+ const source = specKey(adapter);
53
+ const cache = useRef(new Map<string, string>());
54
+ const [texts, setTexts] = useState<ReadonlyMap<string, string>>(() => new Map());
55
+ const [pending, setPending] = useState(false);
56
+ const key = files.join('\n');
57
+ useEffect(() => {
58
+ const wanted = key.length === 0 ? [] : key.split('\n');
59
+ let cancelled = false;
60
+ const missing = wanted.filter((file) => !cache.current.has(file));
61
+ if (missing.length === 0) {
62
+ setPending(false);
63
+ return;
64
+ }
65
+ setPending(true);
66
+ let cursor = 0;
67
+ const pull = async () => {
68
+ for (;;) {
69
+ const file = missing[cursor++];
70
+ if (file === undefined || cancelled) return;
71
+ let text = '';
72
+ try {
73
+ text = await client.fetchQuery({ queryKey: keys.specFile(source, file), queryFn: () => adapter.specFile(file) });
74
+ } catch {
75
+ text = '';
76
+ }
77
+ if (cancelled) return;
78
+ cache.current.set(file, text);
79
+ setTexts(new Map(cache.current));
80
+ }
81
+ };
82
+ void Promise.all([pull(), pull(), pull(), pull()]).then(() => {
83
+ if (!cancelled) setPending(false);
84
+ });
85
+ return () => {
86
+ cancelled = true;
87
+ };
88
+ }, [adapter, client, key, source]);
89
+ return { texts, pending };
90
+ }
91
+
92
+ /** The vector cache outlives a keystroke, so a live run does not embed the suite again. */
93
+ const vectorCache = new Map<string, Float32Array>();
94
+
95
+ function vectorsFor(docs: readonly FindDoc[]): ReadonlyMap<string, Float32Array> {
96
+ const vectors = new Map<string, Float32Array>();
97
+ for (const doc of docs) {
98
+ const vector = vectorCache.get(textHash(embedText(doc)));
99
+ if (vector) vectors.set(doc.id, vector);
100
+ }
101
+ return vectors;
102
+ }
103
+
104
+ export function Finder() {
105
+ const open = useFinderOpen();
106
+ const help = useHelpOpen();
107
+ const { view } = useAtlas();
108
+ const evidence = useEvidence();
109
+ const navigate = useNavigate();
110
+ const routeSearch = useSearch({ strict: false });
111
+ const readable = useMemo(() => evidence.files.filter((file) => kindOfFile(file)), [evidence.files]);
112
+ const { texts, pending } = useSpecTexts(readable);
113
+ const docs = useMemo(() => corpusFrom(view, evidence.files, evidence.titleOf, evidence.view, texts), [view, evidence.files, evidence.titleOf, evidence.view, texts]);
114
+ const index = useMemo(() => indexCorpus(docs), [docs]);
115
+ const [query, setQuery] = useState('');
116
+ const [picked, setPicked] = useState('');
117
+ const lexical = useMemo(() => rankIndex(index, query), [index, query]);
118
+ const [embedded, setEmbedded] = useState<{ readonly query: string; readonly vector: Float32Array } | null>(null);
119
+ const [vectors, setVectors] = useState<ReadonlyMap<string, Float32Array>>(() => new Map());
120
+ const queryVector = embedded !== null && embedded.query === query.trim() ? embedded.vector : null;
121
+ const searchable = useMemo(() => {
122
+ const notations = splitQuery(query).notations;
123
+ if (notations.size === 0) return docs;
124
+ return docs.filter((doc) => notations.has(notationOf(doc)));
125
+ }, [docs, query]);
126
+ const shown = queryVector ? applySemantic(searchable, lexical, queryVector, vectors) : lexical;
127
+ const ordered = useMemo(() => FIND_GROUPS.flatMap((group) => shown.filter((hit) => hit.doc.group === group)), [shown]);
128
+ const selected = ordered.some((hit) => hit.doc.id === picked) ? picked : (ordered[0]?.doc.id ?? '');
129
+ const embedder = useRef<Embedder | null>(null);
130
+ const embedFailed = useRef(false);
131
+
132
+ useEffect(() => {
133
+ if (open) return;
134
+ setQuery('');
135
+ setPicked('');
136
+ }, [open]);
137
+
138
+ useEffect(() => {
139
+ const onKey = (event: KeyboardEvent) => {
140
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k' && !event.altKey && !event.shiftKey) {
141
+ event.preventDefault();
142
+ toggleFinder();
143
+ return;
144
+ }
145
+ if (event.key === 'Escape' && isHelpOpen()) {
146
+ event.preventDefault();
147
+ setHelpOpen(false);
148
+ return;
149
+ }
150
+ if (isTypingTarget(event.target)) return;
151
+ if ((event.key === '?' || (event.key === '/' && event.shiftKey)) && !event.metaKey && !event.ctrlKey && !event.altKey) {
152
+ event.preventDefault();
153
+ setHelpOpen(true);
154
+ return;
155
+ }
156
+ if (event.key === '/' && !event.metaKey && !event.ctrlKey && !event.altKey) {
157
+ event.preventDefault();
158
+ setFinderOpen(true);
159
+ }
160
+ };
161
+ window.addEventListener('keydown', onKey);
162
+ return () => window.removeEventListener('keydown', onKey);
163
+ }, []);
164
+
165
+ useEffect(() => {
166
+ if (!open || embedFailed.current || !embeddingsAvailable() || query.trim().length === 0) return;
167
+ let cancelled = false;
168
+ const needle = query.trim();
169
+ embedder.current ??= createWorkerEmbedder();
170
+ const rankedFirst = new Set(lexical.map((hit) => hit.doc.id));
171
+ const ordered = [...lexical.map((hit) => hit.doc), ...docs.filter((doc) => !rankedFirst.has(doc.id))];
172
+ void (async () => {
173
+ try {
174
+ const embedded = await embedder.current!.embed([needle]);
175
+ const nextQuery = embedded[0];
176
+ if (cancelled || !nextQuery) return;
177
+ setEmbedded({ query: needle, vector: nextQuery });
178
+ for (let start = 0; start < ordered.length && !cancelled; start += 8) {
179
+ const batch = ordered.slice(start, start + 8);
180
+ const missing = batch.filter((doc) => !vectorCache.has(textHash(embedText(doc))));
181
+ if (missing.length > 0) {
182
+ const batchVectors = await embedder.current!.embed(missing.map((doc) => embedText(doc)));
183
+ if (cancelled) return;
184
+ missing.forEach((doc, at) => {
185
+ const vector = batchVectors[at];
186
+ if (vector) vectorCache.set(textHash(embedText(doc)), vector);
187
+ });
188
+ }
189
+ if (!cancelled) setVectors(vectorsFor(docs));
190
+ }
191
+ } catch {
192
+ embedFailed.current = true;
193
+ }
194
+ })();
195
+ return () => {
196
+ cancelled = true;
197
+ };
198
+ }, [open, query, docs, lexical]);
199
+
200
+ const openTarget = (target: FindTarget) => {
201
+ const run = typeof routeSearch.run === 'string' ? routeSearch.run : undefined;
202
+ if (target.to === '/chart/$chartId' && target.chartId) {
203
+ const chartId = target.chartId;
204
+ void navigate({
205
+ to: '/chart/$chartId',
206
+ params: { chartId },
207
+ search: { run, screen: target.search.screen, event: target.search.event, journey: target.search.journey }
208
+ });
209
+ } else if (target.to === '/specs/$kind' && target.kind && target.search.spec) {
210
+ const kind = target.kind;
211
+ void navigate({
212
+ to: '/specs/$kind',
213
+ params: { kind },
214
+ search: { run, spec: target.search.spec, check: target.search.check, view: target.search.view }
215
+ });
216
+ }
217
+ setFinderOpen(false);
218
+ };
219
+
220
+ const onCommandKey = (event: ReactKeyboardEvent) => {
221
+ if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) return;
222
+ event.preventDefault();
223
+ const hit = shown.find((item) => item.doc.id === selected);
224
+ if (hit) openTarget(hit.doc.recording ?? hit.doc.target);
225
+ };
226
+
227
+ return (
228
+ <>
229
+ <Command.Dialog
230
+ open={open}
231
+ onOpenChange={setFinderOpen}
232
+ label="Find"
233
+ shouldFilter={false}
234
+ loop
235
+ vimBindings={false}
236
+ overlayClassName={styles.overlay}
237
+ contentClassName={styles.content}
238
+ className={styles.command}
239
+ value={selected}
240
+ onValueChange={setPicked}
241
+ onKeyDown={onCommandKey}
242
+ >
243
+ <Command.Input className={styles.input} placeholder='Try "add a todo" or "gherkin"' value={query} onValueChange={(value) => { setQuery(value); setPicked(''); }} />
244
+ <Command.List className={styles.list} label="Flows, specs and runs">
245
+ {shown.length === 0 && <p className={styles.empty}>{query.trim() ? 'Nothing close to that.' : 'Type what someone does. For example, add a todo.'}</p>}
246
+ {FIND_GROUPS.map((group) => {
247
+ const hits = shown.filter((hit) => hit.doc.group === group);
248
+ if (hits.length === 0) return null;
249
+ return (
250
+ <Command.Group key={group} heading={group} value={group}>
251
+ {hits.map((hit) => (
252
+ <Command.Item key={hit.doc.id} className={styles.item} value={hit.doc.id} onSelect={() => openTarget(hit.doc.target)}>
253
+ <Row hit={hit} />
254
+ </Command.Item>
255
+ ))}
256
+ </Command.Group>
257
+ );
258
+ })}
259
+ </Command.List>
260
+ <footer className={styles.footer}>
261
+ <span>{pending ? 'Reading specs…' : '↑↓ move'}</span>
262
+ <span>↵ open</span>
263
+ <span>esc close</span>
264
+ </footer>
265
+ </Command.Dialog>
266
+ {help && (
267
+ <div className={styles.overlay} onClick={() => setHelpOpen(false)}>
268
+ <div className={styles.help} role="dialog" aria-label="Keyboard shortcuts" onClick={(event) => event.stopPropagation()}>
269
+ <h2>Keyboard shortcuts</h2>
270
+ <ul>
271
+ {SHORTCUTS.map((shortcut) => (
272
+ <li key={shortcut.keys}>
273
+ <kbd>{shortcut.keys}</kbd>
274
+ <span>{shortcut.does}</span>
275
+ </li>
276
+ ))}
277
+ </ul>
278
+ <button type="button" onClick={() => setHelpOpen(false)}>Close</button>
279
+ </div>
280
+ </div>
281
+ )}
282
+ </>
283
+ );
284
+ }
285
+
286
+ function Row({ hit }: { hit: Ranked }) {
287
+ const { doc, snippet } = hit;
288
+ return (
289
+ <>
290
+ {doc.status && <StatusMark status={doc.status} size={14} />}
291
+ <span className={styles.text}>
292
+ <span className={styles.title}>{doc.title}</span>
293
+ {snippet && snippet !== doc.subtitle && <span className={styles.snippet}>{snippet}</span>}
294
+ </span>
295
+ <span className={styles.meta}>
296
+ {doc.subtitle && <span className={styles.subtitle}>{doc.subtitle}</span>}
297
+ <span className={styles.kind}>{KIND_WORD[doc.kind]}</span>
298
+ </span>
299
+ </>
300
+ );
301
+ }
302
+
303
+ export { commandKey };
@@ -0,0 +1,77 @@
1
+ import { expect, test } from 'bun:test';
2
+
3
+ import { buildCorpus, type CorpusSource } from './corpus';
4
+
5
+ const FEATURE = `Feature: Working the list
6
+ Scenario: Add a todo
7
+ When they add a todo
8
+
9
+ Scenario: Evening routine
10
+ When they tap "Mark all done"
11
+ `;
12
+
13
+ function source(extra: Partial<CorpusSource> = {}): CorpusSource {
14
+ return {
15
+ charts: [{ id: 'app', name: 'Todo' }],
16
+ states: [],
17
+ transitions: [],
18
+ journeys: [],
19
+ files: [{ path: 'working-the-list.feature', title: 'Working the list' }],
20
+ slots: [],
21
+ texts: new Map([['working-the-list.feature', FEATURE]]),
22
+ ...extra
23
+ };
24
+ }
25
+
26
+ test('the same action on several states is one row, opened on a transition of its own', () => {
27
+ const docs = buildCorpus(source({
28
+ transitions: [
29
+ { id: 'carried', chartId: 'app', event: 'Adds a todo', source: 'Desktop', target: 'Has items', status: 'passed', carriedBy: 'phone' },
30
+ { id: 'real', chartId: 'app', event: 'Adds a todo', source: 'No todos', target: 'Has items', status: 'passed' },
31
+ { id: 'again', chartId: 'app', event: 'Adds a todo', source: 'Has items', target: 'Has items', status: 'failed' }
32
+ ]
33
+ }));
34
+ const events = docs.filter((doc) => doc.kind === 'event');
35
+ expect(events.map((doc) => doc.title)).toEqual(['Adds a todo']);
36
+ expect(events[0]?.subtitle).toContain('No todos');
37
+ expect(events[0]?.target.search.event).toBe('real');
38
+ expect(events[0]?.status).toBe('failed');
39
+ });
40
+
41
+ test('a scenario opens its recorded check, and a chart file stays on the map', () => {
42
+ const docs = buildCorpus(source({
43
+ files: [
44
+ { path: 'working-the-list.feature', title: 'Working the list' },
45
+ { path: 'todo.qnt', title: 'todo.qnt' },
46
+ { path: 'todo-app.scxml', title: 'Todo app' }
47
+ ],
48
+ texts: new Map([
49
+ ['working-the-list.feature', FEATURE],
50
+ ['todo.qnt', 'module todo {\n action add = true\n action mark_all_done = true\n}\n']
51
+ ]),
52
+ slots: [
53
+ {
54
+ key: 'lists/evening',
55
+ id: 'lists/evening',
56
+ title: 'Evening routine',
57
+ specPath: 'specs/working-the-list.feature',
58
+ status: 'passed',
59
+ detail: '',
60
+ recording: true
61
+ }
62
+ ]
63
+ }));
64
+ expect(docs.some((doc) => doc.subtitle === 'todo-app.scxml' || doc.title === 'Todo app')).toBe(false);
65
+ const add = docs.find((doc) => doc.kind === 'scenario' && doc.title === 'Add a todo');
66
+ expect(add?.target).toMatchObject({ to: '/specs/$kind', kind: 'gherkin', search: { spec: 'working-the-list.feature', check: 'scenario:Add a todo' } });
67
+ const evening = docs.find((doc) => doc.kind === 'scenario' && doc.title === 'Evening routine');
68
+ expect(evening?.related).toEqual(['run:lists/evening']);
69
+ expect(evening?.target.search.check).toBe('lists/evening');
70
+ const run = docs.find((doc) => doc.id === 'run:lists/evening');
71
+ expect(run?.group).toBe('Runs');
72
+ expect(run?.target.search).toMatchObject({ spec: 'working-the-list.feature', check: 'lists/evening', view: 'recording' });
73
+ expect(run?.recording?.search).toMatchObject({ spec: 'working-the-list.feature', check: 'lists/evening', view: 'recording' });
74
+ expect(evening?.recording?.search).toMatchObject({ spec: 'working-the-list.feature', check: 'lists/evening', view: 'recording' });
75
+ expect(evening?.target.search.view).toBeUndefined();
76
+ expect(docs.filter((doc) => doc.kind === 'action').map((doc) => doc.title).sort()).toEqual(['add', 'mark_all_done']);
77
+ });