@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.
@@ -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, CompletionContext, CompletionItem } from './types.js';
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?: (ctx: CompletionContext) => CompletionItem[] | undefined | Promise<CompletionItem[] | undefined>;
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
- function modelFor(m: MonacoApi, key: string): Monaco.editor.ITextModel {
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(value, mlang, uri);
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 mdl = modelFor(m, key);
156
- if (current !== key) {
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
- value = mdl.getValue();
166
- onchange?.(value);
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 (mdl.getValue() !== value) {
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
- ed.executeEdits('truss-bind', [{ range: mdl.getFullModelRange(), text: value }]);
173
- ed.pushUndoStop();
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 type { CodeMarker, CompletionContext, CompletionItem } from './types.js';
5
- type $$ComponentProps = {
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?: (ctx: CompletionContext) => CompletionItem[] | undefined | Promise<CompletionItem[] | undefined>;
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;
@@ -1,9 +1,11 @@
1
1
  <script lang="ts">
2
- import { tick, onMount } from 'svelte';
3
- import { Badge, Button, Checkbox, Icon, SegmentedControl, Tree } from '@ruckc/truss-ui';
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 { buildTree, entriesFromPatch, entryFromInput, folderIds, totals, type DiffFileInput, type DiffStatus, type FileEntry } from './files.js';
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
- } = $props();
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="df" role="group" aria-label={label} onkeydown={onkey}>
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">&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 DiffFileInput } from './files.js';
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;
@@ -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 { parsePatch, patchLines } from './patch.js';
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
- } = $props();
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 model = $derived.by(() => {
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 { lines: patchLines(parsed), binary: false, oversize: false };
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.by(() => {
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
- model;
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="dv" {style} role="group" aria-label={name ? `${label}: ${name}` : label} tabindex="0" onkeydown={onkey}>
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')}>&darr; {EXPAND}</Button>
646
687
  <Button size="sm" variant="minimal" intent="neutral" aria-label="Show {EXPAND} more lines from below" onclick={() => grow(e.f, 'bottom')}>&uarr; {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: 1%; min-width: 4ch; text-align: right; white-space: nowrap; color: var(--code-comment, var(--tr-on-surface-variant)); user-select: none; position: relative; }
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 { width: 50%; overflow: hidden; text-overflow: ellipsis; }
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; width: 6ch; min-width: 6ch; background-color: var(--code-bg, var(--tr-surface)); }
768
- .virt .tbl:not([data-layout='split']) td.no:nth-child(2) { left: calc(6ch + 2 * var(--tr-space-2)); }
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;
@@ -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 {};
@@ -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', badge: statusLetter(e.status), description: `${e.status}, ${e.additions} added, ${e.deletions} removed` });
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 function patchLines(file: Pick<PatchFile, 'hunks'>): DiffLine[];
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 lines of a parsed file in display order, with a `gap` line for the unchanged lines a patch leaves out between (and before) hunks. */
174
- export function patchLines(file) {
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
- let prevEnd = 0; // the last old line the previous hunk reached
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 first = h.oldLines ? h.oldStart : h.oldStart + 1;
179
- if (first - 1 > prevEnd)
180
- out.push({ type: 'gap', text: '', count: first - 1 - prevEnd });
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
- prevEnd = h.oldLines ? h.oldStart + h.oldLines - 1 : h.oldStart;
205
+ prev = ends(h);
183
206
  }
184
- return out;
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ruckc/truss-code",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "Monaco code editor, read-only code blocks and diff views styled with Truss UI",
5
5
  "type": "module",
6
6
  "license": "MIT",