Skip to content

Commit

Permalink
Merge pull request #6208 from gitbutlerapp/BR-cards
Browse files Browse the repository at this point in the history
BR-cards
  • Loading branch information
Caleb-T-Owens authored Feb 7, 2025
2 parents 0631c34 + 4581227 commit 8eee7b2
Show file tree
Hide file tree
Showing 12 changed files with 412 additions and 203 deletions.
183 changes: 183 additions & 0 deletions apps/desktop/src/components/BranchReview.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<script lang="ts">
import BranchReviewButRequest from '$components/BranchReviewButRequest.svelte';
import { BranchStack, type PatchSeries } from '$lib/branches/branch';
import { BranchController } from '$lib/branches/branchController';
import { cloudReviewFunctionality } from '$lib/config/uiFeatureFlags';
import { getForgePrService } from '$lib/forge/interface/forgePrService';
import { StackPublishingService } from '$lib/history/stackPublishingService';
import { getPr } from '$lib/pr/getPr.svelte';
import { getContextStore, inject } from '@gitbutler/shared/context';
import Button from '@gitbutler/ui/Button.svelte';
import ContextMenuItem from '@gitbutler/ui/ContextMenuItem.svelte';
import ContextMenuSection from '@gitbutler/ui/ContextMenuSection.svelte';
import DropDownButton from '@gitbutler/ui/DropDownButton.svelte';
import { isDefined } from '@gitbutler/ui/utils/typeguards';
import type { DetailedPullRequest } from '$lib/forge/interface/types';
import type { Snippet } from 'svelte';
// TODO: This and the SeriesHeader should have a wholistic refactor to
// reduce the complexity of the forge related functionality.
type Props = {
pullRequestCard: Snippet<[DetailedPullRequest]>;
branchStatus: Snippet;
branchLine: Snippet;
openForgePullRequest: () => void;
branch: PatchSeries;
};
const { pullRequestCard, branchStatus, branchLine, branch, openForgePullRequest }: Props =
$props();
const stack = getContextStore(BranchStack);
const [stackPublishingService, branchController] = inject(
StackPublishingService,
BranchController
);
async function publishReview() {
await branchController.pushBranch($stack.id, true);
await stackPublishingService.upsertStack($stack.id, branch.name);
}
const prService = getForgePrService();
const pr = getPr({
get current() {
return branch;
}
});
const enum CreationAction {
CreateBR,
CreatePR
}
const creationActionsDisplay = {
[CreationAction.CreateBR]: 'Create butler review',
[CreationAction.CreatePR]: 'Create pull request'
};
let selectedAction = $state<CreationAction>();
const actions = $derived.by(() => {
const out: CreationAction[] = [];
if ($prService && !pr.current) {
out.push(CreationAction.CreatePR);
}
if (stackPublishingService.canPublish && !branch.reviewId && $cloudReviewFunctionality) {
out.push(CreationAction.CreateBR);
}
return out;
});
$effect(() => {
selectedAction = actions.at(0);
});
let loading = $state(false);
async function create(action?: CreationAction) {
if (!isDefined(action)) return;
loading = true;
try {
switch (action) {
case CreationAction.CreatePR:
await openForgePullRequest();
break;
case CreationAction.CreateBR:
await publishReview();
break;
}
} finally {
loading = false;
}
}
const disabled = $derived(branch.patches.length === 0 || branch.conflicted);
const tooltip = $derived(
branch.conflicted ? 'Please resolve the conflicts before creating a PR' : undefined
);
let dropDownButton = $state<DropDownButton>();
</script>

