Refactor commits section of branch lanes

Fix remote lane line issue
Drop unused file
Fix criteria for showing push button
Fix alignment of avatar for local commit
Fix criteria for showing pus/rebase button
Show tooltip on shadow commit marker hover
Add missing type param to CommitCard
Fix lint
Remove extraneous parameter
Drop a couple of console.log() calls
This commit is contained in:
Mattias Granlund 2024-05-16 00:59:33 +02:00
parent deb1db188b
commit 31f1cb8607
27 changed files with 903 additions and 511 deletions

View File

@ -0,0 +1,37 @@
<script lang="ts">
import type { Author, CommitStatus } from '$lib/vbranches/types';
export let author: Author;
export let status: CommitStatus;
</script>
<img
class="avatar"
title={author.name}
alt="Gravatar for {author.email}"
srcset="{author.gravatarUrl} 2x"
width="100"
height="100"
class:remote={status == 'remote'}
class:local={status == 'local'}
class:upstream={status == 'upstream'}
on:error
/>
<style lang="postcss">
.avatar {
width: var(--size-16);
height: var(--size-16);
border-radius: var(--radius-m);
}
.local {
border: var(--size-2) solid var(--clr-commit-local);
}
.remote {
border: var(--size-2) solid var(--clr-commit-remote);
}
.upstream {
border: var(--size-2) solid var(--clr-commit-upstream);
}
</style>

View File

@ -48,7 +48,12 @@
</Button> </Button>
<div class="commits-list"> <div class="commits-list">
{#each base.upstreamCommits as commit} {#each base.upstreamCommits as commit}
<CommitCard {commit} isUnapplied={true} commitUrl={base.commitUrl(commit.id)} /> <CommitCard
{commit}
isUnapplied={true}
commitUrl={base.commitUrl(commit.id)}
type="upstream"
/>
{/each} {/each}
</div> </div>
<Spacer margin={2} /> <Spacer margin={2} />
@ -62,7 +67,7 @@
Local Local
</h1> </h1>
{#each base.recentCommits as commit} {#each base.recentCommits as commit}
<CommitCard {commit} isUnapplied={true} commitUrl={base.commitUrl(commit.id)} /> <CommitCard {commit} isUnapplied={true} commitUrl={base.commitUrl(commit.id)} type="remote" />
{/each} {/each}
</div> </div>
</div> </div>

View File

@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import BranchCommits from './BranchCommits.svelte';
import BranchFiles from './BranchFiles.svelte'; import BranchFiles from './BranchFiles.svelte';
import BranchFooter from './BranchFooter.svelte';
import BranchHeader from './BranchHeader.svelte'; import BranchHeader from './BranchHeader.svelte';
import CommitDialog from './CommitDialog.svelte'; import CommitDialog from './CommitDialog.svelte';
import CommitList from './CommitList.svelte';
import DropzoneOverlay from './DropzoneOverlay.svelte'; import DropzoneOverlay from './DropzoneOverlay.svelte';
import InfoMessage from './InfoMessage.svelte'; import InfoMessage from './InfoMessage.svelte';
import PullRequestCard from './PullRequestCard.svelte'; import PullRequestCard from './PullRequestCard.svelte';
@ -282,7 +283,8 @@
{/if} {/if}
</div> </div>
<BranchCommits {isUnapplied} /> <CommitList {isUnapplied} />
<BranchFooter {isUnapplied} />
</div> </div>
</ScrollableContainer> </ScrollableContainer>
<div class="divider-line"> <div class="divider-line">
@ -338,7 +340,6 @@
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
min-height: 100%; min-height: 100%;
gap: var(--size-8);
padding: var(--size-12); padding: var(--size-12);
} }

View File

