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", "tracing",
"url", "url",
"urlencoding", "urlencoding",
"uuid",
] ]
[[package]] [[package]]

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import BranchHeader from './BranchHeader.svelte'; import BranchHeader from './BranchHeader.svelte';
import StackedBranchHeader from './StackedBranchHeader.svelte';
import EmptyStatePlaceholder from '../components/EmptyStatePlaceholder.svelte'; import EmptyStatePlaceholder from '../components/EmptyStatePlaceholder.svelte';
import PullRequestCard from '../pr/PullRequestCard.svelte'; import PullRequestCard from '../pr/PullRequestCard.svelte';
import InfoMessage from '../shared/InfoMessage.svelte'; import InfoMessage from '../shared/InfoMessage.svelte';
@ -12,7 +13,11 @@
import CommitDialog from '$lib/commit/CommitDialog.svelte'; import CommitDialog from '$lib/commit/CommitDialog.svelte';
import CommitList from '$lib/commit/CommitList.svelte'; import CommitList from '$lib/commit/CommitList.svelte';
import { projectAiGenEnabled } from '$lib/config/config'; import { projectAiGenEnabled } from '$lib/config/config';
import { stackingFeature } from '$lib/config/uiFeatureFlags';
import BranchFiles from '$lib/file/BranchFiles.svelte'; 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 { showError } from '$lib/notifications/toasts';
import { persisted } from '$lib/persisted/persisted'; import { persisted } from '$lib/persisted/persisted';
import { isFailure } from '$lib/result'; import { isFailure } from '$lib/result';
@ -22,8 +27,16 @@
import { User } from '$lib/stores/user'; import { User } from '$lib/stores/user';
import { getContext, getContextStore, getContextStoreBySymbol } from '$lib/utils/context'; import { getContext, getContextStore, getContextStoreBySymbol } from '$lib/utils/context';
import { BranchController } from '$lib/vbranches/branchController'; 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 { FileIdSelection } from '$lib/vbranches/fileIdSelection';
import { VirtualBranch } from '$lib/vbranches/types'; import { VirtualBranch } from '$lib/vbranches/types';
import Button from '@gitbutler/ui/Button.svelte';
import lscache from 'lscache'; import lscache from 'lscache';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
@ -93,6 +106,33 @@
onMount(() => { onMount(() => {
laneWidth = lscache.get(laneWidthKey + branch.id); 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> </script>
{#if $isLaneCollapsed} {#if $isLaneCollapsed}
@ -122,10 +162,12 @@
data-tauri-drag-region data-tauri-drag-region
> >
<BranchHeader {isLaneCollapsed} onGenerateBranchName={generateBranchName} /> <BranchHeader {isLaneCollapsed} onGenerateBranchName={generateBranchName} />
<PullRequestCard /> {#if !$stackingFeature && branch.upstream?.givenName}
<div class="card"> <PullRequestCard upstreamName={branch.upstream.givenName} />
{/if}
<div class:card-no-stacking={!$stackingFeature} class:card-stacking={$stackingFeature}>
{#if branch.files?.length > 0} {#if branch.files?.length > 0}
<div class="branch-card__files"> <div class="branch-card__files" class:card={$stackingFeature}>
<Dropzones> <Dropzones>
<BranchFiles <BranchFiles
isUnapplied={false} isUnapplied={false}
@ -157,7 +199,7 @@
</div> </div>
{:else if branch.commits.length === 0} {:else if branch.commits.length === 0}
<Dropzones> <Dropzones>
<div class="new-branch"> <div class="new-branch" class:card={$stackingFeature}>
<EmptyStatePlaceholder image={laneNewSvg} width="11rem"> <EmptyStatePlaceholder image={laneNewSvg} width="11rem">
<svelte:fragment slot="title">This is a new branch</svelte:fragment> <svelte:fragment slot="title">This is a new branch</svelte:fragment>
<svelte:fragment slot="caption"> <svelte:fragment slot="caption">
@ -168,7 +210,7 @@
</Dropzones> </Dropzones>
{:else} {:else}
<Dropzones> <Dropzones>
<div class="no-changes"> <div class="no-changes" class:card={$stackingFeature}>
<EmptyStatePlaceholder image={noChangesSvg} width="11rem" hasBottomMargin={false}> <EmptyStatePlaceholder image={noChangesSvg} width="11rem" hasBottomMargin={false}>
<svelte:fragment slot="caption" <svelte:fragment slot="caption"
>No uncommitted changes on this branch</svelte:fragment >No uncommitted changes on this branch</svelte:fragment
@ -178,7 +220,57 @@
</Dropzones> </Dropzones>
{/if} {/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>
</div> </div>
</ScrollableContainer> </ScrollableContainer>
@ -231,8 +323,19 @@
padding: 12px; padding: 12px;
} }
.card { .card-no-stacking {
flex: 1; 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 { .branch-card__files {
@ -278,4 +381,12 @@
height: 100%; height: 100%;
background-color: var(--clr-border-2); 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> </style>

View File

@ -2,11 +2,13 @@
import ActiveBranchStatus from './ActiveBranchStatus.svelte'; import ActiveBranchStatus from './ActiveBranchStatus.svelte';
import BranchLabel from './BranchLabel.svelte'; import BranchLabel from './BranchLabel.svelte';
import BranchLaneContextMenu from './BranchLaneContextMenu.svelte'; import BranchLaneContextMenu from './BranchLaneContextMenu.svelte';
import DefaultTargetButton from './DefaultTargetButton.svelte';
import PullRequestButton from '../pr/PullRequestButton.svelte'; import PullRequestButton from '../pr/PullRequestButton.svelte';
import { Project } from '$lib/backend/projects'; import { Project } from '$lib/backend/projects';
import { BaseBranch } from '$lib/baseBranch/baseBranch'; import { BaseBranch } from '$lib/baseBranch/baseBranch';
import { BaseBranchService } from '$lib/baseBranch/baseBranchService'; import { BaseBranchService } from '$lib/baseBranch/baseBranchService';
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte'; import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import { stackingFeature } from '$lib/config/uiFeatureFlags';
import { mapErrorToToast } from '$lib/gitHost/github/errorMap'; import { mapErrorToToast } from '$lib/gitHost/github/errorMap';
import { getGitHost } from '$lib/gitHost/interface/gitHost'; import { getGitHost } from '$lib/gitHost/interface/gitHost';
import { getGitHostListingService } from '$lib/gitHost/interface/gitHostListingService'; import { getGitHostListingService } from '$lib/gitHost/interface/gitHostListingService';
@ -140,7 +142,13 @@
return; return;
} }
await $prService.createPr(title, body, opts.draft, upstreamBranchName, baseBranchName); await $prService.createPr({
title,
body,
draft: opts.draft,
baseBranchName,
upstreamName: upstreamBranchName
});
} catch (err: any) { } catch (err: any) {
console.error(err); console.error(err);
const toast = mapErrorToToast(err); const toast = mapErrorToToast(err);
@ -216,90 +224,101 @@
<Icon name="draggable" /> <Icon name="draggable" />
</div> </div>
<div class="header__info"> <div class:header__info={!$stackingFeature} class:stacking-header__info={$stackingFeature}>
<BranchLabel name={branch.name} onChange={(name) => handleBranchNameChange(name)} /> <BranchLabel name={branch.name} onChange={(name) => handleBranchNameChange(name)} />
<div class="header__remote-branch"> {#if $stackingFeature}
<ActiveBranchStatus <span class="button-group">
{hasIntegratedCommits} <DefaultTargetButton
remoteExists={!!branch.upstream} selectedForChanges={branch.selectedForChanges}
isLaneCollapsed={$isLaneCollapsed} 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} {#await branch.isMergeable then isMergeable}
{#if !isMergeable} {#if !isMergeable}
<Button <Button
size="tag" size="tag"
clickable={false} clickable={false}
icon="locked-small" icon="locked-small"
style="warning" style="warning"
tooltip="Applying this branch will add merge conflict markers that you will have to resolve" tooltip="Applying this branch will add merge conflict markers that you will have to resolve"
> >
Conflict Conflict
</Button> </Button>
{/if} {/if}
{/await} {/await}
</div> </div>
{/if}
</div> </div>
</div> </div>
<div class="header__actions"> {#if !$stackingFeature}
<div class="header__buttons"> <div class="header__actions">
{#if branch.selectedForChanges} <div class="header__buttons">
<Button <DefaultTargetButton
style="pop" selectedForChanges={branch.selectedForChanges}
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"
onclick={async () => { onclick={async () => {
isTargetBranchAnimated = true; isTargetBranchAnimated = true;
await branchController.setSelectedForChanges(branch.id); 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>
<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>
</div> {/if}
</div> </div>
<div class="header__top-overlay" data-remove-from-draggable data-tauri-drag-region></div> <div class="header__top-overlay" data-remove-from-draggable data-tauri-drag-region></div>
</div> </div>
@ -370,6 +389,20 @@
overflow: hidden; overflow: hidden;
gap: 10px; 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 { .header__actions {
display: flex; display: flex;
gap: 4px; gap: 4px;

View File

@ -19,7 +19,7 @@
createRemoteCommitsContextStore createRemoteCommitsContextStore
} from '$lib/vbranches/contexts'; } from '$lib/vbranches/contexts';
import { FileIdSelection } from '$lib/vbranches/fileIdSelection'; 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 { RemoteFile, VirtualBranch } from '$lib/vbranches/types';
import lscache from 'lscache'; import lscache from 'lscache';
import { setContext } from 'svelte'; import { setContext } from 'svelte';
@ -55,12 +55,15 @@
// BRANCH // BRANCH
const branchStore = createContextStore(VirtualBranch, 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); const branchFiles = writable(branch.files);
$effect(() => { $effect(() => {
branchStore.set(branch); branchStore.set(branch);
ownershipStore.set(Ownership.fromBranch(branch)); selectedOwnershipStore.update((o) => o?.update(branch));
branchFiles.set(branch.files); 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 { BaseBranch } from '$lib/baseBranch/baseBranch';
import CommitMessageInput from '$lib/commit/CommitMessageInput.svelte'; import CommitMessageInput from '$lib/commit/CommitMessageInput.svelte';
import { persistedCommitMessage } from '$lib/config/config'; 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 } from '$lib/dragging/draggable';
import { DraggableCommit, nonDraggable } from '$lib/dragging/draggables'; import { DraggableCommit, nonDraggable } from '$lib/dragging/draggables';
import BranchFilesList from '$lib/file/BranchFilesList.svelte'; import BranchFilesList from '$lib/file/BranchFilesList.svelte';
@ -50,8 +50,6 @@
const currentCommitMessage = persistedCommitMessage(project.id, branch?.id || ''); const currentCommitMessage = persistedCommitMessage(project.id, branch?.id || '');
const branchStacking = featureBranchStacking();
let draggableCommitElement: HTMLElement | null = null; let draggableCommitElement: HTMLElement | null = null;
let files: RemoteFile[] = []; let files: RemoteFile[] = [];
let showDetails = false; let showDetails = false;
@ -349,18 +347,8 @@
</div> </div>
</button> </button>
{/if} {/if}
<span class="commit__subtitle-divider"></span> <span class="commit__subtitle-divider"></span>
<span>{getTimeAndAuthor()}</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> </div>
{/if} {/if}
</div> </div>
@ -399,7 +387,7 @@
icon="edit-small" icon="edit-small"
onclick={openCommitMessageModal}>Edit message</Button onclick={openCommitMessageModal}>Edit message</Button
> >
{#if $branchStacking && commit instanceof DetailedCommit && !commit.remoteRef} {#if $stackingFeature && commit instanceof DetailedCommit && !commit.remoteRef}
<Button <Button
size="tag" size="tag"
style="ghost" style="ghost"
@ -408,7 +396,7 @@
onclick={(e: Event) => {openCreateRefModal(e, commit)}}>Create ref</Button onclick={(e: Event) => {openCreateRefModal(e, commit)}}>Create ref</Button
> >
{/if} {/if}
{#if $branchStacking && commit instanceof DetailedCommit && commit.remoteRef} {#if $stackingFeature && commit instanceof DetailedCommit && commit.remoteRef}
<Button <Button
size="tag" size="tag"
style="ghost" style="ghost"

View File

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

View File

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

View File

@ -19,7 +19,7 @@
import { KeyName } from '$lib/utils/hotkeys'; import { KeyName } from '$lib/utils/hotkeys';
import { resizeObserver } from '$lib/utils/resizeObserver'; import { resizeObserver } from '$lib/utils/resizeObserver';
import { isWhiteSpaceString } from '$lib/utils/string'; 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 { VirtualBranch, LocalFile } from '$lib/vbranches/types';
import Checkbox from '@gitbutler/ui/Checkbox.svelte'; import Checkbox from '@gitbutler/ui/Checkbox.svelte';
import Icon from '@gitbutler/ui/Icon.svelte'; import Icon from '@gitbutler/ui/Icon.svelte';
@ -33,7 +33,7 @@
export let commit: (() => void) | undefined = undefined; export let commit: (() => void) | undefined = undefined;
const user = getContextStore(User); const user = getContextStore(User);
const selectedOwnership = getContextStore(Ownership); const selectedOwnership = getContextStore(SelectedOwnership);
const aiService = getContext(AIService); const aiService = getContext(AIService);
const branch = getContextStore(VirtualBranch); const branch = getContextStore(VirtualBranch);
const project = getContext(Project); const project = getContext(Project);
@ -71,7 +71,7 @@
async function generateCommitMessage(files: LocalFile[]) { async function generateCommitMessage(files: LocalFile[]) {
const hunks = files.flatMap((f) => 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 // 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" // If the change is a 'one-liner', the branch name is either left as "virtual branch"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,36 +1,256 @@
<script lang="ts"> <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 { getContext } from '$lib/utils/context';
import { BranchController } from '$lib/vbranches/branchController'; 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 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 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> </script>
<Button <Modal bind:this={modal} title="Integrate upstream changes" {onClose} width="small">
size="tag" {#if $base}
style="error" <div class="upstream-commits">
kind="solid" {#each $base.upstreamCommits as commit, index}
tooltip="Merge upstream into common base" <CommitCard
onclick={async () => { {commit}
loading = true; first={index === 0}
try { last={index === $base.upstreamCommits.length - 1}
let infoText = await branchController.updateBaseBranch(); isUnapplied={true}
if (infoText) { commitUrl={$gitHost?.commitUrl(commit.id)}
showInfo('Stashed conflicting branches', infoText); type="remote"
} />
} catch (err) { {/each}
showError('Failed update workspace', err); </div>
} finally {
loading = false;
}
}}
>
{#if loading}
busy...
{:else}
Update
{/if} {/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); background: var(--clr-bg-2);
border: 1px solid var(--clr-border-2); border: 1px solid var(--clr-border-2);
border-radius: var(--radius-m); border-radius: var(--radius-m);
box-shadow: var(--shadow-s); box-shadow: var(--fx-shadow-s);
animation: fadeIn 0.08s ease-out forwards; 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"> <script lang="ts">
import { type Snippet } from 'svelte';
interface Props { interface Props {
text: string; children: Snippet;
} }
const { text }: Props = $props(); const { children }: Props = $props();
</script> </script>
<span> <span>
{text} {@render children()}
</span> </span>

View File

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

View File

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

View File

@ -3,52 +3,46 @@
import FileListItem from './FileListItem.svelte'; import FileListItem from './FileListItem.svelte';
import LazyloadContainer from '$lib/shared/LazyloadContainer.svelte'; import LazyloadContainer from '$lib/shared/LazyloadContainer.svelte';
import TextBox from '$lib/shared/TextBox.svelte'; import TextBox from '$lib/shared/TextBox.svelte';
import { chunk } from '$lib/utils/array';
import { copyToClipboard } from '$lib/utils/clipboard'; import { copyToClipboard } from '$lib/utils/clipboard';
import { getContext } from '$lib/utils/context'; import { getContext } from '$lib/utils/context';
import { selectFilesInList } from '$lib/utils/selectFilesInList'; 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 { getCommitStore } from '$lib/vbranches/contexts';
import { FileIdSelection, stringifyFileKey } from '$lib/vbranches/fileIdSelection'; import { FileIdSelection, stringifyFileKey } from '$lib/vbranches/fileIdSelection';
import { sortLikeFileTree } from '$lib/vbranches/filetree'; import { sortLikeFileTree } from '$lib/vbranches/filetree';
import Button from '@gitbutler/ui/Button.svelte'; import Button from '@gitbutler/ui/Button.svelte';
import type { AnyFile } from '$lib/vbranches/types'; import type { AnyFile } from '$lib/vbranches/types';
export let files: AnyFile[]; const MERGE_DIFF_COMMAND = 'git diff-tree --cc ';
export let isUnapplied = false;
export let showCheckboxes = false; interface Props {
export let allowMultiple = false; files: AnyFile[];
export let readonly = false; 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 fileIdSelection = getContext(FileIdSelection);
const commit = getCommitStore(); const commit = getCommitStore();
function chunk<T>(arr: T[], size: number) { let chunkedFiles: AnyFile[][] = $derived(chunk(sortLikeFileTree(files), 100));
return Array.from({ length: Math.ceil(arr.length / size) }, (_v, i) => let currentDisplayIndex = $state(0);
arr.slice(i * size, i * size + size) let displayedFiles: AnyFile[] = $derived(chunkedFiles.slice(0, currentDisplayIndex + 1).flat());
);
}
let chunkedFiles: AnyFile[][] = []; function loadMore() {
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() {
if (currentDisplayIndex + 1 >= chunkedFiles.length) return; if (currentDisplayIndex + 1 >= chunkedFiles.length) return;
currentDisplayIndex += 1; currentDisplayIndex += 1;
const currentChunkedFiles = chunkedFiles[currentDisplayIndex] ?? [];
displayedFiles = [...displayedFiles, ...currentChunkedFiles];
} }
let mergeDiffCommand = 'git diff-tree --cc ';
</script> </script>
{#if !$commit?.isMergeCommit()} {#if !$commit?.isMergeCommit()}
@ -60,12 +54,12 @@
GitHub, or run the following command in your project directory: GitHub, or run the following command in your project directory:
</p> </p>
<div class="command"> <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 <Button
icon="copy" icon="copy"
style="ghost" style="ghost"
outline outline
onmousedown={() => copyToClipboard(mergeDiffCommand + $commit.id.slice(0, 7))} onmousedown={() => copyToClipboard(MERGE_DIFF_COMMAND + $commit.id.slice(0, 7))}
/> />
</div> </div>
</div> </div>
@ -81,6 +75,21 @@
loadMore(); loadMore();
}} }}
role="listbox" 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)} {#each displayedFiles as file (file.id)}
<FileListItem <FileListItem
@ -92,29 +101,6 @@
onclick={(e) => { onclick={(e) => {
selectFilesInList(e, file, fileIdSelection, displayedFiles, allowMultiple, $commit); 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} {/each}
</LazyloadContainer> </LazyloadContainer>

View File

@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import HunkViewer from '$lib/hunk/HunkViewer.svelte'; import HunkViewer from '$lib/hunk/HunkViewer.svelte';
import InfoMessage from '$lib/shared/InfoMessage.svelte';
import LargeDiffMessage from '$lib/shared/LargeDiffMessage.svelte'; import LargeDiffMessage from '$lib/shared/LargeDiffMessage.svelte';
import { computeAddedRemovedByHunk } from '$lib/utils/metrics'; import { computeAddedRemovedByHunk } from '$lib/utils/metrics';
import { getLocalCommits, getLocalAndRemoteCommits } from '$lib/vbranches/contexts'; import { getLocalCommits, getLocalAndRemoteCommits } from '$lib/vbranches/contexts';
import { getLockText } from '$lib/vbranches/tooltip'; 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'; import type { HunkSection, ContentSection } from '$lib/utils/fileSections';
interface Props { interface Props {
@ -68,22 +67,26 @@
{#each sections as section} {#each sections as section}
{@const { added, removed } = computeAddedRemovedByHunk(section)} {@const { added, removed } = computeAddedRemovedByHunk(section)}
{#if 'hunk' in section} {#if 'hunk' in section}
{@const isHunkLocked = section.hunk.lockedTo && section.hunk.lockedTo.length > 0 && commits}
<div class="hunk-wrapper"> <div class="hunk-wrapper">
<div class="indicators text-11 text-semibold"> {#if isHunkLocked || section.hunk.poisoned}
<div class="text-10 semibold added-removed"> <div class="indicators text-11 text-semibold">
<span class="added">+{added}</span> {#if isHunkLocked}
<span class="removed">-{removed}</span> <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> </div>
{/if}
{#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>
<HunkViewer <HunkViewer
{filePath} {filePath}
{section} {section}
@ -116,30 +119,10 @@
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
} }
.indicators { .indicators {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; 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> </style>

View File

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

View File

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

View File

@ -40,6 +40,10 @@ export class BitBucket implements GitHost {
return undefined; return undefined;
} }
issueService() {
return undefined;
}
prService() { prService() {
return undefined; 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; return undefined;
} }
issueService() {
return undefined;
}
prService() { prService() {
return undefined; return undefined;
} }

View File

@ -1,4 +1,5 @@
import { buildContextStore } from '$lib/utils/context'; import { buildContextStore } from '$lib/utils/context';
import type { GitHostIssueService } from '$lib/gitHost/interface/gitHostIssueService';
import type { GitHostBranch } from './gitHostBranch'; import type { GitHostBranch } from './gitHostBranch';
import type { GitHostChecksMonitor } from './gitHostChecksMonitor'; import type { GitHostChecksMonitor } from './gitHostChecksMonitor';
import type { GitHostListingService } from './gitHostListingService'; import type { GitHostListingService } from './gitHostListingService';
@ -8,6 +9,8 @@ export interface GitHost {
// Lists PRs for the repo. // Lists PRs for the repo.
listService(): GitHostListingService | undefined; listService(): GitHostListingService | undefined;
issueService(): GitHostIssueService | undefined;
// Detailed information about a specific PR. // Detailed information about a specific PR.
prService(): GitHostPrService | undefined; 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 { buildContextStore } from '$lib/utils/context';
import type { GitHostPrMonitor } from './gitHostPrMonitor'; 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'; import type { Writable } from 'svelte/store';
export const [getGitHostPrService, createGitHostPrServiceStore] = buildContextStore< export const [getGitHostPrService, createGitHostPrServiceStore] = buildContextStore<

View File

@ -4,9 +4,15 @@
import ScrollableContainer from '$lib/scroll/ScrollableContainer.svelte'; import ScrollableContainer from '$lib/scroll/ScrollableContainer.svelte';
import { create } from '$lib/utils/codeHighlight'; import { create } from '$lib/utils/codeHighlight';
import { maybeGetContextStore } from '$lib/utils/context'; import { maybeGetContextStore } from '$lib/utils/context';
import { type ContentSection, SectionType, type Line } from '$lib/utils/fileSections'; import {
import { Ownership } from '$lib/vbranches/ownership'; type ContentSection,
SectionType,
type Line,
CountColumnSide
} from '$lib/utils/fileSections';
import { SelectedOwnership } from '$lib/vbranches/ownership';
import { type Hunk } from '$lib/vbranches/types'; import { type Hunk } from '$lib/vbranches/types';
import Checkbox from '@gitbutler/ui/Checkbox.svelte';
import Icon from '@gitbutler/ui/Icon.svelte'; import Icon from '@gitbutler/ui/Icon.svelte';
import diff_match_patch from 'diff-match-patch'; import diff_match_patch from 'diff-match-patch';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
@ -52,9 +58,12 @@
const WHITESPACE_REGEX = /\s/; const WHITESPACE_REGEX = /\s/;
const NUMBER_COLUMN_WIDTH_PX = minWidth * 20; 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); let isSelected = $derived(selectable && selected);
const inlineUnifiedDiffs = featureInlineUnifiedDiffs(); const inlineUnifiedDiffs = featureInlineUnifiedDiffs();
@ -87,7 +96,8 @@
afterLineNumber: line.afterLineNumber, afterLineNumber: line.afterLineNumber,
tokens: toTokens(line.content), tokens: toTokens(line.content),
type: section.sectionType, type: section.sectionType,
size: line.content.length size: line.content.length,
isLast: false
}; };
}); });
} }
@ -129,14 +139,16 @@
afterLineNumber: oldLine.afterLineNumber, afterLineNumber: oldLine.afterLineNumber,
tokens: [] as string[], tokens: [] as string[],
type: prevSection.sectionType, type: prevSection.sectionType,
size: oldLine.content.length size: oldLine.content.length,
isLast: false
}; };
const nextSectionRow = { const nextSectionRow = {
beforeLineNumber: newLine.beforeLineNumber, beforeLineNumber: newLine.beforeLineNumber,
afterLineNumber: newLine.afterLineNumber, afterLineNumber: newLine.afterLineNumber,
tokens: [] as string[], tokens: [] as string[],
type: nextSection.sectionType, type: nextSection.sectionType,
size: newLine.content.length size: newLine.content.length,
isLast: false
}; };
const diff = charDiff(oldLine.content, newLine.content); const diff = charDiff(oldLine.content, newLine.content);
@ -181,7 +193,8 @@
afterLineNumber: newLine.afterLineNumber, afterLineNumber: newLine.afterLineNumber,
tokens: [] as string[], tokens: [] as string[],
type: nextSection.sectionType, type: nextSection.sectionType,
size: newLine.content.length size: newLine.content.length,
isLast: false
}; };
const diff = charDiff(oldLine.content, newLine.content); const diff = charDiff(oldLine.content, newLine.content);
@ -209,7 +222,7 @@
} }
function generateRows(subsections: ContentSection[]) { function generateRows(subsections: ContentSection[]) {
return subsections.reduce((acc, nextSection, i) => { const rows = subsections.reduce((acc, nextSection, i) => {
const prevSection = subsections[i - 1]; const prevSection = subsections[i - 1];
// Filter out section for which we don't need to compute word diffs // Filter out section for which we don't need to compute word diffs
@ -254,59 +267,128 @@
return acc; return acc;
} }
}, [] as Row[]); }, [] as Row[]);
const last = rows.at(-1);
if (last) {
last.isLast = true;
}
return rows;
} }
const renderRows = $derived(generateRows(subsections)); 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> </script>
{#snippet countColumn(count: number | undefined, lineType: SectionType)} {#snippet countColumn(row: Row, side: CountColumnSide)}
<td <td
class="table__numberColumn" class="table__numberColumn"
class:diff-line-deletion={lineType === SectionType.RemovedLines} data-no-drag
class:diff-line-addition={lineType === SectionType.AddedLines} class:diff-line-deletion={row.type === SectionType.RemovedLines}
style="--number-col-width: {NUMBER_COLUMN_WIDTH_PX}px;" class:diff-line-addition={row.type === SectionType.AddedLines}
style="--number-col-width: {NUMBER_COLUMN_WIDTH_PX + 2}px;"
align="center" align="center"
class:is-last={row.isLast}
class:is-before={side === CountColumnSide.Before}
class:selected={isSelected} class:selected={isSelected}
onclick={() => { onclick={() => {
selectable && handleSelected(hunk, !isSelected); selectable && handleSelected(hunk, !isSelected);
}} }}
> >
{count} {side === CountColumnSide.Before ? row.beforeLineNumber : row.afterLineNumber}
</td> </td>
{/snippet} {/snippet}
<div <div
bind:clientWidth={tableWidth}
class="table__wrapper hide-native-scrollbar" 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 }}> <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"> <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> <tbody>
{#each renderRows as line} {#each renderRows as row}
<tr data-no-drag> <tr data-no-drag>
{@render countColumn(line.beforeLineNumber, line.type)} {@render countColumn(row, CountColumnSide.Before)}
{@render countColumn(line.afterLineNumber, line.type)} {@render countColumn(row, CountColumnSide.After)}
<td <td
{onclick} {onclick}
class="table__textContent" class="table__textContent"
style="--tab-size: {tabSize};" style="--tab-size: {tabSize};"
class:readonly class:readonly
data-no-drag data-no-drag
class:diff-line-deletion={line.type === SectionType.RemovedLines} class:diff-line-deletion={row.type === SectionType.RemovedLines}
class:diff-line-addition={line.type === SectionType.AddedLines} class:diff-line-addition={row.type === SectionType.AddedLines}
class:is-last={row.isLast}
oncontextmenu={(event) => { oncontextmenu={(event) => {
const lineNumber = (line.beforeLineNumber const lineNumber = (row.beforeLineNumber
? line.beforeLineNumber ? row.beforeLineNumber
: line.afterLineNumber) as number; : row.afterLineNumber) as number;
handleLineContextMenu({ event, hunk, lineNumber, subsection: subsections[0] as ContentSection }); handleLineContextMenu({ event, hunk, lineNumber, subsection: subsections[0] as ContentSection });
}} }}
> >
{@html line.tokens.join('')} {@html row.tokens.join('')}
</td> </td>
</tr> </tr>
{/each} {/each}
@ -315,45 +397,102 @@
</ScrollableContainer> </ScrollableContainer>
</div> </div>
<style> <style lang="postcss">
.table__wrapper { .table__wrapper {
border: 1px solid var(--clr-border-2); border-radius: var(--radius-m);
border-radius: var(--radius-s);
background-color: var(--clr-diff-line-bg); background-color: var(--clr-diff-line-bg);
overflow-x: auto; overflow-x: auto;
border: 1px solid var(--clr-border-2);
&:hover .table__drag-handle { &:hover .table__drag-handle {
transform: translateY(0) translateX(0) scale(1); transform: scale(1);
opacity: 1; opacity: 1;
pointer-events: auto;
} }
} }
.table__drag-handle { table,
position: absolute; .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; cursor: grab;
top: 6px; user-select: none;
}
.table__drag-handle {
position: fixed;
right: 6px; right: 6px;
top: 6px;
box-sizing: border-box;
background-color: var(--clr-bg-1); background-color: var(--clr-bg-1);
border: 1px solid var(--clr-border-2);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 4px 2px;
border-radius: var(--radius-s); border-radius: var(--radius-s);
opacity: 0; opacity: 0;
transform: translateY(10%) translateX(-10%) scale(0.9); transform: scale(0.9);
transform-origin: top right; transform-origin: top right;
pointer-events: none; pointer-events: none;
color: var(--clr-text-2);
transition: transition:
opacity 0.2s, opacity 0.2s,
transform 0.2s; transform 0.2s;
} }
.table__section { .table__title-content {
border-spacing: 0; position: relative;
width: 100%; font-family: var(--mono-font-family);
font-family: monospace; font-size: 12px;
padding: 4px 6px;
text-wrap: nowrap;
color: var(--clr-text-2);
border-bottom: 1px solid var(--clr-border-2);
} }
.table__numberColumn { .table__numberColumn {
@ -364,7 +503,6 @@
text-align: center; text-align: center;
padding: 0 4px; padding: 0 4px;
text-align: right; text-align: right;
cursor: var(--cursor);
user-select: none; user-select: none;
position: sticky; position: sticky;
@ -372,24 +510,32 @@
width: var(--number-col-width); width: var(--number-col-width);
min-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 { &.diff-line-addition {
background-color: var(--clr-diff-addition-count-bg); background-color: var(--clr-diff-addition-count-bg);
color: var(--clr-diff-addition-count-text); 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 { &.diff-line-deletion {
background-color: var(--clr-diff-deletion-count-bg); background-color: var(--clr-diff-deletion-count-bg);
color: var(--clr-diff-deletion-count-text); 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 { &.selected {
background-color: var(--clr-diff-selected-count-bg); 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); 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 { .table__textContent {
z-index: var(--z-lifted);
width: 100%; width: 100%;
font-size: 12px; font-size: 12px;
padding-left: 4px; padding-left: 4px;

View File

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

View File

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

View File

@ -19,9 +19,7 @@
{title} {title}
</h1> </h1>
{/if} {/if}
{#if children} {@render children()}
{@render children()}
{/if}
</div> </div>
</div> </div>
</ScrollableContainer> </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"> <script lang="ts" generics="T">
/** /**
* Lazily renders a list of many many items. This is intended to be used * 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 LazyloadContainer from '$lib/shared/LazyloadContainer.svelte';
import { chunk } from '$lib/utils/chunk'; import { chunk } from '$lib/utils/array';
import { type Snippet } from 'svelte'; import { type Snippet } from 'svelte';
interface Props { interface Props {

View File

@ -6,8 +6,10 @@
import WorkspaceButton from './WorkspaceButton.svelte'; import WorkspaceButton from './WorkspaceButton.svelte';
import Resizer from '../shared/Resizer.svelte'; import Resizer from '../shared/Resizer.svelte';
import { Project } from '$lib/backend/projects'; import { Project } from '$lib/backend/projects';
import { featureTopics } from '$lib/config/uiFeatureFlags';
import { ModeService } from '$lib/modes/service'; import { ModeService } from '$lib/modes/service';
import EditButton from '$lib/navigation/EditButton.svelte'; import EditButton from '$lib/navigation/EditButton.svelte';
import TopicsButton from '$lib/navigation/TopicsButton.svelte';
import { persisted } from '$lib/persisted/persisted'; import { persisted } from '$lib/persisted/persisted';
import { platformName } from '$lib/platform/platform'; import { platformName } from '$lib/platform/platform';
import { SETTINGS, type Settings } from '$lib/settings/userSettings'; import { SETTINGS, type Settings } from '$lib/settings/userSettings';
@ -43,6 +45,8 @@
const modeService = getContext(ModeService); const modeService = getContext(ModeService);
const mode = modeService.mode; const mode = modeService.mode;
const topicsEnabled = featureTopics();
</script> </script>
<svelte:window on:keydown={handleKeyDown} /> <svelte:window on:keydown={handleKeyDown} />
@ -120,6 +124,10 @@
{:else if $mode?.type === 'Edit'} {:else if $mode?.type === 'Edit'}
<EditButton href={`/${project.id}/edit`} isNavCollapsed={$isNavCollapsed} /> <EditButton href={`/${project.id}/edit`} isNavCollapsed={$isNavCollapsed} />
{/if} {/if}
{#if $topicsEnabled}
<TopicsButton href={`/${project.id}/topics`} isNavCollapsed={$isNavCollapsed} />
{/if}
</div> </div>
</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"> <script lang="ts">
import DomainButton from './DomainButton.svelte'; import DomainButton from './DomainButton.svelte';
import UpdateBaseButton from '../components/UpdateBaseButton.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 { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
@ -13,7 +11,6 @@
const { href, isNavCollapsed }: Props = $props(); const { href, isNavCollapsed }: Props = $props();
const baseBranch = getContextStore(BaseBranch);
const label = 'Workspace'; const label = 'Workspace';
</script> </script>
@ -27,7 +24,7 @@
{#if !isNavCollapsed} {#if !isNavCollapsed}
<span class="text-14 text-semibold" class:collapsed-txt={isNavCollapsed}>{label}</span> <span class="text-14 text-semibold" class:collapsed-txt={isNavCollapsed}>{label}</span>
{#if ($baseBranch?.behind || 0) > 0 && !isNavCollapsed} {#if !isNavCollapsed}
<UpdateBaseButton /> <UpdateBaseButton />
{/if} {/if}
{/if} {/if}

View File

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

View File

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

View File

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

View File

@ -7,9 +7,10 @@
minTriggerCount: number; minTriggerCount: number;
role?: AriaRole | undefined | null; role?: AriaRole | undefined | null;
ontrigger: (lastChild: Element) => void; 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; let lazyContainerEl: HTMLDivElement;
@ -47,7 +48,7 @@
}); });
</script> </script>
<div class="lazy-container" {role} bind:this={lazyContainerEl}> <div class="lazy-container" {role} bind:this={lazyContainerEl} {onkeydown}>
{@render children()} {@render children()}
</div> </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 Context
} }
export enum CountColumnSide {
Before,
After
}
export class HunkSection { export class HunkSection {
hunk!: Hunk; hunk!: Hunk;
header!: HunkHeader; 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 // detect the direction of the selection
const selectionDirection = lastFileIndex < firstFileIndex ? 'down' : 'up'; 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 Code from '$lib/components/markdownRenderers/Code.svelte';
import Codespan from '$lib/components/markdownRenderers/Codespan.svelte'; import Codespan from '$lib/components/markdownRenderers/Codespan.svelte';
import Heading from '$lib/components/markdownRenderers/Heading.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 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 Paragraph from '$lib/components/markdownRenderers/Paragraph.svelte';
import Space from '$lib/components/markdownRenderers/Space.svelte';
import Text from '$lib/components/markdownRenderers/Text.svelte'; import Text from '$lib/components/markdownRenderers/Text.svelte';
import Link from '$lib/shared/Link.svelte'; import Link from '$lib/shared/Link.svelte';
export const renderers = { export const renderers = {
link: Link, link: Link,
image: Image, image: Image,
space: Space,
blockquote: Blockquote, blockquote: Blockquote,
code: Code, code: Code,
codespan: Codespan, codespan: Codespan,
text: Text, text: Text,
html: Html,
list: List,
list_item: ListItem,
heading: Heading, heading: Heading,
paragraph: Paragraph paragraph: Paragraph,
init: null,
space: null
}; };
export const options = { export const options = {

View File

@ -2,71 +2,96 @@
* Shared helper functions for manipulating selected files with keyboard. * Shared helper functions for manipulating selected files with keyboard.
*/ */
import { getSelectionDirection } from './getSelectionDirection'; import { getSelectionDirection } from './getSelectionDirection';
import { KeyName } from './hotkeys';
import { stringifyFileKey, unstringifyFileKey } from '$lib/vbranches/fileIdSelection'; import { stringifyFileKey, unstringifyFileKey } from '$lib/vbranches/fileIdSelection';
import type { FileIdSelection } from '$lib/vbranches/fileIdSelection'; import type { FileIdSelection } from '$lib/vbranches/fileIdSelection';
import type { AnyFile } from '$lib/vbranches/types'; 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); const fileIndex = files.findIndex((f) => f.id === currentId);
return fileIndex !== -1 && fileIndex + 1 < files.length ? files[fileIndex + 1] : undefined; 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); const fileIndex = files.findIndex((f) => f.id === currentId);
return fileIndex > 0 ? files[fileIndex - 1] : undefined; 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; allowMultiple: boolean;
shiftKey: boolean; shiftKey: boolean;
key: string; key: string;
targetElement: HTMLElement; targetElement: HTMLElement;
file: AnyFile;
files: AnyFile[]; files: AnyFile[];
selectedFileIds: string[]; selectedFileIds: string[];
fileIdSelection: FileIdSelection; fileIdSelection: FileIdSelection;
commitId?: string; commitId?: string;
} }
export function maybeMoveSelection({ export function updateSelection({
allowMultiple, allowMultiple,
shiftKey, shiftKey,
key, key,
targetElement, targetElement,
file,
files, files,
selectedFileIds, selectedFileIds,
fileIdSelection, fileIdSelection,
commitId commitId
}: MoveSelectionParams) { }: UpdateSelectionParams) {
if (!selectedFileIds[0] || selectedFileIds.length === 0) return; if (!selectedFileIds[0] || selectedFileIds.length === 0) return;
const firstFileId = unstringifyFileKey(selectedFileIds[0]); const firstFileId = unstringifyFileKey(selectedFileIds[0]);
const lastFileId = unstringifyFileKey(selectedFileIds.at(-1)!); const lastFileId = unstringifyFileKey(selectedFileIds.at(-1)!);
const topFileId = getTopFile(files, selectedFileIds)?.id;
const bottomFileId = getBottomFile(files, selectedFileIds)?.id;
let selectionDirection = getSelectionDirection( let selectionDirection = getSelectionDirection(
files.findIndex((f) => f.id === lastFileId), files.findIndex((f) => f.id === lastFileId),
files.findIndex((f) => f.id === firstFileId) files.findIndex((f) => f.id === firstFileId)
); );
function getAndAddFile( 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) {
// if file is already selected, do nothing // if file is already selected, do nothing
if (selectedFileIds.includes(stringifyFileKey(file.id, commitId))) return; if (selectedFileIds.includes(stringifyFileKey(file.id, commitId))) return;
fileIdSelection.add(file.id, commitId); fileIdSelection.add(file.id, commitId);
} }
} }
function getAndClearAndAddFile( function getAndClearExcept(
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) {
fileIdSelection.clearExcept(file.id, commitId); fileIdSelection.clearExcept(file.id, commitId);
@ -74,7 +99,7 @@ export function maybeMoveSelection({
} }
switch (key) { switch (key) {
case 'ArrowUp': case KeyName.Up:
if (shiftKey && allowMultiple) { if (shiftKey && allowMultiple) {
// Handle case if only one file is selected // Handle case if only one file is selected
// we should update the selection direction // we should update the selection direction
@ -83,22 +108,21 @@ export function maybeMoveSelection({
} else if (selectionDirection === 'down') { } else if (selectionDirection === 'down') {
fileIdSelection.remove(lastFileId, commitId); fileIdSelection.remove(lastFileId, commitId);
} }
getAndAddFile(getPreviousFile, lastFileId); getAndAddFile(lastFileId, getPreviousFile);
} else { } else {
// focus previous file
const previousElement = targetElement.previousElementSibling as HTMLElement;
if (previousElement) previousElement.focus();
// Handle reset of selection // Handle reset of selection
if (selectedFileIds.length > 1) { if (selectedFileIds.length > 1 && topFileId !== undefined) {
getAndClearAndAddFile(getPreviousFile, lastFileId); getAndClearExcept(topFileId);
} else { }
getAndClearAndAddFile(getPreviousFile, file.id);
// Handle navigation
if (selectedFileIds.length === 1) {
getAndClearExcept(firstFileId, getPreviousFile);
} }
} }
break; break;
case 'ArrowDown': case KeyName.Down:
if (shiftKey && allowMultiple) { if (shiftKey && allowMultiple) {
// Handle case if only one file is selected // Handle case if only one file is selected
// we should update the selection direction // we should update the selection direction
@ -108,19 +132,22 @@ export function maybeMoveSelection({
fileIdSelection.remove(lastFileId, commitId); fileIdSelection.remove(lastFileId, commitId);
} }
getAndAddFile(getNextFile, lastFileId); getAndAddFile(lastFileId, getNextFile);
} else { } else {
// focus next file
const nextElement = targetElement.nextElementSibling as HTMLElement;
if (nextElement) nextElement.focus();
// Handle reset of selection // Handle reset of selection
if (selectedFileIds.length > 1) { if (selectedFileIds.length > 1 && bottomFileId !== undefined) {
getAndClearAndAddFile(getNextFile, lastFileId); getAndClearExcept(bottomFileId);
} else { }
getAndClearAndAddFile(getNextFile, file.id);
// Handle navigation
if (selectedFileIds.length === 1) {
getAndClearExcept(firstFileId, getNextFile);
} }
} }
break; 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 HunkClaims = Map<HunkId, AnyHunk>;
export type FileClaims = Map<FilePath, HunkClaims>; 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 claims: FileClaims;
private selection: FileClaims;
constructor(state: SelectedOwnershipState) {
this.claims = state.claims;
this.selection = state.selection;
}
static fromBranch(branch: VirtualBranch) { static fromBranch(branch: VirtualBranch) {
const files = branch.files.reduce((acc, file) => { const state = getState(branch);
const existing = acc.get(file.id); const ownership = new SelectedOwnership(state);
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);
return ownership; return ownership;
} }
constructor(files: FileClaims) { update(branch: VirtualBranch) {
this.claims = files; const { selection, claims } = getState(branch, {
claims: this.claims,
selection: this.selection
});
this.claims = claims;
this.selection = selection;
return this;
} }
remove(fileId: string, ...hunkIds: string[]) { ignore(fileId: string, ...hunkIds: string[]) {
const claims = this.claims; const selection = this.selection;
if (!claims) return this; if (!selection) return this;
hunkIds.forEach((hunkId) => { hunkIds.forEach((hunkId) => {
claims.get(fileId)?.delete(hunkId); selection.get(fileId)?.delete(hunkId);
if (claims.get(fileId)?.size === 0) claims.delete(fileId); if (selection.get(fileId)?.size === 0) selection.delete(fileId);
}); });
return this; return this;
} }
add(fileId: string, ...items: AnyHunk[]) { select(fileId: string, ...items: AnyHunk[]) {
const claim = this.claims.get(fileId); const selectedFile = this.selection.get(fileId);
if (claim) { if (selectedFile) {
items.forEach((hunk) => claim.set(hunk.id, hunk)); items.forEach((hunk) => selectedFile.set(hunk.id, hunk));
} else { } else {
this.claims.set( this.selection.set(
fileId, fileId,
items.reduce((acc, hunk) => { items.reduce((acc, hunk) => {
return acc.set(hunk.id, hunk); return acc.set(hunk.id, hunk);
@ -74,17 +175,17 @@ export class Ownership {
return this; return this;
} }
contains(fileId: string, ...hunkIds: string[]): boolean { isSelected(fileId: string, ...hunkIds: string[]): boolean {
return hunkIds.every((hunkId) => !!this.claims.get(fileId)?.has(hunkId)); return hunkIds.every((hunkId) => !!this.selection.get(fileId)?.has(hunkId));
} }
clear() { clearSelection() {
this.claims.clear(); this.selection.clear();
return this; return this;
} }
toString() { toString() {
return Array.from(this.claims.entries()) return Array.from(this.selection.entries())
.map( .map(
([fileId, hunkMap]) => ([fileId, hunkMap]) =>
fileId + fileId +
@ -98,7 +199,7 @@ export class Ownership {
.join('\n'); .join('\n');
} }
isEmpty() { nothingSelected() {
return this.claims.size === 0; return this.selection.size === 0;
} }
} }

View File

@ -137,6 +137,7 @@ export class VirtualBranch {
allowRebasing!: boolean; allowRebasing!: boolean;
pr?: PullRequest; pr?: PullRequest;
refname!: string; refname!: string;
tree!: string;
get localCommits() { get localCommits() {
return this.commits.filter((c) => c.status === 'local'); 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 NoBaseBranch from '$lib/components/NoBaseBranch.svelte';
import NotOnGitButlerBranch from '$lib/components/NotOnGitButlerBranch.svelte'; import NotOnGitButlerBranch from '$lib/components/NotOnGitButlerBranch.svelte';
import ProblemLoadingRepo from '$lib/components/ProblemLoadingRepo.svelte'; import ProblemLoadingRepo from '$lib/components/ProblemLoadingRepo.svelte';
import { featureTopics } from '$lib/config/uiFeatureFlags';
import { ReorderDropzoneManagerFactory } from '$lib/dragging/reorderDropzoneManager'; import { ReorderDropzoneManagerFactory } from '$lib/dragging/reorderDropzoneManager';
import { DefaultGitHostFactory } from '$lib/gitHost/gitHostFactory'; import { DefaultGitHostFactory } from '$lib/gitHost/gitHostFactory';
import { octokitFromAccessToken } from '$lib/gitHost/github/octokit'; import { octokitFromAccessToken } from '$lib/gitHost/github/octokit';
@ -24,12 +25,17 @@
import Navigation from '$lib/navigation/Navigation.svelte'; import Navigation from '$lib/navigation/Navigation.svelte';
import { persisted } from '$lib/persisted/persisted'; import { persisted } from '$lib/persisted/persisted';
import { RemoteBranchService } from '$lib/stores/remoteBranches'; 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 { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher';
import { parseRemoteUrl } from '$lib/url/gitUrl'; import { parseRemoteUrl } from '$lib/url/gitUrl';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import { BranchController } from '$lib/vbranches/branchController'; import { BranchController } from '$lib/vbranches/branchController';
import { UpstreamIntegrationService } from '$lib/vbranches/upstreamIntegrationService';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch'; import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import { onDestroy, setContext, type Snippet } from 'svelte'; import { onDestroy, setContext, type Snippet } from 'svelte';
import { derived as storeDerived } from 'svelte/store';
import type { LayoutData } from './$types'; import type { LayoutData } from './$types';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@ -87,6 +93,14 @@
const listServiceStore = createGitHostListingServiceStore(undefined); const listServiceStore = createGitHostListingServiceStore(undefined);
const gitHostStore = createGitHostStore(undefined); const gitHostStore = createGitHostStore(undefined);
const branchServiceStore = createBranchServiceStore(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(() => { $effect.pre(() => {
const combinedBranchListingService = new CombinedBranchListingService( const combinedBranchListingService = new CombinedBranchListingService(
@ -160,8 +174,17 @@
onDestroy(() => { onDestroy(() => {
clearFetchInterval(); clearFetchInterval();
}); });
const topicsEnabled = featureTopics();
</script> </script>
{#if $topicsEnabled}
{#if $gitHostStore?.issueService()}
<CreateIssueModal registerKeypress />
{/if}
<CreateTopicModal registerKeypress />
{/if}
<!-- forces components to be recreated when projectId changes --> <!-- forces components to be recreated when projectId changes -->
{#key projectId} {#key projectId}
<ProjectSettingsMenuAction <ProjectSettingsMenuAction

View File

@ -11,6 +11,7 @@ import { ModeService } from '$lib/modes/service';
import { RemoteBranchService } from '$lib/stores/remoteBranches'; import { RemoteBranchService } from '$lib/stores/remoteBranches';
import { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher'; import { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher';
import { BranchController } from '$lib/vbranches/branchController'; import { BranchController } from '$lib/vbranches/branchController';
import { UpstreamIntegrationService } from '$lib/vbranches/upstreamIntegrationService';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch'; import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { Project } from '$lib/backend/projects'; import type { Project } from '$lib/backend/projects';
@ -80,6 +81,7 @@ export const load: LayoutLoad = async ({ params, parent }) => {
const reorderDropzoneManagerFactory = new ReorderDropzoneManagerFactory(branchController); const reorderDropzoneManagerFactory = new ReorderDropzoneManagerFactory(branchController);
const uncommitedFileWatcher = new UncommitedFilesWatcher(project); const uncommitedFileWatcher = new UncommitedFilesWatcher(project);
const upstreamIntegrationService = new UpstreamIntegrationService(project, vbranchService);
return { return {
authService, authService,
@ -93,6 +95,7 @@ export const load: LayoutLoad = async ({ params, parent }) => {
projectMetrics, projectMetrics,
modeService, modeService,
fetchSignal, fetchSignal,
upstreamIntegrationService,
// These observables are provided for convenience // These observables are provided for convenience
branchDragActionsFactory, 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 { import {
featureBaseBranchSwitching, featureBaseBranchSwitching,
featureInlineUnifiedDiffs, featureInlineUnifiedDiffs,
featureBranchStacking stackingFeature,
featureTopics
} from '$lib/config/uiFeatureFlags'; } from '$lib/config/uiFeatureFlags';
import SettingsPage from '$lib/layout/SettingsPage.svelte'; import SettingsPage from '$lib/layout/SettingsPage.svelte';
import Toggle from '$lib/shared/Toggle.svelte'; import Toggle from '$lib/shared/Toggle.svelte';
const baseBranchSwitching = featureBaseBranchSwitching(); const baseBranchSwitching = featureBaseBranchSwitching();
const inlineUnifiedDiffs = featureInlineUnifiedDiffs(); const inlineUnifiedDiffs = featureInlineUnifiedDiffs();
const branchStacking = featureBranchStacking(); const topicsEnabled = featureTopics();
</script> </script>
<SettingsPage title="Experimental features"> <SettingsPage title="Experimental features">
@ -47,16 +48,30 @@
/> />
</svelte:fragment> </svelte:fragment>
</SectionCard> </SectionCard>
<SectionCard labelFor="branchStacking" orientation="row"> <SectionCard labelFor="stackingFeature" orientation="row">
<svelte:fragment slot="title">Branch stacking</svelte:fragment> <svelte:fragment slot="title">Branch stacking</svelte:fragment>
<svelte:fragment slot="caption"> <svelte:fragment slot="caption">
Allows for branch / pull request stacking. The user interface for this is still very crude. Allows for branch / pull request stacking. The user interface for this is still very crude.
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
<Toggle <Toggle
id="branchStacking" id="stackingFeature"
checked={$branchStacking} checked={$stackingFeature}
on:click={() => ($branchStacking = !$branchStacking)} 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> </svelte:fragment>
</SectionCard> </SectionCard>

View File

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

View File

@ -1,5 +1,5 @@
use super::r#virtual as vbranch; use super::r#virtual as vbranch;
use crate::branch; use crate::upstream_integration::{self, BranchStatuses, Resolution, UpstreamIntegrationContext};
use crate::{ use crate::{
base, base,
base::BaseBranch, base::BaseBranch,
@ -517,7 +517,7 @@ pub fn create_virtual_branch_from_branch(
pub fn get_uncommited_files(project: &Project) -> Result<Vec<RemoteBranchFile>> { pub fn get_uncommited_files(project: &Project) -> Result<Vec<RemoteBranchFile>> {
let context = CommandContext::open(project)?; let context = CommandContext::open(project)?;
let guard = project.exclusive_worktree_access(); 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 /// 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> { pub fn get_uncommited_files_reusable(project: &Project) -> Result<DiffByPathMap> {
let context = CommandContext::open(project)?; let context = CommandContext::open(project)?;
let guard = project.exclusive_worktree_access(); 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> { fn open_with_verify(project: &Project) -> Result<CommandContext> {
let ctx = CommandContext::open(project)?; let ctx = CommandContext::open(project)?;
let mut guard = project.exclusive_worktree_access(); let mut guard = project.exclusive_worktree_access();
crate::integration::verify_branch(&ctx, guard.write_permission())?; crate::integration::verify_branch(&ctx, guard.write_permission())?;
Ok(ctx) Ok(ctx)
} }

View File

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

View File

@ -12,7 +12,7 @@ use gitbutler_commit::commit_ext::CommitExt;
use gitbutler_error::error::Marker; use gitbutler_error::error::Marker;
use gitbutler_operating_modes::OPEN_WORKSPACE_REFS; use gitbutler_operating_modes::OPEN_WORKSPACE_REFS;
use gitbutler_project::access::WorktreeWritePermission; use gitbutler_project::access::WorktreeWritePermission;
use gitbutler_repo::{LogUntil, RepoActionsExt, RepositoryExt}; use gitbutler_repo::{LogUntil, RepositoryExt};
use tracing::instrument; use tracing::instrument;
use crate::{branch_manager::BranchManagerExt, conflicts, VirtualBranchesExt}; 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")?; .context("failed to get default target")?;
let commits = ctx let commits = ctx
.repository()
.log(head_commit.id(), LogUntil::Commit(default_target.sha)) .log(head_commit.id(), LogUntil::Commit(default_target.sha))
.context("failed to get log")?; .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, 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, 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, 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, integrate_upstream_commits, list_local_branches, list_remote_commit_files,
list_virtual_branches, list_virtual_branches_cached, move_commit, move_commit_file, 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, 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, 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_base_branch, update_branch_order, update_change_reference, update_commit_message,
update_virtual_branch, update_virtual_branch, upstream_integration_statuses,
}; };
mod r#virtual; mod r#virtual;
@ -29,6 +29,8 @@ pub use branch_manager::{BranchManager, BranchManagerExt};
mod base; mod base;
pub use base::BaseBranch; pub use base::BaseBranch;
pub mod upstream_integration;
mod integration; mod integration;
pub use integration::{update_workspace_commit, verify_branch}; pub use integration::{update_workspace_commit, verify_branch};

View File

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

View File

@ -78,7 +78,7 @@ impl GitHunk {
new_lines: 0, new_lines: 0,
diff_lines: Default::default(), diff_lines: Default::default(),
binary: false, 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 // returns `None` if the reversal failed
pub fn reverse_hunk(hunk: &GitHunk) -> Option<GitHunk> { 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 { if hunk.binary {
None None
} else { } else {
@ -397,7 +402,7 @@ pub fn reverse_hunk(hunk: &GitHunk) -> Option<GitHunk> {
new_lines: hunk.old_lines, new_lines: hunk.old_lines,
diff_lines: diff.into(), diff_lines: diff.into(),
binary: hunk.binary, binary: hunk.binary,
change_type: hunk.change_type, change_type: new_change_type,
}) })
} }
} }

View File

@ -1,6 +1,6 @@
#[cfg(target_family = "unix")] #[cfg(target_family = "unix")]
use std::os::unix::prelude::PermissionsExt; 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 anyhow::{anyhow, Context, Result};
use bstr::{BString, ByteSlice, ByteVec}; use bstr::{BString, ByteSlice, ByteVec};
@ -38,13 +38,14 @@ where
let head_commit = git_repository.find_commit(commit_oid)?; let head_commit = git_repository.find_commit(commit_oid)?;
let base_tree = head_commit.tree()?; 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>( pub fn hunks_onto_tree<T>(
ctx: &CommandContext, ctx: &CommandContext,
base_tree: &git2::Tree, base_tree: &git2::Tree,
files: impl IntoIterator<Item = (impl Borrow<PathBuf>, impl Borrow<Vec<T>>)>, files: impl IntoIterator<Item = (impl Borrow<PathBuf>, impl Borrow<Vec<T>>)>,
allow_new_file: bool,
) -> Result<git2::Oid> ) -> Result<git2::Oid>
where where
T: Into<GitHunk> + Clone, T: Into<GitHunk> + Clone,
@ -62,7 +63,21 @@ where
&& hunks[0].diff_lines.contains_str(b"Subproject commit"); && hunks[0].diff_lines.contains_str(b"Subproject commit");
// if file exists // 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 // if file is executable, use 755, otherwise 644
let mut filemode = git2::FileMode::Blob; let mut filemode = git2::FileMode::Blob;
// check if full_path file is executable // check if full_path file is executable
@ -115,7 +130,7 @@ where
)?; )?;
builder.upsert(rel_path, blob_oid, filemode); builder.upsert(rel_path, blob_oid, filemode);
} else if let Ok(tree_entry) = base_tree.get_path(rel_path) { } 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; let new_blob_oid = &hunks[0].diff_lines;
// convert string to Oid // convert string to Oid
let new_blob_oid = new_blob_oid let new_blob_oid = new_blob_oid
@ -178,6 +193,20 @@ where
let new_blob_oid = git_repository.blob(blob_contents.as_bytes())?; let new_blob_oid = git_repository.blob(blob_contents.as_bytes())?;
// upsert into the builder // upsert into the builder
builder.upsert(rel_path, new_blob_oid, filemode); 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 { } else {
// create a git blob from a file on disk // create a git blob from a file on disk
let blob_oid = git_repository let blob_oid = git_repository

View File

@ -236,7 +236,6 @@ pub(crate) fn save_and_return_to_workspace(
let commit = repository let commit = repository
.find_commit(edit_mode_metadata.commit_oid) .find_commit(edit_mode_metadata.commit_oid)
.context("Failed to find commit")?; .context("Failed to find commit")?;
let commit_parent = commit.parent(0).context("Failed to get commit's parent")?;
let stashed_workspace_changes_reference = repository let stashed_workspace_changes_reference = repository
.find_reference(EDIT_UNCOMMITED_FILES_REF) .find_reference(EDIT_UNCOMMITED_FILES_REF)
.context("Failed to find stashed workspace changes")?; .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") 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 // Recommit commit
let tree = repository.create_wd_tree()?; let tree = repository.create_wd_tree()?;
let commit_headers = commit let commit_headers = commit
@ -266,7 +267,7 @@ pub(crate) fn save_and_return_to_workspace(
&commit.committer(), &commit.committer(),
&commit.message_bstr().to_str_lossy(), &commit.message_bstr().to_str_lossy(),
&tree, &tree,
&[&commit_parent], &parents.iter().collect::<Vec<_>>(),
commit_headers, commit_headers,
) )
.context("Failed to commit new commit")?; .context("Failed to commit new commit")?;
@ -303,9 +304,10 @@ pub(crate) fn save_and_return_to_workspace(
.context("Failed to update gitbutler workspace")?; .context("Failed to update gitbutler workspace")?;
let rebased_stashed_workspace_changes_commit = cherry_rebase_group( let rebased_stashed_workspace_changes_commit = cherry_rebase_group(
ctx, repository,
workspace_commit_oid, workspace_commit_oid,
&mut [stashed_workspace_changes_commit.id()], &[stashed_workspace_changes_commit.id()],
true,
) )
.context("Failed to rebase stashed workspace commit changes")?; .context("Failed to rebase stashed workspace commit changes")?;

View File

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

View File

@ -8,7 +8,7 @@ use gitbutler_commit::{
}; };
use gitbutler_error::error::Marker; use gitbutler_error::error::Marker;
use crate::{LogUntil, RepoActionsExt}; use crate::{LogUntil, RepositoryExt as _};
/// cherry-pick based rebase, which handles empty commits /// cherry-pick based rebase, which handles empty commits
/// this function takes a commit range and generates a Vector of commit oids /// 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, from_commit_oid: git2::Oid,
) -> Result<Option<git2::Oid>> { ) -> Result<Option<git2::Oid>> {
// get a list of the commits to rebase // 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() { if ids_to_rebase.is_empty() {
return Ok(None); 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)) 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 /// the difference between this and a libgit2 based rebase is that this will successfully
/// rebase empty commits (two commits with identical trees) /// rebase empty commits (two commits with identical trees)
pub fn cherry_rebase_group( pub fn cherry_rebase_group(
ctx: &CommandContext, repository: &git2::Repository,
target_commit_oid: git2::Oid, target_commit_oid: git2::Oid,
ids_to_rebase: &mut [git2::Oid], ids_to_rebase: &[git2::Oid],
succeeding_rebases: bool,
) -> Result<git2::Oid> { ) -> Result<git2::Oid> {
ids_to_rebase.reverse();
// now, rebase unchanged commits onto the new commit // now, rebase unchanged commits onto the new commit
let commits_to_rebase = ids_to_rebase let commits_to_rebase = ids_to_rebase
.iter() .iter()
.map(|oid| ctx.repository().find_commit(oid.to_owned())) .map(|oid| repository.find_commit(oid.to_owned()))
.rev()
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
.context("failed to read commits to rebase")?; .context("failed to read commits to rebase")?;
let repository = ctx.repository();
let new_head_id = commits_to_rebase let new_head_id = commits_to_rebase
.into_iter() .into_iter()
.fold( .fold(
@ -70,12 +76,24 @@ pub fn cherry_rebase_group(
.context("failed to cherry pick")?; .context("failed to cherry pick")?;
if cherrypick_index.has_conflicts() { if cherrypick_index.has_conflicts() {
if !ctx.project().succeeding_rebases { if !succeeding_rebases {
return Err(anyhow!("failed to rebase")).context(Marker::BranchConflict); 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 { } 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) 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>( fn commit_unconflicted_cherry_result<'repository>(
ctx: &'repository CommandContext, repository: &'repository git2::Repository,
head: git2::Commit<'repository>, head: git2::Commit<'repository>,
to_rebase: git2::Commit, to_rebase: git2::Commit,
mut cherrypick_index: git2::Index, mut cherrypick_index: git2::Index,
override_commit_details: Option<OverrideCommitDetails>,
) -> Result<git2::Commit<'repository>> { ) -> Result<git2::Commit<'repository>> {
let repository = ctx.repository();
let commit_headers = to_rebase.gitbutler_headers(); let commit_headers = to_rebase.gitbutler_headers();
let is_merge_commit = to_rebase.parent_count() > 0; let is_merge_commit = to_rebase.parent_count() > 0;
@ -113,17 +138,31 @@ fn commit_unconflicted_cherry_result<'repository>(
..commit_headers ..commit_headers
}); });
let commit_oid = crate::RepositoryExt::commit_with_signature( let commit_oid = if let Some(override_commit_details) = override_commit_details {
repository, crate::RepositoryExt::commit_with_signature(
None, repository,
&to_rebase.author(), None,
&to_rebase.committer(), override_commit_details.author,
&to_rebase.message_bstr().to_str_lossy(), override_commit_details.commiter,
&merge_tree, override_commit_details.message,
&[&head], &merge_tree,
commit_headers, override_commit_details.parents,
) commit_headers,
.context("failed to create commit")?; )
.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 repository
.find_commit(commit_oid) .find_commit(commit_oid)
@ -131,12 +170,12 @@ fn commit_unconflicted_cherry_result<'repository>(
} }
fn commit_conflicted_cherry_result<'repository>( fn commit_conflicted_cherry_result<'repository>(
ctx: &'repository CommandContext, repository: &'repository git2::Repository,
head: git2::Commit, head: git2::Commit,
to_rebase: git2::Commit, to_rebase: git2::Commit,
cherrypick_index: git2::Index, cherrypick_index: git2::Index,
override_commit_details: Option<OverrideCommitDetails>,
) -> Result<git2::Commit<'repository>> { ) -> Result<git2::Commit<'repository>> {
let repository = ctx.repository();
let commit_headers = to_rebase.gitbutler_headers(); let commit_headers = to_rebase.gitbutler_headers();
// If the commit we're rebasing is conflicted, use the commits original base. // 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 tree_oid = tree_writer.write().context("failed to write tree")?;
let commit_headers = commit_headers.map(|commit_headers| { let commit_headers =
let conflicted_file_count = conflicted_files commit_headers
.len() .or_else(|| Some(Default::default()))
.try_into() .map(|commit_headers| {
.expect("If you have more than 2^64 conflicting files, we've got bigger problems"); let conflicted_file_count = conflicted_files.len().try_into().expect(
CommitHeadersV2 { "If you have more than 2^64 conflicting files, we've got bigger problems",
conflicted: Some(conflicted_file_count), );
..commit_headers CommitHeadersV2 {
} conflicted: Some(conflicted_file_count),
}); ..commit_headers
}
});
// write a commit let commit_oid = if let Some(override_commit_details) = override_commit_details {
let commit_oid = crate::RepositoryExt::commit_with_signature( crate::RepositoryExt::commit_with_signature(
repository, repository,
None, None,
&to_rebase.author(), override_commit_details.author,
&to_rebase.committer(), override_commit_details.commiter,
&to_rebase.message_bstr().to_str_lossy(), override_commit_details.message,
&repository &repository
.find_tree(tree_oid) .find_tree(tree_oid)
.context("failed to find tree")?, .context("failed to find tree")?,
&[&head], override_commit_details.parents,
commit_headers, commit_headers,
) )
.context("failed to create commit")?; .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 repository
.find_commit(commit_oid) .find_commit(commit_oid)
.context("failed to find commit") .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 std::str::FromStr;
use anyhow::{anyhow, Context, Result}; 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_command_context::CommandContext;
use gitbutler_commit::commit_headers::CommitHeadersV2; use gitbutler_commit::commit_headers::CommitHeadersV2;
use gitbutler_error::error::Code; use gitbutler_error::error::Code;
use gitbutler_project::AuthKey; use gitbutler_project::AuthKey;
use gitbutler_reference::{Refname, RemoteRefname}; use gitbutler_reference::{Refname, RemoteRefname};
use crate::{askpass, credentials, Config, RepositoryExt}; use crate::{askpass, credentials, RepositoryExt};
pub trait RepoActionsExt { pub trait RepoActionsExt {
fn fetch(&self, remote_name: &str, askpass: Option<String>) -> Result<()>; fn fetch(&self, remote_name: &str, askpass: Option<String>) -> Result<()>;
fn push( fn push(
@ -27,9 +27,6 @@ pub trait RepoActionsExt {
commit_headers: Option<CommitHeadersV2>, commit_headers: Option<CommitHeadersV2>,
) -> Result<git2::Oid>; ) -> Result<git2::Oid>;
fn distance(&self, from: git2::Oid, to: git2::Oid) -> Result<u32>; 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 delete_branch_reference(&self, branch: &Branch) -> Result<()>;
fn add_branch_reference(&self, branch: &Branch) -> Result<()>; fn add_branch_reference(&self, branch: &Branch) -> Result<()>;
fn git_test_push( fn git_test_push(
@ -38,7 +35,6 @@ pub trait RepoActionsExt {
branch_name: &str, branch_name: &str,
askpass: Option<Option<BranchId>>, askpass: Option<Option<BranchId>>,
) -> Result<()>; ) -> Result<()>;
fn signatures(&self) -> Result<(git2::Signature, git2::Signature)>;
} }
impl RepoActionsExt for CommandContext { impl RepoActionsExt for CommandContext {
@ -126,97 +122,9 @@ impl RepoActionsExt for CommandContext {
.context("failed to lookup reference") .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 // returns the number of commits between the first oid to the second oid
fn distance(&self, from: git2::Oid, to: git2::Oid) -> Result<u32> { 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()?) Ok(oids.len().try_into()?)
} }
@ -227,7 +135,10 @@ impl RepoActionsExt for CommandContext {
parents: &[&git2::Commit], parents: &[&git2::Commit],
commit_headers: Option<CommitHeadersV2>, commit_headers: Option<CommitHeadersV2>,
) -> Result<git2::Oid> { ) -> 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() self.repository()
.commit_with_signature( .commit_with_signature(
None, None,
@ -404,30 +315,6 @@ impl RepoActionsExt for CommandContext {
Err(anyhow!("authentication failed")).context(Code::ProjectGitAuth) 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>; 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 anyhow::{anyhow, bail, Context, Result};
use bstr::BString; use bstr::BString;
use git2::{BlameOptions, Tree}; use git2::{BlameOptions, Tree};
use gitbutler_branch::{gix_to_git2_signature, SignaturePurpose};
use gitbutler_commit::{commit_buffer::CommitBuffer, commit_headers::CommitHeadersV2}; use gitbutler_commit::{commit_buffer::CommitBuffer, commit_headers::CommitHeadersV2};
use gitbutler_config::git::{GbConfig, GitConfig}; use gitbutler_config::git::{GbConfig, GitConfig};
use gitbutler_error::error::Code; use gitbutler_error::error::Code;
use gitbutler_reference::{Refname, RemoteRefname}; use gitbutler_reference::{Refname, RemoteRefname};
use tracing::instrument; use tracing::instrument;
use crate::{Config, LogUntil};
/// Extension trait for `git2::Repository`. /// Extension trait for `git2::Repository`.
/// ///
/// For now, it collects useful methods from `gitbutler-core::git::Repository` /// For now, it collects useful methods from `gitbutler-core::git::Repository`
pub trait RepositoryExt { 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 /// 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. /// when it's clear that it's representing the current state.
/// ///
@ -390,6 +397,103 @@ impl RepositoryExt for git2::Repository {
}) })
.collect::<Result<Vec<_>>>() .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. /// 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_commit::commit_ext::CommitExt;
use gitbutler_repo::{ use gitbutler_repo::{
create_change_reference, list_branch_references, push_change_reference, create_change_reference, list_branch_references, push_change_reference,
update_change_reference, LogUntil, RepoActionsExt, update_change_reference, LogUntil, RepositoryExt as _,
}; };
use tempfile::TempDir; 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 branch = branches.iter().find(|b| b.name == "virtual").unwrap();
let other_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 target = handle.get_default_target()?;
let branch_commits = ctx.log(branch.head, LogUntil::Commit(target.sha))?; let branch_commits = ctx
let other_commits = ctx.log(other_branch.head, LogUntil::Commit(target.sha))?; .repository()
.log(branch.head, LogUntil::Commit(target.sha))?;
let other_commits = ctx
.repository()
.log(other_branch.head, LogUntil::Commit(target.sha))?;
Ok(TestContext { Ok(TestContext {
branch: branch.clone(), branch: branch.clone(),
commits: branch_commits, commits: branch_commits,

View File

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

View File

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

View File

@ -3,6 +3,7 @@ pub mod commands {
use gitbutler_branch::{ use gitbutler_branch::{
BranchCreateRequest, BranchId, BranchOwnershipClaims, BranchUpdateRequest, BranchCreateRequest, BranchId, BranchOwnershipClaims, BranchUpdateRequest,
}; };
use gitbutler_branch_actions::upstream_integration::{BranchStatuses, Resolution};
use gitbutler_branch_actions::{ use gitbutler_branch_actions::{
BaseBranch, BranchListing, BranchListingDetails, BranchListingFilter, RemoteBranch, BaseBranch, BranchListing, BranchListingDetails, BranchListingFilter, RemoteBranch,
RemoteBranchData, RemoteBranchFile, VirtualBranches, RemoteBranchData, RemoteBranchFile, VirtualBranches,
@ -595,6 +596,35 @@ pub mod commands {
Ok(()) 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) { fn emit_vbranches(windows: &WindowState, project_id: projects::ProjectId) {
if let Err(error) = windows.post(gitbutler_watcher::Action::CalculateVirtualBranches( if let Err(error) = windows.post(gitbutler_watcher::Action::CalculateVirtualBranches(
project_id, project_id,

View File

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

View File

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

View File

@ -293,7 +293,7 @@
}, },
"50": { "50": {
"$type": "color", "$type": "color",
"$value": "#48a8a3", "$value": "#3cb4ae",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": {}, "mode": {},
@ -309,7 +309,7 @@
}, },
"60": { "60": {
"$type": "color", "$type": "color",
"$value": "#97cecb", "$value": "#8fd6d2",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": {}, "mode": {},
@ -325,7 +325,7 @@
}, },
"70": { "70": {
"$type": "color", "$type": "color",
"$value": "#c6e7e5", "$value": "#c1ebe9",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": {}, "mode": {},
@ -341,7 +341,7 @@
}, },
"80": { "80": {
"$type": "color", "$type": "color",
"$value": "#daf1f0", "$value": "#d7f4f2",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": {}, "mode": {},
@ -357,7 +357,7 @@
}, },
"90": { "90": {
"$type": "color", "$type": "color",
"$value": "#e9f7f6", "$value": "#e7f8f7",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": {}, "mode": {},
@ -373,7 +373,7 @@
}, },
"95": { "95": {
"$type": "color", "$type": "color",
"$value": "#f4fbfa", "$value": "#f3fcfb",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": {}, "mode": {},
@ -649,7 +649,7 @@
}, },
"50": { "50": {
"$type": "color", "$type": "color",
"$value": "#dc9b14", "$value": "#dc9914",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": {}, "mode": {},
@ -665,7 +665,7 @@
}, },
"60": { "60": {
"$type": "color", "$type": "color",
"$value": "#f4bb6c", "$value": "#f4c06c",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": {}, "mode": {},
@ -681,7 +681,7 @@
}, },
"70": { "70": {
"$type": "color", "$type": "color",
"$value": "#feddae", "$value": "#fee1ae",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": {}, "mode": {},
@ -697,7 +697,7 @@
}, },
"80": { "80": {
"$type": "color", "$type": "color",
"$value": "#ffe8c7", "$value": "#ffecc7",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": {}, "mode": {},
@ -713,7 +713,7 @@
}, },
"90": { "90": {
"$type": "color", "$type": "color",
"$value": "#fff2e0", "$value": "#fff7e0",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": {}, "mode": {},
@ -729,7 +729,7 @@
}, },
"95": { "95": {
"$type": "color", "$type": "color",
"$value": "#fdf7ed", "$value": "#fdf9ed",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": {}, "mode": {},
@ -859,7 +859,7 @@
}, },
"70": { "70": {
"$type": "color", "$type": "color",
"$value": "#bef4da", "$value": "#c2f0da",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": {}, "mode": {},
@ -875,7 +875,7 @@
}, },
"80": { "80": {
"$type": "color", "$type": "color",
"$value": "#d0f7e5", "$value": "#d2f4e4",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": {}, "mode": {},
@ -891,7 +891,7 @@
}, },
"90": { "90": {
"$type": "color", "$type": "color",
"$value": "#e5faf0", "$value": "#e7f9f0",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": {}, "mode": {},
@ -3652,12 +3652,12 @@
"selected": { "selected": {
"count-bg": { "count-bg": {
"$type": "color", "$type": "color",
"$value": "#378bf2", "$value": "{clr-core.pop.70}",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": { "mode": {
"light": "#378bf2", "light": "{clr-core.pop.70}",
"dark": "#044289" "dark": "{clr-core.pop.10}"
}, },
"figma": { "figma": {
"variableId": "VariableID:3935:251", "variableId": "VariableID:3935:251",
@ -3671,12 +3671,12 @@
}, },
"count-border": { "count-border": {
"$type": "color", "$type": "color",
"$value": "#265dd4", "$value": "{clr-core.pop.60}",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": { "mode": {
"light": "#265dd4", "light": "{clr-core.pop.60}",
"dark": "#005cc5" "dark": "{clr-core.pop.30}"
}, },
"figma": { "figma": {
"variableId": "VariableID:3935:253", "variableId": "VariableID:3935:253",
@ -3690,12 +3690,12 @@
}, },
"count-text": { "count-text": {
"$type": "color", "$type": "color",
"$value": "#ffffff", "$value": "{clr-core.pop.40}",
"$description": "", "$description": "",
"$extensions": { "$extensions": {
"mode": { "mode": {
"light": "#ffffff", "light": "{clr-core.pop.40}",
"dark": "#d6e8ff" "dark": "{clr-core.pop.50}"
}, },
"figma": { "figma": {
"variableId": "VariableID:3935:254", "variableId": "VariableID:3935:254",
@ -4137,7 +4137,7 @@
"useDTCGKeys": true, "useDTCGKeys": true,
"colorMode": "hex", "colorMode": "hex",
"variableCollections": ["clr-core", "clr", "size", "radius"], "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; clickable?: boolean;
showCheckbox?: boolean; showCheckbox?: boolean;
checked?: boolean; checked?: boolean;
indeterminate?: boolean;
conflicted?: boolean; conflicted?: boolean;
locked?: boolean; locked?: boolean;
lockText?: string; lockText?: string;
@ -44,6 +45,7 @@
clickable = true, clickable = true,
showCheckbox = false, showCheckbox = false,
checked = $bindable(), checked = $bindable(),
indeterminate,
conflicted, conflicted,
locked, locked,
lockText, lockText,
@ -81,7 +83,7 @@
}} }}
> >
{#if showCheckbox} {#if showCheckbox}
<Checkbox small {checked} onchange={oncheck} /> <Checkbox small {checked} {indeterminate} onchange={oncheck} />
{/if} {/if}
<div class="info"> <div class="info">
<FileIcon {fileName} size={14} /> <FileIcon {fileName} size={14} />

View File

@ -13,9 +13,16 @@ export const CheckboxStory: Story = {
name: 'Checkbox', name: 'Checkbox',
args: { args: {
name: 'Checkbox', name: 'Checkbox',
style: 'default',
checked: false, checked: false,
disabled: false, disabled: false,
indeterminate: false, indeterminate: false,
small: 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-20: color(srgb 0.10980392156862745 0.32941176470588235 0.3176470588235294);
--clr-core-pop-30: color(srgb 0.1450980392156863 0.43529411764705883 0.4196078431372549); --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-40: color(srgb 0.16470588235294117 0.5725490196078431 0.5529411764705883);
--clr-core-pop-50: color(srgb 0.2823529411764706 0.6588235294117647 0.6392156862745098); --clr-core-pop-50: color(srgb 0.23529411764705882 0.7058823529411765 0.6823529411764706);
--clr-core-pop-60: color(srgb 0.592156862745098 0.807843137254902 0.796078431372549); --clr-core-pop-60: color(srgb 0.5607843137254902 0.8392156862745098 0.8235294117647058);
--clr-core-pop-70: color(srgb 0.7764705882352941 0.9058823529411765 0.8980392156862745); --clr-core-pop-70: color(srgb 0.7568627450980392 0.9215686274509803 0.9137254901960784);
--clr-core-pop-80: color(srgb 0.8549019607843137 0.9450980392156862 0.9411764705882353); --clr-core-pop-80: color(srgb 0.8431372549019608 0.9568627450980393 0.9490196078431372);
--clr-core-pop-90: color(srgb 0.9137254901960784 0.9686274509803922 0.9647058823529412); --clr-core-pop-90: color(srgb 0.9058823529411765 0.9725490196078431 0.9686274509803922);
--clr-core-pop-95: color(srgb 0.9568627450980393 0.984313725490196 0.9803921568627451); --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-5: color(srgb 0.14901960784313725 0.050980392156862744 0.058823529411764705);
--clr-core-err-10: color(srgb 0.2980392156862745 0.10196078431372549 0.12156862745098039); --clr-core-err-10: color(srgb 0.2980392156862745 0.10196078431372549 0.12156862745098039);
--clr-core-err-20: color(srgb 0.4196078431372549 0.1411764705882353 0.16862745098039217); --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-20: color(srgb 0.3764705882352941 0.2549019607843137 0.08627450980392157);
--clr-core-warn-30: color(srgb 0.5411764705882353 0.34901960784313724 0.0784313725490196); --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-40: color(srgb 0.7803921568627451 0.4980392156862745 0.10196078431372549);
--clr-core-warn-50: color(srgb 0.8627450980392157 0.6078431372549019 0.0784313725490196); --clr-core-warn-50: color(srgb 0.8627450980392157 0.6 0.0784313725490196);
--clr-core-warn-60: color(srgb 0.9568627450980393 0.7333333333333333 0.4235294117647059); --clr-core-warn-60: color(srgb 0.9568627450980393 0.7529411764705882 0.4235294117647059);
--clr-core-warn-70: color(srgb 0.996078431372549 0.8666666666666667 0.6823529411764706); --clr-core-warn-70: color(srgb 0.996078431372549 0.8823529411764706 0.6823529411764706);
--clr-core-warn-80: color(srgb 1 0.9098039215686274 0.7803921568627451); --clr-core-warn-80: color(srgb 1 0.9254901960784314 0.7803921568627451);
--clr-core-warn-90: color(srgb 1 0.9490196078431372 0.8784313725490196); --clr-core-warn-90: color(srgb 1 0.9686274509803922 0.8784313725490196);
--clr-core-warn-95: color(srgb 0.9921568627450981 0.9686274509803922 0.9294117647058824); --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-5: color(srgb 0.050980392156862744 0.14901960784313725 0.10196078431372549);
--clr-core-succ-10: color(srgb 0.10980392156862745 0.25098039215686274 0.1843137254901961); --clr-core-succ-10: color(srgb 0.10980392156862745 0.25098039215686274 0.1843137254901961);
--clr-core-succ-20: color(srgb 0.13333333333333333 0.3254901960784314 0.23529411764705882); --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-40: color(srgb 0.23529411764705882 0.6039215686274509 0.43529411764705883);
--clr-core-succ-50: color(srgb 0.2901960784313726 0.7098039215686275 0.5098039215686274); --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-60: color(srgb 0.5725490196078431 0.8666666666666667 0.7294117647058823);
--clr-core-succ-70: color(srgb 0.7450980392156863 0.9568627450980393 0.8549019607843137); --clr-core-succ-70: color(srgb 0.7607843137254902 0.9411764705882353 0.8549019607843137);
--clr-core-succ-80: color(srgb 0.8156862745098039 0.9686274509803922 0.8980392156862745); --clr-core-succ-80: color(srgb 0.8235294117647058 0.9568627450980393 0.8941176470588236);
--clr-core-succ-90: color(srgb 0.8980392156862745 0.9803921568627451 0.9411764705882353); --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-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-5: color(srgb 0.1568627450980392 0.11372549019607843 0.26666666666666666);
--clr-core-purp-10: color(srgb 0.24705882352941178 0.17254901960784313 0.40784313725490196); --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-line-bg: var(--clr-bg-1);
--clr-diff-count-bg: color(srgb 0.9686274509803922 0.9686274509803922 0.9647058823529412); --clr-diff-count-bg: color(srgb 0.9686274509803922 0.9686274509803922 0.9647058823529412);
--clr-diff-count-border: var(--clr-border-2); --clr-diff-count-border: var(--clr-border-2);
--clr-diff-selected-count-bg: color( --clr-diff-selected-count-bg: var(--clr-core-pop-70);
srgb 0.21568627450980393 0.5450980392156862 0.9490196078431372 --clr-diff-selected-count-border: var(--clr-core-pop-60);
); --clr-diff-selected-count-text: var(--clr-core-pop-40);
--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-count-text: var(--clr-text-3); --clr-diff-count-text: var(--clr-text-3);
--clr-diff-deletion-line-bg: color(srgb 1 0.9411764705882353 0.9490196078431372); --clr-diff-deletion-line-bg: color(srgb 1 0.9411764705882353 0.9490196078431372);
--clr-diff-deletion-line-highlight: color( --clr-diff-deletion-line-highlight: color(
@ -385,11 +381,9 @@
--clr-diff-line-bg: var(--clr-bg-1); --clr-diff-line-bg: var(--clr-bg-1);
--clr-diff-count-bg: color(srgb 0.18823529411764706 0.17254901960784313 0.16862745098039217); --clr-diff-count-bg: color(srgb 0.18823529411764706 0.17254901960784313 0.16862745098039217);
--clr-diff-count-border: var(--clr-border-2); --clr-diff-count-border: var(--clr-border-2);
--clr-diff-selected-count-bg: color( --clr-diff-selected-count-bg: var(--clr-core-pop-10);
srgb 0.01568627450980392 0.25882352941176473 0.5372549019607843 --clr-diff-selected-count-border: var(--clr-core-pop-30);
); --clr-diff-selected-count-text: var(--clr-core-pop-50);
--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-count-text: var(--clr-text-3); --clr-diff-count-text: var(--clr-text-3);
--clr-diff-deletion-line-bg: color( --clr-diff-deletion-line-bg: color(
srgb 0.23529411764705882 0.07450980392156863 0.10588235294117647 srgb 0.23529411764705882 0.07450980392156863 0.10588235294117647