diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 98f638b..7a3cf75 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -3,7 +3,9 @@
"allow": [
"Bash(npm test)",
"Bash(export PATH=\"/c/Users/aaron/AppData/Local/mise/shims:$PATH\")",
- "WebFetch(domain:ehire-cms.webflow.io)"
+ "WebFetch(domain:ehire-cms.webflow.io)",
+ "Bash(curl -s --max-time 15 \"http://115.70.110.212:81/swagger/v1/swagger.json\")",
+ "Bash(curl:*)"
]
}
}
diff --git a/changelog/v0.6.0.md b/changelog/v0.6.0.md
index 02d4fb7..9477203 100644
--- a/changelog/v0.6.0.md
+++ b/changelog/v0.6.0.md
@@ -22,4 +22,4 @@
- Fixed document viewer page mode content collapsing to a short height instead of filling the viewport
- Fixed white bars appearing on sides of document in experimental viewer with page view mode
- Moved viewer settings (Styled/Page toggles) from dropdown menu to inline toggle buttons in the top bar
-- Styled viewer strips inline font declarations from SEC HTML for consistent app typography and larger text
+- Moved viewer settings (Styled/Page toggles) from dropdown menu to inline toggle buttons in the top bar
diff --git a/changelog/v0.7.0.md b/changelog/v0.7.0.md
new file mode 100644
index 0000000..bb36398
--- /dev/null
+++ b/changelog/v0.7.0.md
@@ -0,0 +1,12 @@
+# v0.7.0
+
+## New Features
+- Tabbed query interface: keep multiple queries open simultaneously with VS Code-style tabs
+- Open in New Tab option in query three-dot menu
+- Favorites system: right-click any filing row to add/remove favorites
+- Favorites tab shows bookmarked filings with cached results and background refresh
+- Right-click context menu on table rows: Copy Cell, Copy Row, Add/Remove Favorites
+
+## Improvements
+- Tabs persist across page reloads via Strings API; queries lazy-load on first tab switch
+- In-flight queries cancelled on tab switch to prevent results landing in wrong tab
diff --git a/changelog/v0.8.0.md b/changelog/v0.8.0.md
new file mode 100644
index 0000000..89d3735
--- /dev/null
+++ b/changelog/v0.8.0.md
@@ -0,0 +1,16 @@
+# v0.8.0
+
+## New Features
+- CodeMirror 6 query editor with PgSQL syntax highlighting and dark theme
+- Autocomplete dropdown with grouped Filing Fields (from API) and SQL Keywords
+- Dynamic field autocomplete: type suggestions inside {Label:Type} syntax, modifier suggestions after |
+- Favorites field type with |modifier syntax: {favs:Favorites|array} injects favorite IDs into queries
+- Modifiers: csv, array, pgarray, first, last — format values for different SQL patterns
+- Modifiers now work on Tags and Favorites (e.g., {Tags:Tags|pgarray} → array['a','b'])
+
+## Improvements
+- Query parameter {Label:Type:Default} blocks highlighted in teal in the code editor
+- Styled viewer strips inline font declarations from SEC HTML for consistent app typography and larger text
+- Help page updated with Favorites field type and modifiers reference
+- Tag chips in dynamic parameters now show visible X buttons for individual removal
+- Preview button in query toolbar: hold to see compiled query with values replaced
diff --git a/package-lock.json b/package-lock.json
index 2dfd379..285db7e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,13 @@
"@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0",
"@auth0/auth0-angular": "^2.3.0",
+ "@codemirror/autocomplete": "^6.20.1",
+ "@codemirror/commands": "^6.10.3",
+ "@codemirror/lang-sql": "^6.10.0",
+ "@codemirror/language": "^6.12.3",
+ "@codemirror/search": "^6.6.0",
+ "@codemirror/state": "^6.6.0",
+ "@codemirror/view": "^6.40.0",
"@ng-icons/core": "^33.0.0",
"@ng-icons/lucide": "^33.0.0",
"@spartan-ng/brain": "^0.0.1-alpha.584",
@@ -23,6 +30,7 @@
"ag-grid-community": "^35.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
+ "codemirror": "^6.0.2",
"ngx-extended-pdf-viewer": "^25.6.4",
"rxjs": "~7.8.0",
"tailwind-merge": "^3.3.1",
@@ -2633,6 +2641,101 @@
"dev": true,
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.20.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
+ "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.10.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
+ "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.6.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/lang-sql": {
+ "version": "6.10.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
+ "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.12.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
+ "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/lint": {
+ "version": "6.9.5",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
+ "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.35.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/search": {
+ "version": "6.6.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
+ "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.37.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.6.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
+ "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.40.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.40.0.tgz",
+ "integrity": "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.6.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -4168,6 +4271,30 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@lezer/common": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
+ "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.8",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
+ "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
"node_modules/@listr2/prompt-adapter-inquirer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
@@ -4283,6 +4410,12 @@
"win32"
]
},
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
+ "license": "MIT"
+ },
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
@@ -11467,6 +11600,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/codemirror": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
+ "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -11851,6 +11999,12 @@
"node": ">= 6"
}
},
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+ "license": "MIT"
+ },
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
@@ -21277,6 +21431,12 @@
"webpack": "^5.0.0"
}
},
+ "node_modules/style-mod": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
+ "license": "MIT"
+ },
"node_modules/stylehacks": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
@@ -23035,6 +23195,12 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
diff --git a/package.json b/package.json
index 018ddc3..9980bc8 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,13 @@
"@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0",
"@auth0/auth0-angular": "^2.3.0",
+ "@codemirror/autocomplete": "^6.20.1",
+ "@codemirror/commands": "^6.10.3",
+ "@codemirror/lang-sql": "^6.10.0",
+ "@codemirror/language": "^6.12.3",
+ "@codemirror/search": "^6.6.0",
+ "@codemirror/state": "^6.6.0",
+ "@codemirror/view": "^6.40.0",
"@ng-icons/core": "^33.0.0",
"@ng-icons/lucide": "^33.0.0",
"@spartan-ng/brain": "^0.0.1-alpha.584",
@@ -43,6 +50,7 @@
"ag-grid-community": "^35.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
+ "codemirror": "^6.0.2",
"ngx-extended-pdf-viewer": "^25.6.4",
"rxjs": "~7.8.0",
"tailwind-merge": "^3.3.1",
diff --git a/src/app/components/code-editor/code-editor.component.ts b/src/app/components/code-editor/code-editor.component.ts
new file mode 100644
index 0000000..ebf6d82
--- /dev/null
+++ b/src/app/components/code-editor/code-editor.component.ts
@@ -0,0 +1,129 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ NgZone,
+ afterNextRender,
+ effect,
+ inject,
+ input,
+ output,
+ viewChild,
+ DestroyRef,
+} from '@angular/core';
+import { EditorState, Extension } from '@codemirror/state';
+import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
+import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
+import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
+import { sql, PostgreSQL } from '@codemirror/lang-sql';
+
+import { darkTheme, highlightStyle } from './editor-theme';
+import { parameterHighlighter } from './query-language';
+
+@Component({
+ selector: 'app-code-editor',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
`,
+ styles: `
+ :host {
+ display: block;
+ overflow: hidden;
+ }
+ .editor-host {
+ height: 100%;
+ }
+ .editor-host .cm-editor {
+ height: 100%;
+ }
+ .editor-host .cm-editor.cm-focused {
+ outline: none;
+ }
+ `,
+})
+export class CodeEditorComponent {
+ private readonly zone = inject(NgZone);
+ private readonly destroyRef = inject(DestroyRef);
+
+ readonly value = input('');
+ readonly placeholder = input('');
+ readonly extensions = input([]);
+
+ readonly valueChange = output();
+ readonly keyboardShortcut = output();
+
+ private readonly editorHost = viewChild.required>('editorHost');
+ private view: EditorView | null = null;
+ private isExternalUpdate = false;
+
+ constructor() {
+ afterNextRender(() => {
+ this.createEditor();
+ });
+
+ // Sync external value changes into the editor
+ effect(() => {
+ const val = this.value();
+ const view = this.view;
+ if (!view) return;
+ const current = view.state.doc.toString();
+ if (val !== current) {
+ this.isExternalUpdate = true;
+ view.dispatch({
+ changes: { from: 0, to: current.length, insert: val },
+ });
+ this.isExternalUpdate = false;
+ }
+ });
+
+ this.destroyRef.onDestroy(() => {
+ this.view?.destroy();
+ this.view = null;
+ });
+ }
+
+ private createEditor() {
+ const host = this.editorHost().nativeElement;
+
+ const updateListener = EditorView.updateListener.of((update) => {
+ if (update.docChanged && !this.isExternalUpdate) {
+ const text = update.state.doc.toString();
+ this.zone.run(() => this.valueChange.emit(text));
+ }
+ });
+
+ // Ctrl+Enter / Cmd+Enter shortcut
+ const searchKeymap = keymap.of([
+ {
+ key: 'Ctrl-Enter',
+ mac: 'Cmd-Enter',
+ run: () => {
+ this.zone.run(() => this.keyboardShortcut.emit('ctrl-enter'));
+ return true;
+ },
+ },
+ ]);
+
+ const state = EditorState.create({
+ doc: this.value(),
+ extensions: [
+ searchKeymap,
+ history(),
+ keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap]),
+ closeBrackets(),
+ sql({ dialect: PostgreSQL }),
+ darkTheme,
+ highlightStyle,
+ parameterHighlighter,
+ cmPlaceholder(this.placeholder()),
+ updateListener,
+ EditorView.lineWrapping,
+ ...this.extensions(),
+ ],
+ });
+
+ this.zone.runOutsideAngular(() => {
+ this.view = new EditorView({ state, parent: host });
+ });
+ }
+}
diff --git a/src/app/components/code-editor/editor-theme.ts b/src/app/components/code-editor/editor-theme.ts
new file mode 100644
index 0000000..518b94b
--- /dev/null
+++ b/src/app/components/code-editor/editor-theme.ts
@@ -0,0 +1,81 @@
+import { EditorView } from '@codemirror/view';
+import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
+import { tags } from '@lezer/highlight';
+
+/** Dark theme matching the app's VS Code-like color scheme */
+export const darkTheme = EditorView.theme(
+ {
+ '&': {
+ backgroundColor: '#1e1e1e',
+ color: '#d4d4d4',
+ fontFamily:
+ "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace",
+ fontSize: '13px',
+ },
+ '.cm-content': {
+ caretColor: '#d4d4d4',
+ padding: '8px 12px',
+ minHeight: '64px',
+ },
+ '.cm-cursor, .cm-dropCursor': { borderLeftColor: '#d4d4d4' },
+ '&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
+ backgroundColor: '#264f78',
+ },
+ '.cm-gutters': { display: 'none' },
+ '.cm-activeLine': { backgroundColor: 'transparent' },
+ '&.cm-focused .cm-activeLine': { backgroundColor: '#2a2a2a' },
+ '.cm-activeLineGutter': { backgroundColor: 'transparent' },
+ '.cm-scroller': { overflow: 'auto' },
+
+ /* Parameter syntax {Label:Type:Default} */
+ '.cm-parameter-syntax': { color: '#4ec9b0', fontWeight: 'bold' },
+
+ /* Autocomplete tooltip */
+ '.cm-tooltip': {
+ backgroundColor: '#252526',
+ border: '1px solid #3c3c3c',
+ color: '#d4d4d4',
+ },
+ '.cm-tooltip-autocomplete': {
+ '& > ul': { fontFamily: 'inherit', fontSize: '12px' },
+ '& > ul > li': { padding: '2px 8px' },
+ '& > ul > li[aria-selected]': { backgroundColor: '#04395e', color: '#ffffff' },
+ },
+ '.cm-tooltip-autocomplete .cm-completionIcon': {
+ display: 'none',
+ },
+ '.cm-completionLabel': { color: '#d4d4d4' },
+ '.cm-completionDetail': { color: '#858585', fontStyle: 'italic', marginLeft: '8px' },
+ '.cm-completionMatchedText': { color: '#4ec9b0', textDecoration: 'none' },
+
+ /* Section headers in autocomplete */
+ '.cm-completionSection': {
+ color: '#858585',
+ fontSize: '10px',
+ textTransform: 'uppercase',
+ letterSpacing: '0.5px',
+ padding: '4px 8px 2px',
+ borderBottom: '1px solid #3c3c3c',
+ },
+
+ /* Placeholder */
+ '.cm-placeholder': { color: '#6e6e6e', fontStyle: 'normal' },
+ },
+ { dark: true },
+);
+
+/** SQL-like syntax highlighting colors */
+export const highlightStyle = syntaxHighlighting(
+ HighlightStyle.define([
+ { tag: tags.keyword, color: '#569cd6' },
+ { tag: tags.string, color: '#ce9178' },
+ { tag: tags.number, color: '#b5cea8' },
+ { tag: tags.operator, color: '#d4d4d4' },
+ { tag: tags.comment, color: '#6a9955' },
+ { tag: [tags.function(tags.variableName), tags.standard(tags.variableName)], color: '#dcdcaa' },
+ { tag: tags.typeName, color: '#4ec9b0' },
+ { tag: tags.bool, color: '#569cd6' },
+ { tag: tags.null, color: '#569cd6' },
+ { tag: tags.punctuation, color: '#d4d4d4' },
+ ]),
+);
diff --git a/src/app/components/code-editor/index.ts b/src/app/components/code-editor/index.ts
new file mode 100644
index 0000000..37d31d2
--- /dev/null
+++ b/src/app/components/code-editor/index.ts
@@ -0,0 +1,4 @@
+export * from './code-editor.component';
+export * from './query-language';
+export * from './query-autocomplete';
+export * from './editor-theme';
diff --git a/src/app/components/code-editor/query-autocomplete.ts b/src/app/components/code-editor/query-autocomplete.ts
new file mode 100644
index 0000000..27f0d22
--- /dev/null
+++ b/src/app/components/code-editor/query-autocomplete.ts
@@ -0,0 +1,246 @@
+import {
+ autocompletion,
+ CompletionContext,
+ CompletionResult,
+ Completion,
+ CompletionSection,
+} from '@codemirror/autocomplete';
+import { Extension } from '@codemirror/state';
+import { FilingService } from '../../core/services/filing.service';
+import { firstValueFrom } from 'rxjs';
+
+// --- Sections for grouped autocomplete ---
+
+const filingFieldsSection: CompletionSection = { name: 'Filing Fields', rank: 0 };
+const sqlKeywordsSection: CompletionSection = { name: 'SQL Keywords', rank: 1 };
+const fieldTypesSection: CompletionSection = { name: 'Field Types', rank: 0 };
+const modifiersSection: CompletionSection = { name: 'Modifiers', rank: 0 };
+
+// --- Static data ---
+
+const SQL_KEYWORDS = [
+ 'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'NOT', 'IN', 'LIKE', 'ILIKE',
+ 'ORDER BY', 'ORDER', 'BY', 'LIMIT', 'BETWEEN', 'DESC', 'ASC',
+ 'IS', 'NULL', 'AS', 'LEFT', 'RIGHT', 'JOIN', 'ON', 'INNER', 'OUTER',
+ 'GROUP BY', 'GROUP', 'HAVING', 'DISTINCT', 'UNION', 'EXCEPT',
+ 'EXISTS', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'TRUE', 'FALSE',
+ 'ARRAY', 'CAST',
+];
+
+const FIELD_TYPES = [
+ { label: 'StringInput', detail: 'Free text input' },
+ { label: 'NumberInput', detail: 'Numeric input' },
+ { label: 'FormTypes', detail: 'SEC form type dropdown' },
+ { label: 'Tags', detail: 'Tag multi-select' },
+ { label: 'Favorites', detail: 'Inject favorite IDs' },
+];
+
+const MODIFIERS = [
+ { label: 'csv', detail: 'Comma-separated values' },
+ { label: 'array', detail: 'Parenthesized list for IN operator' },
+ { label: 'pgarray', detail: "PostgreSQL array literal: array['a','b']" },
+ { label: 'first', detail: 'First item only' },
+ { label: 'last', detail: 'Last item only' },
+];
+
+// --- API cache ---
+
+let cachedApiResults: Completion[] | null = null;
+let cacheTimestamp = 0;
+const CACHE_TTL = 30_000; // 30 seconds
+
+async function fetchApiCompletions(
+ filingService: FilingService,
+ word: string,
+): Promise {
+ const now = Date.now();
+ if (cachedApiResults && now - cacheTimestamp < CACHE_TTL) {
+ return cachedApiResults;
+ }
+
+ try {
+ const results = await firstValueFrom(filingService.autocomplete(word));
+ const completions: Completion[] = results.map((r) => ({
+ label: r.word,
+ detail: r.description ?? undefined,
+ section: filingFieldsSection,
+ boost: 1,
+ }));
+
+ // Deduplicate by label
+ const seen = new Set();
+ const unique = completions.filter((c) => {
+ if (seen.has(c.label)) return false;
+ seen.add(c.label);
+ return true;
+ });
+
+ cachedApiResults = unique;
+ cacheTimestamp = now;
+ return unique;
+ } catch {
+ return cachedApiResults ?? [];
+ }
+}
+
+// --- Context detection ---
+
+interface BraceContext {
+ /** Which segment the cursor is in: 'label' | 'type' | 'default' | 'modifier' */
+ segment: 'label' | 'type' | 'default' | 'modifier';
+ /** The text typed so far in this segment */
+ typed: string;
+ /** Start position of the typed text in the document */
+ from: number;
+}
+
+/**
+ * Detect if the cursor is inside {Label:Type:Default|modifier} syntax.
+ * Returns context info or null if outside braces.
+ */
+function getBraceContext(doc: string, pos: number): BraceContext | null {
+ // Scan backwards to find the opening brace
+ let braceStart = -1;
+ for (let i = pos - 1; i >= 0; i--) {
+ if (doc[i] === '}') return null; // hit a close brace first → not inside
+ if (doc[i] === '{' && (i === 0 || doc[i - 1] !== '\\')) {
+ braceStart = i;
+ break;
+ }
+ }
+ if (braceStart === -1) return null;
+
+ // Check there's no close brace between braceStart and pos
+ const inside = doc.substring(braceStart + 1, pos);
+
+ // Determine segment by counting colons and pipes
+ const colonCount = (inside.match(/:/g) || []).length;
+ const hasPipe = inside.includes('|');
+
+ if (hasPipe) {
+ // After the pipe → modifier segment
+ const pipeIdx = inside.lastIndexOf('|');
+ const typed = inside.substring(pipeIdx + 1);
+ return { segment: 'modifier', typed, from: braceStart + 1 + pipeIdx + 1 };
+ }
+
+ if (colonCount === 0) {
+ // Label segment
+ return { segment: 'label', typed: inside, from: braceStart + 1 };
+ }
+
+ if (colonCount === 1) {
+ // Type segment (after first colon)
+ const colonIdx = inside.indexOf(':');
+ const typed = inside.substring(colonIdx + 1);
+ return { segment: 'type', typed, from: braceStart + 1 + colonIdx + 1 };
+ }
+
+ // colonCount >= 2 → default segment (or modifier if pipe present, handled above)
+ // Check for pipe in the last part
+ const lastColonIdx = inside.indexOf(':', inside.indexOf(':') + 1);
+ const afterLastColon = inside.substring(lastColonIdx + 1);
+ if (afterLastColon.includes('|')) {
+ const pipeIdx = afterLastColon.lastIndexOf('|');
+ const typed = afterLastColon.substring(pipeIdx + 1);
+ return {
+ segment: 'modifier',
+ typed,
+ from: braceStart + 1 + lastColonIdx + 1 + pipeIdx + 1,
+ };
+ }
+
+ return {
+ segment: 'default',
+ typed: afterLastColon,
+ from: braceStart + 1 + lastColonIdx + 1,
+ };
+}
+
+// --- Main completion source ---
+
+async function queryCompletionSource(
+ context: CompletionContext,
+ filingService: FilingService,
+): Promise {
+ const doc = context.state.doc.toString();
+ const pos = context.pos;
+
+ // Check if inside {braces}
+ const braceCtx = getBraceContext(doc, pos);
+
+ if (braceCtx) {
+ if (braceCtx.segment === 'type') {
+ // Suggest field types
+ const word = braceCtx.typed.trim();
+ const options: Completion[] = FIELD_TYPES
+ .filter((ft) => !word || ft.label.toLowerCase().startsWith(word.toLowerCase()))
+ .map((ft) => ({
+ label: ft.label,
+ detail: ft.detail,
+ section: fieldTypesSection,
+ }));
+ if (options.length === 0) return null;
+ return { from: braceCtx.from, options };
+ }
+
+ if (braceCtx.segment === 'modifier') {
+ const word = braceCtx.typed.trim();
+ const options: Completion[] = MODIFIERS
+ .filter((m) => !word || m.label.startsWith(word.toLowerCase()))
+ .map((m) => ({
+ label: m.label,
+ detail: m.detail,
+ section: modifiersSection,
+ }));
+ if (options.length === 0) return null;
+ return { from: braceCtx.from, options };
+ }
+
+ // Label or default segment → no autocomplete
+ return null;
+ }
+
+ // Outside braces: SQL keywords + filing fields from API
+ const wordMatch = context.matchBefore(/\w+/);
+ if (!wordMatch && !context.explicit) return null;
+ const from = wordMatch?.from ?? pos;
+ const word = wordMatch?.text ?? '';
+
+ // Build SQL keyword completions
+ const sqlOptions: Completion[] = SQL_KEYWORDS
+ .filter((kw) => !word || kw.toLowerCase().startsWith(word.toLowerCase()))
+ .map((kw) => ({
+ label: kw.toLowerCase(),
+ detail: 'keyword',
+ section: sqlKeywordsSection,
+ apply: kw.toLowerCase(),
+ }));
+
+ // Fetch API completions (debounced/cached)
+ let apiOptions: Completion[] = [];
+ if (word.length >= 1 || context.explicit) {
+ const allApi = await fetchApiCompletions(filingService, word);
+ apiOptions = allApi.filter(
+ (c) => !word || c.label.toLowerCase().startsWith(word.toLowerCase()),
+ );
+ }
+
+ const options = [...apiOptions, ...sqlOptions];
+ if (options.length === 0) return null;
+
+ return { from, options };
+}
+
+/**
+ * Create a CodeMirror autocomplete extension for the query editor.
+ */
+export function createQueryAutocomplete(filingService: FilingService): Extension {
+ return autocompletion({
+ override: [
+ (context: CompletionContext) => queryCompletionSource(context, filingService),
+ ],
+ activateOnTyping: true,
+ defaultKeymap: true,
+ });
+}
diff --git a/src/app/components/code-editor/query-language.ts b/src/app/components/code-editor/query-language.ts
new file mode 100644
index 0000000..519afba
--- /dev/null
+++ b/src/app/components/code-editor/query-language.ts
@@ -0,0 +1,42 @@
+import {
+ ViewPlugin,
+ Decoration,
+ DecorationSet,
+ EditorView,
+ ViewUpdate,
+} from '@codemirror/view';
+import { RangeSetBuilder } from '@codemirror/state';
+
+const parameterMark = Decoration.mark({ class: 'cm-parameter-syntax' });
+
+/**
+ * ViewPlugin that highlights {Label:Type:Default|modifier} parameter syntax
+ * with a distinct teal color. Uses the same regex pattern as QueryParserService.
+ */
+export const parameterHighlighter = ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet;
+
+ constructor(view: EditorView) {
+ this.decorations = buildDecorations(view);
+ }
+
+ update(update: ViewUpdate) {
+ if (update.docChanged || update.viewportChanged) {
+ this.decorations = buildDecorations(update.view);
+ }
+ }
+ },
+ { decorations: (v) => v.decorations },
+);
+
+function buildDecorations(view: EditorView): DecorationSet {
+ const builder = new RangeSetBuilder();
+ const doc = view.state.doc.toString();
+ const regex = /(?
+ `,
+})
+export class FavoritesInputComponent {
+ private readonly favoritesService = inject(FavoritesService);
+
+ readonly value = input('');
+ readonly defaultValue = input('');
+ readonly modifier = input('');
+ readonly valueChange = output();
+
+ readonly placeholder = computed(() => {
+ if (!this.favoritesService.loaded()) return 'Loading...';
+ if (!this.favoritesService.hasFavorites()) return 'No favorites';
+ return '';
+ });
+
+ readonly displayText = computed(() => {
+ const ids = this.favoritesService.favoriteIds();
+ if (ids.length === 0) return '';
+
+ switch (this.modifier()) {
+ case 'array':
+ return `(${ids.join(',')})`;
+ case 'pgarray':
+ return `array[${ids.join(',')}]`;
+ case 'first':
+ return String(ids[0]);
+ case 'last':
+ return String(ids[ids.length - 1]);
+ case 'csv':
+ default:
+ return ids.join(',');
+ }
+ });
+}
diff --git a/src/app/components/query-builder/inputs/tags-input.component.ts b/src/app/components/query-builder/inputs/tags-input.component.ts
index 20a0739..e9d4664 100644
--- a/src/app/components/query-builder/inputs/tags-input.component.ts
+++ b/src/app/components/query-builder/inputs/tags-input.component.ts
@@ -38,10 +38,11 @@ import { TagsService } from '../../../core/services/tags.service';
{{ tag }}
}
diff --git a/src/app/components/query-builder/query-parameters.component.ts b/src/app/components/query-builder/query-parameters.component.ts
index 4325fd7..eae4b73 100644
--- a/src/app/components/query-builder/query-parameters.component.ts
+++ b/src/app/components/query-builder/query-parameters.component.ts
@@ -16,6 +16,7 @@ import { StringInputComponent } from './inputs/string-input.component';
import { NumberInputComponent } from './inputs/number-input.component';
import { FormTypesInputComponent } from './inputs/form-types-input.component';
import { TagsInputComponent } from './inputs/tags-input.component';
+import { FavoritesInputComponent } from './inputs/favorites-input.component';
/**
* Component for displaying inline parameter inputs for parameterized queries.
@@ -32,6 +33,7 @@ import { TagsInputComponent } from './inputs/tags-input.component';
NumberInputComponent,
FormTypesInputComponent,
TagsInputComponent,
+ FavoritesInputComponent,
],
providers: [
provideIcons({ lucideRotateCcw, lucideSearch, lucideLoader2 }),
@@ -77,6 +79,14 @@ import { TagsInputComponent } from './inputs/tags-input.component';
(valueChange)="onValueChange(param.index, $event)"
/>
}
+ @case ('Favorites') {
+
+ }
@default {
{
expect(types).toContain('NumberInput');
expect(types).toContain('FormTypes');
expect(types).toContain('Tags');
- expect(types).toHaveLength(4);
+ expect(types).toContain('Favorites');
+ expect(types).toHaveLength(5);
});
});
diff --git a/src/app/core/services/component-registry.service.ts b/src/app/core/services/component-registry.service.ts
index 27a5a9c..a3824d9 100644
--- a/src/app/core/services/component-registry.service.ts
+++ b/src/app/core/services/component-registry.service.ts
@@ -64,6 +64,13 @@ const DEFAULT_COMPONENT_CONFIGS: Record [] as string[],
},
+
+ Favorites: {
+ type: 'Favorites',
+ displayName: 'Favorites',
+ compile: (value) => String(value ?? ''),
+ getDefaultValue: () => [] as string[],
+ },
};
/**
diff --git a/src/app/core/services/favorites.service.ts b/src/app/core/services/favorites.service.ts
new file mode 100644
index 0000000..065425f
--- /dev/null
+++ b/src/app/core/services/favorites.service.ts
@@ -0,0 +1,60 @@
+import { computed, inject, Injectable, signal } from '@angular/core';
+import { catchError, of, Subject, debounceTime, switchMap, take } from 'rxjs';
+import { StringsService } from './strings.service';
+
+const STORAGE_KEY = 'filing-favorites';
+
+@Injectable({ providedIn: 'root' })
+export class FavoritesService {
+ private readonly stringsService = inject(StringsService);
+ private readonly saveSubject = new Subject();
+
+ readonly favoriteIds = signal([]);
+ readonly loaded = signal(false);
+ readonly favoriteIdSet = computed(() => new Set(this.favoriteIds()));
+ readonly hasFavorites = computed(() => this.favoriteIds().length > 0);
+
+ constructor() {
+ this.load();
+
+ // Debounced persistence
+ this.saveSubject.pipe(
+ debounceTime(500),
+ switchMap(() =>
+ this.stringsService.setJson(STORAGE_KEY, this.favoriteIds()).pipe(
+ catchError(err => {
+ console.error('Failed to save favorites', err);
+ return of(null);
+ }),
+ ),
+ ),
+ ).subscribe();
+ }
+
+ private load() {
+ this.stringsService.getJson(STORAGE_KEY).pipe(
+ catchError(() => of(null)),
+ take(1),
+ ).subscribe(ids => {
+ if (Array.isArray(ids)) {
+ this.favoriteIds.set(ids);
+ }
+ this.loaded.set(true);
+ });
+ }
+
+ isFavorite(id: number): boolean {
+ return this.favoriteIdSet().has(id);
+ }
+
+ addFavorite(id: number) {
+ if (this.isFavorite(id)) return;
+ this.favoriteIds.update(ids => [...ids, id]);
+ this.saveSubject.next();
+ }
+
+ removeFavorite(id: number) {
+ this.favoriteIds.update(ids => ids.filter(i => i !== id));
+ this.saveSubject.next();
+ }
+}
diff --git a/src/app/core/services/query-compiler.service.ts b/src/app/core/services/query-compiler.service.ts
index 476d343..d1ba314 100644
--- a/src/app/core/services/query-compiler.service.ts
+++ b/src/app/core/services/query-compiler.service.ts
@@ -2,9 +2,9 @@ import { inject, Injectable } from '@angular/core';
import {
ParsedQuery,
QueryParameter,
- RenderComponentType,
} from '../models/query-parameter.model';
import { QueryParserService } from './query-parser.service';
+import { FavoritesService } from './favorites.service';
/**
* Result of compiling a query.
@@ -29,6 +29,7 @@ export interface CompileResult {
})
export class QueryCompilerService {
private readonly parserService = inject(QueryParserService);
+ private readonly favoritesService = inject(FavoritesService);
/**
* Compile a parameterized query into an executable query string.
@@ -57,6 +58,16 @@ export class QueryCompilerService {
};
}
+ // Check if Favorites parameters are used and service is loaded
+ const hasFavorites = parsed.parameters.some(p => p.componentType === 'Favorites');
+ if (hasFavorites && !this.favoritesService.loaded()) {
+ return {
+ compiledQuery: query,
+ success: false,
+ errors: ['Favorites are still loading. Please wait...'],
+ };
+ }
+
const errors: string[] = [];
let compiledQuery = query;
@@ -79,7 +90,7 @@ export class QueryCompilerService {
}
// Compile the value based on component type
- const compiledValue = this.compileValue(param.componentType, effectiveValue);
+ const compiledValue = this.compileValue(param, effectiveValue);
// Replace in query
compiledQuery =
@@ -99,28 +110,88 @@ export class QueryCompilerService {
}
/**
- * Compile a value based on its component type.
+ * Compile a value based on its component type, then apply any modifier.
*/
- private compileValue(componentType: RenderComponentType, value: string | number | string[]): string {
- switch (componentType) {
- case 'StringInput':
- return this.compileStringValue(value as string);
-
+ private compileValue(param: QueryParameter, value: string | number | string[]): string {
+ // Get the raw items for this type
+ let items: string[];
+
+ switch (param.componentType) {
+ case 'Favorites':
+ items = this.favoritesService.favoriteIds().map(String);
+ break;
+ case 'Tags':
+ items = this.normalizeToArray(value);
+ break;
case 'NumberInput':
- return this.compileNumberValue(value as number | string);
-
+ items = [this.compileNumberValue(value as number | string)];
+ break;
+ case 'StringInput':
case 'FormTypes':
- return this.compileStringValue(value as string);
-
+ default:
+ items = [String(value ?? '')];
+ break;
+ }
+
+ // If there's a modifier, use it to format the output
+ if (param.modifier) {
+ return this.applyModifier(items, param.modifier, param.componentType);
+ }
+
+ // No modifier — use the original type-specific formatting
+ switch (param.componentType) {
+ case 'StringInput':
+ case 'FormTypes':
+ return this.compileStringValue(items[0]);
+ case 'NumberInput':
+ return items[0];
case 'Tags':
return this.compileTagsValue(value as string[] | string);
-
+ case 'Favorites':
+ return items.join(',');
default:
- // Fallback to string compilation
- return this.compileStringValue(String(value));
+ return this.compileStringValue(items[0]);
}
}
+ /**
+ * Apply a modifier to format an array of items.
+ * Numbers stay unquoted, strings get single-quoted.
+ */
+ private applyModifier(items: string[], modifier: string, componentType: string): string {
+ const isNumeric = componentType === 'Favorites' || componentType === 'NumberInput';
+
+ const formatItem = (item: string) => {
+ if (isNumeric) return item;
+ const escaped = item.replace(/'/g, "''");
+ return `'${escaped}'`;
+ };
+
+ switch (modifier) {
+ case 'csv':
+ return items.map(formatItem).join(',');
+ case 'array':
+ return `(${items.map(formatItem).join(',')})`;
+ case 'pgarray':
+ return `array[${items.map(formatItem).join(',')}]`;
+ case 'first':
+ return items.length > 0 ? formatItem(items[0]) : (isNumeric ? '0' : "''");
+ case 'last':
+ return items.length > 0 ? formatItem(items[items.length - 1]) : (isNumeric ? '0' : "''");
+ default:
+ return items.map(formatItem).join(',');
+ }
+ }
+
+ /**
+ * Normalize a value to a string array (for Tags and similar list types).
+ */
+ private normalizeToArray(value: string | number | string[]): string[] {
+ if (Array.isArray(value)) return value.map(String);
+ if (typeof value === 'string') return value.split(',').map(s => s.trim()).filter(s => s);
+ return [String(value)];
+ }
+
/**
* Compile a string value - wraps in single quotes with proper escaping.
*/
@@ -176,6 +247,8 @@ export class QueryCompilerService {
return 0;
case 'Tags':
return [];
+ case 'Favorites':
+ return [];
case 'StringInput':
case 'FormTypes':
default:
diff --git a/src/app/core/services/query-parser.service.ts b/src/app/core/services/query-parser.service.ts
index d42ff62..77c0fc7 100644
--- a/src/app/core/services/query-parser.service.ts
+++ b/src/app/core/services/query-parser.service.ts
@@ -27,6 +27,15 @@ export class QueryParserService {
'NumberInput',
'FormTypes',
'Tags',
+ 'Favorites',
+ ]);
+
+ private readonly validModifiers: Set = new Set([
+ 'csv',
+ 'array',
+ 'pgarray',
+ 'first',
+ 'last',
]);
// Regex to match parameter syntax: {Label:RenderComponent:DefaultValue}
@@ -113,11 +122,22 @@ export class QueryParserService {
};
}
- // Extract component type (optional, defaults to StringInput)
+ // Extract component type and modifier.
+ // Handle {label:Type|modifier} where pipe is part of the type segment (no default value).
let componentType: RenderComponentType = 'StringInput';
+ let modifier: string | undefined;
+
if (parts.length > 1 && parts[1]?.trim()) {
- const typeStr = parts[1].trim();
- if (!this.validComponentTypes.has(typeStr)) {
+ let typeStr = parts[1].trim();
+
+ // Check for |modifier attached to the type (no default value case)
+ const pipeIdx = typeStr.indexOf('|');
+ if (pipeIdx >= 0) {
+ modifier = typeStr.substring(pipeIdx + 1).trim();
+ typeStr = typeStr.substring(0, pipeIdx).trim();
+ }
+
+ if (typeStr && !this.validComponentTypes.has(typeStr)) {
return {
error: {
message: `Invalid component type: ${typeStr}. Valid types are: ${[...this.validComponentTypes].join(', ')}`,
@@ -126,15 +146,37 @@ export class QueryParserService {
},
};
}
- componentType = typeStr as RenderComponentType;
+ if (typeStr) {
+ componentType = typeStr as RenderComponentType;
+ }
}
// Extract default value (optional)
// Join remaining parts back together in case default value contained colons
- const defaultValue = parts.length > 2
- ? parts.slice(2).join(':').trim()
+ let defaultValue = parts.length > 2
+ ? parts.slice(2).join(':').trim()
: '';
+ // Extract modifier from |modifier in the default value (e.g., {label:Type:default|csv})
+ if (!modifier) {
+ const pipeIndex = defaultValue.lastIndexOf('|');
+ if (pipeIndex >= 0) {
+ modifier = defaultValue.substring(pipeIndex + 1).trim();
+ defaultValue = defaultValue.substring(0, pipeIndex).trim();
+ }
+ }
+
+ // Validate modifier if present
+ if (modifier && !this.validModifiers.has(modifier)) {
+ return {
+ error: {
+ message: `Invalid modifier: ${modifier}. Valid modifiers are: ${[...this.validModifiers].join(', ')}`,
+ startIndex,
+ endIndex,
+ },
+ };
+ }
+
return {
parameter: {
label,
@@ -144,6 +186,7 @@ export class QueryParserService {
rawMatch,
startIndex,
endIndex,
+ modifier,
},
};
}
diff --git a/src/app/core/services/saved-queries.service.ts b/src/app/core/services/saved-queries.service.ts
index 9e99aa5..03524ac 100644
--- a/src/app/core/services/saved-queries.service.ts
+++ b/src/app/core/services/saved-queries.service.ts
@@ -212,6 +212,7 @@ export class SavedQueriesService {
case 'NumberInput':
return 0;
case 'Tags':
+ case 'Favorites':
return [];
default:
return '';
diff --git a/src/app/core/services/tab-manager.service.ts b/src/app/core/services/tab-manager.service.ts
new file mode 100644
index 0000000..5d94352
--- /dev/null
+++ b/src/app/core/services/tab-manager.service.ts
@@ -0,0 +1,188 @@
+import { computed, inject, Injectable, signal } from '@angular/core';
+import { catchError, of, Subject, debounceTime, switchMap, take } from 'rxjs';
+import { StringsService } from './strings.service';
+import { Filing } from '../models/filing.model';
+
+const STORAGE_KEY = 'filing-tabs';
+
+export interface TabEntry {
+ queryGuid: string;
+}
+
+export interface TabRuntimeState {
+ results: Filing[];
+ loading: boolean;
+ hasSearched: boolean;
+ error: string | null;
+}
+
+interface PersistedTabState {
+ tabs: TabEntry[];
+ activeIndex: number;
+}
+
+function defaultRuntime(): TabRuntimeState {
+ return { results: [], loading: false, hasSearched: false, error: null };
+}
+
+@Injectable({ providedIn: 'root' })
+export class TabManagerService {
+ private readonly stringsService = inject(StringsService);
+ private readonly saveSubject = new Subject();
+ private readonly runtimeMap = new Map();
+
+ readonly tabs = signal([]);
+ readonly activeIndex = signal(0); // -1 = favorites tab
+ readonly loaded = signal(false);
+
+ readonly activeTab = computed(() => {
+ const idx = this.activeIndex();
+ if (idx < 0) return null; // favorites
+ return this.tabs()[idx] ?? null;
+ });
+
+ readonly isFavoritesActive = computed(() => this.activeIndex() === -1);
+
+ constructor() {
+ // Debounced persistence
+ this.saveSubject.pipe(
+ debounceTime(500),
+ switchMap(() => {
+ const state: PersistedTabState = {
+ tabs: this.tabs(),
+ activeIndex: this.activeIndex(),
+ };
+ return this.stringsService.setJson(STORAGE_KEY, state).pipe(
+ catchError(err => {
+ console.error('Failed to save tab state', err);
+ return of(null);
+ }),
+ );
+ }),
+ ).subscribe();
+ }
+
+ /**
+ * Load persisted tab state. Call once after saved queries are loaded.
+ * If no persisted state, initializes with the given query GUID (from URL or first query).
+ */
+ load(fallbackGuid?: string) {
+ this.stringsService.getJson(STORAGE_KEY).pipe(
+ catchError(() => of(null)),
+ take(1),
+ ).subscribe(state => {
+ if (state?.tabs?.length) {
+ this.tabs.set(state.tabs);
+ this.activeIndex.set(state.activeIndex ?? 0);
+ } else if (fallbackGuid) {
+ this.tabs.set([{ queryGuid: fallbackGuid }]);
+ this.activeIndex.set(0);
+ }
+ this.loaded.set(true);
+ });
+ }
+
+ /**
+ * Add a new tab and switch to it.
+ */
+ openTab(queryGuid: string) {
+ this.tabs.update(t => [...t, { queryGuid }]);
+ this.activeIndex.set(this.tabs().length - 1);
+ this.persist();
+ }
+
+ /**
+ * Replace the active tab's query. Resets its runtime state so it re-runs.
+ */
+ openInCurrentTab(queryGuid: string) {
+ const idx = this.activeIndex();
+ if (idx < 0) {
+ // If favorites is active, open a new tab instead
+ this.openTab(queryGuid);
+ return;
+ }
+ const currentTab = this.tabs()[idx];
+ if (currentTab) {
+ // Clean up old runtime
+ this.runtimeMap.delete(currentTab.queryGuid);
+ }
+ this.tabs.update(t => {
+ const updated = [...t];
+ updated[idx] = { queryGuid };
+ return updated;
+ });
+ this.persist();
+ }
+
+ /**
+ * Close a tab at the given index. Cannot close index 0 (first query tab).
+ */
+ closeTab(index: number) {
+ if (index <= 0) return; // can't close first tab
+ const tab = this.tabs()[index];
+ if (tab) {
+ this.runtimeMap.delete(tab.queryGuid);
+ }
+ this.tabs.update(t => t.filter((_, i) => i !== index));
+ // Adjust active index
+ const active = this.activeIndex();
+ if (active >= index) {
+ this.activeIndex.set(Math.max(0, active - 1));
+ }
+ this.persist();
+ }
+
+ /**
+ * Switch to a tab by index. Use -1 for favorites.
+ */
+ switchTo(index: number) {
+ this.activeIndex.set(index);
+ this.persist();
+ }
+
+ /**
+ * Get the runtime state for a tab (creates default if not exists).
+ */
+ getRuntime(queryGuid: string): TabRuntimeState {
+ let state = this.runtimeMap.get(queryGuid);
+ if (!state) {
+ state = defaultRuntime();
+ this.runtimeMap.set(queryGuid, state);
+ }
+ return state;
+ }
+
+ /**
+ * Update partial runtime state for a tab.
+ */
+ setRuntime(queryGuid: string, partial: Partial) {
+ const current = this.getRuntime(queryGuid);
+ Object.assign(current, partial);
+ }
+
+ /**
+ * Remove tabs for query GUIDs that no longer exist.
+ */
+ cleanupDeletedQueries(existingGuids: Set) {
+ const before = this.tabs();
+ const after = before.filter(t => existingGuids.has(t.queryGuid));
+ if (after.length !== before.length) {
+ // Clean up runtime for removed tabs
+ for (const t of before) {
+ if (!existingGuids.has(t.queryGuid)) {
+ this.runtimeMap.delete(t.queryGuid);
+ }
+ }
+ this.tabs.set(after.length > 0 ? after : []);
+ const active = this.activeIndex();
+ if (active >= after.length) {
+ this.activeIndex.set(Math.max(0, after.length - 1));
+ }
+ this.persist();
+ }
+ }
+
+ private persist() {
+ this.saveSubject.next();
+ }
+}
diff --git a/src/app/features/filings/components/filing-query-editor.component.spec.ts b/src/app/features/filings/components/filing-query-editor.component.spec.ts
index 24e08ce..b82d8e8 100644
--- a/src/app/features/filings/components/filing-query-editor.component.spec.ts
+++ b/src/app/features/filings/components/filing-query-editor.component.spec.ts
@@ -1,5 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl } from '@angular/forms';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
import { FilingQueryEditorComponent } from './filing-query-editor.component';
import { describe, it, expect, vi } from 'vitest';
@@ -16,11 +17,12 @@ describe('FilingQueryEditorComponent', () => {
const queryControl = new FormControl(opts.query ?? '', { nonNullable: true });
await TestBed.configureTestingModule({
- imports: [FilingQueryEditorComponent],
+ imports: [FilingQueryEditorComponent, HttpClientTestingModule],
}).compileComponents();
fixture = TestBed.createComponent(FilingQueryEditorComponent);
fixture.componentRef.setInput('queryControl', queryControl);
+ fixture.componentRef.setInput('queryText', opts.query ?? '');
fixture.componentRef.setInput('isQueryValid', opts.isValid ?? true);
fixture.componentRef.setInput('parseErrors', opts.parseErrors ?? []);
fixture.componentRef.setInput('hasParameters', opts.hasParameters ?? false);
@@ -38,10 +40,10 @@ describe('FilingQueryEditorComponent', () => {
expect(fixture.nativeElement.classList.contains('filing-query-editor')).toBe(true);
});
- it('should render textarea with form control value', async () => {
+ it('should render the code editor element', async () => {
await setupTest({ query: 'test query' });
- const textarea = fixture.nativeElement.querySelector('textarea');
- expect(textarea.value).toBe('test query');
+ const editor = fixture.nativeElement.querySelector('app-code-editor');
+ expect(editor).toBeTruthy();
});
it('should show parse errors when present', async () => {
@@ -67,14 +69,12 @@ describe('FilingQueryEditorComponent', () => {
expect(fixture.nativeElement.textContent).not.toContain('Query has parameters');
});
- it('should emit searchTriggered on Ctrl+Enter', async () => {
+ it('should emit searchTriggered when onShortcut is called with ctrl-enter', async () => {
await setupTest();
const spy = vi.fn();
component.searchTriggered.subscribe(spy);
- const textarea = fixture.nativeElement.querySelector('textarea');
- textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true }));
- fixture.detectChanges();
+ component.onShortcut('ctrl-enter');
expect(spy).toHaveBeenCalled();
});
diff --git a/src/app/features/filings/components/filing-query-editor.component.ts b/src/app/features/filings/components/filing-query-editor.component.ts
index a80408b..4a99466 100644
--- a/src/app/features/filings/components/filing-query-editor.component.ts
+++ b/src/app/features/filings/components/filing-query-editor.component.ts
@@ -1,30 +1,33 @@
-import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
-import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core';
+import { FormControl } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
-import { lucideAlertCircle } from '@ng-icons/lucide';
+import { lucideAlertCircle, lucideEye } from '@ng-icons/lucide';
import { QueryParseError } from '../../../core/models/query-parameter.model';
+import { FilingService } from '../../../core/services/filing.service';
+import { SavedQueriesService } from '../../../core/services/saved-queries.service';
+import { CodeEditorComponent } from '../../../components/code-editor/code-editor.component';
+import { createQueryAutocomplete } from '../../../components/code-editor/query-autocomplete';
@Component({
selector: 'app-filing-query-editor',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'filing-query-editor' },
- imports: [ReactiveFormsModule, NgIcon],
- providers: [provideIcons({ lucideAlertCircle })],
+ imports: [NgIcon, CodeEditorComponent],
+ providers: [provideIcons({ lucideAlertCircle, lucideEye })],
template: `
-
+ [value]="peeking() ? peekValue : queryText()"
+ [placeholder]="'form_type = \\'8-K\\' order by snowflake desc limit 50'"
+ [extensions]="editorExtensions()"
+ (valueChange)="onEditorChange($event)"
+ (keyboardShortcut)="onShortcut($event)"
+ />
@@ -41,10 +44,27 @@ import { QueryParseError } from '../../../core/models/query-parameter.model';
-
+
Ctrl+Enter to search
@if (hasParameters()) {
• Query has parameters
+
}
@@ -52,10 +72,47 @@ import { QueryParseError } from '../../../core/models/query-parameter.model';
`,
})
export class FilingQueryEditorComponent {
+ private readonly filingService = inject(FilingService);
+ private readonly savedQueriesService = inject(SavedQueriesService);
+
readonly queryControl = input.required
>();
+ readonly queryText = input.required();
readonly isQueryValid = input.required();
readonly parseErrors = input.required();
readonly hasParameters = input.required();
readonly searchTriggered = output();
+
+ readonly peeking = signal(false);
+ peekValue = '';
+
+ readonly editorExtensions = computed(() => [
+ createQueryAutocomplete(this.filingService),
+ ]);
+
+ onEditorChange(text: string) {
+ if (this.peeking()) return;
+ this.queryControl().setValue(text);
+ }
+
+ onShortcut(name: string) {
+ if (name === 'ctrl-enter') {
+ this.searchTriggered.emit();
+ }
+ }
+
+ startPeek() {
+ if (!this.isQueryValid()) return;
+ try {
+ this.peekValue = this.savedQueriesService.getCompiledQuery();
+ this.peeking.set(true);
+ } catch {
+ // compilation failed — ignore
+ }
+ }
+
+ stopPeek() {
+ if (!this.peeking()) return;
+ this.peeking.set(false);
+ }
}
diff --git a/src/app/features/filings/components/filing-query-item.component.ts b/src/app/features/filings/components/filing-query-item.component.ts
index 936c94c..17ad4c5 100644
--- a/src/app/features/filings/components/filing-query-item.component.ts
+++ b/src/app/features/filings/components/filing-query-item.component.ts
@@ -9,6 +9,7 @@ import {
lucidePencil,
lucideTrash2,
lucideRotateCcw,
+ lucideExternalLink,
} from '@ng-icons/lucide';
import { HlmDropdownMenuImports } from '@spartan-ng/helm/dropdown-menu';
import { HlmInputImports } from '@spartan-ng/helm/input';
@@ -30,6 +31,7 @@ import { SavedQuery } from '../../../core/models/query-parameter.model';
lucidePencil,
lucideTrash2,
lucideRotateCcw,
+ lucideExternalLink,
}),
],
template: `
@@ -108,6 +110,9 @@ import { SavedQuery } from '../../../core/models/query-parameter.model';
{{ query().pinned ? 'Unpin' : 'Pin' }}
+
@if (isBlueprint()) {
}
+
+
+ @if (rowContextMenu()) {
+
+
+ }
`,
styles: [`
@@ -419,6 +452,10 @@ export class FilingResultsGridComponent implements OnInit {
readonly rowData = input.required();
readonly initialColumnState = input(null);
+ readonly favoriteIds = input>(new Set());
+
+ readonly addToFavorites = output();
+ readonly removeFromFavorites = output();
private gridApi!: GridApi;
private readonly saveState$ = new Subject();
@@ -426,6 +463,7 @@ export class FilingResultsGridComponent implements OnInit {
showColumnPanel = signal(false);
columnList = signal([]);
headerContextMenu = signal<{ x: number; y: number; colId: string; headerName: string } | null>(null);
+ rowContextMenu = signal<{ x: number; y: number; filing: Filing; colId: string | null } | null>(null);
@HostListener('mousedown', ['$event'])
onMouseDown(event: MouseEvent): void {
@@ -445,7 +483,10 @@ export class FilingResultsGridComponent implements OnInit {
@HostListener('contextmenu', ['$event'])
onHostContextMenu(event: MouseEvent): void {
- const headerCell = (event.target as HTMLElement).closest('.ag-header-cell');
+ const target = event.target as HTMLElement;
+
+ // Header context menu
+ const headerCell = target.closest('.ag-header-cell');
if (headerCell) {
event.preventDefault();
const colId = headerCell.getAttribute('col-id');
@@ -458,6 +499,21 @@ export class FilingResultsGridComponent implements OnInit {
headerName: (colDef?.headerName as string) || colId,
});
}
+ return;
+ }
+
+ // Row context menu
+ const row = target.closest('.ag-row');
+ if (row && this.gridApi) {
+ event.preventDefault();
+ const rowIndex = row.getAttribute('row-index');
+ if (rowIndex == null) return;
+ const rowNode = this.gridApi.getDisplayedRowAtIndex(Number(rowIndex));
+ const filing = rowNode?.data as Filing | undefined;
+ if (!filing) return;
+ const cell = target.closest('.ag-cell');
+ const colId = cell?.getAttribute('col-id') ?? null;
+ this.rowContextMenu.set({ x: event.clientX, y: event.clientY, filing, colId });
}
}
@@ -797,6 +853,50 @@ export class FilingResultsGridComponent implements OnInit {
this.headerContextMenu.set(null);
}
+ closeRowContextMenu(): void {
+ this.rowContextMenu.set(null);
+ }
+
+ copyCellValue(): void {
+ const ctx = this.rowContextMenu();
+ if (!ctx) return;
+ let value = '';
+ if (ctx.colId) {
+ const raw = (ctx.filing as unknown as Record)[ctx.colId];
+ value = Array.isArray(raw) ? raw.join(', ') : String(raw ?? '');
+ }
+ navigator.clipboard.writeText(value);
+ this.closeRowContextMenu();
+ }
+
+ copyRowData(): void {
+ const ctx = this.rowContextMenu();
+ if (!ctx || !this.gridApi) return;
+ const visibleCols = this.gridApi.getColumnState().filter(c => !c.hide);
+ const values = visibleCols.map(c => {
+ const raw = (ctx.filing as unknown as Record)[c.colId];
+ return Array.isArray(raw) ? raw.join(', ') : String(raw ?? '');
+ });
+ navigator.clipboard.writeText(values.join('\t'));
+ this.closeRowContextMenu();
+ }
+
+ onAddToFavorites(): void {
+ const ctx = this.rowContextMenu();
+ if (ctx) {
+ this.addToFavorites.emit(ctx.filing.id);
+ }
+ this.closeRowContextMenu();
+ }
+
+ onRemoveFromFavorites(): void {
+ const ctx = this.rowContextMenu();
+ if (ctx) {
+ this.removeFromFavorites.emit(ctx.filing.id);
+ }
+ this.closeRowContextMenu();
+ }
+
contextPinColumn(direction: 'left' | 'right' | null): void {
const ctx = this.headerContextMenu();
if (!ctx || !this.gridApi) return;
diff --git a/src/app/features/filings/components/filing-tab-bar.component.ts b/src/app/features/filings/components/filing-tab-bar.component.ts
new file mode 100644
index 0000000..74ed5d0
--- /dev/null
+++ b/src/app/features/filings/components/filing-tab-bar.component.ts
@@ -0,0 +1,155 @@
+import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { lucideStar, lucidePlus, lucideX } from '@ng-icons/lucide';
+import { TabEntry } from '../../../core/services/tab-manager.service';
+
+@Component({
+ selector: 'app-filing-tab-bar',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [NgIcon],
+ providers: [provideIcons({ lucideStar, lucidePlus, lucideX })],
+ template: `
+
+
+ @if (showFavoritesTab()) {
+
+ }
+
+
+ @for (tab of tabs(); track tab.queryGuid; let i = $index) {
+
+ }
+
+
+
+
+ `,
+ styles: `
+ :host {
+ display: block;
+ min-width: 0;
+ }
+
+ .tab-bar {
+ display: flex;
+ align-items: stretch;
+ height: 100%;
+ overflow-x: auto;
+ scrollbar-width: thin;
+ scrollbar-color: #555 transparent;
+ }
+
+ .tab-bar::-webkit-scrollbar {
+ height: 3px;
+ }
+ .tab-bar::-webkit-scrollbar-thumb {
+ background: #555;
+ border-radius: 2px;
+ }
+ .tab-bar::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ .tab {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ max-width: 180px;
+ padding: 0 12px;
+ font-size: 12px;
+ color: #858585;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: color 0.15s, background 0.15s;
+ gap: 4px;
+ }
+
+ .tab:hover {
+ color: #cccccc;
+ background: #2a2a2e;
+ }
+
+ .tab-active {
+ color: #cccccc;
+ background: #1e1e1e;
+ border-bottom-color: #007acc;
+ }
+
+ .tab-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .tab-close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ border-radius: 3px;
+ color: #858585;
+ flex-shrink: 0;
+ }
+
+ .tab-close:hover {
+ color: #ffffff;
+ background: #505050;
+ }
+
+ .tab-add {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 28px;
+ color: #858585;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ transition: color 0.15s;
+ }
+
+ .tab-add:hover {
+ color: #cccccc;
+ }
+ `,
+})
+export class FilingTabBarComponent {
+ readonly tabs = input.required();
+ readonly activeIndex = input.required();
+ readonly showFavoritesTab = input(false);
+ readonly queryNames = input.required