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 : default:
69 0 : return 'statement';
70 1 : }
71 1 : }
72 :
73 1 : function buildStoryModel(update: core.buffers.editor.EditorUpdateT | null, activation: StoryActivation): StoryModel | null {
74 1 : if (update == null || update.diagnostics.some(d => d.source !== core.buffers.editor.EditorDiagnosticSource.ANALYZER)) {
75 0 : return null;
76 : }
77 1 : const statements: StoryStatement[] = [];
78 1 : const annotations = update.scriptAnnotations;
79 1 : const source = activation === 'toggle' ? annotations?.statements : annotations?.statementDescriptions;
80 1 : for (const current of source ?? []) {
81 1 : const span = current.textSpan;
82 1 : if (span == null) continue;
83 1 : const from = Number(span.offset);
84 1 : const to = Number(span.offset + span.length);
85 1 : if (from >= to) return null;
86 1 : statements.push({
87 1 : id: current.statementId,
88 1 : from,
89 1 : to,
90 1 : label: statementLabel(current.statementType),
91 1 : });
92 1 : }
93 1 : return statements.length > 0 ? { statements } : null;
94 1 : }
95 :
96 1 : class ExpandStatementControl extends WidgetType {
97 1 : constructor(
98 1 : private readonly statementId: number,
99 1 : private readonly label: string,
100 1 : private readonly activation: StoryActivation,
101 1 : private readonly onActivate: ((statementId: number) => void) | undefined,
102 1 : ) { super(); }
103 :
104 0 : eq(other: ExpandStatementControl) {
105 0 : return this.statementId === other.statementId && this.label === other.label && this.activation === other.activation;
106 0 : }
107 :
108 1 : toDOM(view: EditorView): HTMLElement {
109 1 : const button = document.createElement('button');
110 1 : button.type = 'button';
111 1 : button.className = 'dashql-story-sql-control';
112 1 : button.dataset.testid = 'story-sql-control';
113 1 : button.dataset.dashqlStoryControl = 'true';
114 1 : button.dataset.dashqlStoryStatement = String(this.statementId);
115 1 : button.setAttribute('aria-expanded', 'false');
116 1 : button.textContent = this.label;
117 1 : button.addEventListener('click', (event) => {
118 1 : event.preventDefault();
119 1 : event.stopPropagation();
120 1 : if (this.activation === 'open') {
121 1 : this.onActivate?.(this.statementId);
122 1 : return;
123 1 : }
124 0 : view.dispatch({ effects: DashQLStoryToggleStatementEffect.of(this.statementId) });
125 0 : requestAnimationFrame(() => {
126 0 : view.dom.querySelector<HTMLButtonElement>(`[data-dashql-story-statement="${this.statementId}"]`)?.focus();
127 0 : });
128 1 : });
129 1 : return button;
130 1 : }
131 :
132 0 : ignoreEvent() { return false; }
133 1 : }
134 :
135 1 : class StoryFoldMarker extends GutterMarker {
136 1 : constructor(
137 1 : private readonly statementId: number,
138 1 : private readonly expanded: boolean,
139 1 : ) { super(); }
140 :
141 1 : eq(other: StoryFoldMarker) { return this.statementId === other.statementId && this.expanded === other.expanded; }
142 :
143 1 : toDOM(view: EditorView): HTMLElement {
144 1 : const button = document.createElement('button');
145 1 : button.type = 'button';
146 1 : button.className = 'dashql-story-fold-control';
147 1 : button.dataset.testid = 'story-fold-control';
148 1 : button.dataset.dashqlStoryControl = 'true';
149 1 : button.dataset.dashqlStoryStatement = String(this.statementId);
150 1 : button.setAttribute('aria-expanded', String(this.expanded));
151 1 : button.setAttribute('aria-label', this.expanded ? 'Collapse SQL statement' : 'Expand SQL statement');
152 1 : button.textContent = this.expanded ? '▾' : '▸';
153 1 : button.addEventListener('click', event => {
154 1 : event.preventDefault();
155 1 : event.stopPropagation();
156 1 : view.dispatch({ effects: DashQLStoryToggleStatementEffect.of(this.statementId) });
157 1 : requestAnimationFrame(() => {
158 1 : view.dom.querySelector<HTMLButtonElement>(`[data-dashql-story-statement="${this.statementId}"]`)?.focus();
159 1 : });
160 1 : });
161 1 : return button;
162 1 : }
163 :
164 0 : ignoreEvent() { return false; }
165 1 : }
166 :
167 1 : class StoryFoldSpacer extends GutterMarker {
168 1 : toDOM(): HTMLElement {
169 1 : const spacer = document.createElement('span');
170 1 : spacer.className = 'dashql-story-fold-spacer';
171 1 : return spacer;
172 1 : }
173 1 : }
174 :
175 1 : function buildDecorations(state: EditorState, model: StoryModel | null, expanded: ReadonlySet<number>, config: StoryConfig): Pick<StoryState, 'decorations' | 'atomicRanges' | 'gutterMarkers'> {
176 1 : if (model == null) return { decorations: Decoration.none, atomicRanges: Decoration.none, gutterMarkers: RangeSet.empty };
177 1 : const ranges: Range<Decoration>[] = [];
178 1 : const atomic: Range<Decoration>[] = [];
179 1 : const gutterBuilder = new RangeSetBuilder<GutterMarker>();
180 1 : for (const statement of model.statements) {
181 1 : const isExpanded = expanded.has(statement.id);
182 1 : if (config.activation === 'open' || !isExpanded) {
183 1 : const replacement = Decoration.replace({
184 1 : widget: new ExpandStatementControl(statement.id, statement.label, config.activation, config.onActivate),
185 1 : inclusive: false,
186 1 : });
187 1 : ranges.push(replacement.range(statement.from, statement.to));
188 1 : atomic.push(replacement.range(statement.from, statement.to));
189 1 : }
190 1 : if (config.activation === 'toggle') gutterBuilder.add(statement.from, statement.from, new StoryFoldMarker(statement.id, isExpanded));
191 1 : }
192 1 : return { decorations: Decoration.set(ranges, true), atomicRanges: Decoration.set(atomic, true), gutterMarkers: gutterBuilder.finish() };
193 1 : }
194 :
195 1 : export function createStoryDecorations(config: StoryConfig): { extensions: Extension[]; field: StateField<StoryState> } {
196 1 : const field = StateField.define<StoryState>({
197 1 : create(state) {
198 1 : return { model: null, expanded: new Set(), ...buildDecorations(state, null, new Set(), config) };
199 1 : },
200 1 : update(value, transaction: Transaction) {
201 1 : let model = value.model;
202 1 : let expanded = value.expanded;
203 1 : for (const effect of transaction.effects) {
204 1 : if (effect.is(DashQLStoryUpdateEffect)) {
205 1 : const nextModel = buildStoryModel(effect.value, config.activation);
206 1 : if (config.activation === 'toggle') {
207 1 : const collapsed = new Set(model?.statements
208 1 : .filter(statement => !expanded.has(statement.id))
209 1 : .map(statement => statement.id));
210 1 : expanded = new Set(nextModel?.statements
211 1 : .filter(statement => !collapsed.has(statement.id))
212 1 : .map(statement => statement.id));
213 1 : } else {
214 1 : expanded = new Set();
215 : }
216 1 : model = nextModel;
217 1 : } else if (effect.is(DashQLStoryToggleStatementEffect) && model?.statements.some(s => s.id === effect.value)) {
218 1 : const nextExpanded = new Set(expanded);
219 1 : nextExpanded.has(effect.value) ? nextExpanded.delete(effect.value) : nextExpanded.add(effect.value);
220 1 : expanded = nextExpanded;
221 1 : }
222 : }
223 1 : if (model === value.model && expanded === value.expanded && !transaction.docChanged) return value;
224 1 : return { model, expanded, ...buildDecorations(transaction.state, model, expanded, config) };
225 1 : },
226 1 : provide: field => [
227 1 : EditorView.decorations.from(field, value => value.decorations),
228 1 : EditorView.atomicRanges.of(view => view.state.field(field).atomicRanges),
229 1 : ],
230 1 : });
231 1 : const foldGutter = gutter({
232 1 : class: 'dashql-story-fold-gutter',
233 1 : markers: view => view.state.field(field).gutterMarkers,
234 1 : initialSpacer: () => new StoryFoldSpacer(),
235 1 : });
236 1 : return {
237 1 : extensions: config.showGutter === false ? [field] : [lineNumbers(), field, foldGutter],
238 1 : field,
239 1 : };
240 1 : }
|