Skip to content

Commit a6a757e

Browse files
committed
Cherry apply
1 parent 42f0742 commit a6a757e

File tree

10 files changed

+380
-29
lines changed

10 files changed

+380
-29
lines changed

Cargo.lock

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src/components/BranchesViewBranch.svelte

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<script lang="ts">
22
import BranchCard from '$components/BranchCard.svelte';
3+
import BranchesViewCommitContextMenu from '$components/BranchesViewCommitContextMenu.svelte';
4+
import CherryApplyModal from '$components/CherryApplyModal.svelte';
35
import CommitRow from '$components/CommitRow.svelte';
46
import ReduxResult from '$components/ReduxResult.svelte';
57
import { pushStatusToColor, pushStatusToIcon, type BranchDetails } from '$lib/stacks/stack';
@@ -29,6 +31,9 @@
2931
const uiState = inject(UI_STATE);
3032
const projectState = $derived(uiState.project(projectId));
3133
const branchesState = $derived(projectState.branchesSelection);
34+
35+
let cherryApplyModal = $state<CherryApplyModal>();
36+
let selectedCommitId = $state<string>();
3237
</script>
3338

3439
<ReduxResult result={branchQuery.result} {projectId} {stackId} {onerror}>
@@ -41,6 +46,16 @@
4146
{/snippet}
4247
</ReduxResult>
4348

