feat: refactor diff rendering (#4497)

Co-authored-by: Pavel Laptev <pawellaptew@gmail.com>
This commit is contained in:
Nico Domino 2024-07-30 13:27:19 +02:00 committed by GitHub
parent 56b64d7780
commit 9646684f92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 745 additions and 435 deletions

View File

@ -1,171 +0,0 @@
import * as diff from '$lib/diff';
export interface Token {
text: string;
className: string;
}
export interface Row {
originalLineNumber: number;
currentLineNumber: number;
tokens: Token[];
type: RowType;
size: number;
}
export const enum RowType {
Deletion = 'deletion',
Addition = 'addition',
Equal = 'equal',
Spacer = 'spacer'
}
export function buildDiffRows(
diffs: diff.DiffArray,
opts = { paddingLines: 10000 }
): {
originalLines: readonly string[];
currentLines: readonly string[];
rows: readonly Row[];
} {
const { paddingLines } = opts;
let currentLineNumber = 0;
let originalLineNumber = 0;
const originalLines: string[] = [];
const currentLines: string[] = [];
const rows: Row[] = [];
for (let i = 0; i < diffs.length; ++i) {
const token = diffs[i];
switch (token[0]) {
case diff.Operation.Equal:
rows.push(...createEqualRows(token[1], i === 0, i === diffs.length - 1));
originalLines.push(...token[1]);
currentLines.push(...token[1]);
break;
case diff.Operation.Insert:
for (const line of token[1]) {
rows.push(createRow(line, RowType.Addition));
}
currentLines.push(...token[1]);
break;
case diff.Operation.Delete:
originalLines.push(...token[1]);
if (diffs[i + 1] && diffs[i + 1][0] === diff.Operation.Insert) {
i++;
rows.push(...createModifyRows(token[1].join('\n'), diffs[i][1].join('\n')));
currentLines.push(...diffs[i][1]);
} else {
for (const line of token[1]) {
rows.push(createRow(line, RowType.Deletion));
}
}
break;
}
}
return { originalLines, currentLines, rows };
function createEqualRows(lines: string[], atStart: boolean, atEnd: boolean): Row[] {
const equalRows = [];
if (!atStart) {
for (let i = 0; i < paddingLines && i < lines.length; i++) {
equalRows.push(createRow(lines[i], RowType.Equal));
}
if (lines.length > paddingLines * 2 + 1 && !atEnd) {
equalRows.push(
createRow(
`skipping ${lines.length - paddingLines * 2} matching lines`,
RowType.Spacer,
lines.length - paddingLines * 2
)
);
}
}
if (!atEnd) {
const start = Math.max(lines.length - paddingLines - 1, atStart ? 0 : paddingLines);
let skip = lines.length - paddingLines - 1;
if (!atStart) {
skip -= paddingLines;
}
if (skip > 0) {
originalLineNumber += skip;
currentLineNumber += skip;
}
for (let i = start; i < lines.length; i++) {
equalRows.push(createRow(lines[i], RowType.Equal));
}
}
return equalRows;
}
function createModifyRows(before: string, after: string): Row[] {
const internalDiff = diff.char(before, after, true /* cleanup diff */);
const deletionRows = [createRow('', RowType.Deletion)];
const insertionRows = [createRow('', RowType.Addition)];
for (const token of internalDiff) {
const text = token[1];
const type = token[0];
const className = type === diff.Operation.Equal ? '' : 'inner-diff';
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
if (i > 0 && type !== diff.Operation.Insert) {
deletionRows.push(createRow('', RowType.Deletion));
}
if (i > 0 && type !== diff.Operation.Delete) {
insertionRows.push(createRow('', RowType.Addition));
}
if (!lines[i]) {
continue;
}
if (type !== diff.Operation.Insert) {
deletionRows[deletionRows.length - 1].tokens.push({
text: lines[i],
className
});
}
if (type !== diff.Operation.Delete) {
insertionRows[insertionRows.length - 1].tokens.push({
text: lines[i],
className
});
}
}
}
return deletionRows.concat(insertionRows);
}
function createRow(text: string, type: RowType, size = 1): Row {
if (type === RowType.Addition) {
currentLineNumber++;
}
if (type === RowType.Deletion) {
originalLineNumber++;
}
if (type === RowType.Equal) {
originalLineNumber++;
currentLineNumber++;
}
return {
originalLineNumber,
currentLineNumber,
tokens: text ? [{ text, className: 'inner-diff' }] : [],
type,
size
};
}
}
export function documentMap(lines: readonly string[]): Map<number, number> {
const map = new Map<number, number>();
for (let pos = 0, lineNo = 0; lineNo < lines.length; lineNo++) {
map.set(lineNo + 1, pos);
pos += lines[lineNo].length + 1;
}
return map;
}

