@deckbuilderkit/editor 0.0.0-stage → 0.1.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.
- package/package.json +53 -4
- package/src/ArchiveView.tsx +111 -0
- package/src/CodeEditor.tsx +265 -0
- package/src/CodeView.tsx +67 -0
- package/src/DiffView.tsx +139 -0
- package/src/EnvView.tsx +42 -0
- package/src/FilePreview.tsx +108 -0
- package/src/FontView.tsx +48 -0
- package/src/HarView.tsx +70 -0
- package/src/HexView.tsx +28 -0
- package/src/HtmlPreview.tsx +23 -0
- package/src/MarkdownEditor.tsx +90 -0
- package/src/MarkdownToc.tsx +135 -0
- package/src/MarkdownView.tsx +84 -0
- package/src/MediaView.tsx +17 -0
- package/src/NotebookView.tsx +74 -0
- package/src/PdfView.tsx +289 -0
- package/src/RequestEditor.tsx +419 -0
- package/src/ResponseView.tsx +89 -0
- package/src/SqlGraph.tsx +322 -0
- package/src/SqlSteps.tsx +487 -0
- package/src/SqliteView.tsx +68 -0
- package/src/SvgView.tsx +231 -0
- package/src/TableView.tsx +85 -0
- package/src/VarSuggest.tsx +125 -0
- package/src/curl-file.ts +70 -0
- package/src/editorOptions.ts +83 -0
- package/src/editorOptionsContext.ts +4 -0
- package/src/fileKinds.ts +69 -0
- package/src/http-client.ts +9 -0
- package/src/http-import.ts +594 -0
- package/src/http.ts +251 -0
- package/src/import-warning.ts +8 -0
- package/src/index.ts +56 -0
- package/src/languages.ts +107 -0
- package/src/markdown.ts +123 -0
- package/src/markdownLive.ts +289 -0
- package/src/options.ts +3 -0
- package/src/pdf-url.d.ts +4 -0
- package/src/sql/format.ts +637 -0
- package/src/sql/formatOptions.ts +71 -0
- package/src/sql/lineage.ts +365 -0
- package/src/sql/lint.ts +951 -0
- package/src/sql/markerHighlight.ts +50 -0
- package/src/sql/markers.ts +138 -0
- package/src/sql/outline.ts +662 -0
- package/src/sql/statements.ts +83 -0
- package/src/sql/tokens.ts +156 -0
- package/src/sql/xmlQuery.ts +249 -0
- package/src/sqlProblems.ts +40 -0
- package/src/table.ts +89 -0
- package/src/text-save.ts +28 -0
- package/src/theme.ts +86 -0
- package/src/toc.ts +11 -0
- package/src/variables.ts +123 -0
- package/README.md +0 -3
package/src/SqlSteps.tsx
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { Badge, Icon, PanelNotice, t } from '@deckbuilderkit/ui';
|
|
2
|
+
import { type KeyboardEvent, type PointerEvent, type ReactNode, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { SqlGraph } from './SqlGraph';
|
|
4
|
+
import { type SqlLintSeverity, type SqlProblem, sqlLint } from './sql/lint';
|
|
5
|
+
import { type SqlParam, sqlParams } from './sql/markers';
|
|
6
|
+
import { type SqlStatementOutline, type SqlStep, sqlOutline } from './sql/outline';
|
|
7
|
+
import { lineOf, lineStarts, SQL_SEVERITIES, sqlProblemMessage, worstSeverity } from './sqlProblems';
|
|
8
|
+
|
|
9
|
+
export type SqlStepsTab = 'steps' | 'graph' | 'params' | 'problems';
|
|
10
|
+
|
|
11
|
+
export interface SqlStepsProps {
|
|
12
|
+
text: string;
|
|
13
|
+
onJump(from: number, to?: number): void;
|
|
14
|
+
/** Offset of the editor cursor; the card holding it is highlighted. */
|
|
15
|
+
cursor?: number;
|
|
16
|
+
onClose?(): void;
|
|
17
|
+
/** The open tab; uncontrolled ('steps' first) without it. */
|
|
18
|
+
tab?: SqlStepsTab;
|
|
19
|
+
onTab?(tab: SqlStepsTab): void;
|
|
20
|
+
/** Panel width in px (default 300); the left edge drags it, reporting the new width on release. */
|
|
21
|
+
width?: number;
|
|
22
|
+
onWidth?(width: number): void;
|
|
23
|
+
/** Fill a bottom panel (no fixed width, no resize edge) instead of a right side panel. */
|
|
24
|
+
bottom?: boolean;
|
|
25
|
+
/** Values of the `{n}` parameters; the Parameters tab shows an input per parameter when `onParamValue` is given. */
|
|
26
|
+
paramValues?: Record<number, string>;
|
|
27
|
+
onParamValue?(n: number, value: string): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MAIN = 'main';
|
|
31
|
+
const TABS: readonly SqlStepsTab[] = ['steps', 'graph', 'params', 'problems'];
|
|
32
|
+
const DEFAULT_WIDTH = 300;
|
|
33
|
+
const MIN_WIDTH = 260;
|
|
34
|
+
const MAX_SHARE = 0.7;
|
|
35
|
+
const RESIZE_STEP = 16;
|
|
36
|
+
|
|
37
|
+
function SeverityIcon({ severity }: { severity: SqlLintSeverity }) {
|
|
38
|
+
return (
|
|
39
|
+
<span className={`sqlsteps-sev is-${severity}`} aria-label={t(`sqlSteps.severity.${severity}`)}>
|
|
40
|
+
{severity === 'info' ? <span className="sqlsteps-sev-dot" /> : <Icon name="alert" size={13} />}
|
|
41
|
+
</span>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ProblemList({ problems, text, outline, filter, onClearFilter, onJump }: {
|
|
46
|
+
problems: readonly SqlProblem[];
|
|
47
|
+
text: string;
|
|
48
|
+
outline: { statements: SqlStatementOutline[] };
|
|
49
|
+
filter?: { statement: number; step: string };
|
|
50
|
+
onClearFilter(): void;
|
|
51
|
+
onJump(from: number, to?: number): void;
|
|
52
|
+
}) {
|
|
53
|
+
const [hidden, setHidden] = useState<ReadonlySet<SqlLintSeverity>>(new Set());
|
|
54
|
+
const starts = useMemo(() => lineStarts(text), [text]);
|
|
55
|
+
const counts = useMemo(() => {
|
|
56
|
+
const c: Record<SqlLintSeverity, number> = { error: 0, warning: 0, info: 0 };
|
|
57
|
+
for (const p of problems) c[p.severity]++;
|
|
58
|
+
return c;
|
|
59
|
+
}, [problems]);
|
|
60
|
+
const shown = useMemo(
|
|
61
|
+
() => problems
|
|
62
|
+
.filter((p) => !hidden.has(p.severity) && (!filter || (p.statement === filter.statement && p.step === filter.step)))
|
|
63
|
+
.sort((a, b) => a.from - b.from || a.to - b.to),
|
|
64
|
+
[problems, hidden, filter],
|
|
65
|
+
);
|
|
66
|
+
const toggle = (s: SqlLintSeverity) => setHidden((cur) => {
|
|
67
|
+
const next = new Set(cur);
|
|
68
|
+
if (next.has(s)) next.delete(s);
|
|
69
|
+
else next.add(s);
|
|
70
|
+
return next;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (problems.length === 0) {
|
|
74
|
+
return <PanelNotice icon={<Icon name="check" size={20} />} title={t('sqlSteps.problems.none')} message={t('sqlSteps.problems.noneHint')} />;
|
|
75
|
+
}
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
<div className="sqlsteps-chips">
|
|
79
|
+
{SQL_SEVERITIES.map((s) => (
|
|
80
|
+
<button key={s} className={`sqlsteps-chip is-${s}`} aria-pressed={!hidden.has(s)} onClick={() => toggle(s)} disabled={counts[s] === 0}>
|
|
81
|
+
<SeverityIcon severity={s} /> {t(`sqlSteps.severity.${s}`)} <span className="sqlsteps-chip-count">{counts[s]}</span>
|
|
82
|
+
</button>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
{filter && (
|
|
86
|
+
<div className="sqlsteps-infilter">
|
|
87
|
+
<span className="muted">{t('sqlSteps.problems.inStep', { step: filter.step || t('sqlSteps.main') })}</span>
|
|
88
|
+
<button className="sqlsteps-feed" onClick={onClearFilter}>{t('sqlSteps.problems.showAll')}</button>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
<div className="sqlsteps-list">
|
|
92
|
+
{shown.length === 0 ? (
|
|
93
|
+
<p className="muted sqlsteps-none">{t('sqlSteps.problems.noMatch')}</p>
|
|
94
|
+
) : (
|
|
95
|
+
shown.map((p, i) => {
|
|
96
|
+
const step = p.step === undefined ? (outline.statements[p.statement] ? t(`sqlSteps.kind.${outline.statements[p.statement]!.kind}`) : '') : p.step || t('sqlSteps.main');
|
|
97
|
+
const message = sqlProblemMessage(p);
|
|
98
|
+
return (
|
|
99
|
+
<button key={i} className="sqlsteps-problem" onClick={() => onJump(p.from, p.to)} title={message}>
|
|
100
|
+
<SeverityIcon severity={p.severity} />
|
|
101
|
+
<span className="sqlsteps-problem-body">
|
|
102
|
+
<span className="sqlsteps-problem-msg">{message}</span>
|
|
103
|
+
<span className="muted sqlsteps-problem-where">
|
|
104
|
+
<span className="sqlsteps-problem-step">{step}</span>
|
|
105
|
+
<span>{t('sqlSteps.problems.line', { line: lineOf(starts, p.from) })}</span>
|
|
106
|
+
</span>
|
|
107
|
+
</span>
|
|
108
|
+
</button>
|
|
109
|
+
);
|
|
110
|
+
})
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
</>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function ParamList({ params, text, cursor, onJump, values, onValue }: { params: readonly SqlParam[]; text: string; cursor?: number; onJump(from: number, to?: number): void; values?: Record<number, string>; onValue?(n: number, value: string): void }) {
|
|
118
|
+
const [selected, setSelected] = useState<number>();
|
|
119
|
+
const starts = useMemo(() => lineStarts(text), [text]);
|
|
120
|
+
if (params.length === 0) {
|
|
121
|
+
return <PanelNotice icon={<Icon name="list-flat" size={20} />} title={t('sqlSteps.params.none')} message={t('sqlSteps.params.noneHint')} />;
|
|
122
|
+
}
|
|
123
|
+
const lineText = (line: number) => text.slice(starts[line - 1] ?? 0, starts[line] ?? text.length).trim();
|
|
124
|
+
return (
|
|
125
|
+
<div className="sqlsteps-list">
|
|
126
|
+
{params.map((p) => {
|
|
127
|
+
const open = selected === p.n;
|
|
128
|
+
return (
|
|
129
|
+
<div key={p.n} className="sqlsteps-param">
|
|
130
|
+
<button
|
|
131
|
+
className="sqlsteps-problem"
|
|
132
|
+
aria-expanded={open}
|
|
133
|
+
onClick={() => {
|
|
134
|
+
setSelected(open ? undefined : p.n);
|
|
135
|
+
const first = p.uses[0];
|
|
136
|
+
const here = cursor !== undefined && p.uses.some((u) => lineOf(starts, u.from) === lineOf(starts, cursor));
|
|
137
|
+
if (!open && first && !here) onJump(first.from, first.to);
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
<code className="sqlsteps-param-name">{`{${p.n}}`}</code>
|
|
141
|
+
<span className="sqlsteps-problem-body">
|
|
142
|
+
<span className="sqlsteps-problem-msg">{t('sqlSteps.params.uses', { count: p.count })}</span>
|
|
143
|
+
<span className="muted sqlsteps-problem-where">
|
|
144
|
+
<span>{t('sqlSteps.params.guarded', { count: p.markers })}</span>
|
|
145
|
+
<span>{t('sqlSteps.params.unguarded', { count: p.unguarded })}</span>
|
|
146
|
+
</span>
|
|
147
|
+
</span>
|
|
148
|
+
</button>
|
|
149
|
+
{open && (
|
|
150
|
+
<div className="sqlsteps-param-detail">
|
|
151
|
+
{onValue && (
|
|
152
|
+
<input
|
|
153
|
+
className="input"
|
|
154
|
+
value={values?.[p.n] ?? ''}
|
|
155
|
+
onChange={(e) => onValue(p.n, e.target.value)}
|
|
156
|
+
placeholder={t('sqlSteps.params.value')}
|
|
157
|
+
aria-label={t('sqlSteps.params.valueOf', { n: p.n })}
|
|
158
|
+
/>
|
|
159
|
+
)}
|
|
160
|
+
{p.uses.map((use, i) => {
|
|
161
|
+
const line = lineOf(starts, use.from);
|
|
162
|
+
return (
|
|
163
|
+
<button key={i} className="sqlsteps-param-use" onClick={() => onJump(use.from, use.to)} title={t(`sqlSteps.params.kind.${use.kind}`)}>
|
|
164
|
+
<span className="muted sqlsteps-param-line">{line}</span>
|
|
165
|
+
<code className="sqlsteps-code">{lineText(line)}</code>
|
|
166
|
+
</button>
|
|
167
|
+
);
|
|
168
|
+
})}
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
})}
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function stepLabel(step: SqlStep): string {
|
|
179
|
+
return step.name || t('sqlSteps.main');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function stepText(step: SqlStep): string {
|
|
183
|
+
return [step.name, ...step.reads.map((r) => `${r.name} ${r.alias ?? ''}`), ...step.joins.map((j) => `${j.target} ${j.alias ?? ''}`)].join(' ').toLowerCase();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function groupTitle(s: SqlStatementOutline): string {
|
|
187
|
+
const kind = t(`sqlSteps.kind.${s.kind}`);
|
|
188
|
+
return s.target ? `${kind} ${s.target}` : kind;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function Row({ label, children }: { label: string; children: ReactNode }) {
|
|
192
|
+
return (
|
|
193
|
+
<div className="sqlsteps-row">
|
|
194
|
+
<span className="sqlsteps-label">{label}</span>
|
|
195
|
+
<div className="sqlsteps-value">{children}</div>
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function Items({ items }: { items: string[] }) {
|
|
201
|
+
return <>{items.map((x, i) => <code key={i} className="sqlsteps-code" title={x}>{x}</code>)}</>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Explains a SQL script as its CTE steps: what each reads, joins, filters and feeds. */
|
|
205
|
+
export function SqlSteps({ text, onJump, cursor, onClose, tab: tabProp, onTab, width, onWidth, bottom, paramValues, onParamValue }: SqlStepsProps) {
|
|
206
|
+
const deferred = useDeferredValue(text);
|
|
207
|
+
const outline = useMemo(() => sqlOutline(deferred), [deferred]);
|
|
208
|
+
// Shared by every tab, so a tab switch or cursor move never recomputes them.
|
|
209
|
+
const problems = useMemo(() => sqlLint(deferred, outline), [deferred, outline]);
|
|
210
|
+
const params = useMemo(() => sqlParams(deferred), [deferred]);
|
|
211
|
+
const worst = useMemo(() => worstSeverity(problems), [problems]);
|
|
212
|
+
const stepProblems = useMemo(() => {
|
|
213
|
+
const map = new Map<string, number>();
|
|
214
|
+
for (const p of problems) {
|
|
215
|
+
if (p.step === undefined) continue;
|
|
216
|
+
const key = `${p.statement}:${p.step}`;
|
|
217
|
+
map.set(key, (map.get(key) ?? 0) + 1);
|
|
218
|
+
}
|
|
219
|
+
return map;
|
|
220
|
+
}, [problems]);
|
|
221
|
+
const [ownTab, setOwnTab] = useState<SqlStepsTab>('steps');
|
|
222
|
+
const tab = tabProp ?? ownTab;
|
|
223
|
+
const setTab = (next: SqlStepsTab) => {
|
|
224
|
+
setOwnTab(next);
|
|
225
|
+
onTab?.(next);
|
|
226
|
+
};
|
|
227
|
+
const [problemFilter, setProblemFilter] = useState<{ statement: number; step: string }>();
|
|
228
|
+
const showStepProblems = (statement: number, step: string) => {
|
|
229
|
+
setProblemFilter({ statement, step });
|
|
230
|
+
setTab('problems');
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const aside = useRef<HTMLElement>(null);
|
|
234
|
+
const [ownWidth, setOwnWidth] = useState(DEFAULT_WIDTH);
|
|
235
|
+
const [liveWidth, setLiveWidth] = useState<number>();
|
|
236
|
+
const shownWidth = liveWidth ?? width ?? ownWidth;
|
|
237
|
+
const clampWidth = (w: number) => {
|
|
238
|
+
const parent = aside.current?.parentElement?.clientWidth ?? 0;
|
|
239
|
+
const max = parent > 0 ? Math.max(MIN_WIDTH, parent * MAX_SHARE) : Infinity;
|
|
240
|
+
return Math.round(Math.min(max, Math.max(MIN_WIDTH, w)));
|
|
241
|
+
};
|
|
242
|
+
const commitWidth = (w: number) => {
|
|
243
|
+
setOwnWidth(w);
|
|
244
|
+
setLiveWidth(undefined);
|
|
245
|
+
onWidth?.(w);
|
|
246
|
+
};
|
|
247
|
+
const startResize = (e: PointerEvent<HTMLDivElement>) => {
|
|
248
|
+
if (e.button !== 0) return;
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
const handle = e.currentTarget;
|
|
251
|
+
handle.setPointerCapture(e.pointerId);
|
|
252
|
+
const startX = e.clientX;
|
|
253
|
+
const startW = shownWidth;
|
|
254
|
+
let last = startW;
|
|
255
|
+
const move = (ev: globalThis.PointerEvent) => {
|
|
256
|
+
last = clampWidth(startW + startX - ev.clientX);
|
|
257
|
+
setLiveWidth(last);
|
|
258
|
+
};
|
|
259
|
+
const up = () => {
|
|
260
|
+
handle.removeEventListener('pointermove', move);
|
|
261
|
+
handle.removeEventListener('pointerup', up);
|
|
262
|
+
handle.removeEventListener('pointercancel', up);
|
|
263
|
+
commitWidth(last);
|
|
264
|
+
};
|
|
265
|
+
handle.addEventListener('pointermove', move);
|
|
266
|
+
handle.addEventListener('pointerup', up);
|
|
267
|
+
handle.addEventListener('pointercancel', up);
|
|
268
|
+
};
|
|
269
|
+
const resizeKey = (e: KeyboardEvent) => {
|
|
270
|
+
const delta = e.key === 'ArrowLeft' ? RESIZE_STEP : e.key === 'ArrowRight' ? -RESIZE_STEP : 0;
|
|
271
|
+
if (!delta) return;
|
|
272
|
+
e.preventDefault();
|
|
273
|
+
commitWidth(clampWidth(shownWidth + delta));
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const [filter, setFilter] = useState('');
|
|
277
|
+
const list = useRef<HTMLDivElement>(null);
|
|
278
|
+
const q = filter.trim().toLowerCase();
|
|
279
|
+
|
|
280
|
+
const counts = useMemo(() => {
|
|
281
|
+
let ctes = 0;
|
|
282
|
+
for (const s of outline.statements) ctes += s.steps.filter((x) => x.kind === 'cte').length;
|
|
283
|
+
return { statements: outline.statements.length, ctes, tables: outline.tables.length };
|
|
284
|
+
}, [outline]);
|
|
285
|
+
|
|
286
|
+
const active = useMemo(() => {
|
|
287
|
+
if (cursor === undefined) return undefined;
|
|
288
|
+
let best: { key: string; size: number } | undefined;
|
|
289
|
+
outline.statements.forEach((s, si) => {
|
|
290
|
+
s.steps.forEach((step, i) => {
|
|
291
|
+
if (cursor < step.from || cursor > step.to) return;
|
|
292
|
+
const size = step.to - step.from;
|
|
293
|
+
if (!best || size < best.size) best = { key: `${si}:${i}`, size };
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
return best?.key;
|
|
297
|
+
}, [outline, cursor]);
|
|
298
|
+
|
|
299
|
+
const scrollTo = (key: string) => {
|
|
300
|
+
const box = list.current;
|
|
301
|
+
const card = box?.querySelector<HTMLElement>(`[data-step="${key}"]`);
|
|
302
|
+
if (!box || !card) return;
|
|
303
|
+
const top = card.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop;
|
|
304
|
+
if (top < box.scrollTop || top + card.offsetHeight > box.scrollTop + box.clientHeight) {
|
|
305
|
+
box.scrollTop = top - (box.clientHeight - card.offsetHeight) / 2;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
if (active) scrollTo(active);
|
|
310
|
+
}, [active]);
|
|
311
|
+
|
|
312
|
+
const jumpTo = (si: number, name: string) => {
|
|
313
|
+
const statement = outline.statements[si];
|
|
314
|
+
const index = statement?.steps.findIndex((x) => (name === MAIN ? x.kind === 'main' : x.name === name)) ?? -1;
|
|
315
|
+
const step = statement?.steps[index];
|
|
316
|
+
if (!step) return;
|
|
317
|
+
setFilter('');
|
|
318
|
+
window.setTimeout(() => scrollTo(`${si}:${index}`), 0);
|
|
319
|
+
onJump(step.nameFrom ?? step.from, step.nameTo);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const groups = outline.statements
|
|
323
|
+
.map((s, si) => ({ s, si, steps: s.steps.map((step, i) => ({ step, i })).filter(({ step }) => !q || stepText(step).includes(q)) }))
|
|
324
|
+
.filter((g) => g.steps.length > 0);
|
|
325
|
+
|
|
326
|
+
return (
|
|
327
|
+
<aside className={`sqlsteps${bottom ? ' is-bottom' : ''}`} aria-label={t('sqlSteps.title')} ref={aside} style={bottom ? undefined : { width: shownWidth }}>
|
|
328
|
+
{!bottom && <div
|
|
329
|
+
className="sqlsteps-resize"
|
|
330
|
+
role="separator"
|
|
331
|
+
aria-orientation="vertical"
|
|
332
|
+
aria-label={t('sqlSteps.resize')}
|
|
333
|
+
title={t('sqlSteps.resize')}
|
|
334
|
+
tabIndex={0}
|
|
335
|
+
onPointerDown={startResize}
|
|
336
|
+
onKeyDown={resizeKey}
|
|
337
|
+
/>}
|
|
338
|
+
<div className="sqlsteps-head">
|
|
339
|
+
<div className="sqlsteps-tabs" role="tablist">
|
|
340
|
+
{TABS.map((id) => (
|
|
341
|
+
<button key={id} role="tab" className="sqlsteps-tab" aria-selected={tab === id} onClick={() => { if (id === 'problems') setProblemFilter(undefined); setTab(id); }}>
|
|
342
|
+
{t(`sqlSteps.tab.${id}`)}
|
|
343
|
+
{id === 'params' && params.length > 0 && <span className="sqlsteps-count">{params.length}</span>}
|
|
344
|
+
{id === 'problems' && problems.length > 0 && <span className={`sqlsteps-count is-${worst}`}>{problems.length}</span>}
|
|
345
|
+
</button>
|
|
346
|
+
))}
|
|
347
|
+
</div>
|
|
348
|
+
<span className="spacer" />
|
|
349
|
+
{onClose && (
|
|
350
|
+
<button className="btn btn-ghost btn-sm btn-icon" onClick={onClose} title={t('sqlSteps.close')} aria-label={t('sqlSteps.close')}>
|
|
351
|
+
<Icon name="close" />
|
|
352
|
+
</button>
|
|
353
|
+
)}
|
|
354
|
+
</div>
|
|
355
|
+
{tab === 'graph' ? (
|
|
356
|
+
<SqlGraph outline={outline} problems={problems} cursor={cursor} onJump={onJump} />
|
|
357
|
+
) : tab === 'params' ? (
|
|
358
|
+
<ParamList params={params} text={deferred} cursor={cursor} onJump={onJump} values={paramValues} onValue={onParamValue} />
|
|
359
|
+
) : tab === 'problems' ? (
|
|
360
|
+
<ProblemList problems={problems} text={deferred} outline={outline} filter={problemFilter} onClearFilter={() => setProblemFilter(undefined)} onJump={onJump} />
|
|
361
|
+
) : (
|
|
362
|
+
<>
|
|
363
|
+
<span className="muted sqlsteps-counts">{t('sqlSteps.counts', counts)}</span>
|
|
364
|
+
{outline.statements.length > 0 && (
|
|
365
|
+
<input className="sqlsteps-filter" type="search" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder={t('sqlSteps.filter')} aria-label={t('sqlSteps.filter')} />
|
|
366
|
+
)}
|
|
367
|
+
<div className="sqlsteps-list" ref={list}>
|
|
368
|
+
{outline.statements.length === 0 ? (
|
|
369
|
+
<PanelNotice icon={<Icon name="list-flat" size={20} />} title={t('sqlSteps.empty')} message={t('sqlSteps.emptyHint')} />
|
|
370
|
+
) : groups.length === 0 ? (
|
|
371
|
+
<p className="muted sqlsteps-none">{t('sqlSteps.noMatch')}</p>
|
|
372
|
+
) : (
|
|
373
|
+
groups.map(({ s, si, steps }) => (
|
|
374
|
+
<section key={si} className="sqlsteps-group">
|
|
375
|
+
<button className="sqlsteps-group-head" onClick={() => onJump(s.from, s.to)} title={groupTitle(s)}>
|
|
376
|
+
{groupTitle(s)}
|
|
377
|
+
</button>
|
|
378
|
+
{steps.map(({ step, i }) => {
|
|
379
|
+
const key = `${si}:${i}`;
|
|
380
|
+
const clickJump = () => onJump(step.nameFrom ?? step.from, step.nameTo);
|
|
381
|
+
return (
|
|
382
|
+
<div
|
|
383
|
+
key={key}
|
|
384
|
+
data-step={key}
|
|
385
|
+
className={`sqlsteps-card${active === key ? ' is-active' : ''}`}
|
|
386
|
+
role="button"
|
|
387
|
+
tabIndex={0}
|
|
388
|
+
onClick={clickJump}
|
|
389
|
+
onKeyDown={(e) => {
|
|
390
|
+
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
|
|
391
|
+
e.preventDefault();
|
|
392
|
+
clickJump();
|
|
393
|
+
}
|
|
394
|
+
}}
|
|
395
|
+
>
|
|
396
|
+
<div className="sqlsteps-name">
|
|
397
|
+
<span className="sqlsteps-name-text">{stepLabel(step)}</span>
|
|
398
|
+
{(stepProblems.get(`${si}:${step.name}`) ?? 0) > 0 && (
|
|
399
|
+
<button
|
|
400
|
+
className="sqlsteps-count is-step"
|
|
401
|
+
title={t('sqlSteps.stepProblems', { count: stepProblems.get(`${si}:${step.name}`)! })}
|
|
402
|
+
onClick={(e) => {
|
|
403
|
+
e.stopPropagation();
|
|
404
|
+
showStepProblems(si, step.name);
|
|
405
|
+
}}
|
|
406
|
+
>
|
|
407
|
+
{stepProblems.get(`${si}:${step.name}`)}
|
|
408
|
+
</button>
|
|
409
|
+
)}
|
|
410
|
+
</div>
|
|
411
|
+
{step.reads.length > 0 && (
|
|
412
|
+
<Row label={t('sqlSteps.reads')}>
|
|
413
|
+
{step.reads.map((r, k) => (
|
|
414
|
+
<code key={k} className={`sqlsteps-code${r.cte ? ' is-cte' : ''}`} title={r.alias ? `${r.name} ${r.alias}` : r.name}>{r.name}</code>
|
|
415
|
+
))}
|
|
416
|
+
</Row>
|
|
417
|
+
)}
|
|
418
|
+
{step.joins.length > 0 && (
|
|
419
|
+
<Row label={t('sqlSteps.joins')}>
|
|
420
|
+
<div className="sqlsteps-joins">
|
|
421
|
+
{step.joins.map((j, k) => (
|
|
422
|
+
<div key={k} className="sqlsteps-join">
|
|
423
|
+
<Badge>{j.type}</Badge>
|
|
424
|
+
<code className="sqlsteps-code" title={j.target}>{j.target}</code>
|
|
425
|
+
{j.on && <code className="sqlsteps-on" title={j.on}>{t('sqlSteps.on')} {j.on}</code>}
|
|
426
|
+
</div>
|
|
427
|
+
))}
|
|
428
|
+
</div>
|
|
429
|
+
</Row>
|
|
430
|
+
)}
|
|
431
|
+
{step.optional.length > 0 && (
|
|
432
|
+
<Row label={t('sqlSteps.optional')}>
|
|
433
|
+
{step.optional.map((n) => <code key={n} className="sqlsteps-code sqlsteps-optional">{`{${n}}`}</code>)}
|
|
434
|
+
</Row>
|
|
435
|
+
)}
|
|
436
|
+
{step.filters.length > 0 && <Row label={t('sqlSteps.filters')}><Items items={step.filters} /></Row>}
|
|
437
|
+
{(step.groupBy.length > 0 || step.aggregates.length > 0) && (
|
|
438
|
+
<Row label={t('sqlSteps.groupBy')}>
|
|
439
|
+
<Items items={step.groupBy} />
|
|
440
|
+
<Items items={step.aggregates} />
|
|
441
|
+
</Row>
|
|
442
|
+
)}
|
|
443
|
+
{step.having && <Row label={t('sqlSteps.having')}><Items items={[step.having]} /></Row>}
|
|
444
|
+
{(step.windows.length > 0 || step.dedupe) && (
|
|
445
|
+
<Row label={t('sqlSteps.windows')}>
|
|
446
|
+
{step.dedupe && <Badge tone="info">{t('sqlSteps.dedupe')}</Badge>}
|
|
447
|
+
<Items items={step.windows} />
|
|
448
|
+
</Row>
|
|
449
|
+
)}
|
|
450
|
+
{(step.distinct || step.setOps.length > 0 || step.orderBy.length > 0 || step.limit || step.subqueries > 0) && (
|
|
451
|
+
<div className="sqlsteps-badges">
|
|
452
|
+
{step.distinct && <Badge>{t('sqlSteps.distinct')}</Badge>}
|
|
453
|
+
{step.setOps.map((op, k) => <Badge key={k}>{op}</Badge>)}
|
|
454
|
+
{step.orderBy.length > 0 && <Badge title={step.orderBy.join(', ')}>{t('sqlSteps.orderBy')}</Badge>}
|
|
455
|
+
{step.limit && <Badge title={step.limit}>{t('sqlSteps.limit')}</Badge>}
|
|
456
|
+
{step.subqueries > 0 && <Badge>{t('sqlSteps.subqueries', { count: step.subqueries })}</Badge>}
|
|
457
|
+
</div>
|
|
458
|
+
)}
|
|
459
|
+
{step.feeds.length > 0 && (
|
|
460
|
+
<Row label={t('sqlSteps.feeds')}>
|
|
461
|
+
{step.feeds.map((name) => (
|
|
462
|
+
<button
|
|
463
|
+
key={name}
|
|
464
|
+
className="sqlsteps-feed"
|
|
465
|
+
onClick={(e) => {
|
|
466
|
+
e.stopPropagation();
|
|
467
|
+
jumpTo(si, name);
|
|
468
|
+
}}
|
|
469
|
+
>
|
|
470
|
+
{name === MAIN ? t('sqlSteps.main') : name}
|
|
471
|
+
</button>
|
|
472
|
+
))}
|
|
473
|
+
</Row>
|
|
474
|
+
)}
|
|
475
|
+
{step.kind === 'cte' && step.feeds.length === 0 && <div className="muted sqlsteps-unused">{t('sqlSteps.unused')}</div>}
|
|
476
|
+
</div>
|
|
477
|
+
);
|
|
478
|
+
})}
|
|
479
|
+
</section>
|
|
480
|
+
))
|
|
481
|
+
)}
|
|
482
|
+
</div>
|
|
483
|
+
</>
|
|
484
|
+
)}
|
|
485
|
+
</aside>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ErrorBanner, errorOf, Icon, type ApiError, PanelLoading, t } from '@deckbuilderkit/ui';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { DataGrid } from './TableView';
|
|
4
|
+
|
|
5
|
+
/** What `/api/fs/sqlite` returns. */
|
|
6
|
+
export interface SqliteContent {
|
|
7
|
+
tables: { name: string; view: boolean }[];
|
|
8
|
+
table: string | null;
|
|
9
|
+
columns: string[];
|
|
10
|
+
rows: (string | number | null)[][];
|
|
11
|
+
total: number;
|
|
12
|
+
offset: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Rows per page: the server's page size. */
|
|
16
|
+
const PAGE = 200;
|
|
17
|
+
|
|
18
|
+
/** The tables of a SQLite file and 200 rows of the chosen one; `load` fetches another table or page. */
|
|
19
|
+
export function SqliteView({ content, load }: { content: SqliteContent; load(table: string, offset: number): Promise<SqliteContent> }) {
|
|
20
|
+
const [current, setCurrent] = useState(content);
|
|
21
|
+
const [busy, setBusy] = useState(false);
|
|
22
|
+
const [error, setError] = useState<ApiError>();
|
|
23
|
+
|
|
24
|
+
const go = async (table: string, offset: number) => {
|
|
25
|
+
setBusy(true);
|
|
26
|
+
setError(undefined);
|
|
27
|
+
try {
|
|
28
|
+
setCurrent(await load(table, offset));
|
|
29
|
+
} catch (err) {
|
|
30
|
+
setError(errorOf(err));
|
|
31
|
+
} finally {
|
|
32
|
+
setBusy(false);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
if (!current.tables.length) return <div className="archive-empty">{t('preview.sqliteEmpty')}</div>;
|
|
37
|
+
const from = current.total ? current.offset + 1 : 0;
|
|
38
|
+
const to = current.offset + current.rows.length;
|
|
39
|
+
return (
|
|
40
|
+
<div className="sqlite-view">
|
|
41
|
+
<div className="sqlite-tables">
|
|
42
|
+
<div className="sqlite-heading">{t('preview.sqliteTables')}</div>
|
|
43
|
+
{current.tables.map((tb) => (
|
|
44
|
+
<button key={tb.name} className={`sqlite-table${tb.name === current.table ? ' active' : ''}`} onClick={() => void go(tb.name, 0)} disabled={busy}>
|
|
45
|
+
<Icon name={tb.view ? 'search' : 'table'} size={14} /> <span>{tb.name}</span>
|
|
46
|
+
</button>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
<div className="file-preview">
|
|
50
|
+
<div className="file-preview-bar archive-bar">
|
|
51
|
+
<span className="archive-summary">{t('preview.sqliteRows', { from, to, total: current.total })}</span>
|
|
52
|
+
<span>
|
|
53
|
+
<button className="btn btn-ghost btn-sm" disabled={busy || current.offset === 0} onClick={() => void go(current.table!, Math.max(0, current.offset - PAGE))}>
|
|
54
|
+
{t('preview.previous')}
|
|
55
|
+
</button>
|
|
56
|
+
<button className="btn btn-ghost btn-sm" disabled={busy || to >= current.total} onClick={() => void go(current.table!, current.offset + PAGE)}>
|
|
57
|
+
{t('preview.next')}
|
|
58
|
+
</button>
|
|
59
|
+
</span>
|
|
60
|
+
</div>
|
|
61
|
+
{error && <ErrorBanner error={error} compact />}
|
|
62
|
+
<div className="file-preview-body">
|
|
63
|
+
{busy ? <PanelLoading /> : <DataGrid columns={current.columns} rows={current.rows} />}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|