Merge branch 'master' into keyboard-shortcuts

This commit is contained in:
estib 2024-09-11 15:50:07 +02:00
parent c3273c10fe
commit b8c60adc64
18 changed files with 615 additions and 200 deletions

View File

@ -19,7 +19,7 @@
createRemoteCommitsContextStore
} from '$lib/vbranches/contexts';
import { FileIdSelection } from '$lib/vbranches/fileIdSelection';
import { Ownership } from '$lib/vbranches/ownership';
import { SelectedOwnership } from '$lib/vbranches/ownership';
import { RemoteFile, VirtualBranch } from '$lib/vbranches/types';
import lscache from 'lscache';
import { setContext } from 'svelte';
@ -55,12 +55,15 @@
// BRANCH
const branchStore = createContextStore(VirtualBranch, branch);
const ownershipStore = createContextStore(Ownership, Ownership.fromBranch(branch));
const selectedOwnershipStore = createContextStore(
SelectedOwnership,
SelectedOwnership.fromBranch(branch)
);
const branchFiles = writable(branch.files);
$effect(() => {
branchStore.set(branch);
ownershipStore.set(Ownership.fromBranch(branch));
selectedOwnershipStore.update((o) => o?.update(branch));
branchFiles.set(branch.files);
});

View File

@ -4,7 +4,7 @@
import { getContext, getContextStore } from '$lib/utils/context';
import { intersectionObserver } from '$lib/utils/intersectionObserver';
import { BranchController } from '$lib/vbranches/branchController';
import { Ownership } from '$lib/vbranches/ownership';
import { SelectedOwnership } from '$lib/vbranches/ownership';
import { VirtualBranch } from '$lib/vbranches/types';
import Button from '@gitbutler/ui/Button.svelte';
import { slideFade } from '@gitbutler/ui/utils/transitions';
@ -15,7 +15,7 @@
export let hasSectionsAfter: boolean;
const branchController = getContext(BranchController);
const selectedOwnership = getContextStore(Ownership);
const selectedOwnership = getContextStore(SelectedOwnership);
const branch = getContextStore(VirtualBranch);
const runCommitHooks = projectRunCommitHooks(projectId);
@ -93,7 +93,8 @@
outline={!$expanded}
grow
loading={isCommitting}
disabled={(isCommitting || !commitMessageValid || $selectedOwnership.isEmpty()) && $expanded}
disabled={(isCommitting || !commitMessageValid || $selectedOwnership.nothingSelected()) &&
$expanded}
id="commit-to-branch"
onclick={() => {
if ($expanded) {

View File

@ -19,7 +19,7 @@
import { KeyName } from '$lib/utils/hotkeys';
import { resizeObserver } from '$lib/utils/resizeObserver';
import { isWhiteSpaceString } from '$lib/utils/string';
import { Ownership } from '$lib/vbranches/ownership';
import { SelectedOwnership } from '$lib/vbranches/ownership';
import { VirtualBranch, LocalFile } from '$lib/vbranches/types';
import Checkbox from '@gitbutler/ui/Checkbox.svelte';
import Icon from '@gitbutler/ui/Icon.svelte';
@ -35,7 +35,7 @@
export let cancel: () => void;
const user = getContextStore(User);
const selectedOwnership = getContextStore(Ownership);
const selectedOwnership = getContextStore(SelectedOwnership);
const aiService = getContext(AIService);
const branch = getContextStore(VirtualBranch);
const project = getContext(Project);
@ -73,7 +73,7 @@
async function generateCommitMessage(files: LocalFile[]) {
const hunks = files.flatMap((f) =>
f.hunks.filter((h) => $selectedOwnership.contains(f.id, h.id))
f.hunks.filter((h) => $selectedOwnership.isSelected(f.id, h.id))
);
// Branches get their names generated only if there are at least 4 lines of code
// If the change is a 'one-liner', the branch name is either left as "virtual branch"

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { maybeGetContextStore } from '$lib/utils/context';
import { Ownership } from '$lib/vbranches/ownership';
import { SelectedOwnership } from '$lib/vbranches/ownership';
import Badge from '@gitbutler/ui/Badge.svelte';
import Checkbox from '@gitbutler/ui/Checkbox.svelte';
import type { AnyFile } from '$lib/vbranches/types';
@ -10,27 +10,30 @@
export let files: AnyFile[];
export let showCheckboxes = false;
const selectedOwnership: Writable<Ownership> | undefined = maybeGetContextStore(Ownership);
const selectedOwnership: Writable<SelectedOwnership> | undefined =
maybeGetContextStore(SelectedOwnership);
function selectAll(files: AnyFile[]) {
if (!selectedOwnership) return;
files.forEach((f) => selectedOwnership.update((ownership) => ownership.add(f.id, ...f.hunks)));
files.forEach((f) =>
selectedOwnership.update((ownership) => ownership.select(f.id, ...f.hunks))
);
}
function isAllChecked(selectedOwnership: Ownership | undefined): boolean {
function isAllChecked(selectedOwnership: SelectedOwnership | undefined): boolean {
if (!selectedOwnership) return false;
return files.every((f) => f.hunks.every((h) => selectedOwnership.contains(f.id, h.id)));
return files.every((f) => f.hunks.every((h) => selectedOwnership.isSelected(f.id, h.id)));
}
function isIndeterminate(selectedOwnership: Ownership | undefined): boolean {
function isIndeterminate(selectedOwnership: SelectedOwnership | undefined): boolean {
if (!selectedOwnership) return false;
if (files.length <= 1) return false;
let file = files[0] as AnyFile;
let prev = selectedOwnership.contains(file.id, ...file.hunkIds);
let prev = selectedOwnership.isSelected(file.id, ...file.hunkIds);
for (let i = 1; i < files.length; i++) {
file = files[i] as AnyFile;
const contained = selectedOwnership.contains(file.id, ...file.hunkIds);
const contained = selectedOwnership.isSelected(file.id, ...file.hunkIds);
if (contained !== prev) {
return true;
}
@ -49,12 +52,13 @@
small
{checked}
{indeterminate}
style={indeterminate ? 'neutral' : 'default'}
onchange={(e: Event & { currentTarget: EventTarget & HTMLInputElement; }) => {
const isChecked = e.currentTarget.checked;
if (isChecked) {
selectAll(files);
} else {
selectedOwnership?.update((ownership) => ownership.clear());
selectedOwnership?.update((ownership) => ownership.clearSelection());
}
}}
/>

View File

@ -3,6 +3,7 @@
import FileListItem from './FileListItem.svelte';
import LazyloadContainer from '$lib/shared/LazyloadContainer.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import { chunk } from '$lib/utils/array';
import { copyToClipboard } from '$lib/utils/clipboard';
import { getContext } from '$lib/utils/context';
import { KeyName } from '$lib/utils/hotkeys';
@ -15,50 +16,44 @@
import type { AnyFile } from '$lib/vbranches/types';
import type { Writable } from 'svelte/store';
export let files: AnyFile[];
export let isUnapplied = false;
export let showCheckboxes = false;
export let allowMultiple = false;
export let readonly = false;
export let commitDialogExpanded: Writable<boolean> | undefined = undefined;
const MERGE_DIFF_COMMAND = 'git diff-tree --cc ';
interface Props {
files: AnyFile[];
isUnapplied?: boolean;
showCheckboxes?: boolean;
allowMultiple?: boolean;
readonly?: boolean;
commitDialogExpanded: Writable<boolean> | undefined;
}
const {
files,
isUnapplied = false,
showCheckboxes = false,
allowMultiple = false,
readonly = false,
commitDialogExpanded = undefined
}: Props = $props();
const fileIdSelection = getContext(FileIdSelection);
const commit = getCommitStore();
function chunk<T>(arr: T[], size: number) {
return Array.from({ length: Math.ceil(arr.length / size) }, (_v, i) =>
arr.slice(i * size, i * size + size)
);
}
let chunkedFiles: AnyFile[][] = $derived(chunk(sortLikeFileTree(files), 100));
let currentDisplayIndex = $state(0);
let displayedFiles: AnyFile[] = $derived(chunkedFiles.slice(0, currentDisplayIndex + 1).flat());
let chunkedFiles: AnyFile[][] = [];
let displayedFiles: AnyFile[] = [];
let currentDisplayIndex = 0;
function setFiles(files: AnyFile[]) {
chunkedFiles = chunk(sortLikeFileTree(files), 100);
displayedFiles = chunkedFiles[0] || [];
currentDisplayIndex = 0;
}
// Make sure we display when the file list is reset
$: setFiles(files);
function startCommit() {
function startCommit() {
if (commitDialogExpanded === undefined) return;
if (!$commitDialogExpanded) {
$commitDialogExpanded = true;
}
}
export function loadMore() {
function loadMore() {
if (currentDisplayIndex + 1 >= chunkedFiles.length) return;
currentDisplayIndex += 1;
const currentChunkedFiles = chunkedFiles[currentDisplayIndex] ?? [];
displayedFiles = [...displayedFiles, ...currentChunkedFiles];
}
let mergeDiffCommand = 'git diff-tree --cc ';
</script>
{#if !$commit?.isMergeCommit()}
@ -70,12 +65,12 @@
GitHub, or run the following command in your project directory:
</p>
<div class="command">
<TextBox value={mergeDiffCommand + $commit.id.slice(0, 7)} wide readonly />
<TextBox value={MERGE_DIFF_COMMAND + $commit.id.slice(0, 7)} wide readonly />
<Button
icon="copy"
style="ghost"
outline
onmousedown={() => copyToClipboard(mergeDiffCommand + $commit.id.slice(0, 7))}
onmousedown={() => copyToClipboard(MERGE_DIFF_COMMAND + $commit.id.slice(0, 7))}
/>
</div>
</div>

View File

@ -2,12 +2,13 @@
import FileContextMenu from './FileContextMenu.svelte';
import { draggableChips } from '$lib/dragging/draggable';
import { DraggableFile } from '$lib/dragging/draggables';
import { itemsSatisfy } from '$lib/utils/array';
import { getContext, maybeGetContextStore } from '$lib/utils/context';
import { computeFileStatus } from '$lib/utils/fileStatus';
import { getLocalCommits, getLocalAndRemoteCommits } from '$lib/vbranches/contexts';
import { getCommitStore } from '$lib/vbranches/contexts';
import { FileIdSelection } from '$lib/vbranches/fileIdSelection';
import { Ownership } from '$lib/vbranches/ownership';
import { SelectedOwnership } from '$lib/vbranches/ownership';
import { getLockText } from '$lib/vbranches/tooltip';
import { VirtualBranch, type AnyFile, LocalFile } from '$lib/vbranches/types';
import FileListItem from '@gitbutler/ui/file/FileListItem.svelte';
@ -28,7 +29,8 @@
$props();
const branch = maybeGetContextStore(VirtualBranch);
const selectedOwnership: Writable<Ownership> | undefined = maybeGetContextStore(Ownership);
const selectedOwnership: Writable<SelectedOwnership> | undefined =
maybeGetContextStore(SelectedOwnership);
const fileIdSelection = getContext(FileIdSelection);
const commit = getCommitStore();
@ -45,27 +47,20 @@
const selectedFiles = fileIdSelection.files;
let contextMenu: FileContextMenu;
let lastCheckboxDetail = true;
let draggableEl: HTMLDivElement | undefined = $state();
let checked = $state(false);
let indeterminate = $state(false);
const draggable = !readonly && !isUnapplied;
$effect(() => {
if (!lastCheckboxDetail) {
selectedOwnership?.update((ownership) => {
file.hunks.forEach((h) => ownership.remove(file.id, h.id));
return ownership;
});
}
});
$effect(() => {
if (file && $selectedOwnership) {
checked =
file.hunks.every((hunk) => $selectedOwnership?.contains(file.id, hunk.id)) &&
lastCheckboxDetail;
const hunksContained = itemsSatisfy(file.hunks, (h) =>
$selectedOwnership?.isSelected(file.id, h.id)
);
checked = hunksContained === 'all';
indeterminate = hunksContained === 'some';
}
});
@ -101,6 +96,7 @@
{selected}
{showCheckbox}
{checked}
{indeterminate}
{draggable}
{onclick}
{onkeydown}
@ -108,12 +104,11 @@
{lockText}
oncheck={(e) => {
const isChecked = e.currentTarget.checked;
lastCheckboxDetail = isChecked;
selectedOwnership?.update((ownership) => {
if (isChecked) {
file.hunks.forEach((h) => ownership.add(file.id, h));
file.hunks.forEach((h) => ownership.select(file.id, h));
} else {
file.hunks.forEach((h) => ownership.remove(file.id, h.id));
file.hunks.forEach((h) => ownership.ignore(file.id, h.id));
}
return ownership;
});
@ -123,14 +118,14 @@
if (isChecked) {
files.forEach((f) => {
selectedOwnership?.update((ownership) => {
f.hunks.forEach((h) => ownership.add(f.id, h));
f.hunks.forEach((h) => ownership.select(f.id, h));
return ownership;
});
});
} else {
files.forEach((f) => {
selectedOwnership?.update((ownership) => {
f.hunks.forEach((h) => ownership.remove(f.id, h.id));
f.hunks.forEach((h) => ownership.ignore(f.id, h.id));
return ownership;
});
});

View File

@ -4,9 +4,15 @@
import ScrollableContainer from '$lib/scroll/ScrollableContainer.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 ContentSection,
SectionType,
type Line,
CountColumnSide
} from '$lib/utils/fileSections';
import { SelectedOwnership } from '$lib/vbranches/ownership';
import { type Hunk } from '$lib/vbranches/types';
import Checkbox from '@gitbutler/ui/Checkbox.svelte';
import Icon from '@gitbutler/ui/Icon.svelte';
import diff_match_patch from 'diff-match-patch';
import type { Writable } from 'svelte/store';
@ -52,9 +58,12 @@
const WHITESPACE_REGEX = /\s/;
const NUMBER_COLUMN_WIDTH_PX = minWidth * 20;
const selectedOwnership: Writable<Ownership> | undefined = maybeGetContextStore(Ownership);
const selectedOwnership: Writable<SelectedOwnership> | undefined =
maybeGetContextStore(SelectedOwnership);
const selected = $derived($selectedOwnership?.contains(hunk.filePath, hunk.id) ?? false);
let tableWidth = $state<number>(0);
const selected = $derived($selectedOwnership?.isSelected(hunk.filePath, hunk.id) ?? false);
let isSelected = $derived(selectable && selected);
const inlineUnifiedDiffs = featureInlineUnifiedDiffs();
@ -87,7 +96,8 @@
afterLineNumber: line.afterLineNumber,
tokens: toTokens(line.content),
type: section.sectionType,
size: line.content.length
size: line.content.length,
isLast: false
};
});
}
@ -129,14 +139,16 @@
afterLineNumber: oldLine.afterLineNumber,
tokens: [] as string[],
type: prevSection.sectionType,
size: oldLine.content.length
size: oldLine.content.length,
isLast: false
};
const nextSectionRow = {
beforeLineNumber: newLine.beforeLineNumber,
afterLineNumber: newLine.afterLineNumber,
tokens: [] as string[],
type: nextSection.sectionType,
size: newLine.content.length
size: newLine.content.length,
isLast: false
};
const diff = charDiff(oldLine.content, newLine.content);
@ -181,7 +193,8 @@
afterLineNumber: newLine.afterLineNumber,
tokens: [] as string[],
type: nextSection.sectionType,
size: newLine.content.length
size: newLine.content.length,
isLast: false
};
const diff = charDiff(oldLine.content, newLine.content);
@ -209,7 +222,7 @@
}
function generateRows(subsections: ContentSection[]) {
return subsections.reduce((acc, nextSection, i) => {
const rows = subsections.reduce((acc, nextSection, i) => {
const prevSection = subsections[i - 1];
// Filter out section for which we don't need to compute word diffs
@ -254,59 +267,130 @@
return acc;
}
}, [] as Row[]);
const last = rows.at(-1);
if (last) {
last.isLast = true;
}
return rows;
}
const renderRows = $derived(generateRows(subsections));
interface DiffHunkLineInfo {
beforLineStart: number;
beforeLineCount: number;
afterLineStart: number;
afterLineCount: number;
}
function getHunkLineInfo(subsections: ContentSection[]): DiffHunkLineInfo {
const firstSection = subsections[0];
const lastSection = subsections.at(-1);
const beforLineStart = firstSection?.lines[0]?.beforeLineNumber ?? 0;
const beforeLineEnd = lastSection?.lines?.at(-1)?.beforeLineNumber ?? 0;
const beforeLineCount = beforeLineEnd - beforLineStart + 1;
const afterLineStart = firstSection?.lines[0]?.afterLineNumber ?? 0;
const afterLineEnd = lastSection?.lines?.at(-1)?.afterLineNumber ?? 0;
const afterLineCount = afterLineEnd - afterLineStart + 1;
return {
beforLineStart,
beforeLineCount,
afterLineStart,
afterLineCount
};
}
const hunkLineInfo = $derived(getHunkLineInfo(subsections));
</script>
{#snippet countColumn(count: number | undefined, lineType: SectionType)}
{#snippet countColumn(row: Row, side: CountColumnSide)}
<td
class="table__numberColumn"
class:diff-line-deletion={lineType === SectionType.RemovedLines}
class:diff-line-addition={lineType === SectionType.AddedLines}
class:diff-line-deletion={row.type === SectionType.RemovedLines}
class:diff-line-addition={row.type === SectionType.AddedLines}
style="--number-col-width: {NUMBER_COLUMN_WIDTH_PX}px;"
align="center"
class:is-last={row.isLast}
class:is-before={side === CountColumnSide.Before}
class:selected={isSelected}
onclick={() => {
selectable && handleSelected(hunk, !isSelected);
}}
>
{count}
{side === CountColumnSide.Before ? row.beforeLineNumber : row.afterLineNumber}
</td>
{/snippet}
<div
bind:clientWidth={tableWidth}
class="table__wrapper hide-native-scrollbar"
style="--tab-size: {tabSize}; --cursor: {draggingDisabled ? 'default' : 'grab'}"
>
<ScrollableContainer horz padding={{ left: NUMBER_COLUMN_WIDTH_PX * 2 + 2 }}>
{#if !draggingDisabled}
<div class="table__drag-handle">
<Icon name="draggable-narrow" />
</div>
{/if}
<table data-hunk-id={hunk.id} class="table__section">
<thead>
<tr>
<th
class="table__checkbox-container"
class:selected={isSelected}
colspan={2}
onclick={() => {
selectable && handleSelected(hunk, !isSelected);
}}
>
<div class="table__checkbox">
<Checkbox
checked={isSelected}
style="blue"
onclick={() => {
selectable && handleSelected(hunk, !isSelected);
}}
/>
</div>
<div
class="table__title"
style="--number-col-width: {NUMBER_COLUMN_WIDTH_PX}px; --table-width: {tableWidth}px"
>
<p class="table__title-content text-12">
{`@@ -${hunkLineInfo.beforLineStart},${hunkLineInfo.beforeLineCount} +${hunkLineInfo.afterLineStart},${hunkLineInfo.afterLineCount} @@`}
</p>
{#if !draggingDisabled}
<div class="table__drag-handle">
<Icon name="draggable-narrow" />
</div>
{/if}
</div>
</th>
<th class="table__title-container"> </th>
</tr>
</thead>
<tbody>
{#each renderRows as line}
{#each renderRows as row}
<tr data-no-drag>
{@render countColumn(line.beforeLineNumber, line.type)}
{@render countColumn(line.afterLineNumber, line.type)}
{@render countColumn(row, CountColumnSide.Before)}
{@render countColumn(row, CountColumnSide.After)}
<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}
class:diff-line-deletion={row.type === SectionType.RemovedLines}
class:diff-line-addition={row.type === SectionType.AddedLines}
class:is-last={row.isLast}
oncontextmenu={(event) => {
const lineNumber = (line.beforeLineNumber
? line.beforeLineNumber
: line.afterLineNumber) as number;
const lineNumber = (row.beforeLineNumber
? row.beforeLineNumber
: row.afterLineNumber) as number;
handleLineContextMenu({ event, hunk, lineNumber, subsection: subsections[0] as ContentSection });
}}
>
{@html line.tokens.join('')}
{@html row.tokens.join('')}
</td>
</tr>
{/each}
@ -317,7 +401,6 @@
<style>
.table__wrapper {
border: 1px solid var(--clr-border-2);
border-radius: var(--radius-s);
background-color: var(--clr-diff-line-bg);
overflow-x: auto;
@ -330,16 +413,12 @@
}
.table__drag-handle {
position: absolute;
box-sizing: border-box;
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);
@ -350,10 +429,72 @@
transform 0.2s;
}
table,
.table__section {
border-spacing: 0;
width: 100%;
font-family: monospace;
border-collapse: separate;
border-spacing: 0;
}
thead {
width: 100%;
padding: 0;
}
th,
td,
tr {
padding: 0;
margin: 0;
}
table thead th {
top: 0;
left: 0;
position: sticky;
}
.table__checkbox-container {
/* border: 1px solid var(--clr-border-2); */
z-index: var(--z-lifted);
box-shadow: inset 0 0 0 1px var(--clr-border-2);
background-color: var(--clr-diff-count-bg);
border-top-left-radius: var(--radius-s);
box-sizing: border-box;
&.selected {
background-color: var(--clr-diff-selected-count-bg);
border-color: var(--clr-diff-selected-count-border);
box-shadow: inset 0 0 0 1px var(--clr-diff-selected-count-border);
}
}
.table__checkbox {
padding: 4px 6px;
display: flex;
align-items: center;
}
.table__title {
position: absolute;
top: 0;
left: calc(var(--number-col-width) * 2);
width: calc(var(--table-width) - var(--number-col-width) * 2);
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--clr-border-2);
border-right: 1px solid var(--clr-border-2);
border-bottom: 1px solid var(--clr-border-2);
border-top-right-radius: var(--radius-s);
}
.table__title-content {
padding: 4px 6px;
text-wrap: nowrap;
color: var(--clr-text-2);
}
.table__numberColumn {
@ -390,6 +531,19 @@
background-color: var(--clr-diff-selected-count-bg);
box-shadow: inset -1px 0 0 0 var(--clr-diff-selected-count-border);
color: var(--clr-diff-selected-count-text);
border-color: var(--clr-diff-selected-count-border);
}
&.is-last {
border-bottom-width: 1px;
}
&.is-before {
border-left-width: 1px;
}
&.is-before.is-last {
border-bottom-left-radius: var(--radius-s);
}
}
@ -397,9 +551,22 @@
width: var(--number-col-width);
min-width: var(--number-col-width);
left: 0px;
&.diff-line-addition {
box-shadow: inset -1px 0 0 0 var(--clr-diff-addition-count-border);
}
&.diff-line-deletion {
box-shadow: inset -1px 0 0 0 var(--clr-diff-deletion-count-border);
}
&.selected {
box-shadow: inset -1px 0 0 0 var(--clr-diff-selected-count-border);
}
}
.table__textContent {
z-index: var(--z-lifted);
width: 100%;
font-size: 12px;
padding-left: 4px;
@ -408,5 +575,12 @@
white-space: pre;
user-select: text;
cursor: text;
border-right: 1px solid var(--clr-border-2);
&.is-last {
box-shadow: inset 0 -1px 0 0 var(--clr-border-2);
border-bottom-right-radius: var(--radius-s);
}
}
</style>

View File

@ -8,7 +8,7 @@
import LargeDiffMessage from '$lib/shared/LargeDiffMessage.svelte';
import { getContext, getContextStoreBySymbol, maybeGetContextStore } from '$lib/utils/context';
import { type HunkSection } from '$lib/utils/fileSections';
import { Ownership } from '$lib/vbranches/ownership';
import { SelectedOwnership } from '$lib/vbranches/ownership';
import { VirtualBranch, type Hunk } from '$lib/vbranches/types';
import type { Writable } from 'svelte/store';
@ -36,7 +36,8 @@
readonly = false
}: Props = $props();
const selectedOwnership: Writable<Ownership> | undefined = maybeGetContextStore(Ownership);
const selectedOwnership: Writable<SelectedOwnership> | undefined =
maybeGetContextStore(SelectedOwnership);
const userSettings = getContextStoreBySymbol<Settings>(SETTINGS);
const branch = maybeGetContextStore(VirtualBranch);
const project = getContext(Project);
@ -49,9 +50,9 @@
function onHunkSelected(hunk: Hunk, isSelected: boolean) {
if (!selectedOwnership) return;
if (isSelected) {
selectedOwnership.update((ownership) => ownership.add(hunk.filePath, hunk));
selectedOwnership.update((ownership) => ownership.select(hunk.filePath, hunk));
} else {
selectedOwnership.update((ownership) => ownership.remove(hunk.filePath, hunk.id));
selectedOwnership.update((ownership) => ownership.ignore(hunk.filePath, hunk.id));
}
}
</script>

View File

@ -6,6 +6,7 @@ export interface Row {
tokens: string[];
type: SectionType;
size: number;
isLast: boolean;
}
export enum Operation {

View File

@ -12,7 +12,7 @@
*/
import LazyloadContainer from '$lib/shared/LazyloadContainer.svelte';
import { chunk } from '$lib/utils/chunk';
import { chunk } from '$lib/utils/array';
import { type Snippet } from 'svelte';
interface Props {

View File

@ -0,0 +1,30 @@
type ItemsSatisfyResult = 'all' | 'some' | 'none';
export function itemsSatisfy<T>(arr: T[], predicate: (item: T) => boolean): ItemsSatisfyResult {
let satisfyCount = 0;
let offenseCount = 0;
for (const item of arr) {
if (predicate(item)) {
satisfyCount++;
continue;
}
offenseCount++;
}
if (satisfyCount === 0) {
return 'none';
}
if (offenseCount === 0) {
return 'all';
}
return 'some';
}
export function chunk<T>(arr: T[], size: number) {
return Array.from({ length: Math.ceil(arr.length / size) }, (_v, i) =>
arr.slice(i * size, i * size + size)
);
}

View File

@ -1,5 +0,0 @@
export function chunk<T>(arr: T[], size: number) {
return Array.from({ length: Math.ceil(arr.length / size) }, (_v, i) =>
arr.slice(i * size, i * size + size)
);
}

View File

@ -21,6 +21,11 @@ export enum SectionType {
Context
}
export enum CountColumnSide {
Before,
After
}
export class HunkSection {
hunk!: Hunk;
header!: HunkHeader;

View File

@ -23,48 +23,149 @@ export type FilePath = string;
export type HunkClaims = Map<HunkId, AnyHunk>;
export type FileClaims = Map<FilePath, HunkClaims>;
export class Ownership {
function branchFilesToClaims(files: AnyFile[]): FileClaims {
const selection = new Map<FilePath, HunkClaims>();
for (const file of files) {
const existingFile = selection.get(file.id);
if (existingFile) {
file.hunks.forEach((hunk) => existingFile.set(hunk.id, hunk));
continue;
}
selection.set(
file.id,
file.hunks.reduce((acc, hunk) => {
return acc.set(hunk.id, hunk);
}, new Map<string, AnyHunk>())
);
}
return selection;
}
function selectAddedClaims(
branch: VirtualBranch,
previousState: SelectedOwnershipState,
selection: Map<string, HunkClaims>
) {
for (const file of branch.files) {
const existingFile = previousState.claims.get(file.id);
if (!existingFile) {
// Select newly added files
selection.set(
file.id,
file.hunks.reduce((acc, hunk) => {
return acc.set(hunk.id, hunk);
}, new Map<string, AnyHunk>())
);
continue;
}
for (const hunk of file.hunks) {
const existingHunk = existingFile.get(hunk.id);
if (!existingHunk) {
// Select newly added hunks
const existingFile = selection.get(file.id);
if (existingFile) {
existingFile.set(hunk.id, hunk);
} else {
selection.set(file.id, new Map([[hunk.id, hunk]]));
}
}
}
}
}
function ignoreRemovedClaims(
previousState: SelectedOwnershipState,
branch: VirtualBranch,
selection: Map<string, HunkClaims>
) {
for (const [fileId, hunkClaims] of previousState.selection.entries()) {
const branchFile = branch.files.find((f) => f.id === fileId);
if (branchFile) {
for (const hunkId of hunkClaims.keys()) {
const branchHunk = branchFile.hunks.find((h) => h.id === hunkId);
if (branchHunk) {
// Re-select hunks that are still present in the branch
const existingFile = selection.get(fileId);
if (existingFile) {
existingFile.set(hunkId, branchHunk);
} else {
selection.set(fileId, new Map([[hunkId, branchHunk]]));
}
}
}
}
}
}
interface SelectedOwnershipState {
claims: FileClaims;
selection: FileClaims;
}
function getState(
branch: VirtualBranch,
previousState?: SelectedOwnershipState
): SelectedOwnershipState {
const claims = branchFilesToClaims(branch.files);
if (previousState !== undefined) {
const selection = new Map<FilePath, HunkClaims>();
selectAddedClaims(branch, previousState, selection);
ignoreRemovedClaims(previousState, branch, selection);
return { selection, claims };
}
return { selection: claims, claims };
}
export class SelectedOwnership {
private claims: FileClaims;
private selection: FileClaims;
constructor(state: SelectedOwnershipState) {
this.claims = state.claims;
this.selection = state.selection;
}
static fromBranch(branch: VirtualBranch) {
const files = branch.files.reduce((acc, file) => {
const existing = acc.get(file.id);
if (existing) {
file.hunks.forEach((hunk) => existing.set(hunk.id, hunk));
} else {
acc.set(
file.id,
file.hunks.reduce((acc2, hunk) => {
return acc2.set(hunk.id, hunk);
}, new Map<string, AnyHunk>())
);
}
return acc;
}, new Map<FilePath, Map<HunkId, AnyHunk>>());
const ownership = new Ownership(files);
const state = getState(branch);
const ownership = new SelectedOwnership(state);
return ownership;
}
constructor(files: FileClaims) {
this.claims = files;
update(branch: VirtualBranch) {
const { selection, claims } = getState(branch, {
claims: this.claims,
selection: this.selection
});
this.claims = claims;
this.selection = selection;
return this;
}
remove(fileId: string, ...hunkIds: string[]) {
const claims = this.claims;
if (!claims) return this;
ignore(fileId: string, ...hunkIds: string[]) {
const selection = this.selection;
if (!selection) return this;
hunkIds.forEach((hunkId) => {
claims.get(fileId)?.delete(hunkId);
if (claims.get(fileId)?.size === 0) claims.delete(fileId);
selection.get(fileId)?.delete(hunkId);
if (selection.get(fileId)?.size === 0) selection.delete(fileId);
});
return this;
}
add(fileId: string, ...items: AnyHunk[]) {
const claim = this.claims.get(fileId);
if (claim) {
items.forEach((hunk) => claim.set(hunk.id, hunk));
select(fileId: string, ...items: AnyHunk[]) {
const selectedFile = this.selection.get(fileId);
if (selectedFile) {
items.forEach((hunk) => selectedFile.set(hunk.id, hunk));
} else {
this.claims.set(
this.selection.set(
fileId,
items.reduce((acc, hunk) => {
return acc.set(hunk.id, hunk);
@ -74,17 +175,17 @@ export class Ownership {
return this;
}
contains(fileId: string, ...hunkIds: string[]): boolean {
return hunkIds.every((hunkId) => !!this.claims.get(fileId)?.has(hunkId));
isSelected(fileId: string, ...hunkIds: string[]): boolean {
return hunkIds.every((hunkId) => !!this.selection.get(fileId)?.has(hunkId));
}
clear() {
this.claims.clear();
clearSelection() {
this.selection.clear();
return this;
}
toString() {
return Array.from(this.claims.entries())
return Array.from(this.selection.entries())
.map(
([fileId, hunkMap]) =>
fileId +
@ -98,7 +199,7 @@ export class Ownership {
.join('\n');
}
isEmpty() {
return this.claims.size === 0;
nothingSelected() {
return this.selection.size === 0;
}
}

View File

@ -1,4 +1,5 @@
<script lang="ts" module>
export type CheckboxStyle = 'default' | 'blue' | 'neutral';
export interface CheckboxProps {
name?: string;
small?: boolean;
@ -6,6 +7,7 @@
checked?: boolean;
value?: string;
indeterminate?: boolean;
style?: CheckboxStyle;
onclick?: (e: MouseEvent) => void;
onchange?: (
e: Event & {
@ -25,6 +27,7 @@
checked = $bindable(),
value = '',
indeterminate = false,
style = 'default',
onclick,
onchange
}: CheckboxProps = $props();
@ -46,7 +49,7 @@
onchange?.(e);
}}
type="checkbox"
class="checkbox"
class={`checkbox ${style}`}
class:small
{value}
id={name}
@ -90,23 +93,19 @@
border-color: none;
}
&:indeterminate {
background-color: var(--clr-bg-2);
/* indeterminate */
&::before {
content: '';
position: absolute;
width: 50%;
height: 2px;
background-color: var(--clr-scale-ntrl-30);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
&:indeterminate::before {
content: '';
position: absolute;
width: 50%;
height: 2px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* checked */
&:checked {
&.default:indeterminate {
background-color: var(--clr-theme-pop-element);
box-shadow: inset 0 0 0 1px var(--clr-theme-pop-element);
@ -114,6 +113,38 @@
background-color: var(--clr-theme-pop-element-hover);
}
&::before {
background-color: white;
}
}
&.neutral:indeterminate {
background-color: var(--clr-bg-2);
&:hover {
background-color: var(--clr-bg-3);
}
&::before {
background-color: var(--clr-scale-ntrl-30);
}
}
&.blue:indeterminate {
background-color: var(--clr-diff-selected-checkbox);
box-shadow: inset 0 0 0 1px var(--clr-diff-selected-checkbox);
&:hover {
background-color: var(--clr-diff-selected-checkbox-hover);
}
&::before {
background-color: white;
}
}
/* checked */
&:checked {
&:disabled {
pointer-events: none;
opacity: 0.4;
@ -127,6 +158,33 @@
}
}
&.default:checked {
background-color: var(--clr-theme-pop-element);
box-shadow: inset 0 0 0 1px var(--clr-theme-pop-element);
&:hover {
background-color: var(--clr-theme-pop-element-hover);
}
}
&.neutral:checked {
background-color: var(--clr-bg-2);
box-shadow: inset 0 0 0 1px var(--clr-scale-ntrl-30);
&:hover {
background-color: var(--clr-bg-3);
}
}
&.blue:checked {
background-color: var(--clr-diff-selected-checkbox);
box-shadow: inset 0 0 0 1px var(--clr-diff-selected-checkbox);
&:hover {
background-color: var(--clr-diff-selected-checkbox-hover);
}
}
&::after {
content: '';
position: absolute;

View File

@ -293,7 +293,7 @@
},
"50": {
"$type": "color",
"$value": "#48a8a3",
"$value": "#3cb4ae",
"$description": "",
"$extensions": {
"mode": {},
@ -309,7 +309,7 @@
},
"60": {
"$type": "color",
"$value": "#97cecb",
"$value": "#8fd6d2",
"$description": "",
"$extensions": {
"mode": {},
@ -325,7 +325,7 @@
},
"70": {
"$type": "color",
"$value": "#c6e7e5",
"$value": "#c1ebe9",
"$description": "",
"$extensions": {
"mode": {},
@ -341,7 +341,7 @@
},
"80": {
"$type": "color",
"$value": "#daf1f0",
"$value": "#d7f4f2",
"$description": "",
"$extensions": {
"mode": {},
@ -357,7 +357,7 @@
},
"90": {
"$type": "color",
"$value": "#e9f7f6",
"$value": "#e7f8f7",
"$description": "",
"$extensions": {
"mode": {},
@ -373,7 +373,7 @@
},
"95": {
"$type": "color",
"$value": "#f4fbfa",
"$value": "#f3fcfb",
"$description": "",
"$extensions": {
"mode": {},
@ -859,7 +859,7 @@
},
"70": {
"$type": "color",
"$value": "#bef4da",
"$value": "#c2f0da",
"$description": "",
"$extensions": {
"mode": {},
@ -875,7 +875,7 @@
},
"80": {
"$type": "color",
"$value": "#d0f7e5",
"$value": "#d2f4e4",
"$description": "",
"$extensions": {
"mode": {},
@ -891,7 +891,7 @@
},
"90": {
"$type": "color",
"$value": "#e5faf0",
"$value": "#e7f9f0",
"$description": "",
"$extensions": {
"mode": {},
@ -3652,12 +3652,12 @@
"selected": {
"count-bg": {
"$type": "color",
"$value": "#378bf2",
"$value": "#e1eeff",
"$description": "",
"$extensions": {
"mode": {
"light": "#378bf2",
"dark": "#044289"
"light": "#e1eeff",
"dark": "#133161"
},
"figma": {
"variableId": "VariableID:3935:251",
@ -3671,12 +3671,12 @@
},
"count-border": {
"$type": "color",
"$value": "#265dd4",
"$value": "#9dc3f5",
"$description": "",
"$extensions": {
"mode": {
"light": "#265dd4",
"dark": "#005cc5"
"light": "#9dc3f5",
"dark": "#2464ae"
},
"figma": {
"variableId": "VariableID:3935:253",
@ -3690,12 +3690,12 @@
},
"count-text": {
"$type": "color",
"$value": "#ffffff",
"$value": "#6187dc",
"$description": "",
"$extensions": {
"mode": {
"light": "#ffffff",
"dark": "#d6e8ff"
"light": "#6187dc",
"dark": "#6aaeff"
},
"figma": {
"variableId": "VariableID:3935:254",
@ -3706,6 +3706,44 @@
}
}
}
},
"checkbox": {
"$type": "color",
"$value": "#378bf2",
"$description": "",
"$extensions": {
"mode": {
"light": "#378bf2",
"dark": "#2581f3"
},
"figma": {
"variableId": "VariableID:4469:4018",
"collection": {
"id": "VariableCollectionId:8:1868",
"name": "clr",
"defaultModeId": "8:5"
}
}
}
},
"checkbox-hover": {
"$type": "color",
"$value": "#196dd4",
"$description": "",
"$extensions": {
"mode": {
"light": "#196dd4",
"dark": "#1165cc"
},
"figma": {
"variableId": "VariableID:4499:8368",
"collection": {
"id": "VariableCollectionId:8:1868",
"name": "clr",
"defaultModeId": "8:5"
}
}
}
}
},
"count-text": {
@ -4137,7 +4175,7 @@
"useDTCGKeys": true,
"colorMode": "hex",
"variableCollections": ["clr-core", "clr", "size", "radius"],
"createdAt": "2024-08-31T23:16:13.487Z"
"createdAt": "2024-09-10T12:38:43.089Z"
}
}
}

View File

@ -18,6 +18,7 @@
clickable?: boolean;
showCheckbox?: boolean;
checked?: boolean;
indeterminate?: boolean;
conflicted?: boolean;
locked?: boolean;
lockText?: string;
@ -44,6 +45,7 @@
clickable = true,
showCheckbox = false,
checked = $bindable(),
indeterminate,
conflicted,
locked,
lockText,
@ -81,7 +83,7 @@
}}
>
{#if showCheckbox}
<Checkbox small {checked} onchange={oncheck} />
<Checkbox small {checked} {indeterminate} onchange={oncheck} />
{/if}
<div class="info">
<FileIcon {fileName} size={14} />

View File

@ -21,12 +21,12 @@
--clr-core-pop-20: color(srgb 0.10980392156862745 0.32941176470588235 0.3176470588235294);
--clr-core-pop-30: color(srgb 0.1450980392156863 0.43529411764705883 0.4196078431372549);
--clr-core-pop-40: color(srgb 0.16470588235294117 0.5725490196078431 0.5529411764705883);
--clr-core-pop-50: color(srgb 0.2823529411764706 0.6588235294117647 0.6392156862745098);
--clr-core-pop-60: color(srgb 0.592156862745098 0.807843137254902 0.796078431372549);
--clr-core-pop-70: color(srgb 0.7764705882352941 0.9058823529411765 0.8980392156862745);
--clr-core-pop-80: color(srgb 0.8549019607843137 0.9450980392156862 0.9411764705882353);
--clr-core-pop-90: color(srgb 0.9137254901960784 0.9686274509803922 0.9647058823529412);
--clr-core-pop-95: color(srgb 0.9568627450980393 0.984313725490196 0.9803921568627451);
--clr-core-pop-50: color(srgb 0.23529411764705882 0.7058823529411765 0.6823529411764706);
--clr-core-pop-60: color(srgb 0.5607843137254902 0.8392156862745098 0.8235294117647058);
--clr-core-pop-70: color(srgb 0.7568627450980392 0.9215686274509803 0.9137254901960784);
--clr-core-pop-80: color(srgb 0.8431372549019608 0.9568627450980393 0.9490196078431372);
--clr-core-pop-90: color(srgb 0.9058823529411765 0.9725490196078431 0.9686274509803922);
--clr-core-pop-95: color(srgb 0.9529411764705882 0.9882352941176471 0.984313725490196);
--clr-core-err-5: color(srgb 0.14901960784313725 0.050980392156862744 0.058823529411764705);
--clr-core-err-10: color(srgb 0.2980392156862745 0.10196078431372549 0.12156862745098039);
--clr-core-err-20: color(srgb 0.4196078431372549 0.1411764705882353 0.16862745098039217);
@ -56,9 +56,9 @@
--clr-core-succ-40: color(srgb 0.23529411764705882 0.6039215686274509 0.43529411764705883);
--clr-core-succ-50: color(srgb 0.2901960784313726 0.7098039215686275 0.5098039215686274);
--clr-core-succ-60: color(srgb 0.5725490196078431 0.8666666666666667 0.7294117647058823);
--clr-core-succ-70: color(srgb 0.7450980392156863 0.9568627450980393 0.8549019607843137);
--clr-core-succ-80: color(srgb 0.8156862745098039 0.9686274509803922 0.8980392156862745);
--clr-core-succ-90: color(srgb 0.8980392156862745 0.9803921568627451 0.9411764705882353);
--clr-core-succ-70: color(srgb 0.7607843137254902 0.9411764705882353 0.8549019607843137);
--clr-core-succ-80: color(srgb 0.8235294117647058 0.9568627450980393 0.8941176470588236);
--clr-core-succ-90: color(srgb 0.9058823529411765 0.9764705882352941 0.9411764705882353);
--clr-core-succ-95: color(srgb 0.9647058823529412 0.9882352941176471 0.984313725490196);
--clr-core-purp-5: color(srgb 0.1568627450980392 0.11372549019607843 0.26666666666666666);
--clr-core-purp-10: color(srgb 0.24705882352941178 0.17254901960784313 0.40784313725490196);
@ -203,13 +203,19 @@
--clr-diff-line-bg: var(--clr-bg-1);
--clr-diff-count-bg: color(srgb 0.9686274509803922 0.9686274509803922 0.9647058823529412);
--clr-diff-count-border: var(--clr-border-2);
--clr-diff-selected-count-bg: color(
--clr-diff-selected-count-bg: color(srgb 0.8823529411764706 0.9333333333333333 1);
--clr-diff-selected-count-border: color(
srgb 0.615686274509804 0.7647058823529411 0.9607843137254902
);
--clr-diff-selected-count-text: color(
srgb 0.3803921568627451 0.5294117647058824 0.8627450980392157
);
--clr-diff-selected-checkbox: color(
srgb 0.21568627450980393 0.5450980392156862 0.9490196078431372
);
--clr-diff-selected-count-border: color(
srgb 0.14901960784313725 0.36470588235294116 0.8313725490196079
--clr-diff-selected-checkbox-hover: color(
srgb 0.09803921568627451 0.42745098039215684 0.8313725490196079
);
--clr-diff-selected-count-text: color(srgb 1 1 1);
--clr-diff-count-text: var(--clr-text-3);
--clr-diff-deletion-line-bg: color(srgb 1 0.9411764705882353 0.9490196078431372);
--clr-diff-deletion-line-highlight: color(
@ -386,10 +392,16 @@
--clr-diff-count-bg: color(srgb 0.18823529411764706 0.17254901960784313 0.16862745098039217);
--clr-diff-count-border: var(--clr-border-2);
--clr-diff-selected-count-bg: color(
srgb 0.01568627450980392 0.25882352941176473 0.5372549019607843
srgb 0.07450980392156863 0.19215686274509805 0.3803921568627451
);
--clr-diff-selected-count-border: color(srgb 0 0.3607843137254902 0.7725490196078432);
--clr-diff-selected-count-text: color(srgb 0.8392156862745098 0.9098039215686274 1);
--clr-diff-selected-count-border: color(
srgb 0.1411764705882353 0.39215686274509803 0.6823529411764706
);
--clr-diff-selected-count-text: color(srgb 0.41568627450980394 0.6823529411764706 1);
--clr-diff-selected-checkbox: color(
srgb 0.1450980392156863 0.5058823529411764 0.9529411764705882
);
--clr-diff-selected-checkbox-hover: color(srgb 0.06666666666666667 0.396078431372549 0.8);
--clr-diff-count-text: var(--clr-text-3);
--clr-diff-deletion-line-bg: color(
srgb 0.23529411764705882 0.07450980392156863 0.10588235294117647