@ruckc/truss-code 0.0.0-stage → 1.12.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/CodeBlock.svelte +75 -0
- package/dist/CodeBlock.svelte.d.ts +17 -0
- package/dist/CodeEditor.svelte +227 -0
- package/dist/CodeEditor.svelte.d.ts +41 -0
- package/dist/DiffEditor.svelte +142 -0
- package/dist/DiffEditor.svelte.d.ts +32 -0
- package/dist/DiffFiles.svelte +196 -0
- package/dist/DiffFiles.svelte.d.ts +30 -0
- package/dist/DiffView.svelte +357 -0
- package/dist/DiffView.svelte.d.ts +56 -0
- package/dist/diff.d.ts +135 -0
- package/dist/diff.js +346 -0
- package/dist/editor-util.d.ts +46 -0
- package/dist/editor-util.js +52 -0
- package/dist/files.d.ts +44 -0
- package/dist/files.js +57 -0
- package/dist/highlight.d.ts +27 -0
- package/dist/highlight.js +84 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +12 -0
- package/dist/lang.d.ts +4 -0
- package/dist/lang.js +36 -0
- package/dist/monaco.d.ts +15 -0
- package/dist/monaco.js +40 -0
- package/dist/patch.d.ts +24 -0
- package/dist/patch.js +185 -0
- package/dist/theme.d.ts +80 -0
- package/dist/theme.js +217 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.js +1 -0
- package/dist/worker.d.ts +5 -0
- package/package.json +52 -5
- package/README.md +0 -3
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick, onMount } from 'svelte';
|
|
3
|
+
import { Badge, Button, Checkbox, Icon, SegmentedControl, Tree } from '@ruckc/truss-ui';
|
|
4
|
+
import type { TreeNode } from '@ruckc/truss-ui';
|
|
5
|
+
import DiffView from './DiffView.svelte';
|
|
6
|
+
import { buildTree, entriesFromPatch, entryFromInput, folderIds, totals, type DiffFileInput, type DiffStatus, type FileEntry } from './files.js';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
files = [],
|
|
10
|
+
patch,
|
|
11
|
+
viewed = $bindable([]),
|
|
12
|
+
collapsed = $bindable([]),
|
|
13
|
+
selected = $bindable(),
|
|
14
|
+
layout = $bindable('unified'),
|
|
15
|
+
persist,
|
|
16
|
+
tree = true,
|
|
17
|
+
context = 3,
|
|
18
|
+
collapse = true,
|
|
19
|
+
granularity = 'word',
|
|
20
|
+
ignoreWhitespace = false,
|
|
21
|
+
ignoreCase = false,
|
|
22
|
+
highlight = true,
|
|
23
|
+
maxLines = 20000,
|
|
24
|
+
label = 'Changed files',
|
|
25
|
+
onviewed
|
|
26
|
+
}: {
|
|
27
|
+
/** The files, each with `old` and `new` text or a `patch`. */
|
|
28
|
+
files?: DiffFileInput[];
|
|
29
|
+
/** A whole `git diff` output; every file in it is listed. Used when `files` is empty. */
|
|
30
|
+
patch?: string;
|
|
31
|
+
/** Paths marked as viewed. Marking a file viewed collapses it; unmarking expands it. */
|
|
32
|
+
viewed?: string[];
|
|
33
|
+
/** Paths whose diff is hidden. */
|
|
34
|
+
collapsed?: string[];
|
|
35
|
+
/** The path of the file last picked in the tree or last navigated to. */
|
|
36
|
+
selected?: string;
|
|
37
|
+
layout?: 'unified' | 'split';
|
|
38
|
+
/** localStorage key that remembers the layout across visits. */
|
|
39
|
+
persist?: string;
|
|
40
|
+
/** Show the changed-files tree beside the list. */
|
|
41
|
+
tree?: boolean;
|
|
42
|
+
context?: number;
|
|
43
|
+
collapse?: boolean;
|
|
44
|
+
granularity?: 'line' | 'word' | 'char';
|
|
45
|
+
ignoreWhitespace?: boolean;
|
|
46
|
+
ignoreCase?: boolean;
|
|
47
|
+
highlight?: boolean;
|
|
48
|
+
maxLines?: number;
|
|
49
|
+
label?: string;
|
|
50
|
+
onviewed?: (path: string, viewed: boolean) => void;
|
|
51
|
+
} = $props();
|
|
52
|
+
|
|
53
|
+
let root: HTMLDivElement;
|
|
54
|
+
const views = $state<Record<string, { goto(where: 'first' | 'last'): boolean; focus(): void } | null | undefined>>({});
|
|
55
|
+
let expanded = $state<string[]>([]);
|
|
56
|
+
|
|
57
|
+
const entries = $derived<FileEntry[]>(files.length ? files.map((f) => entryFromInput(f, { ignoreWhitespace, ignoreCase })) : patch ? entriesFromPatch(patch) : []);
|
|
58
|
+
const sum = $derived(totals(entries));
|
|
59
|
+
const nodes = $derived<TreeNode[]>(buildTree(entries));
|
|
60
|
+
// the tree opens fully whenever the set of files changes
|
|
61
|
+
$effect(() => { expanded = folderIds(nodes); });
|
|
62
|
+
const viewedSet = $derived(new Set(viewed));
|
|
63
|
+
const collapsedSet = $derived(new Set(collapsed));
|
|
64
|
+
const BADGE: Record<DiffStatus, 'success' | 'primary' | 'danger' | 'warning'> = { added: 'success', modified: 'primary', deleted: 'danger', renamed: 'warning' };
|
|
65
|
+
const slug = (p: string) => p.replace(/[^\w-]/g, '_');
|
|
66
|
+
const uid = $props.id();
|
|
67
|
+
|
|
68
|
+
function toggle(path: string) {
|
|
69
|
+
collapsed = collapsedSet.has(path) ? collapsed.filter((p) => p !== path) : [...collapsed, path];
|
|
70
|
+
}
|
|
71
|
+
function setViewed(path: string, on: boolean) {
|
|
72
|
+
viewed = on ? [...viewed.filter((p) => p !== path), path] : viewed.filter((p) => p !== path);
|
|
73
|
+
collapsed = on ? [...collapsed.filter((p) => p !== path), path] : collapsed.filter((p) => p !== path);
|
|
74
|
+
onviewed?.(path, on);
|
|
75
|
+
}
|
|
76
|
+
async function reveal(path: string) {
|
|
77
|
+
selected = path;
|
|
78
|
+
if (collapsedSet.has(path)) collapsed = collapsed.filter((p) => p !== path);
|
|
79
|
+
await tick();
|
|
80
|
+
root.querySelector(`#${uid}-${slug(path)}`)?.scrollIntoView({ block: 'start' });
|
|
81
|
+
}
|
|
82
|
+
function onselect(n: TreeNode) {
|
|
83
|
+
if (!n.children) reveal(n.id);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Moves to the next (or previous) file that has a change to show, starting after (or before) file `i`. */
|
|
87
|
+
function hop(i: number, dir: 'next' | 'prev') {
|
|
88
|
+
for (let j = i + (dir === 'next' ? 1 : -1); j >= 0 && j < entries.length; j += dir === 'next' ? 1 : -1) {
|
|
89
|
+
const v = views[entries[j].path];
|
|
90
|
+
if (v?.goto(dir === 'next' ? 'first' : 'last')) {
|
|
91
|
+
selected = entries[j].path;
|
|
92
|
+
v.focus();
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
// n / p while focus is on the file list or tree (not inside a diff, which handles them itself)
|
|
99
|
+
function onkey(e: KeyboardEvent) {
|
|
100
|
+
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || (e.target as HTMLElement).closest('.dv, input, textarea, select, [contenteditable]')) return;
|
|
101
|
+
if (e.key === 'n') { e.preventDefault(); hop(-1, 'next'); }
|
|
102
|
+
else if (e.key === 'p') { e.preventDefault(); hop(entries.length, 'prev'); }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const KEY = 'truss-code:diff-layout:';
|
|
106
|
+
onMount(() => {
|
|
107
|
+
if (!persist) return;
|
|
108
|
+
try {
|
|
109
|
+
const v = localStorage.getItem(KEY + persist);
|
|
110
|
+
if (v === 'unified' || v === 'split') layout = v;
|
|
111
|
+
} catch { /* storage blocked */ }
|
|
112
|
+
});
|
|
113
|
+
function setLayout(v: string) {
|
|
114
|
+
layout = v === 'split' ? 'split' : 'unified';
|
|
115
|
+
if (persist) {
|
|
116
|
+
try { localStorage.setItem(KEY + persist, layout); } catch { /* storage blocked */ }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
</script>
|
|
120
|
+
|
|
121
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
122
|
+
<div bind:this={root} class="df" role="group" aria-label={label} onkeydown={onkey}>
|
|
123
|
+
<div class="top">
|
|
124
|
+
<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>
|
|
125
|
+
<span class="prog" data-testid="viewed-count">{viewed.filter((p) => entries.some((e) => e.path === p)).length} of {sum.files} viewed</span>
|
|
126
|
+
<span class="grow"></span>
|
|
127
|
+
<SegmentedControl size="sm" label="Diff layout" value={layout} onchange={setLayout} options={[{ value: 'unified', label: 'Unified' }, { value: 'split', label: 'Split' }]} />
|
|
128
|
+
</div>
|
|
129
|
+
<div class="main">
|
|
130
|
+
{#if tree && entries.length}
|
|
131
|
+
<nav class="side" aria-label="Changed files">
|
|
132
|
+
<Tree {nodes} bind:expanded {selected} {onselect} label="Changed files" />
|
|
133
|
+
</nav>
|
|
134
|
+
{/if}
|
|
135
|
+
<div class="list">
|
|
136
|
+
{#each entries as f, i (f.path)}
|
|
137
|
+
{@const open = !collapsedSet.has(f.path)}
|
|
138
|
+
<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}>
|
|
139
|
+
<header class="fh">
|
|
140
|
+
<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)}>
|
|
141
|
+
<Icon name={open ? 'chevron-down' : 'chevron-right'} />
|
|
142
|
+
</Button>
|
|
143
|
+
<Badge intent={BADGE[f.status]} dot={false}>{f.status}</Badge>
|
|
144
|
+
<span class="path">{#if f.oldPath && f.oldPath !== f.path}<span class="old">{f.oldPath}</span> → {/if}<strong>{f.path}</strong></span>
|
|
145
|
+
<span class="stats"><span class="plus">+{f.additions}</span><span class="minus">−{f.deletions}</span></span>
|
|
146
|
+
<Checkbox label="Viewed" checked={viewedSet.has(f.path)} oncheckedchange={(on) => setViewed(f.path, on)} aria-label="Viewed {f.path}" />
|
|
147
|
+
</header>
|
|
148
|
+
{#if open}
|
|
149
|
+
<div id="{uid}-{slug(f.path)}-body" class="body">
|
|
150
|
+
<DiffView
|
|
151
|
+
bind:this={views[f.path]}
|
|
152
|
+
old={f.old}
|
|
153
|
+
new={f.new}
|
|
154
|
+
file={f.file}
|
|
155
|
+
path={f.path}
|
|
156
|
+
bind:layout
|
|
157
|
+
{context}
|
|
158
|
+
{collapse}
|
|
159
|
+
{granularity}
|
|
160
|
+
{ignoreWhitespace}
|
|
161
|
+
{ignoreCase}
|
|
162
|
+
{highlight}
|
|
163
|
+
{maxLines}
|
|
164
|
+
toolbar={false}
|
|
165
|
+
label="Changes"
|
|
166
|
+
onedge={(dir) => hop(i, dir)}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
{/if}
|
|
170
|
+
</section>
|
|
171
|
+
{:else}
|
|
172
|
+
<p class="empty" role="status">No files changed.</p>
|
|
173
|
+
{/each}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<style>
|
|
179
|
+
.df { border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); background: var(--tr-surface); overflow: hidden; }
|
|
180
|
+
.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); }
|
|
181
|
+
.grow { flex: 1; }
|
|
182
|
+
.sum, .stats { display: inline-flex; gap: var(--tr-space-2); align-items: baseline; }
|
|
183
|
+
.prog { color: var(--tr-on-surface-variant); }
|
|
184
|
+
.plus { color: var(--tr-success); font-weight: 600; } .minus { color: var(--tr-danger); font-weight: 600; }
|
|
185
|
+
.main { display: flex; align-items: flex-start; }
|
|
186
|
+
.side { flex: 0 0 16rem; max-width: 40%; position: sticky; top: 0; max-height: 70vh; overflow: auto; border-right: 1px solid var(--tr-outline-variant); padding: var(--tr-space-2); }
|
|
187
|
+
.list { flex: 1; min-width: 0; display: grid; gap: var(--tr-space-3); padding: var(--tr-space-3); }
|
|
188
|
+
.file { border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); overflow: hidden; scroll-margin-top: var(--tr-space-3); }
|
|
189
|
+
.file[data-viewed='true'] { opacity: 0.8; }
|
|
190
|
+
.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); }
|
|
191
|
+
.path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--tr-font-mono); }
|
|
192
|
+
.old { color: var(--tr-on-surface-variant); }
|
|
193
|
+
.body :global(.dv) { border: 0; border-radius: 0; border-top: 1px solid var(--tr-outline-variant); }
|
|
194
|
+
.empty { margin: 0; padding: var(--tr-space-5); text-align: center; color: var(--tr-on-surface-variant); }
|
|
195
|
+
@media (max-width: 720px) { .main { flex-direction: column; } .side { flex-basis: auto; max-width: none; width: 100%; position: static; border-right: 0; border-bottom: 1px solid var(--tr-outline-variant); } }
|
|
196
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type DiffFileInput } from './files.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
/** The files, each with `old` and `new` text or a `patch`. */
|
|
4
|
+
files?: DiffFileInput[];
|
|
5
|
+
/** A whole `git diff` output; every file in it is listed. Used when `files` is empty. */
|
|
6
|
+
patch?: string;
|
|
7
|
+
/** Paths marked as viewed. Marking a file viewed collapses it; unmarking expands it. */
|
|
8
|
+
viewed?: string[];
|
|
9
|
+
/** Paths whose diff is hidden. */
|
|
10
|
+
collapsed?: string[];
|
|
11
|
+
/** The path of the file last picked in the tree or last navigated to. */
|
|
12
|
+
selected?: string;
|
|
13
|
+
layout?: 'unified' | 'split';
|
|
14
|
+
/** localStorage key that remembers the layout across visits. */
|
|
15
|
+
persist?: string;
|
|
16
|
+
/** Show the changed-files tree beside the list. */
|
|
17
|
+
tree?: boolean;
|
|
18
|
+
context?: number;
|
|
19
|
+
collapse?: boolean;
|
|
20
|
+
granularity?: 'line' | 'word' | 'char';
|
|
21
|
+
ignoreWhitespace?: boolean;
|
|
22
|
+
ignoreCase?: boolean;
|
|
23
|
+
highlight?: boolean;
|
|
24
|
+
maxLines?: number;
|
|
25
|
+
label?: string;
|
|
26
|
+
onviewed?: (path: string, viewed: boolean) => void;
|
|
27
|
+
};
|
|
28
|
+
declare const DiffFiles: import("svelte").Component<$$ComponentProps, {}, "collapsed" | "selected" | "layout" | "viewed">;
|
|
29
|
+
type DiffFiles = ReturnType<typeof DiffFiles>;
|
|
30
|
+
export default DiffFiles;
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, tick, type Snippet } from 'svelte';
|
|
3
|
+
import { Button, SegmentedControl } from '@ruckc/truss-ui';
|
|
4
|
+
import { diffLines, annotateIntraline, foldItems, expandFold, splitRows, changeStarts, describeChange, diffStats, type DiffLine, type Expansion, type Item } from './diff.js';
|
|
5
|
+
import { parsePatch, patchLines, type PatchFile } from './patch.js';
|
|
6
|
+
import { highlight, overlay, paletteVars, segmentLine, type Seg } from './highlight.js';
|
|
7
|
+
import { loadMonaco } from './monaco.js';
|
|
8
|
+
import { resolveLanguage } from './lang.js';
|
|
9
|
+
import { readTheme, watchTheme, type Theme } from './theme.js';
|
|
10
|
+
import type { DiffAnnotation } from './types.js';
|
|
11
|
+
|
|
12
|
+
type Fold = Extract<Item, { type: 'fold' }>;
|
|
13
|
+
type Entry = { k: 'fold'; f: Fold } | { k: 'u'; i: number } | { k: 's'; l: number | null; r: number | null };
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
old: oldText = '',
|
|
17
|
+
new: newText = '',
|
|
18
|
+
patch,
|
|
19
|
+
file,
|
|
20
|
+
path,
|
|
21
|
+
language,
|
|
22
|
+
layout = $bindable('unified'),
|
|
23
|
+
persist,
|
|
24
|
+
context = 3,
|
|
25
|
+
collapse = true,
|
|
26
|
+
granularity = 'word',
|
|
27
|
+
ignoreWhitespace = false,
|
|
28
|
+
ignoreCase = false,
|
|
29
|
+
highlight: syntax = true,
|
|
30
|
+
maxLines = 20000,
|
|
31
|
+
label = 'Diff',
|
|
32
|
+
toolbar = true,
|
|
33
|
+
annotations = [],
|
|
34
|
+
annotation,
|
|
35
|
+
lineActions,
|
|
36
|
+
onlayout,
|
|
37
|
+
onedge
|
|
38
|
+
}: {
|
|
39
|
+
/** The original text. */
|
|
40
|
+
old?: string;
|
|
41
|
+
/** The changed text. */
|
|
42
|
+
new?: string;
|
|
43
|
+
/** 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. */
|
|
44
|
+
patch?: string;
|
|
45
|
+
/** An already parsed file, as from `parsePatch`. Wins over `patch`. */
|
|
46
|
+
file?: PatchFile;
|
|
47
|
+
/** File name: sets the language for syntax colours and the accessible name. */
|
|
48
|
+
path?: string;
|
|
49
|
+
/** A Monaco language id; defaults to the language of `path`. */
|
|
50
|
+
language?: string;
|
|
51
|
+
layout?: 'unified' | 'split';
|
|
52
|
+
/** localStorage key that remembers the chosen layout across visits. */
|
|
53
|
+
persist?: string;
|
|
54
|
+
/** Unchanged lines kept around each change. */
|
|
55
|
+
context?: number;
|
|
56
|
+
/** Collapse long unchanged regions (with expand buttons). */
|
|
57
|
+
collapse?: boolean;
|
|
58
|
+
/** What is marked inside a changed line: whole words, single characters, or nothing. */
|
|
59
|
+
granularity?: 'line' | 'word' | 'char';
|
|
60
|
+
ignoreWhitespace?: boolean;
|
|
61
|
+
ignoreCase?: boolean;
|
|
62
|
+
/** Colour the code with Monaco's tokenizers (loaded on demand; only when the language is known). */
|
|
63
|
+
highlight?: boolean;
|
|
64
|
+
/** Above this many lines in `old` plus `new`, a placeholder with a "Show anyway" button replaces the diff. */
|
|
65
|
+
maxLines?: number;
|
|
66
|
+
label?: string;
|
|
67
|
+
/** Show the summary, layout switch and change navigation. */
|
|
68
|
+
toolbar?: boolean;
|
|
69
|
+
/** Inline notes rendered under a line: `{ line, side?: 'old' | 'new' }` plus anything your `annotation` snippet needs. */
|
|
70
|
+
annotations?: DiffAnnotation[];
|
|
71
|
+
annotation?: Snippet<[a: DiffAnnotation]>;
|
|
72
|
+
/** Rendered beside the line numbers of each changed or unchanged line (for example an "add comment" button). */
|
|
73
|
+
lineActions?: Snippet<[info: { line: DiffLine; side: 'old' | 'new'; number: number }]>;
|
|
74
|
+
onlayout?: (layout: 'unified' | 'split') => void;
|
|
75
|
+
/** `n` on the last change or `p` on the first: lets a parent (such as a file list) move on. */
|
|
76
|
+
onedge?: (dir: 'next' | 'prev') => void;
|
|
77
|
+
} = $props();
|
|
78
|
+
|
|
79
|
+
let root: HTMLDivElement;
|
|
80
|
+
let theme = $state.raw<Theme>();
|
|
81
|
+
let expansion = $state.raw<Expansion>({});
|
|
82
|
+
let cur = $state(-1);
|
|
83
|
+
let announce = $state('');
|
|
84
|
+
let forced = $state(false);
|
|
85
|
+
let hl = $state.raw<{ lines: DiffLine[]; lang: string; old: Seg[][]; new: Seg[][] }>();
|
|
86
|
+
|
|
87
|
+
const countLines = (s: string) => (s.match(/\n/g)?.length ?? 0) + (s && !s.endsWith('\n') ? 1 : 0);
|
|
88
|
+
const opts = $derived({ ignoreWhitespace, ignoreCase });
|
|
89
|
+
|
|
90
|
+
const model = $derived.by(() => {
|
|
91
|
+
const f = file ?? (patch !== undefined ? parsePatch(patch)[0] : undefined);
|
|
92
|
+
if (file || patch !== undefined) {
|
|
93
|
+
if (!f) return { lines: [] as DiffLine[], binary: false, oversize: false };
|
|
94
|
+
if (f.binary) return { lines: [] as DiffLine[], binary: true, oversize: false };
|
|
95
|
+
const lines = patchLines(f).map((l) => ({ ...l }));
|
|
96
|
+
return { lines: granularity === 'line' ? lines : annotateIntraline(lines, granularity, opts), binary: false, oversize: false };
|
|
97
|
+
}
|
|
98
|
+
if (!forced && countLines(oldText) + countLines(newText) > maxLines) return { lines: [] as DiffLine[], binary: false, oversize: true };
|
|
99
|
+
const lines = diffLines(oldText, newText, opts);
|
|
100
|
+
return { lines: granularity === 'line' ? lines : annotateIntraline(lines, granularity, opts), binary: false, oversize: false };
|
|
101
|
+
});
|
|
102
|
+
const lines = $derived(model.lines);
|
|
103
|
+
const starts = $derived(changeStarts(lines));
|
|
104
|
+
const stats = $derived(diffStats(lines));
|
|
105
|
+
const items = $derived(foldItems(lines, collapse ? context : Infinity, expansion));
|
|
106
|
+
const lang = $derived(resolveLanguage(language, path ?? file?.newPath ?? file?.oldPath));
|
|
107
|
+
const name = $derived(path ?? file?.newPath ?? file?.oldPath ?? '');
|
|
108
|
+
|
|
109
|
+
const view = $derived.by((): Entry[] => {
|
|
110
|
+
if (layout === 'unified') return items.map((it): Entry => (it.type === 'fold' ? { k: 'fold', f: it } : { k: 'u', i: it.index }));
|
|
111
|
+
const out: Entry[] = [];
|
|
112
|
+
let run: number[] = [];
|
|
113
|
+
const flush = () => {
|
|
114
|
+
for (const r of splitRows(lines, run)) out.push({ k: 's', l: r.left, r: r.right });
|
|
115
|
+
run = [];
|
|
116
|
+
};
|
|
117
|
+
for (const it of items) {
|
|
118
|
+
if (it.type === 'fold') { flush(); out.push({ k: 'fold', f: it }); } else run.push(it.index);
|
|
119
|
+
}
|
|
120
|
+
flush();
|
|
121
|
+
return out;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// a new diff starts with nothing expanded and no current change
|
|
125
|
+
$effect.pre(() => {
|
|
126
|
+
model;
|
|
127
|
+
expansion = {};
|
|
128
|
+
cur = -1;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ───── syntax colours: tokenize the old and the new side as documents, then look lines up by their position in each
|
|
132
|
+
const docs = $derived.by(() => {
|
|
133
|
+
const oldIdx: number[] = [], newIdx: number[] = [], o: string[] = [], n: string[] = [];
|
|
134
|
+
for (const l of lines) {
|
|
135
|
+
oldIdx.push(l.type === 'ctx' || l.type === 'del' ? o.push(l.text) - 1 : -1);
|
|
136
|
+
newIdx.push(l.type === 'ctx' || l.type === 'add' ? n.push(l.text) - 1 : -1);
|
|
137
|
+
}
|
|
138
|
+
return { oldIdx, newIdx, old: o.join('\n'), new: n.join('\n') };
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
$effect(() => {
|
|
142
|
+
const d = docs, language = lang, ls = lines;
|
|
143
|
+
if (!syntax || language === 'plaintext' || !ls.length) return;
|
|
144
|
+
let stale = false;
|
|
145
|
+
loadMonaco().then(
|
|
146
|
+
async (m) => {
|
|
147
|
+
const [o, n] = await Promise.all([highlight(m, d.old, language), highlight(m, d.new, language)]);
|
|
148
|
+
if (!stale) hl = { lines: ls, lang: language, old: o, new: n };
|
|
149
|
+
},
|
|
150
|
+
() => {} // without Monaco the diff stays uncoloured
|
|
151
|
+
);
|
|
152
|
+
return () => { stale = true; };
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const base = (i: number): Seg[] => {
|
|
156
|
+
const l = lines[i];
|
|
157
|
+
const set = hl && hl.lines === lines && hl.lang === lang ? hl : undefined;
|
|
158
|
+
const row = set ? (l.type === 'del' ? set.old[docs.oldIdx[i]] : set.new[docs.newIdx[i]]) : undefined;
|
|
159
|
+
return row ?? segmentLine(l.text, []);
|
|
160
|
+
};
|
|
161
|
+
const pieces = (i: number) => overlay(base(i), lines[i].segs);
|
|
162
|
+
|
|
163
|
+
// ───── layout persistence and theme
|
|
164
|
+
const KEY = 'truss-code:diff-layout:';
|
|
165
|
+
onMount(() => {
|
|
166
|
+
if (persist) {
|
|
167
|
+
try {
|
|
168
|
+
const v = localStorage.getItem(KEY + persist);
|
|
169
|
+
if (v === 'unified' || v === 'split') layout = v;
|
|
170
|
+
} catch { /* storage blocked */ }
|
|
171
|
+
}
|
|
172
|
+
theme = readTheme(root);
|
|
173
|
+
return watchTheme(root, () => (theme = readTheme(root)));
|
|
174
|
+
});
|
|
175
|
+
function setLayout(v: string) {
|
|
176
|
+
const l = v === 'split' ? 'split' : 'unified';
|
|
177
|
+
layout = l;
|
|
178
|
+
if (persist) {
|
|
179
|
+
try { localStorage.setItem(KEY + persist, l); } catch { /* storage blocked */ }
|
|
180
|
+
}
|
|
181
|
+
onlayout?.(l);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ───── navigation
|
|
185
|
+
const range = $derived.by((): [number, number] | undefined => {
|
|
186
|
+
if (cur < 0 || cur >= starts.length) return undefined;
|
|
187
|
+
let e = starts[cur];
|
|
188
|
+
while (e < lines.length && (lines[e].type === 'add' || lines[e].type === 'del')) e++;
|
|
189
|
+
return [starts[cur], e];
|
|
190
|
+
});
|
|
191
|
+
const active = (i: number | null) => i !== null && !!range && i >= range[0] && i < range[1];
|
|
192
|
+
|
|
193
|
+
async function go(n: number) {
|
|
194
|
+
cur = n;
|
|
195
|
+
announce = describeChange(lines, starts, n);
|
|
196
|
+
await tick();
|
|
197
|
+
root.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' });
|
|
198
|
+
}
|
|
199
|
+
/** Moves to the next change; after the last one, `onedge('next')` fires. */
|
|
200
|
+
export function next() { if (cur + 1 < starts.length) go(cur + 1); else onedge?.('next'); }
|
|
201
|
+
/** Moves to the previous change; before the first one, `onedge('prev')` fires. */
|
|
202
|
+
export function prev() { if (cur > 0) go(cur - 1); else onedge?.('prev'); }
|
|
203
|
+
/** Moves to a change by number, or to the `first` or `last` one. Returns whether there was one to move to. */
|
|
204
|
+
export function goto(where: number | 'first' | 'last'): boolean {
|
|
205
|
+
if (!starts.length) return false;
|
|
206
|
+
go(where === 'first' ? 0 : where === 'last' ? starts.length - 1 : Math.max(0, Math.min(starts.length - 1, where)));
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
export const focus = () => root.focus();
|
|
210
|
+
|
|
211
|
+
function onkey(e: KeyboardEvent) {
|
|
212
|
+
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || (e.target as HTMLElement).closest('input, textarea, select, [contenteditable]')) return;
|
|
213
|
+
if (e.key === 'n' || (e.key === 'F7' && !e.shiftKey)) { e.preventDefault(); next(); }
|
|
214
|
+
else if (e.key === 'p' || (e.key === 'F7' && e.shiftKey)) { e.preventDefault(); prev(); }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const notes = (...idx: (number | null)[]) => {
|
|
218
|
+
const seen = new Set<number>();
|
|
219
|
+
const out: DiffAnnotation[] = [];
|
|
220
|
+
for (const i of idx) {
|
|
221
|
+
if (i === null || seen.has(i)) continue;
|
|
222
|
+
seen.add(i);
|
|
223
|
+
const l = lines[i];
|
|
224
|
+
for (const a of annotations) if (a.side === 'old' ? l.type !== 'add' && l.oldNo === a.line : l.type !== 'del' && l.newNo === a.line) out.push(a);
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
};
|
|
228
|
+
const SYM = { add: '+', del: '−', ctx: '', gap: '' } as const;
|
|
229
|
+
const SR = { add: 'added', del: 'removed', ctx: '', gap: '' } as const;
|
|
230
|
+
const EXPAND = 20;
|
|
231
|
+
const grow = (f: Fold, how: 'top' | 'bottom' | 'all') => (expansion = expandFold(expansion, f, how, EXPAND));
|
|
232
|
+
const cols = $derived(layout === 'split' ? 6 : 4);
|
|
233
|
+
const style = $derived((theme ? paletteVars(theme.palette) + `;--dv-font:${theme.metrics.fontSize}px` : ''));
|
|
234
|
+
</script>
|
|
235
|
+
|
|
236
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
237
|
+
<div bind:this={root} class="dv" {style} role="group" aria-label={name ? `${label}: ${name}` : label} tabindex="0" onkeydown={onkey}>
|
|
238
|
+
{#if toolbar}
|
|
239
|
+
<div class="bar">
|
|
240
|
+
<span class="sum" data-testid="summary">
|
|
241
|
+
{#if model.binary}Binary file{:else if model.oversize}Large diff{:else if !starts.length}No changes{:else}{starts.length} {starts.length === 1 ? 'change' : 'changes'}<span class="plus">+{stats.additions}</span><span class="minus">−{stats.deletions}</span>{/if}
|
|
242
|
+
</span>
|
|
243
|
+
<span class="grow"></span>
|
|
244
|
+
<Button variant="minimal" intent="neutral" size="sm" icon="chevron-up" aria-label="Previous change" shortcut="p" disabled={!starts.length} onclick={prev} />
|
|
245
|
+
<Button variant="minimal" intent="neutral" size="sm" icon="chevron-down" aria-label="Next change" shortcut="n" disabled={!starts.length} onclick={next} />
|
|
246
|
+
<SegmentedControl size="sm" label="Diff layout" value={layout} onchange={setLayout} options={[{ value: 'unified', label: 'Unified' }, { value: 'split', label: 'Split' }]} />
|
|
247
|
+
</div>
|
|
248
|
+
{/if}
|
|
249
|
+
<div class="vh" role="status" aria-live="polite">{announce}</div>
|
|
250
|
+
|
|
251
|
+
{#if model.binary}
|
|
252
|
+
<p class="ph">Binary file not shown.</p>
|
|
253
|
+
{:else if model.oversize}
|
|
254
|
+
<div class="ph">
|
|
255
|
+
<p>This diff is large ({countLines(oldText) + countLines(newText)} lines), so it is not drawn until you ask.</p>
|
|
256
|
+
<Button size="sm" variant="outlined" intent="neutral" onclick={() => (forced = true)}>Show anyway</Button>
|
|
257
|
+
</div>
|
|
258
|
+
{:else if !starts.length}
|
|
259
|
+
<p class="ph" role="status">No differences.</p>
|
|
260
|
+
{:else}
|
|
261
|
+
<div class="scroll">
|
|
262
|
+
<table class="tbl" data-layout={layout} aria-label={name ? `Changes in ${name}` : 'Changes'}>
|
|
263
|
+
<tbody>
|
|
264
|
+
{#each view as e, n (n)}
|
|
265
|
+
{#if e.k === 'fold'}
|
|
266
|
+
<tr class="fold"><td colspan={cols}>
|
|
267
|
+
{#if e.f.fixed}
|
|
268
|
+
<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>
|
|
269
|
+
{:else}
|
|
270
|
+
{#if e.f.count > EXPAND}
|
|
271
|
+
<Button size="sm" variant="minimal" intent="neutral" aria-label="Show {EXPAND} more lines from above" onclick={() => grow(e.f, 'top')}>↓ {EXPAND}</Button>
|
|
272
|
+
<Button size="sm" variant="minimal" intent="neutral" aria-label="Show {EXPAND} more lines from below" onclick={() => grow(e.f, 'bottom')}>↑ {EXPAND}</Button>
|
|
273
|
+
{/if}
|
|
274
|
+
<Button size="sm" variant="minimal" intent="neutral" onclick={() => grow(e.f, 'all')}>Expand {e.f.count === 1 ? '1 line' : `${e.f.count} lines`}</Button>
|
|
275
|
+
{/if}
|
|
276
|
+
</td></tr>
|
|
277
|
+
{:else if e.k === 'u'}
|
|
278
|
+
{@const l = lines[e.i]}
|
|
279
|
+
<tr class="row {l.type}" data-i={e.i} data-active={active(e.i)}>
|
|
280
|
+
<td class="no">{#if l.type !== 'add'}{l.oldNo}{#if lineActions}<span class="act">{@render lineActions({ line: l, side: 'old', number: l.oldNo! })}</span>{/if}{/if}</td>
|
|
281
|
+
<td class="no">{#if l.type !== 'del'}{l.newNo}{#if lineActions && l.type === 'add'}<span class="act">{@render lineActions({ line: l, side: 'new', number: l.newNo! })}</span>{/if}{/if}</td>
|
|
282
|
+
<td class="sym" aria-hidden={SR[l.type] ? undefined : 'true'}>{#if SR[l.type]}<span aria-hidden="true">{SYM[l.type]}</span><span class="vh">{SR[l.type]}: </span>{/if}</td>
|
|
283
|
+
<td class="code">{@render code(e.i)}</td>
|
|
284
|
+
</tr>
|
|
285
|
+
{#each notes(e.i) as a}<tr class="note-row"><td colspan="4">{#if annotation}{@render annotation(a)}{/if}</td></tr>{/each}
|
|
286
|
+
{:else}
|
|
287
|
+
<tr class="row split" data-active={active(e.l) || active(e.r)} data-i={e.l ?? e.r}>
|
|
288
|
+
{@render side(e.l, 'old', e.l !== null && lines[e.l].type === 'del' ? 'del' : 'ctx')}
|
|
289
|
+
{@render side(e.r, 'new', e.r !== null && lines[e.r].type === 'add' ? 'add' : 'ctx')}
|
|
290
|
+
</tr>
|
|
291
|
+
{#each notes(e.l, e.r) as a}<tr class="note-row"><td colspan="6">{#if annotation}{@render annotation(a)}{/if}</td></tr>{/each}
|
|
292
|
+
{/if}
|
|
293
|
+
{/each}
|
|
294
|
+
</tbody>
|
|
295
|
+
</table>
|
|
296
|
+
</div>
|
|
297
|
+
{/if}
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{#snippet code(i: number)}
|
|
301
|
+
{#each pieces(i) as p}{#if p.role || p.changed}<span class:chg={p.changed} class={p.role ? `s-${p.role}` : undefined}>{p.text}</span>{:else}{p.text}{/if}{/each}{#if lines[i].noEol}<span class="eof">↵ No newline at end of file</span>{/if}
|
|
302
|
+
{/snippet}
|
|
303
|
+
|
|
304
|
+
{#snippet side(i: number | null, which: 'old' | 'new', kind: 'del' | 'add' | 'ctx')}
|
|
305
|
+
{#if i === null}
|
|
306
|
+
<td class="no filler"></td><td class="sym filler" aria-hidden="true"></td><td class="code filler" data-side={which}></td>
|
|
307
|
+
{:else}
|
|
308
|
+
{@const l = lines[i]}
|
|
309
|
+
{@const num = which === 'old' ? l.oldNo : l.newNo}
|
|
310
|
+
<td class="no {kind}" data-side={which}>{num}{#if lineActions}<span class="act">{@render lineActions({ line: l, side: which, number: num! })}</span>{/if}</td>
|
|
311
|
+
<td class="sym {kind}">{#if SR[kind]}<span aria-hidden="true">{SYM[kind]}</span><span class="vh">{SR[kind]}: </span>{/if}</td>
|
|
312
|
+
<td class="code {kind}" data-side={which}>{@render code(i)}</td>
|
|
313
|
+
{/if}
|
|
314
|
+
{/snippet}
|
|
315
|
+
|
|
316
|
+
<style>
|
|
317
|
+
.dv {
|
|
318
|
+
--add: var(--code-added, color-mix(in srgb, var(--tr-success) 15%, var(--tr-surface))); --add-strong: var(--code-added-strong, color-mix(in srgb, var(--tr-success) 36%, var(--tr-surface)));
|
|
319
|
+
--del: var(--code-removed, color-mix(in srgb, var(--tr-danger) 14%, var(--tr-surface))); --del-strong: var(--code-removed-strong, color-mix(in srgb, var(--tr-danger) 34%, var(--tr-surface)));
|
|
320
|
+
border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); background: var(--code-bg, var(--tr-surface)); color: var(--code-fg, var(--tr-on-surface)); overflow: hidden;
|
|
321
|
+
}
|
|
322
|
+
.dv:focus-visible { outline: none; border-color: var(--tr-primary); box-shadow: var(--tr-focus-ring); }
|
|
323
|
+
.bar { display: flex; align-items: center; gap: var(--tr-gap); padding: var(--tr-space-1) var(--tr-space-2); border-bottom: 1px solid var(--tr-outline-variant); background: var(--tr-surface-container-low); font-size: var(--tr-text-sm); }
|
|
324
|
+
.grow { flex: 1; }
|
|
325
|
+
.sum { display: inline-flex; gap: var(--tr-space-2); align-items: baseline; color: var(--tr-on-surface-variant); }
|
|
326
|
+
.plus { color: var(--tr-success); font-weight: 600; } .minus { color: var(--tr-danger); font-weight: 600; }
|
|
327
|
+
.ph { margin: 0; padding: var(--tr-space-5); text-align: center; color: var(--tr-on-surface-variant); font-size: var(--tr-text-md); display: grid; gap: var(--tr-space-3); justify-items: center; }
|
|
328
|
+
.ph p { margin: 0; }
|
|
329
|
+
.scroll { overflow: auto; }
|
|
330
|
+
.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; }
|
|
331
|
+
.tbl[data-layout='split'] { table-layout: fixed; }
|
|
332
|
+
td { padding: 0 var(--tr-space-2); vertical-align: top; }
|
|
333
|
+
.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; }
|
|
334
|
+
.sym { width: 2ch; text-align: center; user-select: none; font-weight: 700; padding: 0; }
|
|
335
|
+
.code { white-space: pre-wrap; overflow-wrap: anywhere; width: 100%; }
|
|
336
|
+
.tbl[data-layout='split'] .code { width: 50%; }
|
|
337
|
+
.tbl[data-layout='split'] .no { width: 5ch; } .tbl[data-layout='split'] .sym { width: 3ch; }
|
|
338
|
+
tr.add, td.add { background: var(--add); } tr.del, td.del { background: var(--del); }
|
|
339
|
+
.add .sym, .add.sym { color: var(--tr-success); } .del .sym, .del.sym { color: var(--tr-danger); }
|
|
340
|
+
td.filler { background: var(--tr-surface-container); }
|
|
341
|
+
tr.row[data-active='true'] td { box-shadow: inset 0 0 0 100vmax color-mix(in srgb, var(--tr-primary) 10%, transparent); }
|
|
342
|
+
tr.row[data-active='true'] td.no:first-child { box-shadow: inset 3px 0 0 var(--tr-primary), inset 0 0 0 100vmax color-mix(in srgb, var(--tr-primary) 10%, transparent); }
|
|
343
|
+
.add .chg { background: var(--add-strong); border-radius: 2px; } .del .chg { background: var(--del-strong); border-radius: 2px; }
|
|
344
|
+
.split + .split td.code { border-left: 0; }
|
|
345
|
+
td.code[data-side='new'], td.no[data-side='new'] { border-left: 1px solid var(--tr-outline-variant); }
|
|
346
|
+
.eof { margin-left: var(--tr-space-3); color: var(--tr-on-surface-variant); font-family: var(--tr-font-sans); font-style: italic; font-size: 0.9em; user-select: none; }
|
|
347
|
+
.fold 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); }
|
|
348
|
+
.note { color: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); }
|
|
349
|
+
.note-row td { padding: var(--tr-space-2); font-family: var(--tr-font-sans); background: var(--tr-surface-container-low); }
|
|
350
|
+
.act { position: absolute; left: 0; top: 0; opacity: 0; } tr:hover .act, .act:focus-within { opacity: 1; }
|
|
351
|
+
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
352
|
+
.s-keyword { color: var(--code-keyword); } .s-string { color: var(--code-string); } .s-number { color: var(--code-number); }
|
|
353
|
+
.s-type { color: var(--code-type); } .s-function { color: var(--code-function); } .s-variable { color: var(--code-variable); }
|
|
354
|
+
.s-constant { color: var(--code-constant); } .s-operator { color: var(--code-operator); } .s-tag { color: var(--code-tag); }
|
|
355
|
+
.s-attribute { color: var(--code-attribute); } .s-regexp { color: var(--code-regexp); } .s-comment { color: var(--code-comment); font-style: italic; }
|
|
356
|
+
.s-invalid { color: var(--code-invalid); } .s-delimiter { color: var(--code-delimiter); } .s-meta { color: var(--code-meta); }
|
|
357
|
+
</style>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
import { type DiffLine } from './diff.js';
|
|
3
|
+
import { type PatchFile } from './patch.js';
|
|
4
|
+
import type { DiffAnnotation } from './types.js';
|
|
5
|
+
type $$ComponentProps = {
|
|
6
|
+
/** The original text. */
|
|
7
|
+
old?: string;
|
|
8
|
+
/** The changed text. */
|
|
9
|
+
new?: string;
|
|
10
|
+
/** 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. */
|
|
11
|
+
patch?: string;
|
|
12
|
+
/** An already parsed file, as from `parsePatch`. Wins over `patch`. */
|
|
13
|
+
file?: PatchFile;
|
|
14
|
+
/** File name: sets the language for syntax colours and the accessible name. */
|
|
15
|
+
path?: string;
|
|
16
|
+
/** A Monaco language id; defaults to the language of `path`. */
|
|
17
|
+
language?: string;
|
|
18
|
+
layout?: 'unified' | 'split';
|
|
19
|
+
/** localStorage key that remembers the chosen layout across visits. */
|
|
20
|
+
persist?: string;
|
|
21
|
+
/** Unchanged lines kept around each change. */
|
|
22
|
+
context?: number;
|
|
23
|
+
/** Collapse long unchanged regions (with expand buttons). */
|
|
24
|
+
collapse?: boolean;
|
|
25
|
+
/** What is marked inside a changed line: whole words, single characters, or nothing. */
|
|
26
|
+
granularity?: 'line' | 'word' | 'char';
|
|
27
|
+
ignoreWhitespace?: boolean;
|
|
28
|
+
ignoreCase?: boolean;
|
|
29
|
+
/** Colour the code with Monaco's tokenizers (loaded on demand; only when the language is known). */
|
|
30
|
+
highlight?: boolean;
|
|
31
|
+
/** Above this many lines in `old` plus `new`, a placeholder with a "Show anyway" button replaces the diff. */
|
|
32
|
+
maxLines?: number;
|
|
33
|
+
label?: string;
|
|
34
|
+
/** Show the summary, layout switch and change navigation. */
|
|
35
|
+
toolbar?: boolean;
|
|
36
|
+
/** Inline notes rendered under a line: `{ line, side?: 'old' | 'new' }` plus anything your `annotation` snippet needs. */
|
|
37
|
+
annotations?: DiffAnnotation[];
|
|
38
|
+
annotation?: Snippet<[a: DiffAnnotation]>;
|
|
39
|
+
/** Rendered beside the line numbers of each changed or unchanged line (for example an "add comment" button). */
|
|
40
|
+
lineActions?: Snippet<[info: {
|
|
41
|
+
line: DiffLine;
|
|
42
|
+
side: 'old' | 'new';
|
|
43
|
+
number: number;
|
|
44
|
+
}]>;
|
|
45
|
+
onlayout?: (layout: 'unified' | 'split') => void;
|
|
46
|
+
/** `n` on the last change or `p` on the first: lets a parent (such as a file list) move on. */
|
|
47
|
+
onedge?: (dir: 'next' | 'prev') => void;
|
|
48
|
+
};
|
|
49
|
+
declare const DiffView: import("svelte").Component<$$ComponentProps, {
|
|
50
|
+
next: () => void;
|
|
51
|
+
prev: () => void;
|
|
52
|
+
goto: (where: number | "first" | "last") => boolean;
|
|
53
|
+
focus: () => void;
|
|
54
|
+
}, "layout">;
|
|
55
|
+
type DiffView = ReturnType<typeof DiffView>;
|
|
56
|
+
export default DiffView;
|