mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-28 20:15:20 +03:00
fix: merge conflicts
This commit is contained in:
commit
e842b282ae
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -2204,6 +2204,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import BranchHeader from './BranchHeader.svelte';
|
||||
import StackedBranchHeader from './StackedBranchHeader.svelte';
|
||||
import EmptyStatePlaceholder from '../components/EmptyStatePlaceholder.svelte';
|
||||
import PullRequestCard from '../pr/PullRequestCard.svelte';
|
||||
import InfoMessage from '../shared/InfoMessage.svelte';
|
||||
@ -12,7 +13,11 @@
|
||||
import CommitDialog from '$lib/commit/CommitDialog.svelte';
|
||||
import CommitList from '$lib/commit/CommitList.svelte';
|
||||
import { projectAiGenEnabled } from '$lib/config/config';
|
||||
import { stackingFeature } from '$lib/config/uiFeatureFlags';
|
||||
import BranchFiles from '$lib/file/BranchFiles.svelte';
|
||||
import { getGitHostChecksMonitor } from '$lib/gitHost/interface/gitHostChecksMonitor';
|
||||
import { getGitHostListingService } from '$lib/gitHost/interface/gitHostListingService';
|
||||
import { getGitHostPrMonitor } from '$lib/gitHost/interface/gitHostPrMonitor';
|
||||
import { showError } from '$lib/notifications/toasts';
|
||||
import { persisted } from '$lib/persisted/persisted';
|
||||
import { isFailure } from '$lib/result';
|
||||
@ -22,8 +27,16 @@
|
||||
import { User } from '$lib/stores/user';
|
||||
import { getContext, getContextStore, getContextStoreBySymbol } from '$lib/utils/context';
|
||||
import { BranchController } from '$lib/vbranches/branchController';
|
||||
import { groupCommitsByRef } from '$lib/vbranches/commitGroups';
|
||||
import {
|
||||
getIntegratedCommits,
|
||||
getLocalAndRemoteCommits,
|
||||
getLocalCommits,
|
||||
getRemoteCommits
|
||||
} from '$lib/vbranches/contexts';
|
||||
import { FileIdSelection } from '$lib/vbranches/fileIdSelection';
|
||||
import { VirtualBranch } from '$lib/vbranches/types';
|
||||
import Button from '@gitbutler/ui/Button.svelte';
|
||||
import lscache from 'lscache';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
@ -93,6 +106,33 @@
|
||||
onMount(() => {
|
||||
laneWidth = lscache.get(laneWidthKey + branch.id);
|
||||
});
|
||||
|
||||
const localCommits = getLocalCommits();
|
||||
const localAndRemoteCommits = getLocalAndRemoteCommits();
|
||||
const integratedCommits = getIntegratedCommits();
|
||||
const remoteCommits = getRemoteCommits();
|
||||
|
||||
let isPushingCommits = $state(false);
|
||||
const localCommitsConflicted = $derived($localCommits.some((commit) => commit.conflicted));
|
||||
const localAndRemoteCommitsConflicted = $derived(
|
||||
$localAndRemoteCommits.some((commit) => commit.conflicted)
|
||||
);
|
||||
|
||||
const listingService = getGitHostListingService();
|
||||
const prMonitor = getGitHostPrMonitor();
|
||||
const checksMonitor = getGitHostChecksMonitor();
|
||||
|
||||
async function push() {
|
||||
isPushingCommits = true;
|
||||
try {
|
||||
await branchController.pushBranch(branch.id, branch.requiresForce);
|
||||
$listingService?.refresh();
|
||||
$prMonitor?.refresh();
|
||||
$checksMonitor?.update();
|
||||
} finally {
|
||||
isPushingCommits = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $isLaneCollapsed}
|
||||
@ -122,10 +162,12 @@
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<BranchHeader {isLaneCollapsed} onGenerateBranchName={generateBranchName} />
|
||||
<PullRequestCard />
|
||||
<div class="card">
|
||||
{#if !$stackingFeature && branch.upstream?.givenName}
|
||||
<PullRequestCard upstreamName={branch.upstream.givenName} />
|
||||
{/if}
|
||||
<div class:card-no-stacking={!$stackingFeature} class:card-stacking={$stackingFeature}>
|
||||
{#if branch.files?.length > 0}
|
||||
<div class="branch-card__files">
|
||||
<div class="branch-card__files" class:card={$stackingFeature}>
|
||||
<Dropzones>
|
||||
<BranchFiles
|
||||
isUnapplied={false}
|
||||
@ -157,7 +199,7 @@
|
||||
</div>
|
||||
{:else if branch.commits.length === 0}
|
||||
<Dropzones>
|
||||
<div class="new-branch">
|
||||
<div class="new-branch" class:card={$stackingFeature}>
|
||||
<EmptyStatePlaceholder image={laneNewSvg} width="11rem">
|
||||
<svelte:fragment slot="title">This is a new branch</svelte:fragment>
|
||||
<svelte:fragment slot="caption">
|
||||
@ -168,7 +210,7 @@
|
||||
</Dropzones>
|
||||
{:else}
|
||||
<Dropzones>
|
||||
<div class="no-changes">
|
||||
<div class="no-changes" class:card={$stackingFeature}>
|
||||
<EmptyStatePlaceholder image={noChangesSvg} width="11rem" hasBottomMargin={false}>
|
||||
<svelte:fragment slot="caption"
|
||||
>No uncommitted changes on this branch</svelte:fragment
|
||||
@ -178,7 +220,57 @@
|
||||
</Dropzones>
|
||||
{/if}
|
||||
|
||||
<CommitList isUnapplied={false} />
|
||||
{#snippet pushButton({disabled}: {disabled: boolean})}
|
||||
<Button
|
||||
style="pop"
|
||||
kind="solid"
|
||||
wide
|
||||
loading={isPushingCommits}
|
||||
{disabled}
|
||||
tooltip={localCommitsConflicted
|
||||
? 'In order to push, please resolve any conflicted commits.'
|
||||
: undefined}
|
||||
onclick={push}
|
||||
>
|
||||
{branch.requiresForce ? 'Force push' : 'Push'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#if $stackingFeature}
|
||||
{@const groups = groupCommitsByRef(branch.commits)}
|
||||
{#each groups as group (group.ref)}
|
||||
<div class="commit-group">
|
||||
{#if group.branchName}
|
||||
<StackedBranchHeader upstreamName={group.branchName} />
|
||||
<PullRequestCard upstreamName={group.branchName} />
|
||||
{/if}
|
||||
<CommitList
|
||||
localCommits={group.localCommits}
|
||||
localAndRemoteCommits={group.remoteCommits}
|
||||
integratedCommits={group.integratedCommits}
|
||||
remoteCommits={[]}
|
||||
isUnapplied={false}
|
||||
{localCommitsConflicted}
|
||||
{localAndRemoteCommitsConflicted}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<CommitList
|
||||
localCommits={$localCommits}
|
||||
localAndRemoteCommits={$localAndRemoteCommits}
|
||||
integratedCommits={$integratedCommits}
|
||||
remoteCommits={$remoteCommits}
|
||||
isUnapplied={false}
|
||||
{localCommitsConflicted}
|
||||
{localAndRemoteCommitsConflicted}
|
||||
{pushButton}
|
||||
/>
|
||||
{/if}
|
||||
{#if $stackingFeature}
|
||||
{@render pushButton({
|
||||
disabled: localCommitsConflicted || localAndRemoteCommitsConflicted
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
@ -231,8 +323,19 @@
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
.card-no-stacking {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--clr-border-2);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--clr-bg-1);
|
||||
}
|
||||
|
||||
.card-stacking {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.branch-card__files {
|
||||
@ -278,4 +381,12 @@
|
||||
height: 100%;
|
||||
background-color: var(--clr-border-2);
|
||||
}
|
||||
|
||||
.commit-group {
|
||||
margin: 10px 0;
|
||||
border: 1px solid var(--clr-border-2);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--clr-bg-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
@ -2,11 +2,13 @@
|
||||
import ActiveBranchStatus from './ActiveBranchStatus.svelte';
|
||||
import BranchLabel from './BranchLabel.svelte';
|
||||
import BranchLaneContextMenu from './BranchLaneContextMenu.svelte';
|
||||
import DefaultTargetButton from './DefaultTargetButton.svelte';
|
||||
import PullRequestButton from '../pr/PullRequestButton.svelte';
|
||||
import { Project } from '$lib/backend/projects';
|
||||
import { BaseBranch } from '$lib/baseBranch/baseBranch';
|
||||
import { BaseBranchService } from '$lib/baseBranch/baseBranchService';
|
||||
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
|
||||
import { stackingFeature } from '$lib/config/uiFeatureFlags';
|
||||
import { mapErrorToToast } from '$lib/gitHost/github/errorMap';
|
||||
import { getGitHost } from '$lib/gitHost/interface/gitHost';
|
||||
import { getGitHostListingService } from '$lib/gitHost/interface/gitHostListingService';
|
||||
@ -140,7 +142,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
await $prService.createPr(title, body, opts.draft, upstreamBranchName, baseBranchName);
|
||||
await $prService.createPr({
|
||||
title,
|
||||
body,
|
||||
draft: opts.draft,
|
||||
baseBranchName,
|
||||
upstreamName: upstreamBranchName
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
const toast = mapErrorToToast(err);
|
||||
@ -216,90 +224,101 @@
|
||||
<Icon name="draggable" />
|
||||
</div>
|
||||
|
||||
<div class="header__info">
|
||||
<div class:header__info={!$stackingFeature} class:stacking-header__info={$stackingFeature}>
|
||||
<BranchLabel name={branch.name} onChange={(name) => handleBranchNameChange(name)} />
|
||||
<div class="header__remote-branch">
|
||||
<ActiveBranchStatus
|
||||
{hasIntegratedCommits}
|
||||
remoteExists={!!branch.upstream}
|
||||
isLaneCollapsed={$isLaneCollapsed}
|
||||
/>
|
||||
{#if $stackingFeature}
|
||||
<span class="button-group">
|
||||
<DefaultTargetButton
|
||||
selectedForChanges={branch.selectedForChanges}
|
||||
onclick={async () => {
|
||||
isTargetBranchAnimated = true;
|
||||
await branchController.setSelectedForChanges(branch.id);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
bind:el={meatballButtonEl}
|
||||
style="ghost"
|
||||
icon="kebab"
|
||||
onclick={() => {
|
||||
contextMenu?.toggle();
|
||||
}}
|
||||
/>
|
||||
<BranchLaneContextMenu
|
||||
bind:contextMenuEl={contextMenu}
|
||||
target={meatballButtonEl}
|
||||
onCollapse={collapseLane}
|
||||
{onGenerateBranchName}
|
||||
/>
|
||||
</span>
|
||||
{:else}
|
||||
<div class="header__remote-branch">
|
||||
<ActiveBranchStatus
|
||||
{hasIntegratedCommits}
|
||||
remoteExists={!!branch.upstream}
|
||||
isLaneCollapsed={$isLaneCollapsed}
|
||||
/>
|
||||
|
||||
{#await branch.isMergeable then isMergeable}
|
||||
{#if !isMergeable}
|
||||
<Button
|
||||
size="tag"
|
||||
clickable={false}
|
||||
icon="locked-small"
|
||||
style="warning"
|
||||
tooltip="Applying this branch will add merge conflict markers that you will have to resolve"
|
||||
>
|
||||
Conflict
|
||||
</Button>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
{#await branch.isMergeable then isMergeable}
|
||||
{#if !isMergeable}
|
||||
<Button
|
||||
size="tag"
|
||||
clickable={false}
|
||||
icon="locked-small"
|
||||
style="warning"
|
||||
tooltip="Applying this branch will add merge conflict markers that you will have to resolve"
|
||||
>
|
||||
Conflict
|
||||
</Button>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header__actions">
|
||||
<div class="header__buttons">
|
||||
{#if branch.selectedForChanges}
|
||||
<Button
|
||||
style="pop"
|
||||
kind="soft"
|
||||
tooltip="New changes will land here"
|
||||
icon="target"
|
||||
clickable={false}
|
||||
>
|
||||
Default branch
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
style="ghost"
|
||||
outline
|
||||
tooltip="When selected, new changes land here"
|
||||
icon="target"
|
||||
{#if !$stackingFeature}
|
||||
<div class="header__actions">
|
||||
<div class="header__buttons">
|
||||
<DefaultTargetButton
|
||||
selectedForChanges={branch.selectedForChanges}
|
||||
onclick={async () => {
|
||||
isTargetBranchAnimated = true;
|
||||
await branchController.setSelectedForChanges(branch.id);
|
||||
}}
|
||||
>
|
||||
Set as default
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="header__buttons">
|
||||
{#if !$pr}
|
||||
<PullRequestButton
|
||||
click={async ({ draft }) => await createPr({ draft })}
|
||||
disabled={branch.commits.length === 0 || !$gitHost || !$prService}
|
||||
tooltip={!$gitHost || !$prService
|
||||
? 'You can enable git host integration in the settings'
|
||||
: ''}
|
||||
loading={isLoading}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
bind:el={meatballButtonEl}
|
||||
style="ghost"
|
||||
outline
|
||||
icon="kebab"
|
||||
onclick={() => {
|
||||
contextMenu?.toggle();
|
||||
}}
|
||||
/>
|
||||
<BranchLaneContextMenu
|
||||
bind:contextMenuEl={contextMenu}
|
||||
target={meatballButtonEl}
|
||||
onCollapse={collapseLane}
|
||||
{onGenerateBranchName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="header__buttons">
|
||||
{#if !$pr}
|
||||
<PullRequestButton
|
||||
click={async ({ draft }) => await createPr({ draft })}
|
||||
disabled={branch.commits.length === 0 || !$gitHost || !$prService}
|
||||
tooltip={!$gitHost || !$prService
|
||||
? 'You can enable git host integration in the settings'
|
||||
: ''}
|
||||
loading={isLoading}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
bind:el={meatballButtonEl}
|
||||
style="ghost"
|
||||
outline
|
||||
icon="kebab"
|
||||
onclick={() => {
|
||||
contextMenu?.toggle();
|
||||
}}
|
||||
/>
|
||||
<BranchLaneContextMenu
|
||||
bind:contextMenuEl={contextMenu}
|
||||
target={meatballButtonEl}
|
||||
onCollapse={collapseLane}
|
||||
{onGenerateBranchName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="header__top-overlay" data-remove-from-draggable data-tauri-drag-region></div>
|
||||
</div>
|
||||
@ -370,6 +389,20 @@
|
||||
overflow: hidden;
|
||||
gap: 10px;
|
||||
}
|
||||
/* TODO: Remove me after stacking feature toggle has been removed. */
|
||||
.stacking-header__info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header__actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
@ -19,7 +19,7 @@
|
||||
createRemoteCommitsContextStore
|
||||
} from '$lib/vbranches/contexts';
|
||||
import { FileIdSelection } from '$lib/vbranches/fileIdSelection';
|
||||
import { Ownership } from '$lib/vbranches/ownership';
|
||||
import { SelectedOwnership } from '$lib/vbranches/ownership';
|
||||
import { RemoteFile, VirtualBranch } from '$lib/vbranches/types';
|
||||
import lscache from 'lscache';
|
||||
import { setContext } from 'svelte';
|
||||
@ -55,12 +55,15 @@
|
||||
|
||||
// BRANCH
|
||||
const branchStore = createContextStore(VirtualBranch, branch);
|
||||
const ownershipStore = createContextStore(Ownership, Ownership.fromBranch(branch));
|
||||
const selectedOwnershipStore = createContextStore(
|
||||
SelectedOwnership,
|
||||
SelectedOwnership.fromBranch(branch)
|
||||
);
|
||||
const branchFiles = writable(branch.files);
|
||||
|
||||
$effect(() => {
|
||||
branchStore.set(branch);
|
||||
ownershipStore.set(Ownership.fromBranch(branch));
|
||||
selectedOwnershipStore.update((o) => o?.update(branch));
|
||||
branchFiles.set(branch.files);
|
||||
});
|
||||
|
||||
|
34
apps/desktop/src/lib/branch/DefaultTargetButton.svelte
Normal file
34
apps/desktop/src/lib/branch/DefaultTargetButton.svelte
Normal 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}
|
53
apps/desktop/src/lib/branch/StackedBranchHeader.svelte
Normal file
53
apps/desktop/src/lib/branch/StackedBranchHeader.svelte
Normal 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>
|
@ -4,7 +4,7 @@
|
||||
import { BaseBranch } from '$lib/baseBranch/baseBranch';
|
||||
import CommitMessageInput from '$lib/commit/CommitMessageInput.svelte';
|
||||
import { persistedCommitMessage } from '$lib/config/config';
|
||||
import { featureBranchStacking } from '$lib/config/uiFeatureFlags';
|
||||
import { stackingFeature } from '$lib/config/uiFeatureFlags';
|
||||
import { draggableCommit } from '$lib/dragging/draggable';
|
||||
import { DraggableCommit, nonDraggable } from '$lib/dragging/draggables';
|
||||
import BranchFilesList from '$lib/file/BranchFilesList.svelte';
|
||||
@ -50,8 +50,6 @@
|
||||
|
||||
const currentCommitMessage = persistedCommitMessage(project.id, branch?.id || '');
|
||||
|
||||
const branchStacking = featureBranchStacking();
|
||||
|
||||
let draggableCommitElement: HTMLElement | null = null;
|
||||
let files: RemoteFile[] = [];
|
||||
let showDetails = false;
|
||||
@ -349,18 +347,8 @@
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<span class="commit__subtitle-divider">•</span>
|
||||
|
||||
<span>{getTimeAndAuthor()}</span>
|
||||
|
||||
{#if $branchStacking && commit instanceof DetailedCommit}
|
||||
<div
|
||||
style="background-color:var(--clr-core-pop-80); border-radius: 3px; padding: 2px;"
|
||||
>
|
||||
{commit?.remoteRef}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@ -399,7 +387,7 @@
|
||||
icon="edit-small"
|
||||
onclick={openCommitMessageModal}>Edit message</Button
|
||||
>
|
||||
{#if $branchStacking && commit instanceof DetailedCommit && !commit.remoteRef}
|
||||
{#if $stackingFeature && commit instanceof DetailedCommit && !commit.remoteRef}
|
||||
<Button
|
||||
size="tag"
|
||||
style="ghost"
|
||||
@ -408,7 +396,7 @@
|
||||
onclick={(e: Event) => {openCreateRefModal(e, commit)}}>Create ref</Button
|
||||
>
|
||||
{/if}
|
||||
{#if $branchStacking && commit instanceof DetailedCommit && commit.remoteRef}
|
||||
{#if $stackingFeature && commit instanceof DetailedCommit && commit.remoteRef}
|
||||
<Button
|
||||
size="tag"
|
||||
style="ghost"
|
||||
|
@ -4,7 +4,7 @@
|
||||
import { getContext, getContextStore } from '$lib/utils/context';
|
||||
import { intersectionObserver } from '$lib/utils/intersectionObserver';
|
||||
import { BranchController } from '$lib/vbranches/branchController';
|
||||
import { Ownership } from '$lib/vbranches/ownership';
|
||||
import { SelectedOwnership } from '$lib/vbranches/ownership';
|
||||
import { VirtualBranch } from '$lib/vbranches/types';
|
||||
import Button from '@gitbutler/ui/Button.svelte';
|
||||
import { slideFade } from '@gitbutler/ui/utils/transitions';
|
||||
@ -15,7 +15,7 @@
|
||||
export let hasSectionsAfter: boolean;
|
||||
|
||||
const branchController = getContext(BranchController);
|
||||
const selectedOwnership = getContextStore(Ownership);
|
||||
const selectedOwnership = getContextStore(SelectedOwnership);
|
||||
const branch = getContextStore(VirtualBranch);
|
||||
|
||||
const runCommitHooks = projectRunCommitHooks(projectId);
|
||||
@ -88,7 +88,8 @@
|
||||
outline={!$expanded}
|
||||
grow
|
||||
loading={isCommitting}
|
||||
disabled={(isCommitting || !commitMessageValid || $selectedOwnership.isEmpty()) && $expanded}
|
||||
disabled={(isCommitting || !commitMessageValid || $selectedOwnership.nothingSelected()) &&
|
||||
$expanded}
|
||||
id="commit-to-branch"
|
||||
onclick={() => {
|
||||
if ($expanded) {
|
||||
|
@ -5,6 +5,7 @@
|
||||
import { BaseBranch } from '$lib/baseBranch/baseBranch';
|
||||
import { transformAnyCommit } from '$lib/commitLines/transformers';
|
||||
import InsertEmptyCommitAction from '$lib/components/InsertEmptyCommitAction.svelte';
|
||||
import { stackingFeature } from '$lib/config/uiFeatureFlags';
|
||||
import {
|
||||
ReorderDropzoneManagerFactory,
|
||||
type ReorderDropzone
|
||||
@ -12,97 +13,107 @@
|
||||
import Dropzone from '$lib/dropzone/Dropzone.svelte';
|
||||
import LineOverlay from '$lib/dropzone/LineOverlay.svelte';
|
||||
import { getGitHost } from '$lib/gitHost/interface/gitHost';
|
||||
import { getGitHostChecksMonitor } from '$lib/gitHost/interface/gitHostChecksMonitor';
|
||||
import { getGitHostListingService } from '$lib/gitHost/interface/gitHostListingService';
|
||||
import { getGitHostPrMonitor } from '$lib/gitHost/interface/gitHostPrMonitor';
|
||||
import { getContext } from '$lib/utils/context';
|
||||
import { getContextStore } from '$lib/utils/context';
|
||||
import { BranchController } from '$lib/vbranches/branchController';
|
||||
import {
|
||||
getIntegratedCommits,
|
||||
getLocalCommits,
|
||||
getLocalAndRemoteCommits,
|
||||
getRemoteCommits
|
||||
} from '$lib/vbranches/contexts';
|
||||
import { VirtualBranch } from '$lib/vbranches/types';
|
||||
import { Commit, DetailedCommit, VirtualBranch } from '$lib/vbranches/types';
|
||||
import Button from '@gitbutler/ui/Button.svelte';
|
||||
import LineGroup from '@gitbutler/ui/commitLines/LineGroup.svelte';
|
||||
import { LineManagerFactory } from '@gitbutler/ui/commitLines/lineManager';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
export let isUnapplied: boolean;
|
||||
interface Props {
|
||||
localCommits: DetailedCommit[];
|
||||
localAndRemoteCommits: DetailedCommit[];
|
||||
integratedCommits: DetailedCommit[];
|
||||
remoteCommits: Commit[];
|
||||
isUnapplied: boolean;
|
||||
pushButton?: Snippet<[{ disabled: boolean }]>;
|
||||
localCommitsConflicted: boolean;
|
||||
localAndRemoteCommitsConflicted: boolean;
|
||||
}
|
||||
const {
|
||||
localCommits,
|
||||
localAndRemoteCommits,
|
||||
integratedCommits,
|
||||
remoteCommits,
|
||||
isUnapplied,
|
||||
localCommitsConflicted,
|
||||
pushButton,
|
||||
localAndRemoteCommitsConflicted
|
||||
}: Props = $props();
|
||||
|
||||
const branch = getContextStore(VirtualBranch);
|
||||
const localCommits = getLocalCommits();
|
||||
const localAndRemoteCommits = getLocalAndRemoteCommits();
|
||||
const remoteCommits = getRemoteCommits();
|
||||
const integratedCommits = getIntegratedCommits();
|
||||
const baseBranch = getContextStore(BaseBranch);
|
||||
const project = getContext(Project);
|
||||
const branchController = getContext(BranchController);
|
||||
const lineManagerFactory = getContext(LineManagerFactory);
|
||||
//
|
||||
const listingService = getGitHostListingService();
|
||||
const prMonitor = getGitHostPrMonitor();
|
||||
const checksMonitor = getGitHostChecksMonitor();
|
||||
|
||||
const reorderDropzoneManagerFactory = getContext(ReorderDropzoneManagerFactory);
|
||||
const gitHost = getGitHost();
|
||||
|
||||
// TODO: Why does eslint-svelte-plugin complain about enum?
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
enum LineSpacer {
|
||||
Remote = 'remote-spacer',
|
||||
Local = 'local-spacer',
|
||||
LocalAndRemote = 'local-and-remote-spacer'
|
||||
}
|
||||
|
||||
$: mappedRemoteCommits =
|
||||
$remoteCommits.length > 0
|
||||
? [...$remoteCommits.map(transformAnyCommit), { id: LineSpacer.Remote }]
|
||||
: [];
|
||||
$: mappedLocalCommits =
|
||||
$localCommits.length > 0
|
||||
? [...$localCommits.map(transformAnyCommit), { id: LineSpacer.Local }]
|
||||
: [];
|
||||
$: mappedLocalAndRemoteCommits =
|
||||
$localAndRemoteCommits.length > 0
|
||||
? [...$localAndRemoteCommits.map(transformAnyCommit), { id: LineSpacer.LocalAndRemote }]
|
||||
: [];
|
||||
const mappedRemoteCommits = $derived(
|
||||
remoteCommits.length > 0
|
||||
? [...remoteCommits.map(transformAnyCommit), { id: LineSpacer.Remote }]
|
||||
: []
|
||||
);
|
||||
|
||||
$: lineManager = lineManagerFactory.build(
|
||||
{
|
||||
remoteCommits: mappedRemoteCommits,
|
||||
localCommits: mappedLocalCommits,
|
||||
localAndRemoteCommits: mappedLocalAndRemoteCommits,
|
||||
integratedCommits: $integratedCommits.map(transformAnyCommit)
|
||||
},
|
||||
!isRebased
|
||||
const mappedLocalCommits = $derived(
|
||||
localCommits.length > 0
|
||||
? !$stackingFeature
|
||||
? [...localCommits.map(transformAnyCommit), { id: LineSpacer.Local }]
|
||||
: localCommits.map(transformAnyCommit)
|
||||
: []
|
||||
);
|
||||
const mappedLocalAndRemoteCommits = $derived(
|
||||
localAndRemoteCommits.length > 0
|
||||
? [...localAndRemoteCommits.map(transformAnyCommit), { id: LineSpacer.LocalAndRemote }]
|
||||
: []
|
||||
);
|
||||
|
||||
const forkPoint = $derived($branch.forkPoint);
|
||||
const upstreamForkPoint = $derived($branch.upstreamData?.forkPoint);
|
||||
const isRebased = $derived(!!forkPoint && !!upstreamForkPoint && forkPoint !== upstreamForkPoint);
|
||||
|
||||
const lineManager = $derived(
|
||||
lineManagerFactory.build(
|
||||
{
|
||||
remoteCommits: mappedRemoteCommits,
|
||||
localCommits: mappedLocalCommits,
|
||||
localAndRemoteCommits: mappedLocalAndRemoteCommits,
|
||||
integratedCommits: integratedCommits.map(transformAnyCommit)
|
||||
},
|
||||
!isRebased
|
||||
)
|
||||
);
|
||||
|
||||
// Force the "base" commit lines to update when $branch updates.
|
||||
let tsKey: number | undefined;
|
||||
$: {
|
||||
let tsKey = $state<number | undefined>(undefined);
|
||||
$effect(() => {
|
||||
$branch;
|
||||
tsKey = Date.now();
|
||||
}
|
||||
});
|
||||
|
||||
$: hasCommits = $branch.commits && $branch.commits.length > 0;
|
||||
$: headCommit = $branch.commits.at(0);
|
||||
const hasCommits = $derived($branch.commits && $branch.commits.length > 0);
|
||||
const headCommit = $derived($branch.commits.at(0));
|
||||
|
||||
$: hasRemoteCommits = $remoteCommits.length > 0;
|
||||
const hasRemoteCommits = $derived(remoteCommits.length > 0);
|
||||
|
||||
$: reorderDropzoneManager = reorderDropzoneManagerFactory.build($branch, [
|
||||
...$localCommits,
|
||||
...$localAndRemoteCommits
|
||||
]);
|
||||
const reorderDropzoneManager = $derived(
|
||||
reorderDropzoneManagerFactory.build($branch, [...localCommits, ...localAndRemoteCommits])
|
||||
);
|
||||
|
||||
$: forkPoint = $branch.forkPoint;
|
||||
$: upstreamForkPoint = $branch.upstreamData?.forkPoint;
|
||||
$: isRebased = !!forkPoint && !!upstreamForkPoint && forkPoint !== upstreamForkPoint;
|
||||
|
||||
$: isPushingCommits = false;
|
||||
$: isIntegratingCommits = false;
|
||||
|
||||
let baseIsUnfolded = false;
|
||||
let isIntegratingCommits = $state(false);
|
||||
let baseIsUnfolded = $state(false);
|
||||
|
||||
function insertBlankCommit(commitId: string, location: 'above' | 'below' = 'below') {
|
||||
if (!$branch || !$baseBranch) {
|
||||
@ -126,21 +137,6 @@
|
||||
if (isLast) return 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
$: localCommitsConflicted = $localCommits.some((commit) => commit.conflicted);
|
||||
$: localAndRemoteCommitsConflicted = $localAndRemoteCommits.some((commit) => commit.conflicted);
|
||||
|
||||
async function push() {
|
||||
isPushingCommits = true;
|
||||
try {
|
||||
await branchController.pushBranch($branch.id, $branch.requiresForce);
|
||||
$listingService?.refresh();
|
||||
$prMonitor?.refresh();
|
||||
$checksMonitor?.update();
|
||||
} finally {
|
||||
isPushingCommits = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet reorderDropzone(dropzone: ReorderDropzone, yOffsetPx: number)}
|
||||
@ -152,20 +148,20 @@
|
||||
{/snippet}
|
||||
|
||||
{#if hasCommits || hasRemoteCommits}
|
||||
<div class="commits">
|
||||
<div class="commits" class:stacked={$stackingFeature}>
|
||||
<!-- UPSTREAM COMMITS -->
|
||||
|
||||
{#if $remoteCommits.length > 0}
|
||||
{#if remoteCommits.length > 0}
|
||||
<!-- To make the sticky position work, commits should be wrapped in a div -->
|
||||
<div class="commits-group">
|
||||
{#each $remoteCommits as commit, idx (commit.id)}
|
||||
{#each remoteCommits as commit, idx (commit.id)}
|
||||
<CommitCard
|
||||
type="remote"
|
||||
branch={$branch}
|
||||
{commit}
|
||||
{isUnapplied}
|
||||
first={idx === 0}
|
||||
last={idx === $remoteCommits.length - 1}
|
||||
last={idx === remoteCommits.length - 1}
|
||||
commitUrl={$gitHost?.commitUrl(commit.id)}
|
||||
isHeadCommit={commit.id === headCommit?.id}
|
||||
>
|
||||
@ -201,7 +197,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- LOCAL COMMITS -->
|
||||
{#if $localCommits.length > 0}
|
||||
{#if localCommits.length > 0}
|
||||
<div class="commits-group">
|
||||
<InsertEmptyCommitAction
|
||||
isFirst
|
||||
@ -211,14 +207,14 @@
|
||||
reorderDropzoneManager.topDropzone,
|
||||
getReorderDropzoneOffset({ isFirst: true })
|
||||
)}
|
||||
{#each $localCommits as commit, idx (commit.id)}
|
||||
{#each localCommits as commit, idx (commit.id)}
|
||||
<CommitCard
|
||||
{commit}
|
||||
{isUnapplied}
|
||||
type="local"
|
||||
first={idx === 0}
|
||||
branch={$branch}
|
||||
last={idx === $localCommits.length - 1}
|
||||
last={idx === localCommits.length - 1}
|
||||
isHeadCommit={commit.id === headCommit?.id}
|
||||
>
|
||||
{#snippet lines(topHeightPx)}
|
||||
@ -229,52 +225,40 @@
|
||||
{@render reorderDropzone(
|
||||
reorderDropzoneManager.dropzoneBelowCommit(commit.id),
|
||||
getReorderDropzoneOffset({
|
||||
isLast: idx + 1 === $localCommits.length,
|
||||
isMiddle: idx + 1 === $localCommits.length
|
||||
isLast: idx + 1 === localCommits.length,
|
||||
isMiddle: idx + 1 === localCommits.length
|
||||
})
|
||||
)}
|
||||
|
||||
<InsertEmptyCommitAction
|
||||
isLast={idx + 1 === $localCommits.length}
|
||||
isLast={idx + 1 === localCommits.length}
|
||||
on:click={() => insertBlankCommit(commit.id, 'below')}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#snippet lines()}
|
||||
<LineGroup lineGroup={lineManager.get(LineSpacer.Local)} topHeightPx={0} />
|
||||
{/snippet}
|
||||
|
||||
<CommitAction bottomBorder={hasRemoteCommits} {lines}>
|
||||
{#snippet action()}
|
||||
<Button
|
||||
style="pop"
|
||||
kind="solid"
|
||||
wide
|
||||
loading={isPushingCommits}
|
||||
disabled={localCommitsConflicted}
|
||||
tooltip={localCommitsConflicted
|
||||
? 'In order to push, please resolve any conflicted commits.'
|
||||
: undefined}
|
||||
onclick={push}
|
||||
>
|
||||
{$branch.requiresForce ? 'Force push' : 'Push'}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</CommitAction>
|
||||
{#if !$stackingFeature && pushButton}
|
||||
<CommitAction bottomBorder={hasRemoteCommits}>
|
||||
{#snippet lines()}
|
||||
<LineGroup lineGroup={lineManager.get(LineSpacer.Local)} topHeightPx={0} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
{@render pushButton({ disabled: localCommitsConflicted })}
|
||||
{/snippet}
|
||||
</CommitAction>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- LOCAL AND REMOTE COMMITS -->
|
||||
{#if $localAndRemoteCommits.length > 0}
|
||||
{#if localAndRemoteCommits.length > 0}
|
||||
<div class="commits-group">
|
||||
{#each $localAndRemoteCommits as commit, idx (commit.id)}
|
||||
{#each localAndRemoteCommits as commit, idx (commit.id)}
|
||||
<CommitCard
|
||||
{commit}
|
||||
{isUnapplied}
|
||||
type="localAndRemote"
|
||||
first={idx === 0}
|
||||
branch={$branch}
|
||||
last={idx === $localAndRemoteCommits.length - 1}
|
||||
last={idx === localAndRemoteCommits.length - 1}
|
||||
isHeadCommit={commit.id === headCommit?.id}
|
||||
commitUrl={$gitHost?.commitUrl(commit.id)}
|
||||
>
|
||||
@ -285,34 +269,22 @@
|
||||
{@render reorderDropzone(
|
||||
reorderDropzoneManager.dropzoneBelowCommit(commit.id),
|
||||
getReorderDropzoneOffset({
|
||||
isMiddle: idx + 1 === $localAndRemoteCommits.length
|
||||
isMiddle: idx + 1 === localAndRemoteCommits.length
|
||||
})
|
||||
)}
|
||||
<InsertEmptyCommitAction
|
||||
isLast={idx + 1 === $localAndRemoteCommits.length}
|
||||
isLast={idx + 1 === localAndRemoteCommits.length}
|
||||
on:click={() => insertBlankCommit(commit.id, 'below')}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if $remoteCommits.length > 0 && $localCommits.length === 0}
|
||||
{#if remoteCommits.length > 0 && localCommits.length === 0 && pushButton}
|
||||
<CommitAction>
|
||||
{#snippet lines()}
|
||||
<LineGroup lineGroup={lineManager.get(LineSpacer.LocalAndRemote)} topHeightPx={0} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<Button
|
||||
style="pop"
|
||||
kind="solid"
|
||||
wide
|
||||
loading={isPushingCommits}
|
||||
disabled={localAndRemoteCommitsConflicted}
|
||||
tooltip={localAndRemoteCommitsConflicted
|
||||
? 'In order to push, please resolve any conflicted commits.'
|
||||
: undefined}
|
||||
onclick={push}
|
||||
>
|
||||
{$branch.requiresForce ? 'Force push' : 'Push'}
|
||||
</Button>
|
||||
{@render pushButton({ disabled: localAndRemoteCommitsConflicted })}
|
||||
{/snippet}
|
||||
</CommitAction>
|
||||
{/if}
|
||||
@ -320,9 +292,9 @@
|
||||
{/if}
|
||||
|
||||
<!-- INTEGRATED COMMITS -->
|
||||
{#if $integratedCommits.length > 0}
|
||||
{#if integratedCommits.length > 0}
|
||||
<div class="commits-group">
|
||||
{#each $integratedCommits as commit, idx (commit.id)}
|
||||
{#each integratedCommits as commit, idx (commit.id)}
|
||||
<CommitCard
|
||||
{commit}
|
||||
{isUnapplied}
|
||||
@ -330,7 +302,7 @@
|
||||
first={idx === 0}
|
||||
branch={$branch}
|
||||
isHeadCommit={commit.id === headCommit?.id}
|
||||
last={idx === $integratedCommits.length - 1}
|
||||
last={idx === integratedCommits.length - 1}
|
||||
commitUrl={$gitHost?.commitUrl(commit.id)}
|
||||
>
|
||||
{#snippet lines(topHeightPx)}
|
||||
@ -347,8 +319,11 @@
|
||||
class="base-row"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
on:click|stopPropagation={() => (baseIsUnfolded = !baseIsUnfolded)}
|
||||
on:keydown={(e) => e.key === 'Enter' && (baseIsUnfolded = !baseIsUnfolded)}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
baseIsUnfolded = !baseIsUnfolded;
|
||||
}}
|
||||
onkeydown={(e) => e.key === 'Enter' && (baseIsUnfolded = !baseIsUnfolded)}
|
||||
>
|
||||
<div class="base-row__lines">
|
||||
{#key tsKey}
|
||||
@ -359,7 +334,7 @@
|
||||
<span class="text-11 base-row__text"
|
||||
>Base commit <button
|
||||
class="base-row__commit-link"
|
||||
on:click={async () => await goto(`/${project.id}/base`)}
|
||||
onclick={async () => await goto(`/${project.id}/base`)}
|
||||
>
|
||||
{$branch.forkPoint ? $branch.forkPoint.slice(0, 7) : ''}
|
||||
</button>
|
||||
@ -390,6 +365,10 @@
|
||||
--avatar-top: 16px;
|
||||
}
|
||||
|
||||
.commits.stacked {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* BASE ROW */
|
||||
|
||||
.base-row-container {
|
||||
|
@ -19,7 +19,7 @@
|
||||
import { KeyName } from '$lib/utils/hotkeys';
|
||||
import { resizeObserver } from '$lib/utils/resizeObserver';
|
||||
import { isWhiteSpaceString } from '$lib/utils/string';
|
||||
import { Ownership } from '$lib/vbranches/ownership';
|
||||
import { SelectedOwnership } from '$lib/vbranches/ownership';
|
||||
import { VirtualBranch, LocalFile } from '$lib/vbranches/types';
|
||||
import Checkbox from '@gitbutler/ui/Checkbox.svelte';
|
||||
import Icon from '@gitbutler/ui/Icon.svelte';
|
||||
@ -33,7 +33,7 @@
|
||||
export let commit: (() => void) | undefined = undefined;
|
||||
|
||||
const user = getContextStore(User);
|
||||
const selectedOwnership = getContextStore(Ownership);
|
||||
const selectedOwnership = getContextStore(SelectedOwnership);
|
||||
const aiService = getContext(AIService);
|
||||
const branch = getContextStore(VirtualBranch);
|
||||
const project = getContext(Project);
|
||||
@ -71,7 +71,7 @@
|
||||
|
||||
async function generateCommitMessage(files: LocalFile[]) {
|
||||
const hunks = files.flatMap((f) =>
|
||||
f.hunks.filter((h) => $selectedOwnership.contains(f.id, h.id))
|
||||
f.hunks.filter((h) => $selectedOwnership.isSelected(f.id, h.id))
|
||||
);
|
||||
// Branches get their names generated only if there are at least 4 lines of code
|
||||
// If the change is a 'one-liner', the branch name is either left as "virtual branch"
|
||||
|
@ -70,7 +70,7 @@
|
||||
}}
|
||||
></textarea>
|
||||
{:else}
|
||||
<div class="markdown bubble-message scrollbar text-13 text-body">
|
||||
<div class="bubble-message scrollbar text-13 text-body">
|
||||
<Markdown content={promptMessage.content} />
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Spacer from '../shared/Spacer.svelte';
|
||||
import { Project } from '$lib/backend/projects';
|
||||
import CommitCard from '$lib/commit/CommitCard.svelte';
|
||||
import UpdateBaseButton from '$lib/components/UpdateBaseButton.svelte';
|
||||
import { projectMergeUpstreamWarningDismissed } from '$lib/config/config';
|
||||
import { getGitHost } from '$lib/gitHost/interface/gitHost';
|
||||
import { ModeService } from '$lib/modes/service';
|
||||
@ -18,6 +20,7 @@
|
||||
const branchController = getContext(BranchController);
|
||||
const modeService = getContext(ModeService);
|
||||
const gitHost = getGitHost();
|
||||
const project = getContext(Project);
|
||||
|
||||
const mode = modeService.mode;
|
||||
|
||||
@ -36,6 +39,8 @@
|
||||
showInfo('Stashed conflicting branches', infoText);
|
||||
}
|
||||
}
|
||||
|
||||
let updateBaseButton: UpdateBaseButton | undefined;
|
||||
</script>
|
||||
|
||||
<div class="wrapper">
|
||||
@ -46,16 +51,21 @@
|
||||
</div>
|
||||
|
||||
{#if base.upstreamCommits?.length > 0}
|
||||
<UpdateBaseButton bind:this={updateBaseButton} showButton={false} />
|
||||
<Button
|
||||
style="pop"
|
||||
kind="solid"
|
||||
tooltip={`Merges the commits from ${base.branchName} into the base of all applied virtual branches`}
|
||||
disabled={$mode?.type !== 'OpenWorkspace'}
|
||||
onclick={() => {
|
||||
if ($mergeUpstreamWarningDismissed) {
|
||||
updateBaseBranch();
|
||||
if (project.succeedingRebases) {
|
||||
updateBaseButton?.openModal();
|
||||
} else {
|
||||
updateTargetModal.show();
|
||||
if ($mergeUpstreamWarningDismissed) {
|
||||
updateBaseBranch();
|
||||
} else {
|
||||
updateTargetModal.show();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@ -3,9 +3,11 @@
|
||||
import FullviewLoading from './FullviewLoading.svelte';
|
||||
import BranchDropzone from '$lib/branch/BranchDropzone.svelte';
|
||||
import BranchLane from '$lib/branch/BranchLane.svelte';
|
||||
import { stackingFeature } from '$lib/config/uiFeatureFlags';
|
||||
import { cloneElement } from '$lib/dragging/draggable';
|
||||
import { persisted } from '$lib/persisted/persisted';
|
||||
import { getContext } from '$lib/utils/context';
|
||||
import { createKeybind } from '$lib/utils/hotkeys';
|
||||
import { throttle } from '$lib/utils/misc';
|
||||
import { BranchController } from '$lib/vbranches/branchController';
|
||||
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
|
||||
@ -62,8 +64,15 @@
|
||||
sortedBranches = sortedBranches; // Redraws #each loop.
|
||||
}
|
||||
}, 200);
|
||||
|
||||
const handleKeyDown = createKeybind({
|
||||
's t a c k': async () => {
|
||||
$stackingFeature = !$stackingFeature;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeyDown} />
|
||||
{#if $error}
|
||||
<div class="p-4" data-tauri-drag-region>Something went wrong...</div>
|
||||
{:else if !$branches}
|
||||
|
@ -108,7 +108,7 @@
|
||||
<div class="card">
|
||||
<div class="card__header text-14 text-body text-semibold">{pr.title}</div>
|
||||
{#if pr.body}
|
||||
<div class="markdown card__content text-13 text-body">
|
||||
<div class="card__content text-13 text-body">
|
||||
<Markdown content={pr.body} />
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -4,21 +4,19 @@
|
||||
import { Lexer } from 'marked';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
content: string | undefined;
|
||||
}
|
||||
|
||||
let { content }: Props = $props();
|
||||
|
||||
const lexer = new Lexer(options);
|
||||
const tokens = lexer.lex(content);
|
||||
const tokens = $derived.by(() => {
|
||||
const lexer = new Lexer(options);
|
||||
return lexer.lex(content ?? '');
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="markdown-content">
|
||||
<MarkdownContent type="init" {tokens} />
|
||||
<div class="markdown">
|
||||
{#if tokens}
|
||||
<MarkdownContent type="init" {tokens} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.markdown-content {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,32 +1,49 @@
|
||||
<script lang="ts">
|
||||
/* eslint svelte/valid-compile: "off" */
|
||||
/* - Required because spreading in prop destructuring still throws eslint errors */
|
||||
import { renderers } from '$lib/utils/markdownRenderers';
|
||||
import type { Tokens, Token } from 'marked';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
type Props =
|
||||
| { type: 'init'; tokens: Token[] }
|
||||
| Tokens.Link
|
||||
| Tokens.Heading
|
||||
| Tokens.Image
|
||||
| Tokens.Space
|
||||
| Tokens.Blockquote
|
||||
| Tokens.Code
|
||||
| Tokens.Text
|
||||
| Tokens.Codespan
|
||||
| Tokens.Text;
|
||||
| Tokens.Paragraph
|
||||
| Tokens.ListItem
|
||||
| Tokens.List;
|
||||
|
||||
let { type, ...rest }: Props = $props();
|
||||
const { type, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if type && renderers[type as keyof typeof renderers]}
|
||||
<svelte:component this={renderers[type as keyof typeof renderers] as any} {...rest}>
|
||||
{#if 'tokens' in rest}
|
||||
<svelte:self tokens={rest.tokens} />
|
||||
{/if}
|
||||
</svelte:component>
|
||||
{:else if 'tokens' in rest && rest.tokens}
|
||||
{#if (!type || type === 'init') && 'tokens' in rest && rest.tokens}
|
||||
{#each rest.tokens as token}
|
||||
<svelte:self {...token} />
|
||||
{/each}
|
||||
{:else if 'raw' in rest}
|
||||
{@html rest.raw?.replaceAll('\n', '<br />') ?? ''}
|
||||
{:else if renderers[type]}
|
||||
{@const CurrentComponent = renderers[type] as Component<Props>}
|
||||
{#if type === 'list'}
|
||||
{@const listItems = (rest as Extract<Props, { type: 'list' }>).items}
|
||||
<CurrentComponent {...rest}>
|
||||
{#each listItems as item}
|
||||
{@const ChildComponent = renderers[item.type]}
|
||||
<ChildComponent {...item}>
|
||||
<svelte:self tokens={item.tokens} />
|
||||
</ChildComponent>
|
||||
{/each}
|
||||
</CurrentComponent>
|
||||
{:else}
|
||||
<CurrentComponent {...rest}>
|
||||
{#if 'tokens' in rest && rest.tokens}
|
||||
<svelte:self tokens={rest.tokens} />
|
||||
{:else if 'raw' in rest}
|
||||
{rest.raw}
|
||||
{/if}
|
||||
</CurrentComponent>
|
||||
{/if}
|
||||
{/if}
|
||||
|
@ -127,9 +127,7 @@
|
||||
</span>
|
||||
</div>
|
||||
{#if pullrequest.body}
|
||||
<div class="markdown">
|
||||
<Markdown content={pullrequest.body} />
|
||||
</div>
|
||||
<Markdown content={pullrequest.body} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
|
@ -1,36 +1,256 @@
|
||||
<script lang="ts">
|
||||
import { showInfo, showError } from '$lib/notifications/toasts';
|
||||
import { Project } from '$lib/backend/projects';
|
||||
import { BaseBranchService } from '$lib/baseBranch/baseBranchService';
|
||||
import CommitCard from '$lib/commit/CommitCard.svelte';
|
||||
import { getGitHost } from '$lib/gitHost/interface/gitHost';
|
||||
import { showInfo } from '$lib/notifications/toasts';
|
||||
import Select from '$lib/select/Select.svelte';
|
||||
import SelectItem from '$lib/select/SelectItem.svelte';
|
||||
import { getContext } from '$lib/utils/context';
|
||||
import { BranchController } from '$lib/vbranches/branchController';
|
||||
import { VirtualBranch } from '$lib/vbranches/types';
|
||||
import {
|
||||
UpstreamIntegrationService,
|
||||
type BranchStatus,
|
||||
type BranchStatusesWithBranches,
|
||||
type Resolution,
|
||||
type ResolutionApproach
|
||||
} from '$lib/vbranches/upstreamIntegrationService';
|
||||
import Button from '@gitbutler/ui/Button.svelte';
|
||||
import Modal from '@gitbutler/ui/Modal.svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type { Readable } from 'svelte/store';
|
||||
|
||||
interface Props {
|
||||
showButton?: boolean;
|
||||
}
|
||||
|
||||
const { showButton = true }: Props = $props();
|
||||
|
||||
const upstreamIntegrationService = getContext(UpstreamIntegrationService);
|
||||
const baseBranchService = getContext(BaseBranchService);
|
||||
const gitHost = getGitHost();
|
||||
const branchController = getContext(BranchController);
|
||||
const project = getContext(Project);
|
||||
|
||||
let loading = false;
|
||||
const base = baseBranchService.base;
|
||||
|
||||
let modal = $state<Modal>();
|
||||
|
||||
let modalOpeningState = $state<'inert' | 'loading' | 'completed'>('inert');
|
||||
let branchStatuses = $state<Readable<BranchStatusesWithBranches | undefined>>();
|
||||
|
||||
let results = $state(new SvelteMap<string, Resolution>());
|
||||
|
||||
let statuses = $state<{ branch: VirtualBranch; status: BranchStatus }[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if ($branchStatuses?.type !== 'updatesRequired') {
|
||||
statuses = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const statusesTmp = [...$branchStatuses.subject];
|
||||
statusesTmp.sort((a, b) => {
|
||||
if (
|
||||
(a.status.type !== 'fullyIntegrated' && b.status.type !== 'fullyIntegrated') ||
|
||||
(a.status.type === 'fullyIntegrated' && b.status.type === 'fullyIntegrated')
|
||||
) {
|
||||
return (a.branch?.name || 'Unknown').localeCompare(b.branch?.name || 'Unknown');
|
||||
}
|
||||
|
||||
if (a.status.type === 'fullyIntegrated') {
|
||||
return 1;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
});
|
||||
|
||||
// Side effect, refresh results
|
||||
results = new SvelteMap(
|
||||
statusesTmp.map((status) => {
|
||||
let defaultApproach: ResolutionApproach;
|
||||
|
||||
if (status.status.type === 'fullyIntegrated') {
|
||||
defaultApproach = { type: 'delete' };
|
||||
} else {
|
||||
if (status.branch.allowRebasing) {
|
||||
defaultApproach = { type: 'rebase' };
|
||||
} else {
|
||||
defaultApproach = { type: 'merge' };
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
status.branch.id,
|
||||
{
|
||||
branchId: status.branch.id,
|
||||
branchTree: status.branch.tree,
|
||||
approach: defaultApproach
|
||||
}
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
statuses = statusesTmp;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($branchStatuses && modalOpeningState === 'loading') {
|
||||
modalOpeningState = 'completed';
|
||||
modal?.show();
|
||||
console.log(modalOpeningState);
|
||||
}
|
||||
});
|
||||
|
||||
let integratingUpstream = $state<'inert' | 'loading' | 'complete'>('inert');
|
||||
|
||||
export function openModal() {
|
||||
modalOpeningState = 'loading';
|
||||
integratingUpstream = 'inert';
|
||||
branchStatuses = upstreamIntegrationService.upstreamStatuses();
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
modalOpeningState = 'inert';
|
||||
}
|
||||
|
||||
async function integrate() {
|
||||
integratingUpstream = 'loading';
|
||||
await upstreamIntegrationService.integrateUpstream([...results.values()]);
|
||||
await baseBranchService.refresh();
|
||||
integratingUpstream = 'complete';
|
||||
|
||||
modal?.close();
|
||||
}
|
||||
|
||||
async function updateBaseBranch() {
|
||||
let infoText = await branchController.updateBaseBranch();
|
||||
if (infoText) {
|
||||
showInfo('Stashed conflicting branches', infoText);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
size="tag"
|
||||
style="error"
|
||||
kind="solid"
|
||||
tooltip="Merge upstream into common base"
|
||||
onclick={async () => {
|
||||
loading = true;
|
||||
try {
|
||||
let infoText = await branchController.updateBaseBranch();
|
||||
if (infoText) {
|
||||
showInfo('Stashed conflicting branches', infoText);
|
||||
}
|
||||
} catch (err) {
|
||||
showError('Failed update workspace', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if loading}
|
||||
busy...
|
||||
{:else}
|
||||
Update
|
||||
<Modal bind:this={modal} title="Integrate upstream changes" {onClose} width="small">
|
||||
{#if $base}
|
||||
<div class="upstream-commits">
|
||||
{#each $base.upstreamCommits as commit, index}
|
||||
<CommitCard
|
||||
{commit}
|
||||
first={index === 0}
|
||||
last={index === $base.upstreamCommits.length - 1}
|
||||
isUnapplied={true}
|
||||
commitUrl={$gitHost?.commitUrl(commit.id)}
|
||||
type="remote"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Button>
|
||||
<div class="statuses">
|
||||
{#each statuses as { branch, status }}
|
||||
<div class="branch-status" class:integrated={status.type === 'fullyIntegrated'}>
|
||||
<div class="description">
|
||||
<h5 class="text-16">{branch?.name || 'Unknown'}</h5>
|
||||
{#if status.type === 'conflicted'}
|
||||
<p>Conflicted</p>
|
||||
{:else if status.type === 'saflyUpdatable' || status.type === 'empty'}
|
||||
<p>No Conflicts</p>
|
||||
{:else if status.type === 'fullyIntegrated'}
|
||||
<p>Integrated</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="action" class:action--centered={status.type === 'fullyIntegrated'}>
|
||||
{#if status.type === 'fullyIntegrated'}
|
||||
<p>Changes included in base branch</p>
|
||||
{:else if results.get(branch.id)}
|
||||
<Select
|
||||
value={results.get(branch.id)!.approach.type}
|
||||
onselect={(value) => {
|
||||
const result = results.get(branch.id)!
|
||||
|
||||
results.set(branch.id, {...result, approach: { type: value as "rebase" | "merge" | "unapply" }})
|
||||
}}
|
||||
options={[
|
||||
{ label: 'Rebase', value: 'rebase' },
|
||||
{ label: 'Merge', value: 'merge' },
|
||||
{ label: 'Stash', value: 'unapply' }
|
||||
]}
|
||||
>
|
||||
{#snippet itemSnippet({ item, highlighted })}
|
||||
<SelectItem selected={highlighted} {highlighted}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
{/snippet}
|
||||
</Select>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#snippet controls()}
|
||||
<Button onclick={() => modal?.close()}>Cancel</Button>
|
||||
<Button onclick={integrate} style="pop" kind="solid" loading={integratingUpstream === 'loading'}
|
||||
>Integrate</Button
|
||||
>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
{#if showButton && ($base?.upstreamCommits.length || 0) > 0}
|
||||
<Button
|
||||
size="tag"
|
||||
style="error"
|
||||
kind="solid"
|
||||
tooltip="Merge upstream into common base"
|
||||
onclick={() => {
|
||||
if (project.succeedingRebases) {
|
||||
openModal();
|
||||
} else {
|
||||
updateBaseBranch();
|
||||
}
|
||||
}}
|
||||
loading={modalOpeningState === 'loading'}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.upstream-commits {
|
||||
text-align: left;
|
||||
|
||||
margin-top: -10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.branch-status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 14px;
|
||||
|
||||
&.integrated {
|
||||
background-color: var(--clr-bg-2);
|
||||
}
|
||||
|
||||
& .description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
& .action {
|
||||
width: 144px;
|
||||
|
||||
&.action--centered {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -189,7 +189,7 @@
|
||||
background: var(--clr-bg-2);
|
||||
border: 1px solid var(--clr-border-2);
|
||||
border-radius: var(--radius-m);
|
||||
box-shadow: var(--shadow-s);
|
||||
box-shadow: var(--fx-shadow-s);
|
||||
|
||||
animation: fadeIn 0.08s ease-out forwards;
|
||||
}
|
||||
|
@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
raw: string;
|
||||
}
|
||||
|
||||
const { raw }: Props = $props();
|
||||
</script>
|
||||
|
||||
{@html raw}
|
@ -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}
|
@ -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>
|
@ -1,2 +0,0 @@
|
||||
<br />
|
||||
<br />
|
@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { type Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
const { text }: Props = $props();
|
||||
const { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span>
|
||||
{text}
|
||||
{@render children()}
|
||||
</span>
|
||||
|
@ -16,7 +16,9 @@ export function featureInlineUnifiedDiffs(): Persisted<boolean> {
|
||||
return persisted(false, key);
|
||||
}
|
||||
|
||||
export function featureBranchStacking(): Persisted<boolean> {
|
||||
const key = 'branchStacking';
|
||||
export const stackingFeature = persisted(false, 'stackingFeature');
|
||||
|
||||
export function featureTopics(): Persisted<boolean> {
|
||||
const key = 'feature--topics';
|
||||
return persisted(false, key);
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { maybeGetContextStore } from '$lib/utils/context';
|
||||
import { Ownership } from '$lib/vbranches/ownership';
|
||||
import { SelectedOwnership } from '$lib/vbranches/ownership';
|
||||
import Badge from '@gitbutler/ui/Badge.svelte';
|
||||
import Checkbox from '@gitbutler/ui/Checkbox.svelte';
|
||||
import type { AnyFile } from '$lib/vbranches/types';
|
||||
@ -10,27 +10,30 @@
|
||||
export let files: AnyFile[];
|
||||
export let showCheckboxes = false;
|
||||
|
||||
const selectedOwnership: Writable<Ownership> | undefined = maybeGetContextStore(Ownership);
|
||||
const selectedOwnership: Writable<SelectedOwnership> | undefined =
|
||||
maybeGetContextStore(SelectedOwnership);
|
||||
|
||||
function selectAll(files: AnyFile[]) {
|
||||
if (!selectedOwnership) return;
|
||||
files.forEach((f) => selectedOwnership.update((ownership) => ownership.add(f.id, ...f.hunks)));
|
||||
files.forEach((f) =>
|
||||
selectedOwnership.update((ownership) => ownership.select(f.id, ...f.hunks))
|
||||
);
|
||||
}
|
||||
|
||||
function isAllChecked(selectedOwnership: Ownership | undefined): boolean {
|
||||
function isAllChecked(selectedOwnership: SelectedOwnership | undefined): boolean {
|
||||
if (!selectedOwnership) return false;
|
||||
return files.every((f) => f.hunks.every((h) => selectedOwnership.contains(f.id, h.id)));
|
||||
return files.every((f) => f.hunks.every((h) => selectedOwnership.isSelected(f.id, h.id)));
|
||||
}
|
||||
|
||||
function isIndeterminate(selectedOwnership: Ownership | undefined): boolean {
|
||||
function isIndeterminate(selectedOwnership: SelectedOwnership | undefined): boolean {
|
||||
if (!selectedOwnership) return false;
|
||||
if (files.length <= 1) return false;
|
||||
|
||||
let file = files[0] as AnyFile;
|
||||
let prev = selectedOwnership.contains(file.id, ...file.hunkIds);
|
||||
let prev = selectedOwnership.isSelected(file.id, ...file.hunkIds);
|
||||
for (let i = 1; i < files.length; i++) {
|
||||
file = files[i] as AnyFile;
|
||||
const contained = selectedOwnership.contains(file.id, ...file.hunkIds);
|
||||
const contained = selectedOwnership.isSelected(file.id, ...file.hunkIds);
|
||||
if (contained !== prev) {
|
||||
return true;
|
||||
}
|
||||
@ -49,12 +52,13 @@
|
||||
small
|
||||
{checked}
|
||||
{indeterminate}
|
||||
style={indeterminate ? 'neutral' : 'default'}
|
||||
onchange={(e: Event & { currentTarget: EventTarget & HTMLInputElement; }) => {
|
||||
const isChecked = e.currentTarget.checked;
|
||||
if (isChecked) {
|
||||
selectAll(files);
|
||||
} else {
|
||||
selectedOwnership?.update((ownership) => ownership.clear());
|
||||
selectedOwnership?.update((ownership) => ownership.clearSelection());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@ -3,52 +3,46 @@
|
||||
import FileListItem from './FileListItem.svelte';
|
||||
import LazyloadContainer from '$lib/shared/LazyloadContainer.svelte';
|
||||
import TextBox from '$lib/shared/TextBox.svelte';
|
||||
import { chunk } from '$lib/utils/array';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import { getContext } from '$lib/utils/context';
|
||||
import { selectFilesInList } from '$lib/utils/selectFilesInList';
|
||||
import { maybeMoveSelection } from '$lib/utils/selection';
|
||||
import { updateSelection } from '$lib/utils/selection';
|
||||
import { getCommitStore } from '$lib/vbranches/contexts';
|
||||
import { FileIdSelection, stringifyFileKey } from '$lib/vbranches/fileIdSelection';
|
||||
import { sortLikeFileTree } from '$lib/vbranches/filetree';
|
||||
import Button from '@gitbutler/ui/Button.svelte';
|
||||
import type { AnyFile } from '$lib/vbranches/types';
|
||||
|
||||
export let files: AnyFile[];
|
||||
export let isUnapplied = false;
|
||||
export let showCheckboxes = false;
|
||||
export let allowMultiple = false;
|
||||
export let readonly = false;
|
||||
const MERGE_DIFF_COMMAND = 'git diff-tree --cc ';
|
||||
|
||||
interface Props {
|
||||
files: AnyFile[];
|
||||
isUnapplied?: boolean;
|
||||
showCheckboxes?: boolean;
|
||||
allowMultiple?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
files,
|
||||
isUnapplied = false,
|
||||
showCheckboxes = false,
|
||||
allowMultiple = false,
|
||||
readonly = false
|
||||
}: Props = $props();
|
||||
|
||||
const fileIdSelection = getContext(FileIdSelection);
|
||||
const commit = getCommitStore();
|
||||
|
||||
function chunk<T>(arr: T[], size: number) {
|
||||
return Array.from({ length: Math.ceil(arr.length / size) }, (_v, i) =>
|
||||
arr.slice(i * size, i * size + size)
|
||||
);
|
||||
}
|
||||
let chunkedFiles: AnyFile[][] = $derived(chunk(sortLikeFileTree(files), 100));
|
||||
let currentDisplayIndex = $state(0);
|
||||
let displayedFiles: AnyFile[] = $derived(chunkedFiles.slice(0, currentDisplayIndex + 1).flat());
|
||||
|
||||
let chunkedFiles: AnyFile[][] = [];
|
||||
let displayedFiles: AnyFile[] = [];
|
||||
let currentDisplayIndex = 0;
|
||||
|
||||
function setFiles(files: AnyFile[]) {
|
||||
chunkedFiles = chunk(sortLikeFileTree(files), 100);
|
||||
displayedFiles = chunkedFiles[0] || [];
|
||||
currentDisplayIndex = 0;
|
||||
}
|
||||
|
||||
// Make sure we display when the file list is reset
|
||||
$: setFiles(files);
|
||||
|
||||
export function loadMore() {
|
||||
function loadMore() {
|
||||
if (currentDisplayIndex + 1 >= chunkedFiles.length) return;
|
||||
|
||||
currentDisplayIndex += 1;
|
||||
const currentChunkedFiles = chunkedFiles[currentDisplayIndex] ?? [];
|
||||
displayedFiles = [...displayedFiles, ...currentChunkedFiles];
|
||||
}
|
||||
let mergeDiffCommand = 'git diff-tree --cc ';
|
||||
</script>
|
||||
|
||||
{#if !$commit?.isMergeCommit()}
|
||||
@ -60,12 +54,12 @@
|
||||
GitHub, or run the following command in your project directory:
|
||||
</p>
|
||||
<div class="command">
|
||||
<TextBox value={mergeDiffCommand + $commit.id.slice(0, 7)} wide readonly />
|
||||
<TextBox value={MERGE_DIFF_COMMAND + $commit.id.slice(0, 7)} wide readonly />
|
||||
<Button
|
||||
icon="copy"
|
||||
style="ghost"
|
||||
outline
|
||||
onmousedown={() => copyToClipboard(mergeDiffCommand + $commit.id.slice(0, 7))}
|
||||
onmousedown={() => copyToClipboard(MERGE_DIFF_COMMAND + $commit.id.slice(0, 7))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -81,6 +75,21 @@
|
||||
loadMore();
|
||||
}}
|
||||
role="listbox"
|
||||
onkeydown={(e) => {
|
||||
e.preventDefault();
|
||||
updateSelection(
|
||||
{
|
||||
allowMultiple,
|
||||
shiftKey: e.shiftKey,
|
||||
key: e.key,
|
||||
targetElement: e.currentTarget as HTMLElement,
|
||||
files: displayedFiles,
|
||||
selectedFileIds: $fileIdSelection,
|
||||
fileIdSelection,
|
||||
commitId: $commit?.id
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
{#each displayedFiles as file (file.id)}
|
||||
<FileListItem
|
||||
@ -92,29 +101,6 @@
|
||||
onclick={(e) => {
|
||||
selectFilesInList(e, file, fileIdSelection, displayedFiles, allowMultiple, $commit);
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
e.preventDefault();
|
||||
maybeMoveSelection(
|
||||
{
|
||||
allowMultiple,
|
||||
shiftKey: e.shiftKey,
|
||||
key: e.key,
|
||||
targetElement: e.currentTarget as HTMLElement,
|
||||
file,
|
||||
files: displayedFiles,
|
||||
selectedFileIds: $fileIdSelection,
|
||||
fileIdSelection,
|
||||
commitId: $commit?.id
|
||||
}
|
||||
);
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
fileIdSelection.clear();
|
||||
|
||||
const targetEl = e.target as HTMLElement;
|
||||
targetEl.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</LazyloadContainer>
|
||||
|
@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import HunkViewer from '$lib/hunk/HunkViewer.svelte';
|
||||
import InfoMessage from '$lib/shared/InfoMessage.svelte';
|
||||
import LargeDiffMessage from '$lib/shared/LargeDiffMessage.svelte';
|
||||
import { computeAddedRemovedByHunk } from '$lib/utils/metrics';
|
||||
import { getLocalCommits, getLocalAndRemoteCommits } from '$lib/vbranches/contexts';
|
||||
import { getLockText } from '$lib/vbranches/tooltip';
|
||||
import Icon from '@gitbutler/ui/Icon.svelte';
|
||||
import Tooltip from '@gitbutler/ui/Tooltip.svelte';
|
||||
import type { HunkSection, ContentSection } from '$lib/utils/fileSections';
|
||||
|
||||
interface Props {
|
||||
@ -68,22 +67,26 @@
|
||||
{#each sections as section}
|
||||
{@const { added, removed } = computeAddedRemovedByHunk(section)}
|
||||
{#if 'hunk' in section}
|
||||
{@const isHunkLocked = section.hunk.lockedTo && section.hunk.lockedTo.length > 0 && commits}
|
||||
<div class="hunk-wrapper">
|
||||
<div class="indicators text-11 text-semibold">
|
||||
<div class="text-10 semibold added-removed">
|
||||
<span class="added">+{added}</span>
|
||||
<span class="removed">-{removed}</span>
|
||||
{#if isHunkLocked || section.hunk.poisoned}
|
||||
<div class="indicators text-11 text-semibold">
|
||||
{#if isHunkLocked}
|
||||
<InfoMessage filled outlined={false} style="warning" icon="locked">
|
||||
<svelte:fragment slot="content"
|
||||
>{getLockText(section.hunk.lockedTo, commits)}</svelte:fragment
|
||||
>
|
||||
</InfoMessage>
|
||||
{/if}
|
||||
{#if section.hunk.poisoned}
|
||||
<InfoMessage filled outlined={false}>
|
||||
<svelte:fragment slot="content"
|
||||
>Can not manage this hunk because it depends on changes from multiple branches</svelte:fragment
|
||||
>
|
||||
</InfoMessage>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if section.hunk.lockedTo && section.hunk.lockedTo.length > 0 && commits}
|
||||
<Tooltip text={getLockText(section.hunk.lockedTo, commits)}>
|
||||
<Icon name="locked-small" color="warning" />
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if section.hunk.poisoned}
|
||||
Can not manage this hunk because it depends on changes from multiple branches
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<HunkViewer
|
||||
{filePath}
|
||||
{section}
|
||||
@ -116,30 +119,10 @@
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.indicators {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.added-removed {
|
||||
display: flex;
|
||||
border-radius: var(--radius-s);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.removed,
|
||||
.added {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.added {
|
||||
color: var(--clr-scale-succ-30);
|
||||
background-color: var(--clr-theme-succ-bg);
|
||||
}
|
||||
|
||||
.removed {
|
||||
color: var(--clr-scale-err-30);
|
||||
background-color: var(--clr-theme-err-bg);
|
||||
}
|
||||
</style>
|
||||
|
@ -2,12 +2,13 @@
|
||||
import FileContextMenu from './FileContextMenu.svelte';
|
||||
import { draggableChips } from '$lib/dragging/draggable';
|
||||
import { DraggableFile } from '$lib/dragging/draggables';
|
||||
import { itemsSatisfy } from '$lib/utils/array';
|
||||
import { getContext, maybeGetContextStore } from '$lib/utils/context';
|
||||
import { computeFileStatus } from '$lib/utils/fileStatus';
|
||||
import { getLocalCommits, getLocalAndRemoteCommits } from '$lib/vbranches/contexts';
|
||||
import { getCommitStore } from '$lib/vbranches/contexts';
|
||||
import { FileIdSelection } from '$lib/vbranches/fileIdSelection';
|
||||
import { Ownership } from '$lib/vbranches/ownership';
|
||||
import { SelectedOwnership } from '$lib/vbranches/ownership';
|
||||
import { getLockText } from '$lib/vbranches/tooltip';
|
||||
import { VirtualBranch, type AnyFile, LocalFile } from '$lib/vbranches/types';
|
||||
import FileListItem from '@gitbutler/ui/file/FileListItem.svelte';
|
||||
@ -21,14 +22,15 @@
|
||||
showCheckbox: boolean;
|
||||
readonly: boolean;
|
||||
onclick: (e: MouseEvent) => void;
|
||||
onkeydown: (e: KeyboardEvent) => void;
|
||||
onkeydown?: (e: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
const { file, isUnapplied, selected, showCheckbox, readonly, onclick, onkeydown }: Props =
|
||||
$props();
|
||||
|
||||
const branch = maybeGetContextStore(VirtualBranch);
|
||||
const selectedOwnership: Writable<Ownership> | undefined = maybeGetContextStore(Ownership);
|
||||
const selectedOwnership: Writable<SelectedOwnership> | undefined =
|
||||
maybeGetContextStore(SelectedOwnership);
|
||||
const fileIdSelection = getContext(FileIdSelection);
|
||||
const commit = getCommitStore();
|
||||
|
||||
@ -45,27 +47,20 @@
|
||||
const selectedFiles = fileIdSelection.files;
|
||||
|
||||
let contextMenu: FileContextMenu;
|
||||
let lastCheckboxDetail = true;
|
||||
|
||||
let draggableEl: HTMLDivElement | undefined = $state();
|
||||
let checked = $state(false);
|
||||
let indeterminate = $state(false);
|
||||
|
||||
const draggable = !readonly && !isUnapplied;
|
||||
|
||||
$effect(() => {
|
||||
if (!lastCheckboxDetail) {
|
||||
selectedOwnership?.update((ownership) => {
|
||||
file.hunks.forEach((h) => ownership.remove(file.id, h.id));
|
||||
return ownership;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (file && $selectedOwnership) {
|
||||
checked =
|
||||
file.hunks.every((hunk) => $selectedOwnership?.contains(file.id, hunk.id)) &&
|
||||
lastCheckboxDetail;
|
||||
const hunksContained = itemsSatisfy(file.hunks, (h) =>
|
||||
$selectedOwnership?.isSelected(file.id, h.id)
|
||||
);
|
||||
checked = hunksContained === 'all';
|
||||
indeterminate = hunksContained === 'some';
|
||||
}
|
||||
});
|
||||
|
||||
@ -101,6 +96,7 @@
|
||||
{selected}
|
||||
{showCheckbox}
|
||||
{checked}
|
||||
{indeterminate}
|
||||
{draggable}
|
||||
{onclick}
|
||||
{onkeydown}
|
||||
@ -108,12 +104,11 @@
|
||||
{lockText}
|
||||
oncheck={(e) => {
|
||||
const isChecked = e.currentTarget.checked;
|
||||
lastCheckboxDetail = isChecked;
|
||||
selectedOwnership?.update((ownership) => {
|
||||
if (isChecked) {
|
||||
file.hunks.forEach((h) => ownership.add(file.id, h));
|
||||
file.hunks.forEach((h) => ownership.select(file.id, h));
|
||||
} else {
|
||||
file.hunks.forEach((h) => ownership.remove(file.id, h.id));
|
||||
file.hunks.forEach((h) => ownership.ignore(file.id, h.id));
|
||||
}
|
||||
return ownership;
|
||||
});
|
||||
@ -123,14 +118,14 @@
|
||||
if (isChecked) {
|
||||
files.forEach((f) => {
|
||||
selectedOwnership?.update((ownership) => {
|
||||
f.hunks.forEach((h) => ownership.add(f.id, h));
|
||||
f.hunks.forEach((h) => ownership.select(f.id, h));
|
||||
return ownership;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
files.forEach((f) => {
|
||||
selectedOwnership?.update((ownership) => {
|
||||
f.hunks.forEach((h) => ownership.remove(f.id, h.id));
|
||||
f.hunks.forEach((h) => ownership.ignore(f.id, h.id));
|
||||
return ownership;
|
||||
});
|
||||
});
|
||||
|
@ -36,6 +36,10 @@ export class AzureDevOps implements GitHost {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
issueService() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
prService() {
|
||||
return undefined;
|
||||
}
|
||||
|
@ -40,6 +40,10 @@ export class BitBucket implements GitHost {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
issueService() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
prService() {
|
||||
return undefined;
|
||||
}
|
||||
|
29
apps/desktop/src/lib/gitHost/github/issueService.ts
Normal file
29
apps/desktop/src/lib/gitHost/github/issueService.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -41,6 +41,10 @@ export class GitLab implements GitHost {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
issueService() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
prService() {
|
||||
return undefined;
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { buildContextStore } from '$lib/utils/context';
|
||||
import type { GitHostIssueService } from '$lib/gitHost/interface/gitHostIssueService';
|
||||
import type { GitHostBranch } from './gitHostBranch';
|
||||
import type { GitHostChecksMonitor } from './gitHostChecksMonitor';
|
||||
import type { GitHostListingService } from './gitHostListingService';
|
||||
@ -8,6 +9,8 @@ export interface GitHost {
|
||||
// Lists PRs for the repo.
|
||||
listService(): GitHostListingService | undefined;
|
||||
|
||||
issueService(): GitHostIssueService | undefined;
|
||||
|
||||
// Detailed information about a specific PR.
|
||||
prService(): GitHostPrService | undefined;
|
||||
|
||||
|
@ -0,0 +1,4 @@
|
||||
export interface GitHostIssueService {
|
||||
create(title: string, body: string, labels: string[]): Promise<void>;
|
||||
listLabels(): Promise<string[]>;
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { buildContextStore } from '$lib/utils/context';
|
||||
import type { GitHostPrMonitor } from './gitHostPrMonitor';
|
||||
import type { DetailedPullRequest, MergeMethod, PullRequest } from './types';
|
||||
import type { CreatePullRequestArgs, DetailedPullRequest, MergeMethod, PullRequest } from './types';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
export const [getGitHostPrService, createGitHostPrServiceStore] = buildContextStore<
|
||||
|
@ -4,9 +4,15 @@
|
||||
import ScrollableContainer from '$lib/scroll/ScrollableContainer.svelte';
|
||||
import { create } from '$lib/utils/codeHighlight';
|
||||
import { maybeGetContextStore } from '$lib/utils/context';
|
||||
import { type ContentSection, SectionType, type Line } from '$lib/utils/fileSections';
|
||||
import { Ownership } from '$lib/vbranches/ownership';
|
||||
import {
|
||||
type ContentSection,
|
||||
SectionType,
|
||||
type Line,
|
||||
CountColumnSide
|
||||
} from '$lib/utils/fileSections';
|
||||
import { SelectedOwnership } from '$lib/vbranches/ownership';
|
||||
import { type Hunk } from '$lib/vbranches/types';
|
||||
import Checkbox from '@gitbutler/ui/Checkbox.svelte';
|
||||
import Icon from '@gitbutler/ui/Icon.svelte';
|
||||
import diff_match_patch from 'diff-match-patch';
|
||||
import type { Writable } from 'svelte/store';
|
||||
@ -52,9 +58,12 @@
|
||||
const WHITESPACE_REGEX = /\s/;
|
||||
const NUMBER_COLUMN_WIDTH_PX = minWidth * 20;
|
||||
|
||||
const selectedOwnership: Writable<Ownership> | undefined = maybeGetContextStore(Ownership);
|
||||
const selectedOwnership: Writable<SelectedOwnership> | undefined =
|
||||
maybeGetContextStore(SelectedOwnership);
|
||||
|
||||
const selected = $derived($selectedOwnership?.contains(hunk.filePath, hunk.id) ?? false);
|
||||
let tableWidth = $state<number>(0);
|
||||
|
||||
const selected = $derived($selectedOwnership?.isSelected(hunk.filePath, hunk.id) ?? false);
|
||||
let isSelected = $derived(selectable && selected);
|
||||
|
||||
const inlineUnifiedDiffs = featureInlineUnifiedDiffs();
|
||||
@ -87,7 +96,8 @@
|
||||
afterLineNumber: line.afterLineNumber,
|
||||
tokens: toTokens(line.content),
|
||||
type: section.sectionType,
|
||||
size: line.content.length
|
||||
size: line.content.length,
|
||||
isLast: false
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -129,14 +139,16 @@
|
||||
afterLineNumber: oldLine.afterLineNumber,
|
||||
tokens: [] as string[],
|
||||
type: prevSection.sectionType,
|
||||
size: oldLine.content.length
|
||||
size: oldLine.content.length,
|
||||
isLast: false
|
||||
};
|
||||
const nextSectionRow = {
|
||||
beforeLineNumber: newLine.beforeLineNumber,
|
||||
afterLineNumber: newLine.afterLineNumber,
|
||||
tokens: [] as string[],
|
||||
type: nextSection.sectionType,
|
||||
size: newLine.content.length
|
||||
size: newLine.content.length,
|
||||
isLast: false
|
||||
};
|
||||
|
||||
const diff = charDiff(oldLine.content, newLine.content);
|
||||
@ -181,7 +193,8 @@
|
||||
afterLineNumber: newLine.afterLineNumber,
|
||||
tokens: [] as string[],
|
||||
type: nextSection.sectionType,
|
||||
size: newLine.content.length
|
||||
size: newLine.content.length,
|
||||
isLast: false
|
||||
};
|
||||
|
||||
const diff = charDiff(oldLine.content, newLine.content);
|
||||
@ -209,7 +222,7 @@
|
||||
}
|
||||
|
||||
function generateRows(subsections: ContentSection[]) {
|
||||
return subsections.reduce((acc, nextSection, i) => {
|
||||
const rows = subsections.reduce((acc, nextSection, i) => {
|
||||
const prevSection = subsections[i - 1];
|
||||
|
||||
// Filter out section for which we don't need to compute word diffs
|
||||
@ -254,59 +267,128 @@
|
||||
return acc;
|
||||
}
|
||||
}, [] as Row[]);
|
||||
|
||||
const last = rows.at(-1);
|
||||
if (last) {
|
||||
last.isLast = true;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
const renderRows = $derived(generateRows(subsections));
|
||||
|
||||
interface DiffHunkLineInfo {
|
||||
beforLineStart: number;
|
||||
beforeLineCount: number;
|
||||
afterLineStart: number;
|
||||
afterLineCount: number;
|
||||
}
|
||||
|
||||
function getHunkLineInfo(subsections: ContentSection[]): DiffHunkLineInfo {
|
||||
const firstSection = subsections[0];
|
||||
const lastSection = subsections.at(-1);
|
||||
|
||||
const beforLineStart = firstSection?.lines[0]?.beforeLineNumber ?? 0;
|
||||
const beforeLineEnd = lastSection?.lines?.at(-1)?.beforeLineNumber ?? 0;
|
||||
const beforeLineCount = beforeLineEnd - beforLineStart + 1;
|
||||
|
||||
const afterLineStart = firstSection?.lines[0]?.afterLineNumber ?? 0;
|
||||
const afterLineEnd = lastSection?.lines?.at(-1)?.afterLineNumber ?? 0;
|
||||
const afterLineCount = afterLineEnd - afterLineStart + 1;
|
||||
|
||||
return {
|
||||
beforLineStart,
|
||||
beforeLineCount,
|
||||
afterLineStart,
|
||||
afterLineCount
|
||||
};
|
||||
}
|
||||
|
||||
const hunkLineInfo = $derived(getHunkLineInfo(subsections));
|
||||
</script>
|
||||
|
||||
{#snippet countColumn(count: number | undefined, lineType: SectionType)}
|
||||
{#snippet countColumn(row: Row, side: CountColumnSide)}
|
||||
<td
|
||||
class="table__numberColumn"
|
||||
class:diff-line-deletion={lineType === SectionType.RemovedLines}
|
||||
class:diff-line-addition={lineType === SectionType.AddedLines}
|
||||
style="--number-col-width: {NUMBER_COLUMN_WIDTH_PX}px;"
|
||||
data-no-drag
|
||||
class:diff-line-deletion={row.type === SectionType.RemovedLines}
|
||||
class:diff-line-addition={row.type === SectionType.AddedLines}
|
||||
style="--number-col-width: {NUMBER_COLUMN_WIDTH_PX + 2}px;"
|
||||
align="center"
|
||||
class:is-last={row.isLast}
|
||||
class:is-before={side === CountColumnSide.Before}
|
||||
class:selected={isSelected}
|
||||
onclick={() => {
|
||||
selectable && handleSelected(hunk, !isSelected);
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
{side === CountColumnSide.Before ? row.beforeLineNumber : row.afterLineNumber}
|
||||
</td>
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
bind:clientWidth={tableWidth}
|
||||
class="table__wrapper hide-native-scrollbar"
|
||||
style="--tab-size: {tabSize}; --cursor: {draggingDisabled ? 'default' : 'grab'}"
|
||||
style="--tab-size: {tabSize}"
|
||||
>
|
||||
<ScrollableContainer horz padding={{ left: NUMBER_COLUMN_WIDTH_PX * 2 + 2 }}>
|
||||
{#if !draggingDisabled}
|
||||
<div class="table__drag-handle">
|
||||
<Icon name="draggable-narrow" />
|
||||
</div>
|
||||
{/if}
|
||||
<table data-hunk-id={hunk.id} class="table__section">
|
||||
<thead class="table__title">
|
||||
<tr
|
||||
onclick={() => {
|
||||
selectable && handleSelected(hunk, !isSelected);
|
||||
}}
|
||||
>
|
||||
<th class="table__checkbox-container" class:selected={isSelected} colspan={2}>
|
||||
<div class="table__checkbox">
|
||||
{#if selectable}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
small
|
||||
onclick={() => {
|
||||
selectable && handleSelected(hunk, !isSelected);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
|
||||
<td class="table__title-content">
|
||||
<span style="left: {NUMBER_COLUMN_WIDTH_PX * 2}px">
|
||||
{`@@ -${hunkLineInfo.beforLineStart},${hunkLineInfo.beforeLineCount} +${hunkLineInfo.afterLineStart},${hunkLineInfo.afterLineCount} @@`}
|
||||
</span>
|
||||
{#if !draggingDisabled}
|
||||
<div class="table__drag-handle">
|
||||
<Icon name="draggable" />
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{#each renderRows as line}
|
||||
{#each renderRows as row}
|
||||
<tr data-no-drag>
|
||||
{@render countColumn(line.beforeLineNumber, line.type)}
|
||||
{@render countColumn(line.afterLineNumber, line.type)}
|
||||
{@render countColumn(row, CountColumnSide.Before)}
|
||||
{@render countColumn(row, CountColumnSide.After)}
|
||||
<td
|
||||
{onclick}
|
||||
class="table__textContent"
|
||||
style="--tab-size: {tabSize};"
|
||||
class:readonly
|
||||
data-no-drag
|
||||
class:diff-line-deletion={line.type === SectionType.RemovedLines}
|
||||
class:diff-line-addition={line.type === SectionType.AddedLines}
|
||||
class:diff-line-deletion={row.type === SectionType.RemovedLines}
|
||||
class:diff-line-addition={row.type === SectionType.AddedLines}
|
||||
class:is-last={row.isLast}
|
||||
oncontextmenu={(event) => {
|
||||
const lineNumber = (line.beforeLineNumber
|
||||
? line.beforeLineNumber
|
||||
: line.afterLineNumber) as number;
|
||||
const lineNumber = (row.beforeLineNumber
|
||||
? row.beforeLineNumber
|
||||
: row.afterLineNumber) as number;
|
||||
handleLineContextMenu({ event, hunk, lineNumber, subsection: subsections[0] as ContentSection });
|
||||
}}
|
||||
>
|
||||
{@html line.tokens.join('')}
|
||||
{@html row.tokens.join('')}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@ -315,45 +397,102 @@
|
||||
</ScrollableContainer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
<style lang="postcss">
|
||||
.table__wrapper {
|
||||
border: 1px solid var(--clr-border-2);
|
||||
border-radius: var(--radius-s);
|
||||
border-radius: var(--radius-m);
|
||||
background-color: var(--clr-diff-line-bg);
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--clr-border-2);
|
||||
|
||||
&:hover .table__drag-handle {
|
||||
transform: translateY(0) translateX(0) scale(1);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.table__drag-handle {
|
||||
position: absolute;
|
||||
table,
|
||||
.table__section {
|
||||
width: 100%;
|
||||
font-family: var(--mono-font-family);
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
thead {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
th,
|
||||
td,
|
||||
tr {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
table thead th {
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: sticky;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.table__checkbox-container {
|
||||
z-index: var(--z-lifted);
|
||||
|
||||
border-right: 1px solid var(--clr-border-2);
|
||||
border-bottom: 1px solid var(--clr-border-2);
|
||||
background-color: var(--clr-diff-count-bg);
|
||||
border-top-left-radius: var(--radius-s);
|
||||
box-sizing: border-box;
|
||||
|
||||
&.selected {
|
||||
background-color: var(--clr-diff-selected-count-bg);
|
||||
border-color: var(--clr-diff-selected-count-border);
|
||||
border-right: 1px solid var(--clr-diff-selected-count-border);
|
||||
border-bottom: 1px solid var(--clr-diff-selected-count-border);
|
||||
}
|
||||
}
|
||||
|
||||
.table__checkbox {
|
||||
padding: 4px 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table__title {
|
||||
cursor: grab;
|
||||
top: 6px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.table__drag-handle {
|
||||
position: fixed;
|
||||
right: 6px;
|
||||
top: 6px;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--clr-bg-1);
|
||||
border: 1px solid var(--clr-border-2);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 4px 2px;
|
||||
border-radius: var(--radius-s);
|
||||
opacity: 0;
|
||||
transform: translateY(10%) translateX(-10%) scale(0.9);
|
||||
transform: scale(0.9);
|
||||
transform-origin: top right;
|
||||
pointer-events: none;
|
||||
color: var(--clr-text-2);
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
transform 0.2s;
|
||||
}
|
||||
|
||||
.table__section {
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
.table__title-content {
|
||||
position: relative;
|
||||
font-family: var(--mono-font-family);
|
||||
font-size: 12px;
|
||||
padding: 4px 6px;
|
||||
text-wrap: nowrap;
|
||||
color: var(--clr-text-2);
|
||||
border-bottom: 1px solid var(--clr-border-2);
|
||||
}
|
||||
|
||||
.table__numberColumn {
|
||||
@ -364,7 +503,6 @@
|
||||
text-align: center;
|
||||
padding: 0 4px;
|
||||
text-align: right;
|
||||
cursor: var(--cursor);
|
||||
user-select: none;
|
||||
|
||||
position: sticky;
|
||||
@ -372,24 +510,32 @@
|
||||
width: var(--number-col-width);
|
||||
min-width: var(--number-col-width);
|
||||
|
||||
box-shadow: inset -1px 0 0 0 var(--clr-diff-count-border);
|
||||
border-right: 1px solid var(--clr-border-2);
|
||||
|
||||
&.diff-line-addition {
|
||||
background-color: var(--clr-diff-addition-count-bg);
|
||||
color: var(--clr-diff-addition-count-text);
|
||||
box-shadow: inset -1px 0 0 0 var(--clr-diff-addition-count-border);
|
||||
border-color: var(--clr-diff-addition-count-border);
|
||||
}
|
||||
|
||||
&.diff-line-deletion {
|
||||
background-color: var(--clr-diff-deletion-count-bg);
|
||||
color: var(--clr-diff-deletion-count-text);
|
||||
box-shadow: inset -1px 0 0 0 var(--clr-diff-deletion-count-border);
|
||||
border-color: var(--clr-diff-deletion-count-border);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--clr-diff-selected-count-bg);
|
||||
box-shadow: inset -1px 0 0 0 var(--clr-diff-selected-count-border);
|
||||
color: var(--clr-diff-selected-count-text);
|
||||
border-color: var(--clr-diff-selected-count-border);
|
||||
}
|
||||
|
||||
&.is-last {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
&.is-before.is-last {
|
||||
border-bottom-left-radius: var(--radius-s);
|
||||
}
|
||||
}
|
||||
|
||||
@ -400,6 +546,7 @@
|
||||
}
|
||||
|
||||
.table__textContent {
|
||||
z-index: var(--z-lifted);
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
padding-left: 4px;
|
||||
|
@ -8,7 +8,7 @@
|
||||
import LargeDiffMessage from '$lib/shared/LargeDiffMessage.svelte';
|
||||
import { getContext, getContextStoreBySymbol, maybeGetContextStore } from '$lib/utils/context';
|
||||
import { type HunkSection } from '$lib/utils/fileSections';
|
||||
import { Ownership } from '$lib/vbranches/ownership';
|
||||
import { SelectedOwnership } from '$lib/vbranches/ownership';
|
||||
import { VirtualBranch, type Hunk } from '$lib/vbranches/types';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
@ -36,7 +36,8 @@
|
||||
readonly = false
|
||||
}: Props = $props();
|
||||
|
||||
const selectedOwnership: Writable<Ownership> | undefined = maybeGetContextStore(Ownership);
|
||||
const selectedOwnership: Writable<SelectedOwnership> | undefined =
|
||||
maybeGetContextStore(SelectedOwnership);
|
||||
const userSettings = getContextStoreBySymbol<Settings>(SETTINGS);
|
||||
const branch = maybeGetContextStore(VirtualBranch);
|
||||
const project = getContext(Project);
|
||||
@ -49,9 +50,9 @@
|
||||
function onHunkSelected(hunk: Hunk, isSelected: boolean) {
|
||||
if (!selectedOwnership) return;
|
||||
if (isSelected) {
|
||||
selectedOwnership.update((ownership) => ownership.add(hunk.filePath, hunk));
|
||||
selectedOwnership.update((ownership) => ownership.select(hunk.filePath, hunk));
|
||||
} else {
|
||||
selectedOwnership.update((ownership) => ownership.remove(hunk.filePath, hunk.id));
|
||||
selectedOwnership.update((ownership) => ownership.ignore(hunk.filePath, hunk.id));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -6,6 +6,7 @@ export interface Row {
|
||||
tokens: string[];
|
||||
type: SectionType;
|
||||
size: number;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
export enum Operation {
|
||||
|
@ -19,9 +19,7 @@
|
||||
{title}
|
||||
</h1>
|
||||
{/if}
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
|
@ -1,8 +1,3 @@
|
||||
<script lang="ts" module>
|
||||
// If this is not present, eslint complains that T is not defined below
|
||||
type T = unknown;
|
||||
</script>
|
||||
|
||||
<script lang="ts" generics="T">
|
||||
/**
|
||||
* Lazily renders a list of many many items. This is intended to be used
|
||||
@ -12,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import LazyloadContainer from '$lib/shared/LazyloadContainer.svelte';
|
||||
import { chunk } from '$lib/utils/chunk';
|
||||
import { chunk } from '$lib/utils/array';
|
||||
import { type Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
|
@ -6,8 +6,10 @@
|
||||
import WorkspaceButton from './WorkspaceButton.svelte';
|
||||
import Resizer from '../shared/Resizer.svelte';
|
||||
import { Project } from '$lib/backend/projects';
|
||||
import { featureTopics } from '$lib/config/uiFeatureFlags';
|
||||
import { ModeService } from '$lib/modes/service';
|
||||
import EditButton from '$lib/navigation/EditButton.svelte';
|
||||
import TopicsButton from '$lib/navigation/TopicsButton.svelte';
|
||||
import { persisted } from '$lib/persisted/persisted';
|
||||
import { platformName } from '$lib/platform/platform';
|
||||
import { SETTINGS, type Settings } from '$lib/settings/userSettings';
|
||||
@ -43,6 +45,8 @@
|
||||
|
||||
const modeService = getContext(ModeService);
|
||||
const mode = modeService.mode;
|
||||
|
||||
const topicsEnabled = featureTopics();
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeyDown} />
|
||||
@ -120,6 +124,10 @@
|
||||
{:else if $mode?.type === 'Edit'}
|
||||
<EditButton href={`/${project.id}/edit`} isNavCollapsed={$isNavCollapsed} />
|
||||
{/if}
|
||||
|
||||
{#if $topicsEnabled}
|
||||
<TopicsButton href={`/${project.id}/topics`} isNavCollapsed={$isNavCollapsed} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
34
apps/desktop/src/lib/navigation/TopicsButton.svelte
Normal file
34
apps/desktop/src/lib/navigation/TopicsButton.svelte
Normal 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>
|
@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import DomainButton from './DomainButton.svelte';
|
||||
import UpdateBaseButton from '../components/UpdateBaseButton.svelte';
|
||||
import { BaseBranch } from '$lib/baseBranch/baseBranch';
|
||||
import { getContextStore } from '$lib/utils/context';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
@ -13,7 +11,6 @@
|
||||
|
||||
const { href, isNavCollapsed }: Props = $props();
|
||||
|
||||
const baseBranch = getContextStore(BaseBranch);
|
||||
const label = 'Workspace';
|
||||
</script>
|
||||
|
||||
@ -27,7 +24,7 @@
|
||||
|
||||
{#if !isNavCollapsed}
|
||||
<span class="text-14 text-semibold" class:collapsed-txt={isNavCollapsed}>{label}</span>
|
||||
{#if ($baseBranch?.behind || 0) > 0 && !isNavCollapsed}
|
||||
{#if !isNavCollapsed}
|
||||
<UpdateBaseButton />
|
||||
{/if}
|
||||
{/if}
|
||||
|
@ -16,8 +16,8 @@
|
||||
|
||||
type Props = {
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
tooltip: string;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
click: (opts: { draft: boolean }) => void;
|
||||
};
|
||||
const { loading, disabled, tooltip, click }: Props = $props();
|
||||
|
@ -4,9 +4,9 @@
|
||||
import InfoMessage from '../shared/InfoMessage.svelte';
|
||||
import { Project } from '$lib/backend/projects';
|
||||
import { BaseBranchService } from '$lib/baseBranch/baseBranchService';
|
||||
import { stackingFeature } from '$lib/config/uiFeatureFlags';
|
||||
import { getGitHostChecksMonitor } from '$lib/gitHost/interface/gitHostChecksMonitor';
|
||||
import { getGitHostListingService } from '$lib/gitHost/interface/gitHostListingService';
|
||||
import { getGitHostPrMonitor } from '$lib/gitHost/interface/gitHostPrMonitor';
|
||||
import { getGitHostPrService } from '$lib/gitHost/interface/gitHostPrService';
|
||||
import { getContext } from '$lib/utils/context';
|
||||
import * as toasts from '$lib/utils/toasts';
|
||||
@ -18,6 +18,12 @@
|
||||
import type { MessageStyle } from '$lib/shared/InfoMessage.svelte';
|
||||
import type iconsJson from '@gitbutler/ui/data/icons.json';
|
||||
|
||||
interface Props {
|
||||
upstreamName: string;
|
||||
}
|
||||
|
||||
const { upstreamName }: Props = $props();
|
||||
|
||||
type StatusInfo = {
|
||||
text: string;
|
||||
icon: keyof typeof iconsJson | undefined;
|
||||
@ -29,31 +35,38 @@
|
||||
const baseBranchService = getContext(BaseBranchService);
|
||||
const project = getContext(Project);
|
||||
|
||||
const gitHostListingService = getGitHostListingService();
|
||||
const prStore = $derived($gitHostListingService?.prs);
|
||||
const prs = $derived(prStore ? $prStore : undefined);
|
||||
|
||||
const listedPr = $derived(prs?.find((pr) => pr.sourceBranch === upstreamName));
|
||||
const prNumber = $derived(listedPr?.number);
|
||||
|
||||
const prService = getGitHostPrService();
|
||||
const prMonitor = getGitHostPrMonitor();
|
||||
const prMonitor = $derived(prNumber ? $prService?.prMonitor(prNumber) : undefined);
|
||||
|
||||
const checksMonitor = getGitHostChecksMonitor();
|
||||
const listingService = getGitHostListingService();
|
||||
// This PR has been loaded on demand, and contains more details than the version
|
||||
// obtained when listing them.
|
||||
const pr = $derived($prMonitor?.pr);
|
||||
const pr = $derived(prMonitor?.pr);
|
||||
const checks = $derived($checksMonitor?.status);
|
||||
|
||||
// While the pr monitor is set to fetch updates by interval, we want
|
||||
// frequent updates while checks are running.
|
||||
$effect(() => {
|
||||
if ($checks) $prMonitor?.refresh();
|
||||
if ($checks) prMonitor?.refresh();
|
||||
});
|
||||
|
||||
let isMerging = $state(false);
|
||||
|
||||
const lastFetch = $derived($prMonitor?.lastFetch);
|
||||
const lastFetch = $derived(prMonitor?.lastFetch);
|
||||
const timeAgo = $derived($lastFetch ? createTimeAgoStore($lastFetch) : undefined);
|
||||
|
||||
const mrLoading = $derived($prMonitor?.loading);
|
||||
const mrLoading = $derived(prMonitor?.loading);
|
||||
const checksLoading = $derived($checksMonitor?.loading);
|
||||
|
||||
const checksError = $derived($checksMonitor?.error);
|
||||
const detailsError = $derived($prMonitor?.error);
|
||||
const detailsError = $derived(prMonitor?.error);
|
||||
|
||||
function getChecksCount(status: ChecksStatus): string {
|
||||
if (!status) return 'Running checks';
|
||||
@ -161,7 +174,11 @@
|
||||
</script>
|
||||
|
||||
{#if $pr}
|
||||
<div class="card pr-card">
|
||||
<div
|
||||
class:card={!$stackingFeature}
|
||||
class:pr-card={!$stackingFeature}
|
||||
class:stacked-pr={$stackingFeature}
|
||||
>
|
||||
<div class="floating-button">
|
||||
<Button
|
||||
icon="update-small"
|
||||
@ -172,15 +189,19 @@
|
||||
tooltip={$timeAgo ? 'Updated ' + $timeAgo : ''}
|
||||
onclick={async () => {
|
||||
$checksMonitor?.update();
|
||||
$prMonitor?.refresh();
|
||||
prMonitor?.refresh();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="pr-title text-13 text-semibold">
|
||||
<div
|
||||
class:pr-title={!$stackingFeature}
|
||||
class:stacked-pr-title={$stackingFeature}
|
||||
class="text-13 text-semibold"
|
||||
>
|
||||
<span style="color: var(--clr-scale-ntrl-50)">PR #{$pr?.number}:</span>
|
||||
{$pr.title}
|
||||
</div>
|
||||
<div class="pr-tags">
|
||||
<div class:pr-tags={!$stackingFeature} class:stacked-pr-tags={$stackingFeature}>
|
||||
<Button
|
||||
size="tag"
|
||||
clickable={false}
|
||||
@ -213,7 +234,7 @@
|
||||
immediately.
|
||||
-->
|
||||
{#if $pr}
|
||||
<div class="pr-actions">
|
||||
<div class:pr-actions={!$stackingFeature} class:stacked-pr-actions={$stackingFeature}>
|
||||
{#if infoProps}
|
||||
<InfoMessage icon={infoProps.icon} filled outlined={false} style={infoProps.messageStyle}>
|
||||
<svelte:fragment slot="content">
|
||||
@ -239,8 +260,8 @@
|
||||
await $prService?.merge(method, $pr.number);
|
||||
await baseBranchService.fetchFromRemotes();
|
||||
await Promise.all([
|
||||
$prMonitor?.refresh(),
|
||||
$listingService?.refresh(),
|
||||
prMonitor?.refresh(),
|
||||
$gitHostListingService?.refresh(),
|
||||
vbranchService.refresh(),
|
||||
baseBranchService.refresh()
|
||||
]);
|
||||
@ -258,6 +279,12 @@
|
||||
{/if}
|
||||
|
||||
<style lang="postcss">
|
||||
.stacked-pr {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pr-card {
|
||||
position: relative;
|
||||
padding: 14px;
|
||||
@ -272,11 +299,24 @@
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.stacked-pr-title {
|
||||
color: var(--clr-scale-ntrl-0);
|
||||
padding: 14px 14px 12px 14px;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.pr-tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stacked-pr-tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 14px 12px 14px;
|
||||
}
|
||||
|
||||
.pr-actions {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
@ -284,6 +324,13 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stacked-pr-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 14px 12px 14px;
|
||||
}
|
||||
|
||||
.floating-button {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
|
@ -241,7 +241,7 @@
|
||||
border-radius: var(--radius-m);
|
||||
border: 1px solid var(--clr-border-2);
|
||||
background: var(--clr-bg-1);
|
||||
box-shadow: var(--shadow-s);
|
||||
box-shadow: var(--fx-shadow-s);
|
||||
overflow: hidden;
|
||||
transform-origin: top;
|
||||
|
||||
|
@ -7,9 +7,10 @@
|
||||
minTriggerCount: number;
|
||||
role?: AriaRole | undefined | null;
|
||||
ontrigger: (lastChild: Element) => void;
|
||||
onkeydown?: (e: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
let { children, minTriggerCount, role, ontrigger }: Props = $props();
|
||||
let { children, minTriggerCount, role, ontrigger, onkeydown }: Props = $props();
|
||||
|
||||
let lazyContainerEl: HTMLDivElement;
|
||||
|
||||
@ -47,7 +48,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="lazy-container" {role} bind:this={lazyContainerEl}>
|
||||
<div class="lazy-container" {role} bind:this={lazyContainerEl} {onkeydown}>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
|
149
apps/desktop/src/lib/topics/CreateIssueModal.svelte
Normal file
149
apps/desktop/src/lib/topics/CreateIssueModal.svelte
Normal 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>
|
128
apps/desktop/src/lib/topics/CreateTopicModal.svelte
Normal file
128
apps/desktop/src/lib/topics/CreateTopicModal.svelte
Normal 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>
|
125
apps/desktop/src/lib/topics/Topic.svelte
Normal file
125
apps/desktop/src/lib/topics/Topic.svelte
Normal 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>
|
51
apps/desktop/src/lib/topics/service.ts
Normal file
51
apps/desktop/src/lib/topics/service.ts
Normal 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);
|
||||
}
|
||||
}
|
30
apps/desktop/src/lib/utils/array.ts
Normal file
30
apps/desktop/src/lib/utils/array.ts
Normal 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)
|
||||
);
|
||||
}
|
@ -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)
|
||||
);
|
||||
}
|
@ -21,6 +21,11 @@ export enum SectionType {
|
||||
Context
|
||||
}
|
||||
|
||||
export enum CountColumnSide {
|
||||
Before,
|
||||
After
|
||||
}
|
||||
|
||||
export class HunkSection {
|
||||
hunk!: Hunk;
|
||||
header!: HunkHeader;
|
||||
|
@ -1,4 +1,7 @@
|
||||
export function getSelectionDirection(firstFileIndex: number, lastFileIndex: number) {
|
||||
export function getSelectionDirection(
|
||||
firstFileIndex: number,
|
||||
lastFileIndex: number
|
||||
): 'up' | 'down' {
|
||||
// detect the direction of the selection
|
||||
const selectionDirection = lastFileIndex < firstFileIndex ? 'down' : 'up';
|
||||
|
||||
|
@ -2,22 +2,28 @@ import Blockquote from '$lib/components/markdownRenderers/Blockquote.svelte';
|
||||
import Code from '$lib/components/markdownRenderers/Code.svelte';
|
||||
import Codespan from '$lib/components/markdownRenderers/Codespan.svelte';
|
||||
import Heading from '$lib/components/markdownRenderers/Heading.svelte';
|
||||
import Html from '$lib/components/markdownRenderers/Html.svelte';
|
||||
import Image from '$lib/components/markdownRenderers/Image.svelte';
|
||||
import List from '$lib/components/markdownRenderers/List.svelte';
|
||||
import ListItem from '$lib/components/markdownRenderers/ListItem.svelte';
|
||||
import Paragraph from '$lib/components/markdownRenderers/Paragraph.svelte';
|
||||
import Space from '$lib/components/markdownRenderers/Space.svelte';
|
||||
import Text from '$lib/components/markdownRenderers/Text.svelte';
|
||||
import Link from '$lib/shared/Link.svelte';
|
||||
|
||||
export const renderers = {
|
||||
link: Link,
|
||||
image: Image,
|
||||
space: Space,
|
||||
blockquote: Blockquote,
|
||||
code: Code,
|
||||
codespan: Codespan,
|
||||
text: Text,
|
||||
html: Html,
|
||||
list: List,
|
||||
list_item: ListItem,
|
||||
heading: Heading,
|
||||
paragraph: Paragraph
|
||||
paragraph: Paragraph,
|
||||
init: null,
|
||||
space: null
|
||||
};
|
||||
|
||||
export const options = {
|
||||
|
@ -2,71 +2,96 @@
|
||||
* Shared helper functions for manipulating selected files with keyboard.
|
||||
*/
|
||||
import { getSelectionDirection } from './getSelectionDirection';
|
||||
import { KeyName } from './hotkeys';
|
||||
import { stringifyFileKey, unstringifyFileKey } from '$lib/vbranches/fileIdSelection';
|
||||
import type { FileIdSelection } from '$lib/vbranches/fileIdSelection';
|
||||
import type { AnyFile } from '$lib/vbranches/types';
|
||||
|
||||
export function getNextFile(files: AnyFile[], currentId: string): AnyFile | undefined {
|
||||
function getFile(files: AnyFile[], id: string): AnyFile | undefined {
|
||||
return files.find((f) => f.id === id);
|
||||
}
|
||||
|
||||
function getNextFile(files: AnyFile[], currentId: string): AnyFile | undefined {
|
||||
const fileIndex = files.findIndex((f) => f.id === currentId);
|
||||
return fileIndex !== -1 && fileIndex + 1 < files.length ? files[fileIndex + 1] : undefined;
|
||||
}
|
||||
|
||||
export function getPreviousFile(files: AnyFile[], currentId: string): AnyFile | undefined {
|
||||
function getPreviousFile(files: AnyFile[], currentId: string): AnyFile | undefined {
|
||||
const fileIndex = files.findIndex((f) => f.id === currentId);
|
||||
return fileIndex > 0 ? files[fileIndex - 1] : undefined;
|
||||
}
|
||||
|
||||
interface MoveSelectionParams {
|
||||
function getTopFile(files: AnyFile[], selectedFileIds: string[]): AnyFile | undefined {
|
||||
for (const file of files) {
|
||||
if (selectedFileIds.includes(stringifyFileKey(file.id))) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getBottomFile(files: AnyFile[], selectedFileIds: string[]): AnyFile | undefined {
|
||||
for (let i = files.length - 1; i >= 0; i--) {
|
||||
const file = files[i];
|
||||
if (selectedFileIds.includes(stringifyFileKey(file!.id))) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface UpdateSelectionParams {
|
||||
allowMultiple: boolean;
|
||||
shiftKey: boolean;
|
||||
key: string;
|
||||
targetElement: HTMLElement;
|
||||
file: AnyFile;
|
||||
files: AnyFile[];
|
||||
selectedFileIds: string[];
|
||||
fileIdSelection: FileIdSelection;
|
||||
commitId?: string;
|
||||
}
|
||||
|
||||
export function maybeMoveSelection({
|
||||
export function updateSelection({
|
||||
allowMultiple,
|
||||
shiftKey,
|
||||
key,
|
||||
targetElement,
|
||||
file,
|
||||
files,
|
||||
selectedFileIds,
|
||||
fileIdSelection,
|
||||
commitId
|
||||
}: MoveSelectionParams) {
|
||||
}: UpdateSelectionParams) {
|
||||
if (!selectedFileIds[0] || selectedFileIds.length === 0) return;
|
||||
|
||||
const firstFileId = unstringifyFileKey(selectedFileIds[0]);
|
||||
const lastFileId = unstringifyFileKey(selectedFileIds.at(-1)!);
|
||||
|
||||
const topFileId = getTopFile(files, selectedFileIds)?.id;
|
||||
const bottomFileId = getBottomFile(files, selectedFileIds)?.id;
|
||||
|
||||
let selectionDirection = getSelectionDirection(
|
||||
files.findIndex((f) => f.id === lastFileId),
|
||||
files.findIndex((f) => f.id === firstFileId)
|
||||
);
|
||||
|
||||
function getAndAddFile(
|
||||
getFileFunc: (files: AnyFile[], id: string) => AnyFile | undefined,
|
||||
id: string
|
||||
id: string,
|
||||
getFileFunc?: (files: AnyFile[], id: string) => AnyFile | undefined
|
||||
) {
|
||||
const file = getFileFunc(files, id);
|
||||
const file = getFileFunc?.(files, id) ?? getFile(files, id);
|
||||
if (file) {
|
||||
// if file is already selected, do nothing
|
||||
|
||||
if (selectedFileIds.includes(stringifyFileKey(file.id, commitId))) return;
|
||||
|
||||
fileIdSelection.add(file.id, commitId);
|
||||
}
|
||||
}
|
||||
|
||||
function getAndClearAndAddFile(
|
||||
getFileFunc: (files: AnyFile[], id: string) => AnyFile | undefined,
|
||||
id: string
|
||||
function getAndClearExcept(
|
||||
id: string,
|
||||
getFileFunc?: (files: AnyFile[], id: string) => AnyFile | undefined
|
||||
) {
|
||||
const file = getFileFunc(files, id);
|
||||
const file = getFileFunc?.(files, id) ?? getFile(files, id);
|
||||
|
||||
if (file) {
|
||||
fileIdSelection.clearExcept(file.id, commitId);
|
||||
@ -74,7 +99,7 @@ export function maybeMoveSelection({
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'ArrowUp':
|
||||
case KeyName.Up:
|
||||
if (shiftKey && allowMultiple) {
|
||||
// Handle case if only one file is selected
|
||||
// we should update the selection direction
|
||||
@ -83,22 +108,21 @@ export function maybeMoveSelection({
|
||||
} else if (selectionDirection === 'down') {
|
||||
fileIdSelection.remove(lastFileId, commitId);
|
||||
}
|
||||
getAndAddFile(getPreviousFile, lastFileId);
|
||||
getAndAddFile(lastFileId, getPreviousFile);
|
||||
} else {
|
||||
// focus previous file
|
||||
const previousElement = targetElement.previousElementSibling as HTMLElement;
|
||||
if (previousElement) previousElement.focus();
|
||||
|
||||
// Handle reset of selection
|
||||
if (selectedFileIds.length > 1) {
|
||||
getAndClearAndAddFile(getPreviousFile, lastFileId);
|
||||
} else {
|
||||
getAndClearAndAddFile(getPreviousFile, file.id);
|
||||
if (selectedFileIds.length > 1 && topFileId !== undefined) {
|
||||
getAndClearExcept(topFileId);
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
if (selectedFileIds.length === 1) {
|
||||
getAndClearExcept(firstFileId, getPreviousFile);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
case KeyName.Down:
|
||||
if (shiftKey && allowMultiple) {
|
||||
// Handle case if only one file is selected
|
||||
// we should update the selection direction
|
||||
@ -108,19 +132,22 @@ export function maybeMoveSelection({
|
||||
fileIdSelection.remove(lastFileId, commitId);
|
||||
}
|
||||
|
||||
getAndAddFile(getNextFile, lastFileId);
|
||||
getAndAddFile(lastFileId, getNextFile);
|
||||
} else {
|
||||
// focus next file
|
||||
const nextElement = targetElement.nextElementSibling as HTMLElement;
|
||||
if (nextElement) nextElement.focus();
|
||||
|
||||
// Handle reset of selection
|
||||
if (selectedFileIds.length > 1) {
|
||||
getAndClearAndAddFile(getNextFile, lastFileId);
|
||||
} else {
|
||||
getAndClearAndAddFile(getNextFile, file.id);
|
||||
if (selectedFileIds.length > 1 && bottomFileId !== undefined) {
|
||||
getAndClearExcept(bottomFileId);
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
if (selectedFileIds.length === 1) {
|
||||
getAndClearExcept(firstFileId, getNextFile);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case KeyName.Escape:
|
||||
fileIdSelection.clear();
|
||||
targetElement.blur();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
35
apps/desktop/src/lib/vbranches/commitGroups.test.ts
Normal file
35
apps/desktop/src/lib/vbranches/commitGroups.test.ts
Normal 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');
|
||||
});
|
39
apps/desktop/src/lib/vbranches/commitGroups.ts
Normal file
39
apps/desktop/src/lib/vbranches/commitGroups.ts
Normal 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;
|
||||
}
|
@ -23,48 +23,149 @@ export type FilePath = string;
|
||||
export type HunkClaims = Map<HunkId, AnyHunk>;
|
||||
export type FileClaims = Map<FilePath, HunkClaims>;
|
||||
|
||||
export class Ownership {
|
||||
function branchFilesToClaims(files: AnyFile[]): FileClaims {
|
||||
const selection = new Map<FilePath, HunkClaims>();
|
||||
for (const file of files) {
|
||||
const existingFile = selection.get(file.id);
|
||||
if (existingFile) {
|
||||
file.hunks.forEach((hunk) => existingFile.set(hunk.id, hunk));
|
||||
continue;
|
||||
}
|
||||
|
||||
selection.set(
|
||||
file.id,
|
||||
file.hunks.reduce((acc, hunk) => {
|
||||
return acc.set(hunk.id, hunk);
|
||||
}, new Map<string, AnyHunk>())
|
||||
);
|
||||
}
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
function selectAddedClaims(
|
||||
branch: VirtualBranch,
|
||||
previousState: SelectedOwnershipState,
|
||||
selection: Map<string, HunkClaims>
|
||||
) {
|
||||
for (const file of branch.files) {
|
||||
const existingFile = previousState.claims.get(file.id);
|
||||
|
||||
if (!existingFile) {
|
||||
// Select newly added files
|
||||
selection.set(
|
||||
file.id,
|
||||
file.hunks.reduce((acc, hunk) => {
|
||||
return acc.set(hunk.id, hunk);
|
||||
}, new Map<string, AnyHunk>())
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const hunk of file.hunks) {
|
||||
const existingHunk = existingFile.get(hunk.id);
|
||||
if (!existingHunk) {
|
||||
// Select newly added hunks
|
||||
const existingFile = selection.get(file.id);
|
||||
if (existingFile) {
|
||||
existingFile.set(hunk.id, hunk);
|
||||
} else {
|
||||
selection.set(file.id, new Map([[hunk.id, hunk]]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ignoreRemovedClaims(
|
||||
previousState: SelectedOwnershipState,
|
||||
branch: VirtualBranch,
|
||||
selection: Map<string, HunkClaims>
|
||||
) {
|
||||
for (const [fileId, hunkClaims] of previousState.selection.entries()) {
|
||||
const branchFile = branch.files.find((f) => f.id === fileId);
|
||||
if (branchFile) {
|
||||
for (const hunkId of hunkClaims.keys()) {
|
||||
const branchHunk = branchFile.hunks.find((h) => h.id === hunkId);
|
||||
if (branchHunk) {
|
||||
// Re-select hunks that are still present in the branch
|
||||
const existingFile = selection.get(fileId);
|
||||
if (existingFile) {
|
||||
existingFile.set(hunkId, branchHunk);
|
||||
} else {
|
||||
selection.set(fileId, new Map([[hunkId, branchHunk]]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SelectedOwnershipState {
|
||||
claims: FileClaims;
|
||||
selection: FileClaims;
|
||||
}
|
||||
|
||||
function getState(
|
||||
branch: VirtualBranch,
|
||||
previousState?: SelectedOwnershipState
|
||||
): SelectedOwnershipState {
|
||||
const claims = branchFilesToClaims(branch.files);
|
||||
|
||||
if (previousState !== undefined) {
|
||||
const selection = new Map<FilePath, HunkClaims>();
|
||||
selectAddedClaims(branch, previousState, selection);
|
||||
ignoreRemovedClaims(previousState, branch, selection);
|
||||
|
||||
return { selection, claims };
|
||||
}
|
||||
|
||||
return { selection: claims, claims };
|
||||
}
|
||||
|
||||
export class SelectedOwnership {
|
||||
private claims: FileClaims;
|
||||
private selection: FileClaims;
|
||||
|
||||
constructor(state: SelectedOwnershipState) {
|
||||
this.claims = state.claims;
|
||||
this.selection = state.selection;
|
||||
}
|
||||
|
||||
static fromBranch(branch: VirtualBranch) {
|
||||
const files = branch.files.reduce((acc, file) => {
|
||||
const existing = acc.get(file.id);
|
||||
if (existing) {
|
||||
file.hunks.forEach((hunk) => existing.set(hunk.id, hunk));
|
||||
} else {
|
||||
acc.set(
|
||||
file.id,
|
||||
file.hunks.reduce((acc2, hunk) => {
|
||||
return acc2.set(hunk.id, hunk);
|
||||
}, new Map<string, AnyHunk>())
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, new Map<FilePath, Map<HunkId, AnyHunk>>());
|
||||
const ownership = new Ownership(files);
|
||||
const state = getState(branch);
|
||||
const ownership = new SelectedOwnership(state);
|
||||
return ownership;
|
||||
}
|
||||
|
||||
constructor(files: FileClaims) {
|
||||
this.claims = files;
|
||||
update(branch: VirtualBranch) {
|
||||
const { selection, claims } = getState(branch, {
|
||||
claims: this.claims,
|
||||
selection: this.selection
|
||||
});
|
||||
|
||||
this.claims = claims;
|
||||
this.selection = selection;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
remove(fileId: string, ...hunkIds: string[]) {
|
||||
const claims = this.claims;
|
||||
if (!claims) return this;
|
||||
ignore(fileId: string, ...hunkIds: string[]) {
|
||||
const selection = this.selection;
|
||||
if (!selection) return this;
|
||||
hunkIds.forEach((hunkId) => {
|
||||
claims.get(fileId)?.delete(hunkId);
|
||||
if (claims.get(fileId)?.size === 0) claims.delete(fileId);
|
||||
selection.get(fileId)?.delete(hunkId);
|
||||
if (selection.get(fileId)?.size === 0) selection.delete(fileId);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
add(fileId: string, ...items: AnyHunk[]) {
|
||||
const claim = this.claims.get(fileId);
|
||||
if (claim) {
|
||||
items.forEach((hunk) => claim.set(hunk.id, hunk));
|
||||
select(fileId: string, ...items: AnyHunk[]) {
|
||||
const selectedFile = this.selection.get(fileId);
|
||||
if (selectedFile) {
|
||||
items.forEach((hunk) => selectedFile.set(hunk.id, hunk));
|
||||
} else {
|
||||
this.claims.set(
|
||||
this.selection.set(
|
||||
fileId,
|
||||
items.reduce((acc, hunk) => {
|
||||
return acc.set(hunk.id, hunk);
|
||||
@ -74,17 +175,17 @@ export class Ownership {
|
||||
return this;
|
||||
}
|
||||
|
||||
contains(fileId: string, ...hunkIds: string[]): boolean {
|
||||
return hunkIds.every((hunkId) => !!this.claims.get(fileId)?.has(hunkId));
|
||||
isSelected(fileId: string, ...hunkIds: string[]): boolean {
|
||||
return hunkIds.every((hunkId) => !!this.selection.get(fileId)?.has(hunkId));
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.claims.clear();
|
||||
clearSelection() {
|
||||
this.selection.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return Array.from(this.claims.entries())
|
||||
return Array.from(this.selection.entries())
|
||||
.map(
|
||||
([fileId, hunkMap]) =>
|
||||
fileId +
|
||||
@ -98,7 +199,7 @@ export class Ownership {
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.claims.size === 0;
|
||||
nothingSelected() {
|
||||
return this.selection.size === 0;
|
||||
}
|
||||
}
|
||||
|
@ -137,6 +137,7 @@ export class VirtualBranch {
|
||||
allowRebasing!: boolean;
|
||||
pr?: PullRequest;
|
||||
refname!: string;
|
||||
tree!: string;
|
||||
|
||||
get localCommits() {
|
||||
return this.commits.filter((c) => c.status === 'local');
|
||||
|
91
apps/desktop/src/lib/vbranches/upstreamIntegrationService.ts
Normal file
91
apps/desktop/src/lib/vbranches/upstreamIntegrationService.ts
Normal 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 });
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@
|
||||
import NoBaseBranch from '$lib/components/NoBaseBranch.svelte';
|
||||
import NotOnGitButlerBranch from '$lib/components/NotOnGitButlerBranch.svelte';
|
||||
import ProblemLoadingRepo from '$lib/components/ProblemLoadingRepo.svelte';
|
||||
import { featureTopics } from '$lib/config/uiFeatureFlags';
|
||||
import { ReorderDropzoneManagerFactory } from '$lib/dragging/reorderDropzoneManager';
|
||||
import { DefaultGitHostFactory } from '$lib/gitHost/gitHostFactory';
|
||||
import { octokitFromAccessToken } from '$lib/gitHost/github/octokit';
|
||||
@ -24,12 +25,17 @@
|
||||
import Navigation from '$lib/navigation/Navigation.svelte';
|
||||
import { persisted } from '$lib/persisted/persisted';
|
||||
import { RemoteBranchService } from '$lib/stores/remoteBranches';
|
||||
import CreateIssueModal from '$lib/topics/CreateIssueModal.svelte';
|
||||
import CreateTopicModal from '$lib/topics/CreateTopicModal.svelte';
|
||||
import { TopicService } from '$lib/topics/service';
|
||||
import { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher';
|
||||
import { parseRemoteUrl } from '$lib/url/gitUrl';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import { BranchController } from '$lib/vbranches/branchController';
|
||||
import { UpstreamIntegrationService } from '$lib/vbranches/upstreamIntegrationService';
|
||||
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
|
||||
import { onDestroy, setContext, type Snippet } from 'svelte';
|
||||
import { derived as storeDerived } from 'svelte/store';
|
||||
import type { LayoutData } from './$types';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
@ -87,6 +93,14 @@
|
||||
const listServiceStore = createGitHostListingServiceStore(undefined);
|
||||
const gitHostStore = createGitHostStore(undefined);
|
||||
const branchServiceStore = createBranchServiceStore(undefined);
|
||||
const gitHostIssueSerice = storeDerived(gitHostStore, (gitHostStore) =>
|
||||
gitHostStore?.issueService()
|
||||
);
|
||||
|
||||
$effect.pre(() => {
|
||||
const topicService = new TopicService(project, gitHostIssueSerice);
|
||||
setContext(TopicService, topicService);
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
const combinedBranchListingService = new CombinedBranchListingService(
|
||||
@ -160,8 +174,17 @@
|
||||
onDestroy(() => {
|
||||
clearFetchInterval();
|
||||
});
|
||||
|
||||
const topicsEnabled = featureTopics();
|
||||
</script>
|
||||
|
||||
{#if $topicsEnabled}
|
||||
{#if $gitHostStore?.issueService()}
|
||||
<CreateIssueModal registerKeypress />
|
||||
{/if}
|
||||
<CreateTopicModal registerKeypress />
|
||||
{/if}
|
||||
|
||||
<!-- forces components to be recreated when projectId changes -->
|
||||
{#key projectId}
|
||||
<ProjectSettingsMenuAction
|
||||
|
@ -11,6 +11,7 @@ import { ModeService } from '$lib/modes/service';
|
||||
import { RemoteBranchService } from '$lib/stores/remoteBranches';
|
||||
import { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher';
|
||||
import { BranchController } from '$lib/vbranches/branchController';
|
||||
import { UpstreamIntegrationService } from '$lib/vbranches/upstreamIntegrationService';
|
||||
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { Project } from '$lib/backend/projects';
|
||||
@ -80,6 +81,7 @@ export const load: LayoutLoad = async ({ params, parent }) => {
|
||||
const reorderDropzoneManagerFactory = new ReorderDropzoneManagerFactory(branchController);
|
||||
|
||||
const uncommitedFileWatcher = new UncommitedFilesWatcher(project);
|
||||
const upstreamIntegrationService = new UpstreamIntegrationService(project, vbranchService);
|
||||
|
||||
return {
|
||||
authService,
|
||||
@ -93,6 +95,7 @@ export const load: LayoutLoad = async ({ params, parent }) => {
|
||||
projectMetrics,
|
||||
modeService,
|
||||
fetchSignal,
|
||||
upstreamIntegrationService,
|
||||
|
||||
// These observables are provided for convenience
|
||||
branchDragActionsFactory,
|
||||
|
65
apps/desktop/src/routes/[projectId]/topics/+page.svelte
Normal file
65
apps/desktop/src/routes/[projectId]/topics/+page.svelte
Normal 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>
|
@ -3,14 +3,15 @@
|
||||
import {
|
||||
featureBaseBranchSwitching,
|
||||
featureInlineUnifiedDiffs,
|
||||
featureBranchStacking
|
||||
stackingFeature,
|
||||
featureTopics
|
||||
} from '$lib/config/uiFeatureFlags';
|
||||
import SettingsPage from '$lib/layout/SettingsPage.svelte';
|
||||
import Toggle from '$lib/shared/Toggle.svelte';
|
||||
|
||||
const baseBranchSwitching = featureBaseBranchSwitching();
|
||||
const inlineUnifiedDiffs = featureInlineUnifiedDiffs();
|
||||
const branchStacking = featureBranchStacking();
|
||||
const topicsEnabled = featureTopics();
|
||||
</script>
|
||||
|
||||
<SettingsPage title="Experimental features">
|
||||
@ -47,16 +48,30 @@
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</SectionCard>
|
||||
<SectionCard labelFor="branchStacking" orientation="row">
|
||||
<SectionCard labelFor="stackingFeature" orientation="row">
|
||||
<svelte:fragment slot="title">Branch stacking</svelte:fragment>
|
||||
<svelte:fragment slot="caption">
|
||||
Allows for branch / pull request stacking. The user interface for this is still very crude.
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="actions">
|
||||
<Toggle
|
||||
id="branchStacking"
|
||||
checked={$branchStacking}
|
||||
on:click={() => ($branchStacking = !$branchStacking)}
|
||||
id="stackingFeature"
|
||||
checked={$stackingFeature}
|
||||
on:click={() => ($stackingFeature = !$stackingFeature)}
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</SectionCard>
|
||||
<SectionCard labelFor="topics" orientation="row">
|
||||
<svelte:fragment slot="title">Topics</svelte:fragment>
|
||||
<svelte:fragment slot="caption">
|
||||
A highly experimental form of note taking / conversation. The form & function may change
|
||||
drastically, and may result in lost notes.
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="actions">
|
||||
<Toggle
|
||||
id="topics"
|
||||
checked={$topicsEnabled}
|
||||
on:click={() => ($topicsEnabled = !$topicsEnabled)}
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</SectionCard>
|
||||
|
@ -50,6 +50,7 @@ glob = "0.3.1"
|
||||
serial_test = "3.1.1"
|
||||
tempfile = "3.10"
|
||||
criterion = "0.5.1"
|
||||
uuid.workspace = true
|
||||
|
||||
[features]
|
||||
## Only enabled when benchmark runs are performed.
|
||||
|
@ -1,5 +1,5 @@
|
||||
use super::r#virtual as vbranch;
|
||||
use crate::branch;
|
||||
use crate::upstream_integration::{self, BranchStatuses, Resolution, UpstreamIntegrationContext};
|
||||
use crate::{
|
||||
base,
|
||||
base::BaseBranch,
|
||||
@ -517,7 +517,7 @@ pub fn create_virtual_branch_from_branch(
|
||||
pub fn get_uncommited_files(project: &Project) -> Result<Vec<RemoteBranchFile>> {
|
||||
let context = CommandContext::open(project)?;
|
||||
let guard = project.exclusive_worktree_access();
|
||||
branch::get_uncommited_files(&context, guard.read_permission())
|
||||
crate::branch::get_uncommited_files(&context, guard.read_permission())
|
||||
}
|
||||
|
||||
/// Like [`get_uncommited_files()`], but returns a type that can be re-used with
|
||||
@ -525,12 +525,38 @@ pub fn get_uncommited_files(project: &Project) -> Result<Vec<RemoteBranchFile>>
|
||||
pub fn get_uncommited_files_reusable(project: &Project) -> Result<DiffByPathMap> {
|
||||
let context = CommandContext::open(project)?;
|
||||
let guard = project.exclusive_worktree_access();
|
||||
branch::get_uncommited_files_raw(&context, guard.read_permission())
|
||||
crate::branch::get_uncommited_files_raw(&context, guard.read_permission())
|
||||
}
|
||||
|
||||
pub fn upstream_integration_statuses(project: &Project) -> Result<BranchStatuses> {
|
||||
let command_context = CommandContext::open(project)?;
|
||||
let mut guard = project.exclusive_worktree_access();
|
||||
|
||||
let context = UpstreamIntegrationContext::open(&command_context, guard.write_permission())?;
|
||||
|
||||
upstream_integration::upstream_integration_statuses(&context)
|
||||
}
|
||||
|
||||
pub fn integrate_upstream(project: &Project, resolutions: &[Resolution]) -> Result<()> {
|
||||
let command_context = CommandContext::open(project)?;
|
||||
let mut guard = project.exclusive_worktree_access();
|
||||
|
||||
let _ = command_context.project().create_snapshot(
|
||||
SnapshotDetails::new(OperationKind::UpdateWorkspaceBase),
|
||||
guard.write_permission(),
|
||||
);
|
||||
|
||||
upstream_integration::integrate_upstream(
|
||||
&command_context,
|
||||
resolutions,
|
||||
guard.write_permission(),
|
||||
)
|
||||
}
|
||||
|
||||
fn open_with_verify(project: &Project) -> Result<CommandContext> {
|
||||
let ctx = CommandContext::open(project)?;
|
||||
let mut guard = project.exclusive_worktree_access();
|
||||
|
||||
crate::integration::verify_branch(&ctx, guard.write_permission())?;
|
||||
Ok(ctx)
|
||||
}
|
||||
|
@ -561,7 +561,7 @@ pub(crate) fn target_to_base_branch(ctx: &CommandContext, target: &Target) -> Re
|
||||
let oid = commit.id();
|
||||
|
||||
// gather a list of commits between oid and target.sha
|
||||
let upstream_commits = ctx
|
||||
let upstream_commits = repo
|
||||
.log(oid, LogUntil::Commit(target.sha))
|
||||
.context("failed to get upstream commits")?
|
||||
.iter()
|
||||
@ -569,7 +569,7 @@ pub(crate) fn target_to_base_branch(ctx: &CommandContext, target: &Target) -> Re
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// get some recent commits
|
||||
let recent_commits = ctx
|
||||
let recent_commits = repo
|
||||
.log(target.sha, LogUntil::Take(20))
|
||||
.context("failed to get recent commits")?
|
||||
.iter()
|
||||
|
@ -12,7 +12,7 @@ use gitbutler_commit::commit_ext::CommitExt;
|
||||
use gitbutler_error::error::Marker;
|
||||
use gitbutler_operating_modes::OPEN_WORKSPACE_REFS;
|
||||
use gitbutler_project::access::WorktreeWritePermission;
|
||||
use gitbutler_repo::{LogUntil, RepoActionsExt, RepositoryExt};
|
||||
use gitbutler_repo::{LogUntil, RepositoryExt};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{branch_manager::BranchManagerExt, conflicts, VirtualBranchesExt};
|
||||
@ -329,6 +329,7 @@ fn verify_head_is_clean(ctx: &CommandContext, perm: &mut WorktreeWritePermission
|
||||
.context("failed to get default target")?;
|
||||
|
||||
let commits = ctx
|
||||
.repository()
|
||||
.log(head_commit.id(), LogUntil::Commit(default_target.sha))
|
||||
.context("failed to get log")?;
|
||||
|
||||
|
@ -5,13 +5,13 @@ pub use actions::{
|
||||
amend, can_apply_remote_branch, convert_to_real_branch, create_change_reference, create_commit,
|
||||
create_virtual_branch, create_virtual_branch_from_branch, delete_local_branch,
|
||||
delete_virtual_branch, fetch_from_remotes, get_base_branch_data, get_remote_branch_data,
|
||||
get_uncommited_files, get_uncommited_files_reusable, insert_blank_commit,
|
||||
get_uncommited_files, get_uncommited_files_reusable, insert_blank_commit, integrate_upstream,
|
||||
integrate_upstream_commits, list_local_branches, list_remote_commit_files,
|
||||
list_virtual_branches, list_virtual_branches_cached, move_commit, move_commit_file,
|
||||
push_change_reference, push_virtual_branch, reorder_commit, reset_files, reset_virtual_branch,
|
||||
set_base_branch, set_target_push_remote, squash, unapply_ownership, undo_commit,
|
||||
update_base_branch, update_branch_order, update_change_reference, update_commit_message,
|
||||
update_virtual_branch,
|
||||
update_virtual_branch, upstream_integration_statuses,
|
||||
};
|
||||
|
||||
mod r#virtual;
|
||||
@ -29,6 +29,8 @@ pub use branch_manager::{BranchManager, BranchManagerExt};
|
||||
mod base;
|
||||
pub use base::BaseBranch;
|
||||
|
||||
pub mod upstream_integration;
|
||||
|
||||
mod integration;
|
||||
pub use integration::{update_workspace_commit, verify_branch};
|
||||
|
||||
|
@ -148,6 +148,7 @@ pub(crate) fn branch_to_remote_branch_data(
|
||||
.target()
|
||||
.map(|sha| {
|
||||
let ahead = ctx
|
||||
.repository()
|
||||
.log(sha, LogUntil::Commit(base))
|
||||
.context("failed to get ahead commits")?;
|
||||
|
||||
|
840
crates/gitbutler-branch-actions/src/upstream_integration.rs
Normal file
840
crates/gitbutler-branch-actions/src/upstream_integration.rs
Normal 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)]),
|
||||
)
|
||||
}
|
||||
}
|
@ -71,6 +71,8 @@ pub struct VirtualBranch {
|
||||
#[serde(with = "gitbutler_serde::oid_opt", default)]
|
||||
pub fork_point: Option<git2::Oid>,
|
||||
pub refname: Refname,
|
||||
#[serde(with = "gitbutler_serde::oid")]
|
||||
pub tree: git2::Oid,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Serialize)]
|
||||
@ -159,7 +161,7 @@ pub fn unapply_ownership(
|
||||
},
|
||||
)?;
|
||||
|
||||
let final_tree_oid = gitbutler_diff::write::hunks_onto_tree(ctx, &final_tree, diff)?;
|
||||
let final_tree_oid = gitbutler_diff::write::hunks_onto_tree(ctx, &final_tree, diff, true)?;
|
||||
let final_tree = repo
|
||||
.find_tree(final_tree_oid)
|
||||
.context("failed to find tree")?;
|
||||
@ -292,7 +294,7 @@ pub fn list_virtual_branches_cached(
|
||||
default_target.sha
|
||||
))?;
|
||||
let remote_commit_ids =
|
||||
HashSet::from_iter(ctx.l(upstream.id(), LogUntil::Commit(merge_base))?);
|
||||
HashSet::from_iter(repo.l(upstream.id(), LogUntil::Commit(merge_base))?);
|
||||
let remote_commit_data: HashMap<_, _> = remote_commit_ids
|
||||
.iter()
|
||||
.copied()
|
||||
@ -313,7 +315,7 @@ pub fn list_virtual_branches_cached(
|
||||
let mut is_remote = false;
|
||||
|
||||
// find all commits on head that are not on target.sha
|
||||
let commits = ctx.log(branch.head, LogUntil::Commit(default_target.sha))?;
|
||||
let commits = repo.log(branch.head, LogUntil::Commit(default_target.sha))?;
|
||||
let check_commit = IsCommitIntegrated::new(ctx, &default_target)?;
|
||||
let vbranch_commits = {
|
||||
let _span = tracing::debug_span!(
|
||||
@ -411,6 +413,7 @@ pub fn list_virtual_branches_cached(
|
||||
merge_base,
|
||||
fork_point,
|
||||
refname,
|
||||
tree: branch.tree,
|
||||
};
|
||||
branches.push(branch);
|
||||
}
|
||||
@ -544,8 +547,8 @@ pub fn integrate_upstream_commits(ctx: &CommandContext, branch_id: BranchId) ->
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let upstream_commits = ctx.list_commits(upstream_commit.id(), default_target.sha)?;
|
||||
let branch_commits = ctx.list_commits(branch.head, default_target.sha)?;
|
||||
let upstream_commits = repo.list_commits(upstream_commit.id(), default_target.sha)?;
|
||||
let branch_commits = repo.list_commits(branch.head, default_target.sha)?;
|
||||
|
||||
let branch_commit_ids = branch_commits.iter().map(|c| c.id()).collect::<Vec<_>>();
|
||||
|
||||
@ -649,7 +652,12 @@ pub(crate) fn integrate_with_rebase(
|
||||
branch: &mut Branch,
|
||||
unknown_commits: &mut Vec<git2::Oid>,
|
||||
) -> Result<git2::Oid> {
|
||||
cherry_rebase_group(ctx, branch.head, unknown_commits.as_mut_slice())
|
||||
cherry_rebase_group(
|
||||
ctx.repository(),
|
||||
branch.head,
|
||||
unknown_commits.as_mut_slice(),
|
||||
ctx.project().succeeding_rebases,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn integrate_with_merge(
|
||||
@ -855,6 +863,7 @@ pub(crate) fn reset_branch(
|
||||
|
||||
if default_target.sha != target_commit_id
|
||||
&& !ctx
|
||||
.repository()
|
||||
.l(branch.head, LogUntil::Commit(default_target.sha))?
|
||||
.contains(&target_commit_id)
|
||||
{
|
||||
@ -1112,7 +1121,9 @@ impl<'repo> IsCommitIntegrated<'repo> {
|
||||
.find_branch_by_refname(&target.branch.clone().into())?
|
||||
.ok_or(anyhow!("failed to get branch"))?;
|
||||
let remote_head = remote_branch.get().peel_to_commit()?;
|
||||
let upstream_commits = ctx.l(remote_head.id(), LogUntil::Commit(target.sha))?;
|
||||
let upstream_commits = ctx
|
||||
.repository()
|
||||
.l(remote_head.id(), LogUntil::Commit(target.sha))?;
|
||||
let inmemory_repo = ctx.repository().in_memory_repo()?;
|
||||
Ok(Self {
|
||||
repo: ctx.repository(),
|
||||
@ -1242,7 +1253,9 @@ pub(crate) fn move_commit_file(
|
||||
.context("failed to find commit")?;
|
||||
|
||||
// find all the commits upstream from the target "to" commit
|
||||
let mut upstream_commits = ctx.l(target_branch.head, LogUntil::Commit(amend_commit.id()))?;
|
||||
let mut upstream_commits = ctx
|
||||
.repository()
|
||||
.l(target_branch.head, LogUntil::Commit(amend_commit.id()))?;
|
||||
|
||||
// get a list of all the diffs across all the virtual branches
|
||||
let base_file_diffs = gitbutler_diff::workdir(ctx.repository(), default_target.sha)
|
||||
@ -1366,10 +1379,13 @@ pub(crate) fn move_commit_file(
|
||||
// ok, now we need to identify which the new "to" commit is in the rebased history
|
||||
// so we'll take a list of the upstream oids and find it simply based on location
|
||||
// (since the order should not have changed in our simple rebase)
|
||||
let old_upstream_commit_oids =
|
||||
ctx.l(target_branch.head, LogUntil::Commit(default_target.sha))?;
|
||||
let old_upstream_commit_oids = ctx
|
||||
.repository()
|
||||
.l(target_branch.head, LogUntil::Commit(default_target.sha))?;
|
||||
|
||||
let new_upstream_commit_oids = ctx.l(new_head, LogUntil::Commit(default_target.sha))?;
|
||||
let new_upstream_commit_oids = ctx
|
||||
.repository()
|
||||
.l(new_head, LogUntil::Commit(default_target.sha))?;
|
||||
|
||||
// find to_commit_oid offset in upstream_commits vector
|
||||
let to_commit_offset = old_upstream_commit_oids
|
||||
@ -1389,7 +1405,9 @@ pub(crate) fn move_commit_file(
|
||||
.context("failed to find commit")?;
|
||||
|
||||
// reset the concept of what the upstream commits are to be the rebased ones
|
||||
upstream_commits = ctx.l(new_head, LogUntil::Commit(amend_commit.id()))?;
|
||||
upstream_commits = ctx
|
||||
.repository()
|
||||
.l(new_head, LogUntil::Commit(amend_commit.id()))?;
|
||||
}
|
||||
|
||||
// ok, now we will apply the moved changes to the "to" commit.
|
||||
@ -1479,6 +1497,7 @@ pub(crate) fn amend(
|
||||
}
|
||||
|
||||
if ctx
|
||||
.repository()
|
||||
.l(target_branch.head, LogUntil::Commit(default_target.sha))?
|
||||
.is_empty()
|
||||
{
|
||||
@ -1545,7 +1564,9 @@ pub(crate) fn amend(
|
||||
.context("failed to create commit")?;
|
||||
|
||||
// now rebase upstream commits, if needed
|
||||
let upstream_commits = ctx.l(target_branch.head, LogUntil::Commit(amend_commit.id()))?;
|
||||
let upstream_commits = ctx
|
||||
.repository()
|
||||
.l(target_branch.head, LogUntil::Commit(amend_commit.id()))?;
|
||||
// if there are no upstream commits, we're done
|
||||
if upstream_commits.is_empty() {
|
||||
target_branch.head = commit_oid;
|
||||
@ -1618,6 +1639,8 @@ pub(crate) fn reorder_commit(
|
||||
)
|
||||
.context("Failed to commit uncommited changes")?;
|
||||
|
||||
let succeeding_rebases = ctx.project().succeeding_rebases;
|
||||
|
||||
if offset < 0 {
|
||||
// move commit up
|
||||
if branch.head == commit_oid {
|
||||
@ -1626,7 +1649,9 @@ pub(crate) fn reorder_commit(
|
||||
}
|
||||
|
||||
// get a list of the commits to rebase
|
||||
let mut ids_to_rebase = ctx.l(branch.head, LogUntil::Commit(commit.id()))?;
|
||||
let mut ids_to_rebase = ctx
|
||||
.repository()
|
||||
.l(branch.head, LogUntil::Commit(commit.id()))?;
|
||||
|
||||
ids_to_rebase.insert(
|
||||
ids_to_rebase.len() - offset.unsigned_abs() as usize,
|
||||
@ -1634,7 +1659,8 @@ pub(crate) fn reorder_commit(
|
||||
);
|
||||
|
||||
let new_head =
|
||||
cherry_rebase_group(ctx, parent_oid, &mut ids_to_rebase).context("rebase failed")?;
|
||||
cherry_rebase_group(repository, parent_oid, &ids_to_rebase, succeeding_rebases)
|
||||
.context("rebase failed")?;
|
||||
|
||||
branch.head = new_head;
|
||||
} else {
|
||||
@ -1654,6 +1680,7 @@ pub(crate) fn reorder_commit(
|
||||
|
||||
// get a list of the commits to rebase
|
||||
let mut ids_to_rebase: Vec<git2::Oid> = ctx
|
||||
.repository()
|
||||
.l(branch.head, LogUntil::Commit(target_oid))?
|
||||
.iter()
|
||||
.filter(|id| **id != commit_oid)
|
||||
@ -1663,13 +1690,15 @@ pub(crate) fn reorder_commit(
|
||||
ids_to_rebase.push(commit_oid);
|
||||
|
||||
let new_head =
|
||||
cherry_rebase_group(ctx, target_oid, &mut ids_to_rebase).context("rebase failed")?;
|
||||
cherry_rebase_group(repository, target_oid, &ids_to_rebase, succeeding_rebases)
|
||||
.context("rebase failed")?;
|
||||
|
||||
branch.head = new_head;
|
||||
}
|
||||
|
||||
let new_tree_commit =
|
||||
cherry_rebase_group(ctx, branch.head, &mut [tree_commit]).context("rebase failed")?;
|
||||
cherry_rebase_group(repository, branch.head, &[tree_commit], succeeding_rebases)
|
||||
.context("rebase failed")?;
|
||||
|
||||
let new_tree_commit = repository
|
||||
.find_commit(new_tree_commit)
|
||||
@ -1809,7 +1838,9 @@ pub(crate) fn squash(
|
||||
let vb_state = ctx.project().virtual_branches();
|
||||
let mut branch = vb_state.get_branch_in_workspace(branch_id)?;
|
||||
let default_target = vb_state.get_default_target()?;
|
||||
let branch_commit_oids = ctx.l(branch.head, LogUntil::Commit(default_target.sha))?;
|
||||
let branch_commit_oids = ctx
|
||||
.repository()
|
||||
.l(branch.head, LogUntil::Commit(default_target.sha))?;
|
||||
|
||||
if !branch_commit_oids.contains(&commit_id) {
|
||||
bail!("commit {commit_id} not in the branch")
|
||||
@ -1830,7 +1861,10 @@ pub(crate) fn squash(
|
||||
|
||||
let pushed_commit_oids = branch.upstream_head.map_or_else(
|
||||
|| Ok(vec![]),
|
||||
|upstream_head| ctx.l(upstream_head, LogUntil::Commit(default_target.sha)),
|
||||
|upstream_head| {
|
||||
ctx.repository()
|
||||
.l(upstream_head, LogUntil::Commit(default_target.sha))
|
||||
},
|
||||
)?;
|
||||
|
||||
if pushed_commit_oids.contains(&parent_commit.id()) && !branch.allow_rebasing {
|
||||
@ -1873,9 +1907,14 @@ pub(crate) fn squash(
|
||||
ids.first().copied()
|
||||
}
|
||||
.with_context(|| format!("commit {commit_id} not in the branch"))?;
|
||||
let mut ids_to_rebase = ids_to_rebase.to_vec();
|
||||
let ids_to_rebase = ids_to_rebase.to_vec();
|
||||
|
||||
match cherry_rebase_group(ctx, new_commit_oid, &mut ids_to_rebase) {
|
||||
match cherry_rebase_group(
|
||||
ctx.repository(),
|
||||
new_commit_oid,
|
||||
&ids_to_rebase,
|
||||
ctx.project().succeeding_rebases,
|
||||
) {
|
||||
Ok(new_head_id) => {
|
||||
// save new branch head
|
||||
branch.head = new_head_id;
|
||||
@ -1906,7 +1945,9 @@ pub(crate) fn update_commit_message(
|
||||
let default_target = vb_state.get_default_target()?;
|
||||
|
||||
let mut branch = vb_state.get_branch_in_workspace(branch_id)?;
|
||||
let branch_commit_oids = ctx.l(branch.head, LogUntil::Commit(default_target.sha))?;
|
||||
let branch_commit_oids = ctx
|
||||
.repository()
|
||||
.l(branch.head, LogUntil::Commit(default_target.sha))?;
|
||||
|
||||
if !branch_commit_oids.contains(&commit_id) {
|
||||
bail!("commit {commit_id} not in the branch");
|
||||
@ -1914,7 +1955,10 @@ pub(crate) fn update_commit_message(
|
||||
|
||||
let pushed_commit_oids = branch.upstream_head.map_or_else(
|
||||
|| Ok(vec![]),
|
||||
|upstream_head| ctx.l(upstream_head, LogUntil::Commit(default_target.sha)),
|
||||
|upstream_head| {
|
||||
ctx.repository()
|
||||
.l(upstream_head, LogUntil::Commit(default_target.sha))
|
||||
},
|
||||
)?;
|
||||
|
||||
if pushed_commit_oids.contains(&commit_id) && !branch.allow_rebasing {
|
||||
@ -1949,10 +1993,15 @@ pub(crate) fn update_commit_message(
|
||||
ids.first().copied()
|
||||
}
|
||||
.with_context(|| format!("commit {commit_id} not in the branch"))?;
|
||||
let mut ids_to_rebase = ids_to_rebase.to_vec();
|
||||
let ids_to_rebase = ids_to_rebase.to_vec();
|
||||
|
||||
let new_head_id = cherry_rebase_group(ctx, new_commit_oid, &mut ids_to_rebase)
|
||||
.map_err(|err| err.context("rebase error"))?;
|
||||
let new_head_id = cherry_rebase_group(
|
||||
ctx.repository(),
|
||||
new_commit_oid,
|
||||
&ids_to_rebase,
|
||||
ctx.project().succeeding_rebases,
|
||||
)
|
||||
.map_err(|err| err.context("rebase error"))?;
|
||||
// save new branch head
|
||||
branch.head = new_head_id;
|
||||
branch.updated_timestamp_ms = gitbutler_time::time::now_ms();
|
||||
|
@ -78,7 +78,7 @@ impl GitHunk {
|
||||
new_lines: 0,
|
||||
diff_lines: Default::default(),
|
||||
binary: false,
|
||||
change_type: ChangeType::Modified,
|
||||
change_type: ChangeType::Added,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -387,6 +387,11 @@ fn reverse_patch(patch: &BStr) -> Option<BString> {
|
||||
|
||||
// returns `None` if the reversal failed
|
||||
pub fn reverse_hunk(hunk: &GitHunk) -> Option<GitHunk> {
|
||||
let new_change_type = match hunk.change_type {
|
||||
ChangeType::Added => ChangeType::Deleted,
|
||||
ChangeType::Deleted => ChangeType::Added,
|
||||
ChangeType::Modified => ChangeType::Modified,
|
||||
};
|
||||
if hunk.binary {
|
||||
None
|
||||
} else {
|
||||
@ -397,7 +402,7 @@ pub fn reverse_hunk(hunk: &GitHunk) -> Option<GitHunk> {
|
||||
new_lines: hunk.old_lines,
|
||||
diff_lines: diff.into(),
|
||||
binary: hunk.binary,
|
||||
change_type: hunk.change_type,
|
||||
change_type: new_change_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
#[cfg(target_family = "unix")]
|
||||
use std::os::unix::prelude::PermissionsExt;
|
||||
use std::{borrow::Borrow, path::PathBuf};
|
||||
use std::{borrow::Borrow, fs, path::PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use bstr::{BString, ByteSlice, ByteVec};
|
||||
@ -38,13 +38,14 @@ where
|
||||
let head_commit = git_repository.find_commit(commit_oid)?;
|
||||
let base_tree = head_commit.tree()?;
|
||||
|
||||
hunks_onto_tree(ctx, &base_tree, files)
|
||||
hunks_onto_tree(ctx, &base_tree, files, false)
|
||||
}
|
||||
|
||||
pub fn hunks_onto_tree<T>(
|
||||
ctx: &CommandContext,
|
||||
base_tree: &git2::Tree,
|
||||
files: impl IntoIterator<Item = (impl Borrow<PathBuf>, impl Borrow<Vec<T>>)>,
|
||||
allow_new_file: bool,
|
||||
) -> Result<git2::Oid>
|
||||
where
|
||||
T: Into<GitHunk> + Clone,
|
||||
@ -62,7 +63,21 @@ where
|
||||
&& hunks[0].diff_lines.contains_str(b"Subproject commit");
|
||||
|
||||
// if file exists
|
||||
if full_path.exists() {
|
||||
let full_path_exists = full_path.exists();
|
||||
let discard_hunk = (hunks.len() == 1).then(|| &hunks[0]);
|
||||
if full_path_exists || allow_new_file {
|
||||
if discard_hunk.map_or(false, |hunk| hunk.change_type == crate::ChangeType::Deleted) {
|
||||
// File was created but now that hunk is being discarded with an inversed hunk
|
||||
builder.remove(rel_path);
|
||||
fs::remove_file(full_path.clone()).or_else(|err| {
|
||||
if err.kind() == std::io::ErrorKind::NotFound {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(err)
|
||||
}
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
// if file is executable, use 755, otherwise 644
|
||||
let mut filemode = git2::FileMode::Blob;
|
||||
// check if full_path file is executable
|
||||
@ -115,7 +130,7 @@ where
|
||||
)?;
|
||||
builder.upsert(rel_path, blob_oid, filemode);
|
||||
} else if let Ok(tree_entry) = base_tree.get_path(rel_path) {
|
||||
if hunks.len() == 1 && hunks[0].binary {
|
||||
if discard_hunk.map_or(false, |hunk| hunk.binary) {
|
||||
let new_blob_oid = &hunks[0].diff_lines;
|
||||
// convert string to Oid
|
||||
let new_blob_oid = new_blob_oid
|
||||
@ -178,6 +193,20 @@ where
|
||||
let new_blob_oid = git_repository.blob(blob_contents.as_bytes())?;
|
||||
// upsert into the builder
|
||||
builder.upsert(rel_path, new_blob_oid, filemode);
|
||||
} else if !full_path_exists
|
||||
&& discard_hunk.map_or(false, |hunk| hunk.change_type == crate::ChangeType::Added)
|
||||
{
|
||||
// File was deleted but now that hunk is being discarded with an inversed hunk
|
||||
let mut all_diffs = BString::default();
|
||||
for hunk in hunks {
|
||||
all_diffs.push_str(&hunk.diff_lines);
|
||||
}
|
||||
let patch = Patch::from_bytes(&all_diffs)?;
|
||||
let blob_contents =
|
||||
apply([], &patch).context(format!("failed to apply {}", all_diffs))?;
|
||||
|
||||
let new_blob_oid = git_repository.blob(&blob_contents)?;
|
||||
builder.upsert(rel_path, new_blob_oid, filemode);
|
||||
} else {
|
||||
// create a git blob from a file on disk
|
||||
let blob_oid = git_repository
|
||||
|
@ -236,7 +236,6 @@ pub(crate) fn save_and_return_to_workspace(
|
||||
let commit = repository
|
||||
.find_commit(edit_mode_metadata.commit_oid)
|
||||
.context("Failed to find commit")?;
|
||||
let commit_parent = commit.parent(0).context("Failed to get commit's parent")?;
|
||||
let stashed_workspace_changes_reference = repository
|
||||
.find_reference(EDIT_UNCOMMITED_FILES_REF)
|
||||
.context("Failed to find stashed workspace changes")?;
|
||||
@ -250,6 +249,8 @@ pub(crate) fn save_and_return_to_workspace(
|
||||
bail!("Failed to find virtual branch for this reference. Entering and leaving edit mode for non-virtual branches is unsupported")
|
||||
};
|
||||
|
||||
let parents = commit.parents().collect::<Vec<_>>();
|
||||
|
||||
// Recommit commit
|
||||
let tree = repository.create_wd_tree()?;
|
||||
let commit_headers = commit
|
||||
@ -266,7 +267,7 @@ pub(crate) fn save_and_return_to_workspace(
|
||||
&commit.committer(),
|
||||
&commit.message_bstr().to_str_lossy(),
|
||||
&tree,
|
||||
&[&commit_parent],
|
||||
&parents.iter().collect::<Vec<_>>(),
|
||||
commit_headers,
|
||||
)
|
||||
.context("Failed to commit new commit")?;
|
||||
@ -303,9 +304,10 @@ pub(crate) fn save_and_return_to_workspace(
|
||||
.context("Failed to update gitbutler workspace")?;
|
||||
|
||||
let rebased_stashed_workspace_changes_commit = cherry_rebase_group(
|
||||
ctx,
|
||||
repository,
|
||||
workspace_commit_oid,
|
||||
&mut [stashed_workspace_changes_commit.id()],
|
||||
&[stashed_workspace_changes_commit.id()],
|
||||
true,
|
||||
)
|
||||
.context("Failed to rebase stashed workspace commit changes")?;
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{LogUntil, RepoActionsExt};
|
||||
use crate::{LogUntil, RepoActionsExt, RepositoryExt as _};
|
||||
use anyhow::Context;
|
||||
use anyhow::{anyhow, Result};
|
||||
use gitbutler_branch::ChangeReference;
|
||||
@ -163,6 +163,7 @@ fn commit_by_branch_id_and_change_id<'a>(
|
||||
let target = handle.get_default_target()?;
|
||||
// Find the commit with the change id
|
||||
let commit = ctx
|
||||
.repository()
|
||||
.log(vbranch.head, LogUntil::Commit(target.sha))?
|
||||
.iter()
|
||||
.map(|c| c.id())
|
||||
@ -187,6 +188,7 @@ fn validate_commit(
|
||||
) -> Result<()> {
|
||||
let target = handle.get_default_target()?;
|
||||
let branch_commits = ctx
|
||||
.repository()
|
||||
.log(vbranch.head, LogUntil::Commit(target.sha))?
|
||||
.iter()
|
||||
.map(|c| c.id())
|
||||
|
@ -8,7 +8,7 @@ use gitbutler_commit::{
|
||||
};
|
||||
use gitbutler_error::error::Marker;
|
||||
|
||||
use crate::{LogUntil, RepoActionsExt};
|
||||
use crate::{LogUntil, RepositoryExt as _};
|
||||
|
||||
/// cherry-pick based rebase, which handles empty commits
|
||||
/// this function takes a commit range and generates a Vector of commit oids
|
||||
@ -22,13 +22,20 @@ pub fn cherry_rebase(
|
||||
from_commit_oid: git2::Oid,
|
||||
) -> Result<Option<git2::Oid>> {
|
||||
// get a list of the commits to rebase
|
||||
let mut ids_to_rebase = ctx.l(from_commit_oid, LogUntil::Commit(to_commit_oid))?;
|
||||
let ids_to_rebase = ctx
|
||||
.repository()
|
||||
.l(from_commit_oid, LogUntil::Commit(to_commit_oid))?;
|
||||
|
||||
if ids_to_rebase.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let new_head_id = cherry_rebase_group(ctx, target_commit_oid, &mut ids_to_rebase)?;
|
||||
let new_head_id = cherry_rebase_group(
|
||||
ctx.repository(),
|
||||
target_commit_oid,
|
||||
&ids_to_rebase,
|
||||
ctx.project().succeeding_rebases,
|
||||
)?;
|
||||
|
||||
Ok(Some(new_head_id))
|
||||
}
|
||||
@ -38,20 +45,19 @@ pub fn cherry_rebase(
|
||||
/// the difference between this and a libgit2 based rebase is that this will successfully
|
||||
/// rebase empty commits (two commits with identical trees)
|
||||
pub fn cherry_rebase_group(
|
||||
ctx: &CommandContext,
|
||||
repository: &git2::Repository,
|
||||
target_commit_oid: git2::Oid,
|
||||
ids_to_rebase: &mut [git2::Oid],
|
||||
ids_to_rebase: &[git2::Oid],
|
||||
succeeding_rebases: bool,
|
||||
) -> Result<git2::Oid> {
|
||||
ids_to_rebase.reverse();
|
||||
// now, rebase unchanged commits onto the new commit
|
||||
let commits_to_rebase = ids_to_rebase
|
||||
.iter()
|
||||
.map(|oid| ctx.repository().find_commit(oid.to_owned()))
|
||||
.map(|oid| repository.find_commit(oid.to_owned()))
|
||||
.rev()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("failed to read commits to rebase")?;
|
||||
|
||||
let repository = ctx.repository();
|
||||
|
||||
let new_head_id = commits_to_rebase
|
||||
.into_iter()
|
||||
.fold(
|
||||
@ -70,12 +76,24 @@ pub fn cherry_rebase_group(
|
||||
.context("failed to cherry pick")?;
|
||||
|
||||
if cherrypick_index.has_conflicts() {
|
||||
if !ctx.project().succeeding_rebases {
|
||||
if !succeeding_rebases {
|
||||
return Err(anyhow!("failed to rebase")).context(Marker::BranchConflict);
|
||||
}
|
||||
commit_conflicted_cherry_result(ctx, head, to_rebase, cherrypick_index)
|
||||
commit_conflicted_cherry_result(
|
||||
repository,
|
||||
head,
|
||||
to_rebase,
|
||||
cherrypick_index,
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
commit_unconflicted_cherry_result(ctx, head, to_rebase, cherrypick_index)
|
||||
commit_unconflicted_cherry_result(
|
||||
repository,
|
||||
head,
|
||||
to_rebase,
|
||||
cherrypick_index,
|
||||
None,
|
||||
)
|
||||
}
|
||||
},
|
||||
)?
|
||||
@ -84,13 +102,20 @@ pub fn cherry_rebase_group(
|
||||
Ok(new_head_id)
|
||||
}
|
||||
|
||||
pub struct OverrideCommitDetails<'a, 'repository> {
|
||||
message: &'a str,
|
||||
parents: &'a [&'a git2::Commit<'repository>],
|
||||
author: &'a git2::Signature<'repository>,
|
||||
commiter: &'a git2::Signature<'repository>,
|
||||
}
|
||||
|
||||
fn commit_unconflicted_cherry_result<'repository>(
|
||||
ctx: &'repository CommandContext,
|
||||
repository: &'repository git2::Repository,
|
||||
head: git2::Commit<'repository>,
|
||||
to_rebase: git2::Commit,
|
||||
mut cherrypick_index: git2::Index,
|
||||
override_commit_details: Option<OverrideCommitDetails>,
|
||||
) -> Result<git2::Commit<'repository>> {
|
||||
let repository = ctx.repository();
|
||||
let commit_headers = to_rebase.gitbutler_headers();
|
||||
|
||||
let is_merge_commit = to_rebase.parent_count() > 0;
|
||||
@ -113,17 +138,31 @@ fn commit_unconflicted_cherry_result<'repository>(
|
||||
..commit_headers
|
||||
});
|
||||
|
||||
let commit_oid = crate::RepositoryExt::commit_with_signature(
|
||||
repository,
|
||||
None,
|
||||
&to_rebase.author(),
|
||||
&to_rebase.committer(),
|
||||
&to_rebase.message_bstr().to_str_lossy(),
|
||||
&merge_tree,
|
||||
&[&head],
|
||||
commit_headers,
|
||||
)
|
||||
.context("failed to create commit")?;
|
||||
let commit_oid = if let Some(override_commit_details) = override_commit_details {
|
||||
crate::RepositoryExt::commit_with_signature(
|
||||
repository,
|
||||
None,
|
||||
override_commit_details.author,
|
||||
override_commit_details.commiter,
|
||||
override_commit_details.message,
|
||||
&merge_tree,
|
||||
override_commit_details.parents,
|
||||
commit_headers,
|
||||
)
|
||||
.context("failed to create commit")?
|
||||
} else {
|
||||
crate::RepositoryExt::commit_with_signature(
|
||||
repository,
|
||||
None,
|
||||
&to_rebase.author(),
|
||||
&to_rebase.committer(),
|
||||
&to_rebase.message_bstr().to_str_lossy(),
|
||||
&merge_tree,
|
||||
&[&head],
|
||||
commit_headers,
|
||||
)
|
||||
.context("failed to create commit")?
|
||||
};
|
||||
|
||||
repository
|
||||
.find_commit(commit_oid)
|
||||
@ -131,12 +170,12 @@ fn commit_unconflicted_cherry_result<'repository>(
|
||||
}
|
||||
|
||||
fn commit_conflicted_cherry_result<'repository>(
|
||||
ctx: &'repository CommandContext,
|
||||
repository: &'repository git2::Repository,
|
||||
head: git2::Commit,
|
||||
to_rebase: git2::Commit,
|
||||
cherrypick_index: git2::Index,
|
||||
override_commit_details: Option<OverrideCommitDetails>,
|
||||
) -> Result<git2::Commit<'repository>> {
|
||||
let repository = ctx.repository();
|
||||
let commit_headers = to_rebase.gitbutler_headers();
|
||||
|
||||
// If the commit we're rebasing is conflicted, use the commits original base.
|
||||
@ -201,33 +240,91 @@ fn commit_conflicted_cherry_result<'repository>(
|
||||
|
||||
let tree_oid = tree_writer.write().context("failed to write tree")?;
|
||||
|
||||
let commit_headers = commit_headers.map(|commit_headers| {
|
||||
let conflicted_file_count = conflicted_files
|
||||
.len()
|
||||
.try_into()
|
||||
.expect("If you have more than 2^64 conflicting files, we've got bigger problems");
|
||||
CommitHeadersV2 {
|
||||
conflicted: Some(conflicted_file_count),
|
||||
..commit_headers
|
||||
}
|
||||
});
|
||||
let commit_headers =
|
||||
commit_headers
|
||||
.or_else(|| Some(Default::default()))
|
||||
.map(|commit_headers| {
|
||||
let conflicted_file_count = conflicted_files.len().try_into().expect(
|
||||
"If you have more than 2^64 conflicting files, we've got bigger problems",
|
||||
);
|
||||
CommitHeadersV2 {
|
||||
conflicted: Some(conflicted_file_count),
|
||||
..commit_headers
|
||||
}
|
||||
});
|
||||
|
||||
// write a commit
|
||||
let commit_oid = crate::RepositoryExt::commit_with_signature(
|
||||
repository,
|
||||
None,
|
||||
&to_rebase.author(),
|
||||
&to_rebase.committer(),
|
||||
&to_rebase.message_bstr().to_str_lossy(),
|
||||
&repository
|
||||
.find_tree(tree_oid)
|
||||
.context("failed to find tree")?,
|
||||
&[&head],
|
||||
commit_headers,
|
||||
)
|
||||
.context("failed to create commit")?;
|
||||
let commit_oid = if let Some(override_commit_details) = override_commit_details {
|
||||
crate::RepositoryExt::commit_with_signature(
|
||||
repository,
|
||||
None,
|
||||
override_commit_details.author,
|
||||
override_commit_details.commiter,
|
||||
override_commit_details.message,
|
||||
&repository
|
||||
.find_tree(tree_oid)
|
||||
.context("failed to find tree")?,
|
||||
override_commit_details.parents,
|
||||
commit_headers,
|
||||
)
|
||||
.context("failed to create commit")?
|
||||
} else {
|
||||
crate::RepositoryExt::commit_with_signature(
|
||||
repository,
|
||||
None,
|
||||
&to_rebase.author(),
|
||||
&to_rebase.committer(),
|
||||
&to_rebase.message_bstr().to_str_lossy(),
|
||||
&repository
|
||||
.find_tree(tree_oid)
|
||||
.context("failed to find tree")?,
|
||||
&[&head],
|
||||
commit_headers,
|
||||
)
|
||||
.context("failed to create commit")?
|
||||
};
|
||||
|
||||
repository
|
||||
.find_commit(commit_oid)
|
||||
.context("failed to find commit")
|
||||
}
|
||||
|
||||
pub fn gitbutler_merge_commits<'repository>(
|
||||
repository: &'repository git2::Repository,
|
||||
target_commit: git2::Commit<'repository>,
|
||||
incoming_commit: git2::Commit<'repository>,
|
||||
target_branch_name: &str,
|
||||
incoming_branch_name: &str,
|
||||
) -> Result<git2::Commit<'repository>> {
|
||||
let cherrypick_index =
|
||||
repository.cherry_pick_gitbutler(&target_commit, &incoming_commit, None)?;
|
||||
|
||||
let (author, committer) = repository.signatures()?;
|
||||
|
||||
let override_commit_details = OverrideCommitDetails {
|
||||
message: &format!(
|
||||
"Merge branch `{}` into `{}`",
|
||||
incoming_branch_name, target_branch_name
|
||||
),
|
||||
parents: &[&target_commit.clone(), &incoming_commit.clone()],
|
||||
author: &author,
|
||||
commiter: &committer,
|
||||
};
|
||||
|
||||
if cherrypick_index.has_conflicts() {
|
||||
commit_conflicted_cherry_result(
|
||||
repository,
|
||||
target_commit,
|
||||
incoming_commit,
|
||||
cherrypick_index,
|
||||
Some(override_commit_details),
|
||||
)
|
||||
} else {
|
||||
commit_unconflicted_cherry_result(
|
||||
repository,
|
||||
target_commit,
|
||||
incoming_commit,
|
||||
cherrypick_index,
|
||||
Some(override_commit_details),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,14 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use gitbutler_branch::{gix_to_git2_signature, Branch, BranchId, SignaturePurpose};
|
||||
use gitbutler_branch::{Branch, BranchId};
|
||||
use gitbutler_command_context::CommandContext;
|
||||
use gitbutler_commit::commit_headers::CommitHeadersV2;
|
||||
use gitbutler_error::error::Code;
|
||||
use gitbutler_project::AuthKey;
|
||||
use gitbutler_reference::{Refname, RemoteRefname};
|
||||
|
||||
use crate::{askpass, credentials, Config, RepositoryExt};
|
||||
use crate::{askpass, credentials, RepositoryExt};
|
||||
pub trait RepoActionsExt {
|
||||
fn fetch(&self, remote_name: &str, askpass: Option<String>) -> Result<()>;
|
||||
fn push(
|
||||
@ -27,9 +27,6 @@ pub trait RepoActionsExt {
|
||||
commit_headers: Option<CommitHeadersV2>,
|
||||
) -> Result<git2::Oid>;
|
||||
fn distance(&self, from: git2::Oid, to: git2::Oid) -> Result<u32>;
|
||||
fn log(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Commit>>;
|
||||
fn list_commits(&self, from: git2::Oid, to: git2::Oid) -> Result<Vec<git2::Commit>>;
|
||||
fn l(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Oid>>;
|
||||
fn delete_branch_reference(&self, branch: &Branch) -> Result<()>;
|
||||
fn add_branch_reference(&self, branch: &Branch) -> Result<()>;
|
||||
fn git_test_push(
|
||||
@ -38,7 +35,6 @@ pub trait RepoActionsExt {
|
||||
branch_name: &str,
|
||||
askpass: Option<Option<BranchId>>,
|
||||
) -> Result<()>;
|
||||
fn signatures(&self) -> Result<(git2::Signature, git2::Signature)>;
|
||||
}
|
||||
|
||||
impl RepoActionsExt for CommandContext {
|
||||
@ -126,97 +122,9 @@ impl RepoActionsExt for CommandContext {
|
||||
.context("failed to lookup reference")
|
||||
}
|
||||
|
||||
// returns a list of commit oids from the first oid to the second oid
|
||||
fn l(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Oid>> {
|
||||
match to {
|
||||
LogUntil::Commit(oid) => {
|
||||
let mut revwalk = self
|
||||
.repository()
|
||||
.revwalk()
|
||||
.context("failed to create revwalk")?;
|
||||
revwalk
|
||||
.push(from)
|
||||
.context(format!("failed to push {}", from))?;
|
||||
revwalk
|
||||
.hide(oid)
|
||||
.context(format!("failed to hide {}", oid))?;
|
||||
revwalk
|
||||
.map(|oid| oid.map(Into::into))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
}
|
||||
LogUntil::Take(n) => {
|
||||
let mut revwalk = self
|
||||
.repository()
|
||||
.revwalk()
|
||||
.context("failed to create revwalk")?;
|
||||
revwalk
|
||||
.push(from)
|
||||
.context(format!("failed to push {}", from))?;
|
||||
revwalk
|
||||
.take(n)
|
||||
.map(|oid| oid.map(Into::into))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
}
|
||||
LogUntil::When(cond) => {
|
||||
let mut revwalk = self
|
||||
.repository()
|
||||
.revwalk()
|
||||
.context("failed to create revwalk")?;
|
||||
revwalk
|
||||
.push(from)
|
||||
.context(format!("failed to push {}", from))?;
|
||||
let mut oids: Vec<git2::Oid> = vec![];
|
||||
for oid in revwalk {
|
||||
let oid = oid.context("failed to get oid")?;
|
||||
oids.push(oid);
|
||||
|
||||
let commit = self
|
||||
.repository()
|
||||
.find_commit(oid)
|
||||
.context("failed to find commit")?;
|
||||
|
||||
if cond(&commit).context("failed to check condition")? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(oids)
|
||||
}
|
||||
LogUntil::End => {
|
||||
let mut revwalk = self
|
||||
.repository()
|
||||
.revwalk()
|
||||
.context("failed to create revwalk")?;
|
||||
revwalk
|
||||
.push(from)
|
||||
.context(format!("failed to push {}", from))?;
|
||||
revwalk
|
||||
.map(|oid| oid.map(Into::into))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
}
|
||||
}
|
||||
.context("failed to collect oids")
|
||||
}
|
||||
|
||||
fn list_commits(&self, from: git2::Oid, to: git2::Oid) -> Result<Vec<git2::Commit>> {
|
||||
Ok(self
|
||||
.l(from, LogUntil::Commit(to))?
|
||||
.into_iter()
|
||||
.map(|oid| self.repository().find_commit(oid))
|
||||
.collect::<Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
// returns a list of commits from the first oid to the second oid
|
||||
fn log(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Commit>> {
|
||||
self.l(from, to)?
|
||||
.into_iter()
|
||||
.map(|oid| self.repository().find_commit(oid))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("failed to collect commits")
|
||||
}
|
||||
|
||||
// returns the number of commits between the first oid to the second oid
|
||||
fn distance(&self, from: git2::Oid, to: git2::Oid) -> Result<u32> {
|
||||
let oids = self.l(from, LogUntil::Commit(to))?;
|
||||
let oids = self.repository().l(from, LogUntil::Commit(to))?;
|
||||
Ok(oids.len().try_into()?)
|
||||
}
|
||||
|
||||
@ -227,7 +135,10 @@ impl RepoActionsExt for CommandContext {
|
||||
parents: &[&git2::Commit],
|
||||
commit_headers: Option<CommitHeadersV2>,
|
||||
) -> Result<git2::Oid> {
|
||||
let (author, committer) = self.signatures().context("failed to get signatures")?;
|
||||
let (author, committer) = self
|
||||
.repository()
|
||||
.signatures()
|
||||
.context("failed to get signatures")?;
|
||||
self.repository()
|
||||
.commit_with_signature(
|
||||
None,
|
||||
@ -404,30 +315,6 @@ impl RepoActionsExt for CommandContext {
|
||||
|
||||
Err(anyhow!("authentication failed")).context(Code::ProjectGitAuth)
|
||||
}
|
||||
|
||||
fn signatures(&self) -> Result<(git2::Signature, git2::Signature)> {
|
||||
let repo = gix::open(self.repository().path())?;
|
||||
|
||||
let author = repo
|
||||
.author()
|
||||
.transpose()?
|
||||
.map(gitbutler_branch::gix_to_git2_signature)
|
||||
.transpose()?
|
||||
.context("No author is configured in Git")
|
||||
.context(Code::AuthorMissing)?;
|
||||
|
||||
let config: Config = self.repository().into();
|
||||
let committer = if config.user_real_comitter()? {
|
||||
repo.committer()
|
||||
.transpose()?
|
||||
.map(gix_to_git2_signature)
|
||||
.unwrap_or_else(|| gitbutler_branch::signature(SignaturePurpose::Committer))
|
||||
} else {
|
||||
gitbutler_branch::signature(SignaturePurpose::Committer)
|
||||
}?;
|
||||
|
||||
Ok((author, committer))
|
||||
}
|
||||
}
|
||||
|
||||
type OidFilter = dyn Fn(&git2::Commit) -> Result<bool>;
|
||||
|
@ -7,16 +7,23 @@ use std::{io::Write, path::Path, process::Stdio, str};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use bstr::BString;
|
||||
use git2::{BlameOptions, Tree};
|
||||
use gitbutler_branch::{gix_to_git2_signature, SignaturePurpose};
|
||||
use gitbutler_commit::{commit_buffer::CommitBuffer, commit_headers::CommitHeadersV2};
|
||||
use gitbutler_config::git::{GbConfig, GitConfig};
|
||||
use gitbutler_error::error::Code;
|
||||
use gitbutler_reference::{Refname, RemoteRefname};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{Config, LogUntil};
|
||||
|
||||
/// Extension trait for `git2::Repository`.
|
||||
///
|
||||
/// For now, it collects useful methods from `gitbutler-core::git::Repository`
|
||||
pub trait RepositoryExt {
|
||||
fn signatures(&self) -> Result<(git2::Signature, git2::Signature)>;
|
||||
fn l(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Oid>>;
|
||||
fn list_commits(&self, from: git2::Oid, to: git2::Oid) -> Result<Vec<git2::Commit>>;
|
||||
fn log(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Commit>>;
|
||||
/// Return `HEAD^{commit}` - ideal for obtaining the integration branch commit in open-workspace mode
|
||||
/// when it's clear that it's representing the current state.
|
||||
///
|
||||
@ -390,6 +397,103 @@ impl RepositoryExt for git2::Repository {
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()
|
||||
}
|
||||
|
||||
// returns a list of commit oids from the first oid to the second oid
|
||||
fn l(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Oid>> {
|
||||
match to {
|
||||
LogUntil::Commit(oid) => {
|
||||
let mut revwalk = self.revwalk().context("failed to create revwalk")?;
|
||||
revwalk
|
||||
.push(from)
|
||||
.context(format!("failed to push {}", from))?;
|
||||
revwalk
|
||||
.hide(oid)
|
||||
.context(format!("failed to hide {}", oid))?;
|
||||
revwalk
|
||||
.map(|oid| oid.map(Into::into))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
}
|
||||
LogUntil::Take(n) => {
|
||||
let mut revwalk = self.revwalk().context("failed to create revwalk")?;
|
||||
revwalk
|
||||
.push(from)
|
||||
.context(format!("failed to push {}", from))?;
|
||||
revwalk
|
||||
.take(n)
|
||||
.map(|oid| oid.map(Into::into))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
}
|
||||
LogUntil::When(cond) => {
|
||||
let mut revwalk = self.revwalk().context("failed to create revwalk")?;
|
||||
revwalk
|
||||
.push(from)
|
||||
.context(format!("failed to push {}", from))?;
|
||||
let mut oids: Vec<git2::Oid> = vec![];
|
||||
for oid in revwalk {
|
||||
let oid = oid.context("failed to get oid")?;
|
||||
oids.push(oid);
|
||||
|
||||
let commit = self.find_commit(oid).context("failed to find commit")?;
|
||||
|
||||
if cond(&commit).context("failed to check condition")? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(oids)
|
||||
}
|
||||
LogUntil::End => {
|
||||
let mut revwalk = self.revwalk().context("failed to create revwalk")?;
|
||||
revwalk
|
||||
.push(from)
|
||||
.context(format!("failed to push {}", from))?;
|
||||
revwalk
|
||||
.map(|oid| oid.map(Into::into))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
}
|
||||
}
|
||||
.context("failed to collect oids")
|
||||
}
|
||||
|
||||
fn list_commits(&self, from: git2::Oid, to: git2::Oid) -> Result<Vec<git2::Commit>> {
|
||||
Ok(self
|
||||
.l(from, LogUntil::Commit(to))?
|
||||
.into_iter()
|
||||
.map(|oid| self.find_commit(oid))
|
||||
.collect::<Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
// returns a list of commits from the first oid to the second oid
|
||||
fn log(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Commit>> {
|
||||
self.l(from, to)?
|
||||
.into_iter()
|
||||
.map(|oid| self.find_commit(oid))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("failed to collect commits")
|
||||
}
|
||||
|
||||
fn signatures(&self) -> Result<(git2::Signature, git2::Signature)> {
|
||||
let repo = gix::open(self.path())?;
|
||||
|
||||
let author = repo
|
||||
.author()
|
||||
.transpose()?
|
||||
.map(gitbutler_branch::gix_to_git2_signature)
|
||||
.transpose()?
|
||||
.context("No author is configured in Git")
|
||||
.context(Code::AuthorMissing)?;
|
||||
|
||||
let config: Config = self.into();
|
||||
let committer = if config.user_real_comitter()? {
|
||||
repo.committer()
|
||||
.transpose()?
|
||||
.map(gix_to_git2_signature)
|
||||
.unwrap_or_else(|| gitbutler_branch::signature(SignaturePurpose::Committer))
|
||||
} else {
|
||||
gitbutler_branch::signature(SignaturePurpose::Committer)
|
||||
}?;
|
||||
|
||||
Ok((author, committer))
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs the buffer with the configured gpg key, returning the signature.
|
||||
|
@ -4,7 +4,7 @@ use gitbutler_command_context::CommandContext;
|
||||
use gitbutler_commit::commit_ext::CommitExt;
|
||||
use gitbutler_repo::{
|
||||
create_change_reference, list_branch_references, push_change_reference,
|
||||
update_change_reference, LogUntil, RepoActionsExt,
|
||||
update_change_reference, LogUntil, RepositoryExt as _,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
@ -232,8 +232,12 @@ fn test_ctx(ctx: &CommandContext) -> Result<TestContext> {
|
||||
let branch = branches.iter().find(|b| b.name == "virtual").unwrap();
|
||||
let other_branch = branches.iter().find(|b| b.name != "virtual").unwrap();
|
||||
let target = handle.get_default_target()?;
|
||||
let branch_commits = ctx.log(branch.head, LogUntil::Commit(target.sha))?;
|
||||
let other_commits = ctx.log(other_branch.head, LogUntil::Commit(target.sha))?;
|
||||
let branch_commits = ctx
|
||||
.repository()
|
||||
.log(branch.head, LogUntil::Commit(target.sha))?;
|
||||
let other_commits = ctx
|
||||
.repository()
|
||||
.log(other_branch.head, LogUntil::Commit(target.sha))?;
|
||||
Ok(TestContext {
|
||||
branch: branch.clone(),
|
||||
commits: branch_commits,
|
||||
|
@ -188,6 +188,8 @@ fn main() {
|
||||
virtual_branches::commands::fetch_from_remotes,
|
||||
virtual_branches::commands::move_commit,
|
||||
virtual_branches::commands::normalize_branch_name,
|
||||
virtual_branches::commands::upstream_integration_statuses,
|
||||
virtual_branches::commands::integrate_upstream,
|
||||
secret::secret_get_global,
|
||||
secret::secret_set_global,
|
||||
undo::list_snapshots,
|
||||
|
@ -31,7 +31,6 @@ pub fn list_snapshots(
|
||||
#[instrument(skip(projects), err(Debug))]
|
||||
pub fn restore_snapshot(
|
||||
projects: State<'_, projects::Controller>,
|
||||
handle: tauri::AppHandle,
|
||||
project_id: ProjectId,
|
||||
sha: String,
|
||||
) -> Result<(), Error> {
|
||||
|
@ -3,6 +3,7 @@ pub mod commands {
|
||||
use gitbutler_branch::{
|
||||
BranchCreateRequest, BranchId, BranchOwnershipClaims, BranchUpdateRequest,
|
||||
};
|
||||
use gitbutler_branch_actions::upstream_integration::{BranchStatuses, Resolution};
|
||||
use gitbutler_branch_actions::{
|
||||
BaseBranch, BranchListing, BranchListingDetails, BranchListingFilter, RemoteBranch,
|
||||
RemoteBranchData, RemoteBranchFile, VirtualBranches,
|
||||
@ -595,6 +596,35 @@ pub mod commands {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(projects), err(Debug))]
|
||||
pub fn upstream_integration_statuses(
|
||||
projects: State<'_, projects::Controller>,
|
||||
project_id: ProjectId,
|
||||
) -> Result<BranchStatuses, Error> {
|
||||
let project = projects.get(project_id)?;
|
||||
Ok(gitbutler_branch_actions::upstream_integration_statuses(
|
||||
&project,
|
||||
)?)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(projects, windows), err(Debug))]
|
||||
pub fn integrate_upstream(
|
||||
windows: State<'_, WindowState>,
|
||||
projects: State<'_, projects::Controller>,
|
||||
project_id: ProjectId,
|
||||
resolutions: Vec<Resolution>,
|
||||
) -> Result<(), Error> {
|
||||
let project = projects.get(project_id)?;
|
||||
|
||||
gitbutler_branch_actions::integrate_upstream(&project, &resolutions)?;
|
||||
|
||||
emit_vbranches(&windows, project_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_vbranches(windows: &WindowState, project_id: projects::ProjectId) {
|
||||
if let Err(error) = windows.post(gitbutler_watcher::Action::CalculateVirtualBranches(
|
||||
project_id,
|
||||
|
@ -49,7 +49,10 @@ export default tsEslint.config(
|
||||
parser: svelteParser,
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
extraFileExtensions: ['.svelte']
|
||||
extraFileExtensions: ['.svelte'],
|
||||
svelteFeatures: {
|
||||
experimentalGenerics: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -1,4 +1,5 @@
|
||||
<script lang="ts" module>
|
||||
export type CheckboxStyle = 'default' | 'neutral';
|
||||
export interface CheckboxProps {
|
||||
name?: string;
|
||||
small?: boolean;
|
||||
@ -6,6 +7,7 @@
|
||||
checked?: boolean;
|
||||
value?: string;
|
||||
indeterminate?: boolean;
|
||||
style?: CheckboxStyle;
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
onchange?: (
|
||||
e: Event & {
|
||||
@ -25,6 +27,7 @@
|
||||
checked = $bindable(),
|
||||
value = '',
|
||||
indeterminate = false,
|
||||
style = 'default',
|
||||
onclick,
|
||||
onchange
|
||||
}: CheckboxProps = $props();
|
||||
@ -46,7 +49,7 @@
|
||||
onchange?.(e);
|
||||
}}
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
class={`checkbox ${style}`}
|
||||
class:small
|
||||
{value}
|
||||
id={name}
|
||||
@ -90,23 +93,19 @@
|
||||
border-color: none;
|
||||
}
|
||||
|
||||
&:indeterminate {
|
||||
background-color: var(--clr-bg-2);
|
||||
/* indeterminate */
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 50%;
|
||||
height: 2px;
|
||||
background-color: var(--clr-scale-ntrl-30);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
&:indeterminate::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 50%;
|
||||
height: 2px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* checked */
|
||||
&:checked {
|
||||
&.default:indeterminate {
|
||||
background-color: var(--clr-theme-pop-element);
|
||||
box-shadow: inset 0 0 0 1px var(--clr-theme-pop-element);
|
||||
|
||||
@ -114,6 +113,25 @@
|
||||
background-color: var(--clr-theme-pop-element-hover);
|
||||
}
|
||||
|
||||
&::before {
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&.neutral:indeterminate {
|
||||
background-color: var(--clr-bg-2);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--clr-bg-3);
|
||||
}
|
||||
|
||||
&::before {
|
||||
background-color: var(--clr-scale-ntrl-30);
|
||||
}
|
||||
}
|
||||
|
||||
/* checked */
|
||||
&:checked {
|
||||
&:disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.4;
|
||||
@ -127,6 +145,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.default:checked {
|
||||
background-color: var(--clr-theme-pop-element);
|
||||
box-shadow: inset 0 0 0 1px var(--clr-theme-pop-element);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--clr-theme-pop-element-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.neutral:checked {
|
||||
background-color: var(--clr-bg-2);
|
||||
box-shadow: inset 0 0 0 1px var(--clr-scale-ntrl-30);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--clr-bg-3);
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
@ -293,7 +293,7 @@
|
||||
},
|
||||
"50": {
|
||||
"$type": "color",
|
||||
"$value": "#48a8a3",
|
||||
"$value": "#3cb4ae",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {},
|
||||
@ -309,7 +309,7 @@
|
||||
},
|
||||
"60": {
|
||||
"$type": "color",
|
||||
"$value": "#97cecb",
|
||||
"$value": "#8fd6d2",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {},
|
||||
@ -325,7 +325,7 @@
|
||||
},
|
||||
"70": {
|
||||
"$type": "color",
|
||||
"$value": "#c6e7e5",
|
||||
"$value": "#c1ebe9",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {},
|
||||
@ -341,7 +341,7 @@
|
||||
},
|
||||
"80": {
|
||||
"$type": "color",
|
||||
"$value": "#daf1f0",
|
||||
"$value": "#d7f4f2",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {},
|
||||
@ -357,7 +357,7 @@
|
||||
},
|
||||
"90": {
|
||||
"$type": "color",
|
||||
"$value": "#e9f7f6",
|
||||
"$value": "#e7f8f7",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {},
|
||||
@ -373,7 +373,7 @@
|
||||
},
|
||||
"95": {
|
||||
"$type": "color",
|
||||
"$value": "#f4fbfa",
|
||||
"$value": "#f3fcfb",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {},
|
||||
@ -649,7 +649,7 @@
|
||||
},
|
||||
"50": {
|
||||
"$type": "color",
|
||||
"$value": "#dc9b14",
|
||||
"$value": "#dc9914",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {},
|
||||
@ -665,7 +665,7 @@
|
||||
},
|
||||
"60": {
|
||||
"$type": "color",
|
||||
"$value": "#f4bb6c",
|
||||
"$value": "#f4c06c",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {},
|
||||
@ -681,7 +681,7 @@
|
||||
},
|
||||
"70": {
|
||||
"$type": "color",
|
||||
"$value": "#feddae",
|
||||
"$value": "#fee1ae",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {},
|
||||
@ -697,7 +697,7 @@
|
||||
},
|
||||
"80": {
|
||||
"$type": "color",
|
||||
"$value": "#ffe8c7",
|
||||
"$value": "#ffecc7",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {},
|
||||
@ -713,7 +713,7 @@
|
||||
},
|
||||
"90": {
|
||||
"$type": "color",
|
||||
"$value": "#fff2e0",
|
||||
"$value": "#fff7e0",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {},
|
||||
@ -729,7 +729,7 @@
|
||||
},
|
||||
"95": {
|
||||
"$type": "color",
|
||||
"$value": "#fdf7ed",
|
||||
"$value": "#fdf9ed",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {},
|
||||
@ -859,7 +859,7 @@
|
||||
},
|
||||
"70": {
|
||||
"$type": "color",
|
||||
"$value": "#bef4da",
|
||||
"$value": "#c2f0da",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {},
|
||||
@ -875,7 +875,7 @@
|
||||
},
|
||||
"80": {
|
||||
"$type": "color",
|
||||
"$value": "#d0f7e5",
|
||||
"$value": "#d2f4e4",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {},
|
||||
@ -891,7 +891,7 @@
|
||||
},
|
||||
"90": {
|
||||
"$type": "color",
|
||||
"$value": "#e5faf0",
|
||||
"$value": "#e7f9f0",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {},
|
||||
@ -3652,12 +3652,12 @@
|
||||
"selected": {
|
||||
"count-bg": {
|
||||
"$type": "color",
|
||||
"$value": "#378bf2",
|
||||
"$value": "{clr-core.pop.70}",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {
|
||||
"light": "#378bf2",
|
||||
"dark": "#044289"
|
||||
"light": "{clr-core.pop.70}",
|
||||
"dark": "{clr-core.pop.10}"
|
||||
},
|
||||
"figma": {
|
||||
"variableId": "VariableID:3935:251",
|
||||
@ -3671,12 +3671,12 @@
|
||||
},
|
||||
"count-border": {
|
||||
"$type": "color",
|
||||
"$value": "#265dd4",
|
||||
"$value": "{clr-core.pop.60}",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {
|
||||
"light": "#265dd4",
|
||||
"dark": "#005cc5"
|
||||
"light": "{clr-core.pop.60}",
|
||||
"dark": "{clr-core.pop.30}"
|
||||
},
|
||||
"figma": {
|
||||
"variableId": "VariableID:3935:253",
|
||||
@ -3690,12 +3690,12 @@
|
||||
},
|
||||
"count-text": {
|
||||
"$type": "color",
|
||||
"$value": "#ffffff",
|
||||
"$value": "{clr-core.pop.40}",
|
||||
"$description": "",
|
||||
"$extensions": {
|
||||
"mode": {
|
||||
"light": "#ffffff",
|
||||
"dark": "#d6e8ff"
|
||||
"light": "{clr-core.pop.40}",
|
||||
"dark": "{clr-core.pop.50}"
|
||||
},
|
||||
"figma": {
|
||||
"variableId": "VariableID:3935:254",
|
||||
@ -4137,7 +4137,7 @@
|
||||
"useDTCGKeys": true,
|
||||
"colorMode": "hex",
|
||||
"variableCollections": ["clr-core", "clr", "size", "radius"],
|
||||
"createdAt": "2024-08-31T23:16:13.487Z"
|
||||
"createdAt": "2024-09-11T23:46:21.639Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,7 @@
|
||||
clickable?: boolean;
|
||||
showCheckbox?: boolean;
|
||||
checked?: boolean;
|
||||
indeterminate?: boolean;
|
||||
conflicted?: boolean;
|
||||
locked?: boolean;
|
||||
lockText?: string;
|
||||
@ -44,6 +45,7 @@
|
||||
clickable = true,
|
||||
showCheckbox = false,
|
||||
checked = $bindable(),
|
||||
indeterminate,
|
||||
conflicted,
|
||||
locked,
|
||||
lockText,
|
||||
@ -81,7 +83,7 @@
|
||||
}}
|
||||
>
|
||||
{#if showCheckbox}
|
||||
<Checkbox small {checked} onchange={oncheck} />
|
||||
<Checkbox small {checked} {indeterminate} onchange={oncheck} />
|
||||
{/if}
|
||||
<div class="info">
|
||||
<FileIcon {fileName} size={14} />
|
||||
|
@ -13,9 +13,16 @@ export const CheckboxStory: Story = {
|
||||
name: 'Checkbox',
|
||||
args: {
|
||||
name: 'Checkbox',
|
||||
style: 'default',
|
||||
checked: false,
|
||||
disabled: false,
|
||||
indeterminate: false,
|
||||
small: false
|
||||
},
|
||||
argTypes: {
|
||||
style: {
|
||||
options: ['default', 'neutral'],
|
||||
control: { type: 'select' }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -21,12 +21,12 @@
|
||||
--clr-core-pop-20: color(srgb 0.10980392156862745 0.32941176470588235 0.3176470588235294);
|
||||
--clr-core-pop-30: color(srgb 0.1450980392156863 0.43529411764705883 0.4196078431372549);
|
||||
--clr-core-pop-40: color(srgb 0.16470588235294117 0.5725490196078431 0.5529411764705883);
|
||||
--clr-core-pop-50: color(srgb 0.2823529411764706 0.6588235294117647 0.6392156862745098);
|
||||
--clr-core-pop-60: color(srgb 0.592156862745098 0.807843137254902 0.796078431372549);
|
||||
--clr-core-pop-70: color(srgb 0.7764705882352941 0.9058823529411765 0.8980392156862745);
|
||||
--clr-core-pop-80: color(srgb 0.8549019607843137 0.9450980392156862 0.9411764705882353);
|
||||
--clr-core-pop-90: color(srgb 0.9137254901960784 0.9686274509803922 0.9647058823529412);
|
||||
--clr-core-pop-95: color(srgb 0.9568627450980393 0.984313725490196 0.9803921568627451);
|
||||
--clr-core-pop-50: color(srgb 0.23529411764705882 0.7058823529411765 0.6823529411764706);
|
||||
--clr-core-pop-60: color(srgb 0.5607843137254902 0.8392156862745098 0.8235294117647058);
|
||||
--clr-core-pop-70: color(srgb 0.7568627450980392 0.9215686274509803 0.9137254901960784);
|
||||
--clr-core-pop-80: color(srgb 0.8431372549019608 0.9568627450980393 0.9490196078431372);
|
||||
--clr-core-pop-90: color(srgb 0.9058823529411765 0.9725490196078431 0.9686274509803922);
|
||||
--clr-core-pop-95: color(srgb 0.9529411764705882 0.9882352941176471 0.984313725490196);
|
||||
--clr-core-err-5: color(srgb 0.14901960784313725 0.050980392156862744 0.058823529411764705);
|
||||
--clr-core-err-10: color(srgb 0.2980392156862745 0.10196078431372549 0.12156862745098039);
|
||||
--clr-core-err-20: color(srgb 0.4196078431372549 0.1411764705882353 0.16862745098039217);
|
||||
@ -43,12 +43,12 @@
|
||||
--clr-core-warn-20: color(srgb 0.3764705882352941 0.2549019607843137 0.08627450980392157);
|
||||
--clr-core-warn-30: color(srgb 0.5411764705882353 0.34901960784313724 0.0784313725490196);
|
||||
--clr-core-warn-40: color(srgb 0.7803921568627451 0.4980392156862745 0.10196078431372549);
|
||||
--clr-core-warn-50: color(srgb 0.8627450980392157 0.6078431372549019 0.0784313725490196);
|
||||
--clr-core-warn-60: color(srgb 0.9568627450980393 0.7333333333333333 0.4235294117647059);
|
||||
--clr-core-warn-70: color(srgb 0.996078431372549 0.8666666666666667 0.6823529411764706);
|
||||
--clr-core-warn-80: color(srgb 1 0.9098039215686274 0.7803921568627451);
|
||||
--clr-core-warn-90: color(srgb 1 0.9490196078431372 0.8784313725490196);
|
||||
--clr-core-warn-95: color(srgb 0.9921568627450981 0.9686274509803922 0.9294117647058824);
|
||||
--clr-core-warn-50: color(srgb 0.8627450980392157 0.6 0.0784313725490196);
|
||||
--clr-core-warn-60: color(srgb 0.9568627450980393 0.7529411764705882 0.4235294117647059);
|
||||
--clr-core-warn-70: color(srgb 0.996078431372549 0.8823529411764706 0.6823529411764706);
|
||||
--clr-core-warn-80: color(srgb 1 0.9254901960784314 0.7803921568627451);
|
||||
--clr-core-warn-90: color(srgb 1 0.9686274509803922 0.8784313725490196);
|
||||
--clr-core-warn-95: color(srgb 0.9921568627450981 0.9764705882352941 0.9294117647058824);
|
||||
--clr-core-succ-5: color(srgb 0.050980392156862744 0.14901960784313725 0.10196078431372549);
|
||||
--clr-core-succ-10: color(srgb 0.10980392156862745 0.25098039215686274 0.1843137254901961);
|
||||
--clr-core-succ-20: color(srgb 0.13333333333333333 0.3254901960784314 0.23529411764705882);
|
||||
@ -56,9 +56,9 @@
|
||||
--clr-core-succ-40: color(srgb 0.23529411764705882 0.6039215686274509 0.43529411764705883);
|
||||
--clr-core-succ-50: color(srgb 0.2901960784313726 0.7098039215686275 0.5098039215686274);
|
||||
--clr-core-succ-60: color(srgb 0.5725490196078431 0.8666666666666667 0.7294117647058823);
|
||||
--clr-core-succ-70: color(srgb 0.7450980392156863 0.9568627450980393 0.8549019607843137);
|
||||
--clr-core-succ-80: color(srgb 0.8156862745098039 0.9686274509803922 0.8980392156862745);
|
||||
--clr-core-succ-90: color(srgb 0.8980392156862745 0.9803921568627451 0.9411764705882353);
|
||||
--clr-core-succ-70: color(srgb 0.7607843137254902 0.9411764705882353 0.8549019607843137);
|
||||
--clr-core-succ-80: color(srgb 0.8235294117647058 0.9568627450980393 0.8941176470588236);
|
||||
--clr-core-succ-90: color(srgb 0.9058823529411765 0.9764705882352941 0.9411764705882353);
|
||||
--clr-core-succ-95: color(srgb 0.9647058823529412 0.9882352941176471 0.984313725490196);
|
||||
--clr-core-purp-5: color(srgb 0.1568627450980392 0.11372549019607843 0.26666666666666666);
|
||||
--clr-core-purp-10: color(srgb 0.24705882352941178 0.17254901960784313 0.40784313725490196);
|
||||
@ -203,13 +203,9 @@
|
||||
--clr-diff-line-bg: var(--clr-bg-1);
|
||||
--clr-diff-count-bg: color(srgb 0.9686274509803922 0.9686274509803922 0.9647058823529412);
|
||||
--clr-diff-count-border: var(--clr-border-2);
|
||||
--clr-diff-selected-count-bg: color(
|
||||
srgb 0.21568627450980393 0.5450980392156862 0.9490196078431372
|
||||
);
|
||||
--clr-diff-selected-count-border: color(
|
||||
srgb 0.14901960784313725 0.36470588235294116 0.8313725490196079
|
||||
);
|
||||
--clr-diff-selected-count-text: color(srgb 1 1 1);
|
||||
--clr-diff-selected-count-bg: var(--clr-core-pop-70);
|
||||
--clr-diff-selected-count-border: var(--clr-core-pop-60);
|
||||
--clr-diff-selected-count-text: var(--clr-core-pop-40);
|
||||
--clr-diff-count-text: var(--clr-text-3);
|
||||
--clr-diff-deletion-line-bg: color(srgb 1 0.9411764705882353 0.9490196078431372);
|
||||
--clr-diff-deletion-line-highlight: color(
|
||||
@ -385,11 +381,9 @@
|
||||
--clr-diff-line-bg: var(--clr-bg-1);
|
||||
--clr-diff-count-bg: color(srgb 0.18823529411764706 0.17254901960784313 0.16862745098039217);
|
||||
--clr-diff-count-border: var(--clr-border-2);
|
||||
--clr-diff-selected-count-bg: color(
|
||||
srgb 0.01568627450980392 0.25882352941176473 0.5372549019607843
|
||||
);
|
||||
--clr-diff-selected-count-border: color(srgb 0 0.3607843137254902 0.7725490196078432);
|
||||
--clr-diff-selected-count-text: color(srgb 0.8392156862745098 0.9098039215686274 1);
|
||||
--clr-diff-selected-count-bg: var(--clr-core-pop-10);
|
||||
--clr-diff-selected-count-border: var(--clr-core-pop-30);
|
||||
--clr-diff-selected-count-text: var(--clr-core-pop-50);
|
||||
--clr-diff-count-text: var(--clr-text-3);
|
||||
--clr-diff-deletion-line-bg: color(
|
||||
srgb 0.23529411764705882 0.07450980392156863 0.10588235294117647
|
||||
|
Loading…
Reference in New Issue
Block a user