49+
{#snippet commitMenu(rightClickTrigger: HTMLElement, commitId: string)}
50+
<BranchesViewCommitContextMenu
51+
{rightClickTrigger}
52+
onCherryPick={() => {
53+
selectedCommitId = commitId;
54+
cherryApplyModal?.open();
55+
}}
56+
/>
57+
{/snippet}
58+
4459
{#snippet branchCard(branch: BranchDetails, env: { projectId: string; stackId?: string })}
4560
{@const commitColor = getColorFromBranchType(pushStatusToColor(branch.pushStatus))}
4661
<BranchCard
@@ -68,8 +83,12 @@
6883
{#snippet branchContent()}
6984
<div class="branch-commits hide-when-empty">
7085
{#each branch.upstreamCommits || [] as commit, idx}
86+
{#snippet menu({ rightClickTrigger }: { rightClickTrigger: HTMLElement })}
87+
{@render commitMenu(rightClickTrigger, commit.id)}
88+
{/snippet}
7189
<CommitRow
72-
disableCommitActions
90+
disableCommitActions={false}
91+
stackId={env.stackId}
7392
type="Remote"
7493
active
7594
commitMessage={commit.message}
@@ -86,11 +105,18 @@
86105
});
87106
}}
88107
lastCommit={idx === branch.upstreamCommits.length - 1 && branch.commits.length === 0}
89-
/>
108+
menu={branchesState.current.inWorkspace || branchesState.current.isTarget
109+
? undefined
110+
: menu}
111+
></CommitRow>
90112
{/each}
91113
{#each branch.commits || [] as commit, idx}
114+
{#snippet menu({ rightClickTrigger }: { rightClickTrigger: HTMLElement })}
115+
{@render commitMenu(rightClickTrigger, commit.id)}
116+
{/snippet}
92117
<CommitRow
93-
disableCommitActions
118+
disableCommitActions={false}
119+
stackId={env.stackId}
94120
type={branch.commits.at(0)?.state.type || 'LocalOnly'}
95121
diverged={commit.state.type === 'LocalAndRemote' && commit.id !== commit.state.subject}
96122
commitMessage={commit.message}
@@ -108,13 +134,18 @@
108134
}}
109135
lastCommit={idx === branch.commits.length - 1}
110136
active
111-
/>
137+
menu={branchesState.current.inWorkspace || branchesState.current.isTarget
138+
? undefined
139+
: menu}
140+
></CommitRow>
112141
{/each}
113142
</div>
114143
{/snippet}
115144
</BranchCard>
116145
{/snippet}
117146

147+
<CherryApplyModal bind:this={cherryApplyModal} {projectId} subject={selectedCommitId} />
148+
118149
<style lang="postcss">
119150
.branch-commits {
120151
border-top: 1px solid var(--clr-border-2);
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<script lang="ts">
2+
import {
3+
ContextMenu,
4+
ContextMenuItem,
5+
ContextMenuSection,
6+
KebabButton,
7+
TestId
8+
} from '@gitbutler/ui';
9+
10+
type Props = {
11+
onCherryPick: () => void;
12+
rightClickTrigger: HTMLElement;
13+
};
14+
15+
const { onCherryPick: onclick, rightClickTrigger }: Props = $props();
16+
17+
let kebabButton = $state<HTMLElement>();
18+
let contextMenu = $state<ContextMenu>();
19+
</script>
20+
21+
<KebabButton
22+
flat
23+
bind:el={kebabButton}
24+
contextElement={rightClickTrigger}
25+
testId={TestId.KebabMenuButton}
26+
onclick={() => {
27+
contextMenu?.open();
28+
}}
29+
/>
30+
31+
<ContextMenu
32+
bind:this={contextMenu}
33+
leftClickTrigger={kebabButton}
34+
{rightClickTrigger}
35+
testId={TestId.CommitRowContextMenu}
36+
>
37+
<ContextMenuSection>
38+
<ContextMenuItem
39+
label="Cherry-pick commit"
40+
icon="cherry-pick"
41+
onclick={() => {
42+
contextMenu?.close();
43+
onclick();
44+
}}
45+
/>
46+
</ContextMenuSection>
47+
</ContextMenu>
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
<script lang="ts">
2+
import { goto } from '$app/navigation';
3+
import InfoMessage from '$components/InfoMessage.svelte';
4+
import ReduxResult from '$components/ReduxResult.svelte';
5+
import { CHERRY_APPLY_SERVICE } from '$lib/cherryApply/cherryApplyService';
6+
import { workspacePath } from '$lib/routes/routes.svelte';
7+
import { getStackName } from '$lib/stacks/stack';
8+
import { STACK_SERVICE } from '$lib/stacks/stackService.svelte';
9+
import { combineResults } from '$lib/state/helpers';
10+
import { inject } from '@gitbutler/core/context';
11+
import { Button, Modal, RadioButton, SectionCard } from '@gitbutler/ui';
12+
13+
type Props = {
14+
projectId: string;
15+
/** The commit hash to cherry-apply */
16+
subject?: string;
17+
};
18+
19+
let { projectId, subject }: Props = $props();
20+
21+
const cherryApplyService = inject(CHERRY_APPLY_SERVICE);
22+
const stackService = inject(STACK_SERVICE);
23+
24+
let modalRef = $state<Modal>();
25+
26+
const statusResult = $derived(
27+
subject ? cherryApplyService.status({ projectId, subject }) : undefined
28+
);
29+
const stacksResult = $derived(stackService.stacks(projectId));
30+
const status = $derived(statusResult?.response);
31+
32+
let selectedStackId = $state<string | undefined>(undefined);
33+
const [applyCommit, applyResult] = cherryApplyService.apply();
34+
35+
$effect(() => {
36+
if (status?.type === 'lockedToStack') {
37+
selectedStackId = status.subject;
38+
}
39+
});
40+
41+
export function close() {
42+
modalRef?.close();
43+
}
44+
45+
export function open() {
46+
modalRef?.show();
47+
}
48+
49+
async function handleApply() {
50+
if (!selectedStackId || !subject) return;
51+
52+
await applyCommit({
53+
projectId,
54+
subject,
55+
target: selectedStackId
56+
});
57+
58+
goto(workspacePath(projectId));
59+
60+
close();
61+
}
62+
63+
function getStatusMessage(): string {
64+
if (!status) return '';
65+
66+
switch (status.type) {
67+
case 'applicableToAnyStack':
68+
return 'This commit can be applied to any stack. Select a stack below.';
69+
case 'lockedToStack':
70+
return 'This commit conflicts when applied to the selected stack, as such it must be applied to the selected stack to avoid a workspace conflict.';
71+
case 'causesWorkspaceConflict':
72+
return "This commit can't be applied since it would cause a workspace.";
73+
case 'noStacks':
74+
return 'No stacks are currently applied to the workspace.';
75+
}
76+
}
77+
78+
const canApply = $derived(
79+
status?.type === 'applicableToAnyStack' || status?.type === 'lockedToStack'
80+
);
81+
const canSelectStack = $derived(status?.type === 'applicableToAnyStack');
82+
const isApplying = $derived(applyResult.current.isLoading);
83+
84+
function handleStackSelectionChange(form: HTMLFormElement) {
85+
const formData = new FormData(form);
86+
const selected = formData.get('stackSelection') as string | null;
87+
if (selected) {
88+
selectedStackId = selected;
89+
}
90+
}
91+
92+
const messageStyle = $derived(status?.type === 'causesWorkspaceConflict' ? 'warning' : 'info');
93+
</script>
94+
95+
<Modal bind:this={modalRef} title="Cherry-pick commit" width={500}>
96+
{#if statusResult}
97+
<ReduxResult {projectId} result={combineResults(statusResult?.result, stacksResult.result)}>
98+
{#snippet children([_status, stacks], { projectId: _projectId })}
99+
<div class="cherry-apply-modal">
100+
<InfoMessage style={messageStyle} outlined filled>
101+
{#snippet content()}
102+
{getStatusMessage()}
103+
{/snippet}
104+
</InfoMessage>
105+
106+
{#if canApply && stacks.length > 0}
107+
<form onchange={(e) => handleStackSelectionChange(e.currentTarget)}>
108+
{#each stacks as stack, idx (stack.id)}
109+
{@const isFirst = idx === 0}
110+
{@const isLast = idx === stacks.length - 1}
111+
{@const isDisabled = !canSelectStack && selectedStackId !== stack.id}
112+
<SectionCard
113+
orientation="row"
114+
roundedBottom={isLast}
115+
roundedTop={isFirst}
116+
labelFor="stack-{stack.id}"
117+
disabled={isDisabled}
118+
>
119+
{#snippet title()}
120+
{getStackName(stack)}
121+
{/snippet}
122+
{#snippet caption()}
123+
{stack.heads.length}
124+
{stack.heads.length === 1 ? 'branch' : 'branches'}
125+
{/snippet}
126+
{#snippet actions()}
127+
<RadioButton
128+
name="stackSelection"
129+
value={stack.id}
130+
id="stack-{stack.id}"
131+
checked={selectedStackId === stack.id}
132+
disabled={isDisabled}
133+
/>
134+
{/snippet}
135+
</SectionCard>
136+
{/each}
137+
</form>
138+
{/if}
139+
</div>
140+
{/snippet}
141+
</ReduxResult>
142+
{/if}
143+
{#snippet controls()}
144+
<Button kind="outline" onclick={close} disabled={isApplying}>Cancel</Button>
145+
<Button
146+
style="pop"
147+
onclick={handleApply}
148+
disabled={!canApply || !selectedStackId || isApplying}
149+
loading={isApplying}
150+
>
151+
Apply commit
152+
</Button>
153+
{/snippet}
154+
</Modal>
155+
156+
<style lang="postcss">
157+
.cherry-apply-modal {
158+
display: flex;
159+
flex-direction: column;
160+
gap: 16px;
161+
}
162+
</style>

apps/desktop/src/lib/cherryApply/cherryApplyService.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,23 @@ export class CherryApplyService {
2828
get status() {
2929
return this.api.endpoints.cherryApplyStatus.useQuery;
3030
}
31+
32+
get apply() {
33+
return this.api.endpoints.cherryApply.useMutation;
34+
}
3135
}
3236

3337
function injectEndpoints(backendApi: BackendApi) {
3438
return backendApi.injectEndpoints({
3539
endpoints: (build) => ({
36-
cherryApplyStatus: build.query<CherryApplyStatus, { projectId: string; subject: string }>(
37-
{
38-
extraOptions: { command: 'cherry_apply_status' },
39-
query: (args) => args
40-
}
41-
)
40+
cherryApplyStatus: build.query<CherryApplyStatus, { projectId: string; subject: string }>({
41+
extraOptions: { command: 'cherry_apply_status' },
42+
query: (args) => args
43+
}),
44+
cherryApply: build.mutation<void, { projectId: string; subject: string; target: string }>({
45+
extraOptions: { command: 'cherry_apply' },
46+
query: (args) => args
47+
})
4248
})
4349
});
4450
}

0 commit comments

Comments
 (0)