mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-11-23 20:54:50 +03:00
feat: refactor diff rendering (#4497)
Co-authored-by: Pavel Laptev <pawellaptew@gmail.com>
This commit is contained in:
parent
56b64d7780
commit
9646684f92
@ -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;
|
||||
}
|
@ -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()}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}}
|
||||
/>
|
||||
|
@ -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);
|
||||
|
||||
|
374
apps/desktop/src/lib/hunk/HunkDiff.svelte
Normal file
374
apps/desktop/src/lib/hunk/HunkDiff.svelte
Normal 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>
|
@ -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>
|
@ -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>
|
||||
|
18
apps/desktop/src/lib/hunk/types.ts
Normal file
18
apps/desktop/src/lib/hunk/types.ts
Normal 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[] };
|
@ -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>
|
||||
|
||||
|
199
apps/desktop/src/styles/syntax-highlighting.css
Normal file
199
apps/desktop/src/styles/syntax-highlighting.css
Normal 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;
|
||||
}
|
||||
}
|
@ -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`;
|
||||
}
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user