Change the UX around applying/unapplying lanes

- checkbox has been removed
- actions appear on hovering tray vbranches
- close icon in top right corner of lane stashes lane
- virtual branches tray section now shows stashed branches only
This commit is contained in:
Mattias Granlund 2023-08-31 18:55:38 +01:00
parent cb34e351bc
commit 3f093f5500
10 changed files with 348 additions and 194 deletions

View File

@ -0,0 +1,16 @@
<script lang="ts">
import type { ComponentType } from 'svelte';
let className = '';
export { className as class };
export let icon: ComponentType;
export let title: string;
</script>
<button
on:click|stopPropagation
class="{className} text-light-600 hover:text-light-800 disabled:cursor-not-allowed disabled:text-light-200 dark:text-dark-400 hover:dark:text-dark-100 dark:disabled:text-dark-400"
{title}
>
<svelte:component this={icon} />
</button>

View File

@ -0,0 +1,20 @@
<script lang="ts">
let className = '';
export { className as class };
</script>
<svg
class={className}
width="12"
height="13"
viewBox="0 0 12 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.6569 0.656854C11.364 0.363961 10.8891 0.363961 10.5962 0.656854L6 5.25305L1.40381 0.656854C1.11091 0.363961 0.636039 0.363961 0.343146 0.656854C0.0502529 0.949747 0.0502535 1.42462 0.343146 1.71751L4.93934 6.31371L0.343146 10.9099C0.0502529 11.2028 0.0502526 11.6777 0.343146 11.9706C0.636039 12.2635 1.11091 12.2635 1.40381 11.9706L6 7.37437L10.5962 11.9706C10.8891 12.2635 11.364 12.2635 11.6569 11.9706C11.9497 11.6777 11.9497 11.2028 11.6569 10.9099L7.06066 6.31371L11.6569 1.71751C11.9497 1.42462 11.9497 0.949747 11.6569 0.656854Z"
fill="currentColor"
/>
</svg>

View File

