@ruckc/truss-code 1.13.0 → 1.15.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/dist/CodeEditor.svelte +62 -19
- package/dist/CodeEditor.svelte.d.ts +15 -5
- package/dist/DiffFiles.svelte +44 -8
- package/dist/DiffFiles.svelte.d.ts +17 -2
- package/dist/DiffView.svelte +88 -39
- package/dist/DiffView.svelte.d.ts +13 -2
- package/dist/editor-util.d.ts +13 -1
- package/dist/editor-util.js +2 -0
- package/dist/files.js +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -1
- package/dist/load.d.ts +7 -0
- package/dist/load.js +17 -0
- package/dist/patch.d.ts +10 -1
- package/dist/patch.js +35 -8
- package/package.json +1 -1
package/dist/CodeEditor.svelte
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte';
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
4
|
import { Skeleton, matches } from '@ruckc/truss-ui';
|
|
4
5
|
import type * as Monaco from 'monaco-editor';
|
|
5
6
|
import { loadMonaco, applyTheme, ensureLanguage, setJsonSchema, setupTypeScript, type MonacoApi, type TypeScriptSetup } from './monaco.js';
|
|
6
7
|
import { resolveLanguage, modelLanguage, type SqlDialect } from './lang.js';
|
|
7
8
|
import { watchTheme, fontMetrics, type Metrics } from './theme.js';
|
|
8
|
-
import { toMonacoMarker, toMonacoCompletion } from './editor-util.js';
|
|
9
|
-
import type { CodeMarker
|
|
9
|
+
import { toMonacoMarker, toMonacoCompletion, completionProvider, type CompletionsOption } from './editor-util.js';
|
|
10
|
+
import type { CodeMarker } from './types.js';
|
|
10
11
|
|
|
11
12
|
let {
|
|
12
13
|
value = $bindable(''),
|
|
14
|
+
files = $bindable(),
|
|
13
15
|
language,
|
|
14
16
|
dialect,
|
|
15
17
|
schema,
|
|
@@ -24,14 +26,22 @@
|
|
|
24
26
|
label = 'Code editor',
|
|
25
27
|
markers = [],
|
|
26
28
|
completions,
|
|
29
|
+
autoSurround = 'languageDefined',
|
|
30
|
+
autoClosingBrackets = 'languageDefined',
|
|
31
|
+
autoClosingQuotes = 'languageDefined',
|
|
27
32
|
options,
|
|
28
33
|
onchange,
|
|
29
34
|
onsave,
|
|
30
35
|
onrun,
|
|
31
|
-
onready
|
|
32
|
-
|
|
36
|
+
onready,
|
|
37
|
+
class: klass = '',
|
|
38
|
+
style,
|
|
39
|
+
...rest
|
|
40
|
+
}: Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onchange' | 'onsave' | 'onrun' | 'onready'> & {
|
|
33
41
|
/** The document text. One model per `path` keeps each tab's undo history; the text you bind belongs to the current `path`. */
|
|
34
42
|
value?: string;
|
|
43
|
+
/** Controlled documents keyed by path: `path` picks the active one and edits write back to `files[path]`, so switching `path` can never carry one file's text into another's model. Wins over `value` as the source of the text (`value` still mirrors the active text). Recommended for tabs. */
|
|
44
|
+
files?: Record<string, string>;
|
|
35
45
|
/** A Monaco language id (`sql`, `typescript`, `yaml`, ...). Defaults to the language of `path`'s extension, else plain text. */
|
|
36
46
|
language?: string;
|
|
37
47
|
/** The SQL flavour for `language="sql"`: Monaco's tokenizer for `postgres` (`pgsql`) and `mysql`; `sqlite` is the generic SQL tokenizer plus the SQLite keywords; `standard` and `mssql` use Monaco's `sql`, which is Transact-SQL flavoured. Ignored for other languages. */
|
|
@@ -54,8 +64,14 @@
|
|
|
54
64
|
label?: string;
|
|
55
65
|
/** Diagnostics for the current model. */
|
|
56
66
|
markers?: CodeMarker[];
|
|
57
|
-
/** Called as the user types or invokes suggestions; return the items to offer (may be async). */
|
|
58
|
-
completions?:
|
|
67
|
+
/** Called as the user types or invokes suggestions; return the items to offer (may be async). Pass `{ provide, triggerCharacters }` to also open suggestions when one of those characters is typed (for example `['.']`). */
|
|
68
|
+
completions?: CompletionsOption;
|
|
69
|
+
/** What typing an opening bracket or quote does to the selection: wrap it (`languageDefined`, `quotes`, `brackets`) or replace it (`never`). Monaco's `autoSurround`. */
|
|
70
|
+
autoSurround?: 'languageDefined' | 'quotes' | 'brackets' | 'never';
|
|
71
|
+
/** Whether typing `(`, `[` or `{` inserts the closing one. Monaco's `autoClosingBrackets`. */
|
|
72
|
+
autoClosingBrackets?: 'always' | 'languageDefined' | 'beforeWhitespace' | 'never';
|
|
73
|
+
/** Whether typing a quote inserts the closing one. Monaco's `autoClosingQuotes`. */
|
|
74
|
+
autoClosingQuotes?: 'always' | 'languageDefined' | 'beforeWhitespace' | 'never';
|
|
59
75
|
/** Any Monaco `IStandaloneEditorConstructionOptions`, applied after the props above (for example `{ lightbulb: { enabled: 'on' } }` to bring back the quick-fix bulb, which is off by default because Monaco gives it no accessible name). */
|
|
60
76
|
options?: Monaco.editor.IStandaloneEditorConstructionOptions;
|
|
61
77
|
/** Fires with the new text after every edit made by the user. */
|
|
@@ -94,11 +110,18 @@
|
|
|
94
110
|
export const getEditor = () => editor;
|
|
95
111
|
export const focus = () => editor?.focus();
|
|
96
112
|
|
|
97
|
-
|
|
113
|
+
/** The value last known to match the current model. A `path` change that leaves `value` equal to it has not been given its new value yet. */
|
|
114
|
+
let synced: string | undefined;
|
|
115
|
+
/** The path changed but `value` has not followed yet; the model is left alone until it does. */
|
|
116
|
+
let awaiting = false;
|
|
117
|
+
/** Models created while awaiting hold a placeholder that the arriving value replaces outright. */
|
|
118
|
+
const pristine = new Set<string>();
|
|
119
|
+
|
|
120
|
+
function modelFor(m: MonacoApi, key: string, text: string): Monaco.editor.ITextModel {
|
|
98
121
|
let mdl = models.get(key);
|
|
99
122
|
if (!mdl) {
|
|
100
123
|
const uri = m.Uri.from({ scheme: 'inmemory', authority: `truss-${id}`, path: '/' + key.replace(/^\/+/, '') });
|
|
101
|
-
mdl = m.editor.createModel(
|
|
124
|
+
mdl = m.editor.createModel(text, mlang, uri);
|
|
102
125
|
models.set(key, mdl);
|
|
103
126
|
}
|
|
104
127
|
return mdl;
|
|
@@ -147,13 +170,18 @@
|
|
|
147
170
|
return () => { stale = true; };
|
|
148
171
|
});
|
|
149
172
|
|
|
150
|
-
// the model follows `path`; its text follows `value`
|
|
173
|
+
// the model follows `path`; its text follows `files[path]`, or `value` once it has caught up with `path`
|
|
151
174
|
$effect(() => {
|
|
152
175
|
const m = monaco, ed = editor, id = mlang;
|
|
153
176
|
if (!m || !ed || !id) return;
|
|
154
177
|
const key = path ?? '';
|
|
155
|
-
const
|
|
156
|
-
|
|
178
|
+
const text = files ? (files[key] ?? '') : value;
|
|
179
|
+
const switching = current !== key;
|
|
180
|
+
if (!files && switching && current !== undefined && text === synced) awaiting = true;
|
|
181
|
+
const fresh = !models.has(key);
|
|
182
|
+
const mdl = modelFor(m, key, text);
|
|
183
|
+
if (fresh && awaiting) pristine.add(key);
|
|
184
|
+
if (switching) {
|
|
157
185
|
if (current !== undefined) views.set(current, ed.saveViewState());
|
|
158
186
|
ed.setModel(mdl);
|
|
159
187
|
if (views.get(key)) ed.restoreViewState(views.get(key)!);
|
|
@@ -162,17 +190,27 @@
|
|
|
162
190
|
contentSub?.dispose();
|
|
163
191
|
contentSub = mdl.onDidChangeContent(() => {
|
|
164
192
|
if (external) return;
|
|
165
|
-
|
|
166
|
-
|
|
193
|
+
const v = mdl.getValue();
|
|
194
|
+
if (files) files = { ...files, [key]: v };
|
|
195
|
+
value = synced = v;
|
|
196
|
+
awaiting = false;
|
|
197
|
+
onchange?.(v);
|
|
167
198
|
});
|
|
168
199
|
}
|
|
169
200
|
if (mdl.getLanguageId() !== id) m.editor.setModelLanguage(mdl, id);
|
|
170
|
-
if (
|
|
201
|
+
if (awaiting && text === synced) return; // `path` moved but `value` is still the previous file's text: leave this model alone
|
|
202
|
+
if (mdl.getValue() !== text) {
|
|
171
203
|
external = true;
|
|
172
|
-
|
|
173
|
-
|
|
204
|
+
if (pristine.delete(key)) mdl.setValue(text);
|
|
205
|
+
else {
|
|
206
|
+
ed.executeEdits('truss-bind', [{ range: mdl.getFullModelRange(), text }]);
|
|
207
|
+
ed.pushUndoStop();
|
|
208
|
+
}
|
|
174
209
|
external = false;
|
|
175
210
|
}
|
|
211
|
+
pristine.delete(key);
|
|
212
|
+
awaiting = false;
|
|
213
|
+
synced = text;
|
|
176
214
|
});
|
|
177
215
|
|
|
178
216
|
// options: props first, then the passthrough, then the theme's type metrics
|
|
@@ -185,6 +223,9 @@
|
|
|
185
223
|
wordWrap: wordWrap ? 'on' : 'off',
|
|
186
224
|
accessibilitySupport: screenReader,
|
|
187
225
|
ariaLabel: label,
|
|
226
|
+
autoSurround,
|
|
227
|
+
autoClosingBrackets,
|
|
228
|
+
autoClosingQuotes,
|
|
188
229
|
// Monaco's quick-fix bulb is a listbox without an accessible name; keep it off unless you opt in through `options`
|
|
189
230
|
lightbulb: { enabled: 'off' as Monaco.editor.ShowLightbulbIconMode },
|
|
190
231
|
fontSize: metrics.fontSize,
|
|
@@ -213,14 +254,16 @@
|
|
|
213
254
|
return () => m.editor.setModelMarkers(mdl, 'truss', []);
|
|
214
255
|
});
|
|
215
256
|
|
|
257
|
+
const triggers = $derived(completionProvider(completions)?.triggerCharacters?.join(''));
|
|
216
258
|
$effect(() => {
|
|
217
|
-
const m = monaco, id = mlang, on = !!completions;
|
|
259
|
+
const m = monaco, id = mlang, on = !!completions, trig = triggers;
|
|
218
260
|
if (!m || !id || !on) return;
|
|
219
261
|
const d = m.languages.registerCompletionItemProvider(id, {
|
|
262
|
+
...(trig ? { triggerCharacters: [...trig] } : {}),
|
|
220
263
|
provideCompletionItems: async (mdl, pos) => {
|
|
221
264
|
if (mdl !== model) return { suggestions: [] };
|
|
222
265
|
const w = mdl.getWordUntilPosition(pos);
|
|
223
|
-
const items = (await completions?.({ value: mdl.getValue(), language: lang, path, line: pos.lineNumber, col: pos.column, word: w.word, prefix: mdl.getLineContent(pos.lineNumber).slice(0, pos.column - 1) })) ?? [];
|
|
266
|
+
const items = (await completionProvider(completions)?.provide({ value: mdl.getValue(), language: lang, path, line: pos.lineNumber, col: pos.column, word: w.word, prefix: mdl.getLineContent(pos.lineNumber).slice(0, pos.column - 1) })) ?? [];
|
|
224
267
|
const range = { startLineNumber: pos.lineNumber, endLineNumber: pos.lineNumber, startColumn: w.startColumn, endColumn: w.endColumn };
|
|
225
268
|
return { suggestions: items.map((i) => toMonacoCompletion(i, m.languages.CompletionItemKind, range, m.languages.CompletionItemInsertTextRule.InsertAsSnippet)) };
|
|
226
269
|
}
|
|
@@ -229,7 +272,7 @@
|
|
|
229
272
|
});
|
|
230
273
|
</script>
|
|
231
274
|
|
|
232
|
-
<div class="ce" style:height={css} data-ready={ready} role="group" aria-label={label} aria-describedby="ce-hint-{id}">
|
|
275
|
+
<div {...rest} class="ce {klass}" style:height={css} {style} data-ready={ready} role="group" aria-label={label} aria-describedby="ce-hint-{id}">
|
|
233
276
|
<div class="mount" bind:this={host}></div>
|
|
234
277
|
{#if !ready}
|
|
235
278
|
<div class="ph">
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
1
2
|
import type * as Monaco from 'monaco-editor';
|
|
2
3
|
import { type MonacoApi, type TypeScriptSetup } from './monaco.js';
|
|
3
4
|
import { type SqlDialect } from './lang.js';
|
|
4
|
-
import
|
|
5
|
-
type
|
|
5
|
+
import { type CompletionsOption } from './editor-util.js';
|
|
6
|
+
import type { CodeMarker } from './types.js';
|
|
7
|
+
type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onchange' | 'onsave' | 'onrun' | 'onready'> & {
|
|
6
8
|
/** The document text. One model per `path` keeps each tab's undo history; the text you bind belongs to the current `path`. */
|
|
7
9
|
value?: string;
|
|
10
|
+
/** Controlled documents keyed by path: `path` picks the active one and edits write back to `files[path]`, so switching `path` can never carry one file's text into another's model. Wins over `value` as the source of the text (`value` still mirrors the active text). Recommended for tabs. */
|
|
11
|
+
files?: Record<string, string>;
|
|
8
12
|
/** A Monaco language id (`sql`, `typescript`, `yaml`, ...). Defaults to the language of `path`'s extension, else plain text. */
|
|
9
13
|
language?: string;
|
|
10
14
|
/** The SQL flavour for `language="sql"`: Monaco's tokenizer for `postgres` (`pgsql`) and `mysql`; `sqlite` is the generic SQL tokenizer plus the SQLite keywords; `standard` and `mssql` use Monaco's `sql`, which is Transact-SQL flavoured. Ignored for other languages. */
|
|
@@ -27,8 +31,14 @@ type $$ComponentProps = {
|
|
|
27
31
|
label?: string;
|
|
28
32
|
/** Diagnostics for the current model. */
|
|
29
33
|
markers?: CodeMarker[];
|
|
30
|
-
/** Called as the user types or invokes suggestions; return the items to offer (may be async). */
|
|
31
|
-
completions?:
|
|
34
|
+
/** Called as the user types or invokes suggestions; return the items to offer (may be async). Pass `{ provide, triggerCharacters }` to also open suggestions when one of those characters is typed (for example `['.']`). */
|
|
35
|
+
completions?: CompletionsOption;
|
|
36
|
+
/** What typing an opening bracket or quote does to the selection: wrap it (`languageDefined`, `quotes`, `brackets`) or replace it (`never`). Monaco's `autoSurround`. */
|
|
37
|
+
autoSurround?: 'languageDefined' | 'quotes' | 'brackets' | 'never';
|
|
38
|
+
/** Whether typing `(`, `[` or `{` inserts the closing one. Monaco's `autoClosingBrackets`. */
|
|
39
|
+
autoClosingBrackets?: 'always' | 'languageDefined' | 'beforeWhitespace' | 'never';
|
|
40
|
+
/** Whether typing a quote inserts the closing one. Monaco's `autoClosingQuotes`. */
|
|
41
|
+
autoClosingQuotes?: 'always' | 'languageDefined' | 'beforeWhitespace' | 'never';
|
|
32
42
|
/** Any Monaco `IStandaloneEditorConstructionOptions`, applied after the props above (for example `{ lightbulb: { enabled: 'on' } }` to bring back the quick-fix bulb, which is off by default because Monaco gives it no accessible name). */
|
|
33
43
|
options?: Monaco.editor.IStandaloneEditorConstructionOptions;
|
|
34
44
|
/** Fires with the new text after every edit made by the user. */
|
|
@@ -43,6 +53,6 @@ type $$ComponentProps = {
|
|
|
43
53
|
declare const CodeEditor: import("svelte").Component<$$ComponentProps, {
|
|
44
54
|
/** The focusable Monaco editor instance, once loaded. */ getEditor: () => Monaco.editor.IStandaloneCodeEditor | undefined;
|
|
45
55
|
focus: () => void | undefined;
|
|
46
|
-
}, "value">;
|
|
56
|
+
}, "value" | "files">;
|
|
47
57
|
type CodeEditor = ReturnType<typeof CodeEditor>;
|
|
48
58
|
export default CodeEditor;
|
package/dist/DiffFiles.svelte
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { tick, onMount } from 'svelte';
|
|
3
|
-
import {
|
|
2
|
+
import { tick, onMount, type Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
+
import { Badge, Button, Checkbox, Icon, SegmentedControl, Tooltip, Tree } from '@ruckc/truss-ui';
|
|
4
5
|
import type { TreeNode } from '@ruckc/truss-ui';
|
|
5
6
|
import DiffView from './DiffView.svelte';
|
|
6
|
-
import {
|
|
7
|
+
import { cachedLoader, type LoadText } from './load.js';
|
|
8
|
+
import { buildTree, entriesFromPatch, entryFromInput, folderIds, statusLetter, totals, type DiffFileInput, type DiffStatus, type FileEntry } from './files.js';
|
|
7
9
|
|
|
8
10
|
let {
|
|
9
11
|
files = [],
|
|
@@ -24,7 +26,13 @@
|
|
|
24
26
|
virtualize,
|
|
25
27
|
detectMoves = false,
|
|
26
28
|
label = 'Changed files',
|
|
27
|
-
onviewed
|
|
29
|
+
onviewed,
|
|
30
|
+
fileExtra,
|
|
31
|
+
fileExtraPosition = 'above',
|
|
32
|
+
fileAttrs,
|
|
33
|
+
load,
|
|
34
|
+
class: cls,
|
|
35
|
+
...rest
|
|
28
36
|
}: {
|
|
29
37
|
/** The files, each with `old` and `new` text or a `patch`. */
|
|
30
38
|
files?: DiffFileInput[];
|
|
@@ -54,7 +62,16 @@
|
|
|
54
62
|
detectMoves?: boolean;
|
|
55
63
|
label?: string;
|
|
56
64
|
onviewed?: (path: string, viewed: boolean) => void;
|
|
57
|
-
|
|
65
|
+
/** Rendered inside each open file's body, next to its diff (for example review threads or a summary). Receives the file (`path`, `status`, counts...) and its position in the list. */
|
|
66
|
+
fileExtra?: Snippet<[info: { file: FileEntry; index: number }]>;
|
|
67
|
+
/** Where `fileExtra` goes relative to the file's diff. */
|
|
68
|
+
fileExtraPosition?: 'above' | 'below';
|
|
69
|
+
/** Extra attributes for each file's `<section>`, merged over the default `data-testid="diff-file-<path>"`. */
|
|
70
|
+
fileAttrs?: (file: FileEntry) => HTMLAttributes<HTMLElement>;
|
|
71
|
+
/** For patch input without the full text: fetches a whole file the first time its "Expand N lines" is pressed (see `DiffView`'s `load`). Results are cached per path and side, across collapsing and reopening a file. */
|
|
72
|
+
load?: LoadText;
|
|
73
|
+
class?: string;
|
|
74
|
+
} & Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'role' | 'onkeydown'> = $props();
|
|
58
75
|
|
|
59
76
|
let root: HTMLDivElement;
|
|
60
77
|
const views = $state<Record<string, { goto(where: 'first' | 'last'): boolean; focus(): void } | null | undefined>>({});
|
|
@@ -62,6 +79,8 @@
|
|
|
62
79
|
|
|
63
80
|
const entries = $derived<FileEntry[]>(files.length ? files.map((f) => entryFromInput(f, { ignoreWhitespace, ignoreCase })) : patch ? entriesFromPatch(patch) : []);
|
|
64
81
|
const sum = $derived(totals(entries));
|
|
82
|
+
const byPath = $derived(new Map(entries.map((e) => [e.path, e])));
|
|
83
|
+
const loader = $derived(load && cachedLoader(load));
|
|
65
84
|
const nodes = $derived<TreeNode[]>(buildTree(entries));
|
|
66
85
|
// the tree opens fully whenever the set of files changes
|
|
67
86
|
$effect(() => { expanded = folderIds(nodes); });
|
|
@@ -125,7 +144,7 @@
|
|
|
125
144
|
</script>
|
|
126
145
|
|
|
127
146
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
128
|
-
<div bind:this={root} class=
|
|
147
|
+
<div bind:this={root} class={['df', cls]} {...rest} role="group" aria-label={label} onkeydown={onkey}>
|
|
129
148
|
<div class="top">
|
|
130
149
|
<span class="sum" data-testid="files-summary">{sum.files} {sum.files === 1 ? 'file' : 'files'} changed<span class="plus">+{sum.additions}</span><span class="minus">−{sum.deletions}</span></span>
|
|
131
150
|
<span class="prog" data-testid="viewed-count">{viewed.filter((p) => entries.some((e) => e.path === p)).length} of {sum.files} viewed</span>
|
|
@@ -135,13 +154,13 @@
|
|
|
135
154
|
<div class="main">
|
|
136
155
|
{#if tree && entries.length}
|
|
137
156
|
<nav class="side" aria-label="Changed files">
|
|
138
|
-
<Tree {nodes} bind:expanded {selected} {onselect} label="Changed files" />
|
|
157
|
+
<Tree {nodes} bind:expanded {selected} {onselect} label="Changed files" nodeActions={status} />
|
|
139
158
|
</nav>
|
|
140
159
|
{/if}
|
|
141
160
|
<div class="list">
|
|
142
161
|
{#each entries as f, i (f.path)}
|
|
143
162
|
{@const open = !collapsedSet.has(f.path)}
|
|
144
|
-
<section id="{uid}-{slug(f.path)}" class="file" data-path={f.path} data-status={f.status} data-viewed={viewedSet.has(f.path)} aria-label={f.path}>
|
|
163
|
+
<section data-testid="diff-file-{f.path}" {...fileAttrs?.(f)} id="{uid}-{slug(f.path)}" class="file" data-path={f.path} data-status={f.status} data-viewed={viewedSet.has(f.path)} aria-label={f.path}>
|
|
145
164
|
<header class="fh">
|
|
146
165
|
<Button variant="minimal" intent="neutral" size="sm" aria-expanded={open} aria-controls="{uid}-{slug(f.path)}-body" aria-label="{open ? 'Collapse' : 'Expand'} {f.path}" onclick={() => toggle(f.path)}>
|
|
147
166
|
<Icon name={open ? 'chevron-down' : 'chevron-right'} />
|
|
@@ -153,6 +172,7 @@
|
|
|
153
172
|
</header>
|
|
154
173
|
{#if open}
|
|
155
174
|
<div id="{uid}-{slug(f.path)}-body" class="body">
|
|
175
|
+
{#if fileExtra && fileExtraPosition === 'above'}{@render fileExtra({ file: f, index: i })}{/if}
|
|
156
176
|
<DiffView
|
|
157
177
|
bind:this={views[f.path]}
|
|
158
178
|
old={f.old}
|
|
@@ -171,8 +191,10 @@
|
|
|
171
191
|
{detectMoves}
|
|
172
192
|
toolbar={false}
|
|
173
193
|
label="Changes"
|
|
194
|
+
load={f.file ? loader : undefined}
|
|
174
195
|
onedge={(dir) => hop(i, dir)}
|
|
175
196
|
/>
|
|
197
|
+
{#if fileExtra && fileExtraPosition === 'below'}{@render fileExtra({ file: f, index: i })}{/if}
|
|
176
198
|
</div>
|
|
177
199
|
{/if}
|
|
178
200
|
</section>
|
|
@@ -183,6 +205,16 @@
|
|
|
183
205
|
</div>
|
|
184
206
|
</div>
|
|
185
207
|
|
|
208
|
+
<!-- the status of a changed file in the tree: its letter, with the whole word in the tooltip and the accessible name -->
|
|
209
|
+
{#snippet status(n: TreeNode)}
|
|
210
|
+
{@const e = byPath.get(n.id)}
|
|
211
|
+
{#if e}
|
|
212
|
+
<Tooltip text="{e.status}, {e.additions} added, {e.deletions} removed" placement="right">
|
|
213
|
+
<span class="st" data-status={e.status}><span aria-hidden="true">{statusLetter(e.status)}</span><span class="vh">{e.status}</span></span>
|
|
214
|
+
</Tooltip>
|
|
215
|
+
{/if}
|
|
216
|
+
{/snippet}
|
|
217
|
+
|
|
186
218
|
<style>
|
|
187
219
|
.df { border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); background: var(--tr-surface); overflow: hidden; }
|
|
188
220
|
.top { display: flex; align-items: center; gap: var(--tr-space-3); padding: var(--tr-space-2) var(--tr-space-3); background: var(--tr-surface-container-low); border-bottom: 1px solid var(--tr-outline-variant); font-size: var(--tr-text-sm); }
|
|
@@ -197,6 +229,10 @@
|
|
|
197
229
|
.file[data-viewed='true'] { opacity: 0.8; }
|
|
198
230
|
.fh { display: flex; align-items: center; gap: var(--tr-space-2); padding: var(--tr-space-1) var(--tr-space-2); background: var(--tr-surface-container-low); font-size: var(--tr-text-sm); }
|
|
199
231
|
.path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--tr-font-mono); }
|
|
232
|
+
.side :global(.acts) { visibility: visible; }
|
|
233
|
+
.st { position: relative; display: inline-flex; align-items: center; justify-content: center; min-width: 1.5em; padding: 0 var(--tr-space-1); border-radius: var(--tr-radius-sm); font-size: var(--tr-text-xs, 0.75rem); font-weight: 700; color: var(--tr-on-surface); background: var(--tr-surface-container-high); }
|
|
234
|
+
.st[data-status='added'] { color: var(--tr-success); } .st[data-status='deleted'] { color: var(--tr-danger); } .st[data-status='renamed'] { color: var(--tr-warning); } .st[data-status='modified'] { color: var(--tr-primary); }
|
|
235
|
+
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
200
236
|
.old { color: var(--tr-on-surface-variant); }
|
|
201
237
|
.body :global(.dv) { border: 0; border-radius: 0; border-top: 1px solid var(--tr-outline-variant); }
|
|
202
238
|
.empty { margin: 0; padding: var(--tr-space-5); text-align: center; color: var(--tr-on-surface-variant); }
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import { type LoadText } from './load.js';
|
|
4
|
+
import { type DiffFileInput, type FileEntry } from './files.js';
|
|
2
5
|
type $$ComponentProps = {
|
|
3
6
|
/** The files, each with `old` and `new` text or a `patch`. */
|
|
4
7
|
files?: DiffFileInput[];
|
|
@@ -28,7 +31,19 @@ type $$ComponentProps = {
|
|
|
28
31
|
detectMoves?: boolean;
|
|
29
32
|
label?: string;
|
|
30
33
|
onviewed?: (path: string, viewed: boolean) => void;
|
|
31
|
-
|
|
34
|
+
/** Rendered inside each open file's body, next to its diff (for example review threads or a summary). Receives the file (`path`, `status`, counts...) and its position in the list. */
|
|
35
|
+
fileExtra?: Snippet<[info: {
|
|
36
|
+
file: FileEntry;
|
|
37
|
+
index: number;
|
|
38
|
+
}]>;
|
|
39
|
+
/** Where `fileExtra` goes relative to the file's diff. */
|
|
40
|
+
fileExtraPosition?: 'above' | 'below';
|
|
41
|
+
/** Extra attributes for each file's `<section>`, merged over the default `data-testid="diff-file-<path>"`. */
|
|
42
|
+
fileAttrs?: (file: FileEntry) => HTMLAttributes<HTMLElement>;
|
|
43
|
+
/** For patch input without the full text: fetches a whole file the first time its "Expand N lines" is pressed (see `DiffView`'s `load`). Results are cached per path and side, across collapsing and reopening a file. */
|
|
44
|
+
load?: LoadText;
|
|
45
|
+
class?: string;
|
|
46
|
+
} & Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'role' | 'onkeydown'>;
|
|
32
47
|
declare const DiffFiles: import("svelte").Component<$$ComponentProps, {}, "collapsed" | "selected" | "layout" | "viewed">;
|
|
33
48
|
type DiffFiles = ReturnType<typeof DiffFiles>;
|
|
34
49
|
export default DiffFiles;
|
package/dist/DiffView.svelte
CHANGED
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|
import { detectMoves as findMoves, type Move } from './moves.js';
|
|
10
10
|
import { buildRows, rowLine, type Fold, type Layout, type Row } from './rows.js';
|
|
11
11
|
import { offsets, rulerRuns, windowOf, type Lane } from './virtual.js';
|
|
12
|
-
import {
|
|
12
|
+
import { layoutPatch, parsePatch } from './patch.js';
|
|
13
|
+
import { cachedLoader, type LoadText } from './load.js';
|
|
13
14
|
import { highlight, overlay, paletteVars, segmentLine, type Seg } from './highlight.js';
|
|
14
15
|
import { loadMonaco } from './monaco.js';
|
|
15
16
|
import { resolveLanguage } from './lang.js';
|
|
16
17
|
import { readTheme, watchTheme, type Theme } from './theme.js';
|
|
17
18
|
import type { PatchFile } from './patch.js';
|
|
18
19
|
import type { DiffAnnotation } from './types.js';
|
|
20
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
19
21
|
|
|
20
22
|
let {
|
|
21
23
|
old: oldText = '',
|
|
@@ -48,13 +50,18 @@
|
|
|
48
50
|
lineActions,
|
|
49
51
|
onlayout,
|
|
50
52
|
onselect,
|
|
51
|
-
onedge
|
|
53
|
+
onedge,
|
|
54
|
+
wrap = true,
|
|
55
|
+
load,
|
|
56
|
+
class: cls,
|
|
57
|
+
style: userStyle,
|
|
58
|
+
...rest
|
|
52
59
|
}: {
|
|
53
60
|
/** The original text. */
|
|
54
61
|
old?: string;
|
|
55
62
|
/** The changed text. */
|
|
56
63
|
new?: string;
|
|
57
|
-
/** A unified patch (the first file of `git diff` output) shown instead of `old`/`new`. Only the lines the patch contains are available, so the gaps between its hunks cannot be expanded
|
|
64
|
+
/** A unified patch (the first file of `git diff` output) shown instead of `old`/`new`. Only the lines the patch contains are available, so the gaps between its hunks cannot be expanded unless you give `load`. */
|
|
58
65
|
patch?: string;
|
|
59
66
|
/** An already parsed file, as from `parsePatch`. Wins over `patch`. */
|
|
60
67
|
file?: PatchFile;
|
|
@@ -111,7 +118,16 @@
|
|
|
111
118
|
onselect?: (selection: LineSelection | null) => void;
|
|
112
119
|
/** `n` on the last change or `p` on the first: lets a parent (such as a file list) move on. */
|
|
113
120
|
onedge?: (dir: 'next' | 'prev') => void;
|
|
114
|
-
|
|
121
|
+
/** Keep long lines on one line and scroll sideways instead of wrapping them. With `layout="split"` both sides scroll together, so rows stay aligned. */
|
|
122
|
+
wrap?: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* For a `patch` or `file` without the full text: fetches the whole file (`side` is always `'new'` here) the first time "Expand N lines" is pressed on a gap
|
|
125
|
+
* between hunks. The unchanged lines are then filled in and the hunks re-folded as for text input. The button shows a loading state, and a rejection shows
|
|
126
|
+
* the error with a Retry button. Results are cached per path and side. Without `load` those gaps cannot be expanded.
|
|
127
|
+
*/
|
|
128
|
+
load?: LoadText;
|
|
129
|
+
class?: string;
|
|
130
|
+
} & Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'role' | 'tabindex' | 'onkeydown' | 'onselect'> = $props();
|
|
115
131
|
|
|
116
132
|
const AUTO = 2000; // rows above which virtual rendering starts by itself
|
|
117
133
|
const OVERSCAN = 20;
|
|
@@ -150,15 +166,21 @@
|
|
|
150
166
|
|
|
151
167
|
const parsed = $derived(file ?? (patch !== undefined ? parsePatch(patch)[0] : undefined));
|
|
152
168
|
const fromPatch = $derived(file !== undefined || patch !== undefined);
|
|
153
|
-
const
|
|
169
|
+
const source = $derived.by(() => {
|
|
154
170
|
if (fromPatch) {
|
|
155
|
-
if (!parsed) return { lines: [] as DiffLine[], binary: false, oversize: false };
|
|
156
|
-
if (parsed.binary) return { lines: [] as DiffLine[], binary: true, oversize: false };
|
|
157
|
-
return {
|
|
171
|
+
if (!parsed) return { lines: [] as DiffLine[], anchors: [] as number[], binary: false, oversize: false };
|
|
172
|
+
if (parsed.binary) return { lines: [] as DiffLine[], anchors: [] as number[], binary: true, oversize: false };
|
|
173
|
+
return { ...layoutPatch(parsed), binary: false, oversize: false };
|
|
158
174
|
}
|
|
159
|
-
if (!forced && countLines(oldText) + countLines(newText) > maxLines) return { lines: [] as DiffLine[], binary: false, oversize: true };
|
|
160
|
-
return { lines: diffLines(oldText, newText, opts), binary: false, oversize: false };
|
|
175
|
+
if (!forced && countLines(oldText) + countLines(newText) > maxLines) return { lines: [] as DiffLine[], anchors: [] as number[], binary: false, oversize: true };
|
|
176
|
+
return { lines: diffLines(oldText, newText, opts), anchors: [] as number[], binary: false, oversize: false };
|
|
161
177
|
});
|
|
178
|
+
// the full text of a patched file, once `load` has fetched it: the gaps between hunks are then real lines
|
|
179
|
+
let fetched = $state.raw<{ file: PatchFile; text: string }>();
|
|
180
|
+
let busy = $state(-1); // key of the fold being loaded
|
|
181
|
+
let failed = $state<{ key: number; message: string }>();
|
|
182
|
+
const fetcher = $derived(load && cachedLoader(load));
|
|
183
|
+
const model = $derived(fetched && fetched.file === parsed ? { ...source, ...layoutPatch(fetched.file, fetched.text) } : source);
|
|
162
184
|
const lines = $derived(model.lines);
|
|
163
185
|
const starts = $derived(changeStarts(lines));
|
|
164
186
|
const stats = $derived(diffStats(lines));
|
|
@@ -202,17 +224,7 @@
|
|
|
202
224
|
|
|
203
225
|
// ───── hunks
|
|
204
226
|
const hunks = $derived<Hunk[]>(fromPatch ? (parsed?.hunks ?? []) : toHunks(lines, context));
|
|
205
|
-
const anchors = $derived.
|
|
206
|
-
if (!hunkActions) return [];
|
|
207
|
-
if (!fromPatch) return hunkSpans(lines, context).map((s) => s[0]);
|
|
208
|
-
let at = 0;
|
|
209
|
-
return hunks.map((h) => {
|
|
210
|
-
if (lines[at]?.type === 'gap') at++;
|
|
211
|
-
const a = at;
|
|
212
|
-
at += h.lines.length;
|
|
213
|
-
return a;
|
|
214
|
-
});
|
|
215
|
-
});
|
|
227
|
+
const anchors = $derived(!hunkActions ? [] : fromPatch ? model.anchors : hunkSpans(lines, context).map((s) => s[0]));
|
|
216
228
|
|
|
217
229
|
// ───── rows
|
|
218
230
|
const noteMap = $derived.by(() => {
|
|
@@ -269,8 +281,10 @@
|
|
|
269
281
|
|
|
270
282
|
// a new diff starts with nothing expanded, no current change and the top in view
|
|
271
283
|
$effect.pre(() => {
|
|
272
|
-
|
|
284
|
+
source;
|
|
273
285
|
expansion = {};
|
|
286
|
+
busy = -1;
|
|
287
|
+
failed = undefined;
|
|
274
288
|
cur = -1;
|
|
275
289
|
kb = -1;
|
|
276
290
|
hitCur = -1;
|
|
@@ -546,13 +560,35 @@
|
|
|
546
560
|
const SYM = { add: '+', del: '−', ctx: '', gap: '' } as const;
|
|
547
561
|
const SR = { add: 'added', del: 'removed', ctx: '', gap: '' } as const;
|
|
548
562
|
const grow = (f: Fold, how: 'top' | 'bottom' | 'all') => (expansion = expandFold(expansion, f, how, EXPAND));
|
|
563
|
+
/** Fetches the whole file for a gap between hunks, fills the gaps with its lines and opens the region that was asked for. */
|
|
564
|
+
async function fill(f: Fold) {
|
|
565
|
+
const file = parsed!;
|
|
566
|
+
const before = lines[f.from - 1], after = lines[f.to]; // the lines on either side of the gap survive the fill
|
|
567
|
+
busy = f.key;
|
|
568
|
+
failed = undefined;
|
|
569
|
+
try {
|
|
570
|
+
fetched = { file, text: await fetcher!(name, 'new') };
|
|
571
|
+
} catch (e) {
|
|
572
|
+
failed = { key: f.key, message: e instanceof Error ? e.message : String(e) };
|
|
573
|
+
return;
|
|
574
|
+
} finally {
|
|
575
|
+
busy = -1;
|
|
576
|
+
}
|
|
577
|
+
await tick();
|
|
578
|
+
const from = before ? lines.indexOf(before) + 1 : 0, to = lines.indexOf(after);
|
|
579
|
+
for (const it of items) if (it.type === 'fold' && it.from >= from && it.to <= to) expansion = expandFold(expansion, it, 'all');
|
|
580
|
+
}
|
|
549
581
|
const cols = $derived(layout === 'split' ? 6 : 4);
|
|
582
|
+
// the line-number column is sized from the largest number shown: its figures plus one, in ch
|
|
583
|
+
const figures = $derived(String(lines.reduce((m, l) => Math.max(m, l.oldNo ?? 0, l.newNo ?? 0), 1)).length);
|
|
584
|
+
// without wrapping, the code column is as wide as the longest line
|
|
585
|
+
const longest = $derived(wrap ? 0 : lines.reduce((m, l) => Math.max(m, l.text.replace(/\t/g, ' ').length), 0));
|
|
550
586
|
const style = $derived((theme ? paletteVars(theme.palette) + `;--dv-font:${theme.metrics.fontSize}px` : ''));
|
|
551
587
|
const sr = (i: number) => (moveAt[i] >= 0 ? (lines[i].type === 'add' ? 'moved here' : 'moved away') : SR[lines[i].type]);
|
|
552
588
|
</script>
|
|
553
589
|
|
|
554
590
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
555
|
-
<div bind:this={root} class=
|
|
591
|
+
<div bind:this={root} class={['dv', cls]} {...rest} style={[style, userStyle].join(';')} role="group" aria-label={name ? `${label}: ${name}` : label} tabindex="0" onkeydown={onkey}>
|
|
556
592
|
{#if toolbar}
|
|
557
593
|
<div class="bar">
|
|
558
594
|
<span class="sum" data-testid="summary">
|
|
@@ -592,8 +628,13 @@
|
|
|
592
628
|
<p class="ph" role="status">No differences.</p>
|
|
593
629
|
{:else}
|
|
594
630
|
<div class="view">
|
|
595
|
-
<div class="scroll" class:virt bind:this={scroller} bind:clientHeight={vh} {onscroll} style:max-height={maxH} data-virtual={virt}>
|
|
596
|
-
<table class="tbl" data-layout={layout} aria-label={name ? `Changes in ${name}` : 'Changes'} aria-rowcount={rows.length}>
|
|
631
|
+
<div class="scroll" class:virt class:nowrap={!wrap} bind:this={scroller} bind:clientHeight={vh} {onscroll} style:max-height={maxH} data-virtual={virt}>
|
|
632
|
+
<table class="tbl" data-layout={layout} style:--dv-no="{figures + 1}ch" style:--dv-code={wrap ? undefined : `calc(${longest + 1}ch + 2 * var(--tr-space-2))`} aria-label={name ? `Changes in ${name}` : 'Changes'} aria-rowcount={rows.length}>
|
|
633
|
+
{#if layout === 'split'}
|
|
634
|
+
<colgroup>
|
|
635
|
+
{#each ['old', 'new'] as s (s)}<col class="c-no" /><col class="c-sym" /><col class="c-code" />{/each}
|
|
636
|
+
</colgroup>
|
|
637
|
+
{/if}
|
|
597
638
|
<tbody>
|
|
598
639
|
{#if virt}<tr class="pad" aria-hidden="true"><td colspan={cols} style:height="{offs[win[0]]}px"></td></tr>{/if}
|
|
599
640
|
{#each shown as e, n (e.key)}
|
|
@@ -638,14 +679,15 @@
|
|
|
638
679
|
</tr>
|
|
639
680
|
{:else if e.k === 'fold'}
|
|
640
681
|
<tr class="fold" aria-rowindex={ri + 1} data-vk={e.key}><td colspan={cols}>
|
|
641
|
-
{#if e.f.fixed}
|
|
682
|
+
{#if e.f.fixed && !load}
|
|
642
683
|
<span class="note">{e.f.count ? `${e.f.count} unchanged ${e.f.count === 1 ? 'line' : 'lines'} not in this patch` : 'Lines not in this patch'}</span>
|
|
643
684
|
{:else}
|
|
644
|
-
{#if e.f.count > EXPAND}
|
|
685
|
+
{#if !e.f.fixed && e.f.count > EXPAND}
|
|
645
686
|
<Button size="sm" variant="minimal" intent="neutral" aria-label="Show {EXPAND} more lines from above" onclick={() => grow(e.f, 'top')}>↓ {EXPAND}</Button>
|
|
646
687
|
<Button size="sm" variant="minimal" intent="neutral" aria-label="Show {EXPAND} more lines from below" onclick={() => grow(e.f, 'bottom')}>↑ {EXPAND}</Button>
|
|
647
688
|
{/if}
|
|
648
|
-
<Button size="sm" variant="minimal" intent="neutral" onclick={() => grow(e.f, 'all')}>Expand {e.f.count === 1 ? '1 line' : `${e.f.count} lines`}</Button>
|
|
689
|
+
<Button size="sm" variant="minimal" intent="neutral" loading={busy === e.f.key} onclick={() => (e.f.fixed ? fill(e.f) : grow(e.f, 'all'))}>{failed?.key === e.f.key ? 'Retry' : `Expand ${e.f.count === 1 ? '1 line' : `${e.f.count} lines`}`}</Button>
|
|
690
|
+
{#if failed?.key === e.f.key}<span class="note err" role="alert">Could not load the file: {failed.message}</span>{/if}
|
|
649
691
|
{/if}
|
|
650
692
|
</td></tr>
|
|
651
693
|
{:else if e.k === 'note'}
|
|
@@ -720,16 +762,15 @@
|
|
|
720
762
|
.scroll { overflow: auto; flex: 1; min-width: 0; }
|
|
721
763
|
.tbl { border-collapse: collapse; width: 100%; font-family: var(--tr-font-mono); font-size: var(--dv-font, var(--tr-text-sm)); line-height: 1.5; tab-size: 4; }
|
|
722
764
|
.tbl[data-layout='split'] { table-layout: fixed; }
|
|
765
|
+
.c-no { width: var(--dv-no); } .c-sym { width: 2ch; } .c-code { width: var(--dv-code, auto); }
|
|
723
766
|
td { padding: 0 var(--tr-space-2); vertical-align: top; }
|
|
724
767
|
.pad td { padding: 0; border: 0; }
|
|
725
|
-
.no { width:
|
|
768
|
+
.no { box-sizing: border-box; width: var(--dv-no); min-width: var(--dv-no); padding: 0 0.5ch; text-align: right; white-space: nowrap; color: var(--code-comment, var(--tr-on-surface-variant)); user-select: none; position: relative; }
|
|
726
769
|
.ln { all: unset; cursor: pointer; font: inherit; color: inherit; }
|
|
727
770
|
.ln:hover { text-decoration: underline; }
|
|
728
771
|
.ln:focus-visible { outline: 2px solid var(--tr-primary); }
|
|
729
|
-
.sym { width: 2ch; text-align: center; user-select: none; font-weight: 700; padding: 0; }
|
|
772
|
+
.sym { width: 2ch; min-width: 2ch; text-align: center; user-select: none; font-weight: 700; padding: 0; }
|
|
730
773
|
.code { white-space: pre-wrap; overflow-wrap: anywhere; width: 100%; }
|
|
731
|
-
.tbl[data-layout='split'] .code { width: 50%; }
|
|
732
|
-
.tbl[data-layout='split'] .no { width: 5ch; } .tbl[data-layout='split'] .sym { width: 3ch; }
|
|
733
774
|
tr.add, td.add { background: var(--add); } tr.del, td.del { background: var(--del); }
|
|
734
775
|
tr.moved, td.moved { background: var(--mv); background-image: repeating-linear-gradient(135deg, transparent 0 5px, color-mix(in srgb, var(--tr-primary) 22%, transparent) 5px 7px); }
|
|
735
776
|
.add .sym, .add.sym { color: var(--tr-success); } .del .sym, .del.sym { color: var(--tr-danger); } .moved .sym, .moved.sym { color: var(--tr-primary); }
|
|
@@ -753,7 +794,7 @@
|
|
|
753
794
|
.fold td, .hunk td { background: var(--tr-surface-container-low); padding: 2px var(--tr-space-2); border-block: 1px solid var(--tr-outline-variant); font-family: var(--tr-font-sans); }
|
|
754
795
|
.hunk td { font-size: var(--tr-text-sm); }
|
|
755
796
|
.hh { margin-right: var(--tr-space-3); color: var(--tr-on-surface-variant); font-family: var(--tr-font-mono); }
|
|
756
|
-
.note { color: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); }
|
|
797
|
+
.note { color: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); } .note.err { color: var(--tr-danger); margin-left: var(--tr-space-2); }
|
|
757
798
|
.note-row td { padding: var(--tr-space-2); font-family: var(--tr-font-sans); background: var(--tr-surface-container-low); }
|
|
758
799
|
.act { position: absolute; left: 0; top: 0; opacity: 0; } tr:hover .act, .act:focus-within { opacity: 1; }
|
|
759
800
|
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
@@ -762,14 +803,22 @@
|
|
|
762
803
|
.virt .tbl { width: max-content; min-width: 100%; }
|
|
763
804
|
.virt .tbl[data-layout='split'] { width: 100%; }
|
|
764
805
|
.virt .code { white-space: pre; overflow-wrap: normal; width: auto; }
|
|
765
|
-
.virt .tbl[data-layout='split'] .code {
|
|
806
|
+
.virt .tbl[data-layout='split'] .code { overflow: hidden; text-overflow: ellipsis; }
|
|
766
807
|
.virt .fold td, .virt .hunk td, .virt .note-row td { position: sticky; left: 0; }
|
|
767
|
-
.virt td.no { position: sticky; left: 0; z-index: 1;
|
|
768
|
-
.virt .tbl:not([data-layout='split']) td.no:nth-child(2) { left:
|
|
808
|
+
.virt td.no { position: sticky; left: 0; z-index: 1; background-color: var(--code-bg, var(--tr-surface)); }
|
|
809
|
+
.virt .tbl:not([data-layout='split']) td.no:nth-child(2) { left: var(--dv-no); }
|
|
769
810
|
.virt .tbl[data-layout='split'] td.no[data-side='new'] { position: relative; }
|
|
770
|
-
.virt tr.add td.no, .virt td.no.add { background-image: linear-gradient(var(--add), var(--add)); }
|
|
771
|
-
.virt tr.del td.no, .virt td.no.del { background-image: linear-gradient(var(--del), var(--del)); }
|
|
772
|
-
.virt tr.moved td.no, .virt td.no.moved { background-image: linear-gradient(var(--mv), var(--mv)); }
|
|
811
|
+
.virt tr.add td.no, .virt td.no.add, .nowrap tr.add td.no, .nowrap td.no.add { background-image: linear-gradient(var(--add), var(--add)); }
|
|
812
|
+
.virt tr.del td.no, .virt td.no.del, .nowrap tr.del td.no, .nowrap td.no.del { background-image: linear-gradient(var(--del), var(--del)); }
|
|
813
|
+
.virt tr.moved td.no, .virt td.no.moved, .nowrap tr.moved td.no, .nowrap td.no.moved { background-image: linear-gradient(var(--mv), var(--mv)); }
|
|
814
|
+
|
|
815
|
+
/* wrap={false}: lines keep to one line and the whole diff scrolls sideways, so the rows of the two sides stay aligned */
|
|
816
|
+
.nowrap .tbl { width: max-content; min-width: 100%; }
|
|
817
|
+
.nowrap .tbl[data-layout='split'] { width: max(100%, calc(2 * (var(--dv-no) + 2ch + var(--dv-code)))); }
|
|
818
|
+
.nowrap .code { white-space: pre; overflow-wrap: normal; width: auto; overflow: hidden; }
|
|
819
|
+
.nowrap td.no { position: sticky; left: 0; z-index: 1; background-color: var(--code-bg, var(--tr-surface)); }
|
|
820
|
+
.nowrap .tbl[data-layout='split'] td.no[data-side='new'] { position: relative; }
|
|
821
|
+
.nowrap .tbl:not([data-layout='split']) td.no:nth-child(2) { left: var(--dv-no); }
|
|
773
822
|
|
|
774
823
|
.ov { position: relative; flex: 0 0 14px; border-left: 1px solid var(--tr-outline-variant); background: var(--tr-surface-container-low); cursor: pointer; touch-action: none; min-height: 3rem; }
|
|
775
824
|
.mk { position: absolute; min-height: 2px; width: 25%; }
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { type Snippet } from 'svelte';
|
|
2
2
|
import { type DiffLine, type LineSelection, type Side } from './diff.js';
|
|
3
3
|
import { type Layout } from './rows.js';
|
|
4
|
+
import { type LoadText } from './load.js';
|
|
4
5
|
import type { PatchFile } from './patch.js';
|
|
5
6
|
import type { DiffAnnotation } from './types.js';
|
|
7
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
6
8
|
type $$ComponentProps = {
|
|
7
9
|
/** The original text. */
|
|
8
10
|
old?: string;
|
|
9
11
|
/** The changed text. */
|
|
10
12
|
new?: string;
|
|
11
|
-
/** A unified patch (the first file of `git diff` output) shown instead of `old`/`new`. Only the lines the patch contains are available, so the gaps between its hunks cannot be expanded
|
|
13
|
+
/** A unified patch (the first file of `git diff` output) shown instead of `old`/`new`. Only the lines the patch contains are available, so the gaps between its hunks cannot be expanded unless you give `load`. */
|
|
12
14
|
patch?: string;
|
|
13
15
|
/** An already parsed file, as from `parsePatch`. Wins over `patch`. */
|
|
14
16
|
file?: PatchFile;
|
|
@@ -69,7 +71,16 @@ type $$ComponentProps = {
|
|
|
69
71
|
onselect?: (selection: LineSelection | null) => void;
|
|
70
72
|
/** `n` on the last change or `p` on the first: lets a parent (such as a file list) move on. */
|
|
71
73
|
onedge?: (dir: 'next' | 'prev') => void;
|
|
72
|
-
|
|
74
|
+
/** Keep long lines on one line and scroll sideways instead of wrapping them. With `layout="split"` both sides scroll together, so rows stay aligned. */
|
|
75
|
+
wrap?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* For a `patch` or `file` without the full text: fetches the whole file (`side` is always `'new'` here) the first time "Expand N lines" is pressed on a gap
|
|
78
|
+
* between hunks. The unchanged lines are then filled in and the hunks re-folded as for text input. The button shows a loading state, and a rejection shows
|
|
79
|
+
* the error with a Retry button. Results are cached per path and side. Without `load` those gaps cannot be expanded.
|
|
80
|
+
*/
|
|
81
|
+
load?: LoadText;
|
|
82
|
+
class?: string;
|
|
83
|
+
} & Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class' | 'role' | 'tabindex' | 'onkeydown' | 'onselect'>;
|
|
73
84
|
declare const DiffView: import("svelte").Component<$$ComponentProps, {
|
|
74
85
|
next: () => void;
|
|
75
86
|
prev: () => void;
|
package/dist/editor-util.d.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import type { CodeMarker, CompletionItem, CompletionKind, MarkerSeverity } from './types.js';
|
|
1
|
+
import type { CodeMarker, CompletionContext, CompletionItem, CompletionKind, MarkerSeverity } from './types.js';
|
|
2
|
+
type Provide = (ctx: CompletionContext) => CompletionItem[] | undefined | Promise<CompletionItem[] | undefined>;
|
|
3
|
+
/** The `completions` prop: a provider function, or one with the characters that open suggestions when typed. */
|
|
4
|
+
export type CompletionsOption = Provide | {
|
|
5
|
+
provide: Provide;
|
|
6
|
+
triggerCharacters?: string[];
|
|
7
|
+
};
|
|
8
|
+
/** The provider and trigger characters of a `completions` prop. */
|
|
9
|
+
export declare const completionProvider: (c: CompletionsOption | undefined) => {
|
|
10
|
+
provide: Provide;
|
|
11
|
+
triggerCharacters?: string[];
|
|
12
|
+
} | undefined;
|
|
2
13
|
/** Monaco's `MarkerSeverity` enum (numeric), passed in so these helpers stay independent of the Monaco bundle. */
|
|
3
14
|
export interface SeverityEnum {
|
|
4
15
|
Error: number;
|
|
@@ -44,3 +55,4 @@ export declare function toMonacoCompletion(item: CompletionItem, kinds: KindEnum
|
|
|
44
55
|
endColumn: number;
|
|
45
56
|
};
|
|
46
57
|
};
|
|
58
|
+
export {};
|
package/dist/editor-util.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** The provider and trigger characters of a `completions` prop. */
|
|
2
|
+
export const completionProvider = (c) => (typeof c === 'function' ? { provide: c } : c);
|
|
1
3
|
export const markerSeverity = (s, e) => (s === 'error' ? e.Error : s === 'warning' ? e.Warning : s === 'hint' ? e.Hint : e.Info);
|
|
2
4
|
/** Column just past the word that starts at `col` (1-based) in `lineText`; one column when there is no word there. */
|
|
3
5
|
export function wordEnd(lineText, col) {
|
package/dist/files.js
CHANGED
|
@@ -42,7 +42,7 @@ export function buildTree(entries) {
|
|
|
42
42
|
}
|
|
43
43
|
at = d;
|
|
44
44
|
}
|
|
45
|
-
at.node.children.push({ id: e.path, label: parts[parts.length - 1], icon: 'file'
|
|
45
|
+
at.node.children.push({ id: e.path, label: parts[parts.length - 1], icon: 'file' });
|
|
46
46
|
}
|
|
47
47
|
const sort = (list) => {
|
|
48
48
|
list.sort((a, b) => Number(!!b.children) - Number(!!a.children) || a.label.localeCompare(b.label));
|
package/dist/index.d.ts
CHANGED
|
@@ -7,17 +7,19 @@ export { detectLanguage, resolveLanguage, modelLanguage } from './lang.js';
|
|
|
7
7
|
export { loadMonaco } from './monaco.js';
|
|
8
8
|
export type { TypeScriptSetup } from './monaco.js';
|
|
9
9
|
export type { SqlDialect } from './lang.js';
|
|
10
|
+
export type { CompletionsOption } from './editor-util.js';
|
|
10
11
|
export { buildPalette, monacoTheme, ensureContrast, contrast, parseColor } from './theme.js';
|
|
11
12
|
export { diffLines, diffSeq, intraline, annotateIntraline, pairLines, inlineWords, hunkSpans, toHunks, formatHunks, unifiedPatch, foldItems, expandFold, splitRows, splitDocument, changeStarts, diffStats } from './diff.js';
|
|
12
13
|
export { detectMoves } from './moves.js';
|
|
13
|
-
export { parsePatch, patchLines, unquotePath, filePath } from './patch.js';
|
|
14
|
+
export { parsePatch, patchLines, layoutPatch, unquotePath, filePath } from './patch.js';
|
|
14
15
|
export { entriesFromPatch, buildTree } from './files.js';
|
|
15
16
|
export type { CodeMarker, MarkerSeverity, CompletionItem, CompletionKind, CompletionContext, DiffAnnotation } from './types.js';
|
|
16
17
|
export type { Palette, Role, Mode } from './theme.js';
|
|
17
18
|
export type { DiffLine, Hunk, DiffOptions, IntraSeg, Expansion, LineSelection, Side, WordPart } from './diff.js';
|
|
18
19
|
export type { Move, MoveOptions } from './moves.js';
|
|
19
20
|
export type { PatchFile, FileStatus } from './patch.js';
|
|
20
|
-
export type { DiffFileInput, DiffStatus } from './files.js';
|
|
21
|
+
export type { DiffFileInput, DiffStatus, FileEntry } from './files.js';
|
|
22
|
+
export type { LoadText } from './load.js';
|
|
21
23
|
export { default as MergeView } from './MergeView.svelte';
|
|
22
24
|
export { diff3, render as renderMerge, mergeText, chosenLines, customResolution } from './merge.js';
|
|
23
25
|
export type { MergeState } from './MergeView.svelte';
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ export { buildPalette, monacoTheme, ensureContrast, contrast, parseColor } from
|
|
|
9
9
|
// pure diff and patch logic, usable without any component
|
|
10
10
|
export { diffLines, diffSeq, intraline, annotateIntraline, pairLines, inlineWords, hunkSpans, toHunks, formatHunks, unifiedPatch, foldItems, expandFold, splitRows, splitDocument, changeStarts, diffStats } from './diff.js';
|
|
11
11
|
export { detectMoves } from './moves.js';
|
|
12
|
-
export { parsePatch, patchLines, unquotePath, filePath } from './patch.js';
|
|
12
|
+
export { parsePatch, patchLines, layoutPatch, unquotePath, filePath } from './patch.js';
|
|
13
13
|
export { entriesFromPatch, buildTree } from './files.js';
|
|
14
14
|
export { default as MergeView } from './MergeView.svelte';
|
|
15
15
|
export { diff3, render as renderMerge, mergeText, chosenLines, customResolution } from './merge.js';
|
package/dist/load.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Fetches the full text of a file for one side of a diff. */
|
|
2
|
+
export type LoadText = (path: string, side: 'old' | 'new') => Promise<string>;
|
|
3
|
+
/**
|
|
4
|
+
* Wraps a loader so each path and side is fetched once: later calls (and calls made while the first is still running) share its result.
|
|
5
|
+
* A failed fetch is forgotten, so asking again retries.
|
|
6
|
+
*/
|
|
7
|
+
export declare function cachedLoader(load: LoadText): LoadText;
|
package/dist/load.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps a loader so each path and side is fetched once: later calls (and calls made while the first is still running) share its result.
|
|
3
|
+
* A failed fetch is forgotten, so asking again retries.
|
|
4
|
+
*/
|
|
5
|
+
export function cachedLoader(load) {
|
|
6
|
+
const seen = new Map();
|
|
7
|
+
return (path, side) => {
|
|
8
|
+
const key = `${side}:${path}`;
|
|
9
|
+
let p = seen.get(key);
|
|
10
|
+
if (!p) {
|
|
11
|
+
p = load(path, side);
|
|
12
|
+
seen.set(key, p);
|
|
13
|
+
p.catch(() => seen.delete(key));
|
|
14
|
+
}
|
|
15
|
+
return p;
|
|
16
|
+
};
|
|
17
|
+
}
|
package/dist/patch.d.ts
CHANGED
|
@@ -20,5 +20,14 @@ export declare const filePath: (f: Pick<PatchFile, "oldPath" | "newPath">) => st
|
|
|
20
20
|
export declare function unquotePath(raw: string): string;
|
|
21
21
|
/** Parses unified diff text, including `git diff` / `git format-patch` output with renames, mode changes, binary files, CRLF and "". */
|
|
22
22
|
export declare function parsePatch(text: string): PatchFile[];
|
|
23
|
+
/**
|
|
24
|
+
* The lines of a parsed file in display order, with a `gap` line for the unchanged lines a patch leaves out between (and before) hunks,
|
|
25
|
+
* and for each hunk the index of its first line. With `full` (the file's new text) the gaps are filled with the real lines instead,
|
|
26
|
+
* and the lines after the last hunk are added too.
|
|
27
|
+
*/
|
|
28
|
+
export declare function layoutPatch(file: Pick<PatchFile, 'hunks'>, full?: string): {
|
|
29
|
+
lines: DiffLine[];
|
|
30
|
+
anchors: number[];
|
|
31
|
+
};
|
|
23
32
|
/** The lines of a parsed file in display order, with a `gap` line for the unchanged lines a patch leaves out between (and before) hunks. */
|
|
24
|
-
export declare
|
|
33
|
+
export declare const patchLines: (file: Pick<PatchFile, "hunks">) => DiffLine[];
|
package/dist/patch.js
CHANGED
|
@@ -170,16 +170,43 @@ export function parsePatch(text) {
|
|
|
170
170
|
}
|
|
171
171
|
return files;
|
|
172
172
|
}
|
|
173
|
-
/** The
|
|
174
|
-
|
|
173
|
+
/** The last old and the last new line a hunk reaches (the line before it starts, for an empty side). */
|
|
174
|
+
const ends = (h) => ({ old: h.oldLines ? h.oldStart + h.oldLines - 1 : h.oldStart, new: h.newLines ? h.newStart + h.newLines - 1 : h.newStart });
|
|
175
|
+
/**
|
|
176
|
+
* The lines of a parsed file in display order, with a `gap` line for the unchanged lines a patch leaves out between (and before) hunks,
|
|
177
|
+
* and for each hunk the index of its first line. With `full` (the file's new text) the gaps are filled with the real lines instead,
|
|
178
|
+
* and the lines after the last hunk are added too.
|
|
179
|
+
*/
|
|
180
|
+
export function layoutPatch(file, full) {
|
|
175
181
|
const out = [];
|
|
176
|
-
|
|
182
|
+
const anchors = [];
|
|
183
|
+
const text = full?.split('\n');
|
|
184
|
+
if (text)
|
|
185
|
+
text.length -= +(text[text.length - 1] === ''); // a final newline ends the last line, it does not start another
|
|
186
|
+
/** `count` unchanged lines whose old and new numbers start at `oldNo` and `newNo`, taken from the text. */
|
|
187
|
+
const unchanged = (oldNo, newNo, count) => {
|
|
188
|
+
const n = Math.max(0, Math.min(count, text.length - newNo + 1));
|
|
189
|
+
for (let k = 0; k < n; k++) {
|
|
190
|
+
const raw = text[newNo + k - 1];
|
|
191
|
+
out.push({ type: 'ctx', oldNo: oldNo + k, newNo: newNo + k, text: raw.replace(/\r$/, ''), cr: raw.endsWith('\r') });
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
let prev = { old: 0, new: 0 }; // the last old and new line the previous hunk reached
|
|
177
195
|
for (const h of file.hunks) {
|
|
178
|
-
const
|
|
179
|
-
if (
|
|
180
|
-
|
|
196
|
+
const missing = (h.oldLines ? h.oldStart : h.oldStart + 1) - 1 - prev.old;
|
|
197
|
+
if (missing > 0) {
|
|
198
|
+
if (text)
|
|
199
|
+
unchanged(prev.old + 1, prev.new + 1, missing);
|
|
200
|
+
else
|
|
201
|
+
out.push({ type: 'gap', text: '', count: missing });
|
|
202
|
+
}
|
|
203
|
+
anchors.push(out.length);
|
|
181
204
|
out.push(...h.lines);
|
|
182
|
-
|
|
205
|
+
prev = ends(h);
|
|
183
206
|
}
|
|
184
|
-
|
|
207
|
+
if (text)
|
|
208
|
+
unchanged(prev.old + 1, prev.new + 1, text.length - prev.new);
|
|
209
|
+
return { lines: out, anchors };
|
|
185
210
|
}
|
|
211
|
+
/** The lines of a parsed file in display order, with a `gap` line for the unchanged lines a patch leaves out between (and before) hunks. */
|
|
212
|
+
export const patchLines = (file) => layoutPatch(file).lines;
|