@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.
Files changed (56) hide show
  1. package/package.json +53 -4
  2. package/src/ArchiveView.tsx +111 -0
  3. package/src/CodeEditor.tsx +265 -0
  4. package/src/CodeView.tsx +67 -0
  5. package/src/DiffView.tsx +139 -0
  6. package/src/EnvView.tsx +42 -0
  7. package/src/FilePreview.tsx +108 -0
  8. package/src/FontView.tsx +48 -0
  9. package/src/HarView.tsx +70 -0
  10. package/src/HexView.tsx +28 -0
  11. package/src/HtmlPreview.tsx +23 -0
  12. package/src/MarkdownEditor.tsx +90 -0
  13. package/src/MarkdownToc.tsx +135 -0
  14. package/src/MarkdownView.tsx +84 -0
  15. package/src/MediaView.tsx +17 -0
  16. package/src/NotebookView.tsx +74 -0
  17. package/src/PdfView.tsx +289 -0
  18. package/src/RequestEditor.tsx +419 -0
  19. package/src/ResponseView.tsx +89 -0
  20. package/src/SqlGraph.tsx +322 -0
  21. package/src/SqlSteps.tsx +487 -0
  22. package/src/SqliteView.tsx +68 -0
  23. package/src/SvgView.tsx +231 -0
  24. package/src/TableView.tsx +85 -0
  25. package/src/VarSuggest.tsx +125 -0
  26. package/src/curl-file.ts +70 -0
  27. package/src/editorOptions.ts +83 -0
  28. package/src/editorOptionsContext.ts +4 -0
  29. package/src/fileKinds.ts +69 -0
  30. package/src/http-client.ts +9 -0
  31. package/src/http-import.ts +594 -0
  32. package/src/http.ts +251 -0
  33. package/src/import-warning.ts +8 -0
  34. package/src/index.ts +56 -0
  35. package/src/languages.ts +107 -0
  36. package/src/markdown.ts +123 -0
  37. package/src/markdownLive.ts +289 -0
  38. package/src/options.ts +3 -0
  39. package/src/pdf-url.d.ts +4 -0
  40. package/src/sql/format.ts +637 -0
  41. package/src/sql/formatOptions.ts +71 -0
  42. package/src/sql/lineage.ts +365 -0
  43. package/src/sql/lint.ts +951 -0
  44. package/src/sql/markerHighlight.ts +50 -0
  45. package/src/sql/markers.ts +138 -0
  46. package/src/sql/outline.ts +662 -0
  47. package/src/sql/statements.ts +83 -0
  48. package/src/sql/tokens.ts +156 -0
  49. package/src/sql/xmlQuery.ts +249 -0
  50. package/src/sqlProblems.ts +40 -0
  51. package/src/table.ts +89 -0
  52. package/src/text-save.ts +28 -0
  53. package/src/theme.ts +86 -0
  54. package/src/toc.ts +11 -0
  55. package/src/variables.ts +123 -0
  56. package/README.md +0 -3
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@deckbuilderkit/editor",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/adrianogpena/deckbuilder.git",
8
+ "directory": "packages/editor"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/*.test.ts"
16
+ ],
17
+ "type": "module",
18
+ "exports": {
19
+ ".": "./src/index.ts",
20
+ "./http": "./src/http.ts",
21
+ "./options": "./src/options.ts"
22
+ },
23
+ "dependencies": {
24
+ "@codemirror/autocomplete": "^6.20.3",
25
+ "@codemirror/commands": "^6.11.1",
26
+ "@codemirror/lang-json": "^6.0.2",
27
+ "@codemirror/lang-markdown": "^6.5.2",
28
+ "@codemirror/lang-sql": "^6.10.0",
29
+ "@codemirror/language": "^6.13.1",
30
+ "@codemirror/legacy-modes": "^6.5.5",
31
+ "@codemirror/lint": "^6.9.7",
32
+ "@codemirror/merge": "^6.12.2",
33
+ "@codemirror/search": "^6.7.2",
34
+ "@codemirror/state": "^6.7.6",
35
+ "@codemirror/view": "^6.43.14",
36
+ "@deckbuilderkit/server-core": "0.1.0",
37
+ "@deckbuilderkit/ui": "0.1.0",
38
+ "@lezer/common": "^1.5.0",
39
+ "@lezer/highlight": "^1.2.5",
40
+ "@lezer/markdown": "^1.8.0",
41
+ "dompurify": "^3.4.16",
42
+ "marked": "^18.1.0",
43
+ "pdfjs-dist": "4.10.38"
44
+ },
45
+ "peerDependencies": {
46
+ "react": "^18.3.1",
47
+ "react-dom": "^18.3.1"
48
+ },
49
+ "devDependencies": {
50
+ "@types/react": "^18.3.12",
51
+ "react": "^18.3.1",
52
+ "react-dom": "^18.3.1",
53
+ "@types/react-dom": "^18.3.1"
54
+ }
55
+ }
@@ -0,0 +1,111 @@
1
+ import { fileIconOf, formatBytes, Icon, PanelBanner, t } from '@deckbuilderkit/ui';
2
+ import { useMemo, useState } from 'react';
3
+
4
+ /** What `/api/fs/archive` returns. */
5
+ export interface ArchiveContent {
6
+ entries: { path: string; dir: boolean; size: number; mtime: number; encrypted: boolean }[];
7
+ truncated: boolean;
8
+ }
9
+
10
+ interface Node {
11
+ name: string;
12
+ path: string;
13
+ dir: boolean;
14
+ size: number;
15
+ mtime: number;
16
+ encrypted: boolean;
17
+ children: Node[];
18
+ }
19
+
20
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
21
+
22
+ function treeOf(entries: ArchiveContent['entries']): Node {
23
+ const root: Node = { name: '', path: '', dir: true, size: 0, mtime: 0, encrypted: false, children: [] };
24
+ const byPath = new Map<string, Node>([['', root]]);
25
+ const folder = (path: string): Node => {
26
+ const known = byPath.get(path);
27
+ if (known) return known;
28
+ const i = path.lastIndexOf('/');
29
+ const node: Node = { name: path.slice(i + 1), path, dir: true, size: 0, mtime: 0, encrypted: false, children: [] };
30
+ byPath.set(path, node);
31
+ folder(i < 0 ? '' : path.slice(0, i)).children.push(node);
32
+ return node;
33
+ };
34
+ for (const e of entries) {
35
+ const path = e.path.replace(/^\.?\/+|\/+$/g, '');
36
+ if (!path) continue;
37
+ if (e.dir) {
38
+ folder(path).mtime = e.mtime;
39
+ continue;
40
+ }
41
+ const i = path.lastIndexOf('/');
42
+ folder(i < 0 ? '' : path.slice(0, i)).children.push({ name: path.slice(i + 1), path, dir: false, size: e.size, mtime: e.mtime, encrypted: e.encrypted, children: [] });
43
+ }
44
+ const finish = (n: Node): number => {
45
+ n.children.sort((a, b) => Number(b.dir) - Number(a.dir) || collator.compare(a.name, b.name));
46
+ if (n.dir) n.size = n.children.reduce((sum, c) => sum + finish(c), 0);
47
+ return n.size;
48
+ };
49
+ finish(root);
50
+ return root;
51
+ }
52
+
53
+ /** The files of an archive as a folder tree with sizes and dates, and a filter. */
54
+ export function ArchiveView({ archive }: { archive: ArchiveContent }) {
55
+ const tree = useMemo(() => treeOf(archive.entries), [archive]);
56
+ const files = useMemo(() => archive.entries.filter((e) => !e.dir), [archive]);
57
+ const total = useMemo(() => files.reduce((sum, e) => sum + e.size, 0), [files]);
58
+ const [open, setOpen] = useState<Set<string>>(() => new Set(tree.children.length === 1 && tree.children[0]!.dir ? [tree.children[0]!.path] : []));
59
+ const [filter, setFilter] = useState('');
60
+ const q = filter.trim().toLowerCase();
61
+
62
+ const rows: { node: Node; depth: number }[] = [];
63
+ const walk = (n: Node, depth: number) => {
64
+ for (const c of n.children) {
65
+ if (q) {
66
+ if (c.dir) walk(c, depth);
67
+ else if (c.path.toLowerCase().includes(q)) rows.push({ node: c, depth: 0 });
68
+ } else {
69
+ rows.push({ node: c, depth });
70
+ if (c.dir && open.has(c.path)) walk(c, depth + 1);
71
+ }
72
+ }
73
+ };
74
+ walk(tree, 0);
75
+
76
+ const toggle = (path: string) =>
77
+ setOpen((s) => {
78
+ const next = new Set(s);
79
+ if (!next.delete(path)) next.add(path);
80
+ return next;
81
+ });
82
+
83
+ return (
84
+ <div className="file-preview">
85
+ {archive.truncated && <PanelBanner tone="warn" icon="alert">{t('archive.truncated')}</PanelBanner>}
86
+ <div className="file-preview-bar archive-bar">
87
+ <span className="archive-summary">{t('archive.summary', { count: files.length, size: formatBytes(total) })}</span>
88
+ <input className="input archive-filter" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder={t('archive.filter')} aria-label={t('archive.filter')} />
89
+ </div>
90
+ <div className="file-preview-body archive-body">
91
+ {rows.length === 0 && <div className="archive-empty">{t('archive.empty')}</div>}
92
+ {rows.map(({ node, depth }) => (
93
+ <div
94
+ key={node.path}
95
+ className={`archive-row${node.dir ? ' archive-dir' : ''}`}
96
+ style={{ paddingLeft: 8 + depth * 16 }}
97
+ onClick={node.dir && !q ? () => toggle(node.path) : undefined}
98
+ >
99
+ <span className="archive-name" title={node.path}>
100
+ <Icon name={node.dir ? 'folder' : fileIconOf(node.name).icon} size={14} />
101
+ <span>{q ? node.path : node.name}</span>
102
+ {node.encrypted && <span title={t('archive.encrypted')}>🔒</span>}
103
+ </span>
104
+ <span className="archive-size">{formatBytes(node.size)}</span>
105
+ <span className="archive-date">{node.mtime ? new Date(node.mtime).toLocaleString() : ''}</span>
106
+ </div>
107
+ ))}
108
+ </div>
109
+ </div>
110
+ );
111
+ }
@@ -0,0 +1,265 @@
1
+ import { autocompletion, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
2
+ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
3
+ import { indentOnInput } from '@codemirror/language';
4
+ import { type Diagnostic, lintGutter, linter } from '@codemirror/lint';
5
+ import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
6
+ import { Annotation, Compartment, EditorState, type Extension, Prec } from '@codemirror/state';
7
+ import { EditorView, keymap, placeholder as placeholderExt } from '@codemirror/view';
8
+ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
9
+ import { type EditorOptions, editorOptionsExtension, useEditorOptions } from './editorOptions';
10
+ import { type Lang, languageFor, type SqlDialect, type SqlSchema } from './languages';
11
+ import { formatSql, type SqlFormatOptions } from './sql/format';
12
+ import { type SqlProblem, sqlLint as runSqlLint } from './sql/lint';
13
+ import { sqlMarkerHighlight } from './sql/markerHighlight';
14
+ import { statementAt } from './sql/statements';
15
+ import { sqlProblemMessage } from './sqlProblems';
16
+ import { themeExtension } from './theme';
17
+
18
+ export interface CodeEditorProps {
19
+ value: string;
20
+ lang: Lang;
21
+ onChange?(value: string): void;
22
+ /** Mod-Enter runs the statement under the cursor (or the selection); Mod-Shift-Enter runs everything. */
23
+ onRun?(value: string, mode: RunMode): void;
24
+ readOnly?: boolean;
25
+ placeholder?: string;
26
+ sqlDialect?: SqlDialect;
27
+ /** Table → columns, for SQL completion. */
28
+ sqlSchema?: SqlSchema;
29
+ /** CSS length; a number is px. Without it the editor grows with its content. */
30
+ height?: number | string;
31
+ autoFocus?: boolean;
32
+ /** Scroll position as a fraction (0..1) of the scroll range. */
33
+ onScroll?(fraction: number): void;
34
+ /** Extra extensions, swapped in place when the value changes. */
35
+ extensions?: Extension;
36
+ /** Wrap long lines; default off. */
37
+ wrap?: boolean;
38
+ /** The cursor and selection, on load and whenever they change. */
39
+ onSelection?(info: SelectionInfo): void;
40
+ /** Editor preferences; without it the app-wide ones (EditorOptionsContext), else the built-in defaults. */
41
+ options?: EditorOptions;
42
+ /** Underlines SQL lint problems (see `sqlLint`), with a gutter marker per line. */
43
+ sqlLint?: boolean;
44
+ /** The problems of each lint pass while `sqlLint` is on; [] when it turns off. */
45
+ onSqlProblems?(problems: SqlProblem[]): void;
46
+ }
47
+
48
+ export type RunMode = 'statement' | 'all';
49
+
50
+ export interface CodeEditorHandle {
51
+ view: EditorView | null;
52
+ getSelectionText(): string;
53
+ /** The selection if any, else the SQL statement at the cursor, else the whole document. */
54
+ getRunText(): string;
55
+ replaceRange(from: number, to: number, text: string): void;
56
+ /** Formats the selection if any, else the whole document, as one undoable change. No-op when nothing changes. */
57
+ format(options: SqlFormatOptions): void;
58
+ focus(): void;
59
+ /** Selects from..to (or puts the cursor at from), scrolls it to the center and focuses the editor. */
60
+ reveal(from: number, to?: number): void;
61
+ }
62
+
63
+ /** Where the main cursor is (1-based) and how much is selected, for a status bar. */
64
+ export interface SelectionInfo {
65
+ line: number;
66
+ col: number;
67
+ /** Characters selected over all selections. */
68
+ selected: number;
69
+ selections: number;
70
+ /** Offset of the main cursor. */
71
+ pos: number;
72
+ }
73
+
74
+ export function selectionInfo(state: EditorState): SelectionInfo {
75
+ const { main, ranges } = state.selection;
76
+ const line = state.doc.lineAt(main.head);
77
+ return { line: line.number, col: main.head - line.from + 1, selected: ranges.reduce((n, r) => n + r.to - r.from, 0), selections: ranges.length, pos: main.head };
78
+ }
79
+
80
+ function langExt(lang: Lang, sqlDialect?: SqlDialect, sqlSchema?: SqlSchema): Extension {
81
+ const base = languageFor(lang, { sqlDialect, sqlSchema });
82
+ return lang === 'sql' ? [base, sqlMarkerHighlight] : base;
83
+ }
84
+
85
+ /** Marks the transaction that loads an outside `value`, so it doesn't echo back through onChange. */
86
+ const external = Annotation.define<boolean>();
87
+
88
+ /** A CodeMirror 6 editor; one view for the component's lifetime, options swapped in place. */
89
+ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function CodeEditor({
90
+ value, lang, onChange, onRun, readOnly = false, placeholder, sqlDialect, sqlSchema, height, autoFocus, onScroll, extensions, wrap = false, onSelection, options, sqlLint = false, onSqlProblems,
91
+ }, ref) {
92
+ const contextOptions = useEditorOptions();
93
+ const optionsExt = editorOptionsExtension(options ?? contextOptions);
94
+ const optionsKey = JSON.stringify(options ?? contextOptions ?? null);
95
+ const host = useRef<HTMLDivElement>(null);
96
+ const view = useRef<EditorView | null>(null);
97
+ const compartments = useRef({ lang: new Compartment(), readOnly: new Compartment(), placeholder: new Compartment(), extra: new Compartment(), options: new Compartment(), wrap: new Compartment(), lint: new Compartment() }).current;
98
+ // The callbacks change every render; the view's extensions read them through this ref.
99
+ const handlers = useRef({ onChange, onRun, onScroll, onSelection, onSqlProblems });
100
+ handlers.current = { onChange, onRun, onScroll, onSelection, onSqlProblems };
101
+ const lintExt = (on: boolean): Extension => (on ? sqlLintExtension((p) => handlers.current.onSqlProblems?.(p)) : []);
102
+ const langRef = useRef(lang);
103
+ langRef.current = lang;
104
+
105
+ const selectionText = (s: EditorState) => s.selection.ranges.map((r) => s.sliceDoc(r.from, r.to)).join('\n');
106
+ const runText = (s: EditorState) => {
107
+ const selected = selectionText(s);
108
+ if (selected.trim()) return selected;
109
+ const doc = s.doc.toString();
110
+ if (langRef.current !== 'sql') return doc;
111
+ return statementAt(doc, s.selection.main.head)?.text ?? doc;
112
+ };
113
+
114
+ useImperativeHandle(ref, () => ({
115
+ get view() {
116
+ return view.current;
117
+ },
118
+ getSelectionText: () => (view.current ? selectionText(view.current.state) : ''),
119
+ getRunText: () => (view.current ? runText(view.current.state) : ''),
120
+ replaceRange: (from, to, text) => view.current?.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from + text.length } }),
121
+ format: (options) => {
122
+ const v = view.current;
123
+ if (!v) return;
124
+ const { main } = v.state.selection;
125
+ const from = main.empty ? 0 : main.from;
126
+ const to = main.empty ? v.state.doc.length : main.to;
127
+ const original = v.state.sliceDoc(from, to);
128
+ const formatted = formatSql(original, options);
129
+ if (formatted === original) return;
130
+ v.dispatch({
131
+ changes: { from, to, insert: formatted },
132
+ selection: main.empty ? { anchor: Math.min(main.head, from + formatted.length) } : { anchor: from, head: from + formatted.length },
133
+ });
134
+ },
135
+ focus: () => view.current?.focus(),
136
+ reveal: (from, to) => {
137
+ const v = view.current;
138
+ if (!v) return;
139
+ const len = v.state.doc.length;
140
+ const a = Math.max(0, Math.min(from, len));
141
+ const b = to === undefined ? a : Math.max(0, Math.min(to, len));
142
+ v.dispatch({ selection: { anchor: a, head: b }, effects: EditorView.scrollIntoView(a, { y: 'center' }) });
143
+ v.focus();
144
+ },
145
+ }), []);
146
+
147
+ useEffect(() => {
148
+ const v = new EditorView({
149
+ parent: host.current!,
150
+ state: EditorState.create({
151
+ doc: value,
152
+ extensions: [
153
+ Prec.highest(keymap.of([
154
+ { key: 'Mod-Enter', run: (ed) => {
155
+ const onRun = handlers.current.onRun;
156
+ if (!onRun) return false;
157
+ onRun(runText(ed.state), 'statement');
158
+ return true;
159
+ } },
160
+ { key: 'Mod-Shift-Enter', run: (ed) => {
161
+ const onRun = handlers.current.onRun;
162
+ if (!onRun) return false;
163
+ onRun(ed.state.doc.toString(), 'all');
164
+ return true;
165
+ } },
166
+ ])),
167
+ compartments.options.of(optionsExt),
168
+ history(),
169
+ indentOnInput(),
170
+ autocompletion(),
171
+ highlightSelectionMatches(),
172
+ keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap, ...completionKeymap, indentWithTab]),
173
+ compartments.lang.of(langExt(lang, sqlDialect, sqlSchema)),
174
+ compartments.readOnly.of(readOnlyExt(readOnly)),
175
+ compartments.placeholder.of(placeholder ? placeholderExt(placeholder) : []),
176
+ themeExtension,
177
+ compartments.extra.of(extensions ?? []),
178
+ compartments.wrap.of(wrap ? EditorView.lineWrapping : []),
179
+ compartments.lint.of(lintExt(sqlLint)),
180
+ EditorView.domEventHandlers({
181
+ scroll: (_e, ed) => {
182
+ const { scrollTop, scrollHeight, clientHeight } = ed.scrollDOM;
183
+ const range = scrollHeight - clientHeight;
184
+ handlers.current.onScroll?.(range > 0 ? scrollTop / range : 0);
185
+ },
186
+ }),
187
+ EditorView.updateListener.of((u) => {
188
+ if (u.docChanged || u.selectionSet) handlers.current.onSelection?.(selectionInfo(u.state));
189
+ if (!u.docChanged || u.transactions.some((tr) => tr.annotation(external))) return;
190
+ handlers.current.onChange?.(u.state.doc.toString());
191
+ }),
192
+ ],
193
+ }),
194
+ });
195
+ view.current = v;
196
+ handlers.current.onSelection?.(selectionInfo(v.state));
197
+ if (autoFocus) v.focus();
198
+ return () => {
199
+ v.destroy();
200
+ view.current = null;
201
+ };
202
+ // Created once; the effects below keep it in step with the props.
203
+ }, []);
204
+
205
+ useEffect(() => {
206
+ view.current?.dispatch({ effects: compartments.lang.reconfigure(langExt(lang, sqlDialect, sqlSchema)) });
207
+ }, [lang, sqlDialect, sqlSchema]);
208
+
209
+ useEffect(() => {
210
+ view.current?.dispatch({ effects: compartments.wrap.reconfigure(wrap ? EditorView.lineWrapping : []) });
211
+ }, [wrap]);
212
+
213
+ const lintReady = useRef(false);
214
+ useEffect(() => {
215
+ // The first run is the view's own initial config.
216
+ if (!lintReady.current) {
217
+ lintReady.current = true;
218
+ return;
219
+ }
220
+ view.current?.dispatch({ effects: compartments.lint.reconfigure(lintExt(sqlLint)) });
221
+ if (!sqlLint) handlers.current.onSqlProblems?.([]);
222
+ }, [sqlLint]);
223
+
224
+ useEffect(() => {
225
+ view.current?.dispatch({ effects: compartments.options.reconfigure(optionsExt) });
226
+ }, [optionsKey]);
227
+
228
+ useEffect(() => {
229
+ view.current?.dispatch({ effects: compartments.extra.reconfigure(extensions ?? []) });
230
+ }, [extensions]);
231
+
232
+ useEffect(() => {
233
+ view.current?.dispatch({ effects: compartments.readOnly.reconfigure(readOnlyExt(readOnly)) });
234
+ }, [readOnly]);
235
+
236
+ useEffect(() => {
237
+ view.current?.dispatch({ effects: compartments.placeholder.reconfigure(placeholder ? placeholderExt(placeholder) : []) });
238
+ }, [placeholder]);
239
+
240
+ useEffect(() => {
241
+ const v = view.current;
242
+ if (!v || v.state.doc.toString() === value) return;
243
+ v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value }, annotations: external.of(true) });
244
+ }, [value]);
245
+
246
+ return <div ref={host} className="code-editor" style={height === undefined ? undefined : { height }} />;
247
+ });
248
+
249
+ const readOnlyExt = (readOnly: boolean) => EditorState.readOnly.of(readOnly);
250
+
251
+ /** Lint diagnostics from `sqlLint`, messages from the `sqlLint.<code>` labels, plus a gutter. */
252
+ export function sqlLintExtension(onProblems?: (problems: SqlProblem[]) => void): Extension {
253
+ return [
254
+ linter((view) => {
255
+ const problems = runSqlLint(view.state.doc.toString());
256
+ onProblems?.(problems);
257
+ const len = view.state.doc.length;
258
+ return problems.map((p): Diagnostic => {
259
+ const from = Math.max(0, Math.min(p.from, len));
260
+ return { from, to: Math.max(from, Math.min(p.to, len)), severity: p.severity, message: sqlProblemMessage(p), source: 'SQL' };
261
+ });
262
+ }, { delay: 500 }),
263
+ lintGutter(),
264
+ ];
265
+ }
@@ -0,0 +1,67 @@
1
+ import { openSearchPanel, searchKeymap } from '@codemirror/search';
2
+ import { Compartment, EditorState } from '@codemirror/state';
3
+ import { drawSelection, EditorView, keymap, lineNumbers as lineNumbersExt } from '@codemirror/view';
4
+ import { useEffect, useRef } from 'react';
5
+ import { type Lang, languageFor } from './languages';
6
+ import { themeExtension } from './theme';
7
+
8
+ /** Characters; longer documents render as a plain `<pre>`. */
9
+ export const CODE_VIEW_LIMIT = 2_000_000;
10
+
11
+ const optionsExt = (lang: Lang, wrap: boolean, lineNumbers: boolean) => [
12
+ languageFor(lang),
13
+ wrap ? EditorView.lineWrapping : [],
14
+ lineNumbers ? lineNumbersExt() : [],
15
+ ];
16
+
17
+ /** Read-only, highlighted text (searchable with Mod-f). */
18
+ export function CodeView({ value, lang, wrap = false, lineNumbers = false }: { value: string; lang: Lang; wrap?: boolean; lineNumbers?: boolean }) {
19
+ if (value.length > CODE_VIEW_LIMIT) return <pre className={wrap ? 'code-view-plain wrap' : 'code-view-plain'}>{value}</pre>;
20
+ return <HighlightedView value={value} lang={lang} wrap={wrap} lineNumbers={lineNumbers} />;
21
+ }
22
+
23
+ function HighlightedView({ value, lang, wrap, lineNumbers }: { value: string; lang: Lang; wrap: boolean; lineNumbers: boolean }) {
24
+ const host = useRef<HTMLDivElement>(null);
25
+ const view = useRef<EditorView | null>(null);
26
+ const options = useRef(new Compartment()).current;
27
+
28
+ useEffect(() => {
29
+ const v = new EditorView({
30
+ parent: host.current!,
31
+ state: EditorState.create({
32
+ doc: value,
33
+ extensions: [
34
+ EditorState.readOnly.of(true),
35
+ drawSelection(),
36
+ keymap.of(searchKeymap),
37
+ options.of(optionsExt(lang, wrap, lineNumbers)),
38
+ themeExtension,
39
+ ],
40
+ }),
41
+ });
42
+ view.current = v;
43
+ return () => {
44
+ v.destroy();
45
+ view.current = null;
46
+ };
47
+ }, []);
48
+
49
+ useEffect(() => {
50
+ view.current?.dispatch({ effects: options.reconfigure(optionsExt(lang, wrap, lineNumbers)) });
51
+ }, [lang, wrap, lineNumbers]);
52
+
53
+ useEffect(() => {
54
+ const v = view.current;
55
+ if (v && v.state.doc.toString() !== value) v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
56
+ }, [value]);
57
+
58
+ const onKeyDown = (e: React.KeyboardEvent) => {
59
+ const v = view.current;
60
+ if (!v || !(e.ctrlKey || e.metaKey) || e.altKey || e.key.toLowerCase() !== 'f') return;
61
+ e.preventDefault();
62
+ v.focus();
63
+ openSearchPanel(v);
64
+ };
65
+
66
+ return <div ref={host} className="code-view" tabIndex={-1} onKeyDown={onKeyDown} onMouseDown={(e) => { if (e.target === host.current) view.current?.focus(); }} />;
67
+ }
@@ -0,0 +1,139 @@
1
+ import { getOriginalDoc, goToNextChunk, goToPreviousChunk, MergeView, originalDocChangeEffect, unifiedMergeView } from '@codemirror/merge';
2
+ import { searchKeymap } from '@codemirror/search';
3
+ import { ChangeSet, Compartment, EditorState, type Extension } from '@codemirror/state';
4
+ import { drawSelection, EditorView, keymap, lineNumbers } from '@codemirror/view';
5
+ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
6
+ import { type Lang, languageFor } from './languages';
7
+ import { themeExtension } from './theme';
8
+
9
+ export type DiffMode = 'split' | 'inline';
10
+
11
+ /** Imperative API: move the selection to the next/previous change (F7 / Shift+F7). */
12
+ export interface DiffViewHandle {
13
+ next(): boolean;
14
+ prev(): boolean;
15
+ }
16
+
17
+ export interface DiffViewProps {
18
+ original: string;
19
+ modified: string;
20
+ lang?: Lang;
21
+ mode: DiffMode;
22
+ /** When given, a Split/Inline toggle is shown above the diff. */
23
+ onModeChange?: (mode: DiffMode) => void;
24
+ modeLabels?: { split: string; inline: string };
25
+ }
26
+
27
+ const COLLAPSE = { margin: 3, minSize: 4 };
28
+ const chunkKeys = keymap.of([{ key: 'F7', run: goToNextChunk, shift: goToPreviousChunk }, ...searchKeymap]);
29
+
30
+ const baseExt = (lang: Compartment, l: Lang): Extension => [
31
+ EditorState.readOnly.of(true),
32
+ EditorView.editable.of(false),
33
+ drawSelection(),
34
+ lineNumbers(),
35
+ chunkKeys,
36
+ lang.of(languageFor(l)),
37
+ themeExtension,
38
+ ];
39
+
40
+ const replaceAll = (v: EditorView, text: string) => {
41
+ if (v.state.doc.toString() !== text) v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: text } });
42
+ };
43
+
44
+ /** Read-only diff of two strings: side by side (`split`) or unified (`inline`). */
45
+ export const DiffView = forwardRef<DiffViewHandle, DiffViewProps>(function DiffView(
46
+ { original, modified, lang = 'text', mode, onModeChange, modeLabels = { split: 'Split', inline: 'Inline' } },
47
+ ref,
48
+ ) {
49
+ const host = useRef<HTMLDivElement>(null);
50
+ const merge = useRef<MergeView | null>(null);
51
+ const unified = useRef<EditorView | null>(null);
52
+ const langA = useRef(new Compartment()).current;
53
+ const langB = useRef(new Compartment()).current;
54
+ const latest = useRef({ original, modified, lang });
55
+ latest.current = { original, modified, lang };
56
+
57
+ useEffect(() => {
58
+ const { original, modified, lang } = latest.current;
59
+ if (mode === 'split') {
60
+ merge.current = new MergeView({
61
+ parent: host.current!,
62
+ a: { doc: original, extensions: baseExt(langA, lang) },
63
+ b: { doc: modified, extensions: baseExt(langB, lang) },
64
+ collapseUnchanged: COLLAPSE,
65
+ highlightChanges: true,
66
+ gutter: true,
67
+ });
68
+ } else {
69
+ unified.current = new EditorView({
70
+ parent: host.current!,
71
+ state: EditorState.create({
72
+ doc: modified,
73
+ extensions: [baseExt(langB, lang), unifiedMergeView({ original, mergeControls: false, collapseUnchanged: COLLAPSE })],
74
+ }),
75
+ });
76
+ }
77
+ return () => {
78
+ merge.current?.destroy();
79
+ unified.current?.destroy();
80
+ merge.current = null;
81
+ unified.current = null;
82
+ };
83
+ }, [mode]);
84
+
85
+ useEffect(() => {
86
+ const m = merge.current;
87
+ if (m) {
88
+ replaceAll(m.a, original);
89
+ replaceAll(m.b, modified);
90
+ return;
91
+ }
92
+ const u = unified.current;
93
+ if (!u) return;
94
+ const old = getOriginalDoc(u.state);
95
+ if (old.toString() !== original) {
96
+ const changes = ChangeSet.of({ from: 0, to: old.length, insert: original }, old.length);
97
+ u.dispatch({ effects: originalDocChangeEffect(u.state, changes) });
98
+ }
99
+ replaceAll(u, modified);
100
+ }, [original, modified]);
101
+
102
+ useEffect(() => {
103
+ const m = merge.current;
104
+ if (m) {
105
+ m.a.dispatch({ effects: langA.reconfigure(languageFor(lang)) });
106
+ m.b.dispatch({ effects: langB.reconfigure(languageFor(lang)) });
107
+ } else unified.current?.dispatch({ effects: langB.reconfigure(languageFor(lang)) });
108
+ }, [lang]);
109
+
110
+ useImperativeHandle(ref, () => {
111
+ const target = () => {
112
+ const m = merge.current;
113
+ if (m) return m.a.hasFocus ? m.a : m.b;
114
+ return unified.current;
115
+ };
116
+ const run = (cmd: typeof goToNextChunk) => {
117
+ const v = target();
118
+ if (!v) return false;
119
+ v.focus();
120
+ return cmd(v);
121
+ };
122
+ return { next: () => run(goToNextChunk), prev: () => run(goToPreviousChunk) };
123
+ }, []);
124
+
125
+ return (
126
+ <div className="diff-view">
127
+ {onModeChange && (
128
+ <div className="diff-view-bar" role="group">
129
+ {(['split', 'inline'] as const).map((m) => (
130
+ <button key={m} type="button" className={m === mode ? 'active' : undefined} aria-pressed={m === mode} onClick={() => onModeChange(m)}>
131
+ {modeLabels[m]}
132
+ </button>
133
+ ))}
134
+ </div>
135
+ )}
136
+ <div ref={host} className={`diff-view-body diff-${mode}`} />
137
+ </div>
138
+ );
139
+ });
@@ -0,0 +1,42 @@
1
+ import { Icon, t } from '@deckbuilderkit/ui';
2
+ import { useMemo, useState } from 'react';
3
+ import { parseEnv } from './table';
4
+
5
+ /** A `.env` file as a list of variables with their values hidden until revealed. */
6
+ export function EnvView({ text }: { text: string }) {
7
+ const lines = useMemo(() => parseEnv(text), [text]);
8
+ const [shown, setShown] = useState<Set<string>>(new Set());
9
+ const keys = lines.flatMap((l) => (l.key ? [l.key] : []));
10
+ const allShown = keys.length > 0 && keys.every((k) => shown.has(k));
11
+
12
+ const toggle = (key: string) =>
13
+ setShown((s) => {
14
+ const next = new Set(s);
15
+ if (!next.delete(key)) next.add(key);
16
+ return next;
17
+ });
18
+
19
+ return (
20
+ <div className="file-preview">
21
+ <div className="file-preview-bar">
22
+ <button className="btn btn-ghost btn-sm" onClick={() => setShown(allShown ? new Set() : new Set(keys))}>
23
+ {allShown ? t('preview.hideAll') : t('preview.revealAll')}
24
+ </button>
25
+ </div>
26
+ <div className="file-preview-body">
27
+ {keys.length === 0 && <div className="archive-empty">{t('preview.empty')}</div>}
28
+ {lines.map((l, i) => l.key === null
29
+ ? (l.raw.trim() ? <div key={i} className="env-row env-comment">{l.raw}</div> : null)
30
+ : (
31
+ <div key={i} className="env-row">
32
+ <span className="env-key">{l.key}</span>
33
+ <span className="env-value">{shown.has(l.key) ? l.value : l.value ? '••••••••' : ''}</span>
34
+ <button className="btn btn-ghost btn-sm btn-icon" onClick={() => toggle(l.key!)} aria-label={shown.has(l.key) ? t('preview.hide') : t('preview.reveal')} title={shown.has(l.key) ? t('preview.hide') : t('preview.reveal')}>
35
+ <Icon name={shown.has(l.key) ? 'lock' : 'key'} size={14} />
36
+ </button>
37
+ </div>
38
+ ))}
39
+ </div>
40
+ </div>
41
+ );
42
+ }