@ -82,7 +82,7 @@
}
}}
>
{#each branches.filter((c) => c.active) as { id, name, files, commits, order, conflicted, upstream } (id)}
{#each branches.filter((c) => c.active) as { id, name, files, commits, order, conflicted, upstream, notes } (id)}
<Lane
on:dragstart={(e) => {
if (!e.dataTransfer) return;
@ -104,6 +104,7 @@
{cloud}
{upstream}
{branchController}
{notes}
/>
{/each}

View File

@ -24,10 +24,10 @@
import Resizer from '$lib/components/Resizer.svelte';
import { SETTINGS_CONTEXT, type SettingsStore } from '$lib/userSettings';
import lscache from 'lscache';
import FileTree from './FileTree.svelte';
import { filesToFileTree } from '$lib/vbranches/filetree';
import IconTriangleUp from '$lib/icons/IconTriangleUp.svelte';
import IconTriangleDown from '$lib/icons/IconTriangleDown.svelte';
import IconCloseSmall from '$lib/icons/IconCloseSmall.svelte';
import Tabs from './Tabs.svelte';
import NotesTabPanel from './NotesTabPanel.svelte';
import FileTreeTabPanel from './FileTreeTabPanel.svelte';
const [send, receive] = crossfade({
duration: (d) => Math.sqrt(d * 200),
@ -59,6 +59,7 @@
export let cloudEnabled: boolean;
export let cloud: ReturnType<typeof getCloudApiClient>;
export let upstream: string | undefined;
export let notes: string | undefined;
export let branchController: BranchController;
const user = userStore;
@ -73,23 +74,17 @@
let allExpanded: boolean | undefined;
let maximized = false;
let isPushing = false;
let treeExpanded = false;
let popupMenu: PopupMenu;
let meatballButton: HTMLButtonElement;
let textAreaInput: HTMLTextAreaElement;
let viewport: Element;
let contents: Element;
let rsViewport: HTMLElement;
let thViewport: HTMLElement;
let thContents: HTMLElement;
let laneWidth: number;
let treeHeight: number;
const hoverClass = 'drop-zone-hover';
const dzType = 'text/hunk';
const laneWidthKey = 'laneWidth:';
const treeHeightKey = 'treeHeight:';
const treeExpandedKey = 'treeExpanded:';
function commit() {
branchController.commitBranch(branchId, commitMessage);
@ -105,8 +100,6 @@
onMount(() => {
expandFromCache();
laneWidth = lscache.get(laneWidthKey + branchId) ?? $userSettings.defaultLaneWidth;
treeHeight = lscache.get(treeHeightKey + branchId) ?? $userSettings.defaultTreeHeight;
treeExpanded = Boolean(lscache.get(treeExpandedKey + branchId));
});
$: {
@ -246,7 +239,7 @@
class="flex bg-light-200 text-light-900 dark:bg-dark-800 dark:font-normal dark:text-dark-100"
>
<div class="flex flex-grow flex-col border-b border-light-400 dark:border-dark-600">
<div class="flex w-full items-center px-1.5 py-1">
<div class="flex w-full items-center py-1 pl-1.5">
<button
bind:this={meatballButton}
class="h-8 w-8 flex-grow-0 p-2 text-light-600 transition-colors hover:bg-zinc-300 dark:text-dark-200 dark:hover:bg-zinc-800"
@ -284,6 +277,15 @@
</span>
</Button>
</div>
<button
class="scale-90 px-2 py-2 text-light-600 hover:text-light-800"
title="Stash this branch"
on:click={() => {
if (branchId) branchController.unapplyBranch(branchId);
}}
>
<IconCloseSmall />
</button>
</div>
{#if commitDialogShown}
@ -367,50 +369,22 @@
</div>
</div>
{#if files.length !== 0}
<div
class="border-b border-t border-light-300 bg-light-50 dark:border-dark-500 dark:bg-dark-800"
>
<button
class="flex w-full items-center gap-x-4 py-0 text-left"
on:click|stopPropagation={() => {
treeExpanded = !treeExpanded;
lscache.set(treeExpandedKey + branchId, treeExpanded);
}}
>
<div class="flex-grow p-2 font-semibold">Changed files ({files.length})</div>
<div class="pr-2">
{#if treeExpanded}
<IconTriangleUp />
{:else}
<IconTriangleDown />
{/if}
</div>
</button>
{#if treeExpanded}
<div class="relative" transition:slide={{ duration: 250 }}>
<div
bind:this={thViewport}
style:height={`${treeHeight}px`}
class="hide-native-scrollbar relative max-h-fit shrink-0 overflow-scroll overscroll-none"
>
<div bind:this={thContents} class="px-2 pb-2">
<FileTree node={filesToFileTree(files)} isRoot={true} />
</div>
</div>
<Scrollbar viewport={thViewport} contents={thContents} width="0.4rem" />
</div>
{/if}
</div>
<Resizer
minHeight={40}
viewport={thViewport}
direction="vertical"
class="z-30"
on:height={(e) => {
treeHeight = e.detail;
lscache.set(treeHeightKey + branchId, e.detail, 7 * 1440); // 7 day ttl
userSettings.update((s) => ({ ...s, defaultTreeHeight: e.detail }));
}}
<Tabs
{branchId}
items={[
{
name: 'files',
displayName: 'Changed files (' + files.length + ')',
component: FileTreeTabPanel,
props: { files }
},
{
name: 'notes',
displayName: 'Notes',
component: NotesTabPanel,
props: { notes: notes, branchId, branchController }
}
]}
/>
{/if}
<div class="relative flex flex-grow overflow-y-hidden">

View File

@ -9,6 +9,8 @@
import IconFolder from '$lib/icons/IconFolder.svelte';
import type { TreeNode } from '$lib/vbranches/filetree';
let className = '';
export { className as class };
export let expanded = true;
export let node: TreeNode;
export let isRoot = false;
@ -18,83 +20,84 @@
}
</script>
{#if isRoot}
<!-- Node is a root and should only render children -->
<ul id={`fileTree-${fileTreeId++}`}>
{#each node.children as childNode}
<li>
<svelte:self node={childNode} />
</li>
{/each}
</ul>
{:else if node.file}
{@const { status, added, removed } = node.file.getSummary()}
<!-- Node is a file -->
<button
class="flex w-full items-center gap-x-2 py-0 text-left"
on:click={() => {
const el = document.getElementById('file-' + node.file?.id);
console.log(el);
el?.scrollIntoView({ behavior: 'smooth' });
setTimeout(() => el?.classList.add('wiggle'), 50);
setTimeout(() => el?.classList.remove('wiggle'), 550);
}}
>
<div class="w-4 shrink-0 text-center">
<IconFile class="h-4 w-4" />
</div>
<div
class="flex-grow truncate"
class:text-red-500={status == 'D'}
class:dark:text-red-400={status == 'D'}
class:text-green-700={status == 'A'}
class:dark:text-green-500={status == 'A'}
class:text-orange-800={status == 'M'}
class:dark:text-orange-400={status == 'M'}
<div class={className}>
{#if isRoot}
<!-- Node is a root and should only render children! -->
<ul id={`fileTree-${fileTreeId++}`}>
{#each node.children as childNode}
<li>
<svelte:self node={childNode} />
</li>
{/each}
</ul>
{:else if node.file}
{@const { status, added, removed } = node.file.getSummary()}
<!-- Node is a file -->
<button
class="flex w-full items-center gap-x-2 py-0 text-left"
on:click={() => {
const el = document.getElementById('file-' + node.file?.id);
el?.scrollIntoView({ behavior: 'smooth' });
setTimeout(() => el?.classList.add('wiggle'), 50);
setTimeout(() => el?.classList.remove('wiggle'), 550);
}}
>
{node.name}
</div>
<div class="flex gap-1 font-mono text-xs font-bold">
<span class="text-green-500">
+{added}
</span>
<span class="text-red-500">
-{removed}
</span>
</div>
</button>
{:else if node.children.length > 0}
<!-- Node is a folder -->
<button class="flex w-full items-center py-0 text-left" class:expanded on:click={toggle}>
<div class="w-3 shrink-0 text-center">
{#if expanded}
<IconChevronDownSmall class="scale-90 text-light-600 dark:text-dark-200" />
{:else}
<IconChevronRightSmall class="scale-90 text-light-600 dark:text-dark-200" />
{/if}
</div>
<div class="w-4 shrink-0 pl-1 text-center">
<IconFolder class="h-4 w-4 scale-75 text-blue-400" />
</div>
<div class="flex-grow truncate pl-2">
{node.name}
</div>
</button>
<!-- We assume a folder cannot be empty -->
{#if expanded}
<div class="flex">
<div class="flex">
<div class="w-3 shrink-0 text-center">
<div class="inline-block h-full w-px bg-light-200 dark:bg-dark-400" />
</div>
<div class="w-4 shrink-0 text-center">
<IconFile class="h-4 w-4" />
</div>
<ul class="w-full overflow-hidden">
{#each node.children as childNode}
<li>
<svelte:self node={childNode} expanded={true} />
</li>
{/each}
</ul>
</div>
<div
class="flex-grow truncate"
class:text-red-500={status == 'D'}
class:dark:text-red-400={status == 'D'}
class:text-green-700={status == 'A'}
class:dark:text-green-500={status == 'A'}
class:text-orange-800={status == 'M'}
class:dark:text-orange-400={status == 'M'}
>
{node.name}
</div>
<div class="flex gap-1 font-mono text-xs font-bold">
<span class="text-green-500">
+{added}
</span>
<span class="text-red-500">
-{removed}
</span>
</div>
</button>
{:else if node.children.length > 0}
<!-- Node is a folder -->
<button class="flex w-full items-center py-0 text-left" class:expanded on:click={toggle}>
<div class="w-3 shrink-0 text-center">
{#if expanded}
<IconChevronDownSmall class="scale-90 text-light-600 dark:text-dark-200" />
{:else}
<IconChevronRightSmall class="scale-90 text-light-600 dark:text-dark-200" />
{/if}
</div>
<div class="w-4 shrink-0 pl-1 text-center">
<IconFolder class="h-4 w-4 scale-75 text-blue-400" />
</div>
<div class="flex-grow truncate pl-2">
{node.name}
</div>
</button>
<!-- We assume a folder cannot be empty -->
{#if expanded}
<div class="flex">
<div class="flex">
<div class="w-3 shrink-0 text-center">
<div class="inline-block h-full w-px bg-light-200 dark:bg-dark-400" />
</div>
</div>
<ul class="w-full overflow-hidden">
{#each node.children as childNode}
<li>
<svelte:self node={childNode} expanded={true} />
</li>
{/each}
</ul>
</div>
{/if}
{/if}
{/if}
</div>

View File

@ -0,0 +1,9 @@
<script lang="ts">
import { filesToFileTree } from '$lib/vbranches/filetree';
import type { File } from '$lib/vbranches/types';
import FileTree from './FileTree.svelte';
export let files: File[];
</script>
<FileTree node={filesToFileTree(files)} isRoot={true} class="p-2" />

View File

@ -0,0 +1,23 @@
<script lang="ts">
import type { BranchController } from '$lib/vbranches/branchController';
export let notes: string;
export let branchController: BranchController;
export let branchId: string;
function handleUpdateNotes() {
if (!notes) return;
branchController.updateBranchNotes(branchId, notes);
}
</script>
<textarea
autocomplete="off"
autocorrect="off"
spellcheck="true"
bind:value={notes}
on:change={handleUpdateNotes}
name="notes"
class="outline-none-important h-full w-full resize-none bg-transparent p-2 align-top text-light-900 dark:text-dark-100"
placeholder="Branch notes (optional)"
/>

View File

@ -0,0 +1,113 @@
<script lang="ts">
import IconTriangleUp from '$lib/icons/IconTriangleUp.svelte';
import IconTriangleDown from '$lib/icons/IconTriangleDown.svelte';
import { type ComponentType, getContext } from 'svelte';
import lscache from 'lscache';
import { SETTINGS_CONTEXT, type SettingsStore } from '$lib/userSettings';
import { slide } from 'svelte/transition';
import Scrollbar from '$lib/components/Scrollbar.svelte';
import Resizer from '$lib/components/Resizer.svelte';
interface Tab {
name: string;
displayName: string;
component: ComponentType;
props: any;
}
const userSettings = getContext<SettingsStore>(SETTINGS_CONTEXT);
const treeHeightKey = 'treeHeight:';
const activeTabKey = 'activeTab:';
const expandedKey = 'expanded:';
export let items: Tab[] = [];
export let branchId: string;
$: expanded = branchId ? Boolean(lscache.get(expandedKey + branchId)) : false;
$: activeTabValue = lscache.get(activeTabKey + branchId) ?? items[0].name;
$: treeHeight = lscache.get(treeHeightKey + branchId) || $userSettings.defaultTreeHeight;
let thViewport: HTMLElement;
let thContents: HTMLElement;
function setTreeExpanded(value: boolean) {
expanded = value;
lscache.set(expandedKey + branchId, expanded, 7 * 1440);
}
function setActiveTab(value: string) {
activeTabValue = value;
lscache.set(activeTabKey + branchId, activeTabValue, 7 * 1440);
}
</script>
<div class="border-b border-t border-light-300 bg-light-50 dark:border-dark-500 dark:bg-dark-800">
<div
class="flex w-full border-b border-light-200 text-light-700 dark:border-dark-500 dark:text-dark-200"
>
{#each items as item}
<button
class:text-light-800={activeTabValue == item.name}
class:dark:text-white={activeTabValue == item.name}
class="-mb-px rounded-none p-2 font-medium"
on:click={() => {
if (activeTabValue == item.name && expanded) {
setTreeExpanded(false);
setActiveTab('');
return;
}
setTreeExpanded(true);
setActiveTab(item.name);
}}
>
{item.displayName}
</button>
{/each}
<div class="flex-grow" />
<button
class="flex items-center gap-x-4 py-0 text-light-600"
on:click|stopPropagation={() => {
setTreeExpanded(!expanded);
}}
>
<div class="pr-3">
{#if expanded}
<IconTriangleUp />
{:else}
<IconTriangleDown />
{/if}
</div>
</button>
</div>
{#if expanded}
<div class="relative">
<div
class="hide-native-scrollbar relative shrink-0 overflow-scroll overscroll-none bg-white dark:bg-dark-1000"
transition:slide|local={{ duration: 250 }}
style:height={`${treeHeight}px`}
bind:this={thViewport}
>
<div bind:this={thContents} class="h-full">
{#each items as item}
{#if activeTabValue == item.name}
<svelte:component this={item.component} {...item.props} />
{/if}
{/each}
</div>
</div>
<Scrollbar viewport={thViewport} contents={thContents} width="0.4rem" />
</div>
{/if}
</div>
<Resizer
minHeight={40}
viewport={thViewport}
direction="vertical"
class="z-30"
on:height={(e) => {
treeHeight = e.detail;
console.log(branchId);
lscache.set(treeHeightKey + branchId, e.detail, 7 * 1440); // 7 day ttl
userSettings.update((s) => ({ ...s, defaultTreeHeight: e.detail }));
}}
/>

View File

@ -15,10 +15,12 @@
import IconRefresh from '$lib/icons/IconRefresh.svelte';
import IconGithub from '$lib/icons/IconGithub.svelte';
import TimeAgo from '$lib/components/TimeAgo/TimeAgo.svelte';
import Checkbox from '$lib/components/Checkbox/Checkbox.svelte';
import Button from '$lib/components/Button/Button.svelte';
import Modal from '$lib/components/Modal/Modal.svelte';
import Resizer from '$lib/components/Resizer.svelte';
import IconDelete from '$lib/icons/IconDelete.svelte';
import IconAdd from '$lib/icons/IconAdd.svelte';
import IconButton from '$lib/components/IconButton.svelte';
export let vbranchStore: Loadable<Branch[] | undefined>;
export let remoteBranchStore: Loadable<BranchData[] | undefined>;
@ -42,6 +44,7 @@
let rbContents: HTMLElement;
let rbSection: HTMLElement;
let baseContents: HTMLElement;
let deleteBranchModal: Modal;
let selectedItem: Readable<Branch | BranchData | BaseBranch | undefined> | undefined;
let overlayOffsetTop = 0;
@ -111,16 +114,6 @@
removed: comitted.removed + uncomitted.removed
};
}
function toggleBranch(branch: Branch) {
if (branch.active) {
branchController.unapplyBranch(branch.id);
} else if (!branch.baseCurrent) {
applyConflictedModal.show(branch);
} else {
branchController.applyBranch(branch.id);
}
}
</script>
<PeekTray
@ -200,7 +193,7 @@
<div
class="flex items-center justify-between border-b border-t border-light-300 bg-light-100 px-2 py-1 pr-1 dark:border-dark-600 dark:bg-dark-800"
>
<div class="font-bold">Your Virtual Branches</div>
<div class="font-bold">Stashed branches</div>
<div class="flex h-4 w-4 justify-around">
<button class="h-full w-full" on:click={() => (yourBranchesOpen = !yourBranchesOpen)}>
{#if yourBranchesOpen}
@ -227,9 +220,11 @@
{:else if $branchesState?.isError}
<div class="px-2 py-1">Something went wrong!</div>
{:else if !$vbranchStore || $vbranchStore.length == 0}
<div class="p-4 text-light-700">You currently have no virtual branches.</div>
<div class="p-2 text-light-700">You currently have no virtual branches</div>
{:else if $vbranchStore.filter((b) => !b.active).length == 0}
<div class="p-2 text-light-700">You have no stashed branches</div>
{:else}
{#each $vbranchStore as branch, i (branch.id)}
{#each $vbranchStore.filter((b) => !b.active) as branch, i (branch.id)}
{@const { added, removed } = sumBranchLinesAddedRemoved(branch)}
{@const latestModifiedAt = branch.files.at(0)?.hunks.at(0)?.modifiedAt}
<div
@ -237,7 +232,7 @@
tabindex="0"
on:click={() => select(branch, i)}
on:keypress|capture={() => select(branch, i)}
class="border-b border-light-200 p-2 last:border-b dark:border-dark-600"
class="group border-b border-light-200 p-2 pr-0 last:border-b dark:border-dark-600"
class:bg-light-50={$selectedItem == branch && peekTrayExpanded}
class:dark:bg-zinc-700={$selectedItem == branch && peekTrayExpanded}
>
@ -280,11 +275,23 @@
{/if}
</div>
</div>
<div class="shrink-0">
<Checkbox
on:change={() => toggleBranch(branch)}
bind:checked={branch.active}
disabled={!(branch.mergeable || !branch.baseCurrent) || branch.conflicted}
<div
class="w-0 shrink-0 self-center overflow-hidden whitespace-nowrap transition-width group-hover:w-12 group-focus:w-12"
>
<IconButton
icon={IconDelete}
class="scale-90 p-0"
title="delete branch"
on:click={() => deleteBranchModal.show(branch)}
/>
<IconButton
icon={IconAdd}
class="scale-90 p-0"
title="apply branch"
on:click={() => {
peekTrayExpanded = false;
branchController.applyBranch(branch.id);
}}
/>
</div>
</div>
@ -449,3 +456,25 @@
</Button>
</svelte:fragment>
</Modal>
<!-- Delete branch confirmation modal -->
<Modal width="small" bind:this={deleteBranchModal} let:item>
<svelte:fragment slot="title">Delete branch</svelte:fragment>
<div>
Deleting <code>{item.name}</code> cannot be undone.
</div>
<svelte:fragment slot="controls" let:close let:item>
<Button height="small" kind="outlined" on:click={close}>Cancel</Button>
<Button
height="small"
color="destructive"
on:click={() => {
branchController.deleteBranch(item.id);
close();
}}
>
Delete
</Button>
</svelte:fragment>
</Modal>

View File

@ -28,23 +28,11 @@
{#if branch != undefined}
<div class="flex w-full max-w-full flex-col gap-y-4 p-4">
<div class="flex">
<div class="flex-grow items-center">
<p class="text-lg font-bold" title="name of virtual branch">{branch.name}</p>
<p class="text-light-700 dark:text-dark-200" title="upstream target">
{branch.upstream?.replace('refs/remotes/', '') || ''}
</p>
</div>
<div>
<button
class="p-0 align-middle text-light-500 hover:text-light-700 disabled:cursor-not-allowed disabled:text-light-200 dark:text-dark-400 hover:dark:text-dark-200 dark:disabled:text-dark-400"
disabled={branch.active}
title={branch.active ? 'branch cannot be deleted while applied' : 'deletes this branch'}
on:click={() => deleteBranchModal.show(branch)}
>
<IconDelete class="h-4 w-4" />
</button>
</div>
<div>
<p class="text-lg font-bold" title="name of virtual branch">{branch.name}</p>
<p class="text-light-700 dark:text-dark-200" title="upstream target">
{branch.upstream?.replace('refs/remotes/', '') || ''}
</p>
</div>
<div>
{#if branch.active}
@ -128,26 +116,4 @@
</Button>
</svelte:fragment>
</Modal>
<!-- Delete branch confirmation modal -->
<Modal width="small" bind:this={deleteBranchModal} let:item>
<svelte:fragment slot="title">Delete branch</svelte:fragment>
<div>
Deleting <code>{item.name}</code> cannot be undone.
</div>
<svelte:fragment slot="controls" let:close let:item>
<Button height="small" kind="outlined" on:click={close}>Cancel</Button>
<Button
height="small"
color="destructive"
on:click={() => {
branchController.deleteBranch(item.id);
close();
}}
>
Delete
</Button>
</svelte:fragment>
</Modal>
{/if}