<div class="branch-action">
{@render branchLine()}
<div class="branch-action__body">
{#if $prService && pr.current}
{@render pullRequestCard(pr.current)}
{/if}
{#if branch.reviewId}
<BranchReviewButRequest reviewId={branch.reviewId} />
{/if}

{@render branchStatus()}

{#if actions.length > 0 && isDefined(selectedAction)}
{#if actions.length > 1}
<DropDownButton
style="neutral"
kind="outline"
onclick={() => create(selectedAction)}
{loading}
bind:this={dropDownButton}
>
{creationActionsDisplay[selectedAction]}
{#snippet contextMenuSlot()}
<ContextMenuSection>
{#each actions as action}
<ContextMenuItem
label={creationActionsDisplay[action]}
onclick={() => {
selectedAction = action;
dropDownButton?.close();
}}
/>
{/each}
</ContextMenuSection>
{/snippet}
</DropDownButton>
{:else}
<Button
onclick={() => create(selectedAction)}
{disabled}
{tooltip}
{loading}
style="neutral"
kind="outline"
>
{creationActionsDisplay[selectedAction]}
</Button>
{/if}
{/if}
</div>
</div>

<style lang="postcss">
.branch-action {
width: 100%;
display: flex;
justify-content: flex-start;
align-items: stretch;
.branch-action__body {
width: 100%;
padding: 0 14px 14px 0;
display: flex;
flex-direction: column;
gap: 14px;
}
}
/*
The :empty selector does not work in svelte because undeterminate reasons.
As such we have this beauty.
All we want to do is to have this thing to not add extra whitespace if
there is nothing interesting going on inside of the component.
*/
.branch-action:not(:has(> .branch-action__body > *)) {
display: none;
}
</style>
120 changes: 120 additions & 0 deletions apps/desktop/src/components/BranchReviewButRequest.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<script lang="ts">
import { ProjectService } from '$lib/project/projectService';
import { sleep } from '$lib/utils/sleep';
import { BranchService as CloudBranchService } from '@gitbutler/shared/branches/branchService';
import { getBranchReview } from '@gitbutler/shared/branches/branchesPreview.svelte';
import { lookupLatestBranchUuid } from '@gitbutler/shared/branches/latestBranchLookup.svelte';
import { LatestBranchLookupService } from '@gitbutler/shared/branches/latestBranchLookupService';
import { inject } from '@gitbutler/shared/context';
import Loading from '@gitbutler/shared/network/Loading.svelte';
import { and, combine, isFound, isNotFound, map } from '@gitbutler/shared/network/loadable';
import { ProjectService as CloudProjectService } from '@gitbutler/shared/organizations/projectService';
import { getProjectByRepositoryId } from '@gitbutler/shared/organizations/projectsPreview.svelte';
import { AppState } from '@gitbutler/shared/redux/store.svelte';
import { WebRoutesService } from '@gitbutler/shared/routing/webRoutes.svelte';
import Link from '@gitbutler/ui/link/Link.svelte';
import { untrack } from 'svelte';
type Props = {
reviewId: string;
};
const { reviewId }: Props = $props();
const [
projectService,
appState,
cloudProjectService,
latestBranchLookupService,
cloudBranchService,
webRoutes
] = inject(
ProjectService,
AppState,
CloudProjectService,
LatestBranchLookupService,
CloudBranchService,
WebRoutesService
);
const project = projectService.project;
const cloudProject = $derived(
$project?.api?.repository_id
? getProjectByRepositoryId(appState, cloudProjectService, $project.api.repository_id)
: undefined
);
const cloudBranchUuid = $derived(
map(cloudProject?.current, (cloudProject) => {
return lookupLatestBranchUuid(
appState,
latestBranchLookupService,
cloudProject.owner,
cloudProject.slug,
reviewId
);
})
);
const cloudBranch = $derived(
map(cloudBranchUuid?.current, (cloudBranchUuid) => {
return getBranchReview(appState, cloudBranchService, cloudBranchUuid);
})
);
$effect(() => {
const options = { keepPolling: true };
if (anyNotFound()) {
pollWhileNotFound(reviewId, options);
}
return () => {
options.keepPolling = false;
};
});
async function pollWhileNotFound(reviewId: string, options: { keepPolling: boolean }) {
let counter = 0;
while (counter < 8 && options.keepPolling && untrack(() => anyNotFound())) {
await sleep(100 * (2 ^ counter));
await invalidateAll(reviewId);
}
}
function anyNotFound() {
return isNotFound(cloudBranchUuid?.current) || isNotFound(cloudBranch?.current);
}
async function invalidateAll(reviewId: string) {
await untrack(async () => {
if (!isFound(cloudProject?.current)) return;
if (isNotFound(cloudBranchUuid?.current)) {
await latestBranchLookupService.refreshBranchUuid(reviewId);
}
if (isFound(cloudBranchUuid?.current) && isNotFound(cloudBranch?.current)) {
await cloudBranchService.refreshBranch(cloudBranchUuid.current.value);
}
});
}
</script>

<Loading
loadable={and([cloudBranchUuid?.current, combine([cloudBranch?.current, cloudProject?.current])])}
>
{#snippet children([cloudBranch, cloudProject])}
<Link
target="_blank"
rel="noreferrer"
href={webRoutes.projectReviewBranchUrl({
ownerSlug: cloudProject.owner,
projectSlug: cloudProject.slug,
branchId: cloudBranch.branchId
})}
>
Open review</Link
>
{/snippet}
</Loading>
20 changes: 1 addition & 19 deletions apps/desktop/src/components/HeaderMetaSection.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@
import SeriesRowLabels from './SeriesLabels.svelte';
import BranchLaneContextMenu from '$components/BranchLaneContextMenu.svelte';
import { PatchSeries } from '$lib/branches/branch';
import { cloudReviewFunctionality } from '$lib/config/uiFeatureFlags';
import { StackPublishingService } from '$lib/history/stackPublishingService';
import { getContext } from '@gitbutler/shared/context';
import Button from '@gitbutler/ui/Button.svelte';
import ContextMenu from '@gitbutler/ui/ContextMenu.svelte';
Expand All @@ -14,21 +11,11 @@
stackId?: string;
}
const { series, onCollapseButtonClick, stackId }: Props = $props();
const { series, onCollapseButtonClick }: Props = $props();
let contextMenu = $state<ReturnType<typeof ContextMenu>>();
let kebabButtonEl: HTMLButtonElement | undefined = $state();
let isContextMenuOpen = $state(false);
const stackPublishingService = getContext(StackPublishingService);
const canPublish = stackPublishingService.canPublish;
let publishing = $state<'inert' | 'loading' | 'complete'>('inert');
async function publishStack() {
publishing = 'loading';
await stackPublishingService.upsertStack(stackId);
publishing = 'complete';
}
</script>

<div class="stack-meta">
Expand All @@ -52,11 +39,6 @@
ontoggle={(isOpen) => (isContextMenuOpen = isOpen)}
/>
</div>
{#if $cloudReviewFunctionality && $canPublish}
<div class="stack-meta-bottom">
<Button wide onclick={publishStack} loading={publishing === 'loading'}>Publish stack</Button>
</div>
{/if}
</div>

<style lang="postcss">
Expand Down
Loading

0 comments on commit 8eee7b2

Please sign in to comment.