View File

@ -1,10 +1,10 @@
<script lang="ts">
import { clickOutside } from '$lib/clickOutside';
import { createKeybind } from '$lib/utils/hotkeys';
import { portal } from '$lib/utils/portal';
import { resizeObserver } from '$lib/utils/resizeObserver';
import { type Snippet } from 'svelte';
// TYPES AND INTERFACES
interface Props {
target?: HTMLElement;
openByMouse?: boolean;
@ -25,26 +25,24 @@
onopen
}: Props = $props();
// LOCAL VARS
// STATES
let item = $state<any>();
let contextMenuHeight = $state(0);
let contextMenuWidth = $state(0);
let isVisibile = $state(false);
let isVisible = $state(false);
let menuPosition = $state({ x: 0, y: 0 });
// METHODS
export function close() {
isVisibile = false;
isVisible = false;
onclose && onclose();
}
export function open(e?: MouseEvent, newItem?: any) {
if (!target) return;
if (newItem) item = newItem;
isVisibile = true;
if (newItem) {
item = newItem;
}
isVisible = true;
onopen && onopen();
if (!openByMouse) {
@ -60,7 +58,7 @@
}
export function toggle(e?: MouseEvent, newItem?: any) {
if (!isVisibile) {
if (!isVisible) {
open(e, newItem);
} else {
close();
@ -113,8 +111,18 @@
return 'top left';
}
}
const handleKeyDown = createKeybind({
Escape: () => {
if (isVisible) {
close();
}
}
});
</script>
<svelte:window on:keydown={handleKeyDown} />
{#snippet contextMenu()}
<div
use:clickOutside={{
@ -138,7 +146,7 @@
</div>
{/snippet}
{#if isVisibile}
{#if isVisible}
<div class="portal-wrap" use:portal={'body'}>
{#if openByMouse}
{@render contextMenu()}

View File

@ -7,16 +7,27 @@
import { BranchController } from '$lib/vbranches/branchController';
import type { AnyFile } from '$lib/vbranches/types';
export let file: AnyFile;
export let conflicted: boolean;
export let isUnapplied: boolean;
export let selectable = false;
export let readonly = false;
export let isCard = true;
interface Props {
file: AnyFile;
conflicted: boolean;
isUnapplied: boolean;
selectable?: boolean;
readonly?: boolean;
isCard?: boolean;
}
let {
file,
conflicted,
isUnapplied,
selectable = false,
readonly = false,
isCard = true
}: Props = $props();
const branchController = getContext(BranchController);
let sections: (HunkSection | ContentSection)[] = [];
let sections: (HunkSection | ContentSection)[] = $state([]);
function parseFile(file: AnyFile) {
// When we toggle expansion status on sections we need to assign
@ -24,11 +35,13 @@
// variable.
if (!file.binary && !file.large) sections = parseFileSections(file);
}
$: parseFile(file);
$effect(() => parseFile(file));
$: isFileLocked = sections
.filter((section): section is HunkSection => section instanceof HunkSection)
.some((section) => section.hunk.locked);
const isFileLocked = $derived(
sections
.filter((section): section is HunkSection => section instanceof HunkSection)
.some((section) => section.hunk.locked)
);
</script>
<div id={`file-${file.id}`} class="file-card" class:card={isCard}>
@ -37,7 +50,7 @@
<div class="mb-2 bg-red-500 px-2 py-0 font-bold text-white">
<button
class="font-bold text-white"
on:click={async () => await branchController.markResolved(file.path)}
onclick={async () => await branchController.markResolved(file.path)}
>
Mark resolved
</button>

View File

@ -8,31 +8,46 @@
import { tooltip } from '@gitbutler/ui/utils/tooltip';
import type { HunkSection, ContentSection } from '$lib/utils/fileSections';
export let filePath: string;
export let isBinary: boolean;
export let isLarge: boolean;
export let sections: (HunkSection | ContentSection)[];
export let isUnapplied: boolean;
export let selectable = false;
export let isFileLocked = false;
export let readonly: boolean = false;
interface Props {
filePath: string;
isBinary: boolean;
isLarge: boolean;
sections: (HunkSection | ContentSection)[];
isUnapplied: boolean;
selectable: boolean;
isFileLocked: boolean;
readonly: boolean;
}
$: maxLineNumber = sections[sections.length - 1]?.maxLineNumber;
$: minWidth = getGutterMinWidth(maxLineNumber);
let {
filePath,
isBinary,
isLarge,
sections,
isUnapplied,
selectable = false,
isFileLocked = false,
readonly = false
}: Props = $props();
let alwaysShow = $state(false);
const localCommits = isFileLocked ? getLocalCommits() : undefined;
const remoteCommits = isFileLocked ? getLocalAndRemoteCommits() : undefined;
const commits = isFileLocked ? ($localCommits || []).concat($remoteCommits || []) : undefined;
let alwaysShow = false;
function getGutterMinWidth(max: number) {
function getGutterMinWidth(max: number | undefined) {
if (!max) {
return 1;
}
if (max >= 10000) return 2.5;
if (max >= 1000) return 2;
if (max >= 100) return 1.5;
if (max >= 10) return 1.25;
return 1;
}
const maxLineNumber = $derived(sections.at(-1)?.maxLineNumber);
const minWidth = $derived(getGutterMinWidth(maxLineNumber));
</script>
<div class="hunks">
@ -43,7 +58,7 @@
{:else if sections.length > 50 && !alwaysShow}
<LargeDiffMessage
showFrame
on:show={() => {
handleShow={() => {
alwaysShow = true;
}}
/>

View File

@ -7,10 +7,14 @@
import { BranchController } from '$lib/vbranches/branchController';
import { open as openFile } from '@tauri-apps/api/shell';
export let target: HTMLElement;
export let filePath: string;
export let projectPath: string | undefined;
export let readonly: boolean;
interface Props {
target: HTMLElement | undefined;
filePath: string;
projectPath: string | undefined;
readonly: boolean;
}
let { target, filePath, projectPath, readonly }: Props = $props();
const branchController = getContext(BranchController);

View File

@ -0,0 +1,374 @@
<script lang="ts">
import { type Row, Operation, type DiffRows } from './types';
import Icon from '$lib/shared/Icon.svelte';
import Scrollbar from '$lib/shared/Scrollbar.svelte';
import { create } from '$lib/utils/codeHighlight';
import { maybeGetContextStore } from '$lib/utils/context';
import { type ContentSection, SectionType, type Line } from '$lib/utils/fileSections';
import { Ownership } from '$lib/vbranches/ownership';
import { type Hunk } from '$lib/vbranches/types';
import diff_match_patch from 'diff-match-patch';
import type { Writable } from 'svelte/store';
interface Props {
hunk: Hunk;
readonly: boolean;
filePath: string;
selectable: boolean;
subsections: ContentSection[];
tabSize: number;
minWidth: number;
draggingDisabled: boolean;
onclick: () => void;
handleSelected: (hunk: Hunk, isSelected: boolean) => void;
handleLineContextMenu: ({
event,
lineNumber,
hunk,
subsection
}: {
event: MouseEvent;
lineNumber: number;
hunk: Hunk;
subsection: ContentSection;
}) => void;
}
const {
hunk,
readonly = false,
filePath,
selectable,
subsections,
tabSize,
minWidth,
draggingDisabled = false,
onclick,
handleSelected,
handleLineContextMenu
}: Props = $props();
let viewport = $state<HTMLDivElement>();
let contents = $state<HTMLDivElement>();
const WHITESPACE_REGEX = /\s/;
const NUMBER_COLUMN_WIDTH_PX = minWidth * 16;
const selectedOwnership: Writable<Ownership> | undefined = maybeGetContextStore(Ownership);
const selected = $derived($selectedOwnership?.contains(hunk.filePath, hunk.id) ?? false);
let isSelected = $derived(selectable && selected);
function charDiff(text1: string, text2: string): { 0: number; 1: string }[] {
const differ = new diff_match_patch();
const diff = differ.diff_main(text1, text2);
differ.diff_cleanupSemantic(diff);
return diff;
}
function isLineEmpty(lines: Line[]) {
const whitespaceRegex = new RegExp(WHITESPACE_REGEX);
if (!lines[0].content.match(whitespaceRegex)) {
return true;
}
return false;
}
function createRowData(section: ContentSection): Row[] {
return section.lines.map((line) => {
if (line.content === '') {
// Add extra \n for empty lines for correct copy/pasting output
line.content = '\n';
}
return {
beforeLineNumber: line.beforeLineNumber,
afterLineNumber: line.afterLineNumber,
tokens: toTokens(line.content),
type: section.sectionType,
size: line.content.length
};
});
}
function toTokens(inputLine: string): string[] {
function sanitize(text: string) {
var element = document.createElement('div');
element.innerText = text;
return element.innerHTML;
}
let highlighter = create(inputLine, filePath);
let tokens: string[] = [];
highlighter.highlight((text, classNames) => {
const token = classNames
? `<span data-no-drag class=${classNames}>${sanitize(text)}</span>`
: sanitize(text);
tokens.push(token);
});
return tokens;
}
function computeWordDiff(prevSection: ContentSection, nextSection: ContentSection): DiffRows {
const numberOfLines = nextSection.lines.length;
const returnRows: DiffRows = {
prevRows: [],
nextRows: []
};
// Loop through every line in the section
// We're only bothered with prev/next sections with equal # of lines changes
for (let i = 0; i < numberOfLines; i++) {
const oldLine = prevSection.lines[i];
const newLine = nextSection.lines[i];
const prevSectionRow = {
beforeLineNumber: oldLine.beforeLineNumber,
afterLineNumber: oldLine.afterLineNumber,
tokens: [] as string[],
type: prevSection.sectionType,
size: oldLine.content.length
};
const nextSectionRow = {
beforeLineNumber: newLine.beforeLineNumber,
afterLineNumber: newLine.afterLineNumber,
tokens: [] as string[],
type: nextSection.sectionType,
size: newLine.content.length
};
const diff = charDiff(oldLine.content, newLine.content);
for (const token of diff) {
const text = token[1];
const type = token[0];
if (type === Operation.Equal) {
prevSectionRow.tokens.push(...toTokens(text));
nextSectionRow.tokens.push(...toTokens(text));
} else if (type === Operation.Insert) {
nextSectionRow.tokens.push(`<span data-no-drag class="token-inserted">${text}</span>`);
} else if (type === Operation.Delete) {
prevSectionRow.tokens.push(`<span data-no-drag class="token-deleted">${text}</span>`);
}
}
returnRows.nextRows.push(nextSectionRow);
returnRows.prevRows.push(prevSectionRow);
}
return returnRows;
}
function generateRows(subsections: ContentSection[]) {
return subsections.reduce((acc, nextSection, i) => {
const prevSection = subsections[i - 1];
// Filter out section for which we don't need to compute word diffs
if (!prevSection || nextSection.sectionType === SectionType.Context) {
acc.push(...createRowData(nextSection));
return acc;
}
if (prevSection.sectionType === SectionType.Context) {
acc.push(...createRowData(nextSection));
return acc;
}
if (prevSection.lines.length !== nextSection.lines.length) {
acc.push(...createRowData(nextSection));
return acc;
}
if (isLineEmpty(prevSection.lines)) {
acc.push(...createRowData(nextSection));
return acc;
}
const { prevRows, nextRows } = computeWordDiff(prevSection, nextSection);
// Insert returned row datastructures into the correct place
// Find and replace previous rows with tokenized version
prevRows.forEach((row) => {
const accIndex = acc.findIndex(
(accRow) =>
accRow.beforeLineNumber === row.beforeLineNumber &&
accRow.afterLineNumber === row.afterLineNumber
);
if (!accIndex) return;
acc[accIndex] = row;
});
acc.push(...nextRows);
return acc;
}, [] as Row[]);
}
const renderRows = $derived(generateRows(subsections));
</script>
<div
class="table__wrapper hide-native-scrollbar"
bind:this={viewport}
style="--tab-size: {tabSize}; --cursor: {draggingDisabled ? 'default' : 'grab'}"
>
{#if !draggingDisabled}
<div class="table__drag-handle">
<Icon name="draggable-narrow" />
</div>
{/if}
<table bind:this={contents} data-hunk-id={hunk.id} class="table__section">
<tbody>
{#each renderRows as line}
<tr data-no-drag>
<td
class="table__numberColumn"
style="--number-col-width: {NUMBER_COLUMN_WIDTH_PX}px;"
align="center"
class:selected={isSelected}
onclick={() => {
selectable && handleSelected(hunk, !isSelected);
}}
>
{line.beforeLineNumber}
</td>
<td
class="table__numberColumn"
style="--number-col-width: {NUMBER_COLUMN_WIDTH_PX}px;"
align="center"
class:selected={isSelected}
onclick={() => {
selectable && handleSelected(hunk, !isSelected);
}}
>
{line.afterLineNumber}
</td>
<td
{onclick}
class="table__textContent"
style="--tab-size: {tabSize};"
class:readonly
data-no-drag
class:diff-line-deletion={line.type === SectionType.RemovedLines}
class:diff-line-addition={line.type === SectionType.AddedLines}
oncontextmenu={(event) => {
const lineNumber = (line.beforeLineNumber
? line.beforeLineNumber
: line.afterLineNumber) as number;
handleLineContextMenu({ event, hunk, lineNumber, subsection: subsections[0] });
}}
>
{@html line.tokens.join('')}
</td>
</tr>
{/each}
</tbody>
</table>
<Scrollbar {viewport} {contents} horz padding={{ left: NUMBER_COLUMN_WIDTH_PX * 2 + 2 }} />
</div>
<style>
.table__wrapper {
border: 1px solid var(--clr-border-2);
border-radius: var(--radius-s);
overflow-x: auto;
&:hover .table__drag-handle {
transform: translateY(0) translateX(0) scale(1);
opacity: 1;
pointer-events: auto;
}
}
.table__drag-handle {
position: absolute;
cursor: grab;
top: 6px;
right: 6px;
background-color: var(--clr-bg-1);
border: 1px solid var(--clr-border-2);
display: flex;
justify-content: center;
align-items: center;
padding: 4px 2px;
border-radius: var(--radius-s);
opacity: 0;
transform: translateY(10%) translateX(-10%) scale(0.9);
transform-origin: top right;
pointer-events: none;
transition:
opacity 0.2s,
transform 0.2s;
}
.table__section {
border-spacing: 0;
width: 100%;
font-family: monospace;
}
.table__numberColumn {
color: var(--clr-text-3);
border-color: var(--clr-border-2);
background-color: var(--clr-bg-1-muted);
font-size: 11px;
padding-left: 4px;
padding-right: 4px;
text-align: right;
cursor: var(--cursor);
user-select: none;
position: sticky;
width: var(--number-col-width);
min-width: var(--number-col-width);
max-width: var(--number-col-width);
left: calc(var(--number-col-width) + 1px);
box-shadow: 1px 0px 0px 0px var(--clr-border-2);
&.selected {
background-color: var(--hunk-line-selected-bg);
border-color: var(--hunk-line-selected-border);
color: white;
}
}
.table__numberColumn:first-of-type {
width: var(--number-col-width);
min-width: var(--number-col-width);
max-width: var(--number-col-width);
left: 0px;
}
tr:first-of-type .table__numberColumn:first-child {
border-radius: var(--radius-s) 0 0 0;
}
tr:last-of-type .table__numberColumn:first-child {
border-radius: 0 0 0 var(--radius-s);
}
.diff-line-deletion {
background-color: #cf8d8e20;
}
.diff-line-addition {
background-color: #94cf8d20;
}
.table__textContent {
width: 100%;
font-size: 12px;
padding-left: 4px;
line-height: 1.25;
tab-size: var(--tab-size);
white-space: pre;
user-select: text;
&:hover {
cursor: text;
}
}
</style>

View File

@ -1,144 +0,0 @@
<script lang="ts">
import { create } from '$lib/components/Differ/CodeHighlighter';
import { SectionType } from '$lib/utils/fileSections';
import { createEventDispatcher } from 'svelte';
import type { Line } from '$lib/utils/fileSections';
export let lines: Line[];
export let sectionType: SectionType;
export let filePath: string;
export let minWidth = 1.75;
export let selectable: boolean = false;
export let selected: boolean = true;
export let readonly: boolean = false;
export let draggingDisabled: boolean = false;
export let tabSize = 4;
const dispatch = createEventDispatcher<{
lineContextMenu: { lineNumber: number | undefined; event: MouseEvent };
click: void;
selected: boolean;
}>();
function toTokens(inputLine: string): string[] {
function sanitize(text: string) {
var element = document.createElement('div');
element.innerText = text;
return element.innerHTML;
}
let highlighter = create(inputLine, filePath);
let tokens: string[] = [];
highlighter.highlight((text, classNames) => {
const token = classNames
? `<span class=${classNames}>${sanitize(text)}</span>`
: sanitize(text);
tokens.push(token);
});
return tokens;
}
$: isSelected = selectable && selected;
</script>
<div
class="line-wrapper"
style="--tab-size: {tabSize}; --minwidth: {minWidth}rem; --cursor: {draggingDisabled
? 'default'
: 'grab'}"
>
{#each lines as line}
<div
tabindex="-1"
role="none"
class="code-line"
on:click={() => dispatch('click')}
on:contextmenu={(event) => {
const lineNumber = line.afterLineNumber ? line.afterLineNumber : line.beforeLineNumber;
dispatch('lineContextMenu', { event, lineNumber });
}}
>
<div class="code-line__numbers-line">
<button
on:click={() => selectable && dispatch('selected', !selected)}
class="numbers-line-count"
class:selected={isSelected}
>
{line.beforeLineNumber || ''}
</button>
<button
on:click={() => selectable && dispatch('selected', !selected)}
class="numbers-line-count"
class:selected={isSelected}
>
{line.afterLineNumber || ''}
</button>
</div>
<div
class="line"
class:readonly
class:diff-line-deletion={sectionType === SectionType.RemovedLines}
class:diff-line-addition={sectionType === SectionType.AddedLines}
>
<span class="selectable-wrapper" data-no-drag>
{@html toTokens(line.content).join('')}
</span>
</div>
</div>
{/each}
</div>
<style lang="postcss">
.code-line {
display: flex;
width: 100%;
min-width: max-content;
font-family: var(--mono-font-family);
background-color: var(--clr-bg-1);
white-space: pre;
tab-size: var(--tab-size);
font-size: 11px;
line-height: 1.5;
}
.line {
flex-grow: 1;
cursor: var(--cursor);
}
.code-line__numbers-line {
position: sticky;
left: 0;
display: flex;
}
.numbers-line-count {
color: var(--clr-text-3);
border-color: var(--clr-border-2);
background-color: var(--clr-bg-1-muted);
font-size: 10px;
flex-shrink: 0;
user-select: none;
border-right-width: 1px;
padding-left: 2px;
padding-right: 2px;
text-align: right;
min-width: var(--minwidth);
cursor: var(--cursor);
&.selected {
background-color: var(--hunk-line-selected-bg);
border-color: var(--hunk-line-selected-border);
color: white;
}
}
.selectable-wrapper {
cursor: text;
display: inline-block;
text-indent: 4px;
margin-right: 4px;
}
</style>

View File

@ -1,36 +1,48 @@
<script lang="ts">
import HunkDiff from './HunkDiff.svelte';
import { Project } from '$lib/backend/projects';
import { draggableElement } from '$lib/dragging/draggable';
import { DraggableHunk } from '$lib/dragging/draggables';
import HunkContextMenu from '$lib/hunk/HunkContextMenu.svelte';
import HunkLines from '$lib/hunk/HunkLines.svelte';
import { SETTINGS, type Settings } from '$lib/settings/userSettings';
import LargeDiffMessage from '$lib/shared/LargeDiffMessage.svelte';
import Scrollbar from '$lib/shared/Scrollbar.svelte';
import { getContext, getContextStoreBySymbol, maybeGetContextStore } from '$lib/utils/context';
import { type HunkSection } from '$lib/utils/fileSections';
import { Ownership } from '$lib/vbranches/ownership';
import { VirtualBranch, type Hunk } from '$lib/vbranches/types';
import type { HunkSection } from '$lib/utils/fileSections';
import type { Writable } from 'svelte/store';
export let filePath: string;
export let section: HunkSection;
export let minWidth: number;
export let selectable = false;
export let isUnapplied: boolean;
export let isFileLocked: boolean;
export let readonly: boolean = false;
export let linesModified: number;
interface Props {
filePath: string;
section: HunkSection;
selectable: boolean;
isUnapplied: boolean;
isFileLocked: boolean;
readonly: boolean;
minWidth: number;
linesModified: number;
}
let {
filePath,
section,
linesModified,
selectable = false,
isUnapplied,
isFileLocked,
minWidth,
readonly = false
}: Props = $props();
const selectedOwnership: Writable<Ownership> | undefined = maybeGetContextStore(Ownership);
const userSettings = getContextStoreBySymbol<Settings>(SETTINGS);
const branch = maybeGetContextStore(VirtualBranch);
const project = getContext(Project);
let viewport: HTMLDivElement;
let contents: HTMLDivElement;
let contextMenu: HunkContextMenu;
let alwaysShow = false;
let alwaysShow = $state(false);
let viewport = $state<HTMLDivElement>();
let contextMenu = $state<HunkContextMenu>();
const draggingDisabled = $derived(readonly || isUnapplied);
function onHunkSelected(hunk: Hunk, isSelected: boolean) {
if (!selectedOwnership) return;
@ -40,7 +52,6 @@
selectedOwnership.update((ownership) => ownership.remove(hunk.filePath, hunk.id));
}
}
$: draggingDisabled = readonly || isUnapplied;
</script>
<HunkContextMenu
@ -53,55 +64,47 @@
<div class="scrollable">
<div
bind:this={viewport}
tabindex="0"
role="cell"
bind:this={viewport}
class="hunk"
class:opacity-60={section.hunk.locked && !isFileLocked}
oncontextmenu={(e) => e.preventDefault()}
use:draggableElement={{
data: new DraggableHunk($branch?.id || '', section.hunk, section.hunk.lockedTo),
disabled: draggingDisabled
}}
on:contextmenu|preventDefault
class="hunk hide-native-scrollbar"
class:readonly
class:opacity-60={section.hunk.locked && !isFileLocked}
>
<div bind:this={contents} class="hunk__bg-stretch">
{#if linesModified > 2500 && !alwaysShow}
<LargeDiffMessage
on:show={() => {
alwaysShow = true;
}}
/>
{:else}
{#each section.subSections as subsection}
{@const hunk = section.hunk}
<HunkLines
lines={subsection.lines}
{filePath}
{readonly}
{minWidth}
{selectable}
{draggingDisabled}
tabSize={$userSettings.tabSize}
selected={$selectedOwnership?.contains(hunk.filePath, hunk.id)}
on:selected={(e) => onHunkSelected(hunk, e.detail)}
sectionType={subsection.sectionType}
on:click={() => {
contextMenu.close();
}}
on:lineContextMenu={(e) => {
contextMenu.open(e.detail.event, {
hunk,
section: subsection,
lineNumber: e.detail.lineNumber
});
}}
/>
{/each}
{/if}
</div>
{#if linesModified > 2500 && !alwaysShow}
<LargeDiffMessage
handleShow={() => {
alwaysShow = true;
}}
/>
{:else}
<HunkDiff
{readonly}
{filePath}
{minWidth}
{selectable}
{draggingDisabled}
tabSize={$userSettings.tabSize}
hunk={section.hunk}
onclick={() => {
contextMenu?.close();
}}
subsections={section.subSections}
handleSelected={(hunk, isSelected) => onHunkSelected(hunk, isSelected)}
handleLineContextMenu={({ event, lineNumber, hunk, subsection }) => {
contextMenu?.open(event, {
hunk,
section: subsection,
lineNumber: lineNumber
});
}}
/>
{/if}
</div>
<Scrollbar {viewport} {contents} horz />
</div>
<style lang="postcss">
@ -109,24 +112,12 @@
display: flex;
flex-direction: column;
position: relative;
border-radius: var(--radius-s);
overflow: hidden;
}
.hunk {
display: flex;
flex-direction: column;
overflow-x: auto;
user-select: text;
background: var(--clr-bg-1);
border-radius: var(--radius-s);
border: 1px solid var(--clr-border-2);
transition: border-color var(--transition-fast);
}
.hunk__bg-stretch {
width: 100%;
min-width: max-content;
user-select: text;
overflow-x: auto;
will-change: transform;
}
</style>

View File

@ -0,0 +1,18 @@
import { SectionType } from '$lib/utils/fileSections';
export interface Row {
beforeLineNumber?: number;
afterLineNumber?: number;
tokens: string[];
type: SectionType;
size: number;
}
export enum Operation {
Equal = 0,
Insert = 1,
Delete = -1,
Edit = 2
}
export type DiffRows = { prevRows: Row[]; nextRows: Row[] };

View File

@ -1,13 +1,15 @@
<script lang="ts">
import Button from '$lib/shared/Button.svelte';
import { createEventDispatcher } from 'svelte';
export let showFrame = false;
interface Props {
showFrame?: boolean;
handleShow: () => void;
}
const dispatch = createEventDispatcher();
const { handleShow, showFrame = false }: Props = $props();
function show() {
dispatch('show');
handleShow();
}
</script>

View File

@ -0,0 +1,199 @@
.token-variable {
color: #8953800;
}
.token-property {
color: #0550ae;
}
.token-type {
color: #116329;
}
.token-variable-special {
color: #953800;
}
.token-definition {
color: #953800;
}
/* .token-builtin {
color: #d3869b;
} */
.token-number {
color: #0550ae;
}
.token-string {
color: #0550ae;
}
.token-string-special {
color: #0a3069;
}
/* .token-atom {
color: #0a3069;
} */
.token-keyword {
color: #cf222e;
}
.token-comment {
color: #6e7781;
}
.token-meta {
color: #1f2328;
}
.token-invalid {
color: #82071e;
}
.token-tag {
color: #116329;
}
.token-attribute {
color: #1f2328;
}
.token-attribute-value {
color: var(--color-token-attribute-value);
}
.token-inserted {
color: #116329;
background-color: #11632960;
}
.token-deleted {
color: #82071e;
background-color: #82071e40;
}
.token-heading {
color: var(--color-token-variable-special);
}
.token-link {
color: var(--color-token-variable-special);
text-decoration: underline;
}
.token-strikethrough {
text-decoration: strike-through;
}
.token-strong {
font-weight: bold;
}
.token-emphasis {
font-style: italic;
}
.dark {
.token-variable {
color: #79c0ff;
}
.token-property {
color: #79c0ff;
}
.token-type {
color: #7ee787;
}
.token-variable-special {
color: #79c0ff;
}
.token-definition {
color: #ffa657;
}
/* .token-builtin {
color: #d3869b;
} */
.token-number {
color: #a5d6ff;
}
.token-string {
color: #79c0ff;
}
.token-string-special {
color: #a5d6ff;
}
/* .token-atom {
color: #0a3069;
} */
.token-keyword {
color: #ff7b72;
}
.token-comment {
color: #8b949e;
}
.token-meta {
color: #ffa657;
}
.token-invalid {
color: #ffa198;
}
.token-tag {
color: #7ee787;
}
.token-attribute {
color: #e6edf3;
}
.token-attribute-value {
color: var(--color-token-attribute-value);
}
.token-inserted {
color: #7ee787;
background-color: #7ee78740;
}
.token-deleted {
color: #ffa198;
background-color: #ffa19840;
}
.token-heading {
color: var(--color-token-variable-special);
}
.token-link {
color: var(--color-token-variable-special);
text-decoration: underline;
}
.token-strikethrough {
text-decoration: strike-through;
}
.token-strong {
font-weight: bold;
}
.token-emphasis {
font-style: italic;
}
}

View File

@ -1,4 +1,3 @@
export function pxToRem(px: number | undefined, base: number = 16) {
if (!px) return;
export function pxToRem(px: number, base: number = 16) {
return `${px / base}rem`;
}

View File

@ -61,15 +61,16 @@
.token-inserted {
color: #116329;
background-color: #11632960;
}
.token-deleted {
color: #82071e;
background-color: #82071e40;
}
.token-heading {
color: var(--color-token-variable-special);
font-weight: bold;
}
.token-link {
@ -153,15 +154,16 @@
.dark .token-inserted {
color: #7ee787;
background-color: #7ee78740;
}
.dark .token-deleted {
color: #ffa198;
background-color: #ffa19840;
}
.dark .token-heading {
color: var(--color-token-variable-special);
font-weight: bold;
}
.dark .token-link {