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