@ -1,23 +0,0 @@
<script lang="ts">
import CommitList from './CommitList.svelte';
import {
getIntegratedCommits,
getLocalCommits,
getRemoteCommits,
getUnknownCommits
} from '$lib/vbranches/contexts';
export let isUnapplied: boolean;
const localCommits = getLocalCommits();
const remoteCommits = getRemoteCommits();
const integratedCommits = getIntegratedCommits();
const unknownCommits = getUnknownCommits();
</script>
{#if $unknownCommits && $unknownCommits.length > 0}
<CommitList {isUnapplied} commits={$unknownCommits} type="upstream" />
{/if}
<CommitList {isUnapplied} type="local" commits={$localCommits} />
<CommitList {isUnapplied} type="remote" commits={$remoteCommits} />
<CommitList {isUnapplied} type="integrated" commits={$integratedCommits} />

View File

@ -0,0 +1,65 @@
<script lang="ts">
import PassphraseBox from './PassphraseBox.svelte';
import PushButton, { BranchAction } from './PushButton.svelte';
import { PromptService } from '$lib/backend/prompt';
import { getContext, getContextStore } from '$lib/utils/context';
import { BranchController } from '$lib/vbranches/branchController';
import { getLocalCommits } from '$lib/vbranches/contexts';
import { Branch } from '$lib/vbranches/types';
export let isUnapplied: boolean;
const branchController = getContext(BranchController);
const promptService = getContext(PromptService);
const branch = getContextStore(Branch);
const [prompt, promptError] = promptService.reactToPrompt({
branchId: $branch.id,
timeoutMs: 30000
});
const localCommits = getLocalCommits();
let isLoading: boolean;
$: isPushed = $localCommits.length == 0;
</script>
{#if !isUnapplied}
<div class="actions">
{#if !isPushed}
{#if $prompt}
<PassphraseBox prompt={$prompt} error={$promptError} />
{/if}
<PushButton
wide
branch={$branch}
{isLoading}
on:trigger={async (e) => {
try {
if (e.detail.action == BranchAction.Push) {
isLoading = true;
await branchController.pushBranch($branch.id, $branch.requiresForce);
isLoading = false;
} else if (e.detail.action == BranchAction.Rebase) {
isLoading = true;
await branchController.mergeUpstream($branch.id);
isLoading = false;
}
} catch (e) {
console.error(e);
}
}}
/>
{:else}
Branch origin/second-branch is up to date with the remote.
{/if}
</div>
{/if}
<style lang="postcss">
.actions {
background: var(--clr-bg-1);
padding: var(--size-16);
border-radius: 0 0 var(--radius-m) var(--radius-m);
border: 1px solid var(--clr-border-2);
}
</style>

View File

@ -2,16 +2,21 @@
import ActiveBranchStatus from './ActiveBranchStatus.svelte'; import ActiveBranchStatus from './ActiveBranchStatus.svelte';
import BranchLabel from './BranchLabel.svelte'; import BranchLabel from './BranchLabel.svelte';
import BranchLanePopupMenu from './BranchLanePopupMenu.svelte'; import BranchLanePopupMenu from './BranchLanePopupMenu.svelte';
import PullRequestButton from './PullRequestButton.svelte';
import Tag from './Tag.svelte'; import Tag from './Tag.svelte';
import { Project } from '$lib/backend/projects'; import { Project } from '$lib/backend/projects';
import { BranchService } from '$lib/branches/service';
import { clickOutside } from '$lib/clickOutside'; import { clickOutside } from '$lib/clickOutside';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
import { GitHubService } from '$lib/github/service';
import { showError } from '$lib/notifications/toasts'; import { showError } from '$lib/notifications/toasts';
import { normalizeBranchName } from '$lib/utils/branch'; import { normalizeBranchName } from '$lib/utils/branch';
import { getContext, getContextStore } from '$lib/utils/context'; import { getContext, getContextStore } from '$lib/utils/context';
import { BranchController } from '$lib/vbranches/branchController'; import { BranchController } from '$lib/vbranches/branchController';
import { Branch } from '$lib/vbranches/types'; import { BaseBranch, Branch } from '$lib/vbranches/types';
import toast from 'svelte-french-toast';
import type { PullRequest } from '$lib/github/types';
import type { Persisted } from '$lib/persisted/persisted'; import type { Persisted } from '$lib/persisted/persisted';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@ -20,16 +25,21 @@
export let isLaneCollapsed: Persisted<boolean>; export let isLaneCollapsed: Persisted<boolean>;
const branchController = getContext(BranchController); const branchController = getContext(BranchController);
const githubService = getContext(GitHubService);
const branchStore = getContextStore(Branch); const branchStore = getContextStore(Branch);
const project = getContext(Project); const project = getContext(Project);
const branchService = getContext(BranchService);
const baseBranch = getContextStore(BaseBranch);
$: branch = $branchStore; $: branch = $branchStore;
$: hasPullRequest = branch.upstreamName && githubService.hasPr(branch.upstreamName);
let meatballButton: HTMLDivElement; let meatballButton: HTMLDivElement;
let visible = false; let visible = false;
let isApplying = false; let isApplying = false;
let isDeleting = false; let isDeleting = false;
let branchName = branch?.upstreamName || normalizeBranchName($branchStore.name); let branchName = branch?.upstreamName || normalizeBranchName($branchStore.name);
let isLoading: boolean;
function handleBranchNameChange(title: string) { function handleBranchNameChange(title: string) {
if (title == '') return; if (title == '') return;
@ -49,6 +59,34 @@
$: hasIntegratedCommits = branch.commits?.some((b) => b.isIntegrated); $: hasIntegratedCommits = branch.commits?.some((b) => b.isIntegrated);
let headerInfoHeight = 0; let headerInfoHeight = 0;
interface CreatePrOpts {
draft: boolean;
}
const defaultPrOpts: CreatePrOpts = {
draft: true
};
async function createPr(createPrOpts: CreatePrOpts): Promise<PullRequest | undefined> {
const opts = { ...defaultPrOpts, ...createPrOpts };
if (!githubService.isEnabled) {
toast.error('Cannot create PR without GitHub credentials');
return;
}
if (!$baseBranch?.shortName) {
toast.error('Cannot create PR without base branch');
return;
}
isLoading = true;
try {
return await branchService.createPr(branch, $baseBranch.shortName, opts.draft);
} finally {
isLoading = false;
}
}
</script> </script>
{#if $isLaneCollapsed} {#if $isLaneCollapsed}
@ -215,6 +253,12 @@
</Button> </Button>
{:else} {:else}
<div class="header__buttons"> <div class="header__buttons">
{#if !hasPullRequest}
<PullRequestButton
on:click={async (e) => await createPr({ draft: e.detail.action == 'draft' })}
loading={isLoading}
/>
{/if}
<Button <Button
style="ghost" style="ghost"
kind="solid" kind="solid"
@ -253,6 +297,7 @@
z-index: var(--z-lifted); z-index: var(--z-lifted);
position: sticky; position: sticky;
top: var(--size-12); top: var(--size-12);
padding-bottom: var(--size-8);
} }
.header { .header {
z-index: var(--z-lifted); z-index: var(--z-lifted);

View File

@ -10,7 +10,6 @@
import { getContext, getContextStoreBySymbol, createContextStore } from '$lib/utils/context'; import { getContext, getContextStoreBySymbol, createContextStore } from '$lib/utils/context';
import { isDefined } from '$lib/utils/typeguards'; import { isDefined } from '$lib/utils/typeguards';
import { import {
createIntegratedContextStore,
createLocalContextStore, createLocalContextStore,
createRemoteContextStore, createRemoteContextStore,
createSelectedFiles, createSelectedFiles,
@ -18,7 +17,7 @@
} 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 { Ownership } from '$lib/vbranches/ownership';
import { RemoteFile, Branch } from '$lib/vbranches/types'; import { RemoteFile, Branch, commitCompare, RemoteCommit } from '$lib/vbranches/types';
import lscache from 'lscache'; import lscache from 'lscache';
import { setContext } from 'svelte'; import { setContext } from 'svelte';
import { quintOut } from 'svelte/easing'; import { quintOut } from 'svelte/easing';
@ -40,11 +39,8 @@
const remoteCommits = createRemoteContextStore(branch.remoteCommits); const remoteCommits = createRemoteContextStore(branch.remoteCommits);
$: remoteCommits.set(branch.remoteCommits); $: remoteCommits.set(branch.remoteCommits);
const integratedCommits = createIntegratedContextStore(branch.integratedCommits);
$: integratedCommits.set(branch.integratedCommits);
// Set the store immediately so it can be updated later. // Set the store immediately so it can be updated later.
const unknownCommits = createUnknownContextStore([]); const upstreamCommits = createUnknownContextStore([]);
$: if (branch.upstream?.name) loadRemoteBranch(branch.upstream?.name); $: if (branch.upstream?.name) loadRemoteBranch(branch.upstream?.name);
const fileIdSelection = new FileIdSelection(); const fileIdSelection = new FileIdSelection();
@ -63,11 +59,20 @@
$: displayedFile = $selectedFiles.length == 1 ? $selectedFiles[0] : undefined; $: displayedFile = $selectedFiles.length == 1 ? $selectedFiles[0] : undefined;
async function loadRemoteBranch(name: string) { async function loadRemoteBranch(name: string) {
const remoteBranchData = await getRemoteBranchData(project.id, name); const upstream = await getRemoteBranchData(project.id, name);
const commits = remoteBranchData?.commits.filter( if (!upstream.commits) return;
(remoteCommit) => !branch.commits.find((commit) => remoteCommit.id == commit.id) const unknownCommits: RemoteCommit[] = [];
); upstream?.commits.forEach((upstreamCommit) => {
unknownCommits.set(commits); let match = branch.commits.find((commit) => commitCompare(upstreamCommit, commit));
if (match) {
match.relatedTo = upstreamCommit;
} else unknownCommits.push(upstreamCommit);
});
if (upstream.commits.length != unknownCommits.length) {
// Force update since we've mutated local commits
localCommits.set($localCommits);
}
upstreamCommits.set(unknownCommits);
} }
const project = getContext(Project); const project = getContext(Project);
@ -88,12 +93,7 @@
} }
</script> </script>
<div <div class="wrapper" data-tauri-drag-region class:file-selected={displayedFile}>
class="wrapper"
data-tauri-drag-region
class:target-branch={branch.active && branch.selectedForChanges}
class:file-selected={displayedFile}
>
<BranchCard {isUnapplied} {commitBoxOpen} bind:isLaneCollapsed /> <BranchCard {isUnapplied} {commitBoxOpen} bind:isLaneCollapsed />
{#if displayedFile} {#if displayedFile}
@ -140,10 +140,6 @@
background-color: var(--target-branch-background); background-color: var(--target-branch-background);
} }
.target-branch {
--target-branch-background: color-mix(in srgb, var(--clr-scale-pop-60) 20%, var(--clr-bg-2));
}
.file-preview { .file-preview {
display: flex; display: flex;
position: relative; position: relative;

View File

@ -37,7 +37,6 @@
$: branch = $branchStore; $: branch = $branchStore;
$: commits = branch.commits; $: commits = branch.commits;
$: setAIConfigurationValid($user); $: setAIConfigurationValid($user);
$: hasIntegratedCommits = branch.integratedCommits.length > 0;
async function setAIConfigurationValid(user: User | undefined) { async function setAIConfigurationValid(user: User | undefined) {
aiConfigurationValid = await aiService.validateConfiguration(user?.access_token); aiConfigurationValid = await aiService.validateConfiguration(user?.access_token);
@ -92,7 +91,7 @@
<ContextMenuSection> <ContextMenuSection>
<ContextMenuItem <ContextMenuItem
label="Set remote branch name" label="Set remote branch name"
disabled={isUnapplied || hasIntegratedCommits} disabled={isUnapplied}
on:click={() => { on:click={() => {
newRemoteName = branch.upstreamName || normalizeBranchName(branch.name) || ''; newRemoteName = branch.upstreamName || normalizeBranchName(branch.name) || '';
close(); close();

View File

@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import BranchFilesList from './BranchFilesList.svelte'; import BranchFilesList from './BranchFilesList.svelte';
import Icon from './Icon.svelte';
import { Project } from '$lib/backend/projects'; import { Project } from '$lib/backend/projects';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import CommitMessageInput from '$lib/components/CommitMessageInput.svelte'; import CommitMessageInput from '$lib/components/CommitMessageInput.svelte';
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import Tag from '$lib/components/Tag.svelte'; import Tag from '$lib/components/Tag.svelte';
import TimeAgo from '$lib/components/TimeAgo.svelte';
import { persistedCommitMessage } from '$lib/config/config'; import { persistedCommitMessage } from '$lib/config/config';
import { featureAdvancedCommitOperations } from '$lib/config/uiFeatureFlags'; import { featureAdvancedCommitOperations } from '$lib/config/uiFeatureFlags';
import { draggable } from '$lib/dragging/draggable'; import { draggable } from '$lib/dragging/draggable';
@ -16,7 +16,14 @@
import { createCommitStore, getSelectedFiles } from '$lib/vbranches/contexts'; import { createCommitStore, getSelectedFiles } from '$lib/vbranches/contexts';
import { FileIdSelection } from '$lib/vbranches/fileIdSelection'; import { FileIdSelection } from '$lib/vbranches/fileIdSelection';
import { listRemoteCommitFiles } from '$lib/vbranches/remoteCommits'; import { listRemoteCommitFiles } from '$lib/vbranches/remoteCommits';
import { RemoteCommit, Commit, RemoteFile, Branch, BaseBranch } from '$lib/vbranches/types'; import {
RemoteCommit,
Commit,
RemoteFile,
Branch,
BaseBranch,
type CommitStatus
} from '$lib/vbranches/types';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
export let branch: Branch | undefined = undefined; export let branch: Branch | undefined = undefined;
@ -24,6 +31,9 @@
export let commitUrl: string | undefined = undefined; export let commitUrl: string | undefined = undefined;
export let isHeadCommit: boolean = false; export let isHeadCommit: boolean = false;
export let isUnapplied = false; export let isUnapplied = false;
export let first = false;
export let last = false;
export let type: CommitStatus;
const branchController = getContext(BranchController); const branchController = getContext(BranchController);
const baseBranch = getContextStore(BaseBranch); const baseBranch = getContextStore(BaseBranch);
@ -140,8 +150,30 @@
: nonDraggable()} : nonDraggable()}
class="commit" class="commit"
class:is-commit-open={showFiles} class:is-commit-open={showFiles}
class:is-first={first}
class:is-last={last}
> >
<div
class="accent"
class:is-first={first}
class:is-last={last}
class:local={type == 'local'}
class:remote={type == 'remote'}
class:upstream={type == 'upstream'}
></div>
<div class="commit__header" on:click={toggleFiles} on:keyup={onKeyup} role="button" tabindex="0"> <div class="commit__header" on:click={toggleFiles} on:keyup={onKeyup} role="button" tabindex="0">
{#if first}
<div class="commit__type text-semibold text-base-12">
{#if type == 'remote'}
Local and remote <Icon name="local-remote" />
{:else if type == 'local'}
Local <Icon name="local" />
{:else if type == 'upstream'}
Remote upstream <Icon name="remote" />
{/if}
</div>
{/if}
<div class="commit__message"> <div class="commit__message">
{#if $advancedCommitOperations} {#if $advancedCommitOperations}
<div class="commit__id"> <div class="commit__id">
@ -204,23 +236,9 @@
{/if} {/if}
{/if} {/if}
</div> </div>
<div class="commit__row"> <!-- <span class="commit__time text-base-11">
<div class="commit__author">
<img
class="commit__avatar"
title="Gravatar for {commit.author.email}"
alt="Gravatar for {commit.author.email}"
srcset="{commit.author.gravatarUrl} 2x"
width="100"
height="100"
on:error
/>
<span class="commit__author-name text-base-12 truncate">{commit.author.name}</span>
</div>
<span class="commit__time text-base-11">
<TimeAgo date={commit.createdAt} /> <TimeAgo date={commit.createdAt} />
</span> </span> -->
</div>
</div> </div>
{#if showFiles} {#if showFiles}
@ -308,19 +326,51 @@
.commit { .commit {
display: flex; display: flex;
position: relative;
flex-direction: column; flex-direction: column;
border-radius: var(--size-6);
background-color: var(--clr-bg-1); background-color: var(--clr-bg-1);
border: 1px solid var(--clr-border-2); border: 1px solid var(--clr-border-2);
overflow: hidden; overflow: hidden;
transition: background-color var(--transition-fast); transition: background-color var(--transition-fast);
&.is-first {
border-top-left-radius: var(--radius-m);
border-top-right-radius: var(--radius-m);
}
&.is-last {
border-bottom-left-radius: var(--radius-m);
border-bottom-right-radius: var(--radius-m);
}
&:not(.is-first):not(.is-commit-open) {
border-top: none;
}
&:not(.is-commit-open):hover { &:not(.is-commit-open):hover {
background-color: var(--clr-bg-2); background-color: var(--clr-bg-2);
} }
} }
.accent {
position: absolute;
width: var(--size-4);
height: 100%;
&.local {
background-color: var(--clr-commit-local);
}
&.remote {
background-color: var(--clr-commit-remote);
}
&.upstream {
background-color: var(--clr-commit-upstream);
}
&.is-first {
border-top-left-radius: var(--radius-m);
}
&.is-last {
border-bottom-left-radius: var(--radius-m);
}
}
.commit__header { .commit__header {
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@ -329,8 +379,13 @@
padding: var(--size-14); padding: var(--size-14);
} }
.commit__type {
opacity: 0.4;
}
.is-commit-open { .is-commit-open {
background-color: var(--clr-bg-2); background-color: var(--clr-bg-2);
margin: 0.5rem 0;
& .commit__header { & .commit__header {
padding-bottom: var(--size-16); padding-bottom: var(--size-16);
@ -390,29 +445,6 @@
margin-bottom: -8px; margin-bottom: -8px;
} }
.commit__author {
display: block;
flex: 1;
display: flex;
align-items: center;
gap: var(--size-6);
}
.commit__avatar {
width: var(--size-16);
height: var(--size-16);
border-radius: 100%;
}
.commit__author-name {
max-width: calc(100% - var(--size-16));
}
.commit__time,
.commit__author-name {
color: var(--clr-scale-ntrl-50);
}
.files-container { .files-container {
background-color: var(--clr-bg-1); background-color: var(--clr-bg-1);
padding: 0 var(--size-14) var(--size-14); padding: 0 var(--size-14) var(--size-14);

View File

@ -65,7 +65,7 @@
</Button> </Button>
{/if} {/if}
<Button <Button
style="pop" style="neutral"
kind="solid" kind="solid"
grow grow
loading={isCommitting} loading={isCommitting}

View File

@ -0,0 +1,55 @@
<script lang="ts">
import LocalLine from './LocalLine.svelte';
import RemoteLine from './RemoteLine.svelte';
import ShadowLine from './ShadowLine.svelte';
import type { Commit, RemoteCommit } from '$lib/vbranches/types';
export let hasShadowColumn = false;
export let hasLocalColumn = false;
export let localCommit: Commit | undefined = undefined;
export let remoteCommit: RemoteCommit | undefined = undefined;
export let first = false;
export let localLine = false;
export let remoteLine = false;
export let upstreamLine = false;
export let shadowLine = false;
export let base = false;
</script>
<div class="lines" class:base>
{#if hasShadowColumn}
<ShadowLine
line={shadowLine}
dashed={base}
{upstreamLine}
{remoteCommit}
{localCommit}
{first}
/>
{/if}
<RemoteLine
commit={localCommit?.status == 'remote' ? localCommit : undefined}
line={localCommit?.status == 'remote' || remoteLine}
root={localLine}
{first}
{base}
/>
{#if hasLocalColumn}
<LocalLine
commit={localCommit?.status == 'local' ? localCommit : undefined}
dashed={localLine}
{first}
/>
{/if}
</div>
<style lang="postcss">
.lines {
display: flex;
align-items: stretch;
min-height: var(--size-16);
&.base {
height: var(--size-40);
}
}
</style>

View File

@ -1,65 +1,128 @@
<script lang="ts"> <script lang="ts">
import CommitListFooter from './CommitListFooter.svelte'; import CommitCard from './CommitCard.svelte';
import CommitListHeader from './CommitListHeader.svelte'; import CommitLines from './CommitLines.svelte';
import CommitListItem from './CommitListItem.svelte'; import CommitListItem from './CommitListItem.svelte';
import { getContext, getContextStore } from '$lib/utils/context'; import { getContextStore } from '$lib/utils/context';
import { Branch, type Commit, type CommitStatus, type RemoteCommit } from '$lib/vbranches/types'; import { getLocalCommits, getRemoteCommits, getUnknownCommits } from '$lib/vbranches/contexts';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch'; import { BaseBranch, Branch } from '$lib/vbranches/types';
import { map } from 'rxjs';
export let type: CommitStatus;
export let isUnapplied: boolean; export let isUnapplied: boolean;
export let commits: Commit[] | RemoteCommit[];
const branchService = getContext(VirtualBranchService);
const branch = getContextStore(Branch); const branch = getContextStore(Branch);
const localCommits = getLocalCommits();
const remoteCommits = getRemoteCommits();
const unknownCommits = getUnknownCommits();
const baseBranch = getContextStore(BaseBranch);
let headerHeight: number; $: hasShadowColumn = $localCommits.some((c) => !!c.relatedTo);
let expanded = true; $: hasLocalColumn = $localCommits.length > 0;
$: hasCommits = $branch.commits && $branch.commits.length > 0;
$: headCommit = $branch.commits[0]; $: headCommit = $branch.commits.at(0);
$: hasCommits = commits && commits.length > 0; $: hasUnknownCommits = $unknownCommits.length > 0;
$: remoteRequiresForcePush = type === 'remote' && $branch.requiresForce;
$: branchCount = branchService.activeBranches$.pipe(map((branches) => branches?.length || 0));
</script> </script>
{#if hasCommits || remoteRequiresForcePush} {#if hasCommits}
<div class="commit-list card" class:upstream={type == 'upstream'}> <div class="commit-list__content">
<CommitListHeader <div class="title text-base-13 text-semibold"></div>
{type} <div class="commits">
bind:expanded {#if $unknownCommits.length > 0}
bind:height={headerHeight} <CommitLines {hasShadowColumn} {hasLocalColumn} localLine />
isExpandable={hasCommits} {#each $unknownCommits as commit, idx (commit.id)}
commitCount={commits.length} <div class="flex">
/> <CommitLines
{#if expanded} {hasLocalColumn}
<div class="commit-list__content"> {hasShadowColumn}
{#if hasCommits} first={idx == 0}
<div class="commits"> localLine={hasLocalColumn}
{#each commits as commit, idx (commit.id)} remoteCommit={commit}
<CommitListItem upstreamLine
/>
<CommitListItem {commit}>
<CommitCard
branch={$branch}
{commit} {commit}
{isUnapplied} commitUrl={$baseBranch?.commitUrl(commit.id)}
isChained={idx != commits.length - 1}
isHeadCommit={commit.id === headCommit?.id} isHeadCommit={commit.id === headCommit?.id}
{isUnapplied}
first={idx == 0}
last={idx == $unknownCommits.length - 1}
type="upstream"
/> />
{/each} </CommitListItem>
</div> </div>
{/if} {/each}
{#if type == 'upstream' && $branchCount > 1} {/if}
<div class="upstream-message text-base-body-11"> {#if $localCommits.length > 0}
You have {$branchCount} active branches. To merge upstream work, we will unapply all other <CommitLines
branches. {hasShadowColumn}
</div>{/if} {hasLocalColumn}
<CommitListFooter {type} {isUnapplied} {hasCommits} /> upstreamLine={hasUnknownCommits}
</div> localLine
{/if} />
{#each $localCommits as commit, idx (commit.id)}
<div class="flex">
<CommitLines
{hasLocalColumn}
{hasShadowColumn}
localCommit={commit}
shadowLine={hasShadowColumn}
first={idx == 0}
upstreamLine={hasUnknownCommits}
/>
<CommitListItem {commit}>
<CommitCard
branch={$branch}
{commit}
commitUrl={$baseBranch?.commitUrl(commit.id)}
isHeadCommit={commit.id === headCommit?.id}
{isUnapplied}
first={idx == 0}
last={idx == $localCommits.length - 1}
type="local"
/>
</CommitListItem>
</div>
{/each}
{/if}
{#if $remoteCommits.length > 0}
<CommitLines {hasShadowColumn} {hasLocalColumn} localLine />
{#each $remoteCommits as commit, idx (commit.id)}
<div class="flex">
<CommitLines
{hasLocalColumn}
{hasShadowColumn}
localCommit={commit}
first={idx == 0}
upstreamLine={hasUnknownCommits}
/>
<CommitListItem {commit}>
<CommitCard
branch={$branch}
{commit}
commitUrl={$baseBranch?.commitUrl(commit.id)}
isHeadCommit={commit.id === headCommit?.id}
{isUnapplied}
first={idx == 0}
last={idx == $remoteCommits.length - 1}
type="remote"
/>
</CommitListItem>
</div>
{/each}
{/if}
<CommitLines
{hasShadowColumn}
localLine={$remoteCommits.length == 0 && $localCommits.length > 0}
remoteLine={$remoteCommits.length > 0}
shadowLine={hasShadowColumn}
base
/>
</div>
</div> </div>
{/if} {/if}
<style lang="postcss"> <style lang="postcss">
.commit-list { /* .commit-list {
&.upstream { &.upstream {
background-color: var(--clr-bg-2); background-color: var(--clr-bg-2);
} }
@ -68,19 +131,10 @@
flex-direction: column; flex-direction: column;
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
} } */
.commit-list__content { .commit-list__content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0 var(--size-14) var(--size-14) var(--size-14);
gap: var(--size-8);
}
.upstream-message {
color: var(--clr-scale-warn-30);
border-radius: var(--radius-m);
background: var(--clr-scale-warn-80);
padding: var(--size-12);
margin-left: var(--size-16);
} }
.commits { .commits {

View File

@ -1,149 +0,0 @@
<script lang="ts">
import PassphraseBox from './PassphraseBox.svelte';
import PushButton, { BranchAction } from './PushButton.svelte';
import { PromptService } from '$lib/backend/prompt';
import { BranchService } from '$lib/branches/service';
import Button from '$lib/components/Button.svelte';
import { GitHubService } from '$lib/github/service';
import { getContext, getContextStore } from '$lib/utils/context';
import { BranchController } from '$lib/vbranches/branchController';
import { BaseBranch, Branch, type CommitStatus } from '$lib/vbranches/types';
import toast from 'svelte-french-toast';
import type { PullRequest } from '$lib/github/types';
export let type: CommitStatus;
export let isUnapplied: boolean;
export let hasCommits: boolean;
const branchService = getContext(BranchService);
const githubService = getContext(GitHubService);
const branchController = getContext(BranchController);
const promptService = getContext(PromptService);
const baseBranch = getContextStore(BaseBranch);
const branch = getContextStore(Branch);
const [prompt, promptError] = promptService.reactToPrompt({
branchId: $branch.id,
timeoutMs: 30000
});
$: githubServiceState$ = githubService.getState($branch.id);
$: pr$ = githubService.getPr$($branch.upstreamName);
let isPushing: boolean;
let isMerging: boolean;
interface CreatePrOpts {
draft: boolean;
}
const defaultPrOpts: CreatePrOpts = {
draft: true
};
async function push() {
isPushing = true;
await branchController.pushBranch($branch.id, $branch.requiresForce);
isPushing = false;
}
async function createPr(createPrOpts: CreatePrOpts): Promise<PullRequest | undefined> {
const opts = { ...defaultPrOpts, ...createPrOpts };
if (!githubService.isEnabled) {
toast.error('Cannot create PR without GitHub credentials');
return;
}
if (!$baseBranch?.shortName) {
toast.error('Cannot create PR without base branch');
return;
}
isPushing = true;
try {
return await branchService.createPr($branch, $baseBranch.shortName, opts.draft);
} finally {
isPushing = false;
}
}
</script>
{#if !isUnapplied && type != 'integrated'}
<div class="actions" class:hasCommits>
{#if $prompt && type == 'local'}
<PassphraseBox prompt={$prompt} error={$promptError} />
{:else if githubService.isEnabled && (type == 'local' || type == 'remote')}
<PushButton
wide
isLoading={isPushing || $githubServiceState$?.busy}
isPr={!!$pr$}
{type}
branch={$branch}
githubEnabled={true}
on:trigger={async (e) => {
try {
if (e.detail.action == BranchAction.Pr) {
await createPr({ draft: false });
} else if (e.detail.action == BranchAction.DraftPr) {
await createPr({ draft: true });
} else {
await push();
}
} catch (e) {
console.error(e);
}
}}
/>
{:else if type == 'local'}
<Button
style="ghost"
kind="solid"
wide
loading={isPushing}
on:click={async () => {
try {
await push();
} catch {
toast.error('Failed to push');
}
}}
>
{#if $branch.requiresForce}
Force Push
{:else}
Push
{/if}
</Button>
{:else if type == 'upstream'}
<Button
style="warning"
kind="solid"
wide
loading={isMerging}
on:click={async () => {
isMerging = true;
try {
await branchController.mergeUpstream($branch.id);
} catch (err) {
toast.error('Failed to merge upstream commits');
} finally {
isMerging = false;
}
}}
>
Merge upstream commits
</Button>
{/if}
</div>
{/if}
<style lang="postcss">
.hasCommits {
padding-left: var(--size-16);
}
.actions {
&:empty {
display: none;
}
}
</style>

View File

@ -1,64 +0,0 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import { onMount } from 'svelte';
import type { CommitStatus } from '$lib/vbranches/types';
export let isExpandable = true;
export let expanded: boolean;
export let type: CommitStatus;
export let height: number | undefined;
export let commitCount = 0;
let element: HTMLButtonElement | undefined = undefined;
onMount(() => (height = element?.offsetHeight));
</script>
<button class="header" bind:this={element} on:click={() => (expanded = !expanded)}>
<div class="title text-base-13 text-semibold">
{#if type == 'local'}
Local
{:else if type == 'remote'}
Remote branch
{:else if type == 'integrated'}
Integrated
{:else if type == 'upstream'}
{commitCount} upstream {commitCount == 1 ? 'commit' : 'commits'}
<Icon name="warning" color="warning" />
{/if}
</div>
{#if isExpandable}
<div class="expander">
<Icon name={expanded ? 'chevron-down' : 'chevron-top'} />
</div>
{/if}
</button>
<style lang="postcss">
.header {
display: flex;
align-items: center;
padding: var(--size-16) var(--size-14) var(--size-16) var(--size-14);
justify-content: space-between;
gap: var(--size-8);
&:hover {
& .expander {
opacity: 1;
}
}
}
.title {
display: flex;
align-items: center;
color: var(--clr-scale-ntrl-0);
gap: var(--size-8);
overflow-x: hidden;
}
.expander {
color: var(--clr-scale-ntrl-50);
opacity: 0.5;
transition: opacity var(--transition-fast);
}
</style>

View File

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import CommitCard from './CommitCard.svelte';
import DropzoneOverlay from './DropzoneOverlay.svelte'; import DropzoneOverlay from './DropzoneOverlay.svelte';
import { Project } from '$lib/backend/projects'; import { Project } from '$lib/backend/projects';
import { DraggableCommit, DraggableFile, DraggableHunk } from '$lib/dragging/draggables'; import { DraggableCommit, DraggableFile, DraggableHunk } from '$lib/dragging/draggables';
@ -7,22 +6,11 @@
import { getContext, getContextStore } from '$lib/utils/context'; import { getContext, getContextStore } from '$lib/utils/context';
import { BranchController } from '$lib/vbranches/branchController'; import { BranchController } from '$lib/vbranches/branchController';
import { filesToOwnership, filesToSimpleOwnership } from '$lib/vbranches/ownership'; import { filesToOwnership, filesToSimpleOwnership } from '$lib/vbranches/ownership';
import { import { RemoteCommit, Branch, Commit, LocalFile, RemoteFile } from '$lib/vbranches/types';
RemoteCommit,
Branch,
type Commit,
BaseBranch,
LocalFile,
RemoteFile
} from '$lib/vbranches/types';
export let commit: Commit | RemoteCommit; export let commit: Commit | RemoteCommit;
export let isHeadCommit: boolean;
export let isChained: boolean;
export let isUnapplied = false;
const branchController = getContext(BranchController); const branchController = getContext(BranchController);
const baseBranch = getContextStore(BaseBranch);
const project = getContext(Project); const project = getContext(Project);
const branch = getContextStore(Branch); const branch = getContextStore(Branch);
@ -107,10 +95,6 @@
</script> </script>
<div class="commit-list-item"> <div class="commit-list-item">
{#if isChained}
<div class="line" />
{/if}
<div class="connector" />
<div <div
class="commit-card-wrapper" class="commit-card-wrapper"
use:dropzone={{ use:dropzone={{
@ -130,45 +114,21 @@
<DropzoneOverlay class="amend-dz-marker" label="Amend" /> <DropzoneOverlay class="amend-dz-marker" label="Amend" />
<DropzoneOverlay class="squash-dz-marker" label="Squash" /> <DropzoneOverlay class="squash-dz-marker" label="Squash" />
<CommitCard <slot />
branch={$branch}
{commit}
commitUrl={$baseBranch?.commitUrl(commit.id)}
{isHeadCommit}
{isUnapplied}
/>
</div> </div>
</div> </div>
<style> <style>
.commit-list-item { .commit-list-item {
display: flex; display: flex;
padding: 0 0 var(--size-6) var(--size-16);
position: relative; position: relative;
padding: 0;
gap: var(--size-8);
flex-grow: 1;
&:last-child { &:last-child {
padding-bottom: 0; padding-bottom: 0;
} }
} }
.line {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 1px;
background-color: var(--clr-border-2);
}
.connector {
width: 1rem;
height: 1.125rem;
position: absolute;
top: 0;
left: 0;
border-bottom: 1px solid var(--clr-border-2);
border-left: 1px solid var(--clr-border-2);
border-radius: 0 0 0 0.5rem;
}
.commit-card-wrapper { .commit-card-wrapper {
position: relative; position: relative;
width: 100%; width: 100%;

View File

@ -0,0 +1,90 @@
<script lang="ts">
import Avatar from './Avatar.svelte';
import type { Commit } from '$lib/vbranches/types';
export let dashed: boolean;
export let commit: Commit | undefined;
export let first: boolean;
$: hasRoot = isRoot(commit);
function isRoot(commit: Commit | undefined): boolean {
return !!commit && (commit.parent == undefined || commit.parent?.status == 'remote');
}
</script>
<div class="local-column">
{#if !commit && dashed}
<div class="local-line dashed"></div>
{:else if commit}
{#if first}
<div class="local-line dashed tip" />
{/if}
<div class="local-line" class:has-root={hasRoot} class:short={first} />
{/if}
{#if commit}
{@const author = commit.author}
<div class="avatar" class:first>
<Avatar {author} status={commit.status} />
</div>
{/if}
{#if hasRoot}
<div class="root" class:long-root={commit?.parent} />
{/if}
</div>
<style lang="postcss">
.local-column {
position: relative;
width: var(--size-16);
}
.avatar {
position: absolute;
top: var(--size-12);
left: calc(-1 * var(--size-4));
&.first {
top: 2.625rem;
}
}
.local-line {
position: absolute;
width: var(--size-2);
background-color: var(--clr-commit-local);
left: var(--size-4);
top: 0;
bottom: 0;
&.dashed {
background: repeating-linear-gradient(
0,
transparent,
transparent 0.1875rem,
var(--clr-commit-local) 0.1875rem,
var(--clr-commit-local) 0.4375rem
);
}
&.has-root {
bottom: var(--size-8);
}
&.tip {
bottom: calc(100% - 2.625rem);
}
&.short {
top: 2.625rem;
}
}
.root {
position: absolute;
width: var(--size-10);
top: calc(100% - var(--size-12));
left: calc(-1 * var(--size-4));
bottom: calc(-1 * var(--size-2));
border-radius: 0 0 var(--radius-l) 0;
border-color: var(--clr-commit-local);
border-width: 0 var(--size-2) var(--size-2) 0;
&.long-root {
bottom: -3rem;
}
}
</style>

View File

@ -0,0 +1,68 @@
<script lang="ts">
import DropDownButton from './DropDownButton.svelte';
import ContextMenu from './contextmenu/ContextMenu.svelte';
import ContextMenuItem from './contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from './contextmenu/ContextMenuSection.svelte';
import { persisted, type Persisted } from '$lib/persisted/persisted';
import * as toasts from '$lib/utils/toasts';
import { createEventDispatcher } from 'svelte';
enum Action {
Create = 'create',
Draft = 'draft'
}
const dispatch = createEventDispatcher<{ click: { action: Action } }>();
const action = defaultAction();
export let loading = false;
let dropDown: DropDownButton;
let contextMenu: ContextMenu;
$: selection$ = contextMenu?.selection$;
function defaultAction(): Persisted<Action> {
const key = 'projectDefaultPrAction';
return persisted<Action>(Action.Create, key);
}
</script>
<DropDownButton
style="ghost"
kind="solid"
{loading}
bind:this={dropDown}
on:click={() => {
dispatch('click', { action: $action });
}}
>
{$selection$?.label}
<ContextMenu
type="select"
slot="context-menu"
bind:this={contextMenu}
on:select={(e) => {
// TODO: Refactor to use generics if/when that works with Svelte
switch (e.detail?.id) {
case Action.Create:
$action = Action.Create;
break;
case Action.Draft:
$action = Action.Draft;
break;
default:
toasts.error('Unknown merge method');
}
dropDown.close();
}}
>
<ContextMenuSection>
<ContextMenuItem id={Action.Create} label="Create PR" selected={$action == Action.Create} />
<ContextMenuItem
id={Action.Draft}
label="Create Draft PR"
selected={$action == Action.Draft}
/>
</ContextMenuSection>
</ContextMenu>
</DropDownButton>

View File

@ -255,7 +255,7 @@
}} }}
/> />
</div> </div>
<div class="pr-title text-base-13 font-semibold"> <div class="pr-title text-base-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>
@ -350,6 +350,7 @@
.pr-card { .pr-card {
position: relative; position: relative;
padding: var(--size-14); padding: var(--size-14);
margin-bottom: var(--size-8);
} }
.pr-title { .pr-title {

View File

@ -1,14 +1,12 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export enum BranchAction { export enum BranchAction {
Push = 'push', Push = 'push',
Pr = 'pr', Rebase = 'rebase'
DraftPr = 'draftPr'
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import { Project } from '$lib/backend/projects'; import { Project } from '$lib/backend/projects';
import Button from '$lib/components/Button.svelte';
import DropDownButton from '$lib/components/DropDownButton.svelte'; import DropDownButton from '$lib/components/DropDownButton.svelte';
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte'; import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte'; import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
@ -16,17 +14,17 @@
import { persisted, type Persisted } from '$lib/persisted/persisted'; import { persisted, type Persisted } from '$lib/persisted/persisted';
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';
import { getLocalCommits, getUnknownCommits } from '$lib/vbranches/contexts';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import type { Branch } from '$lib/vbranches/types'; import type { Branch } from '$lib/vbranches/types';
export let type: string;
export let isLoading = false; export let isLoading = false;
export let githubEnabled: boolean;
export let wide = false; export let wide = false;
export let branch: Branch; export let branch: Branch;
export let isPr = false;
const project = getContext(Project); const project = getContext(Project);
const localCommits = getLocalCommits();
const unknownCommits = getUnknownCommits();
function defaultAction(): Persisted<BranchAction> { function defaultAction(): Persisted<BranchAction> {
const key = 'projectDefaultAction_'; const key = 'projectDefaultAction_';
@ -39,100 +37,80 @@
let contextMenu: ContextMenu; let contextMenu: ContextMenu;
let dropDown: DropDownButton; let dropDown: DropDownButton;
let disabled = false; let disabled = false;
let isPushed = $localCommits.length == 0 && !branch.requiresForce;
$: canBeRebased = $unknownCommits.length > 0;
$: selection$ = contextMenu?.selection$; $: selection$ = contextMenu?.selection$;
$: action = selectAction(isPushed, $preferredAction);
let action!: BranchAction; function selectAction(isPushed: boolean, preferredAction: BranchAction) {
$: { // TODO: Refactor such that this is not necessary
isPushed; // selectAction is dependant on isPushed if (isPushed) {
action = selectAction($preferredAction); return BranchAction.Rebase;
} } else if (!branch.requiresForce) {
function selectAction(preferredAction: BranchAction) {
if (isPushed && !githubEnabled) {
// TODO: Refactor such that this is not necessary
console.log('No push actions possible');
return BranchAction.Push;
} else if (isPushed) {
if (preferredAction == BranchAction.Push) return BranchAction.Pr;
return preferredAction;
} else if (!githubEnabled) {
return BranchAction.Push; return BranchAction.Push;
} }
return preferredAction; return preferredAction;
} }
$: pushLabel = branch.requiresForce ? 'Force push to remote' : 'Push to remote'; $: pushLabel = branch.requiresForce ? 'Force push' : 'Push';
$: commits = branch.commits.filter((c) => c.status == type);
$: isPushed = type === 'remote' && !branch.requiresForce;
</script> </script>
{#if (isPr || commits.length === 0) && !isPushed} <DropDownButton
<Button style="pop"
style="ghost" kind="soft"
kind="solid" loading={isLoading}
{wide} bind:this={dropDown}
disabled={isPushed} {wide}
loading={isLoading} {disabled}
on:click={() => { on:click={() => {
dispatch('trigger', { action: BranchAction.Push }); dispatch('trigger', { action });
}}>{pushLabel}</Button }}
> >
{:else if !isPr} {$selection$?.label}
<DropDownButton <ContextMenu
style="ghost" type="select"
kind="solid" slot="context-menu"
loading={isLoading} bind:this={contextMenu}
bind:this={dropDown} on:select={(e) => {
{wide} // TODO: Refactor to use generics if/when that works with Svelte
{disabled} switch (e.detail?.id) {
on:click={() => { case BranchAction.Push:
dispatch('trigger', { action }); $preferredAction = BranchAction.Push;
break;
case BranchAction.Rebase:
$preferredAction = BranchAction.Rebase;
break;
default:
toasts.error('Uknown branch action');
}
dropDown.close();
}} }}
> >
{$selection$?.label} <ContextMenuSection>
<ContextMenu {#if !isPushed}
type="select"
slot="context-menu"
bind:this={contextMenu}
on:select={(e) => {
// TODO: Refactor to use generics if/when that works with Svelte
switch (e.detail?.id) {
case BranchAction.Push:
$preferredAction = BranchAction.Push;
break;
case BranchAction.Pr:
$preferredAction = BranchAction.Pr;
break;
case BranchAction.DraftPr:
$preferredAction = BranchAction.DraftPr;
break;
default:
toasts.error('Uknown branch action');
}
dropDown.close();
}}
>
<ContextMenuSection>
<ContextMenuItem <ContextMenuItem
id="push" id="push"
label={pushLabel} label={pushLabel}
selected={action == BranchAction.Push} selected={action == BranchAction.Push}
disabled={isPushed} disabled={isPushed}
/> />
{/if}
{#if !branch.requiresForce}
<ContextMenuItem <ContextMenuItem
id="pr" id="rebase"
label="Create pull request" label="Rebase upstream"
disabled={!githubEnabled} selected={action == BranchAction.Rebase}
selected={action == BranchAction.Pr} disabled={isPushed}
/> />
{/if}
{#if canBeRebased}
<ContextMenuItem <ContextMenuItem
id="draftPr" id="rebase"
label="Create draft pull request" label="Rebase upstream"
disabled={!githubEnabled} selected={action == BranchAction.Rebase}
selected={action == BranchAction.DraftPr} disabled={isPushed}
/> />
</ContextMenuSection> {/if}
</ContextMenu> </ContextMenuSection>
</DropDownButton> </ContextMenu>
{/if} </DropDownButton>

View File

@ -68,7 +68,7 @@
{#if branchData.commits && branchData.commits.length > 0} {#if branchData.commits && branchData.commits.length > 0}
<div class="branch-preview__commits-list"> <div class="branch-preview__commits-list">
{#each branchData.commits as commit (commit.id)} {#each branchData.commits as commit (commit.id)}
<CommitCard {commit} commitUrl={$baseBranch?.commitUrl(commit.id)} /> <CommitCard {commit} commitUrl={$baseBranch?.commitUrl(commit.id)} type="remote" />
{/each} {/each}
</div> </div>
{/if} {/if}

View File

@ -0,0 +1,123 @@
<script lang="ts">
import Avatar from './Avatar.svelte';
import type { Commit } from '$lib/vbranches/types';
export let commit: Commit | undefined;
export let base: boolean;
export let first: boolean;
export let line: boolean;
export let root: boolean;
$: hasRoot = isRoot(commit);
function isRoot(commit: Commit | undefined): boolean {
return commit?.status == 'remote' && commit?.children?.[0]?.status == 'local';
}
</script>
<div class="remote-column" class:has-root={hasRoot}>
{#if base}
<div class="remote-line dashed" class:short={!line} />
{#if root}
<div class="root base" />
{/if}
<div class="commit-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.32501 7.25C4.67247 5.53832 6.18578 4.25 8 4.25C9.81422 4.25 11.3275 5.53832 11.675 7.25H14V8.75H11.675C11.3275 10.4617 9.81422 11.75 8 11.75C6.18578 11.75 4.67247 10.4617 4.32501 8.75H2V7.25H4.32501ZM8 5.75C6.75736 5.75 5.75 6.75736 5.75 8C5.75 9.24264 6.75736 10.25 8 10.25C9.24264 10.25 10.25 9.24264 10.25 8C10.25 6.75736 9.24264 5.75 8 5.75Z"
fill="white"
/>
</svg>
</div>
{:else}
{#if line}
<div class="remote-line" class:first></div>
{/if}
{#if hasRoot}
<div class="root" />
{/if}
{#if commit}
{@const author = commit.author}
<div class="avatar" class:first>
<Avatar {author} status={commit.status} />
</div>
{/if}
{/if}
</div>
<style lang="postcss">
.remote-column {
position: relative;
width: var(--size-24);
}
.remote-line {
position: absolute;
width: var(--size-2);
background-color: var(--clr-commit-remote);
left: calc(var(--size-10) + var(--size-1));
bottom: 0;
top: 0;
&.first {
top: calc(var(--size-40) + var(--size-2));
}
&.short {
top: 1rem;
}
&.dashed {
background: repeating-linear-gradient(
0,
transparent,
transparent 0.1875rem,
var(--clr-commit-remote) 0.1875rem,
var(--clr-commit-remote) 0.4375rem
);
}
}
.avatar {
position: absolute;
top: var(--size-10);
left: var(--size-4);
&.first {
top: calc(var(--size-40) + var(--size-2));
}
}
.root {
position: absolute;
width: var(--size-10);
top: 1.875rem;
border-radius: var(--radius-l) 0 0 0;
height: var(--size-10);
left: calc(var(--size-10) + var(--size-1));
border-color: var(--clr-commit-local);
border-width: var(--size-2) 0 0 var(--size-2);
&.base {
top: 0;
}
}
.commit-icon {
display: inline-block;
position: absolute;
border-radius: 6px;
left: var(--size-4);
background: var(--clr-commit-remote);
height: var(--size-16);
width: var(--size-16);
top: var(--size-10);
& svg {
height: var(--size-16);
width: var(--size-16);
}
}
</style>

View File

@ -0,0 +1,102 @@
<script lang="ts">
import { tooltip } from '$lib/utils/tooltip';
import type { Commit, RemoteCommit } from '$lib/vbranches/types';
export let line: boolean;
export let first: boolean;
export let remoteCommit: RemoteCommit | undefined;
export let localCommit: Commit | undefined;
export let dashed: boolean;
export let upstreamLine: boolean;
</script>
<div class="shadow-column">
{#if line}
{#if upstreamLine}
<div class="shadow-line tip" class:upstream={upstreamLine}></div>
{/if}
<div class="shadow-line" class:dashed class:short={first} />
{:else if upstreamLine}
<div class="shadow-line upstream" class:short={first} />
{/if}
{#if localCommit}
<div class="shadow-marker" class:first use:tooltip={localCommit.descriptionTitle}></div>
{/if}
{#if remoteCommit}
{@const author = remoteCommit.author}
<img
class="avatar avatar"
class:first
title={author.name}
alt="Gravatar for {author.email}"
srcset="{author.gravatarUrl} 2x"
width="100"
height="100"
on:error
/>
{/if}
</div>
<style lang="postcss">
.shadow-column {
position: relative;
}
.shadow-column {
width: var(--size-16);
}
.shadow-line {
position: absolute;
height: 100%;
width: var(--size-2);
background-color: var(--clr-commit-shadow);
left: 75%;
bottom: 0;
top: 0;
&.short {
top: 3rem;
}
&.dashed {
background: repeating-linear-gradient(
0,
transparent,
transparent 0.1875rem,
var(--clr-commit-shadow) 0.1875rem,
var(--clr-commit-shadow) 0.4375rem
);
}
&.tip {
bottom: calc(100% - 2.625rem);
}
&.upstream {
background-color: var(--clr-commit-upstream);
}
}
.shadow-marker {
position: absolute;
width: var(--size-10);
height: var(--size-10);
border-radius: 100%;
background-color: var(--clr-commit-shadow);
top: var(--size-14);
left: 50%;
&.first {
top: 2.75rem;
}
}
.avatar {
position: absolute;
width: var(--size-16);
height: var(--size-16);
border-radius: var(--radius-m);
top: var(--size-10);
left: var(--size-4);
border: var(--size-2) solid var(--clr-commit-upstream);
&.first {
top: var(--size-40);
}
}
</style>

View File

@ -151,7 +151,7 @@ export class GitHubService {
if (!skipCache) { if (!skipCache) {
const cachedRsp = lscache.get(key); const cachedRsp = lscache.get(key);
if (cachedRsp) subscriber.next(cachedRsp.data.map(ghResponseToInstance)); if (cachedRsp?.data) subscriber.next(cachedRsp.data.map(ghResponseToInstance));
} }
try { try {
@ -239,6 +239,10 @@ export class GitHubService {
return this.prs$.pipe(map((prs) => prs.find((pr) => pr.targetBranch == branch))); return this.prs$.pipe(map((prs) => prs.find((pr) => pr.targetBranch == branch)));
} }
hasPr(branch: string): boolean {
return !!this.prs$.value.find((pr) => pr.targetBranch == branch);
}
/* TODO: Figure out a way to cleanup old behavior subjects */ /* TODO: Figure out a way to cleanup old behavior subjects */
getState(branchId: string) { getState(branchId: string) {
let state$ = this.stateMap.get(branchId); let state$ = this.stateMap.get(branchId);

View File

@ -8,8 +8,6 @@ export const [getLocalCommits, createLocalContextStore] =
buildContextStore<Commit[]>('localCommits'); buildContextStore<Commit[]>('localCommits');
export const [getRemoteCommits, createRemoteContextStore] = export const [getRemoteCommits, createRemoteContextStore] =
buildContextStore<Commit[]>('remoteCommits'); buildContextStore<Commit[]>('remoteCommits');
export const [getIntegratedCommits, createIntegratedContextStore] =
buildContextStore<Commit[]>('integratedCommits');
export const [getUnknownCommits, createUnknownContextStore] = export const [getUnknownCommits, createUnknownContextStore] =
buildContextStore<RemoteCommit[]>('unknownCommits'); buildContextStore<RemoteCommit[]>('unknownCommits');
export const [getSelectedFiles, createSelectedFiles] = buildContextStore< export const [getSelectedFiles, createSelectedFiles] = buildContextStore<

View File

@ -131,10 +131,6 @@ export class Branch {
get remoteCommits() { get remoteCommits() {
return this.commits.filter((c) => c.status == 'remote'); return this.commits.filter((c) => c.status == 'remote');
} }
get integratedCommits() {
return this.commits.filter((c) => c.status == 'integrated');
}
} }
// Used for dependency injection // Used for dependency injection
@ -148,7 +144,7 @@ export type ComponentColor =
| 'error' | 'error'
| 'warning' | 'warning'
| 'purple'; | 'purple';
export type CommitStatus = 'local' | 'remote' | 'integrated' | 'upstream'; export type CommitStatus = 'local' | 'remote' | 'upstream';
export class Commit { export class Commit {
id!: string; id!: string;
@ -164,6 +160,7 @@ export class Commit {
branchId!: string; branchId!: string;
changeId!: string; changeId!: string;
isSigned!: boolean; isSigned!: boolean;
relatedTo?: RemoteCommit;
parent?: Commit; parent?: Commit;
children?: Commit[]; children?: Commit[];
@ -172,14 +169,9 @@ export class Commit {
return !this.isRemote && !this.isIntegrated; return !this.isRemote && !this.isIntegrated;
} }
get status() { get status(): CommitStatus {
if (!this.isIntegrated && !this.isRemote) { if (this.isRemote) return 'remote';
return 'local'; return 'local';
} else if (!this.isIntegrated && this.isRemote) {
return 'remote';
} else if (this.isIntegrated) {
return 'integrated';
}
} }
get descriptionTitle(): string | undefined { get descriptionTitle(): string | undefined {
@ -195,6 +187,10 @@ export class Commit {
} }
} }
export function isLocalCommit(obj: any): obj is Commit {
return obj instanceof Commit;
}
export class RemoteCommit { export class RemoteCommit {
id!: string; id!: string;
author!: Author; author!: Author;
@ -218,6 +214,14 @@ export class RemoteCommit {
get descriptionBody(): string | undefined { get descriptionBody(): string | undefined {
return splitMessage(this.description).description || undefined; return splitMessage(this.description).description || undefined;
} }
get status(): CommitStatus {
return 'upstream';
}
}
export function isRemoteCommit(obj: any): obj is RemoteCommit {
return obj instanceof RemoteCommit;
} }
export type AnyCommit = Commit | RemoteCommit; export type AnyCommit = Commit | RemoteCommit;
@ -227,6 +231,14 @@ export const REMOTE_COMMITS = Symbol('RemoteCommits');
export const INTEGRATED_COMMITS = Symbol('IntegratedCommits'); export const INTEGRATED_COMMITS = Symbol('IntegratedCommits');
export const UNKNOWN_COMMITS = Symbol('UnknownCommits'); export const UNKNOWN_COMMITS = Symbol('UnknownCommits');
export function commitCompare(left: AnyCommit, right: AnyCommit): boolean {
if (left.id == right.id) return true;
if (left.description != right.description) return false;
if (left.author.name != right.author.name) return false;
if (left.author.email != right.author.email) return false;
return true;
}
export class RemoteHunk { export class RemoteHunk {
diff!: string; diff!: string;
hash?: string; hash?: string;

View File

@ -58,11 +58,13 @@ export class VirtualBranchService {
const commits = branch.commits; const commits = branch.commits;
for (let j = 0; j < commits.length; j++) { for (let j = 0; j < commits.length; j++) {
const commit = commits[j]; const commit = commits[j];
if (j != 0) { if (j == 0) {
commit.parent = commits[j - 1]; commit.children = [];
} else {
commit.children = [commits[j - 1]];
} }
if (j != commits.length - 1) { if (j != commits.length - 1) {
commit.children = [commits[j + 1]]; commit.parent = commits[j + 1];
} }
} }
} }

View File

@ -189,6 +189,7 @@
--radius-l: 0.75rem; --radius-l: 0.75rem;
--radius-m: 0.375rem; --radius-m: 0.375rem;
--radius-s: 0.25rem; --radius-s: 0.25rem;
--size-1: 0.06125rem;
--size-2: 0.125rem; --size-2: 0.125rem;
--size-4: 0.25rem; --size-4: 0.25rem;
--size-6: 0.375rem; --size-6: 0.375rem;