fix: merge conflicts

This commit is contained in:
ndom91 2024-09-13 11:15:06 +02:00
commit e842b282ae
92 changed files with 3640 additions and 880 deletions

1
Cargo.lock generated
View File

@ -2204,6 +2204,7 @@ dependencies = [
"tracing",
"url",
"urlencoding",
"uuid",
]
[[package]]

View File

@ -1,5 +1,6 @@
<script lang="ts">
import BranchHeader from './BranchHeader.svelte';
import StackedBranchHeader from './StackedBranchHeader.svelte';
import EmptyStatePlaceholder from '../components/EmptyStatePlaceholder.svelte';
import PullRequestCard from '../pr/PullRequestCard.svelte';
import InfoMessage from '../shared/InfoMessage.svelte';
@ -12,7 +13,11 @@
import CommitDialog from '$lib/commit/CommitDialog.svelte';
import CommitList from '$lib/commit/CommitList.svelte';
import { projectAiGenEnabled } from '$lib/config/config';
import { stackingFeature } from '$lib/config/uiFeatureFlags';
import BranchFiles from '$lib/file/BranchFiles.svelte';
import { getGitHostChecksMonitor } from '$lib/gitHost/interface/gitHostChecksMonitor';
import { getGitHostListingService } from '$lib/gitHost/interface/gitHostListingService';
import { getGitHostPrMonitor } from '$lib/gitHost/interface/gitHostPrMonitor';
import { showError } from '$lib/notifications/toasts';
import { persisted } from '$lib/persisted/persisted';
import { isFailure } from '$lib/result';
@ -22,8 +27,16 @@
import { User } from '$lib/stores/user';
import { getContext, getContextStore, getContextStoreBySymbol } from '$lib/utils/context';
import { BranchController } from '$lib/vbranches/branchController';
import { groupCommitsByRef } from '$lib/vbranches/commitGroups';
import {
getIntegratedCommits,
getLocalAndRemoteCommits,
getLocalCommits,
getRemoteCommits
} from '$lib/vbranches/contexts';
import { FileIdSelection } from '$lib/vbranches/fileIdSelection';
import { VirtualBranch } from '$lib/vbranches/types';
import Button from '@gitbutler/ui/Button.svelte';
import lscache from 'lscache';
import { onMount } from 'svelte';
import type { Writable } from 'svelte/store';
@ -93,6 +106,33 @@
onMount(() => {
laneWidth = lscache.get(laneWidthKey + branch.id);
});
const localCommits = getLocalCommits();
const localAndRemoteCommits = getLocalAndRemoteCommits();
const integratedCommits = getIntegratedCommits();
const remoteCommits = getRemoteCommits();
let isPushingCommits = $state(false);
const localCommitsConflicted = $derived($localCommits.some((commit) => commit.conflicted));
const localAndRemoteCommitsConflicted = $derived(
$localAndRemoteCommits.some((commit) => commit.conflicted)
);
const listingService = getGitHostListingService();
const prMonitor = getGitHostPrMonitor();
const checksMonitor = getGitHostChecksMonitor();
async function push() {
isPushingCommits = true;
try {
await branchController.pushBranch(branch.id, branch.requiresForce);
$listingService?.refresh();
$prMonitor?.refresh();
$checksMonitor?.update();
} finally {
isPushingCommits = false;
}
}
</script>
{#if $isLaneCollapsed}
@ -122,10 +162,12 @@
data-tauri-drag-region
>
<BranchHeader {isLaneCollapsed} onGenerateBranchName={generateBranchName} />
<PullRequestCard />
<div class="card">
{#if !$stackingFeature && branch.upstream?.givenName}
<PullRequestCard upstreamName={branch.upstream.givenName} />
{/if}
<div class:card-no-stacking={!$stackingFeature} class:card-stacking={$stackingFeature}>
{#if branch.files?.length > 0}
<div class="branch-card__files">
<div class="branch-card__files" class:card={$stackingFeature}>
<Dropzones>
<BranchFiles
isUnapplied={false}
@ -157,7 +199,7 @@
</div>
{:else if branch.commits.length === 0}
<Dropzones>
<div class="new-branch">
<div class="new-branch" class:card={$stackingFeature}>
<EmptyStatePlaceholder image={laneNewSvg} width="11rem">
<svelte:fragment slot="title">This is a new branch</svelte:fragment>
<svelte:fragment slot="caption">
@ -168,7 +210,7 @@
</Dropzones>
{:else}
<Dropzones>
<div class="no-changes">
<div class="no-changes" class:card={$stackingFeature}>
<EmptyStatePlaceholder image={noChangesSvg} width="11rem" hasBottomMargin={false}>
<svelte:fragment slot="caption"
>No uncommitted changes on this branch</svelte:fragment
@ -178,7 +220,57 @@
</Dropzones>
{/if}
<CommitList isUnapplied={false} />
{#snippet pushButton({disabled}: {disabled: boolean})}
<Button
style="pop"
kind="solid"
wide
loading={isPushingCommits}
{disabled}
tooltip={localCommitsConflicted
? 'In order to push, please resolve any conflicted commits.'
: undefined}
onclick={push}
>
{branch.requiresForce ? 'Force push' : 'Push'}
</Button>
{/snippet}
{#if $stackingFeature}
{@const groups = groupCommitsByRef(branch.commits)}
{#each groups as group (group.ref)}
<div class="commit-group">
{#if group.branchName}
<StackedBranchHeader upstreamName={group.branchName} />
<PullRequestCard upstreamName={group.branchName} />
{/if}
<CommitList
localCommits={group.localCommits}
localAndRemoteCommits={group.remoteCommits}
integratedCommits={group.integratedCommits}
remoteCommits={[]}
isUnapplied={false}
{localCommitsConflicted}
{localAndRemoteCommitsConflicted}
/>
</div>
{/each}
{:else}
<CommitList
localCommits={$localCommits}
localAndRemoteCommits={$localAndRemoteCommits}
integratedCommits={$integratedCommits}
remoteCommits={$remoteCommits}
isUnapplied={false}
{localCommitsConflicted}
{localAndRemoteCommitsConflicted}
{pushButton}
/>
{/if}
{#if $stackingFeature}
{@render pushButton({
disabled: localCommitsConflicted || localAndRemoteCommitsConflicted
})}
{/if}
</div>
</div>
</ScrollableContainer>
@ -231,8 +323,19 @@
padding: 12px;
}
.card {
.card-no-stacking {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid var(--clr-border-2);
border-radius: var(--radius-m);
background: var(--clr-bg-1);
}
.card-stacking {
flex: 1;
display: flex;
flex-direction: column;
}
.branch-card__files {
@ -278,4 +381,12 @@
height: 100%;
background-color: var(--clr-border-2);
}
.commit-group {
margin: 10px 0;
border: 1px solid var(--clr-border-2);
border-radius: var(--radius-m);
background: var(--clr-bg-1);
overflow: hidden;
}
</style>

View File

@ -2,11 +2,13 @@
import ActiveBranchStatus from './ActiveBranchStatus.svelte';
import BranchLabel from './BranchLabel.svelte';
import BranchLaneContextMenu from './BranchLaneContextMenu.svelte';
import DefaultTargetButton from './DefaultTargetButton.svelte';
import PullRequestButton from '../pr/PullRequestButton.svelte';
import { Project } from '$lib/backend/projects';
import { BaseBranch } from '$lib/baseBranch/baseBranch';
import { BaseBranchService } from '$lib/baseBranch/baseBranchService';
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import { stackingFeature } from '$lib/config/uiFeatureFlags';
import { mapErrorToToast } from '$lib/gitHost/github/errorMap';
import { getGitHost } from '$lib/gitHost/interface/gitHost';
import { getGitHostListingService } from '$lib/gitHost/interface/gitHostListingService';
@ -140,7 +142,13 @@
return;
}
await $prService.createPr(title, body, opts.draft, upstreamBranchName, baseBranchName);
await $prService.createPr({
title,
body,
draft: opts.draft,
baseBranchName,
upstreamName: upstreamBranchName
});
} catch (err: any) {
console.error(err);
const toast = mapErrorToToast(err);
@ -216,90 +224,101 @@
<Icon name="draggable" />
</div>
<div class="header__info">
<div class:header__info={!$stackingFeature} class:stacking-header__info={$stackingFeature}>
<BranchLabel name={branch.name} onChange={(name) => handleBranchNameChange(name)} />
<div class="header__remote-branch">
<ActiveBranchStatus
{hasIntegratedCommits}
remoteExists={!!branch.upstream}
isLaneCollapsed={$isLaneCollapsed}
/>
{#if $stackingFeature}
<span class="button-group">
<DefaultTargetButton
selectedForChanges={branch.selectedForChanges}
onclick={async () => {
isTargetBranchAnimated = true;
await branchController.setSelectedForChanges(branch.id);
}}
/>
<Button
bind:el={meatballButtonEl}
style="ghost"
icon="kebab"
onclick={() => {
contextMenu?.toggle();
}}
/>
<BranchLaneContextMenu
bind:contextMenuEl={contextMenu}
target={meatballButtonEl}
onCollapse={collapseLane}
{onGenerateBranchName}
/>
</span>
{:else}
<div class="header__remote-branch">
<ActiveBranchStatus
{hasIntegratedCommits}
remoteExists={!!branch.upstream}
isLaneCollapsed={$isLaneCollapsed}
/>
{#await branch.isMergeable then isMergeable}
{#if !isMergeable}
<Button
size="tag"
clickable={false}
icon="locked-small"
style="warning"
tooltip="Applying this branch will add merge conflict markers that you will have to resolve"
>
Conflict
</Button>
{/if}
{/await}
</div>
{#await branch.isMergeable then isMergeable}
{#if !isMergeable}
<Button
size="tag"
clickable={false}
icon="locked-small"
style="warning"
tooltip="Applying this branch will add merge conflict markers that you will have to resolve"
>
Conflict
</Button>
{/if}
{/await}
</div>
{/if}
</div>
</div>
<div class="header__actions">
<div class="header__buttons">
{#if branch.selectedForChanges}
<Button
style="pop"
kind="soft"
tooltip="New changes will land here"
icon="target"
clickable={false}
>
Default branch
</Button>
{:else}
<Button
style="ghost"
outline
tooltip="When selected, new changes land here"
icon="target"
{#if !$stackingFeature}
<div class="header__actions">
<div class="header__buttons">
<DefaultTargetButton
selectedForChanges={branch.selectedForChanges}
onclick={async () => {
isTargetBranchAnimated = true;
await branchController.setSelectedForChanges(branch.id);
}}
>
Set as default
</Button>
{/if}
</div>
<div class="relative">
<div class="header__buttons">
{#if !$pr}
<PullRequestButton
click={async ({ draft }) => await createPr({ draft })}
disabled={branch.commits.length === 0 || !$gitHost || !$prService}
tooltip={!$gitHost || !$prService
? 'You can enable git host integration in the settings'
: ''}
loading={isLoading}
/>
{/if}
<Button
bind:el={meatballButtonEl}
style="ghost"
outline
icon="kebab"
onclick={() => {
contextMenu?.toggle();
}}
/>
<BranchLaneContextMenu
bind:contextMenuEl={contextMenu}
target={meatballButtonEl}
onCollapse={collapseLane}
{onGenerateBranchName}
/>
</div>
<div class="relative">
<div class="header__buttons">
{#if !$pr}
<PullRequestButton
click={async ({ draft }) => await createPr({ draft })}
disabled={branch.commits.length === 0 || !$gitHost || !$prService}
tooltip={!$gitHost || !$prService
? 'You can enable git host integration in the settings'
: ''}
loading={isLoading}
/>
{/if}
<Button
bind:el={meatballButtonEl}
style="ghost"
outline
icon="kebab"
onclick={() => {
contextMenu?.toggle();
}}
/>
<BranchLaneContextMenu
bind:contextMenuEl={contextMenu}
target={meatballButtonEl}
onCollapse={collapseLane}
{onGenerateBranchName}
/>
</div>
</div>
</div>
</div>
{/if}
</div>
<div class="header__top-overlay" data-remove-from-draggable data-tauri-drag-region></div>
</div>
@ -370,6 +389,20 @@
overflow: hidden;
gap: 10px;
}
/* TODO: Remove me after stacking feature toggle has been removed. */
.stacking-header__info {
flex: 1;
display: flex;
overflow: hidden;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.button-group {
display: flex;
align-items: center;
gap: 10px;
}
.header__actions {
display: flex;
gap: 4px;

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

@ -0,0 +1,34 @@
<script lang="ts">
import Button from '@gitbutler/ui/Button.svelte';
interface Props {
selectedForChanges: boolean;
onclick: () => void;
}
const { selectedForChanges }: Props = $props();
</script>
{#if selectedForChanges}
<Button
style="pop"
kind="soft"
tooltip="New changes will land here"
icon="target"
size="tag"
clickable={false}
>
Default branch
</Button>
{:else}
<Button
style="ghost"
outline
tooltip="When selected, new changes land here"
icon="target"
size="tag"
{onclick}
>
Set as default
</Button>
{/if}

View File

@ -0,0 +1,53 @@
<script lang="ts">
import { getGitHostListingService } from '$lib/gitHost/interface/gitHostListingService';
import { getGitHostPrService } from '$lib/gitHost/interface/gitHostPrService';
import PullRequestButton from '$lib/pr/PullRequestButton.svelte';
interface Props {
upstreamName: string | undefined;
}
const { upstreamName }: Props = $props();
const hostedListingServiceStore = getGitHostListingService();
const prStore = $derived($hostedListingServiceStore?.prs);
const prs = $derived(prStore ? $prStore : undefined);
const listedPr = $derived(prs?.find((pr) => pr.sourceBranch === upstreamName));
const prNumber = $derived(listedPr?.number);
const prService = getGitHostPrService();
const prMonitor = $derived(prNumber ? $prService?.prMonitor(prNumber) : undefined);
const pr = $derived(prMonitor?.pr);
let loading = $state(false);
</script>
<div class="branch-info">
<div class="branch-name text-14 text-bold">
<span class="remote-name">origin/</span>{upstreamName}
</div>
{#if !pr}
<PullRequestButton {loading} click={() => {}} />
{/if}
</div>
<style lang="postcss">
.branch-info {
padding: 14px 16px;
display: flex;
border-bottom: 1px solid var(--clr-border-2);
user-select: text;
cursor: text;
display: flex;
flex-direction: column;
gap: 14px;
}
.branch-name {
}
.remote-name {
color: var(--clr-scale-ntrl-60);
}
</style>

View File

@ -4,7 +4,7 @@
import { BaseBranch } from '$lib/baseBranch/baseBranch';
import CommitMessageInput from '$lib/commit/CommitMessageInput.svelte';
import { persistedCommitMessage } from '$lib/config/config';
import { featureBranchStacking } from '$lib/config/uiFeatureFlags';
import { stackingFeature } from '$lib/config/uiFeatureFlags';
import { draggableCommit } from '$lib/dragging/draggable';
import { DraggableCommit, nonDraggable } from '$lib/dragging/draggables';
import BranchFilesList from '$lib/file/BranchFilesList.svelte';
@ -50,8 +50,6 @@
const currentCommitMessage = persistedCommitMessage(project.id, branch?.id || '');
const branchStacking = featureBranchStacking();
let draggableCommitElement: HTMLElement | null = null;
let files: RemoteFile[] = [];
let showDetails = false;
@ -349,18 +347,8 @@
</div>
</button>
{/if}
<span class="commit__subtitle-divider"></span>
<span>{getTimeAndAuthor()}</span>
{#if $branchStacking && commit instanceof DetailedCommit}
<div
style="background-color:var(--clr-core-pop-80); border-radius: 3px; padding: 2px;"
>
{commit?.remoteRef}
</div>
{/if}
</div>
{/if}
</div>
@ -399,7 +387,7 @@
icon="edit-small"
onclick={openCommitMessageModal}>Edit message</Button
>
{#if $branchStacking && commit instanceof DetailedCommit && !commit.remoteRef}
{#if $stackingFeature && commit instanceof DetailedCommit && !commit.remoteRef}
<Button
size="tag"
style="ghost"
@ -408,7 +396,7 @@
onclick={(e: Event) => {openCreateRefModal(e, commit)}}>Create ref</Button
>
{/if}
{#if $branchStacking && commit instanceof DetailedCommit && commit.remoteRef}
{#if $stackingFeature && commit instanceof DetailedCommit && commit.remoteRef}
<Button
size="tag"
style="ghost"

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);
@ -88,7 +88,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

@ -5,6 +5,7 @@
import { BaseBranch } from '$lib/baseBranch/baseBranch';
import { transformAnyCommit } from '$lib/commitLines/transformers';
import InsertEmptyCommitAction from '$lib/components/InsertEmptyCommitAction.svelte';
import { stackingFeature } from '$lib/config/uiFeatureFlags';
import {
ReorderDropzoneManagerFactory,
type ReorderDropzone
@ -12,97 +13,107 @@
import Dropzone from '$lib/dropzone/Dropzone.svelte';
import LineOverlay from '$lib/dropzone/LineOverlay.svelte';
import { getGitHost } from '$lib/gitHost/interface/gitHost';
import { getGitHostChecksMonitor } from '$lib/gitHost/interface/gitHostChecksMonitor';
import { getGitHostListingService } from '$lib/gitHost/interface/gitHostListingService';
import { getGitHostPrMonitor } from '$lib/gitHost/interface/gitHostPrMonitor';
import { getContext } from '$lib/utils/context';
import { getContextStore } from '$lib/utils/context';
import { BranchController } from '$lib/vbranches/branchController';
import {
getIntegratedCommits,
getLocalCommits,
getLocalAndRemoteCommits,
getRemoteCommits
} from '$lib/vbranches/contexts';
import { VirtualBranch } from '$lib/vbranches/types';
import { Commit, DetailedCommit, VirtualBranch } from '$lib/vbranches/types';
import Button from '@gitbutler/ui/Button.svelte';
import LineGroup from '@gitbutler/ui/commitLines/LineGroup.svelte';
import { LineManagerFactory } from '@gitbutler/ui/commitLines/lineManager';
import type { Snippet } from 'svelte';
import { goto } from '$app/navigation';
export let isUnapplied: boolean;
interface Props {
localCommits: DetailedCommit[];
localAndRemoteCommits: DetailedCommit[];
integratedCommits: DetailedCommit[];
remoteCommits: Commit[];
isUnapplied: boolean;
pushButton?: Snippet<[{ disabled: boolean }]>;
localCommitsConflicted: boolean;
localAndRemoteCommitsConflicted: boolean;
}
const {
localCommits,
localAndRemoteCommits,
integratedCommits,
remoteCommits,
isUnapplied,
localCommitsConflicted,
pushButton,
localAndRemoteCommitsConflicted
}: Props = $props();
const branch = getContextStore(VirtualBranch);
const localCommits = getLocalCommits();
const localAndRemoteCommits = getLocalAndRemoteCommits();
const remoteCommits = getRemoteCommits();
const integratedCommits = getIntegratedCommits();
const baseBranch = getContextStore(BaseBranch);
const project = getContext(Project);
const branchController = getContext(BranchController);
const lineManagerFactory = getContext(LineManagerFactory);
//
const listingService = getGitHostListingService();
const prMonitor = getGitHostPrMonitor();
const checksMonitor = getGitHostChecksMonitor();
const reorderDropzoneManagerFactory = getContext(ReorderDropzoneManagerFactory);
const gitHost = getGitHost();
// TODO: Why does eslint-svelte-plugin complain about enum?
// eslint-disable-next-line svelte/valid-compile
enum LineSpacer {
Remote = 'remote-spacer',
Local = 'local-spacer',
LocalAndRemote = 'local-and-remote-spacer'
}
$: mappedRemoteCommits =
$remoteCommits.length > 0
? [...$remoteCommits.map(transformAnyCommit), { id: LineSpacer.Remote }]
: [];
$: mappedLocalCommits =
$localCommits.length > 0
? [...$localCommits.map(transformAnyCommit), { id: LineSpacer.Local }]
: [];
$: mappedLocalAndRemoteCommits =
$localAndRemoteCommits.length > 0
? [...$localAndRemoteCommits.map(transformAnyCommit), { id: LineSpacer.LocalAndRemote }]
: [];
const mappedRemoteCommits = $derived(
remoteCommits.length > 0
? [...remoteCommits.map(transformAnyCommit), { id: LineSpacer.Remote }]
: []
);
$: lineManager = lineManagerFactory.build(
{
remoteCommits: mappedRemoteCommits,
localCommits: mappedLocalCommits,
localAndRemoteCommits: mappedLocalAndRemoteCommits,
integratedCommits: $integratedCommits.map(transformAnyCommit)
},
!isRebased
const mappedLocalCommits = $derived(
localCommits.length > 0
? !$stackingFeature
? [...localCommits.map(transformAnyCommit), { id: LineSpacer.Local }]
: localCommits.map(transformAnyCommit)
: []
);
const mappedLocalAndRemoteCommits = $derived(
localAndRemoteCommits.length > 0
? [...localAndRemoteCommits.map(transformAnyCommit), { id: LineSpacer.LocalAndRemote }]
: []
);
const forkPoint = $derived($branch.forkPoint);
const upstreamForkPoint = $derived($branch.upstreamData?.forkPoint);
const isRebased = $derived(!!forkPoint && !!upstreamForkPoint && forkPoint !== upstreamForkPoint);
const lineManager = $derived(
lineManagerFactory.build(
{
remoteCommits: mappedRemoteCommits,
localCommits: mappedLocalCommits,
localAndRemoteCommits: mappedLocalAndRemoteCommits,
integratedCommits: integratedCommits.map(transformAnyCommit)
},
!isRebased
)
);
// Force the "base" commit lines to update when $branch updates.
let tsKey: number | undefined;
$: {
let tsKey = $state<number | undefined>(undefined);
$effect(() => {
$branch;
tsKey = Date.now();
}
});
$: hasCommits = $branch.commits && $branch.commits.length > 0;
$: headCommit = $branch.commits.at(0);
const hasCommits = $derived($branch.commits && $branch.commits.length > 0);
const headCommit = $derived($branch.commits.at(0));
$: hasRemoteCommits = $remoteCommits.length > 0;
const hasRemoteCommits = $derived(remoteCommits.length > 0);
$: reorderDropzoneManager = reorderDropzoneManagerFactory.build($branch, [
...$localCommits,
...$localAndRemoteCommits
]);
const reorderDropzoneManager = $derived(
reorderDropzoneManagerFactory.build($branch, [...localCommits, ...localAndRemoteCommits])
);
$: forkPoint = $branch.forkPoint;
$: upstreamForkPoint = $branch.upstreamData?.forkPoint;
$: isRebased = !!forkPoint && !!upstreamForkPoint && forkPoint !== upstreamForkPoint;
$: isPushingCommits = false;
$: isIntegratingCommits = false;
let baseIsUnfolded = false;
let isIntegratingCommits = $state(false);
let baseIsUnfolded = $state(false);
function insertBlankCommit(commitId: string, location: 'above' | 'below' = 'below') {
if (!$branch || !$baseBranch) {
@ -126,21 +137,6 @@
if (isLast) return 0;
return 0;
}
$: localCommitsConflicted = $localCommits.some((commit) => commit.conflicted);
$: localAndRemoteCommitsConflicted = $localAndRemoteCommits.some((commit) => commit.conflicted);
async function push() {
isPushingCommits = true;
try {
await branchController.pushBranch($branch.id, $branch.requiresForce);
$listingService?.refresh();
$prMonitor?.refresh();
$checksMonitor?.update();
} finally {
isPushingCommits = false;
}
}
</script>
{#snippet reorderDropzone(dropzone: ReorderDropzone, yOffsetPx: number)}
@ -152,20 +148,20 @@
{/snippet}
{#if hasCommits || hasRemoteCommits}
<div class="commits">
<div class="commits" class:stacked={$stackingFeature}>
<!-- UPSTREAM COMMITS -->
{#if $remoteCommits.length > 0}
{#if remoteCommits.length > 0}
<!-- To make the sticky position work, commits should be wrapped in a div -->
<div class="commits-group">
{#each $remoteCommits as commit, idx (commit.id)}
{#each remoteCommits as commit, idx (commit.id)}
<CommitCard
type="remote"
branch={$branch}
{commit}
{isUnapplied}
first={idx === 0}
last={idx === $remoteCommits.length - 1}
last={idx === remoteCommits.length - 1}
commitUrl={$gitHost?.commitUrl(commit.id)}
isHeadCommit={commit.id === headCommit?.id}
>
@ -201,7 +197,7 @@
{/if}
<!-- LOCAL COMMITS -->
{#if $localCommits.length > 0}
{#if localCommits.length > 0}
<div class="commits-group">
<InsertEmptyCommitAction
isFirst
@ -211,14 +207,14 @@
reorderDropzoneManager.topDropzone,
getReorderDropzoneOffset({ isFirst: true })
)}
{#each $localCommits as commit, idx (commit.id)}
{#each localCommits as commit, idx (commit.id)}
<CommitCard
{commit}
{isUnapplied}
type="local"
first={idx === 0}
branch={$branch}
last={idx === $localCommits.length - 1}
last={idx === localCommits.length - 1}
isHeadCommit={commit.id === headCommit?.id}
>
{#snippet lines(topHeightPx)}
@ -229,52 +225,40 @@
{@render reorderDropzone(
reorderDropzoneManager.dropzoneBelowCommit(commit.id),
getReorderDropzoneOffset({
isLast: idx + 1 === $localCommits.length,
isMiddle: idx + 1 === $localCommits.length
isLast: idx + 1 === localCommits.length,
isMiddle: idx + 1 === localCommits.length
})
)}
<InsertEmptyCommitAction
isLast={idx + 1 === $localCommits.length}
isLast={idx + 1 === localCommits.length}
on:click={() => insertBlankCommit(commit.id, 'below')}
/>
{/each}
{#snippet lines()}
<LineGroup lineGroup={lineManager.get(LineSpacer.Local)} topHeightPx={0} />
{/snippet}
<CommitAction bottomBorder={hasRemoteCommits} {lines}>
{#snippet action()}
<Button
style="pop"
kind="solid"
wide
loading={isPushingCommits}
disabled={localCommitsConflicted}
tooltip={localCommitsConflicted
? 'In order to push, please resolve any conflicted commits.'
: undefined}
onclick={push}
>
{$branch.requiresForce ? 'Force push' : 'Push'}
</Button>
{/snippet}
</CommitAction>
{#if !$stackingFeature && pushButton}
<CommitAction bottomBorder={hasRemoteCommits}>
{#snippet lines()}
<LineGroup lineGroup={lineManager.get(LineSpacer.Local)} topHeightPx={0} />
{/snippet}
{#snippet action()}
{@render pushButton({ disabled: localCommitsConflicted })}
{/snippet}
</CommitAction>
{/if}
</div>
{/if}
<!-- LOCAL AND REMOTE COMMITS -->
{#if $localAndRemoteCommits.length > 0}
{#if localAndRemoteCommits.length > 0}
<div class="commits-group">
{#each $localAndRemoteCommits as commit, idx (commit.id)}
{#each localAndRemoteCommits as commit, idx (commit.id)}
<CommitCard
{commit}
{isUnapplied}
type="localAndRemote"
first={idx === 0}
branch={$branch}
last={idx === $localAndRemoteCommits.length - 1}
last={idx === localAndRemoteCommits.length - 1}
isHeadCommit={commit.id === headCommit?.id}
commitUrl={$gitHost?.commitUrl(commit.id)}
>
@ -285,34 +269,22 @@
{@render reorderDropzone(
reorderDropzoneManager.dropzoneBelowCommit(commit.id),
getReorderDropzoneOffset({
isMiddle: idx + 1 === $localAndRemoteCommits.length
isMiddle: idx + 1 === localAndRemoteCommits.length
})
)}
<InsertEmptyCommitAction
isLast={idx + 1 === $localAndRemoteCommits.length}
isLast={idx + 1 === localAndRemoteCommits.length}
on:click={() => insertBlankCommit(commit.id, 'below')}
/>
{/each}
{#if $remoteCommits.length > 0 && $localCommits.length === 0}
{#if remoteCommits.length > 0 && localCommits.length === 0 && pushButton}
<CommitAction>
{#snippet lines()}
<LineGroup lineGroup={lineManager.get(LineSpacer.LocalAndRemote)} topHeightPx={0} />
{/snippet}
{#snippet action()}
<Button
style="pop"
kind="solid"
wide
loading={isPushingCommits}
disabled={localAndRemoteCommitsConflicted}
tooltip={localAndRemoteCommitsConflicted
? 'In order to push, please resolve any conflicted commits.'
: undefined}
onclick={push}
>
{$branch.requiresForce ? 'Force push' : 'Push'}
</Button>
{@render pushButton({ disabled: localAndRemoteCommitsConflicted })}
{/snippet}
</CommitAction>
{/if}
@ -320,9 +292,9 @@
{/if}
<!-- INTEGRATED COMMITS -->
{#if $integratedCommits.length > 0}
{#if integratedCommits.length > 0}
<div class="commits-group">
{#each $integratedCommits as commit, idx (commit.id)}
{#each integratedCommits as commit, idx (commit.id)}
<CommitCard
{commit}
{isUnapplied}
@ -330,7 +302,7 @@
first={idx === 0}
branch={$branch}
isHeadCommit={commit.id === headCommit?.id}
last={idx === $integratedCommits.length - 1}
last={idx === integratedCommits.length - 1}
commitUrl={$gitHost?.commitUrl(commit.id)}
>
{#snippet lines(topHeightPx)}
@ -347,8 +319,11 @@
class="base-row"
tabindex="0"
role="button"
on:click|stopPropagation={() => (baseIsUnfolded = !baseIsUnfolded)}
on:keydown={(e) => e.key === 'Enter' && (baseIsUnfolded = !baseIsUnfolded)}
onclick={(e) => {
e.stopPropagation();
baseIsUnfolded = !baseIsUnfolded;
}}
onkeydown={(e) => e.key === 'Enter' && (baseIsUnfolded = !baseIsUnfolded)}
>
<div class="base-row__lines">
{#key tsKey}
@ -359,7 +334,7 @@
<span class="text-11 base-row__text"
>Base commit <button
class="base-row__commit-link"
on:click={async () => await goto(`/${project.id}/base`)}
onclick={async () => await goto(`/${project.id}/base`)}
>
{$branch.forkPoint ? $branch.forkPoint.slice(0, 7) : ''}
</button>
@ -390,6 +365,10 @@
--avatar-top: 16px;
}
.commits.stacked {
border-top: none;
}
/* BASE ROW */
.base-row-container {

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';
@ -33,7 +33,7 @@
export let commit: (() => void) | undefined = undefined;
const user = getContextStore(User);
const selectedOwnership = getContextStore(Ownership);
const selectedOwnership = getContextStore(SelectedOwnership);
const aiService = getContext(AIService);
const branch = getContextStore(VirtualBranch);
const project = getContext(Project);
@ -71,7 +71,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

@ -70,7 +70,7 @@
}}
></textarea>
{:else}
<div class="markdown bubble-message scrollbar text-13 text-body">
<div class="bubble-message scrollbar text-13 text-body">
<Markdown content={promptMessage.content} />
</div>
{/if}

View File

@ -1,6 +1,8 @@
<script lang="ts">
import Spacer from '../shared/Spacer.svelte';
import { Project } from '$lib/backend/projects';
import CommitCard from '$lib/commit/CommitCard.svelte';
import UpdateBaseButton from '$lib/components/UpdateBaseButton.svelte';
import { projectMergeUpstreamWarningDismissed } from '$lib/config/config';
import { getGitHost } from '$lib/gitHost/interface/gitHost';
import { ModeService } from '$lib/modes/service';
@ -18,6 +20,7 @@
const branchController = getContext(BranchController);
const modeService = getContext(ModeService);
const gitHost = getGitHost();
const project = getContext(Project);
const mode = modeService.mode;
@ -36,6 +39,8 @@
showInfo('Stashed conflicting branches', infoText);
}
}
let updateBaseButton: UpdateBaseButton | undefined;
</script>
<div class="wrapper">
@ -46,16 +51,21 @@
</div>
{#if base.upstreamCommits?.length > 0}
<UpdateBaseButton bind:this={updateBaseButton} showButton={false} />
<Button
style="pop"
kind="solid"
tooltip={`Merges the commits from ${base.branchName} into the base of all applied virtual branches`}
disabled={$mode?.type !== 'OpenWorkspace'}
onclick={() => {
if ($mergeUpstreamWarningDismissed) {
updateBaseBranch();
if (project.succeedingRebases) {
updateBaseButton?.openModal();
} else {
updateTargetModal.show();
if ($mergeUpstreamWarningDismissed) {
updateBaseBranch();
} else {
updateTargetModal.show();
}
}
}}
>

View File

@ -3,9 +3,11 @@
import FullviewLoading from './FullviewLoading.svelte';
import BranchDropzone from '$lib/branch/BranchDropzone.svelte';
import BranchLane from '$lib/branch/BranchLane.svelte';
import { stackingFeature } from '$lib/config/uiFeatureFlags';
import { cloneElement } from '$lib/dragging/draggable';
import { persisted } from '$lib/persisted/persisted';
import { getContext } from '$lib/utils/context';
import { createKeybind } from '$lib/utils/hotkeys';
import { throttle } from '$lib/utils/misc';
import { BranchController } from '$lib/vbranches/branchController';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
@ -62,8 +64,15 @@
sortedBranches = sortedBranches; // Redraws #each loop.
}
}, 200);
const handleKeyDown = createKeybind({
's t a c k': async () => {
$stackingFeature = !$stackingFeature;
}
});
</script>
<svelte:window on:keydown={handleKeyDown} />
{#if $error}
<div class="p-4" data-tauri-drag-region>Something went wrong...</div>
{:else if !$branches}

View File

@ -108,7 +108,7 @@
<div class="card">
<div class="card__header text-14 text-body text-semibold">{pr.title}</div>
{#if pr.body}
<div class="markdown card__content text-13 text-body">
<div class="card__content text-13 text-body">
<Markdown content={pr.body} />
</div>
{/if}

View File

@ -4,21 +4,19 @@
import { Lexer } from 'marked';
interface Props {
content: string;
content: string | undefined;
}
let { content }: Props = $props();
const lexer = new Lexer(options);
const tokens = lexer.lex(content);
const tokens = $derived.by(() => {
const lexer = new Lexer(options);
return lexer.lex(content ?? '');
});
</script>
<div class="markdown-content">
<MarkdownContent type="init" {tokens} />
<div class="markdown">
{#if tokens}
<MarkdownContent type="init" {tokens} />
{/if}
</div>
<style>
.markdown-content {
display: inline;
}
</style>

View File

@ -1,32 +1,49 @@
<script lang="ts">
/* eslint svelte/valid-compile: "off" */
/* - Required because spreading in prop destructuring still throws eslint errors */
import { renderers } from '$lib/utils/markdownRenderers';
import type { Tokens, Token } from 'marked';
import type { Component } from 'svelte';
type Props =
| { type: 'init'; tokens: Token[] }
| Tokens.Link
| Tokens.Heading
| Tokens.Image
| Tokens.Space
| Tokens.Blockquote
| Tokens.Code
| Tokens.Text
| Tokens.Codespan
| Tokens.Text;
| Tokens.Paragraph
| Tokens.ListItem
| Tokens.List;
let { type, ...rest }: Props = $props();
const { type, ...rest }: Props = $props();
</script>
{#if type && renderers[type as keyof typeof renderers]}
<svelte:component this={renderers[type as keyof typeof renderers] as any} {...rest}>
{#if 'tokens' in rest}
<svelte:self tokens={rest.tokens} />
{/if}
</svelte:component>
{:else if 'tokens' in rest && rest.tokens}
{#if (!type || type === 'init') && 'tokens' in rest && rest.tokens}
{#each rest.tokens as token}
<svelte:self {...token} />
{/each}
{:else if 'raw' in rest}
{@html rest.raw?.replaceAll('\n', '<br />') ?? ''}
{:else if renderers[type]}
{@const CurrentComponent = renderers[type] as Component<Props>}
{#if type === 'list'}
{@const listItems = (rest as Extract<Props, { type: 'list' }>).items}
<CurrentComponent {...rest}>
{#each listItems as item}
{@const ChildComponent = renderers[item.type]}
<ChildComponent {...item}>
<svelte:self tokens={item.tokens} />
</ChildComponent>
{/each}
</CurrentComponent>
{:else}
<CurrentComponent {...rest}>
{#if 'tokens' in rest && rest.tokens}
<svelte:self tokens={rest.tokens} />
{:else if 'raw' in rest}
{rest.raw}
{/if}
</CurrentComponent>
{/if}
{/if}

View File

@ -127,9 +127,7 @@
</span>
</div>
{#if pullrequest.body}
<div class="markdown">
<Markdown content={pullrequest.body} />
</div>
<Markdown content={pullrequest.body} />
{/if}
</div>
<div class="card__footer">

View File

@ -1,36 +1,256 @@
<script lang="ts">
import { showInfo, showError } from '$lib/notifications/toasts';
import { Project } from '$lib/backend/projects';
import { BaseBranchService } from '$lib/baseBranch/baseBranchService';
import CommitCard from '$lib/commit/CommitCard.svelte';
import { getGitHost } from '$lib/gitHost/interface/gitHost';
import { showInfo } from '$lib/notifications/toasts';
import Select from '$lib/select/Select.svelte';
import SelectItem from '$lib/select/SelectItem.svelte';
import { getContext } from '$lib/utils/context';
import { BranchController } from '$lib/vbranches/branchController';
import { VirtualBranch } from '$lib/vbranches/types';
import {
UpstreamIntegrationService,
type BranchStatus,
type BranchStatusesWithBranches,
type Resolution,
type ResolutionApproach
} from '$lib/vbranches/upstreamIntegrationService';
import Button from '@gitbutler/ui/Button.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { Readable } from 'svelte/store';
interface Props {
showButton?: boolean;
}
const { showButton = true }: Props = $props();
const upstreamIntegrationService = getContext(UpstreamIntegrationService);
const baseBranchService = getContext(BaseBranchService);
const gitHost = getGitHost();
const branchController = getContext(BranchController);
const project = getContext(Project);
let loading = false;
const base = baseBranchService.base;
let modal = $state<Modal>();
let modalOpeningState = $state<'inert' | 'loading' | 'completed'>('inert');
let branchStatuses = $state<Readable<BranchStatusesWithBranches | undefined>>();
let results = $state(new SvelteMap<string, Resolution>());
let statuses = $state<{ branch: VirtualBranch; status: BranchStatus }[]>([]);
$effect(() => {
if ($branchStatuses?.type !== 'updatesRequired') {
statuses = [];
return;
}
const statusesTmp = [...$branchStatuses.subject];
statusesTmp.sort((a, b) => {
if (
(a.status.type !== 'fullyIntegrated' && b.status.type !== 'fullyIntegrated') ||
(a.status.type === 'fullyIntegrated' && b.status.type === 'fullyIntegrated')
) {
return (a.branch?.name || 'Unknown').localeCompare(b.branch?.name || 'Unknown');
}
if (a.status.type === 'fullyIntegrated') {
return 1;
} else {
return -1;
}
});
// Side effect, refresh results
results = new SvelteMap(
statusesTmp.map((status) => {
let defaultApproach: ResolutionApproach;
if (status.status.type === 'fullyIntegrated') {
defaultApproach = { type: 'delete' };
} else {
if (status.branch.allowRebasing) {
defaultApproach = { type: 'rebase' };
} else {
defaultApproach = { type: 'merge' };
}
}
return [
status.branch.id,
{
branchId: status.branch.id,
branchTree: status.branch.tree,
approach: defaultApproach
}
];
})
);
statuses = statusesTmp;
});
$effect(() => {
if ($branchStatuses && modalOpeningState === 'loading') {
modalOpeningState = 'completed';
modal?.show();
console.log(modalOpeningState);
}
});
let integratingUpstream = $state<'inert' | 'loading' | 'complete'>('inert');
export function openModal() {
modalOpeningState = 'loading';
integratingUpstream = 'inert';
branchStatuses = upstreamIntegrationService.upstreamStatuses();
}
function onClose() {
modalOpeningState = 'inert';
}
async function integrate() {
integratingUpstream = 'loading';
await upstreamIntegrationService.integrateUpstream([...results.values()]);
await baseBranchService.refresh();
integratingUpstream = 'complete';
modal?.close();
}
async function updateBaseBranch() {
let infoText = await branchController.updateBaseBranch();
if (infoText) {
showInfo('Stashed conflicting branches', infoText);
}
}
</script>
<Button
size="tag"
style="error"
kind="solid"
tooltip="Merge upstream into common base"
onclick={async () => {
loading = true;
try {
let infoText = await branchController.updateBaseBranch();
if (infoText) {
showInfo('Stashed conflicting branches', infoText);
}
} catch (err) {
showError('Failed update workspace', err);
} finally {
loading = false;
}
}}
>
{#if loading}
busy...
{:else}
Update
<Modal bind:this={modal} title="Integrate upstream changes" {onClose} width="small">
{#if $base}
<div class="upstream-commits">
{#each $base.upstreamCommits as commit, index}
<CommitCard
{commit}
first={index === 0}
last={index === $base.upstreamCommits.length - 1}
isUnapplied={true}
commitUrl={$gitHost?.commitUrl(commit.id)}
type="remote"
/>
{/each}
</div>
{/if}
</Button>
<div class="statuses">
{#each statuses as { branch, status }}
<div class="branch-status" class:integrated={status.type === 'fullyIntegrated'}>
<div class="description">
<h5 class="text-16">{branch?.name || 'Unknown'}</h5>
{#if status.type === 'conflicted'}
<p>Conflicted</p>
{:else if status.type === 'saflyUpdatable' || status.type === 'empty'}
<p>No Conflicts</p>
{:else if status.type === 'fullyIntegrated'}
<p>Integrated</p>
{/if}
</div>
<div class="action" class:action--centered={status.type === 'fullyIntegrated'}>
{#if status.type === 'fullyIntegrated'}
<p>Changes included in base branch</p>
{:else if results.get(branch.id)}
<Select
value={results.get(branch.id)!.approach.type}
onselect={(value) => {
const result = results.get(branch.id)!
results.set(branch.id, {...result, approach: { type: value as "rebase" | "merge" | "unapply" }})
}}
options={[
{ label: 'Rebase', value: 'rebase' },
{ label: 'Merge', value: 'merge' },
{ label: 'Stash', value: 'unapply' }
]}
>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={highlighted} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
{/if}
</div>
</div>
{/each}
</div>
{#snippet controls()}
<Button onclick={() => modal?.close()}>Cancel</Button>
<Button onclick={integrate} style="pop" kind="solid" loading={integratingUpstream === 'loading'}
>Integrate</Button
>
{/snippet}
</Modal>
{#if showButton && ($base?.upstreamCommits.length || 0) > 0}
<Button
size="tag"
style="error"
kind="solid"
tooltip="Merge upstream into common base"
onclick={() => {
if (project.succeedingRebases) {
openModal();
} else {
updateBaseBranch();
}
}}
loading={modalOpeningState === 'loading'}
>
Update
</Button>
{/if}
<style>
.upstream-commits {
text-align: left;
margin-top: -10px;
margin-bottom: 8px;
}
.branch-status {
display: flex;
justify-content: space-between;
padding: 14px;
&.integrated {
background-color: var(--clr-bg-2);
}
& .description {
display: flex;
flex-direction: column;
gap: 8px;
text-align: left;
}
& .action {
width: 144px;
&.action--centered {
display: flex;
align-items: center;
justify-content: center;
}
}
}
</style>

View File

@ -189,7 +189,7 @@
background: var(--clr-bg-2);
border: 1px solid var(--clr-border-2);
border-radius: var(--radius-m);
box-shadow: var(--shadow-s);
box-shadow: var(--fx-shadow-s);
animation: fadeIn 0.08s ease-out forwards;
}

View File

@ -0,0 +1,9 @@
<script lang="ts">
interface Props {
raw: string;
}
const { raw }: Props = $props();
</script>
{@html raw}

View File

@ -0,0 +1,21 @@
<script lang="ts">
import { type Snippet } from 'svelte';
interface Props {
ordered: boolean;
start: number | '';
children: Snippet;
}
const { ordered, start, children }: Props = $props();
</script>
{#if ordered}
<ol start={Number(start)}>
{@render children()}
</ol>
{:else}
<ul>
{@render children()}
</ul>
{/if}

View File

@ -0,0 +1,13 @@
<script lang="ts">
import { type Snippet } from 'svelte';
interface Props {
children: Snippet;
}
const { children }: Props = $props();
</script>
<li>
{@render children()}
</li>

View File

@ -1,2 +0,0 @@
<br />
<br />

View File

@ -1,11 +1,13 @@
<script lang="ts">
import { type Snippet } from 'svelte';
interface Props {
text: string;
children: Snippet;
}
const { text }: Props = $props();
const { children }: Props = $props();
</script>
<span>
{text}
{@render children()}
</span>

View File

@ -16,7 +16,9 @@ export function featureInlineUnifiedDiffs(): Persisted<boolean> {
return persisted(false, key);
}
export function featureBranchStacking(): Persisted<boolean> {
const key = 'branchStacking';
export const stackingFeature = persisted(false, 'stackingFeature');
export function featureTopics(): Persisted<boolean> {
const key = 'feature--topics';
return persisted(false, key);
}

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,52 +3,46 @@
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 { selectFilesInList } from '$lib/utils/selectFilesInList';
import { maybeMoveSelection } from '$lib/utils/selection';
import { updateSelection } from '$lib/utils/selection';
import { getCommitStore } from '$lib/vbranches/contexts';
import { FileIdSelection, stringifyFileKey } from '$lib/vbranches/fileIdSelection';
import { sortLikeFileTree } from '$lib/vbranches/filetree';
import Button from '@gitbutler/ui/Button.svelte';
import type { AnyFile } from '$lib/vbranches/types';
export let files: AnyFile[];
export let isUnapplied = false;
export let showCheckboxes = false;
export let allowMultiple = false;
export let readonly = false;
const MERGE_DIFF_COMMAND = 'git diff-tree --cc ';
interface Props {
files: AnyFile[];
isUnapplied?: boolean;
showCheckboxes?: boolean;
allowMultiple?: boolean;
readonly?: boolean;
}
const {
files,
isUnapplied = false,
showCheckboxes = false,
allowMultiple = false,
readonly = false
}: 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);
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()}
@ -60,12 +54,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>
@ -81,6 +75,21 @@
loadMore();
}}
role="listbox"
onkeydown={(e) => {
e.preventDefault();
updateSelection(
{
allowMultiple,
shiftKey: e.shiftKey,
key: e.key,
targetElement: e.currentTarget as HTMLElement,
files: displayedFiles,
selectedFileIds: $fileIdSelection,
fileIdSelection,
commitId: $commit?.id
}
);
}}
>
{#each displayedFiles as file (file.id)}
<FileListItem
@ -92,29 +101,6 @@
onclick={(e) => {
selectFilesInList(e, file, fileIdSelection, displayedFiles, allowMultiple, $commit);
}}
onkeydown={(e) => {
e.preventDefault();
maybeMoveSelection(
{
allowMultiple,
shiftKey: e.shiftKey,
key: e.key,
targetElement: e.currentTarget as HTMLElement,
file,
files: displayedFiles,
selectedFileIds: $fileIdSelection,
fileIdSelection,
commitId: $commit?.id
}
);
if (e.key === 'Escape') {
fileIdSelection.clear();
const targetEl = e.target as HTMLElement;
targetEl.blur();
}
}}
/>
{/each}
</LazyloadContainer>

View File

@ -1,11 +1,10 @@
<script lang="ts">
import HunkViewer from '$lib/hunk/HunkViewer.svelte';
import InfoMessage from '$lib/shared/InfoMessage.svelte';
import LargeDiffMessage from '$lib/shared/LargeDiffMessage.svelte';
import { computeAddedRemovedByHunk } from '$lib/utils/metrics';
import { getLocalCommits, getLocalAndRemoteCommits } from '$lib/vbranches/contexts';
import { getLockText } from '$lib/vbranches/tooltip';
import Icon from '@gitbutler/ui/Icon.svelte';
import Tooltip from '@gitbutler/ui/Tooltip.svelte';
import type { HunkSection, ContentSection } from '$lib/utils/fileSections';
interface Props {
@ -68,22 +67,26 @@
{#each sections as section}
{@const { added, removed } = computeAddedRemovedByHunk(section)}
{#if 'hunk' in section}
{@const isHunkLocked = section.hunk.lockedTo && section.hunk.lockedTo.length > 0 && commits}
<div class="hunk-wrapper">
<div class="indicators text-11 text-semibold">
<div class="text-10 semibold added-removed">
<span class="added">+{added}</span>
<span class="removed">-{removed}</span>
{#if isHunkLocked || section.hunk.poisoned}
<div class="indicators text-11 text-semibold">
{#if isHunkLocked}
<InfoMessage filled outlined={false} style="warning" icon="locked">
<svelte:fragment slot="content"
>{getLockText(section.hunk.lockedTo, commits)}</svelte:fragment
>
</InfoMessage>
{/if}
{#if section.hunk.poisoned}
<InfoMessage filled outlined={false}>
<svelte:fragment slot="content"
>Can not manage this hunk because it depends on changes from multiple branches</svelte:fragment
>
</InfoMessage>
{/if}
</div>
{#if section.hunk.lockedTo && section.hunk.lockedTo.length > 0 && commits}
<Tooltip text={getLockText(section.hunk.lockedTo, commits)}>
<Icon name="locked-small" color="warning" />
</Tooltip>
{/if}
{#if section.hunk.poisoned}
Can not manage this hunk because it depends on changes from multiple branches
{/if}
</div>
{/if}
<HunkViewer
{filePath}
{section}
@ -116,30 +119,10 @@
flex-direction: column;
gap: 10px;
}
.indicators {
display: flex;
align-items: center;
gap: 2px;
}
.added-removed {
display: flex;
border-radius: var(--radius-s);
overflow: hidden;
}
.removed,
.added {
padding: 2px 4px;
}
.added {
color: var(--clr-scale-succ-30);
background-color: var(--clr-theme-succ-bg);
}
.removed {
color: var(--clr-scale-err-30);
background-color: var(--clr-theme-err-bg);
}
</style>

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';
@ -21,14 +22,15 @@
showCheckbox: boolean;
readonly: boolean;
onclick: (e: MouseEvent) => void;
onkeydown: (e: KeyboardEvent) => void;
onkeydown?: (e: KeyboardEvent) => void;
}
const { file, isUnapplied, selected, showCheckbox, readonly, onclick, onkeydown }: Props =
$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

@ -36,6 +36,10 @@ export class AzureDevOps implements GitHost {
return undefined;
}
issueService() {
return undefined;
}
prService() {
return undefined;
}

View File

@ -40,6 +40,10 @@ export class BitBucket implements GitHost {
return undefined;
}
issueService() {
return undefined;
}
prService() {
return undefined;
}

View File

@ -0,0 +1,29 @@
import type { GitHostIssueService } from '$lib/gitHost/interface/gitHostIssueService';
import type { RepoInfo } from '$lib/url/gitUrl';
import type { Octokit } from '@octokit/rest';
export class GitHubIssueService implements GitHostIssueService {
constructor(
private octokit: Octokit,
private repository: RepoInfo
) {}
async create(title: string, body: string, labels: string[]): Promise<void> {
await this.octokit.rest.issues.create({
repo: this.repository.name,
owner: this.repository.owner,
title,
body,
labels
});
}
async listLabels(): Promise<string[]> {
const result = await this.octokit.paginate(this.octokit.rest.issues.listLabelsForRepo, {
repo: this.repository.name,
owner: this.repository.owner
});
return result.map((label) => label.name);
}
}

View File

@ -41,6 +41,10 @@ export class GitLab implements GitHost {
return undefined;
}
issueService() {
return undefined;
}
prService() {
return undefined;
}

View File

@ -1,4 +1,5 @@
import { buildContextStore } from '$lib/utils/context';
import type { GitHostIssueService } from '$lib/gitHost/interface/gitHostIssueService';
import type { GitHostBranch } from './gitHostBranch';
import type { GitHostChecksMonitor } from './gitHostChecksMonitor';
import type { GitHostListingService } from './gitHostListingService';
@ -8,6 +9,8 @@ export interface GitHost {
// Lists PRs for the repo.
listService(): GitHostListingService | undefined;
issueService(): GitHostIssueService | undefined;
// Detailed information about a specific PR.
prService(): GitHostPrService | undefined;

View File

@ -0,0 +1,4 @@
export interface GitHostIssueService {
create(title: string, body: string, labels: string[]): Promise<void>;
listLabels(): Promise<string[]>;
}

View File

@ -1,6 +1,6 @@
import { buildContextStore } from '$lib/utils/context';
import type { GitHostPrMonitor } from './gitHostPrMonitor';
import type { DetailedPullRequest, MergeMethod, PullRequest } from './types';
import type { CreatePullRequestArgs, DetailedPullRequest, MergeMethod, PullRequest } from './types';
import type { Writable } from 'svelte/store';
export const [getGitHostPrService, createGitHostPrServiceStore] = buildContextStore<

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,128 @@
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}
style="--number-col-width: {NUMBER_COLUMN_WIDTH_PX}px;"
data-no-drag
class:diff-line-deletion={row.type === SectionType.RemovedLines}
class:diff-line-addition={row.type === SectionType.AddedLines}
style="--number-col-width: {NUMBER_COLUMN_WIDTH_PX + 2}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'}"
style="--tab-size: {tabSize}"
>
<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 class="table__title">
<tr
onclick={() => {
selectable && handleSelected(hunk, !isSelected);
}}
>
<th class="table__checkbox-container" class:selected={isSelected} colspan={2}>
<div class="table__checkbox">
{#if selectable}
<Checkbox
checked={isSelected}
small
onclick={() => {
selectable && handleSelected(hunk, !isSelected);
}}
/>
{/if}
</div>
</th>
<td class="table__title-content">
<span style="left: {NUMBER_COLUMN_WIDTH_PX * 2}px">
{`@@ -${hunkLineInfo.beforLineStart},${hunkLineInfo.beforeLineCount} +${hunkLineInfo.afterLineStart},${hunkLineInfo.afterLineCount} @@`}
</span>
{#if !draggingDisabled}
<div class="table__drag-handle">
<Icon name="draggable" />
</div>
{/if}
</td>
</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}
@ -315,45 +397,102 @@
</ScrollableContainer>
</div>
<style>
<style lang="postcss">
.table__wrapper {
border: 1px solid var(--clr-border-2);
border-radius: var(--radius-s);
border-radius: var(--radius-m);
background-color: var(--clr-diff-line-bg);
overflow-x: auto;
border: 1px solid var(--clr-border-2);
&:hover .table__drag-handle {
transform: translateY(0) translateX(0) scale(1);
transform: scale(1);
opacity: 1;
pointer-events: auto;
}
}
.table__drag-handle {
position: absolute;
table,
.table__section {
width: 100%;
font-family: var(--mono-font-family);
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;
height: 28px;
}
.table__checkbox-container {
z-index: var(--z-lifted);
border-right: 1px solid var(--clr-border-2);
border-bottom: 1px solid 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);
border-right: 1px solid var(--clr-diff-selected-count-border);
border-bottom: 1px solid var(--clr-diff-selected-count-border);
}
}
.table__checkbox {
padding: 4px 6px;
display: flex;
align-items: center;
}
.table__title {
cursor: grab;
top: 6px;
user-select: none;
}
.table__drag-handle {
position: fixed;
right: 6px;
top: 6px;
box-sizing: border-box;
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: scale(0.9);
transform-origin: top right;
pointer-events: none;
color: var(--clr-text-2);
transition:
opacity 0.2s,
transform 0.2s;
}
.table__section {
border-spacing: 0;
width: 100%;
font-family: monospace;
.table__title-content {
position: relative;
font-family: var(--mono-font-family);
font-size: 12px;
padding: 4px 6px;
text-wrap: nowrap;
color: var(--clr-text-2);
border-bottom: 1px solid var(--clr-border-2);
}
.table__numberColumn {
@ -364,7 +503,6 @@
text-align: center;
padding: 0 4px;
text-align: right;
cursor: var(--cursor);
user-select: none;
position: sticky;
@ -372,24 +510,32 @@
width: var(--number-col-width);
min-width: var(--number-col-width);
box-shadow: inset -1px 0 0 0 var(--clr-diff-count-border);
border-right: 1px solid var(--clr-border-2);
&.diff-line-addition {
background-color: var(--clr-diff-addition-count-bg);
color: var(--clr-diff-addition-count-text);
box-shadow: inset -1px 0 0 0 var(--clr-diff-addition-count-border);
border-color: var(--clr-diff-addition-count-border);
}
&.diff-line-deletion {
background-color: var(--clr-diff-deletion-count-bg);
color: var(--clr-diff-deletion-count-text);
box-shadow: inset -1px 0 0 0 var(--clr-diff-deletion-count-border);
border-color: var(--clr-diff-deletion-count-border);
}
&.selected {
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.is-last {
border-bottom-left-radius: var(--radius-s);
}
}
@ -400,6 +546,7 @@
}
.table__textContent {
z-index: var(--z-lifted);
width: 100%;
font-size: 12px;
padding-left: 4px;

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

@ -19,9 +19,7 @@
{title}
</h1>
{/if}
{#if children}
{@render children()}
{/if}
{@render children()}
</div>
</div>
</ScrollableContainer>

View File

@ -1,8 +1,3 @@
<script lang="ts" module>
// If this is not present, eslint complains that T is not defined below
type T = unknown;
</script>
<script lang="ts" generics="T">
/**
* Lazily renders a list of many many items. This is intended to be used
@ -12,7 +7,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

@ -6,8 +6,10 @@
import WorkspaceButton from './WorkspaceButton.svelte';
import Resizer from '../shared/Resizer.svelte';
import { Project } from '$lib/backend/projects';
import { featureTopics } from '$lib/config/uiFeatureFlags';
import { ModeService } from '$lib/modes/service';
import EditButton from '$lib/navigation/EditButton.svelte';
import TopicsButton from '$lib/navigation/TopicsButton.svelte';
import { persisted } from '$lib/persisted/persisted';
import { platformName } from '$lib/platform/platform';
import { SETTINGS, type Settings } from '$lib/settings/userSettings';
@ -43,6 +45,8 @@
const modeService = getContext(ModeService);
const mode = modeService.mode;
const topicsEnabled = featureTopics();
</script>
<svelte:window on:keydown={handleKeyDown} />
@ -120,6 +124,10 @@
{:else if $mode?.type === 'Edit'}
<EditButton href={`/${project.id}/edit`} isNavCollapsed={$isNavCollapsed} />
{/if}
{#if $topicsEnabled}
<TopicsButton href={`/${project.id}/topics`} isNavCollapsed={$isNavCollapsed} />
{/if}
</div>
</div>

View File

@ -0,0 +1,34 @@
<script lang="ts">
import DomainButton from '$lib/navigation/DomainButton.svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
interface Props {
href: string;
isNavCollapsed: boolean;
}
const { href, isNavCollapsed }: Props = $props();
const label = 'Topics';
</script>
<DomainButton
isSelected={$page.url.pathname === href}
{isNavCollapsed}
tooltipLabel={label}
onmousedown={async () => await goto(href)}
>
<img class="icon" src="/images/domain-icons/working-branches.svg" alt="" />
{#if !isNavCollapsed}
<span class="text-14 text-semibold" class:collapsed-txt={isNavCollapsed}>{label}</span>
{/if}
</DomainButton>
<style lang="postcss">
.icon {
border-radius: var(--radius-s);
height: 20px;
width: 20px;
flex-shrink: 0;
}
</style>

View File

@ -1,8 +1,6 @@
<script lang="ts">
import DomainButton from './DomainButton.svelte';
import UpdateBaseButton from '../components/UpdateBaseButton.svelte';
import { BaseBranch } from '$lib/baseBranch/baseBranch';
import { getContextStore } from '$lib/utils/context';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
@ -13,7 +11,6 @@
const { href, isNavCollapsed }: Props = $props();
const baseBranch = getContextStore(BaseBranch);
const label = 'Workspace';
</script>
@ -27,7 +24,7 @@
{#if !isNavCollapsed}
<span class="text-14 text-semibold" class:collapsed-txt={isNavCollapsed}>{label}</span>
{#if ($baseBranch?.behind || 0) > 0 && !isNavCollapsed}
{#if !isNavCollapsed}
<UpdateBaseButton />
{/if}
{/if}

View File

@ -16,8 +16,8 @@
type Props = {
loading: boolean;
disabled: boolean;
tooltip: string;
disabled?: boolean;
tooltip?: string;
click: (opts: { draft: boolean }) => void;
};
const { loading, disabled, tooltip, click }: Props = $props();

View File

@ -4,9 +4,9 @@
import InfoMessage from '../shared/InfoMessage.svelte';
import { Project } from '$lib/backend/projects';
import { BaseBranchService } from '$lib/baseBranch/baseBranchService';
import { stackingFeature } from '$lib/config/uiFeatureFlags';
import { getGitHostChecksMonitor } from '$lib/gitHost/interface/gitHostChecksMonitor';
import { getGitHostListingService } from '$lib/gitHost/interface/gitHostListingService';
import { getGitHostPrMonitor } from '$lib/gitHost/interface/gitHostPrMonitor';
import { getGitHostPrService } from '$lib/gitHost/interface/gitHostPrService';
import { getContext } from '$lib/utils/context';
import * as toasts from '$lib/utils/toasts';
@ -18,6 +18,12 @@
import type { MessageStyle } from '$lib/shared/InfoMessage.svelte';
import type iconsJson from '@gitbutler/ui/data/icons.json';
interface Props {
upstreamName: string;
}
const { upstreamName }: Props = $props();
type StatusInfo = {
text: string;
icon: keyof typeof iconsJson | undefined;
@ -29,31 +35,38 @@
const baseBranchService = getContext(BaseBranchService);
const project = getContext(Project);
const gitHostListingService = getGitHostListingService();
const prStore = $derived($gitHostListingService?.prs);
const prs = $derived(prStore ? $prStore : undefined);
const listedPr = $derived(prs?.find((pr) => pr.sourceBranch === upstreamName));
const prNumber = $derived(listedPr?.number);
const prService = getGitHostPrService();
const prMonitor = getGitHostPrMonitor();
const prMonitor = $derived(prNumber ? $prService?.prMonitor(prNumber) : undefined);
const checksMonitor = getGitHostChecksMonitor();
const listingService = getGitHostListingService();
// This PR has been loaded on demand, and contains more details than the version
// obtained when listing them.
const pr = $derived($prMonitor?.pr);
const pr = $derived(prMonitor?.pr);
const checks = $derived($checksMonitor?.status);
// While the pr monitor is set to fetch updates by interval, we want
// frequent updates while checks are running.
$effect(() => {
if ($checks) $prMonitor?.refresh();
if ($checks) prMonitor?.refresh();
});
let isMerging = $state(false);
const lastFetch = $derived($prMonitor?.lastFetch);
const lastFetch = $derived(prMonitor?.lastFetch);
const timeAgo = $derived($lastFetch ? createTimeAgoStore($lastFetch) : undefined);
const mrLoading = $derived($prMonitor?.loading);
const mrLoading = $derived(prMonitor?.loading);
const checksLoading = $derived($checksMonitor?.loading);
const checksError = $derived($checksMonitor?.error);
const detailsError = $derived($prMonitor?.error);
const detailsError = $derived(prMonitor?.error);
function getChecksCount(status: ChecksStatus): string {
if (!status) return 'Running checks';
@ -161,7 +174,11 @@
</script>
{#if $pr}
<div class="card pr-card">
<div
class:card={!$stackingFeature}
class:pr-card={!$stackingFeature}
class:stacked-pr={$stackingFeature}
>
<div class="floating-button">
<Button
icon="update-small"
@ -172,15 +189,19 @@
tooltip={$timeAgo ? 'Updated ' + $timeAgo : ''}
onclick={async () => {
$checksMonitor?.update();
$prMonitor?.refresh();
prMonitor?.refresh();
}}
/>
</div>
<div class="pr-title text-13 text-semibold">
<div
class:pr-title={!$stackingFeature}
class:stacked-pr-title={$stackingFeature}
class="text-13 text-semibold"
>
<span style="color: var(--clr-scale-ntrl-50)">PR #{$pr?.number}:</span>
{$pr.title}
</div>
<div class="pr-tags">
<div class:pr-tags={!$stackingFeature} class:stacked-pr-tags={$stackingFeature}>
<Button
size="tag"
clickable={false}
@ -213,7 +234,7 @@
immediately.
-->
{#if $pr}
<div class="pr-actions">
<div class:pr-actions={!$stackingFeature} class:stacked-pr-actions={$stackingFeature}>
{#if infoProps}
<InfoMessage icon={infoProps.icon} filled outlined={false} style={infoProps.messageStyle}>
<svelte:fragment slot="content">
@ -239,8 +260,8 @@
await $prService?.merge(method, $pr.number);
await baseBranchService.fetchFromRemotes();
await Promise.all([
$prMonitor?.refresh(),
$listingService?.refresh(),
prMonitor?.refresh(),
$gitHostListingService?.refresh(),
vbranchService.refresh(),
baseBranchService.refresh()
]);
@ -258,6 +279,12 @@
{/if}
<style lang="postcss">
.stacked-pr {
position: relative;
display: flex;
flex-direction: column;
}
.pr-card {
position: relative;
padding: 14px;
@ -272,11 +299,24 @@
cursor: text;
}
.stacked-pr-title {
color: var(--clr-scale-ntrl-0);
padding: 14px 14px 12px 14px;
user-select: text;
cursor: text;
}
.pr-tags {
display: flex;
gap: 4px;
}
.stacked-pr-tags {
display: flex;
gap: 4px;
padding: 0 14px 12px 14px;
}
.pr-actions {
margin-top: 14px;
display: flex;
@ -284,6 +324,13 @@
gap: 8px;
}
.stacked-pr-actions {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 14px 12px 14px;
}
.floating-button {
position: absolute;
right: 6px;

View File

@ -241,7 +241,7 @@
border-radius: var(--radius-m);
border: 1px solid var(--clr-border-2);
background: var(--clr-bg-1);
box-shadow: var(--shadow-s);
box-shadow: var(--fx-shadow-s);
overflow: hidden;
transform-origin: top;

View File

@ -7,9 +7,10 @@
minTriggerCount: number;
role?: AriaRole | undefined | null;
ontrigger: (lastChild: Element) => void;
onkeydown?: (e: KeyboardEvent) => void;
}
let { children, minTriggerCount, role, ontrigger }: Props = $props();
let { children, minTriggerCount, role, ontrigger, onkeydown }: Props = $props();
let lazyContainerEl: HTMLDivElement;
@ -47,7 +48,7 @@
});
</script>
<div class="lazy-container" {role} bind:this={lazyContainerEl}>
<div class="lazy-container" {role} bind:this={lazyContainerEl} {onkeydown}>
{@render children()}
</div>

View File

@ -0,0 +1,149 @@
<script lang="ts">
import { getGitHost } from '$lib/gitHost/interface/gitHost';
import TextArea from '$lib/shared/TextArea.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import { TopicService, type Topic } from '$lib/topics/service';
import { getContext } from '$lib/utils/context';
import { createKeybind } from '$lib/utils/hotkeys';
import Button from '@gitbutler/ui/Button.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
interface Props {
registerKeypress?: boolean;
topic?: Topic;
}
const { registerKeypress = false, topic }: Props = $props();
const gitHost = getGitHost();
const issueService = $derived($gitHost?.issueService());
const topicService = getContext(TopicService);
let modal = $state<Modal>();
let chooseLabelModal = $state<Modal>();
let availables = $state<string[]>([]);
let labels = $state<string[]>([]);
let title = $state(topic?.title || '');
let body = $state(topic?.body || '');
$effect(() => {
issueService?.listLabels().then((labels) => {
availables = labels;
});
});
let submitProgress = $state<'inert' | 'loading' | 'complete'>('inert');
async function submit() {
submitProgress = 'loading';
issueService?.create(title, body, labels);
if (topic) {
const updatedTopic = { ...topic, title, body, hasIssue: true };
topicService.update(updatedTopic);
} else {
topicService.create(title, body, true);
}
submitProgress = 'complete';
modal?.close();
}
export function open() {
title = topic?.title || '';
body = topic?.body || '';
labels = [];
submitProgress = 'inert';
modal?.show();
}
let handleKeyDown = $state(() => {});
$effect(() => {
if (registerKeypress && issueService) {
handleKeyDown = createKeybind({
'$mod+i': open
});
} else {
handleKeyDown = () => {};
}
});
</script>
<svelte:window on:keydown={handleKeyDown} />
{#if issueService}
<Modal bind:this={modal}>
<h2 class="text-18 text-bold">Create an issue</h2>
<div class="input">
<p class="text-14 label">Title</p>
<TextBox bind:value={title} />
</div>
<div class="input">
<p class="text-14 label">Body</p>
<TextArea bind:value={body} />
</div>
<div class="labels">
{#each labels as label}
<Button onclick={() => (labels = labels.filter((l) => l !== label))} size="tag"
>{label}</Button
>
{/each}
<Modal bind:this={chooseLabelModal} width="small">
<div class="availables">
{#each availables.filter((label) => !labels.includes(label)) as label}
<Button
onclick={() => {
labels.push(label);
chooseLabelModal?.close();
}}
size="tag">{label}</Button
>
{/each}
</div>
</Modal>
<Button icon="plus-small" size="tag" onclick={() => chooseLabelModal?.show()}
>Add Label</Button
>
</div>
{#snippet controls()}
<Button onclick={() => modal?.close()}>Cancel</Button>
<Button kind="solid" style="pop" onclick={submit} loading={submitProgress === 'loading'}
>Submit</Button
>
{/snippet}
</Modal>
{/if}
<style lang="postcss">
.input {
margin-top: 8px;
}
.label {
margin-bottom: 4px;
}
.labels {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.availables {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>

View File

@ -0,0 +1,128 @@
<script lang="ts">
import TextArea from '$lib/shared/TextArea.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import { TopicService, type Topic } from '$lib/topics/service';
import { getContext } from '$lib/utils/context';
import { createKeybind } from '$lib/utils/hotkeys';
import Button from '@gitbutler/ui/Button.svelte';
import Icon from '@gitbutler/ui/Icon.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
interface Props {
registerKeypress?: boolean;
topic?: Topic;
}
const { registerKeypress = false, topic }: Props = $props();
const topicService = getContext(TopicService);
let modal = $state<Modal>();
let title = $state(topic?.title || '');
let body = $state(topic?.body || '');
let submitProgress = $state<'inert' | 'loading' | 'complete'>('inert');
async function submit() {
submitProgress = 'loading';
if (topic) {
const updatedTopic = { ...topic, title, body };
topicService.update(updatedTopic);
} else {
topicService.create(title, body);
}
submitProgress = 'complete';
modal?.close();
}
export function open() {
title = topic?.title || '';
body = topic?.body || '';
submitProgress = 'inert';
modal?.show();
}
let handleKeyDown = $state(() => {});
$effect(() => {
if (registerKeypress) {
handleKeyDown = createKeybind({
'$mod+k': open
});
} else {
handleKeyDown = () => {};
}
});
let detailsExpanded = $state(!!topic?.body);
</script>
<svelte:window on:keydown={handleKeyDown} />
<Modal bind:this={modal}>
<h2 class="text-18 text-bold">Create an topic</h2>
<div class="input">
<p class="text-14 label">Title</p>
<TextBox bind:value={title} />
</div>
<div class="details">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="details__header" onclick={() => (detailsExpanded = !detailsExpanded)}>
<p class="text-13">Add details</p>
{#if detailsExpanded}
<Icon name="chevron-down" />
{:else}
<Icon name="chevron-up" />
{/if}
</div>
<div class="details__expanded" class:hidden={!detailsExpanded}>
<div class="input">
<p class="text-14 label">Body</p>
<TextArea bind:value={body} />
</div>
</div>
</div>
{#snippet controls()}
<Button onclick={() => modal?.close()}>Cancel</Button>
<Button kind="solid" style="pop" onclick={submit} loading={submitProgress === 'loading'}
>{topic ? 'Update' : 'Create'}</Button
>
{/snippet}
</Modal>
<style lang="postcss">
.input {
margin-top: 8px;
}
.label {
margin-bottom: 4px;
}
.details {
margin-top: 16px;
}
.details__header {
display: flex;
justify-content: space-between;
}
.details__expanded {
margin-top: 8px;
&.hidden {
display: none;
}
}
</style>

View File

@ -0,0 +1,125 @@
<script lang="ts">
import Markdown from '$lib/components/Markdown.svelte';
import { getGitHost } from '$lib/gitHost/interface/gitHost';
import CreateIssueModal from '$lib/topics/CreateIssueModal.svelte';
import CreateTopicModal from '$lib/topics/CreateTopicModal.svelte';
import { TopicService, type Topic } from '$lib/topics/service';
import { getContext } from '$lib/utils/context';
import Button from '@gitbutler/ui/Button.svelte';
import Icon from '@gitbutler/ui/Icon.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
interface Props {
topic: Topic;
}
const { topic }: Props = $props();
const topicService = getContext(TopicService);
const gitHost = getGitHost();
let deleteModal = $state<Modal>();
let expanded = $state(false);
let createIssueModal = $state<CreateIssueModal>();
let createTopicModal = $state<CreateIssueModal>();
</script>
<CreateIssueModal bind:this={createIssueModal} {topic} />
<CreateTopicModal bind:this={createTopicModal} {topic} />
<Modal bind:this={deleteModal} width="small">
<p>Are you sure you want to delete this topic?</p>
{#snippet controls()}
<Button onclick={() => deleteModal?.close()}>Cancel</Button>
<Button
onclick={() => {
topicService.remove(topic);
deleteModal?.close();
}}
kind="solid"
style="error">Delete</Button
>
{/snippet}
</Modal>
<div class="topic">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="header" onclick={() => (expanded = !expanded)}>
<p class="text-14 text-bold title">{topic.title}</p>
<div class="header__details">
{#if topic.hasIssue}
<Button size="tag" clickable={false}>Has Issue</Button>
{/if}
{#if expanded}
<Icon name="chevron-down" />
{:else}
<Icon name="chevron-up" />
{/if}
</div>
</div>
{#if expanded}
<div class="footer">
<div class="markdown text-13 text-body">
<Markdown content={topic.body} />
</div>
<div class="footer__actions">
<Button onclick={() => createTopicModal?.open()}>Edit</Button>
{#if !topic.hasIssue && $gitHost?.issueService()}
<Button onclick={() => createIssueModal?.open()}>Convert to issue</Button>
{/if}
<Button icon="bin" style="error" onclick={() => deleteModal?.show()} />
</div>
</div>
{/if}
</div>
<style lang="postcss">
.topic {
border-bottom: 1px solid var(--clr-border-2);
&:last-child {
border: none;
}
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
}
.header__details {
display: flex;
gap: 8px;
align-items: center;
}
.footer {
border-top: 1px solid var(--clr-border-3);
width: 100%;
padding: 16px;
}
.footer__actions {
display: flex;
justify-content: flex-end;
width: 100%;
gap: 8px;
}
</style>

View File

@ -0,0 +1,51 @@
import { persisted } from '$lib/persisted/persisted';
import { get, type Readable } from 'svelte/store';
import type { Project } from '$lib/backend/projects';
import type { GitHostIssueService } from '$lib/gitHost/interface/gitHostIssueService';
export type Topic = {
title: string;
body: string;
hasIssue: boolean;
createdAt: number;
id: string;
};
export class TopicService {
topics = persisted<Topic[]>([], this.localStorageKey);
constructor(
private project: Project,
private issueService: Readable<GitHostIssueService | undefined>
) {}
private get localStorageKey(): string {
return `TopicService--${this.project.id}`;
}
create(title: string, body: string, hasIssue: boolean = false): Topic {
const topic = {
title,
body,
hasIssue,
createdAt: Date.now(),
id: crypto.randomUUID()
};
this.topics.set([topic, ...get(this.topics)]);
return topic;
}
update(topic: Topic) {
const filteredTopics = get(this.topics).filter((storedTopic) => storedTopic.id !== topic.id);
this.topics.set([topic, ...filteredTopics]);
}
remove(topic: Topic) {
const filteredTopics = get(this.topics).filter((storedTopic) => storedTopic.id !== topic.id);
this.topics.set(filteredTopics);
}
}

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

@ -1,4 +1,7 @@
export function getSelectionDirection(firstFileIndex: number, lastFileIndex: number) {
export function getSelectionDirection(
firstFileIndex: number,
lastFileIndex: number
): 'up' | 'down' {
// detect the direction of the selection
const selectionDirection = lastFileIndex < firstFileIndex ? 'down' : 'up';

View File

@ -2,22 +2,28 @@ import Blockquote from '$lib/components/markdownRenderers/Blockquote.svelte';
import Code from '$lib/components/markdownRenderers/Code.svelte';
import Codespan from '$lib/components/markdownRenderers/Codespan.svelte';
import Heading from '$lib/components/markdownRenderers/Heading.svelte';
import Html from '$lib/components/markdownRenderers/Html.svelte';
import Image from '$lib/components/markdownRenderers/Image.svelte';
import List from '$lib/components/markdownRenderers/List.svelte';
import ListItem from '$lib/components/markdownRenderers/ListItem.svelte';
import Paragraph from '$lib/components/markdownRenderers/Paragraph.svelte';
import Space from '$lib/components/markdownRenderers/Space.svelte';
import Text from '$lib/components/markdownRenderers/Text.svelte';
import Link from '$lib/shared/Link.svelte';
export const renderers = {
link: Link,
image: Image,
space: Space,
blockquote: Blockquote,
code: Code,
codespan: Codespan,
text: Text,
html: Html,
list: List,
list_item: ListItem,
heading: Heading,
paragraph: Paragraph
paragraph: Paragraph,
init: null,
space: null
};
export const options = {

View File

@ -2,71 +2,96 @@
* Shared helper functions for manipulating selected files with keyboard.
*/
import { getSelectionDirection } from './getSelectionDirection';
import { KeyName } from './hotkeys';
import { stringifyFileKey, unstringifyFileKey } from '$lib/vbranches/fileIdSelection';
import type { FileIdSelection } from '$lib/vbranches/fileIdSelection';
import type { AnyFile } from '$lib/vbranches/types';
export function getNextFile(files: AnyFile[], currentId: string): AnyFile | undefined {
function getFile(files: AnyFile[], id: string): AnyFile | undefined {
return files.find((f) => f.id === id);
}
function getNextFile(files: AnyFile[], currentId: string): AnyFile | undefined {
const fileIndex = files.findIndex((f) => f.id === currentId);
return fileIndex !== -1 && fileIndex + 1 < files.length ? files[fileIndex + 1] : undefined;
}
export function getPreviousFile(files: AnyFile[], currentId: string): AnyFile | undefined {
function getPreviousFile(files: AnyFile[], currentId: string): AnyFile | undefined {
const fileIndex = files.findIndex((f) => f.id === currentId);
return fileIndex > 0 ? files[fileIndex - 1] : undefined;
}
interface MoveSelectionParams {
function getTopFile(files: AnyFile[], selectedFileIds: string[]): AnyFile | undefined {
for (const file of files) {
if (selectedFileIds.includes(stringifyFileKey(file.id))) {
return file;
}
}
return undefined;
}
function getBottomFile(files: AnyFile[], selectedFileIds: string[]): AnyFile | undefined {
for (let i = files.length - 1; i >= 0; i--) {
const file = files[i];
if (selectedFileIds.includes(stringifyFileKey(file!.id))) {
return file;
}
}
return undefined;
}
interface UpdateSelectionParams {
allowMultiple: boolean;
shiftKey: boolean;
key: string;
targetElement: HTMLElement;
file: AnyFile;
files: AnyFile[];
selectedFileIds: string[];
fileIdSelection: FileIdSelection;
commitId?: string;
}
export function maybeMoveSelection({
export function updateSelection({
allowMultiple,
shiftKey,
key,
targetElement,
file,
files,
selectedFileIds,
fileIdSelection,
commitId
}: MoveSelectionParams) {
}: UpdateSelectionParams) {
if (!selectedFileIds[0] || selectedFileIds.length === 0) return;
const firstFileId = unstringifyFileKey(selectedFileIds[0]);
const lastFileId = unstringifyFileKey(selectedFileIds.at(-1)!);
const topFileId = getTopFile(files, selectedFileIds)?.id;
const bottomFileId = getBottomFile(files, selectedFileIds)?.id;
let selectionDirection = getSelectionDirection(
files.findIndex((f) => f.id === lastFileId),
files.findIndex((f) => f.id === firstFileId)
);
function getAndAddFile(
getFileFunc: (files: AnyFile[], id: string) => AnyFile | undefined,
id: string
id: string,
getFileFunc?: (files: AnyFile[], id: string) => AnyFile | undefined
) {
const file = getFileFunc(files, id);
const file = getFileFunc?.(files, id) ?? getFile(files, id);
if (file) {
// if file is already selected, do nothing
if (selectedFileIds.includes(stringifyFileKey(file.id, commitId))) return;
fileIdSelection.add(file.id, commitId);
}
}
function getAndClearAndAddFile(
getFileFunc: (files: AnyFile[], id: string) => AnyFile | undefined,
id: string
function getAndClearExcept(
id: string,
getFileFunc?: (files: AnyFile[], id: string) => AnyFile | undefined
) {
const file = getFileFunc(files, id);
const file = getFileFunc?.(files, id) ?? getFile(files, id);
if (file) {
fileIdSelection.clearExcept(file.id, commitId);
@ -74,7 +99,7 @@ export function maybeMoveSelection({
}
switch (key) {
case 'ArrowUp':
case KeyName.Up:
if (shiftKey && allowMultiple) {
// Handle case if only one file is selected
// we should update the selection direction
@ -83,22 +108,21 @@ export function maybeMoveSelection({
} else if (selectionDirection === 'down') {
fileIdSelection.remove(lastFileId, commitId);
}
getAndAddFile(getPreviousFile, lastFileId);
getAndAddFile(lastFileId, getPreviousFile);
} else {
// focus previous file
const previousElement = targetElement.previousElementSibling as HTMLElement;
if (previousElement) previousElement.focus();
// Handle reset of selection
if (selectedFileIds.length > 1) {
getAndClearAndAddFile(getPreviousFile, lastFileId);
} else {
getAndClearAndAddFile(getPreviousFile, file.id);
if (selectedFileIds.length > 1 && topFileId !== undefined) {
getAndClearExcept(topFileId);
}
// Handle navigation
if (selectedFileIds.length === 1) {
getAndClearExcept(firstFileId, getPreviousFile);
}
}
break;
case 'ArrowDown':
case KeyName.Down:
if (shiftKey && allowMultiple) {
// Handle case if only one file is selected
// we should update the selection direction
@ -108,19 +132,22 @@ export function maybeMoveSelection({
fileIdSelection.remove(lastFileId, commitId);
}
getAndAddFile(getNextFile, lastFileId);
getAndAddFile(lastFileId, getNextFile);
} else {
// focus next file
const nextElement = targetElement.nextElementSibling as HTMLElement;
if (nextElement) nextElement.focus();
// Handle reset of selection
if (selectedFileIds.length > 1) {
getAndClearAndAddFile(getNextFile, lastFileId);
} else {
getAndClearAndAddFile(getNextFile, file.id);
if (selectedFileIds.length > 1 && bottomFileId !== undefined) {
getAndClearExcept(bottomFileId);
}
// Handle navigation
if (selectedFileIds.length === 1) {
getAndClearExcept(firstFileId, getNextFile);
}
}
break;
case KeyName.Escape:
fileIdSelection.clear();
targetElement.blur();
break;
}
}

View File

@ -0,0 +1,35 @@
import { groupCommitsByRef } from './commitGroups';
import { DetailedCommit } from './types';
import { expect, test } from 'vitest';
test('group commits correctly by remote ref', () => {
const commits = [
{ id: '1', remoteRef: 'a' },
{ id: '2', remoteRef: 'b' },
{ id: '3' },
{ id: '4' },
{ id: '5', remoteRef: 'c' },
{ id: '6' }
] as DetailedCommit[];
const groups = groupCommitsByRef(commits);
expect(groups.length).toEqual(3);
const [groupA, groupB, groupC] = groups;
expect(groupA?.ref).toEqual('a');
expect(groupB?.ref).toEqual('b');
expect(groupC?.ref).toEqual('c');
expect(groupA?.commits.length).toEqual(1);
expect(groupB?.commits.length).toEqual(3);
expect(groupC?.commits.length).toEqual(2);
});
test('group commits with undefined head ref', () => {
const commits = [{ id: '1' }, { id: '2', remoteRef: 'b' }] as DetailedCommit[];
const groups = groupCommitsByRef(commits);
expect(groups.length).toEqual(2);
const [groupA, groupB] = groups;
expect(groupA?.ref).toBeUndefined();
expect(groupB?.ref).toEqual('b');
});

View File

@ -0,0 +1,39 @@
import { DetailedCommit } from './types';
class CommitGroup {
commits: DetailedCommit[];
constructor(readonly ref?: string | undefined) {
this.commits = [];
}
get localCommits() {
return this.commits.filter((c) => c.status === 'local');
}
get remoteCommits() {
return this.commits.filter((c) => c.status === 'localAndRemote');
}
get integratedCommits() {
return this.commits.filter((c) => c.status === 'integrated');
}
get branchName() {
return this.ref?.replace('refs/remotes/origin/', '');
}
}
export function groupCommitsByRef(arr: DetailedCommit[]): CommitGroup[] {
const groups: CommitGroup[] = [];
let currentGroup: CommitGroup | undefined;
for (const item of arr) {
if (item.remoteRef || currentGroup === undefined) {
currentGroup = new CommitGroup(item.remoteRef);
groups.push(currentGroup);
}
currentGroup.commits.push(item);
}
return groups;
}

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

@ -137,6 +137,7 @@ export class VirtualBranch {
allowRebasing!: boolean;
pr?: PullRequest;
refname!: string;
tree!: string;
get localCommits() {
return this.commits.filter((c) => c.status === 'local');

View File

@ -0,0 +1,91 @@
import { invoke } from '$lib/backend/ipc';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import { isDefined } from '@gitbutler/ui/utils/typeguards';
import { derived, readable, type Readable } from 'svelte/store';
import type { Project } from '$lib/backend/projects';
import type { VirtualBranch } from '$lib/vbranches/types';
export type BranchStatus =
| {
type: 'empty' | 'fullyIntegrated' | 'saflyUpdatable';
}
| {
type: 'conflicted';
subject: {
potentiallyConflictedUncommitedChanges: boolean;
};
};
type BranchStatuses =
| {
type: 'upToDate';
}
| {
type: 'updatesRequired';
subject: [string, BranchStatus][];
};
export type BranchStatusesWithBranches =
| {
type: 'upToDate';
}
| {
type: 'updatesRequired';
subject: { branch: VirtualBranch; status: BranchStatus }[];
};
export type ResolutionApproach = {
type: 'rebase' | 'merge' | 'unapply' | 'delete';
};
export type Resolution = {
branchId: string;
branchTree: string;
approach: ResolutionApproach;
};
export class UpstreamIntegrationService {
constructor(
private project: Project,
private virtualBranchService: VirtualBranchService
) {}
upstreamStatuses(): Readable<BranchStatusesWithBranches | undefined> {
const branchStatuses = readable<BranchStatuses | undefined>(undefined, (set) => {
invoke<BranchStatuses>('upstream_integration_statuses', { projectId: this.project.id }).then(
set
);
});
const branchStatusesWithBranches = derived(
[branchStatuses, this.virtualBranchService.branches],
([branchStatuses, branches]): BranchStatusesWithBranches | undefined => {
if (!branchStatuses || !branches) return;
if (branchStatuses.type === 'upToDate') return branchStatuses;
return {
type: 'updatesRequired',
subject: branchStatuses.subject
.map((status) => {
const branch = branches.find((appliedBranch) => appliedBranch.id === status[0]);
if (!branch) return;
return {
branch,
status: status[1]
};
})
.filter(isDefined)
};
}
);
return branchStatusesWithBranches;
}
async integrateUpstream(resolutions: Resolution[]) {
console.log(resolutions);
return await invoke('integrate_upstream', { projectId: this.project.id, resolutions });
}
}

View File

@ -12,6 +12,7 @@
import NoBaseBranch from '$lib/components/NoBaseBranch.svelte';
import NotOnGitButlerBranch from '$lib/components/NotOnGitButlerBranch.svelte';
import ProblemLoadingRepo from '$lib/components/ProblemLoadingRepo.svelte';
import { featureTopics } from '$lib/config/uiFeatureFlags';
import { ReorderDropzoneManagerFactory } from '$lib/dragging/reorderDropzoneManager';
import { DefaultGitHostFactory } from '$lib/gitHost/gitHostFactory';
import { octokitFromAccessToken } from '$lib/gitHost/github/octokit';
@ -24,12 +25,17 @@
import Navigation from '$lib/navigation/Navigation.svelte';
import { persisted } from '$lib/persisted/persisted';
import { RemoteBranchService } from '$lib/stores/remoteBranches';
import CreateIssueModal from '$lib/topics/CreateIssueModal.svelte';
import CreateTopicModal from '$lib/topics/CreateTopicModal.svelte';
import { TopicService } from '$lib/topics/service';
import { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher';
import { parseRemoteUrl } from '$lib/url/gitUrl';
import { debounce } from '$lib/utils/debounce';
import { BranchController } from '$lib/vbranches/branchController';
import { UpstreamIntegrationService } from '$lib/vbranches/upstreamIntegrationService';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import { onDestroy, setContext, type Snippet } from 'svelte';
import { derived as storeDerived } from 'svelte/store';
import type { LayoutData } from './$types';
import { goto } from '$app/navigation';
@ -87,6 +93,14 @@
const listServiceStore = createGitHostListingServiceStore(undefined);
const gitHostStore = createGitHostStore(undefined);
const branchServiceStore = createBranchServiceStore(undefined);
const gitHostIssueSerice = storeDerived(gitHostStore, (gitHostStore) =>
gitHostStore?.issueService()
);
$effect.pre(() => {
const topicService = new TopicService(project, gitHostIssueSerice);
setContext(TopicService, topicService);
});
$effect.pre(() => {
const combinedBranchListingService = new CombinedBranchListingService(
@ -160,8 +174,17 @@
onDestroy(() => {
clearFetchInterval();
});
const topicsEnabled = featureTopics();
</script>
{#if $topicsEnabled}
{#if $gitHostStore?.issueService()}
<CreateIssueModal registerKeypress />
{/if}
<CreateTopicModal registerKeypress />
{/if}
<!-- forces components to be recreated when projectId changes -->
{#key projectId}
<ProjectSettingsMenuAction

View File

@ -11,6 +11,7 @@ import { ModeService } from '$lib/modes/service';
import { RemoteBranchService } from '$lib/stores/remoteBranches';
import { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher';
import { BranchController } from '$lib/vbranches/branchController';
import { UpstreamIntegrationService } from '$lib/vbranches/upstreamIntegrationService';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import { error } from '@sveltejs/kit';
import type { Project } from '$lib/backend/projects';
@ -80,6 +81,7 @@ export const load: LayoutLoad = async ({ params, parent }) => {
const reorderDropzoneManagerFactory = new ReorderDropzoneManagerFactory(branchController);
const uncommitedFileWatcher = new UncommitedFilesWatcher(project);
const upstreamIntegrationService = new UpstreamIntegrationService(project, vbranchService);
return {
authService,
@ -93,6 +95,7 @@ export const load: LayoutLoad = async ({ params, parent }) => {
projectMetrics,
modeService,
fetchSignal,
upstreamIntegrationService,
// These observables are provided for convenience
branchDragActionsFactory,

View File

@ -0,0 +1,65 @@
<script lang="ts">
import { getGitHost } from '$lib/gitHost/interface/gitHost';
import SettingsPage from '$lib/layout/SettingsPage.svelte';
import CreateIssueModal from '$lib/topics/CreateIssueModal.svelte';
import CreateTopicModal from '$lib/topics/CreateTopicModal.svelte';
import Topic from '$lib/topics/Topic.svelte';
import { TopicService } from '$lib/topics/service';
import { getContext } from '$lib/utils/context';
import Button from '@gitbutler/ui/Button.svelte';
const topicService = getContext(TopicService);
const topics = topicService.topics;
const gitHost = getGitHost();
const sortedTopics = $derived.by(() => {
const clonedTopics = structuredClone($topics);
clonedTopics.sort((a, b) => b.createdAt - a.createdAt);
return clonedTopics;
});
let createTopicModal = $state<CreateTopicModal>();
let createIssueModal = $state<CreateIssueModal>();
</script>
<CreateTopicModal bind:this={createTopicModal} />
<CreateIssueModal bind:this={createIssueModal} />
<SettingsPage title="Topics">
<div>
<div class="topic__actions">
<Button kind="solid" style="pop" onclick={() => createTopicModal?.open()}>Create Topic</Button
>
{#if $gitHost?.issueService()}
<Button style="pop" onclick={() => createIssueModal?.open()}>Create Issue</Button>
{/if}
</div>
{#if sortedTopics.length > 0}
<div class="container">
{#each sortedTopics as topic}
<Topic {topic} />
{/each}
</div>
{/if}
</div>
</SettingsPage>
<style lang="postcss">
.container {
width: 100%;
background-color: var(--clr-bg-1);
border-radius: var(--radius-l);
border: 1px solid var(--clr-border-2);
}
.topic__actions {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
</style>

View File

@ -3,14 +3,15 @@
import {
featureBaseBranchSwitching,
featureInlineUnifiedDiffs,
featureBranchStacking
stackingFeature,
featureTopics
} from '$lib/config/uiFeatureFlags';
import SettingsPage from '$lib/layout/SettingsPage.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
const baseBranchSwitching = featureBaseBranchSwitching();
const inlineUnifiedDiffs = featureInlineUnifiedDiffs();
const branchStacking = featureBranchStacking();
const topicsEnabled = featureTopics();
</script>
<SettingsPage title="Experimental features">
@ -47,16 +48,30 @@
/>
</svelte:fragment>
</SectionCard>
<SectionCard labelFor="branchStacking" orientation="row">
<SectionCard labelFor="stackingFeature" orientation="row">
<svelte:fragment slot="title">Branch stacking</svelte:fragment>
<svelte:fragment slot="caption">
Allows for branch / pull request stacking. The user interface for this is still very crude.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle
id="branchStacking"
checked={$branchStacking}
on:click={() => ($branchStacking = !$branchStacking)}
id="stackingFeature"
checked={$stackingFeature}
on:click={() => ($stackingFeature = !$stackingFeature)}
/>
</svelte:fragment>
</SectionCard>
<SectionCard labelFor="topics" orientation="row">
<svelte:fragment slot="title">Topics</svelte:fragment>
<svelte:fragment slot="caption">
A highly experimental form of note taking / conversation. The form & function may change
drastically, and may result in lost notes.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle
id="topics"
checked={$topicsEnabled}
on:click={() => ($topicsEnabled = !$topicsEnabled)}
/>
</svelte:fragment>
</SectionCard>

View File

@ -50,6 +50,7 @@ glob = "0.3.1"
serial_test = "3.1.1"
tempfile = "3.10"
criterion = "0.5.1"
uuid.workspace = true
[features]
## Only enabled when benchmark runs are performed.

View File

@ -1,5 +1,5 @@
use super::r#virtual as vbranch;
use crate::branch;
use crate::upstream_integration::{self, BranchStatuses, Resolution, UpstreamIntegrationContext};
use crate::{
base,
base::BaseBranch,
@ -517,7 +517,7 @@ pub fn create_virtual_branch_from_branch(
pub fn get_uncommited_files(project: &Project) -> Result<Vec<RemoteBranchFile>> {
let context = CommandContext::open(project)?;
let guard = project.exclusive_worktree_access();
branch::get_uncommited_files(&context, guard.read_permission())
crate::branch::get_uncommited_files(&context, guard.read_permission())
}
/// Like [`get_uncommited_files()`], but returns a type that can be re-used with
@ -525,12 +525,38 @@ pub fn get_uncommited_files(project: &Project) -> Result<Vec<RemoteBranchFile>>
pub fn get_uncommited_files_reusable(project: &Project) -> Result<DiffByPathMap> {
let context = CommandContext::open(project)?;
let guard = project.exclusive_worktree_access();
branch::get_uncommited_files_raw(&context, guard.read_permission())
crate::branch::get_uncommited_files_raw(&context, guard.read_permission())
}
pub fn upstream_integration_statuses(project: &Project) -> Result<BranchStatuses> {
let command_context = CommandContext::open(project)?;
let mut guard = project.exclusive_worktree_access();
let context = UpstreamIntegrationContext::open(&command_context, guard.write_permission())?;
upstream_integration::upstream_integration_statuses(&context)
}
pub fn integrate_upstream(project: &Project, resolutions: &[Resolution]) -> Result<()> {
let command_context = CommandContext::open(project)?;
let mut guard = project.exclusive_worktree_access();
let _ = command_context.project().create_snapshot(
SnapshotDetails::new(OperationKind::UpdateWorkspaceBase),
guard.write_permission(),
);
upstream_integration::integrate_upstream(
&command_context,
resolutions,
guard.write_permission(),
)
}
fn open_with_verify(project: &Project) -> Result<CommandContext> {
let ctx = CommandContext::open(project)?;
let mut guard = project.exclusive_worktree_access();
crate::integration::verify_branch(&ctx, guard.write_permission())?;
Ok(ctx)
}

View File

@ -561,7 +561,7 @@ pub(crate) fn target_to_base_branch(ctx: &CommandContext, target: &Target) -> Re
let oid = commit.id();
// gather a list of commits between oid and target.sha
let upstream_commits = ctx
let upstream_commits = repo
.log(oid, LogUntil::Commit(target.sha))
.context("failed to get upstream commits")?
.iter()
@ -569,7 +569,7 @@ pub(crate) fn target_to_base_branch(ctx: &CommandContext, target: &Target) -> Re
.collect::<Vec<_>>();
// get some recent commits
let recent_commits = ctx
let recent_commits = repo
.log(target.sha, LogUntil::Take(20))
.context("failed to get recent commits")?
.iter()

View File

@ -12,7 +12,7 @@ use gitbutler_commit::commit_ext::CommitExt;
use gitbutler_error::error::Marker;
use gitbutler_operating_modes::OPEN_WORKSPACE_REFS;
use gitbutler_project::access::WorktreeWritePermission;
use gitbutler_repo::{LogUntil, RepoActionsExt, RepositoryExt};
use gitbutler_repo::{LogUntil, RepositoryExt};
use tracing::instrument;
use crate::{branch_manager::BranchManagerExt, conflicts, VirtualBranchesExt};
@ -329,6 +329,7 @@ fn verify_head_is_clean(ctx: &CommandContext, perm: &mut WorktreeWritePermission
.context("failed to get default target")?;
let commits = ctx
.repository()
.log(head_commit.id(), LogUntil::Commit(default_target.sha))
.context("failed to get log")?;

View File

@ -5,13 +5,13 @@ pub use actions::{
amend, can_apply_remote_branch, convert_to_real_branch, create_change_reference, create_commit,
create_virtual_branch, create_virtual_branch_from_branch, delete_local_branch,
delete_virtual_branch, fetch_from_remotes, get_base_branch_data, get_remote_branch_data,
get_uncommited_files, get_uncommited_files_reusable, insert_blank_commit,
get_uncommited_files, get_uncommited_files_reusable, insert_blank_commit, integrate_upstream,
integrate_upstream_commits, list_local_branches, list_remote_commit_files,
list_virtual_branches, list_virtual_branches_cached, move_commit, move_commit_file,
push_change_reference, push_virtual_branch, reorder_commit, reset_files, reset_virtual_branch,
set_base_branch, set_target_push_remote, squash, unapply_ownership, undo_commit,
update_base_branch, update_branch_order, update_change_reference, update_commit_message,
update_virtual_branch,
update_virtual_branch, upstream_integration_statuses,
};
mod r#virtual;
@ -29,6 +29,8 @@ pub use branch_manager::{BranchManager, BranchManagerExt};
mod base;
pub use base::BaseBranch;
pub mod upstream_integration;
mod integration;
pub use integration::{update_workspace_commit, verify_branch};

View File

@ -148,6 +148,7 @@ pub(crate) fn branch_to_remote_branch_data(
.target()
.map(|sha| {
let ahead = ctx
.repository()
.log(sha, LogUntil::Commit(base))
.context("failed to get ahead commits")?;

View File

@ -0,0 +1,840 @@
use anyhow::{anyhow, bail, Context, Result};
use gitbutler_branch::{
signature, Branch, BranchId, SignaturePurpose, Target, VirtualBranchesHandle,
};
use gitbutler_cherry_pick::RepositoryExt as _;
use gitbutler_command_context::CommandContext;
use gitbutler_commit::commit_ext::CommitExt;
use gitbutler_project::access::WorktreeWritePermission;
use gitbutler_repo::{
rebase::{cherry_rebase_group, gitbutler_merge_commits},
LogUntil, RepoActionsExt as _, RepositoryExt as _,
};
use serde::{Deserialize, Serialize};
use crate::{BranchManagerExt, VirtualBranchesExt as _};
#[derive(Serialize, PartialEq, Debug)]
#[serde(tag = "type", content = "subject", rename_all = "camelCase")]
pub enum BranchStatus {
Empty,
FullyIntegrated,
Conflicted {
potentially_conflicted_uncommited_changes: bool,
},
SaflyUpdatable,
}
#[derive(Serialize, PartialEq, Debug)]
#[serde(tag = "type", content = "subject", rename_all = "camelCase")]
pub enum BranchStatuses {
UpToDate,
UpdatesRequired(Vec<(BranchId, BranchStatus)>),
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
#[serde(tag = "type", content = "subject", rename_all = "camelCase")]
enum ResolutionApproach {
Rebase,
Merge,
Unapply,
Delete,
}
impl BranchStatus {
fn resolution_acceptable(&self, approach: &ResolutionApproach) -> bool {
match self {
Self::Empty | Self::SaflyUpdatable | Self::Conflicted { .. } => matches!(
approach,
ResolutionApproach::Rebase
| ResolutionApproach::Merge
| ResolutionApproach::Unapply
),
Self::FullyIntegrated => matches!(approach, ResolutionApproach::Delete),
}
}
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Resolution {
branch_id: BranchId,
/// Used to ensure a given branch hasn't changed since the UI issued the command.
#[serde(with = "gitbutler_serde::oid")]
branch_tree: git2::Oid,
approach: ResolutionApproach,
}
enum IntegrationResult {
UpdatedObjects { head: git2::Oid, tree: git2::Oid },
UnapplyBranch,
DeleteBranch,
}
pub struct UpstreamIntegrationContext<'a> {
_permission: Option<&'a mut WorktreeWritePermission>,
repository: &'a git2::Repository,
virtual_branches_in_workspace: Vec<Branch>,
new_target: git2::Commit<'a>,
old_target: git2::Commit<'a>,
target_branch_name: String,
}
impl<'a> UpstreamIntegrationContext<'a> {
pub(crate) fn open(
command_context: &'a CommandContext,
permission: &'a mut WorktreeWritePermission,
) -> Result<Self> {
let virtual_branches_handle = command_context.project().virtual_branches();
let target = virtual_branches_handle.get_default_target()?;
let repository = command_context.repository();
let target_branch = repository
.find_branch_by_refname(&target.branch.clone().into())?
.ok_or(anyhow!("Branch not found"))?;
let new_target = target_branch.get().peel_to_commit()?;
let old_target = repository.find_commit(target.sha)?;
let virtual_branches_in_workspace = virtual_branches_handle.list_branches_in_workspace()?;
Ok(Self {
_permission: Some(permission),
repository,
new_target,
old_target,
virtual_branches_in_workspace,
target_branch_name: target.branch.branch().to_string(),
})
}
}
pub fn upstream_integration_statuses(
context: &UpstreamIntegrationContext,
) -> Result<BranchStatuses> {
let UpstreamIntegrationContext {
repository,
new_target,
old_target,
virtual_branches_in_workspace,
..
} = context;
// look up the target and see if there is a new oid
let old_target_tree = repository.find_real_tree(old_target, Default::default())?;
let new_target_tree = repository.find_real_tree(new_target, Default::default())?;
if new_target.id() == old_target.id() {
return Ok(BranchStatuses::UpToDate);
};
let statuses = virtual_branches_in_workspace
.iter()
.map(|virtual_branch| {
let tree = repository.find_tree(virtual_branch.tree)?;
let head = repository.find_commit(virtual_branch.head)?;
let head_tree = repository.find_real_tree(&head, Default::default())?;
// Try cherry pick the branch's head commit onto the target to
// see if it conflics. This is equivalent to doing a merge
// but accounts for the commit being conflicted.
let has_commits = virtual_branch.head != old_target.id();
let has_uncommited_changes = head_tree.id() != tree.id();
// Is the branch completly empty?
{
if !has_commits && !has_uncommited_changes {
return Ok((virtual_branch.id, BranchStatus::Empty));
};
}
let head_merge_index =
repository.merge_trees(&old_target_tree, &new_target_tree, &head_tree, None)?;
let mut tree_merge_index =
repository.merge_trees(&old_target_tree, &new_target_tree, &tree, None)?;
// Is the branch conflicted?
// A branch can't be integrated if its conflicted
{
let commits_conflicted = head_merge_index.has_conflicts();
// See whether uncommited changes are potentially conflicted
let potentially_conflicted_uncommited_changes = if has_uncommited_changes {
// If the commits are conflicted, we can guarentee that the
// tree will be conflicted.
if commits_conflicted {
true
} else {
tree_merge_index.has_conflicts()
}
} else {
// If there are no uncommited changes, then there can't be
// any conflicts.
false
};
if commits_conflicted || potentially_conflicted_uncommited_changes {
return Ok((
virtual_branch.id,
BranchStatus::Conflicted {
potentially_conflicted_uncommited_changes,
},
));
}
}
// Is the branch fully integrated?
{
// We're safe to write the tree as we've ensured it's
// unconflicted in the previous test.
let tree_merge_index_tree = tree_merge_index.write_tree_to(repository)?;
// Identical trees will have the same Oid so we can compare
// the two
if tree_merge_index_tree == new_target_tree.id() {
return Ok((virtual_branch.id, BranchStatus::FullyIntegrated));
}
}
Ok((virtual_branch.id, BranchStatus::SaflyUpdatable))
})
.collect::<Result<Vec<_>>>()?;
Ok(BranchStatuses::UpdatesRequired(statuses))
}
pub(crate) fn integrate_upstream(
command_context: &CommandContext,
resolutions: &[Resolution],
permission: &mut WorktreeWritePermission,
) -> Result<()> {
let context = UpstreamIntegrationContext::open(command_context, permission)?;
let virtual_branches_state = VirtualBranchesHandle::new(command_context.project().gb_dir());
let default_target = virtual_branches_state.get_default_target()?;
// Ensure resolutions match current statuses
{
let statuses = upstream_integration_statuses(&context)?;
let BranchStatuses::UpdatesRequired(statuses) = statuses else {
bail!("Branches are all up to date")
};
if resolutions.len() != context.virtual_branches_in_workspace.len() {
bail!("Chosen resolutions do not match quantity of applied virtual branches")
}
let all_resolutions_are_up_to_date = resolutions.iter().all(|resolution| {
// This is O(n^2), in reality, n is unlikly to be more than 3 or 4
let Some(branch) = context
.virtual_branches_in_workspace
.iter()
.find(|branch| branch.id == resolution.branch_id)
else {
return false;
};
if resolution.branch_tree != branch.tree {
return false;
};
let Some(status) = statuses
.iter()
.find(|status| status.0 == resolution.branch_id)
else {
return false;
};
status.1.resolution_acceptable(&resolution.approach)
});
if !all_resolutions_are_up_to_date {
bail!("Chosen resolutions do not match current integration statuses")
}
}
let integration_results = compute_resolutions(&context, resolutions)?;
{
// We preform the updates in stages. If deleting or unapplying fails, we
// could enter a much worse state if we're simultaniously updating trees
// Delete branches
for (branch_id, integration_result) in &integration_results {
if !matches!(integration_result, IntegrationResult::DeleteBranch) {
continue;
};
let branch = virtual_branches_state.get_branch(*branch_id)?;
virtual_branches_state.delete_branch_entry(branch_id)?;
command_context.delete_branch_reference(&branch)?;
}
let permission = context._permission.expect("Permission provided above");
// Unapply branches
for (branch_id, integration_result) in &integration_results {
if !matches!(integration_result, IntegrationResult::UnapplyBranch) {
continue;
};
command_context
.branch_manager()
.convert_to_real_branch(*branch_id, permission)?;
}
let mut branches = virtual_branches_state.list_branches_in_workspace()?;
let new_target_tree = context.new_target.tree()?;
let mut final_tree = context.new_target.tree()?;
let repository = context.repository;
// Update branch trees
for (branch_id, integration_result) in &integration_results {
let IntegrationResult::UpdatedObjects { head, tree } = integration_result else {
continue;
};
let Some(branch) = branches.iter_mut().find(|branch| branch.id == *branch_id) else {
continue;
};
branch.head = *head;
branch.tree = *tree;
virtual_branches_state.set_branch(branch.clone())?;
// Combine tree into new working tree
{
let branch_tree = repository.find_tree(branch.tree)?;
let mut merge_result: git2::Index =
repository.merge_trees(&new_target_tree, &final_tree, &branch_tree, None)?;
let final_tree_oid = merge_result.write_tree_to(repository)?;
final_tree = repository.find_tree(final_tree_oid)?;
}
}
repository.checkout_tree_builder(&final_tree)
.force()
.checkout()
.context("failed to checkout index, this should not have happened, we should have already detected this")?;
virtual_branches_state.set_default_target(Target {
sha: context.new_target.id(),
..default_target
})?;
crate::integration::update_workspace_commit(&virtual_branches_state, command_context)?;
}
Ok(())
}
fn compute_resolutions(
context: &UpstreamIntegrationContext,
resolutions: &[Resolution],
) -> Result<Vec<(BranchId, IntegrationResult)>> {
let UpstreamIntegrationContext {
repository,
new_target,
virtual_branches_in_workspace,
target_branch_name,
..
} = context;
let results = resolutions
.iter()
.map(|resolution| {
let Some(virtual_branch) = virtual_branches_in_workspace
.iter()
.find(|branch| branch.id == resolution.branch_id)
else {
bail!("Failed to find virtual branch");
};
match resolution.approach {
ResolutionApproach::Unapply => {
Ok((virtual_branch.id, IntegrationResult::UnapplyBranch))
}
ResolutionApproach::Delete => {
Ok((virtual_branch.id, IntegrationResult::DeleteBranch))
}
ResolutionApproach::Merge => {
// Make a merge commit on top of the branch commits,
// then rebase the tree ontop of that. If the tree ends
// up conflicted, commit the tree.
let target_commit = repository.find_commit(virtual_branch.head)?;
let new_head = gitbutler_merge_commits(
repository,
target_commit,
new_target.clone(),
&virtual_branch.name,
target_branch_name,
)?;
let head = repository.find_commit(virtual_branch.head)?;
let tree = repository.find_tree(virtual_branch.tree)?;
// Rebase tree
let author_signature = signature(SignaturePurpose::Author)
.context("Failed to get gitbutler signature")?;
let committer_signature = signature(SignaturePurpose::Committer)
.context("Failed to get gitbutler signature")?;
let committed_tree = repository.commit(
None,
&author_signature,
&committer_signature,
"Uncommited changes",
&tree,
&[&head],
)?;
// Rebase commited tree
let new_commited_tree =
cherry_rebase_group(repository, new_head.id(), &[committed_tree], true)?;
let new_commited_tree = repository.find_commit(new_commited_tree)?;
if new_commited_tree.is_conflicted() {
Ok((
virtual_branch.id,
IntegrationResult::UpdatedObjects {
head: new_commited_tree.id(),
tree: repository
.find_real_tree(&new_commited_tree, Default::default())?
.id(),
},
))
} else {
Ok((
virtual_branch.id,
IntegrationResult::UpdatedObjects {
head: new_head.id(),
tree: new_commited_tree.tree_id(),
},
))
}
}
ResolutionApproach::Rebase => {
// Rebase the commits, then try rebasing the tree. If
// the tree ends up conflicted, commit the tree.
// Rebase virtual branches' commits
let virtual_branch_commits =
repository.l(virtual_branch.head, LogUntil::Commit(new_target.id()))?;
let new_head = cherry_rebase_group(
repository,
new_target.id(),
&virtual_branch_commits,
true,
)?;
let head = repository.find_commit(virtual_branch.head)?;
let tree = repository.find_tree(virtual_branch.tree)?;
// Rebase tree
let author_signature = signature(SignaturePurpose::Author)
.context("Failed to get gitbutler signature")?;
let committer_signature = signature(SignaturePurpose::Committer)
.context("Failed to get gitbutler signature")?;
let committed_tree = repository.commit(
None,
&author_signature,
&committer_signature,
"Uncommited changes",
&tree,
&[&head],
)?;
// Rebase commited tree
let new_commited_tree =
cherry_rebase_group(repository, new_head, &[committed_tree], true)?;
let new_commited_tree = repository.find_commit(new_commited_tree)?;
if new_commited_tree.is_conflicted() {
Ok((
virtual_branch.id,
IntegrationResult::UpdatedObjects {
head: new_commited_tree.id(),
tree: repository
.find_real_tree(&new_commited_tree, Default::default())?
.id(),
},
))
} else {
Ok((
virtual_branch.id,
IntegrationResult::UpdatedObjects {
head: new_head,
tree: new_commited_tree.tree_id(),
},
))
}
}
}
})
.collect::<Result<Vec<_>>>()?;
Ok(results)
}
#[cfg(test)]
mod test {
use std::fs;
use gitbutler_branch::BranchOwnershipClaims;
use tempfile::tempdir;
use uuid::Uuid;
use super::*;
fn commit_file<'a>(
repository: &'a git2::Repository,
parent: Option<&git2::Commit>,
files: &[(&str, &str)],
) -> git2::Commit<'a> {
for (file_name, contents) in files {
fs::write(repository.path().join("..").join(file_name), contents).unwrap();
}
let mut index = repository.index().unwrap();
// Make sure we're not having weird cached state
index.read(true).unwrap();
index
.add_all(["*"], git2::IndexAddOption::DEFAULT, None)
.unwrap();
let signature = git2::Signature::now("Caleb", "caleb@gitbutler.com").unwrap();
let commit = repository
.commit(
None,
&signature,
&signature,
"Committee",
&repository.find_tree(index.write_tree().unwrap()).unwrap(),
parent.map(|c| vec![c]).unwrap_or_default().as_slice(),
)
.unwrap();
repository.find_commit(commit).unwrap()
}
fn make_branch(head: git2::Oid, tree: git2::Oid) -> Branch {
Branch {
id: Uuid::new_v4().into(),
name: "branchy branch".into(),
notes: "bla bla bla".into(),
source_refname: None,
upstream: None,
upstream_head: None,
created_timestamp_ms: 69420,
updated_timestamp_ms: 69420,
tree,
head,
ownership: BranchOwnershipClaims::default(),
order: 0,
selected_for_changes: None,
allow_rebasing: true,
in_workspace: true,
not_in_workspace_wip_change_id: None,
references: vec![],
}
}
#[test]
fn test_up_to_date_if_head_commits_equivalent() {
let tempdir = tempdir().unwrap();
let repository = git2::Repository::init(tempdir.path()).unwrap();
let initial_commit = commit_file(&repository, None, &[("foo.txt", "bar")]);
let head_commit = commit_file(&repository, Some(&initial_commit), &[("foo.txt", "baz")]);
let context = UpstreamIntegrationContext {
_permission: None,
old_target: head_commit.clone(),
new_target: head_commit,
repository: &repository,
virtual_branches_in_workspace: vec![],
target_branch_name: "main".to_string(),
};
assert_eq!(
upstream_integration_statuses(&context).unwrap(),
BranchStatuses::UpToDate,
)
}
#[test]
fn test_updates_required_if_new_head_ahead() {
let tempdir = tempdir().unwrap();
let repository = git2::Repository::init(tempdir.path()).unwrap();
let initial_commit = commit_file(&repository, None, &[("foo.txt", "bar")]);
let old_target = commit_file(&repository, Some(&initial_commit), &[("foo.txt", "baz")]);
let new_target = commit_file(&repository, Some(&old_target), &[("foo.txt", "qux")]);
let context = UpstreamIntegrationContext {
_permission: None,
old_target,
new_target,
repository: &repository,
virtual_branches_in_workspace: vec![],
target_branch_name: "main".to_string(),
};
assert_eq!(
upstream_integration_statuses(&context).unwrap(),
BranchStatuses::UpdatesRequired(vec![]),
)
}
#[test]
fn test_empty_branch() {
let tempdir = tempdir().unwrap();
let repository = git2::Repository::init(tempdir.path()).unwrap();
let initial_commit = commit_file(&repository, None, &[("foo.txt", "bar")]);
let old_target = commit_file(&repository, Some(&initial_commit), &[("foo.txt", "baz")]);
let new_target = commit_file(&repository, Some(&old_target), &[("foo.txt", "qux")]);
let branch = make_branch(old_target.id(), old_target.tree_id());
let context = UpstreamIntegrationContext {
_permission: None,
old_target,
new_target,
repository: &repository,
virtual_branches_in_workspace: vec![branch.clone()],
target_branch_name: "main".to_string(),
};
assert_eq!(
upstream_integration_statuses(&context).unwrap(),
BranchStatuses::UpdatesRequired(vec![(branch.id, BranchStatus::Empty)]),
)
}
#[test]
fn test_conflicted_head_branch() {
let tempdir = tempdir().unwrap();
let repository = git2::Repository::init(tempdir.path()).unwrap();
let initial_commit = commit_file(&repository, None, &[("foo.txt", "bar")]);
let old_target = commit_file(&repository, Some(&initial_commit), &[("foo.txt", "baz")]);
let branch_head = commit_file(&repository, Some(&old_target), &[("foo.txt", "fux")]);
let new_target = commit_file(&repository, Some(&old_target), &[("foo.txt", "qux")]);
let branch = make_branch(branch_head.id(), branch_head.tree_id());
let context = UpstreamIntegrationContext {
_permission: None,
old_target,
new_target: new_target.clone(),
repository: &repository,
virtual_branches_in_workspace: vec![branch.clone()],
target_branch_name: "main".to_string(),
};
assert_eq!(
upstream_integration_statuses(&context).unwrap(),
BranchStatuses::UpdatesRequired(vec![(
branch.id,
BranchStatus::Conflicted {
potentially_conflicted_uncommited_changes: false
}
)]),
);
let updates = compute_resolutions(
&context,
&[Resolution {
branch_id: branch.id,
branch_tree: branch.tree,
approach: ResolutionApproach::Rebase,
}],
)
.unwrap();
assert_eq!(updates.len(), 1);
let IntegrationResult::UpdatedObjects { head, tree } = updates[0].1 else {
panic!("Should be variant UpdatedObjects")
};
let head_commit = repository.find_commit(head).unwrap();
assert_eq!(head_commit.parent(0).unwrap().id(), new_target.id());
assert!(head_commit.is_conflicted());
let head_tree = repository
.find_real_tree(&head_commit, Default::default())
.unwrap();
assert_eq!(head_tree.id(), tree)
}
#[test]
fn test_conflicted_tree_branch() {
let tempdir = tempdir().unwrap();
let repository = git2::Repository::init(tempdir.path()).unwrap();
let initial_commit = commit_file(&repository, None, &[("foo.txt", "bar")]);
let old_target = commit_file(&repository, Some(&initial_commit), &[("foo.txt", "baz")]);
let branch_head = commit_file(&repository, Some(&old_target), &[("foo.txt", "fux")]);
let new_target = commit_file(&repository, Some(&old_target), &[("foo.txt", "qux")]);
let branch = make_branch(old_target.id(), branch_head.tree_id());
let context = UpstreamIntegrationContext {
_permission: None,
old_target,
new_target,
repository: &repository,
virtual_branches_in_workspace: vec![branch.clone()],
target_branch_name: "main".to_string(),
};
assert_eq!(
upstream_integration_statuses(&context).unwrap(),
BranchStatuses::UpdatesRequired(vec![(
branch.id,
BranchStatus::Conflicted {
potentially_conflicted_uncommited_changes: true
}
)]),
)
}
#[test]
fn test_conflicted_head_and_tree_branch() {
let tempdir = tempdir().unwrap();
let repository = git2::Repository::init(tempdir.path()).unwrap();
let initial_commit = commit_file(&repository, None, &[("foo.txt", "bar")]);
let old_target = commit_file(&repository, Some(&initial_commit), &[("foo.txt", "baz")]);
let branch_head = commit_file(&repository, Some(&old_target), &[("foo.txt", "fux")]);
let branch_tree = commit_file(&repository, Some(&old_target), &[("foo.txt", "bax")]);
let new_target = commit_file(&repository, Some(&old_target), &[("foo.txt", "qux")]);
let branch = make_branch(branch_head.id(), branch_tree.tree_id());
let context = UpstreamIntegrationContext {
_permission: None,
old_target,
new_target,
repository: &repository,
virtual_branches_in_workspace: vec![branch.clone()],
target_branch_name: "main".to_string(),
};
assert_eq!(
upstream_integration_statuses(&context).unwrap(),
BranchStatuses::UpdatesRequired(vec![(
branch.id,
BranchStatus::Conflicted {
potentially_conflicted_uncommited_changes: true
}
)]),
)
}
#[test]
fn test_integrated() {
let tempdir = tempdir().unwrap();
let repository = git2::Repository::init(tempdir.path()).unwrap();
let initial_commit = commit_file(&repository, None, &[("foo.txt", "bar")]);
let old_target = commit_file(&repository, Some(&initial_commit), &[("foo.txt", "baz")]);
let new_target = commit_file(&repository, Some(&old_target), &[("foo.txt", "qux")]);
let branch = make_branch(new_target.id(), new_target.tree_id());
let context = UpstreamIntegrationContext {
_permission: None,
old_target,
new_target,
repository: &repository,
virtual_branches_in_workspace: vec![branch.clone()],
target_branch_name: "main".to_string(),
};
assert_eq!(
upstream_integration_statuses(&context).unwrap(),
BranchStatuses::UpdatesRequired(vec![(branch.id, BranchStatus::FullyIntegrated)]),
)
}
#[test]
fn test_integrated_commit_with_uncommited_changes() {
let tempdir = tempdir().unwrap();
let repository = git2::Repository::init(tempdir.path()).unwrap();
let initial_commit =
commit_file(&repository, None, &[("foo.txt", "bar"), ("bar.txt", "bar")]);
let old_target = commit_file(
&repository,
Some(&initial_commit),
&[("foo.txt", "baz"), ("bar.txt", "bar")],
);
let new_target = commit_file(
&repository,
Some(&old_target),
&[("foo.txt", "qux"), ("bar.txt", "bar")],
);
let tree = commit_file(
&repository,
Some(&old_target),
&[("foo.txt", "baz"), ("bar.txt", "qux")],
);
let branch = make_branch(new_target.id(), tree.tree_id());
let context = UpstreamIntegrationContext {
_permission: None,
old_target,
new_target,
repository: &repository,
virtual_branches_in_workspace: vec![branch.clone()],
target_branch_name: "main".to_string(),
};
assert_eq!(
upstream_integration_statuses(&context).unwrap(),
BranchStatuses::UpdatesRequired(vec![(branch.id, BranchStatus::SaflyUpdatable)]),
)
}
#[test]
fn test_safly_updatable() {
let tempdir = tempdir().unwrap();
let repository = git2::Repository::init(tempdir.path()).unwrap();
let initial_commit = commit_file(
&repository,
None,
&[("files-one.txt", "foo"), ("file-two.txt", "foo")],
);
let old_target = commit_file(
&repository,
Some(&initial_commit),
&[("file-one.txt", "bar"), ("file-two.txt", "foo")],
);
let new_target = commit_file(
&repository,
Some(&old_target),
&[("file-one.txt", "baz"), ("file-two.txt", "foo")],
);
let branch_head = commit_file(
&repository,
Some(&old_target),
&[("file-one.txt", "bar"), ("file-two.txt", "bar")],
);
let branch_tree = commit_file(
&repository,
Some(&branch_head),
&[("file-one.txt", "bar"), ("file-two.txt", "baz")],
);
let branch = make_branch(branch_head.id(), branch_tree.tree_id());
let context = UpstreamIntegrationContext {
_permission: None,
old_target,
new_target,
repository: &repository,
virtual_branches_in_workspace: vec![branch.clone()],
target_branch_name: "main".to_string(),
};
assert_eq!(
upstream_integration_statuses(&context).unwrap(),
BranchStatuses::UpdatesRequired(vec![(branch.id, BranchStatus::SaflyUpdatable)]),
)
}
}

View File

@ -71,6 +71,8 @@ pub struct VirtualBranch {
#[serde(with = "gitbutler_serde::oid_opt", default)]
pub fork_point: Option<git2::Oid>,
pub refname: Refname,
#[serde(with = "gitbutler_serde::oid")]
pub tree: git2::Oid,
}
#[derive(Debug, PartialEq, Clone, Serialize)]
@ -159,7 +161,7 @@ pub fn unapply_ownership(
},
)?;
let final_tree_oid = gitbutler_diff::write::hunks_onto_tree(ctx, &final_tree, diff)?;
let final_tree_oid = gitbutler_diff::write::hunks_onto_tree(ctx, &final_tree, diff, true)?;
let final_tree = repo
.find_tree(final_tree_oid)
.context("failed to find tree")?;
@ -292,7 +294,7 @@ pub fn list_virtual_branches_cached(
default_target.sha
))?;
let remote_commit_ids =
HashSet::from_iter(ctx.l(upstream.id(), LogUntil::Commit(merge_base))?);
HashSet::from_iter(repo.l(upstream.id(), LogUntil::Commit(merge_base))?);
let remote_commit_data: HashMap<_, _> = remote_commit_ids
.iter()
.copied()
@ -313,7 +315,7 @@ pub fn list_virtual_branches_cached(
let mut is_remote = false;
// find all commits on head that are not on target.sha
let commits = ctx.log(branch.head, LogUntil::Commit(default_target.sha))?;
let commits = repo.log(branch.head, LogUntil::Commit(default_target.sha))?;
let check_commit = IsCommitIntegrated::new(ctx, &default_target)?;
let vbranch_commits = {
let _span = tracing::debug_span!(
@ -411,6 +413,7 @@ pub fn list_virtual_branches_cached(
merge_base,
fork_point,
refname,
tree: branch.tree,
};
branches.push(branch);
}
@ -544,8 +547,8 @@ pub fn integrate_upstream_commits(ctx: &CommandContext, branch_id: BranchId) ->
return Ok(());
}
let upstream_commits = ctx.list_commits(upstream_commit.id(), default_target.sha)?;
let branch_commits = ctx.list_commits(branch.head, default_target.sha)?;
let upstream_commits = repo.list_commits(upstream_commit.id(), default_target.sha)?;
let branch_commits = repo.list_commits(branch.head, default_target.sha)?;
let branch_commit_ids = branch_commits.iter().map(|c| c.id()).collect::<Vec<_>>();
@ -649,7 +652,12 @@ pub(crate) fn integrate_with_rebase(
branch: &mut Branch,
unknown_commits: &mut Vec<git2::Oid>,
) -> Result<git2::Oid> {
cherry_rebase_group(ctx, branch.head, unknown_commits.as_mut_slice())
cherry_rebase_group(
ctx.repository(),
branch.head,
unknown_commits.as_mut_slice(),
ctx.project().succeeding_rebases,
)
}
pub(crate) fn integrate_with_merge(
@ -855,6 +863,7 @@ pub(crate) fn reset_branch(
if default_target.sha != target_commit_id
&& !ctx
.repository()
.l(branch.head, LogUntil::Commit(default_target.sha))?
.contains(&target_commit_id)
{
@ -1112,7 +1121,9 @@ impl<'repo> IsCommitIntegrated<'repo> {
.find_branch_by_refname(&target.branch.clone().into())?
.ok_or(anyhow!("failed to get branch"))?;
let remote_head = remote_branch.get().peel_to_commit()?;
let upstream_commits = ctx.l(remote_head.id(), LogUntil::Commit(target.sha))?;
let upstream_commits = ctx
.repository()
.l(remote_head.id(), LogUntil::Commit(target.sha))?;
let inmemory_repo = ctx.repository().in_memory_repo()?;
Ok(Self {
repo: ctx.repository(),
@ -1242,7 +1253,9 @@ pub(crate) fn move_commit_file(
.context("failed to find commit")?;
// find all the commits upstream from the target "to" commit
let mut upstream_commits = ctx.l(target_branch.head, LogUntil::Commit(amend_commit.id()))?;
let mut upstream_commits = ctx
.repository()
.l(target_branch.head, LogUntil::Commit(amend_commit.id()))?;
// get a list of all the diffs across all the virtual branches
let base_file_diffs = gitbutler_diff::workdir(ctx.repository(), default_target.sha)
@ -1366,10 +1379,13 @@ pub(crate) fn move_commit_file(
// ok, now we need to identify which the new "to" commit is in the rebased history
// so we'll take a list of the upstream oids and find it simply based on location
// (since the order should not have changed in our simple rebase)
let old_upstream_commit_oids =
ctx.l(target_branch.head, LogUntil::Commit(default_target.sha))?;
let old_upstream_commit_oids = ctx
.repository()
.l(target_branch.head, LogUntil::Commit(default_target.sha))?;
let new_upstream_commit_oids = ctx.l(new_head, LogUntil::Commit(default_target.sha))?;
let new_upstream_commit_oids = ctx
.repository()
.l(new_head, LogUntil::Commit(default_target.sha))?;
// find to_commit_oid offset in upstream_commits vector
let to_commit_offset = old_upstream_commit_oids
@ -1389,7 +1405,9 @@ pub(crate) fn move_commit_file(
.context("failed to find commit")?;
// reset the concept of what the upstream commits are to be the rebased ones
upstream_commits = ctx.l(new_head, LogUntil::Commit(amend_commit.id()))?;
upstream_commits = ctx
.repository()
.l(new_head, LogUntil::Commit(amend_commit.id()))?;
}
// ok, now we will apply the moved changes to the "to" commit.
@ -1479,6 +1497,7 @@ pub(crate) fn amend(
}
if ctx
.repository()
.l(target_branch.head, LogUntil::Commit(default_target.sha))?
.is_empty()
{
@ -1545,7 +1564,9 @@ pub(crate) fn amend(
.context("failed to create commit")?;
// now rebase upstream commits, if needed
let upstream_commits = ctx.l(target_branch.head, LogUntil::Commit(amend_commit.id()))?;
let upstream_commits = ctx
.repository()
.l(target_branch.head, LogUntil::Commit(amend_commit.id()))?;
// if there are no upstream commits, we're done
if upstream_commits.is_empty() {
target_branch.head = commit_oid;
@ -1618,6 +1639,8 @@ pub(crate) fn reorder_commit(
)
.context("Failed to commit uncommited changes")?;
let succeeding_rebases = ctx.project().succeeding_rebases;
if offset < 0 {
// move commit up
if branch.head == commit_oid {
@ -1626,7 +1649,9 @@ pub(crate) fn reorder_commit(
}
// get a list of the commits to rebase
let mut ids_to_rebase = ctx.l(branch.head, LogUntil::Commit(commit.id()))?;
let mut ids_to_rebase = ctx
.repository()
.l(branch.head, LogUntil::Commit(commit.id()))?;
ids_to_rebase.insert(
ids_to_rebase.len() - offset.unsigned_abs() as usize,
@ -1634,7 +1659,8 @@ pub(crate) fn reorder_commit(
);
let new_head =
cherry_rebase_group(ctx, parent_oid, &mut ids_to_rebase).context("rebase failed")?;
cherry_rebase_group(repository, parent_oid, &ids_to_rebase, succeeding_rebases)
.context("rebase failed")?;
branch.head = new_head;
} else {
@ -1654,6 +1680,7 @@ pub(crate) fn reorder_commit(
// get a list of the commits to rebase
let mut ids_to_rebase: Vec<git2::Oid> = ctx
.repository()
.l(branch.head, LogUntil::Commit(target_oid))?
.iter()
.filter(|id| **id != commit_oid)
@ -1663,13 +1690,15 @@ pub(crate) fn reorder_commit(
ids_to_rebase.push(commit_oid);
let new_head =
cherry_rebase_group(ctx, target_oid, &mut ids_to_rebase).context("rebase failed")?;
cherry_rebase_group(repository, target_oid, &ids_to_rebase, succeeding_rebases)
.context("rebase failed")?;
branch.head = new_head;
}
let new_tree_commit =
cherry_rebase_group(ctx, branch.head, &mut [tree_commit]).context("rebase failed")?;
cherry_rebase_group(repository, branch.head, &[tree_commit], succeeding_rebases)
.context("rebase failed")?;
let new_tree_commit = repository
.find_commit(new_tree_commit)
@ -1809,7 +1838,9 @@ pub(crate) fn squash(
let vb_state = ctx.project().virtual_branches();
let mut branch = vb_state.get_branch_in_workspace(branch_id)?;
let default_target = vb_state.get_default_target()?;
let branch_commit_oids = ctx.l(branch.head, LogUntil::Commit(default_target.sha))?;
let branch_commit_oids = ctx
.repository()
.l(branch.head, LogUntil::Commit(default_target.sha))?;
if !branch_commit_oids.contains(&commit_id) {
bail!("commit {commit_id} not in the branch")
@ -1830,7 +1861,10 @@ pub(crate) fn squash(
let pushed_commit_oids = branch.upstream_head.map_or_else(
|| Ok(vec![]),
|upstream_head| ctx.l(upstream_head, LogUntil::Commit(default_target.sha)),
|upstream_head| {
ctx.repository()
.l(upstream_head, LogUntil::Commit(default_target.sha))
},
)?;
if pushed_commit_oids.contains(&parent_commit.id()) && !branch.allow_rebasing {
@ -1873,9 +1907,14 @@ pub(crate) fn squash(
ids.first().copied()
}
.with_context(|| format!("commit {commit_id} not in the branch"))?;
let mut ids_to_rebase = ids_to_rebase.to_vec();
let ids_to_rebase = ids_to_rebase.to_vec();
match cherry_rebase_group(ctx, new_commit_oid, &mut ids_to_rebase) {
match cherry_rebase_group(
ctx.repository(),
new_commit_oid,
&ids_to_rebase,
ctx.project().succeeding_rebases,
) {
Ok(new_head_id) => {
// save new branch head
branch.head = new_head_id;
@ -1906,7 +1945,9 @@ pub(crate) fn update_commit_message(
let default_target = vb_state.get_default_target()?;
let mut branch = vb_state.get_branch_in_workspace(branch_id)?;
let branch_commit_oids = ctx.l(branch.head, LogUntil::Commit(default_target.sha))?;
let branch_commit_oids = ctx
.repository()
.l(branch.head, LogUntil::Commit(default_target.sha))?;
if !branch_commit_oids.contains(&commit_id) {
bail!("commit {commit_id} not in the branch");
@ -1914,7 +1955,10 @@ pub(crate) fn update_commit_message(
let pushed_commit_oids = branch.upstream_head.map_or_else(
|| Ok(vec![]),
|upstream_head| ctx.l(upstream_head, LogUntil::Commit(default_target.sha)),
|upstream_head| {
ctx.repository()
.l(upstream_head, LogUntil::Commit(default_target.sha))
},
)?;
if pushed_commit_oids.contains(&commit_id) && !branch.allow_rebasing {
@ -1949,10 +1993,15 @@ pub(crate) fn update_commit_message(
ids.first().copied()
}
.with_context(|| format!("commit {commit_id} not in the branch"))?;
let mut ids_to_rebase = ids_to_rebase.to_vec();
let ids_to_rebase = ids_to_rebase.to_vec();
let new_head_id = cherry_rebase_group(ctx, new_commit_oid, &mut ids_to_rebase)
.map_err(|err| err.context("rebase error"))?;
let new_head_id = cherry_rebase_group(
ctx.repository(),
new_commit_oid,
&ids_to_rebase,
ctx.project().succeeding_rebases,
)
.map_err(|err| err.context("rebase error"))?;
// save new branch head
branch.head = new_head_id;
branch.updated_timestamp_ms = gitbutler_time::time::now_ms();

View File

@ -78,7 +78,7 @@ impl GitHunk {
new_lines: 0,
diff_lines: Default::default(),
binary: false,
change_type: ChangeType::Modified,
change_type: ChangeType::Added,
}
}
}
@ -387,6 +387,11 @@ fn reverse_patch(patch: &BStr) -> Option<BString> {
// returns `None` if the reversal failed
pub fn reverse_hunk(hunk: &GitHunk) -> Option<GitHunk> {
let new_change_type = match hunk.change_type {
ChangeType::Added => ChangeType::Deleted,
ChangeType::Deleted => ChangeType::Added,
ChangeType::Modified => ChangeType::Modified,
};
if hunk.binary {
None
} else {
@ -397,7 +402,7 @@ pub fn reverse_hunk(hunk: &GitHunk) -> Option<GitHunk> {
new_lines: hunk.old_lines,
diff_lines: diff.into(),
binary: hunk.binary,
change_type: hunk.change_type,
change_type: new_change_type,
})
}
}

View File

@ -1,6 +1,6 @@
#[cfg(target_family = "unix")]
use std::os::unix::prelude::PermissionsExt;
use std::{borrow::Borrow, path::PathBuf};
use std::{borrow::Borrow, fs, path::PathBuf};
use anyhow::{anyhow, Context, Result};
use bstr::{BString, ByteSlice, ByteVec};
@ -38,13 +38,14 @@ where
let head_commit = git_repository.find_commit(commit_oid)?;
let base_tree = head_commit.tree()?;
hunks_onto_tree(ctx, &base_tree, files)
hunks_onto_tree(ctx, &base_tree, files, false)
}
pub fn hunks_onto_tree<T>(
ctx: &CommandContext,
base_tree: &git2::Tree,
files: impl IntoIterator<Item = (impl Borrow<PathBuf>, impl Borrow<Vec<T>>)>,
allow_new_file: bool,
) -> Result<git2::Oid>
where
T: Into<GitHunk> + Clone,
@ -62,7 +63,21 @@ where
&& hunks[0].diff_lines.contains_str(b"Subproject commit");
// if file exists
if full_path.exists() {
let full_path_exists = full_path.exists();
let discard_hunk = (hunks.len() == 1).then(|| &hunks[0]);
if full_path_exists || allow_new_file {
if discard_hunk.map_or(false, |hunk| hunk.change_type == crate::ChangeType::Deleted) {
// File was created but now that hunk is being discarded with an inversed hunk
builder.remove(rel_path);
fs::remove_file(full_path.clone()).or_else(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
Ok(())
} else {
Err(err)
}
})?;
continue;
}
// if file is executable, use 755, otherwise 644
let mut filemode = git2::FileMode::Blob;
// check if full_path file is executable
@ -115,7 +130,7 @@ where
)?;
builder.upsert(rel_path, blob_oid, filemode);
} else if let Ok(tree_entry) = base_tree.get_path(rel_path) {
if hunks.len() == 1 && hunks[0].binary {
if discard_hunk.map_or(false, |hunk| hunk.binary) {
let new_blob_oid = &hunks[0].diff_lines;
// convert string to Oid
let new_blob_oid = new_blob_oid
@ -178,6 +193,20 @@ where
let new_blob_oid = git_repository.blob(blob_contents.as_bytes())?;
// upsert into the builder
builder.upsert(rel_path, new_blob_oid, filemode);
} else if !full_path_exists
&& discard_hunk.map_or(false, |hunk| hunk.change_type == crate::ChangeType::Added)
{
// File was deleted but now that hunk is being discarded with an inversed hunk
let mut all_diffs = BString::default();
for hunk in hunks {
all_diffs.push_str(&hunk.diff_lines);
}
let patch = Patch::from_bytes(&all_diffs)?;
let blob_contents =
apply([], &patch).context(format!("failed to apply {}", all_diffs))?;
let new_blob_oid = git_repository.blob(&blob_contents)?;
builder.upsert(rel_path, new_blob_oid, filemode);
} else {
// create a git blob from a file on disk
let blob_oid = git_repository

View File

@ -236,7 +236,6 @@ pub(crate) fn save_and_return_to_workspace(
let commit = repository
.find_commit(edit_mode_metadata.commit_oid)
.context("Failed to find commit")?;
let commit_parent = commit.parent(0).context("Failed to get commit's parent")?;
let stashed_workspace_changes_reference = repository
.find_reference(EDIT_UNCOMMITED_FILES_REF)
.context("Failed to find stashed workspace changes")?;
@ -250,6 +249,8 @@ pub(crate) fn save_and_return_to_workspace(
bail!("Failed to find virtual branch for this reference. Entering and leaving edit mode for non-virtual branches is unsupported")
};
let parents = commit.parents().collect::<Vec<_>>();
// Recommit commit
let tree = repository.create_wd_tree()?;
let commit_headers = commit
@ -266,7 +267,7 @@ pub(crate) fn save_and_return_to_workspace(
&commit.committer(),
&commit.message_bstr().to_str_lossy(),
&tree,
&[&commit_parent],
&parents.iter().collect::<Vec<_>>(),
commit_headers,
)
.context("Failed to commit new commit")?;
@ -303,9 +304,10 @@ pub(crate) fn save_and_return_to_workspace(
.context("Failed to update gitbutler workspace")?;
let rebased_stashed_workspace_changes_commit = cherry_rebase_group(
ctx,
repository,
workspace_commit_oid,
&mut [stashed_workspace_changes_commit.id()],
&[stashed_workspace_changes_commit.id()],
true,
)
.context("Failed to rebase stashed workspace commit changes")?;

View File

@ -1,6 +1,6 @@
use std::str::FromStr;
use crate::{LogUntil, RepoActionsExt};
use crate::{LogUntil, RepoActionsExt, RepositoryExt as _};
use anyhow::Context;
use anyhow::{anyhow, Result};
use gitbutler_branch::ChangeReference;
@ -163,6 +163,7 @@ fn commit_by_branch_id_and_change_id<'a>(
let target = handle.get_default_target()?;
// Find the commit with the change id
let commit = ctx
.repository()
.log(vbranch.head, LogUntil::Commit(target.sha))?
.iter()
.map(|c| c.id())
@ -187,6 +188,7 @@ fn validate_commit(
) -> Result<()> {
let target = handle.get_default_target()?;
let branch_commits = ctx
.repository()
.log(vbranch.head, LogUntil::Commit(target.sha))?
.iter()
.map(|c| c.id())

View File

@ -8,7 +8,7 @@ use gitbutler_commit::{
};
use gitbutler_error::error::Marker;
use crate::{LogUntil, RepoActionsExt};
use crate::{LogUntil, RepositoryExt as _};
/// cherry-pick based rebase, which handles empty commits
/// this function takes a commit range and generates a Vector of commit oids
@ -22,13 +22,20 @@ pub fn cherry_rebase(
from_commit_oid: git2::Oid,
) -> Result<Option<git2::Oid>> {
// get a list of the commits to rebase
let mut ids_to_rebase = ctx.l(from_commit_oid, LogUntil::Commit(to_commit_oid))?;
let ids_to_rebase = ctx
.repository()
.l(from_commit_oid, LogUntil::Commit(to_commit_oid))?;
if ids_to_rebase.is_empty() {
return Ok(None);
}
let new_head_id = cherry_rebase_group(ctx, target_commit_oid, &mut ids_to_rebase)?;
let new_head_id = cherry_rebase_group(
ctx.repository(),
target_commit_oid,
&ids_to_rebase,
ctx.project().succeeding_rebases,
)?;
Ok(Some(new_head_id))
}
@ -38,20 +45,19 @@ pub fn cherry_rebase(
/// the difference between this and a libgit2 based rebase is that this will successfully
/// rebase empty commits (two commits with identical trees)
pub fn cherry_rebase_group(
ctx: &CommandContext,
repository: &git2::Repository,
target_commit_oid: git2::Oid,
ids_to_rebase: &mut [git2::Oid],
ids_to_rebase: &[git2::Oid],
succeeding_rebases: bool,
) -> Result<git2::Oid> {
ids_to_rebase.reverse();
// now, rebase unchanged commits onto the new commit
let commits_to_rebase = ids_to_rebase
.iter()
.map(|oid| ctx.repository().find_commit(oid.to_owned()))
.map(|oid| repository.find_commit(oid.to_owned()))
.rev()
.collect::<Result<Vec<_>, _>>()
.context("failed to read commits to rebase")?;
let repository = ctx.repository();
let new_head_id = commits_to_rebase
.into_iter()
.fold(
@ -70,12 +76,24 @@ pub fn cherry_rebase_group(
.context("failed to cherry pick")?;
if cherrypick_index.has_conflicts() {
if !ctx.project().succeeding_rebases {
if !succeeding_rebases {
return Err(anyhow!("failed to rebase")).context(Marker::BranchConflict);
}
commit_conflicted_cherry_result(ctx, head, to_rebase, cherrypick_index)
commit_conflicted_cherry_result(
repository,
head,
to_rebase,
cherrypick_index,
None,
)
} else {
commit_unconflicted_cherry_result(ctx, head, to_rebase, cherrypick_index)
commit_unconflicted_cherry_result(
repository,
head,
to_rebase,
cherrypick_index,
None,
)
}
},
)?
@ -84,13 +102,20 @@ pub fn cherry_rebase_group(
Ok(new_head_id)
}
pub struct OverrideCommitDetails<'a, 'repository> {
message: &'a str,
parents: &'a [&'a git2::Commit<'repository>],
author: &'a git2::Signature<'repository>,
commiter: &'a git2::Signature<'repository>,
}
fn commit_unconflicted_cherry_result<'repository>(
ctx: &'repository CommandContext,
repository: &'repository git2::Repository,
head: git2::Commit<'repository>,
to_rebase: git2::Commit,
mut cherrypick_index: git2::Index,
override_commit_details: Option<OverrideCommitDetails>,
) -> Result<git2::Commit<'repository>> {
let repository = ctx.repository();
let commit_headers = to_rebase.gitbutler_headers();
let is_merge_commit = to_rebase.parent_count() > 0;
@ -113,17 +138,31 @@ fn commit_unconflicted_cherry_result<'repository>(
..commit_headers
});
let commit_oid = crate::RepositoryExt::commit_with_signature(
repository,
None,
&to_rebase.author(),
&to_rebase.committer(),
&to_rebase.message_bstr().to_str_lossy(),
&merge_tree,
&[&head],
commit_headers,
)
.context("failed to create commit")?;
let commit_oid = if let Some(override_commit_details) = override_commit_details {
crate::RepositoryExt::commit_with_signature(
repository,
None,
override_commit_details.author,
override_commit_details.commiter,
override_commit_details.message,
&merge_tree,
override_commit_details.parents,
commit_headers,
)
.context("failed to create commit")?
} else {
crate::RepositoryExt::commit_with_signature(
repository,
None,
&to_rebase.author(),
&to_rebase.committer(),
&to_rebase.message_bstr().to_str_lossy(),
&merge_tree,
&[&head],
commit_headers,
)
.context("failed to create commit")?
};
repository
.find_commit(commit_oid)
@ -131,12 +170,12 @@ fn commit_unconflicted_cherry_result<'repository>(
}
fn commit_conflicted_cherry_result<'repository>(
ctx: &'repository CommandContext,
repository: &'repository git2::Repository,
head: git2::Commit,
to_rebase: git2::Commit,
cherrypick_index: git2::Index,
override_commit_details: Option<OverrideCommitDetails>,
) -> Result<git2::Commit<'repository>> {
let repository = ctx.repository();
let commit_headers = to_rebase.gitbutler_headers();
// If the commit we're rebasing is conflicted, use the commits original base.
@ -201,33 +240,91 @@ fn commit_conflicted_cherry_result<'repository>(
let tree_oid = tree_writer.write().context("failed to write tree")?;
let commit_headers = commit_headers.map(|commit_headers| {
let conflicted_file_count = conflicted_files
.len()
.try_into()
.expect("If you have more than 2^64 conflicting files, we've got bigger problems");
CommitHeadersV2 {
conflicted: Some(conflicted_file_count),
..commit_headers
}
});
let commit_headers =
commit_headers
.or_else(|| Some(Default::default()))
.map(|commit_headers| {
let conflicted_file_count = conflicted_files.len().try_into().expect(
"If you have more than 2^64 conflicting files, we've got bigger problems",
);
CommitHeadersV2 {
conflicted: Some(conflicted_file_count),
..commit_headers
}
});
// write a commit
let commit_oid = crate::RepositoryExt::commit_with_signature(
repository,
None,
&to_rebase.author(),
&to_rebase.committer(),
&to_rebase.message_bstr().to_str_lossy(),
&repository
.find_tree(tree_oid)
.context("failed to find tree")?,
&[&head],
commit_headers,
)
.context("failed to create commit")?;
let commit_oid = if let Some(override_commit_details) = override_commit_details {
crate::RepositoryExt::commit_with_signature(
repository,
None,
override_commit_details.author,
override_commit_details.commiter,
override_commit_details.message,
&repository
.find_tree(tree_oid)
.context("failed to find tree")?,
override_commit_details.parents,
commit_headers,
)
.context("failed to create commit")?
} else {
crate::RepositoryExt::commit_with_signature(
repository,
None,
&to_rebase.author(),
&to_rebase.committer(),
&to_rebase.message_bstr().to_str_lossy(),
&repository
.find_tree(tree_oid)
.context("failed to find tree")?,
&[&head],
commit_headers,
)
.context("failed to create commit")?
};
repository
.find_commit(commit_oid)
.context("failed to find commit")
}
pub fn gitbutler_merge_commits<'repository>(
repository: &'repository git2::Repository,
target_commit: git2::Commit<'repository>,
incoming_commit: git2::Commit<'repository>,
target_branch_name: &str,
incoming_branch_name: &str,
) -> Result<git2::Commit<'repository>> {
let cherrypick_index =
repository.cherry_pick_gitbutler(&target_commit, &incoming_commit, None)?;
let (author, committer) = repository.signatures()?;
let override_commit_details = OverrideCommitDetails {
message: &format!(
"Merge branch `{}` into `{}`",
incoming_branch_name, target_branch_name
),
parents: &[&target_commit.clone(), &incoming_commit.clone()],
author: &author,
commiter: &committer,
};
if cherrypick_index.has_conflicts() {
commit_conflicted_cherry_result(
repository,
target_commit,
incoming_commit,
cherrypick_index,
Some(override_commit_details),
)
} else {
commit_unconflicted_cherry_result(
repository,
target_commit,
incoming_commit,
cherrypick_index,
Some(override_commit_details),
)
}
}

View File

@ -1,14 +1,14 @@
use std::str::FromStr;
use anyhow::{anyhow, Context, Result};
use gitbutler_branch::{gix_to_git2_signature, Branch, BranchId, SignaturePurpose};
use gitbutler_branch::{Branch, BranchId};
use gitbutler_command_context::CommandContext;
use gitbutler_commit::commit_headers::CommitHeadersV2;
use gitbutler_error::error::Code;
use gitbutler_project::AuthKey;
use gitbutler_reference::{Refname, RemoteRefname};
use crate::{askpass, credentials, Config, RepositoryExt};
use crate::{askpass, credentials, RepositoryExt};
pub trait RepoActionsExt {
fn fetch(&self, remote_name: &str, askpass: Option<String>) -> Result<()>;
fn push(
@ -27,9 +27,6 @@ pub trait RepoActionsExt {
commit_headers: Option<CommitHeadersV2>,
) -> Result<git2::Oid>;
fn distance(&self, from: git2::Oid, to: git2::Oid) -> Result<u32>;
fn log(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Commit>>;
fn list_commits(&self, from: git2::Oid, to: git2::Oid) -> Result<Vec<git2::Commit>>;
fn l(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Oid>>;
fn delete_branch_reference(&self, branch: &Branch) -> Result<()>;
fn add_branch_reference(&self, branch: &Branch) -> Result<()>;
fn git_test_push(
@ -38,7 +35,6 @@ pub trait RepoActionsExt {
branch_name: &str,
askpass: Option<Option<BranchId>>,
) -> Result<()>;
fn signatures(&self) -> Result<(git2::Signature, git2::Signature)>;
}
impl RepoActionsExt for CommandContext {
@ -126,97 +122,9 @@ impl RepoActionsExt for CommandContext {
.context("failed to lookup reference")
}
// returns a list of commit oids from the first oid to the second oid
fn l(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Oid>> {
match to {
LogUntil::Commit(oid) => {
let mut revwalk = self
.repository()
.revwalk()
.context("failed to create revwalk")?;
revwalk
.push(from)
.context(format!("failed to push {}", from))?;
revwalk
.hide(oid)
.context(format!("failed to hide {}", oid))?;
revwalk
.map(|oid| oid.map(Into::into))
.collect::<Result<Vec<_>, _>>()
}
LogUntil::Take(n) => {
let mut revwalk = self
.repository()
.revwalk()
.context("failed to create revwalk")?;
revwalk
.push(from)
.context(format!("failed to push {}", from))?;
revwalk
.take(n)
.map(|oid| oid.map(Into::into))
.collect::<Result<Vec<_>, _>>()
}
LogUntil::When(cond) => {
let mut revwalk = self
.repository()
.revwalk()
.context("failed to create revwalk")?;
revwalk
.push(from)
.context(format!("failed to push {}", from))?;
let mut oids: Vec<git2::Oid> = vec![];
for oid in revwalk {
let oid = oid.context("failed to get oid")?;
oids.push(oid);
let commit = self
.repository()
.find_commit(oid)
.context("failed to find commit")?;
if cond(&commit).context("failed to check condition")? {
break;
}
}
Ok(oids)
}
LogUntil::End => {
let mut revwalk = self
.repository()
.revwalk()
.context("failed to create revwalk")?;
revwalk
.push(from)
.context(format!("failed to push {}", from))?;
revwalk
.map(|oid| oid.map(Into::into))
.collect::<Result<Vec<_>, _>>()
}
}
.context("failed to collect oids")
}
fn list_commits(&self, from: git2::Oid, to: git2::Oid) -> Result<Vec<git2::Commit>> {
Ok(self
.l(from, LogUntil::Commit(to))?
.into_iter()
.map(|oid| self.repository().find_commit(oid))
.collect::<Result<Vec<_>, _>>()?)
}
// returns a list of commits from the first oid to the second oid
fn log(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Commit>> {
self.l(from, to)?
.into_iter()
.map(|oid| self.repository().find_commit(oid))
.collect::<Result<Vec<_>, _>>()
.context("failed to collect commits")
}
// returns the number of commits between the first oid to the second oid
fn distance(&self, from: git2::Oid, to: git2::Oid) -> Result<u32> {
let oids = self.l(from, LogUntil::Commit(to))?;
let oids = self.repository().l(from, LogUntil::Commit(to))?;
Ok(oids.len().try_into()?)
}
@ -227,7 +135,10 @@ impl RepoActionsExt for CommandContext {
parents: &[&git2::Commit],
commit_headers: Option<CommitHeadersV2>,
) -> Result<git2::Oid> {
let (author, committer) = self.signatures().context("failed to get signatures")?;
let (author, committer) = self
.repository()
.signatures()
.context("failed to get signatures")?;
self.repository()
.commit_with_signature(
None,
@ -404,30 +315,6 @@ impl RepoActionsExt for CommandContext {
Err(anyhow!("authentication failed")).context(Code::ProjectGitAuth)
}
fn signatures(&self) -> Result<(git2::Signature, git2::Signature)> {
let repo = gix::open(self.repository().path())?;
let author = repo
.author()
.transpose()?
.map(gitbutler_branch::gix_to_git2_signature)
.transpose()?
.context("No author is configured in Git")
.context(Code::AuthorMissing)?;
let config: Config = self.repository().into();
let committer = if config.user_real_comitter()? {
repo.committer()
.transpose()?
.map(gix_to_git2_signature)
.unwrap_or_else(|| gitbutler_branch::signature(SignaturePurpose::Committer))
} else {
gitbutler_branch::signature(SignaturePurpose::Committer)
}?;
Ok((author, committer))
}
}
type OidFilter = dyn Fn(&git2::Commit) -> Result<bool>;

View File

@ -7,16 +7,23 @@ use std::{io::Write, path::Path, process::Stdio, str};
use anyhow::{anyhow, bail, Context, Result};
use bstr::BString;
use git2::{BlameOptions, Tree};
use gitbutler_branch::{gix_to_git2_signature, SignaturePurpose};
use gitbutler_commit::{commit_buffer::CommitBuffer, commit_headers::CommitHeadersV2};
use gitbutler_config::git::{GbConfig, GitConfig};
use gitbutler_error::error::Code;
use gitbutler_reference::{Refname, RemoteRefname};
use tracing::instrument;
use crate::{Config, LogUntil};
/// Extension trait for `git2::Repository`.
///
/// For now, it collects useful methods from `gitbutler-core::git::Repository`
pub trait RepositoryExt {
fn signatures(&self) -> Result<(git2::Signature, git2::Signature)>;
fn l(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Oid>>;
fn list_commits(&self, from: git2::Oid, to: git2::Oid) -> Result<Vec<git2::Commit>>;
fn log(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Commit>>;
/// Return `HEAD^{commit}` - ideal for obtaining the integration branch commit in open-workspace mode
/// when it's clear that it's representing the current state.
///
@ -390,6 +397,103 @@ impl RepositoryExt for git2::Repository {
})
.collect::<Result<Vec<_>>>()
}
// returns a list of commit oids from the first oid to the second oid
fn l(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Oid>> {
match to {
LogUntil::Commit(oid) => {
let mut revwalk = self.revwalk().context("failed to create revwalk")?;
revwalk
.push(from)
.context(format!("failed to push {}", from))?;
revwalk
.hide(oid)
.context(format!("failed to hide {}", oid))?;
revwalk
.map(|oid| oid.map(Into::into))
.collect::<Result<Vec<_>, _>>()
}
LogUntil::Take(n) => {
let mut revwalk = self.revwalk().context("failed to create revwalk")?;
revwalk
.push(from)
.context(format!("failed to push {}", from))?;
revwalk
.take(n)
.map(|oid| oid.map(Into::into))
.collect::<Result<Vec<_>, _>>()
}
LogUntil::When(cond) => {
let mut revwalk = self.revwalk().context("failed to create revwalk")?;
revwalk
.push(from)
.context(format!("failed to push {}", from))?;
let mut oids: Vec<git2::Oid> = vec![];
for oid in revwalk {
let oid = oid.context("failed to get oid")?;
oids.push(oid);
let commit = self.find_commit(oid).context("failed to find commit")?;
if cond(&commit).context("failed to check condition")? {
break;
}
}
Ok(oids)
}
LogUntil::End => {
let mut revwalk = self.revwalk().context("failed to create revwalk")?;
revwalk
.push(from)
.context(format!("failed to push {}", from))?;
revwalk
.map(|oid| oid.map(Into::into))
.collect::<Result<Vec<_>, _>>()
}
}
.context("failed to collect oids")
}
fn list_commits(&self, from: git2::Oid, to: git2::Oid) -> Result<Vec<git2::Commit>> {
Ok(self
.l(from, LogUntil::Commit(to))?
.into_iter()
.map(|oid| self.find_commit(oid))
.collect::<Result<Vec<_>, _>>()?)
}
// returns a list of commits from the first oid to the second oid
fn log(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Commit>> {
self.l(from, to)?
.into_iter()
.map(|oid| self.find_commit(oid))
.collect::<Result<Vec<_>, _>>()
.context("failed to collect commits")
}
fn signatures(&self) -> Result<(git2::Signature, git2::Signature)> {
let repo = gix::open(self.path())?;
let author = repo
.author()
.transpose()?
.map(gitbutler_branch::gix_to_git2_signature)
.transpose()?
.context("No author is configured in Git")
.context(Code::AuthorMissing)?;
let config: Config = self.into();
let committer = if config.user_real_comitter()? {
repo.committer()
.transpose()?
.map(gix_to_git2_signature)
.unwrap_or_else(|| gitbutler_branch::signature(SignaturePurpose::Committer))
} else {
gitbutler_branch::signature(SignaturePurpose::Committer)
}?;
Ok((author, committer))
}
}
/// Signs the buffer with the configured gpg key, returning the signature.

View File

@ -4,7 +4,7 @@ use gitbutler_command_context::CommandContext;
use gitbutler_commit::commit_ext::CommitExt;
use gitbutler_repo::{
create_change_reference, list_branch_references, push_change_reference,
update_change_reference, LogUntil, RepoActionsExt,
update_change_reference, LogUntil, RepositoryExt as _,
};
use tempfile::TempDir;
@ -232,8 +232,12 @@ fn test_ctx(ctx: &CommandContext) -> Result<TestContext> {
let branch = branches.iter().find(|b| b.name == "virtual").unwrap();
let other_branch = branches.iter().find(|b| b.name != "virtual").unwrap();
let target = handle.get_default_target()?;
let branch_commits = ctx.log(branch.head, LogUntil::Commit(target.sha))?;
let other_commits = ctx.log(other_branch.head, LogUntil::Commit(target.sha))?;
let branch_commits = ctx
.repository()
.log(branch.head, LogUntil::Commit(target.sha))?;
let other_commits = ctx
.repository()
.log(other_branch.head, LogUntil::Commit(target.sha))?;
Ok(TestContext {
branch: branch.clone(),
commits: branch_commits,

View File

@ -188,6 +188,8 @@ fn main() {
virtual_branches::commands::fetch_from_remotes,
virtual_branches::commands::move_commit,
virtual_branches::commands::normalize_branch_name,
virtual_branches::commands::upstream_integration_statuses,
virtual_branches::commands::integrate_upstream,
secret::secret_get_global,
secret::secret_set_global,
undo::list_snapshots,

View File

@ -31,7 +31,6 @@ pub fn list_snapshots(
#[instrument(skip(projects), err(Debug))]
pub fn restore_snapshot(
projects: State<'_, projects::Controller>,
handle: tauri::AppHandle,
project_id: ProjectId,
sha: String,
) -> Result<(), Error> {

View File

@ -3,6 +3,7 @@ pub mod commands {
use gitbutler_branch::{
BranchCreateRequest, BranchId, BranchOwnershipClaims, BranchUpdateRequest,
};
use gitbutler_branch_actions::upstream_integration::{BranchStatuses, Resolution};
use gitbutler_branch_actions::{
BaseBranch, BranchListing, BranchListingDetails, BranchListingFilter, RemoteBranch,
RemoteBranchData, RemoteBranchFile, VirtualBranches,
@ -595,6 +596,35 @@ pub mod commands {
Ok(())
}
#[tauri::command(async)]
#[instrument(skip(projects), err(Debug))]
pub fn upstream_integration_statuses(
projects: State<'_, projects::Controller>,
project_id: ProjectId,
) -> Result<BranchStatuses, Error> {
let project = projects.get(project_id)?;
Ok(gitbutler_branch_actions::upstream_integration_statuses(
&project,
)?)
}
#[tauri::command(async)]
#[instrument(skip(projects, windows), err(Debug))]
pub fn integrate_upstream(
windows: State<'_, WindowState>,
projects: State<'_, projects::Controller>,
project_id: ProjectId,
resolutions: Vec<Resolution>,
) -> Result<(), Error> {
let project = projects.get(project_id)?;
gitbutler_branch_actions::integrate_upstream(&project, &resolutions)?;
emit_vbranches(&windows, project_id);
Ok(())
}
fn emit_vbranches(windows: &WindowState, project_id: projects::ProjectId) {
if let Err(error) = windows.post(gitbutler_watcher::Action::CalculateVirtualBranches(
project_id,

View File

@ -49,7 +49,10 @@ export default tsEslint.config(
parser: svelteParser,
parserOptions: {
parser: tsParser,
extraFileExtensions: ['.svelte']
extraFileExtensions: ['.svelte'],
svelteFeatures: {
experimentalGenerics: true
}
}
}
},

View File

@ -1,4 +1,5 @@
<script lang="ts" module>
export type CheckboxStyle = 'default' | '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,25 @@
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);
}
}
/* checked */
&:checked {
&:disabled {
pointer-events: none;
opacity: 0.4;
@ -127,6 +145,24 @@
}
}
&.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);
}
}
&::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": {},
@ -649,7 +649,7 @@
},
"50": {
"$type": "color",
"$value": "#dc9b14",
"$value": "#dc9914",
"$description": "",
"$extensions": {
"mode": {},
@ -665,7 +665,7 @@
},
"60": {
"$type": "color",
"$value": "#f4bb6c",
"$value": "#f4c06c",
"$description": "",
"$extensions": {
"mode": {},
@ -681,7 +681,7 @@
},
"70": {
"$type": "color",
"$value": "#feddae",
"$value": "#fee1ae",
"$description": "",
"$extensions": {
"mode": {},
@ -697,7 +697,7 @@
},
"80": {
"$type": "color",
"$value": "#ffe8c7",
"$value": "#ffecc7",
"$description": "",
"$extensions": {
"mode": {},
@ -713,7 +713,7 @@
},
"90": {
"$type": "color",
"$value": "#fff2e0",
"$value": "#fff7e0",
"$description": "",
"$extensions": {
"mode": {},
@ -729,7 +729,7 @@
},
"95": {
"$type": "color",
"$value": "#fdf7ed",
"$value": "#fdf9ed",
"$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": "{clr-core.pop.70}",
"$description": "",
"$extensions": {
"mode": {
"light": "#378bf2",
"dark": "#044289"
"light": "{clr-core.pop.70}",
"dark": "{clr-core.pop.10}"
},
"figma": {
"variableId": "VariableID:3935:251",
@ -3671,12 +3671,12 @@
},
"count-border": {
"$type": "color",
"$value": "#265dd4",
"$value": "{clr-core.pop.60}",
"$description": "",
"$extensions": {
"mode": {
"light": "#265dd4",
"dark": "#005cc5"
"light": "{clr-core.pop.60}",
"dark": "{clr-core.pop.30}"
},
"figma": {
"variableId": "VariableID:3935:253",
@ -3690,12 +3690,12 @@
},
"count-text": {
"$type": "color",
"$value": "#ffffff",
"$value": "{clr-core.pop.40}",
"$description": "",
"$extensions": {
"mode": {
"light": "#ffffff",
"dark": "#d6e8ff"
"light": "{clr-core.pop.40}",
"dark": "{clr-core.pop.50}"
},
"figma": {
"variableId": "VariableID:3935:254",
@ -4137,7 +4137,7 @@
"useDTCGKeys": true,
"colorMode": "hex",
"variableCollections": ["clr-core", "clr", "size", "radius"],
"createdAt": "2024-08-31T23:16:13.487Z"
"createdAt": "2024-09-11T23:46:21.639Z"
}
}
}

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

@ -13,9 +13,16 @@ export const CheckboxStory: Story = {
name: 'Checkbox',
args: {
name: 'Checkbox',
style: 'default',
checked: false,
disabled: false,
indeterminate: false,
small: false
},
argTypes: {
style: {
options: ['default', 'neutral'],
control: { type: 'select' }
}
}
};

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);
@ -43,12 +43,12 @@
--clr-core-warn-20: color(srgb 0.3764705882352941 0.2549019607843137 0.08627450980392157);
--clr-core-warn-30: color(srgb 0.5411764705882353 0.34901960784313724 0.0784313725490196);
--clr-core-warn-40: color(srgb 0.7803921568627451 0.4980392156862745 0.10196078431372549);
--clr-core-warn-50: color(srgb 0.8627450980392157 0.6078431372549019 0.0784313725490196);
--clr-core-warn-60: color(srgb 0.9568627450980393 0.7333333333333333 0.4235294117647059);
--clr-core-warn-70: color(srgb 0.996078431372549 0.8666666666666667 0.6823529411764706);
--clr-core-warn-80: color(srgb 1 0.9098039215686274 0.7803921568627451);
--clr-core-warn-90: color(srgb 1 0.9490196078431372 0.8784313725490196);
--clr-core-warn-95: color(srgb 0.9921568627450981 0.9686274509803922 0.9294117647058824);
--clr-core-warn-50: color(srgb 0.8627450980392157 0.6 0.0784313725490196);
--clr-core-warn-60: color(srgb 0.9568627450980393 0.7529411764705882 0.4235294117647059);
--clr-core-warn-70: color(srgb 0.996078431372549 0.8823529411764706 0.6823529411764706);
--clr-core-warn-80: color(srgb 1 0.9254901960784314 0.7803921568627451);
--clr-core-warn-90: color(srgb 1 0.9686274509803922 0.8784313725490196);
--clr-core-warn-95: color(srgb 0.9921568627450981 0.9764705882352941 0.9294117647058824);
--clr-core-succ-5: color(srgb 0.050980392156862744 0.14901960784313725 0.10196078431372549);
--clr-core-succ-10: color(srgb 0.10980392156862745 0.25098039215686274 0.1843137254901961);
--clr-core-succ-20: color(srgb 0.13333333333333333 0.3254901960784314 0.23529411764705882);
@ -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,9 @@
--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(
srgb 0.21568627450980393 0.5450980392156862 0.9490196078431372
);
--clr-diff-selected-count-border: color(
srgb 0.14901960784313725 0.36470588235294116 0.8313725490196079
);
--clr-diff-selected-count-text: color(srgb 1 1 1);
--clr-diff-selected-count-bg: var(--clr-core-pop-70);
--clr-diff-selected-count-border: var(--clr-core-pop-60);
--clr-diff-selected-count-text: var(--clr-core-pop-40);
--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(
@ -385,11 +381,9 @@
--clr-diff-line-bg: var(--clr-bg-1);
--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
);
--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-bg: var(--clr-core-pop-10);
--clr-diff-selected-count-border: var(--clr-core-pop-30);
--clr-diff-selected-count-text: var(--clr-core-pop-50);
--clr-diff-count-text: var(--clr-text-3);
--clr-diff-deletion-line-bg: color(
srgb 0.23529411764705882 0.07450980392156863 0.10588235294117647