Line data Source code
1 : import * as core from '@ankoh/dashql-core';
2 :
3 : import { Decoration, DecorationSet, EditorView, GutterMarker, gutter, lineNumbers, WidgetType } from '@codemirror/view';
4 : import { EditorState, type Extension, Range, RangeSet, RangeSetBuilder, StateEffect, StateEffectType, StateField, Transaction } from '@codemirror/state';
5 :
6 : interface StoryStatement {
7 : id: number;
8 : from: number;
9 : to: number;
10 : label: string;
11 : }
12 :
13 : interface StoryModel {
14 : statements: StoryStatement[];
15 : }
16 :
17 : export type StoryActivation = 'toggle' | 'open';
18 :
19 : interface StoryConfig {
20 : activation: StoryActivation;
21 : onActivate?: (statementId: number) => void;
22 : showGutter?: boolean;
23 : }
24 :
25 : export interface StoryState {
26 : model: StoryModel | null;
27 : expanded: ReadonlySet<number>;
28 : decorations: DecorationSet;
29 : atomicRanges: DecorationSet;
30 : gutterMarkers: RangeSet<GutterMarker>;
31 : }
32 :
33 1 : export const DashQLStoryUpdateEffect: StateEffectType<core.buffers.editor.EditorUpdateT | null> =
34 1 : StateEffect.define<core.buffers.editor.EditorUpdateT | null>();
35 1 : export const DashQLStoryToggleStatementEffect: StateEffectType<number> = StateEffect.define<number>();
36 :
37 : /// Return whether any statement has description comments. This lets the preview preserve its compact
38 : /// rendering for ordinary SQL while using raw source offsets only when descriptions are present.
39 : export function hasStatementDescriptions(update: core.buffers.editor.EditorUpdateT | null | undefined): boolean {
40 : return (update?.scriptAnnotations?.statementDescriptions.length ?? 0) > 0;
41 : }
42 :
43 1 : function statementLabel(type: core.buffers.parser.StatementType): string {
44 1 : switch (type) {
45 1 : case core.buffers.parser.StatementType.CREATE_TABLE:
46 1 : case core.buffers.parser.StatementType.CREATE_TABLE_AS:
47 1 : return 'create table';
48 1 : case core.buffers.parser.StatementType.CREATE_VIEW:
49 1 : return 'create view';
50 1 : case core.buffers.parser.StatementType.SELECT:
51 1 : return 'select';
52 1 : case core.buffers.parser.StatementType.SET:
53 1 : return 'set';
54 1 : case core.buffers.parser.StatementType.CREATE_FUNCTION:
55 1 : return 'create function';
56 1 : case core.buffers.parser.StatementType.EXPLAIN:
57 1 : return 'explain';
58 1 : case core.buffers.parser.StatementType.DROP_TABLE:
59 1 : return 'drop table';
60 1 : case core.buffers.parser.StatementType.DROP_VIEW:
61 1 : return 'drop view';
62 1 : case core.buffers.parser.StatementType.SELECT_INTO:
63 1 : return 'select into';
64 1 : case core.buffers.parser.StatementType.ATTACH_DATABASE:
65 1 : return 'attach database';
66 1 : case core.buffers.parser.StatementType.INSERT:
67 1 : return 'insert into';
68 0 : case core.buffers.parser.StatementType.CALL:
69 0 : return 'call';
70 0 : default:
71 0 : return 'statement';
72 1 : }
73 1 : }
74 :
75 1 : function buildStoryModel(update: core.buffers.editor.EditorUpdateT | null, activation: StoryActivation): StoryModel | null {
76 1 : if (update == null || update.diagnostics.some(d => d.source !== core.buffers.editor.EditorDiagnosticSource.ANALYZER)) {
77 0 : return null;
78 : }
79 1 : const statements: StoryStatement[] = [];
80 1 : const annotations = update.scriptAnnotations;
81 1 : const source = activation === 'toggle' ? annotations?.statements : annotations?.statementDescriptions;
82 1 : for (const current of source ?? []) {
83 1 : const span = current.textSpan;
84 1 : if (span == null) continue;
85 1 : const from = Number(span.offset);
86 1 : const to = Number(span.offset + span.length);
87 1 : if (from >= to) return null;
88 1 : statements.push({
89 1 : id: current.statementId,
90 1 : from,
91 1 : to,
92 1 : label: statementLabel(current.statementType),
93 1 : });
94 1 : }
95 1 : return statements.length > 0 ? { statements } : null;
96 1 : }
97 :
98 1 : class ExpandStatementControl extends WidgetType {
99 1 : constructor(
100 1 : private readonly statementId: number,
101 1 : private readonly label: string,
102 1 : private readonly activation: StoryActivation,
103 1 : private readonly onActivate: ((statementId: number) => void) | undefined,
104 1 : ) { super(); }
105 :
106 0 : eq(other: ExpandStatementControl) {
107 0 : return this.statementId === other.statementId && this.label === other.label && this.activation === other.activation;
108 0 : }
109 :
110 1 : toDOM(view: EditorView): HTMLElement {
111 1 : const button = document.createElement('button');
112 1 : button.type = 'button';
113 1 : button.className = 'dashql-story-sql-control';
114 1 : button.dataset.testid = 'story-sql-control';
115 1 : button.dataset.dashqlStoryControl = 'true';
116 1 : button.dataset.dashqlStoryStatement = String(this.statementId);
117 1 : button.setAttribute('aria-expanded', 'false');
118 1 : button.textContent = this.label;
119 1 : button.addEventListener('click', (event) => {
120 1 : event.preventDefault();
121 1 : event.stopPropagation();
122 1 : if (this.activation === 'open') {
123 1 : this.onActivate?.(this.statementId);
124 1 : return;
125 1 : }
126 0 : view.dispatch({ effects: DashQLStoryToggleStatementEffect.of(this.statementId) });
127 0 : requestAnimationFrame(() => {
128 0 : view.dom.querySelector<HTMLButtonElement>(`[data-dashql-story-statement="${this.statementId}"]`)?.focus();
129 0 : });
130 1 : });
131 1 : return button;
132 1 : }
133 :
134 0 : ignoreEvent() { return false; }
135 1 : }
136 :
137 1 : class StoryFoldMarker extends GutterMarker {
138 1 : constructor(
139 1 : private readonly statementId: number,
140 1 : private readonly expanded: boolean,
141 1 : ) { super(); }
142 :
143 1 : eq(other: StoryFoldMarker) { return this.statementId === other.statementId && this.expanded === other.expanded; }
144 :
145 1 : toDOM(view: EditorView): HTMLElement {
146 1 : const button = document.createElement('button');
147 1 : button.type = 'button';
148 1 : button.className = 'dashql-story-fold-control';
149 1 : button.dataset.testid = 'story-fold-control';
150 1 : button.dataset.dashqlStoryControl = 'true';
151 1 : button.dataset.dashqlStoryStatement = String(this.statementId);
152 1 : button.setAttribute('aria-expanded', String(this.expanded));
153 1 : button.setAttribute('aria-label', this.expanded ? 'Collapse SQL statement' : 'Expand SQL statement');
154 1 : button.textContent = this.expanded ? '▾' : '▸';
155 1 : button.addEventListener('click', event => {
156 1 : event.preventDefault();
157 1 : event.stopPropagation();
158 1 : view.dispatch({ effects: DashQLStoryToggleStatementEffect.of(this.statementId) });
159 1 : requestAnimationFrame(() => {
160 1 : view.dom.querySelector<HTMLButtonElement>(`[data-dashql-story-statement="${this.statementId}"]`)?.focus();
161 1 : });
162 1 : });
163 1 : return button;
164 1 : }
165 :
166 0 : ignoreEvent() { return false; }
167 1 : }
168 :
169 1 : class StoryFoldSpacer extends GutterMarker {
170 1 : toDOM(): HTMLElement {
171 1 : const spacer = document.createElement('span');
172 1 : spacer.className = 'dashql-story-fold-spacer';
173 1 : return spacer;
174 1 : }
175 1 : }
176 :
177 1 : function buildDecorations(state: EditorState, model: StoryModel | null, expanded: ReadonlySet<number>, config: StoryConfig): Pick<StoryState, 'decorations' | 'atomicRanges' | 'gutterMarkers'> {
178 1 : if (model == null) return { decorations: Decoration.none, atomicRanges: Decoration.none, gutterMarkers: RangeSet.empty };
179 1 : const ranges: Range<Decoration>[] = [];
180 1 : const atomic: Range<Decoration>[] = [];
181 1 : const gutterBuilder = new RangeSetBuilder<GutterMarker>();
182 1 : for (const statement of model.statements) {
183 1 : const isExpanded = expanded.has(statement.id);
184 1 : if (config.activation === 'open' || !isExpanded) {
185 1 : const replacement = Decoration.replace({
186 1 : widget: new ExpandStatementControl(statement.id, statement.label, config.activation, config.onActivate),
187 1 : inclusive: false,
188 1 : });
189 1 : ranges.push(replacement.range(statement.from, statement.to));
190 1 : atomic.push(replacement.range(statement.from, statement.to));
191 1 : }
192 1 : if (config.activation === 'toggle') gutterBuilder.add(statement.from, statement.from, new StoryFoldMarker(statement.id, isExpanded));
193 1 : }
194 1 : return { decorations: Decoration.set(ranges, true), atomicRanges: Decoration.set(atomic, true), gutterMarkers: gutterBuilder.finish() };
195 1 : }
196 :
197 1 : export function createStoryDecorations(config: StoryConfig): { extensions: Extension[]; field: StateField<StoryState> } {
198 1 : const field = StateField.define<StoryState>({
199 1 : create(state) {
200 1 : return { model: null, expanded: new Set(), ...buildDecorations(state, null, new Set(), config) };
201 1 : },
202 1 : update(value, transaction: Transaction) {
203 1 : let model = value.model;
204 1 : let expanded = value.expanded;
205 1 : for (const effect of transaction.effects) {
206 1 : if (effect.is(DashQLStoryUpdateEffect)) {
207 1 : const nextModel = buildStoryModel(effect.value, config.activation);
208 1 : if (config.activation === 'toggle') {
209 1 : const collapsed = new Set(model?.statements
210 1 : .filter(statement => !expanded.has(statement.id))
211 1 : .map(statement => statement.id));
212 1 : expanded = new Set(nextModel?.statements
213 1 : .filter(statement => !collapsed.has(statement.id))
214 1 : .map(statement => statement.id));
215 1 : } else {
216 1 : expanded = new Set();
217 : }
218 1 : model = nextModel;
219 1 : } else if (effect.is(DashQLStoryToggleStatementEffect) && model?.statements.some(s => s.id === effect.value)) {
220 1 : const nextExpanded = new Set(expanded);
221 1 : nextExpanded.has(effect.value) ? nextExpanded.delete(effect.value) : nextExpanded.add(effect.value);
222 1 : expanded = nextExpanded;
223 1 : }
224 : }
225 1 : if (model === value.model && expanded === value.expanded && !transaction.docChanged) return value;
226 1 : return { model, expanded, ...buildDecorations(transaction.state, model, expanded, config) };
227 1 : },
228 1 : provide: field => [
229 1 : EditorView.decorations.from(field, value => value.decorations),
230 1 : EditorView.atomicRanges.of(view => view.state.field(field).atomicRanges),
231 1 : ],
232 1 : });
233 1 : const foldGutter = gutter({
234 1 : class: 'dashql-story-fold-gutter',
235 1 : markers: view => view.state.field(field).gutterMarkers,
236 1 : initialSpacer: () => new StoryFoldSpacer(),
237 1 : });
238 1 : return {
239 1 : extensions: config.showGutter === false ? [field] : [lineNumbers(), field, foldGutter],
240 1 : field,
241 1 : };
242 1 : }
|