Merge pull request #1551 from gitbutlerapp/gb-601-squash-commits-ui

GB-601: squash commits ui
This commit is contained in:
Nikita Galaiko 2023-11-10 10:42:24 +01:00 committed by GitHub
commit 67cea3c292
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 322 additions and 151 deletions

View File

@ -13,7 +13,7 @@ use crate::{
reader,
sessions::{self, SessionId},
users,
virtual_branches::{self, target},
virtual_branches::target,
watcher,
};

View File

@ -206,7 +206,7 @@ fn main() {
virtual_branches::commands::cherry_pick_onto_virtual_branch,
virtual_branches::commands::amend_virtual_branch,
virtual_branches::commands::list_remote_branches,
virtual_branches::commands::squash,
virtual_branches::commands::squash_branch_commit,
keys::commands::get_public_key,
github::commands::init_device_oauth,
github::commands::check_auth_status,

View File

@ -493,7 +493,7 @@ pub async fn list_remote_branches(
#[tauri::command(async)]
#[instrument(skip(handle))]
pub async fn squash(
pub async fn squash_branch_commit(
handle: tauri::AppHandle,
project_id: &str,
branch_id: &str,

View File

@ -12,7 +12,7 @@ use slug::slugify;
use crate::{
dedup::{dedup, dedup_fmt},
gb_repository,
git::{self, diff, RemoteBranchName},
git::{self, diff, Commit, RemoteBranchName},
keys,
project_repository::{self, conflicts, LogUntil},
reader, sessions, users,
@ -68,6 +68,8 @@ pub struct VirtualBranchCommit {
pub is_remote: bool,
pub files: Vec<VirtualBranchFile>,
pub is_integrated: bool,
pub parent_ids: Vec<git::Oid>,
pub branch_id: BranchId,
}
// this struct is a mapping to the view `File` type in Typescript
@ -704,6 +706,7 @@ pub fn list_virtual_branches(
.map(|commit| {
commit_to_vbranch_commit(
project_repository,
branch,
&default_target,
commit,
Some(&pushed_commits),
@ -895,6 +898,7 @@ fn list_virtual_commit_files(
pub fn commit_to_vbranch_commit(
repository: &project_repository::Repository,
branch: &branch::Branch,
target: &target::Target,
commit: &git::Commit,
upstream_commits: Option<&HashMap<git::Oid, bool>>,
@ -913,6 +917,8 @@ pub fn commit_to_vbranch_commit(
let is_integrated = is_commit_integrated(repository, target, commit)?;
let parent_ids = commit.parents()?.iter().map(Commit::id).collect::<Vec<_>>();
let commit = VirtualBranchCommit {
id: commit.id(),
created_at: timestamp * 1000,
@ -921,6 +927,8 @@ pub fn commit_to_vbranch_commit(
is_remote,
files,
is_integrated,
parent_ids,
branch_id: branch.id,
};
Ok(commit)

View File

@ -0,0 +1,60 @@
import type { Commit, File, Hunk, RemoteCommit } from './vbranches/types';
export function nonDraggable() {
return {
disabled: true,
data: {}
};
}
export type DraggableHunk = {
branchId: string;
hunk: Hunk;
};
export function draggableHunk(branchId: string, hunk: Hunk) {
return { data: { branchId, hunk } };
}
export function isDraggableHunk(obj: any): obj is DraggableHunk {
return obj && obj.branchId && obj.hunk;
}
export type DraggableFile = {
branchId: string;
file: File;
};
export function draggableFile(branchId: string, file: File) {
return { data: { branchId, file } };
}
export function isDraggableFile(obj: any): obj is DraggableFile {
return obj && obj.branchId && obj.file;
}
export type DraggableCommit = {
branchId: string;
commit: Commit;
};
export function draggableCommit(branchId: string, commit: Commit) {
return { data: { branchId, commit } };
}
export function isDraggableCommit(obj: any): obj is DraggableCommit {
return obj && obj.branchId && obj.commit;
}
export type DraggableRemoteCommit = {
branchId: string;
remoteCommit: RemoteCommit;
};
export function draggableRemoteCommit(branchId: string, remoteCommit: RemoteCommit) {
return { data: { branchId, remoteCommit } };
}
export function isDraggableRemoteCommit(obj: any): obj is DraggableRemoteCommit {
return obj && obj.branchId && obj.remoteCommit;
}

View File

@ -17,42 +17,15 @@ const defaultDropzoneOptions: Dropzone = {
export function dropzone(node: HTMLElement, opts: Partial<Dropzone> | undefined) {
let currentOptions = { ...defaultDropzoneOptions, ...opts };
function handleDragEnter(e: DragEvent) {
if (activeZones.has(node)) {
node.classList.add(currentOptions.hover);
e.preventDefault();
}
}
function handleDragLeave(_e: DragEvent) {
if (activeZones.has(node)) {
node.classList.remove(currentOptions.hover);
}
}
function handleDragOver(e: DragEvent) {
if (activeZones.has(node)) {
e.preventDefault();
}
}
function setup(opts: Partial<Dropzone> | undefined) {
currentOptions = { ...defaultDropzoneOptions, ...opts };
if (currentOptions.disabled) return;
register(node, currentOptions);
node.addEventListener('dragenter', handleDragEnter);
node.addEventListener('dragleave', handleDragLeave);
node.addEventListener('dragover', handleDragOver);
}
function clean() {
unregister(currentOptions);
node.removeEventListener('dragenter', handleDragEnter);
node.removeEventListener('dragleave', handleDragLeave);
node.removeEventListener('dragover', handleDragOver);
}
setup(opts);
@ -96,6 +69,9 @@ export function draggable(node: HTMLElement, opts: Partial<Draggable> | undefine
let clone: HTMLElement;
const onDropListeners = new Map<HTMLElement, Array<(e: DragEvent) => void>>();
const onDragLeaveListeners = new Map<HTMLElement, Array<(e: DragEvent) => void>>();
const onDragEnterListeners = new Map<HTMLElement, Array<(e: DragEvent) => void>>();
const onDragOverListeners = new Map<HTMLElement, Array<(e: DragEvent) => void>>();
/**
* The problem with the ghost element is that it gets clipped after rotation unless we enclose
@ -129,6 +105,20 @@ export function draggable(node: HTMLElement, opts: Partial<Draggable> | undefine
dz.onDrop(currentOptions.data);
};
const onDragEnter = (e: DragEvent) => {
e.preventDefault();
target.classList.add(dz.hover);
};
const onDragLeave = (e: DragEvent) => {
e.preventDefault();
target.classList.remove(dz.hover);
};
const onDragOver = (e: DragEvent) => {
e.preventDefault();
};
// keep track of listeners so that we can remove them later
if (onDropListeners.has(target)) {
onDropListeners.get(target)!.push(onDrop);
@ -136,10 +126,33 @@ export function draggable(node: HTMLElement, opts: Partial<Draggable> | undefine
onDropListeners.set(target, [onDrop]);
}
if (onDragEnterListeners.has(target)) {
onDragEnterListeners.get(target)!.push(onDragEnter);
} else {
onDragEnterListeners.set(target, [onDragEnter]);
}
if (onDragLeaveListeners.has(target)) {
onDragLeaveListeners.get(target)!.push(onDragLeave);
} else {
onDragLeaveListeners.set(target, [onDragLeave]);
}
if (onDragOverListeners.has(target)) {
onDragOverListeners.get(target)!.push(onDragOver);
} else {
onDragOverListeners.set(target, [onDragOver]);
}
// https://stackoverflow.com/questions/14203734/dragend-dragenter-and-dragleave-firing-off-immediately-when-i-drag
setTimeout(() => target.classList.add(dz.active), 10);
setTimeout(() => {
target.classList.add(dz.active);
}, 10);
target.addEventListener('drop', onDrop);
target.addEventListener('dragenter', onDragEnter);
target.addEventListener('dragleave', onDragLeave);
target.addEventListener('dragover', onDragOver);
activeZones.add(target);
});
@ -156,14 +169,21 @@ export function draggable(node: HTMLElement, opts: Partial<Draggable> | undefine
.filter(([_node, dz]) => dz.accepts(currentOptions.data))
.forEach(([node, dz]) => {
// remove all listeners
const onDrop = onDropListeners.get(node);
if (onDrop) {
onDrop.forEach((listener) => {
node.removeEventListener('drop', listener);
});
}
onDropListeners.get(node)?.forEach((listener) => {
node.removeEventListener('drop', listener);
});
onDragEnterListeners.get(node)?.forEach((listener) => {
node.removeEventListener('dragenter', listener);
});
onDragLeaveListeners.get(node)?.forEach((listener) => {
node.removeEventListener('dragleave', listener);
});
onDragOverListeners.get(node)?.forEach((listener) => {
node.removeEventListener('dragover', listener);
});
node.classList.remove(dz.active);
node.classList.remove(dz.hover);
activeZones.delete(node);
});

View File

@ -254,6 +254,19 @@ export class BranchController {
}
}
async squashBranchCommit(branchId: string, targetCommitOid: string) {
try {
await invoke<void>('squash_branch_commit', {
projectId: this.projectId,
branchId,
targetCommitOid
});
await this.virtualBranchStore.reload();
} catch (err: any) {
toasts.error(`Failed to squash commit: ${err.message}`);
}
}
async amendBranch(branchId: string, ownership: string) {
try {
await invoke<void>('amend_virtual_branch', {

View File

@ -58,6 +58,8 @@ export class Commit {
isIntegrated!: boolean;
@Type(() => File)
files!: File[];
parentIds!: string[];
branchId!: string;
}
export class RemoteCommit {

View File

@ -1,21 +1,24 @@
<script lang="ts">
import {
isDraggableHunk,
isDraggableFile,
type DraggableFile,
type DraggableHunk
} from '$lib/draggables';
import { dropzone } from '$lib/utils/draggable';
import type { BranchController } from '$lib/vbranches/branchController';
import type { File, Hunk } from '$lib/vbranches/types';
export let branchController: BranchController;
function accepts(data: { hunk?: Hunk; file?: File }) {
if (data.hunk !== undefined) return true;
if (data.file !== undefined) return true;
return false;
function accepts(data: any) {
return isDraggableFile(data) || isDraggableHunk(data);
}
function onDrop(data: { hunk?: Hunk; file?: File }) {
if (data.hunk) {
function onDrop(data: DraggableFile | DraggableHunk) {
if (isDraggableHunk(data)) {
const ownership = `${data.hunk.filePath}:${data.hunk.id}`;
branchController.createBranch({ ownership });
} else if (data.file) {
} else if (isDraggableFile(data)) {
const ownership = `${data.file.path}:${data.file.hunks.map(({ id }) => id).join(',')}`;
branchController.createBranch({ ownership });
}

View File

@ -1,45 +0,0 @@
<script lang="ts">
import { dropzone } from '$lib/utils/draggable';
import type { Hunk, File, RemoteCommit } from '$lib/vbranches/types';
import type { BranchController } from '$lib/vbranches/branchController';
import CommitCard from './CommitCard.svelte';
export let branchController: BranchController;
export let branchId: string;
export let commit: RemoteCommit;
export let projectId: string;
export let commitUrl: string | undefined = undefined;
function acceptBranchDrop(data: { branchId: string; file?: File; hunk?: Hunk }) {
if (data.branchId !== branchId) return false;
return !!data.file || !!data.hunk;
}
function onDrop(data: { file?: File; hunk?: Hunk }) {
if (data.hunk) {
const newOwnership = `${data.hunk.filePath}:${data.hunk.id}`;
branchController.amendBranch(branchId, newOwnership);
} else if (data.file) {
const newOwnership = `${data.file.path}:${data.file.hunks.map(({ id }) => id).join(',')}`;
branchController.amendBranch(branchId, newOwnership);
}
}
</script>
<div
class="relative h-full w-full"
use:dropzone={{
active: 'amend-dz-active',
hover: 'amend-dz-hover',
accepts: acceptBranchDrop,
onDrop: onDrop
}}
>
<div
class="amend-dz-marker absolute z-10 hidden h-full w-full items-center justify-center rounded bg-blue-100/70 outline-dashed outline-2 -outline-offset-8 outline-light-600 dark:bg-blue-900/60 dark:outline-dark-300"
>
<div class="hover-text font-semibold">Amend</div>
</div>
<CommitCard {commit} {projectId} {commitUrl} />
</div>

View File

@ -1,8 +1,17 @@
<script lang="ts">
import { userStore } from '$lib/stores/user';
import type { BaseBranch, Branch, File, Hunk, RemoteCommit } from '$lib/vbranches/types';
import type { BaseBranch, Branch, Commit } from '$lib/vbranches/types';
import { getContext, onDestroy, onMount } from 'svelte';
import { draggable, dropzone } from '$lib/utils/draggable';
import {
isDraggableHunk,
isDraggableFile,
isDraggableCommit,
type DraggableCommit,
type DraggableFile,
type DraggableHunk,
draggableRemoteCommit
} from '$lib/draggables';
import { Ownership } from '$lib/vbranches/ownership';
import IconKebabMenu from '$lib/icons/IconKebabMenu.svelte';
import CommitCard from './CommitCard.svelte';
@ -39,7 +48,7 @@
import Button from '$lib/components/Button.svelte';
import Link from '$lib/components/Link.svelte';
import Modal from '$lib/components/Modal.svelte';
import AmendableCommitCard from './AmendableCommitCard.svelte';
import { isDraggableRemoteCommit, type DraggableRemoteCommit } from '$lib/draggables';
const [send, receive] = crossfade({
duration: (d) => Math.sqrt(d * 200),
@ -259,29 +268,32 @@
const selectedOwnership = writable(Ownership.fromBranch(branch));
$: if (commitDialogShown) selectedOwnership.set(Ownership.fromBranch(branch));
function acceptCherrypick(data: { branchId?: string; commit?: RemoteCommit }) {
return data?.branchId === branch.id && data.commit !== undefined;
function acceptCherrypick(data: any) {
return isDraggableRemoteCommit(data) && data.branchId == branch.id;
}
function onCherrypicked(data: { branchId: string; commit: RemoteCommit }) {
branchController.cherryPick(branch.id, data.commit.id);
function onCherrypicked(data: DraggableRemoteCommit) {
branchController.cherryPick(branch.id, data.remoteCommit.id);
}
function acceptBranchDrop(data: { branchId: string; file?: File; hunk?: Hunk }) {
if (data.branchId === branch.id) return false; // can't drag to the same branch
if (data.hunk !== undefined && !data.hunk.locked) return true; // can only drag not locked hunks
if (data.file !== undefined && data.file.hunks.some((hunk) => !hunk.locked)) return true; // can only draged non fully locked files
return false;
function acceptBranchDrop(data: any) {
if (isDraggableHunk(data) && data.branchId != branch.id) {
return true;
} else if (isDraggableFile(data) && data.branchId != branch.id) {
return true;
} else {
return false;
}
}
function onBranchDrop(data: { file?: File; hunk?: Hunk }) {
if (data.hunk) {
function onBranchDrop(data: DraggableHunk | DraggableFile) {
if (isDraggableHunk(data)) {
const newOwnership = `${data.hunk.filePath}:${data.hunk.id}`;
branchController.updateBranchOwnership(
branch.id,
(newOwnership + '\n' + branch.ownership).trim()
);
} else if (data.file) {
} else if (isDraggableFile(data)) {
const newOwnership = `${data.file.path}:${data.file.hunks.map(({ id }) => id).join(',')}`;
branchController.updateBranchOwnership(
branch.id,
@ -289,6 +301,59 @@
);
}
}
function acceptAmend(commit: Commit) {
return (data: any) => {
if (
isDraggableHunk(data) &&
data.branchId == branch.id &&
commit.id == branch.commits.at(0)?.id
) {
return true;
} else if (
isDraggableFile(data) &&
data.branchId == branch.id &&
commit.id == branch.commits.at(0)?.id
) {
return true;
} else {
return false;
}
};
}
function onAmend(data: DraggableFile | DraggableHunk) {
if (isDraggableHunk(data)) {
const newOwnership = `${data.hunk.filePath}:${data.hunk.id}`;
branchController.amendBranch(branch.id, newOwnership);
} else if (isDraggableFile(data)) {
const newOwnership = `${data.file.path}:${data.file.hunks.map(({ id }) => id).join(',')}`;
branchController.amendBranch(branch.id, newOwnership);
}
}
function acceptSquash(commit: Commit) {
return (data: any) => {
return (
isDraggableCommit(data) &&
data.branchId == branch.id &&
(commit.parentIds.includes(data.commit.id) || data.commit.parentIds.includes(commit.id))
);
};
}
function onSquash(commit: Commit) {
function isParentOf(commit: Commit, other: Commit) {
return commit.parentIds.includes(other.id);
}
return (data: DraggableCommit) => {
if (isParentOf(commit, data.commit)) {
branchController.squashBranchCommit(data.branchId, commit.id);
} else if (isParentOf(data.commit, commit)) {
branchController.squashBranchCommit(data.branchId, data.commit.id);
}
};
}
</script>
<div class="flex h-full shrink-0 snap-center" style:width={maximized ? '100%' : `${laneWidth}px`}>
@ -435,7 +500,7 @@
id="upstreamCommits"
>
{#each branch.upstream.commits as commit (commit.id)}
<div use:draggable={{ data: { branchId: branch.id, commit } }}>
<div use:draggable={draggableRemoteCommit(branch.id, commit)}>
<CommitCard {commit} {projectId} commitUrl={base?.commitUrl(commit.id)} />
</div>
{/each}
@ -649,17 +714,34 @@
<div class="border-color-4 h-3 w-3 rounded-full border-2" />
</div>
{/if}
{#if branch.commits.at(0)?.id === commit.id}
<AmendableCommitCard
{branchController}
branchId={branch.id}
{commit}
{projectId}
commitUrl={base?.commitUrl(commit.id)}
/>
{:else}
<div
class="relative h-full w-full"
use:dropzone={{
active: 'amend-dz-active',
hover: 'amend-dz-hover',
accepts: acceptAmend(commit),
onDrop: onAmend
}}
use:dropzone={{
active: 'squash-dz-active',
hover: 'squash-dz-hover',
accepts: acceptSquash(commit),
onDrop: onSquash(commit)
}}
>
<div
class="amend-dz-marker absolute z-10 hidden h-full w-full items-center justify-center rounded bg-blue-100/70 outline-dashed outline-2 -outline-offset-8 outline-light-600 dark:bg-blue-900/60 dark:outline-dark-300"
>
<div class="hover-text font-semibold">Amend</div>
</div>
<div
class="squash-dz-marker absolute z-10 hidden h-full w-full items-center justify-center rounded bg-blue-100/70 outline-dashed outline-2 -outline-offset-8 outline-light-600 dark:bg-blue-900/60 dark:outline-dark-300"
>
<div class="hover-text font-semibold">Squash</div>
</div>
<CommitCard {commit} {projectId} commitUrl={base?.commitUrl(commit.id)} />
{/if}
</div>
</div>
{/each}
</div>
@ -746,17 +828,35 @@
/>
</div>
{/if}
{#if branch.commits.at(0)?.id === commit.id}
<AmendableCommitCard
{branchController}
branchId={branch.id}
{commit}
{projectId}
commitUrl={base?.commitUrl(commit.id)}
/>
{:else}
<div
class="relative h-full w-full"
use:dropzone={{
active: 'amend-dz-active',
hover: 'amend-dz-hover',
accepts: acceptAmend(commit),
onDrop: onAmend
}}
use:dropzone={{
active: 'squash-dz-active',
hover: 'squash-dz-hover',
accepts: acceptSquash(commit),
onDrop: onSquash(commit)
}}
>
<div
class="amend-dz-marker absolute z-10 hidden h-full w-full items-center justify-center rounded bg-blue-100/70 outline-dashed outline-2 -outline-offset-8 outline-light-600 dark:bg-blue-900/60 dark:outline-dark-300"
>
<div class="hover-text font-semibold">Amend</div>
</div>
<div
class="squash-dz-marker absolute z-10 hidden h-full w-full items-center justify-center rounded bg-blue-100/70 outline-dashed outline-2 -outline-offset-8 outline-light-600 dark:bg-blue-900/60 dark:outline-dark-300"
>
<div class="hover-text font-semibold">Squash</div>
</div>
<CommitCard {commit} {projectId} commitUrl={base?.commitUrl(commit.id)} />
{/if}
</div>
</div>
{/each}
</div>
@ -884,4 +984,12 @@
:global(.cherrypick-dz-hover .hover-text) {
@apply visible;
}
/* squash drop zone */
:global(.squash-dz-active .squash-dz-marker) {
@apply flex;
}
:global(.squash-dz-hover .hover-text) {
@apply visible;
}
</style>

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { RemoteFile, type RemoteCommit } from '$lib/vbranches/types';
import { RemoteFile, type RemoteCommit, Commit } from '$lib/vbranches/types';
import TimeAgo from '$lib/components/TimeAgo.svelte';
import { getVSIFileIcon } from '$lib/ext-icons';
import { ContentSection, HunkSection, parseFileSections } from './fileSections';
@ -10,8 +10,10 @@
import Modal from '$lib/components/Modal.svelte';
import Button from '$lib/components/Button.svelte';
import Link from '$lib/components/Link.svelte';
import { draggableCommit, nonDraggable } from '$lib/draggables';
import { draggable } from '$lib/utils/draggable';
export let commit: RemoteCommit;
export let commit: Commit | RemoteCommit;
export let projectId: string;
export let commitUrl: string | undefined = undefined;
@ -35,10 +37,12 @@
</script>
<div
class="text-color-2 bg-color-5 border-color-4 relative w-full truncate rounded border p-2 text-left"
use:draggable={commit instanceof Commit
? draggableCommit(commit.branchId, commit)
: nonDraggable()}
>
<div class="mb-1 flex justify-between">
<div class="truncate">
<div class="text-color-2 bg-color-5 border-color-4 truncate rounded border p-2 text-left">
<div class="mb-1 truncate">
<button
on:click={() => {
loadEntries();
@ -48,21 +52,21 @@
{commit.description}
</button>
</div>
</div>
<div class="text-color-3 flex space-x-1 text-sm">
<img
class="relative inline-block h-4 w-4 rounded-full ring-1 ring-white dark:ring-black"
title="Gravatar for {commit.author.email}"
alt="Gravatar for {commit.author.email}"
srcset="{commit.author.gravatarUrl} 2x"
width="100"
height="100"
on:error
/>
<div class="flex-grow truncate">{commit.author.name}</div>
<div class="truncate">
<TimeAgo date={commit.createdAt} />
<div class="text-color-3 flex space-x-1 text-sm">
<img
class="relative inline-block h-4 w-4 rounded-full ring-1 ring-white dark:ring-black"
title="Gravatar for {commit.author.email}"
alt="Gravatar for {commit.author.email}"
srcset="{commit.author.gravatarUrl} 2x"
width="100"
height="100"
on:error
/>
<div class="flex-1 truncate">{commit.author.name}</div>
<div class="truncate">
<TimeAgo date={commit.createdAt} />
</div>
</div>
</div>
</div>
@ -77,7 +81,7 @@
<div class="overflow-y-scroll">
{#if isLoading}
<div class="flex w-full justify-center">
<div class="h-32 w-32 animate-spin rounded-full border-b-2 border-gray-900" />
<div class="border-gray-900 h-32 w-32 animate-spin rounded-full border-b-2" />
</div>
{:else}
{#each entries as [filepath, sections]}

View File

@ -23,6 +23,7 @@
import Tooltip from '$lib/components/Tooltip.svelte';
import IconLock from '$lib/icons/IconLock.svelte';
import HunkContextMenu from './HunkContextMenu.svelte';
import { draggableFile, draggableHunk } from '$lib/draggables';
export let branchId: string;
export let file: File;
@ -96,7 +97,7 @@
<div
id={`file-${file.id}`}
use:draggable={{
data: { branchId, file },
...draggableFile(branchId, file),
disabled: readonly
}}
class="changed-file inner"
@ -192,10 +193,7 @@
tabindex="0"
role="cell"
use:draggable={{
data: {
branchId,
hunk: section.hunk
},
...draggableHunk(branchId, section.hunk),
disabled: readonly
}}
on:dblclick