Modal and select component fixes (#4253)

* feat: fix clickoutside for multi modal case, fix select list options display.

* Move select to a separate folder

* Serach field CSS tweak

* Replace the old `Select` with the new one

* Remove old `Select` component and switch `SelectNew` component name

* Added `$effect` type to eslint config

* Added the `searchable` property to `Select`

* disable text selection and added an animation

* dropzone hover animation update

* Remove focus state outline

* Move `Listitem` to `ProjectPopup` as a snippet

* rename `useResize` to `resizeObserver`

* Rename `useAutoHeight` to `autoHeight`

* remove unused component `PopupMenuItem`

* Update `Unupply` modal design

* Handle `Select` position on resize

* added an animation to the new context menu

* Right click context menu added

* revert right click context menu

* Context menu: add `item` arg for the `open` method

* updated context menu for `Filter`, `FileItem`, branch `kebab` menu

* Updated Dropdown context menus

* Temporary remove `copy PR link`

* Rename: `BranchLanePopupMenu ` to `BranchLaneContextMenu`

* FileListItem uncomment commented code

* Hunk context menu updated

* Remove `PopupMenu`

* Replace the old `ContextMenu` file with the new one

* Added blocking overlay for dropdown context menus

* animation timing update

* `Select` folder renamed to `select`

* import fix

* import fix

* remove commented code and `console.log`

* add arrow navigation to the `Select` component

* case sensitivity added

* Update HunkViewer.svelte

* remove duplicated folder
This commit is contained in:
Pavel Laptev 2024-07-08 11:38:50 +02:00 committed by GitHub
parent 8a77a4b179
commit 2cdbc5e205
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1238 additions and 996 deletions

View File

@ -1,11 +1,11 @@
<script lang="ts">
import ActiveBranchStatus from './ActiveBranchStatus.svelte';
import BranchLabel from './BranchLabel.svelte';
import BranchLanePopupMenu from './BranchLanePopupMenu.svelte';
import BranchLaneContextMenu from './BranchLaneContextMenu.svelte';
import PullRequestButton from '../pr/PullRequestButton.svelte';
import { Project } from '$lib/backend/projects';
import { BranchService } from '$lib/branches/service';
import { clickOutside } from '$lib/clickOutside';
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import { GitHubService } from '$lib/github/service';
import { showError } from '$lib/notifications/toasts';
import Button from '$lib/shared/Button.svelte';
@ -33,8 +33,8 @@
$: pr$ = githubService.getPr$(branch.upstream?.sha || branch.head);
$: hasPullRequest = branch.upstreamName && $pr$;
let meatballButton: HTMLDivElement;
let visible = false;
let contextMenu: ContextMenu;
let meatballButtonEl: HTMLDivElement;
let isApplying = false;
let isDeleting = false;
let isLoading: boolean;
@ -263,23 +263,19 @@
/>
{/if}
<Button
element={meatballButton}
bind:el={meatballButtonEl}
style="ghost"
outline
icon="kebab"
on:mousedown={() => {
visible = !visible;
on:click={() => {
contextMenu.toggle();
}}
/>
<div
class="branch-popup-menu"
use:clickOutside={{
trigger: meatballButton,
handler: () => (visible = false)
}}
>
<BranchLanePopupMenu {isUnapplied} bind:visible on:action />
</div>
<BranchLaneContextMenu
bind:contextMenuEl={contextMenu}
{isUnapplied}
target={meatballButtonEl}
/>
</div>
{/if}
</div>
@ -383,13 +379,6 @@
}
}
.branch-popup-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: var(--z-floating);
}
.header__remote-branch {
color: var(--clr-scale-ntrl-50);
padding-left: 2px;

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { useResize } from '$lib/utils/useResize';
import { resizeObserver } from '$lib/utils/resizeObserver';
import { createEventDispatcher } from 'svelte';
export let name: string;
@ -17,7 +17,7 @@
</script>
<span
use:useResize={(e) => {
use:resizeObserver={(e) => {
inputWidth = `${Math.round(e.frame.width)}px`;
}}
class="branch-name-mesure-el text-base-14 text-bold"

View File

@ -5,10 +5,10 @@
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import { projectAiGenEnabled } from '$lib/config/config';
import Select from '$lib/select/Select.svelte';
import SelectItem from '$lib/select/SelectItem.svelte';
import Button from '$lib/shared/Button.svelte';
import Modal from '$lib/shared/Modal.svelte';
import Select from '$lib/shared/Select.svelte';
import SelectItem from '$lib/shared/SelectItem.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import { User } from '$lib/stores/user';
@ -18,7 +18,8 @@
import { Branch, type NameConflictResolution } from '$lib/vbranches/types';
import { createEventDispatcher } from 'svelte';
export let visible: boolean;
export let contextMenuEl: ContextMenu;
export let target: HTMLElement;
export let isUnapplied = false;
const user = getContextStore(User);
@ -50,10 +51,6 @@
aiConfigurationValid = await aiService.validateConfiguration(user?.access_token);
}
function close() {
visible = false;
}
let unapplyBranchModal: Modal;
type ResolutionVariants = NameConflictResolution['type'];
@ -105,27 +102,47 @@
branchController.convertToRealBranch(branch.id);
}
}
function setButtonCoppy() {
switch (selectedResolution) {
case 'overwrite':
return 'Overwrite and unapply';
case 'suffix':
return 'Suffix and unapply';
case 'rename':
return 'Rename and unapply';
}
}
</script>
<Modal bind:this={unapplyBranchModal}>
<Modal width="small" bind:this={unapplyBranchModal}>
<div class="flow">
<div class="modal-copy">
<p class="text-base-15">There is already branch with the name</p>
<Button size="tag" clickable={false}>{normalizeBranchName(branch.name)}</Button>
<p class="text-base-15">.</p>
<p class="text-base-15">Please choose how you want to resolve this:</p>
<p class="text-base-14 text-semibold">
"{normalizeBranchName(branch.name)}" branch already exists
</p>
<p class="text-base-body-13 modal-copy-caption">
A branch with the same name already exists.
<br />
Please select a resolution:
</p>
</div>
<Select
items={resolutions}
itemId={'value'}
labelId={'label'}
bind:selectedItemId={selectedResolution}
value={selectedResolution}
options={resolutions}
onselect={(value) => {
selectedResolution = value as ResolutionVariants;
}}
>
<SelectItem slot="template" let:item let:selected {selected}>
{item.label}
</SelectItem>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === selectedResolution} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
{#if selectedResolution === 'rename'}
<TextBox
label="New branch name"
@ -137,107 +154,112 @@
</div>
{#snippet controls()}
<Button style="ghost" outline on:click={() => unapplyBranchModal.close()}>Cancel</Button>
<Button style="pop" kind="solid" grow on:click={unapplyBranchWithSelectedResolution}
>Submit</Button
<Button
style="pop"
kind="solid"
on:click={unapplyBranchWithSelectedResolution}
disabled={!newBranchName && selectedResolution === 'rename'}
>
{setButtonCoppy()}
</Button>
{/snippet}
</Modal>
{#if visible}
<ContextMenu>
<ContextMenuSection>
{#if !isUnapplied}
<ContextMenuItem
label="Collapse lane"
on:click={() => {
dispatch('action', 'collapse');
close();
}}
/>
{/if}
</ContextMenuSection>
<ContextMenuSection>
{#if !isUnapplied}
<ContextMenuItem
label="Unapply"
on:click={() => {
tryUnapplyBranch();
close();
}}
/>
{/if}
<ContextMenu bind:this={contextMenuEl} {target}>
<ContextMenuSection>
{#if !isUnapplied}
<ContextMenuItem
label="Delete"
on:click={async () => {
if (
branch.name.toLowerCase().includes('virtual branch') &&
commits.length === 0 &&
branch.files?.length === 0
) {
await branchController.deleteBranch(branch.id);
} else {
deleteBranchModal.show(branch);
}
close();
}}
/>
<ContextMenuItem
label="Generate branch name"
label="Collapse lane"
on:click={() => {
dispatch('action', 'generate-branch-name');
close();
dispatch('action', 'collapse');
contextMenuEl.close();
}}
disabled={isUnapplied ||
!($aiGenEnabled && aiConfigurationValid) ||
branch.files?.length === 0}
/>
</ContextMenuSection>
<ContextMenuSection>
{/if}
</ContextMenuSection>
<ContextMenuSection>
{#if !isUnapplied}
<ContextMenuItem
label="Set remote branch name"
disabled={isUnapplied}
label="Unapply"
on:click={() => {
newRemoteName = branch.upstreamName || normalizeBranchName(branch.name) || '';
close();
renameRemoteModal.show(branch);
tryUnapplyBranch();
contextMenuEl.close();
}}
/>
</ContextMenuSection>
{/if}
<ContextMenuSection>
<ContextMenuItem label="Allow rebasing" on:click={toggleAllowRebasing}>
<Toggle
small
slot="control"
bind:checked={allowRebasing}
on:click={toggleAllowRebasing}
help="Having this enabled permits commit amending and reordering after a branch has been pushed, which would subsequently require force pushing"
/>
</ContextMenuItem>
</ContextMenuSection>
<ContextMenuItem
label="Delete"
on:click={async () => {
if (
branch.name.toLowerCase().includes('virtual branch') &&
commits.length === 0 &&
branch.files?.length === 0
) {
await branchController.deleteBranch(branch.id);
} else {
deleteBranchModal.show(branch);
}
contextMenuEl.close();
}}
/>
<ContextMenuSection>
<ContextMenuItem
label="Create branch to the left"
on:click={() => {
branchController.createBranch({ order: branch.order });
close();
}}
<ContextMenuItem
label="Generate branch name"
on:click={() => {
dispatch('action', 'generate-branch-name');
contextMenuEl.close();
}}
disabled={isUnapplied ||
!($aiGenEnabled && aiConfigurationValid) ||
branch.files?.length === 0}
/>
</ContextMenuSection>
<ContextMenuSection>
<ContextMenuItem
label="Set remote branch name"
disabled={isUnapplied}
on:click={() => {
console.log('Set remote branch name');
newRemoteName = branch.upstreamName || normalizeBranchName(branch.name) || '';
renameRemoteModal.show(branch);
contextMenuEl.close();
}}
/>
</ContextMenuSection>
<ContextMenuSection>
<ContextMenuItem label="Allow rebasing" on:click={toggleAllowRebasing}>
<Toggle
small
slot="control"
bind:checked={allowRebasing}
on:click={toggleAllowRebasing}
help="Having this enabled permits commit amending and reordering after a branch has been pushed, which would subsequently require force pushing"
/>
</ContextMenuItem>
</ContextMenuSection>
<ContextMenuItem
label="Create branch to the right"
on:click={() => {
branchController.createBranch({ order: branch.order + 1 });
close();
}}
/>
</ContextMenuSection>
</ContextMenu>
{/if}
<ContextMenuSection>
<ContextMenuItem
label="Create branch to the left"
on:click={() => {
branchController.createBranch({ order: branch.order });
contextMenuEl.close();
}}
/>
<ContextMenuItem
label="Create branch to the right"
on:click={() => {
branchController.createBranch({ order: branch.order + 1 });
contextMenuEl.close();
}}
/>
</ContextMenuSection>
</ContextMenu>
<Modal width="small" bind:this={renameRemoteModal}>
<TextBox label="Remote branch name" id="newRemoteName" bind:value={newRemoteName} focus />
@ -282,9 +304,14 @@
flex-direction: column;
gap: 16px;
}
.modal-copy {
& > * {
display: inline;
}
display: flex;
flex-direction: column;
gap: 8px;
}
.modal-copy-caption {
color: var(--clr-text-2);
}
</style>

View File

@ -1,7 +1,6 @@
<script lang="ts">
import BranchLabel from './BranchLabel.svelte';
import { Project } from '$lib/backend/projects';
import ViewPrContextMenu from '$lib/components/ViewPrContextMenu.svelte';
import Button from '$lib/shared/Button.svelte';
import Icon from '$lib/shared/Icon.svelte';
import { getContext } from '$lib/utils/context';
@ -9,7 +8,6 @@
import { tooltip } from '$lib/utils/tooltip';
import { openExternalUrl } from '$lib/utils/url';
import { BranchController } from '$lib/vbranches/branchController';
import { mount, onDestroy, unmount } from 'svelte';
import type { PullRequest } from '$lib/github/types';
import type { BaseBranch, RemoteBranch } from '$lib/vbranches/types';
import { goto } from '$app/navigation';
@ -22,22 +20,6 @@
const project = getContext(Project);
let isApplying = false;
function updateContextMenu(copyablePrUrl: string) {
if (popupMenu) unmount(popupMenu);
return mount(ViewPrContextMenu, {
target: document.body,
props: { prUrl: copyablePrUrl }
});
}
$: popupMenu = updateContextMenu(pr?.htmlUrl || '');
onDestroy(() => {
if (popupMenu) {
unmount(popupMenu);
}
});
</script>
<div class="header__wrapper">

View File

@ -0,0 +1,21 @@
export type ClickOpts = { excludeElement?: HTMLElement; handler: () => void };
export function clickOutside(node: HTMLElement, params: ClickOpts) {
function onClick(event: MouseEvent) {
if (
node &&
!node.contains(event.target as HTMLElement) &&
!params.excludeElement?.contains(event.target as HTMLElement)
) {
params.handler();
}
}
document.addEventListener('mousedown', onClick, true);
document.addEventListener('contextmenu', onClick, true);
return {
destroy() {
document.removeEventListener('mousedown', onClick, true);
document.removeEventListener('contextmenu', onClick, true);
}
};
}

View File

@ -2,7 +2,6 @@
import { PromptService } from '$lib/ai/promptService';
import { AIService } from '$lib/ai/service';
import { Project } from '$lib/backend/projects';
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import {
@ -16,11 +15,11 @@
import DropDownButton from '$lib/shared/DropDownButton.svelte';
import Icon from '$lib/shared/Icon.svelte';
import { User } from '$lib/stores/user';
import { autoHeight } from '$lib/utils/autoHeight';
import { splitMessage } from '$lib/utils/commitMessage';
import { getContext, getContextStore } from '$lib/utils/context';
import { resizeObserver } from '$lib/utils/resizeObserver';
import { tooltip } from '$lib/utils/tooltip';
import { useAutoHeight } from '$lib/utils/useAutoHeight';
import { useResize } from '$lib/utils/useResize';
import { Ownership } from '$lib/vbranches/ownership';
import { Branch, LocalFile } from '$lib/vbranches/types';
import { createEventDispatcher, onMount } from 'svelte';
@ -115,9 +114,9 @@
{#if isExpanded}
<div
class="commit-box__textarea-wrapper text-input"
use:useResize={() => {
if (titleTextArea) useAutoHeight(titleTextArea);
if (descriptionTextArea) useAutoHeight(descriptionTextArea);
use:resizeObserver={() => {
if (titleTextArea) autoHeight(titleTextArea);
if (descriptionTextArea) autoHeight(descriptionTextArea);
}}
>
<textarea
@ -129,10 +128,10 @@
rows="1"
bind:this={titleTextArea}
use:focusTextAreaOnMount
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:focus={(e) => autoHeight(e.currentTarget)}
on:input={(e) => {
commitMessage = concatMessage(e.currentTarget.value, description);
useAutoHeight(e.currentTarget);
autoHeight(e.currentTarget);
}}
on:keydown={(e) => {
if (commit && (e.ctrlKey || e.metaKey) && e.key === 'Enter') commit();
@ -152,10 +151,10 @@
spellcheck="false"
rows="1"
bind:this={descriptionTextArea}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:focus={(e) => autoHeight(e.currentTarget)}
on:input={(e) => {
commitMessage = concatMessage(title, e.currentTarget.value);
useAutoHeight(e.currentTarget);
autoHeight(e.currentTarget);
}}
on:keydown={(e) => {
const value = e.currentTarget.value;
@ -165,7 +164,7 @@
titleTextArea?.focus();
titleTextArea.selectionStart = titleTextArea.textLength;
}
useAutoHeight(e.currentTarget);
autoHeight(e.currentTarget);
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey) && value.length === 0) {
// select previous textarea on cmd+a if this textarea is empty
e.preventDefault();
@ -205,23 +204,22 @@
on:click={async () => await generateCommitMessage($branch.files)}
>
Generate message
<ContextMenu slot="context-menu">
<ContextMenuSection>
<ContextMenuItem
label="Extra concise"
on:click={() => ($commitGenerationExtraConcise = !$commitGenerationExtraConcise)}
>
<Checkbox small slot="control" bind:checked={$commitGenerationExtraConcise} />
</ContextMenuItem>
<ContextMenuItem
label="Use emojis 😎"
on:click={() => ($commitGenerationUseEmojis = !$commitGenerationUseEmojis)}
>
<Checkbox small slot="control" bind:checked={$commitGenerationUseEmojis} />
</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
<ContextMenuSection slot="context-menu">
<ContextMenuItem
label="Extra concise"
on:click={() => ($commitGenerationExtraConcise = !$commitGenerationExtraConcise)}
>
<Checkbox small slot="control" bind:checked={$commitGenerationExtraConcise} />
</ContextMenuItem>
<ContextMenuItem
label="Use emojis 😎"
on:click={() => ($commitGenerationUseEmojis = !$commitGenerationUseEmojis)}
>
<Checkbox small slot="control" bind:checked={$commitGenerationUseEmojis} />
</ContextMenuItem>
</ContextMenuSection>
</DropDownButton>
</div>
</div>

View File

@ -2,7 +2,7 @@
import { MessageRole } from '$lib/ai/types';
import Button from '$lib/shared/Button.svelte';
import Icon from '$lib/shared/Icon.svelte';
import { useAutoHeight } from '$lib/utils/useAutoHeight';
import { autoHeight } from '$lib/utils/autoHeight';
import { marked } from 'marked';
import { createEventDispatcher } from 'svelte';
@ -33,7 +33,7 @@
$: focusTextareaOnMount(textareaElement, autofocus, editing);
$: if (textareaElement) useAutoHeight(textareaElement);
$: if (textareaElement) autoHeight(textareaElement);
</script>
<div
@ -61,12 +61,12 @@
class:is-error={isError}
rows={1}
on:input={(e) => {
useAutoHeight(e.currentTarget);
autoHeight(e.currentTarget);
dispatcher('input', e.currentTarget.value);
}}
on:change={(e) => {
useAutoHeight(e.currentTarget);
autoHeight(e.currentTarget);
}}
></textarea>
{:else}

View File

@ -1,8 +1,8 @@
<script lang="ts">
import Select from '../shared/Select.svelte';
import { PromptService } from '$lib/ai/promptService';
import { Project } from '$lib/backend/projects';
import SelectItem from '$lib/shared/SelectItem.svelte';
import Select from '$lib/select/Select.svelte';
import SelectItem from '$lib/select/SelectItem.svelte';
import { getContext } from '$lib/utils/context';
import type { Prompts, UserPrompt } from '$lib/ai/types';
import type { Persisted } from '$lib/persisted/persisted';
@ -24,7 +24,6 @@
}
let userPrompts = prompts.userPrompts;
let allPrompts: UserPrompt[] = [];
const defaultId = crypto.randomUUID();
@ -44,16 +43,19 @@
</script>
<Select
items={allPrompts}
bind:selectedItemId={$selectedPromptId}
itemId="id"
labelId="name"
disabled={allPrompts.length === 1}
wide={true}
value={$selectedPromptId}
options={allPrompts.map((p) => ({ label: p.name, value: p.id }))}
label={promptUse === 'commits' ? 'Commit message' : 'Branch name'}
wide={true}
searchable
disabled={allPrompts.length === 1}
onselect={(value) => {
$selectedPromptId = value;
}}
>
<SelectItem slot="template" let:item let:selected {selected} let:highlighted {highlighted}>
{item.name}
{highlighted}
</SelectItem>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === $selectedPromptId} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>

View File

@ -1,11 +1,11 @@
<script lang="ts">
import InfoMessage from '../shared/InfoMessage.svelte';
import Select from '../shared/Select.svelte';
import { Project } from '$lib/backend/projects';
import SectionCard from '$lib/components/SectionCard.svelte';
import Select from '$lib/select/Select.svelte';
import SelectItem from '$lib/select/SelectItem.svelte';
import Section from '$lib/settings/Section.svelte';
import Button from '$lib/shared/Button.svelte';
import SelectItem from '$lib/shared/SelectItem.svelte';
import { getContext, getContextStore } from '$lib/utils/context';
import { getRemoteBranches } from '$lib/vbranches/baseBranch';
import { BranchController } from '$lib/vbranches/branchController';
@ -67,47 +67,37 @@
</svelte:fragment>
<Select
items={remoteBranches}
bind:value={selectedBranch}
itemId="name"
labelId="name"
selectedItemId={$baseBranch.branchName}
value={selectedBranch.name}
options={remoteBranches.map((b) => ({ label: b.name, value: b.name }))}
onselect={(value) => {
selectedBranch = { name: value };
}}
disabled={targetChangeDisabled}
wide={true}
label="Current target branch"
searchable
>
<SelectItem
slot="template"
let:item
let:selected
{selected}
let:highlighted
{highlighted}
>
{item.name}
</SelectItem>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === selectedBranch.name} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
{#if uniqueRemotes(remoteBranches).length > 1}
<Select
items={uniqueRemotes(remoteBranches)}
bind:value={selectedRemote}
itemId="name"
labelId="name"
selectedItemId={$baseBranch.actualPushRemoteName()}
value={selectedRemote.name}
options={uniqueRemotes(remoteBranches).map((r) => ({ label: r.name, value: r.name }))}
onselect={(value) => {
selectedRemote = { name: value };
}}
disabled={targetChangeDisabled}
label="Create branches on remote"
>
<SelectItem
slot="template"
let:item
let:selected
{selected}
let:highlighted
{highlighted}
>
{item.name}
</SelectItem>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === selectedRemote.name} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
{/if}

View File

@ -2,11 +2,12 @@
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import Button from '$lib/shared/Button.svelte';
import Checkbox from '$lib/shared/Checkbox.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import type { Writable } from 'svelte/store';
import type { Writable, Readable } from 'svelte/store';
export let visible: boolean;
export let filtersActive: Readable<boolean>;
export let showPrCheckbox: boolean;
export let includePrs: Writable<boolean | undefined>;
@ -14,10 +15,26 @@
export let includeStashed: Writable<boolean | undefined>;
export let hideBots: Writable<boolean | undefined>;
export let hideInactive: Writable<boolean | undefined>;
let target: HTMLElement;
let contextMenu: ContextMenu;
export function onFilterClick() {
contextMenu.toggle();
}
</script>
{#if visible}
<ContextMenu>
<div class="header__filter-btn">
<Button
bind:el={target}
style="ghost"
outline
icon={$filtersActive ? 'filter-applied-small' : 'filter-small'}
on:mousedown={onFilterClick}
>
Filter
</Button>
<ContextMenu bind:this={contextMenu} {target}>
<ContextMenuSection>
{#if showPrCheckbox}
<ContextMenuItem label="Pull requests" on:click={() => ($includePrs = !$includePrs)}>
@ -42,4 +59,4 @@
</ContextMenuItem>
</ContextMenuSection>
</ContextMenu>
{/if}
</div>

View File

@ -5,10 +5,10 @@
import Login from '$lib/components/Login.svelte';
import SetupFeature from '$lib/components/SetupFeature.svelte';
import { projectAiGenEnabled } from '$lib/config/config';
import Select from '$lib/select/Select.svelte';
import SelectItem from '$lib/select/SelectItem.svelte';
import GithubIntegration from '$lib/settings/GithubIntegration.svelte';
import Button from '$lib/shared/Button.svelte';
import Select from '$lib/shared/Select.svelte';
import SelectItem from '$lib/shared/SelectItem.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import { UserService } from '$lib/stores/user';
import { getContext } from '$lib/utils/context';
@ -88,11 +88,22 @@
<div class="project-setup__fields">
<div class="project-setup__field-wrap">
<Select items={remoteBranches} bind:value={selectedBranch} itemId="name" labelId="name">
<SelectItem slot="template" let:item let:selected {selected} let:highlighted {highlighted}>
{item.name}
</SelectItem>
<Select
value={selectedBranch.name}
options={remoteBranches.map((b) => ({ label: b.name, value: b.name }))}
onselect={(value) => {
selectedBranch = { name: value };
}}
label="Current target branch"
searchable
>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === selectedBranch.name} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
<p class="project-setup__description-text text-base-body-12">
This is the branch that you consider "production", normally something like "origin/master"
or "upstream/main".
@ -101,18 +112,21 @@
{#if remotes.length > 1}
<div class="project-setup__field-wrap">
<Select items={remotes} bind:value={selectedRemote} itemId="name" labelId="name">
<SelectItem
slot="template"
let:item
let:selected
{selected}
let:highlighted
{highlighted}
>
{item.name}
</SelectItem>
<Select
value={selectedRemote.value}
options={remotes.map((r) => ({ label: r.name, value: r.value }))}
onselect={(value) => {
const newSelectedRemote = remotes.find((r) => r.value === value);
selectedRemote = newSelectedRemote || selectedRemote;
}}
>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === selectedRemote.name} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
<p class="project-setup__description-text text-base-body-12">
You have branches from multiple remotes. If you want to specify a remote for creating
branches that is different from the remote that your target branch is on, change it here.

View File

@ -1,7 +1,8 @@
<script lang="ts">
import Select from '../shared/Select.svelte';
import SelectItem from '../shared/SelectItem.svelte';
import { ProjectService, Project } from '$lib/backend/projects';
import OptionsGroup from '$lib/select/OptionsGroup.svelte';
import Select from '$lib/select/Select.svelte';
import SelectItem from '$lib/select/SelectItem.svelte';
import Button from '$lib/shared/Button.svelte';
import { getContext, maybeGetContext } from '$lib/utils/context';
import { derived } from 'svelte/store';
@ -10,61 +11,59 @@
const projectService = getContext(ProjectService);
const project = maybeGetContext(Project);
type ProjectRecord = {
id: string;
title: string;
};
const mappedProjects = derived(projectService.projects, (projects) =>
projects.map((project) => ({
id: project.id,
title: project.title
value: project.id,
label: project.title
}))
);
let loading = false;
let select: Select<ProjectRecord>;
let selectValue: ProjectRecord | undefined = project;
let selectedProjectId: string | undefined = project ? project.id : undefined;
</script>
<div class="project-switcher">
<Select
id="select-project"
value={selectedProjectId}
options={$mappedProjects}
label="Switch to another project"
itemId="id"
labelId="title"
items={$mappedProjects}
placeholder="Select a project..."
wide
bind:value={selectValue}
bind:this={select}
onselect={(value) => {
selectedProjectId = value;
}}
searchable
>
<SelectItem slot="template" let:item let:selected {selected} let:highlighted {highlighted}>
{item.title}
</SelectItem>
<SelectItem
slot="append"
icon="plus"
{loading}
on:click={async () => {
loading = true;
try {
await projectService.addProject();
} finally {
loading = false;
}
}}
>
Add new project
</SelectItem>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === selectedProjectId} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
<OptionsGroup>
<SelectItem
icon="plus"
{loading}
on:click={async () => {
loading = true;
try {
await projectService.addProject();
} finally {
loading = false;
}
}}
>
Add new project
</SelectItem>
</OptionsGroup>
</Select>
<Button
style="pop"
kind="solid"
icon="chevron-right-small"
disabled={selectValue === project}
disabled={selectedProjectId === project}
on:mousedown={() => {
if (selectValue) goto(`/${selectValue.id}/`);
if (selectedProjectId) goto(`/${selectedProjectId}/`);
}}
>
Open project

View File

@ -6,7 +6,6 @@
</script>
<script lang="ts">
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import { persisted } from '$lib/persisted/persisted';
@ -26,7 +25,6 @@
'projectDefaultAction_' + projectId
);
let contextMenu: ContextMenu;
let dropDown: DropDownButton;
let disabled = false;
@ -56,23 +54,21 @@
}}
>
{labels[action]}
<ContextMenu slot="context-menu" bind:this={contextMenu}>
<ContextMenuSection>
<ContextMenuItem
label={labels[BranchAction.Push]}
on:click={() => {
$preferredAction = BranchAction.Push;
dropDown.close();
}}
/>
<ContextMenuItem
label={labels[BranchAction.Integrate]}
disabled={!integrate}
on:click={() => {
$preferredAction = BranchAction.Integrate;
dropDown.close();
}}
/>
</ContextMenuSection>
</ContextMenu>
<ContextMenuSection slot="context-menu">
<ContextMenuItem
label={labels[BranchAction.Push]}
on:click={() => {
$preferredAction = BranchAction.Push;
dropDown.close();
}}
/>
<ContextMenuItem
label={labels[BranchAction.Integrate]}
disabled={!integrate}
on:click={() => {
$preferredAction = BranchAction.Integrate;
dropDown.close();
}}
/>
</ContextMenuSection>
</DropDownButton>

View File

@ -1,28 +0,0 @@
<script lang="ts">
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import PopupMenu from '$lib/shared/PopupMenu.svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
export let prUrl: string;
let popupMenu: PopupMenu;
export function openByMouse(e: MouseEvent, item: any) {
popupMenu.openByMouse(e, item);
}
</script>
<PopupMenu bind:this={popupMenu} let:dismiss>
<ContextMenu>
<ContextMenuSection>
<ContextMenuItem
label="Copy to Clipbaord"
on:click={() => {
copyToClipboard(prUrl);
dismiss();
}}
/>
</ContextMenuSection>
</ContextMenu>
</PopupMenu>

View File

@ -1,14 +1,196 @@
<div class="context-menu">
<slot />
</div>
<script lang="ts">
import { clickOutside } from '$lib/clickOutsideNew';
import { portal } from '$lib/utils/portal';
import { pxToRem } from '$lib/utils/pxToRem';
import { resizeObserver } from '$lib/utils/resizeObserver';
import { type Snippet } from 'svelte';
// TYPES AND INTERFACES
interface Props {
target?: HTMLElement;
openByMouse?: boolean;
verticalAlign?: 'top' | 'bottom';
horizontalAlign?: 'left' | 'right';
children: Snippet<[item: any]>;
onclose?: () => void;
onopen?: () => void;
}
const {
target,
openByMouse,
verticalAlign = 'bottom',
horizontalAlign = 'right',
children,
onclose,
onopen
}: Props = $props();
// LOCAL VARS
let menuMargin = 4;
// STATES
let item = $state<any>();
let contextMenuHeight = $state(0);
let contextMenuWidth = $state(0);
let isVisibile = $state(false);
let menuPosition = $state({ x: 0, y: 0 });
// METHODS
export function close() {
isVisibile = false;
onclose && onclose();
}
export function open(e?: MouseEvent, newItem?: any) {
if (!target) return;
if (newItem) item = newItem;
isVisibile = true;
onopen && onopen();
if (!openByMouse) {
setAlignByTarget();
}
if (openByMouse && e) {
menuPosition = {
x: e.clientX,
y: e.clientY
};
}
}
export function toggle(e?: MouseEvent, newItem?: any) {
if (!isVisibile) {
open(e, newItem);
} else {
close();
}
}
function setVerticalAlign(targetBoundingRect: DOMRect) {
if (verticalAlign === 'top') {
return targetBoundingRect?.top ? targetBoundingRect.top - contextMenuHeight - menuMargin : 0;
}
return targetBoundingRect?.top
? targetBoundingRect.top + targetBoundingRect.height + menuMargin
: 0;
}
function setHorizontalAlign(targetBoundingRect: DOMRect) {
if (horizontalAlign === 'left') {
return targetBoundingRect?.left ? targetBoundingRect.left : 0;
}
return targetBoundingRect?.left
? targetBoundingRect.left + targetBoundingRect.width - contextMenuWidth
: 0;
}
function setAlignByTarget() {
if (target) {
const targetBoundingRect = target.getBoundingClientRect();
menuPosition = {
x: setHorizontalAlign(targetBoundingRect),
y: setVerticalAlign(targetBoundingRect)
};
}
}
function setTransformOrigin() {
if (!openByMouse) {
if (verticalAlign === 'top' && horizontalAlign === 'left') {
return 'bottom left';
}
if (verticalAlign === 'top' && horizontalAlign === 'right') {
return 'bottom right';
}
if (verticalAlign === 'bottom' && horizontalAlign === 'left') {
return 'top left';
}
if (verticalAlign === 'bottom' && horizontalAlign === 'right') {
return 'top right';
}
} else {
return 'top left';
}
}
</script>
{#snippet contextMenu()}
<div
use:clickOutside={{
excludeElement: target,
handler: () => close()
}}
use:resizeObserver={() => {
if (!openByMouse) setAlignByTarget();
}}
bind:offsetHeight={contextMenuHeight}
bind:offsetWidth={contextMenuWidth}
class="context-menu"
style:top={pxToRem(menuPosition.y)}
style:left={pxToRem(menuPosition.x)}
style:transform-origin={setTransformOrigin()}
style:--animation-transform-shift={verticalAlign === 'top' ? '6px' : '-6px'}
>
{@render children(item)}
</div>
{/snippet}
{#if isVisibile}
<div class="portal-wrap" use:portal={'body'}>
{#if openByMouse}
{@render contextMenu()}
{:else}
<div class="overlay-wrapper">
{@render contextMenu()}
</div>
{/if}
</div>
{/if}
<style lang="postcss">
.portal-wrap {
display: contents;
}
.overlay-wrapper {
z-index: var(--z-blocker);
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* background-color: rgba(0, 0, 0, 0.1); */
}
.context-menu {
z-index: var(--z-blocker);
position: fixed;
display: flex;
flex-direction: column;
background: var(--clr-bg-2);
border: 1px solid var(--clr-border-2);
border-radius: var(--radius-m);
box-shadow: var(--fx-shadow-s);
animation: fadeIn 0.1s ease-out forwards;
}
@keyframes fadeIn {
0% {
opacity: 0;
transform: translateY(var(--animation-transform-shift)) scale(0.9);
}
50% {
opacity: 1;
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style>

View File

@ -15,10 +15,7 @@
}
const { hovered, activated, label = 'Drop here', extraPaddings }: Props = $props();
let defaultPadding = $derived.by(() => {
if (hovered) return 2;
return 4;
});
let defaultPadding = 4;
const extraPaddingTop = extraPaddings?.top ?? 0;
const extraPaddingRight = extraPaddings?.right ?? 0;
@ -94,7 +91,7 @@
transform: scale(1.01);
.animated-rectangle rect {
fill: oklch(from var(--clr-scale-pop-50) l c h / 0.14);
fill: oklch(from var(--clr-scale-pop-50) l c h / 0.16);
}
.dropzone-label {

View File

@ -5,7 +5,6 @@
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import Button from '$lib/shared/Button.svelte';
import Modal from '$lib/shared/Modal.svelte';
import PopupMenu from '$lib/shared/PopupMenu.svelte';
import { getContext } from '$lib/utils/context';
import { computeFileStatus } from '$lib/utils/fileStatus';
import { editor } from '$lib/utils/systemEditor';
@ -13,15 +12,16 @@
import { BranchController } from '$lib/vbranches/branchController';
import { LocalFile, type AnyFile } from '$lib/vbranches/types';
import { join } from '@tauri-apps/api/path';
import { open } from '@tauri-apps/api/shell';
import { open as openFile } from '@tauri-apps/api/shell';
export let target: HTMLElement;
export let isUnapplied;
const branchController = getContext(BranchController);
const project = getContext(Project);
let confirmationModal: Modal;
let popupMenu: PopupMenu;
let contextMenu: ContextMenu;
function containsBinaryFiles(item: any) {
return item.files.some((f: AnyFile) => f.binary);
@ -31,17 +31,16 @@
return item.files.some((f: AnyFile) => computeFileStatus(f) === 'D');
}
export function openByMouse(e: MouseEvent, item: any) {
popupMenu.openByMouse(e, item);
export function open(e: MouseEvent, item: any) {
contextMenu.open(e, item);
}
</script>
<PopupMenu bind:this={popupMenu} let:item let:dismiss>
<ContextMenu>
<ContextMenu bind:this={contextMenu} {target} openByMouse>
{#snippet children(item)}
<ContextMenuSection>
{#if item.files && item.files.length > 0}
{@const files = item.files}
<!-- TODO: Refactor so we can have types -->
{#if files[0] instanceof LocalFile && !isUnapplied}
{#if containsBinaryFiles(item)}
<ContextMenuItem label="Discard changes (Binary files not yet supported)" disabled />
@ -50,7 +49,7 @@
label="Discard changes"
on:click={() => {
confirmationModal.show(item);
dismiss();
contextMenu.close();
}}
/>
{/if}
@ -63,7 +62,8 @@
if (!project) return;
const absPath = await join(project.path, item.files[0].path);
navigator.clipboard.writeText(absPath);
dismiss();
contextMenu.close();
// dismiss();
} catch (err) {
console.error('Failed to copy path', err);
toasts.error('Failed to copy path');
@ -76,7 +76,7 @@
try {
if (!project) return;
navigator.clipboard.writeText(item.files[0].path);
dismiss();
contextMenu.close();
} catch (err) {
console.error('Failed to copy relative path', err);
toasts.error('Failed to copy relative path');
@ -92,9 +92,9 @@
if (!project) return;
for (let file of item.files) {
const absPath = await join(project.vscodePath, file.path);
open(`${editor.get()}://file${absPath}`);
openFile(`${editor.get()}://file${absPath}`);
}
dismiss();
contextMenu.close();
} catch {
console.error('Failed to open in VSCode');
toasts.error('Failed to open in VSCode');
@ -103,8 +103,8 @@
/>
{/if}
</ContextMenuSection>
</ContextMenu>
</PopupMenu>
{/snippet}
</ContextMenu>
<Modal width="small" title="Discard changes" bind:this={confirmationModal}>
{#snippet children(item)}

View File

@ -12,7 +12,6 @@
import { FileIdSelection } from '$lib/vbranches/fileIdSelection';
import { Ownership } from '$lib/vbranches/ownership';
import { Branch, type AnyFile } from '$lib/vbranches/types';
import { mount, onDestroy, unmount } from 'svelte';
import type { Writable } from 'svelte/store';
export let file: AnyFile;
@ -29,6 +28,7 @@
const selectedFiles = fileIdSelection.files;
let checked = false;
let contextMenu: FileContextMenu;
let draggableElt: HTMLDivElement;
let lastCheckboxDetail = true;
@ -49,25 +49,11 @@
$: if ($fileIdSelection && draggableElt && $fileIdSelection.length === 1)
updateFocus(draggableElt, file, fileIdSelection, $commit?.id);
$: popupMenu = updateContextMenu();
function updateContextMenu() {
if (popupMenu) unmount(popupMenu);
return mount(FileContextMenu, {
target: document.body,
props: { isUnapplied }
});
}
onDestroy(() => {
if (popupMenu) {
unmount(popupMenu);
}
});
const isDraggable = !readonly && !isUnapplied;
</script>
<FileContextMenu bind:this={contextMenu} target={draggableElt} {isUnapplied} />
<div
bind:this={draggableElt}
class="file-list-item"
@ -107,15 +93,15 @@
draggableElt.classList.remove('locked-file-animation');
}
}}
role="button"
tabindex="0"
on:contextmenu|preventDefault={async (e) => {
if (fileIdSelection.has(file.id, $commit?.id)) {
popupMenu.openByMouse(e, { files: await $selectedFiles });
contextMenu.open(e, { files: await $selectedFiles });
} else {
popupMenu.openByMouse(e, { files: [file] });
contextMenu.open(e, { files: [file] });
}
}}
role="button"
tabindex="0"
use:draggableChips={{
label: `${file.filename}`,
filePath: file.path,

View File

@ -6,7 +6,7 @@
import ScrollableContainer from '../shared/ScrollableContainer.svelte';
import emptyFolderSvg from '$lib/assets/empty-state/empty-folder.svg?raw';
import { Project } from '$lib/backend/projects';
import { clickOutside } from '$lib/clickOutside';
import { clickOutside } from '$lib/clickOutsideNew';
import FileCard from '$lib/file/FileCard.svelte';
import SnapshotCard from '$lib/history/SnapshotCard.svelte';
import { HistoryService, createdOnDay } from '$lib/history/history';

View File

@ -2,34 +2,38 @@
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import PopupMenu from '$lib/shared/PopupMenu.svelte';
import { getContext } from '$lib/utils/context';
import { editor } from '$lib/utils/systemEditor';
import { BranchController } from '$lib/vbranches/branchController';
import { open } from '@tauri-apps/api/shell';
import { open as openFile } from '@tauri-apps/api/shell';
export let target: HTMLElement;
export let filePath: string;
export let projectPath: string | undefined;
export let readonly: boolean;
const branchController = getContext(BranchController);
let popupMenu: PopupMenu;
let contextMenu: ContextMenu;
export function openByMouse(e: MouseEvent, item: any) {
popupMenu.openByMouse(e, item);
export function open(e: MouseEvent, item: any) {
contextMenu.open(e, item);
}
export function close() {
contextMenu.close();
}
</script>
<PopupMenu bind:this={popupMenu} let:item let:dismiss>
<ContextMenu>
<ContextMenu bind:this={contextMenu} {target} openByMouse>
{#snippet children(item)}
<ContextMenuSection>
{#if item.hunk !== undefined && !readonly}
<ContextMenuItem
label="Discard"
on:click={() => {
branchController.unapplyHunk(item.hunk);
dismiss();
contextMenu.close();
}}
/>
{/if}
@ -38,11 +42,11 @@
label="Open in VS Code"
on:mousedown={() => {
projectPath &&
open(`${editor.get()}://file${projectPath}/${filePath}:${item.lineNumber}`);
dismiss();
openFile(`${editor.get()}://file${projectPath}/${filePath}:${item.lineNumber}`);
contextMenu.close();
}}
/>
{/if}
</ContextMenuSection>
</ContextMenu>
</PopupMenu>
{/snippet}
</ContextMenu>

View File

@ -16,6 +16,7 @@
const dispatch = createEventDispatcher<{
lineContextMenu: { lineNumber: number | undefined; event: MouseEvent };
click: void;
selected: boolean;
}>();
@ -49,8 +50,10 @@
>
{#each lines as line}
<div
tabindex="-1"
role="none"
class="code-line"
role="group"
on:click={() => dispatch('click')}
on:contextmenu={(event) => {
const lineNumber = line.afterLineNumber ? line.afterLineNumber : line.beforeLineNumber;
dispatch('lineContextMenu', { event, lineNumber });

View File

@ -10,12 +10,9 @@
import { getContext, getContextStoreBySymbol, maybeGetContextStore } from '$lib/utils/context';
import { Ownership } from '$lib/vbranches/ownership';
import { Branch, type Hunk } from '$lib/vbranches/types';
import { mount, onDestroy, unmount } from 'svelte';
import type { HunkSection } from '$lib/utils/fileSections';
import type { Writable } from 'svelte/store';
export let viewport: HTMLDivElement | undefined = undefined;
export let contents: HTMLDivElement | undefined = undefined;
export let filePath: string;
export let section: HunkSection;
export let minWidth: number;
@ -30,6 +27,11 @@
const branch = maybeGetContextStore(Branch);
const project = getContext(Project);
let viewport: HTMLDivElement;
let contents: HTMLDivElement;
let contextMenu: HunkContextMenu;
let alwaysShow = false;
function onHunkSelected(hunk: Hunk, isSelected: boolean) {
if (!selectedOwnership) return;
if (isSelected) {
@ -38,26 +40,17 @@
selectedOwnership.update((ownership) => ownership.remove(hunk.filePath, hunk.id));
}
}
function updateContextMenu(filePath: string) {
if (popupMenu) unmount(popupMenu);
return mount(HunkContextMenu, {
target: document.body,
props: { projectPath: project.vscodePath, filePath, readonly }
});
}
$: popupMenu = updateContextMenu(filePath);
$: draggingDisabled = readonly || isUnapplied;
onDestroy(() => {
if (popupMenu) {
unmount(popupMenu);
}
});
let alwaysShow = false;
</script>
<HunkContextMenu
bind:this={contextMenu}
target={viewport}
projectPath={project.vscodePath}
{filePath}
{readonly}
/>
<div class="scrollable">
<div
bind:this={viewport}
@ -93,8 +86,11 @@
selected={$selectedOwnership?.contains(hunk.filePath, hunk.id)}
on:selected={(e) => onHunkSelected(hunk, e.detail)}
sectionType={subsection.sectionType}
on:click={() => {
contextMenu.close();
}}
on:lineContextMenu={(e) => {
popupMenu.openByMouse(e.detail.event, {
contextMenu.open(e.detail.event, {
hunk,
section: subsection,
lineNumber: e.detail.lineNumber

View File

@ -128,5 +128,6 @@
"item-dashed": "M14 5.75H12.5V5C12.5 4.79385 12.4592 4.601 12.3868 4.42622L13.7724 3.85164C13.919 4.20536 14 4.59323 14 5V5.75Z M5.75 2V3.5H5C4.79385 3.5 4.601 3.54075 4.42622 3.61323L3.85164 2.22764C4.20536 2.08096 4.59323 2 5 2H5.75Z M2.22764 3.85164C2.08096 4.20536 2 4.59323 2 5V5.75H3.5V5C3.5 4.79385 3.54075 4.601 3.61323 4.42622L2.22764 3.85164Z M2 10.25H3.5V11C3.5 11.2062 3.54075 11.399 3.61323 11.5738L2.22764 12.1484C2.08096 11.7946 2 11.4068 2 11V10.25Z M10.25 14V12.5H11C11.2062 12.5 11.399 12.4592 11.5738 12.3868L12.1484 13.7724C11.7946 13.919 11.4068 14 11 14H10.25Z M14 7.25H12.5V8.75H14V7.25Z M14 10.25H12.5V11C12.5 11.2062 12.4592 11.399 12.3868 11.5738L13.7724 12.1484C13.919 11.7946 14 11.4068 14 11V10.25Z M8.75 14V12.5H7.25V14H8.75Z M5.75 14V12.5H5C4.79385 12.5 4.601 12.4592 4.42622 12.3868L3.85164 13.7724C4.20536 13.919 4.59323 14 5 14H5.75Z M2 8.75H3.5V7.25H2V8.75Z M7.25 2V3.5H8.75V2H7.25Z M10.25 2V3.5H11C11.2062 3.5 11.399 3.54075 11.5738 3.61323L12.1484 2.22764C11.7946 2.08096 11.4068 2 11 2H10.25Z",
"item-move": "M11 12.5H5C4.17157 12.5 3.5 11.8284 3.5 11V8.75H8.11127L6.30367 10.4543L7.33269 11.5457L10.5145 8.54569L11.0933 8L10.5145 7.45431L7.33269 4.45431L6.30367 5.54569L8.11127 7.25H3.5V5C3.5 4.17157 4.17157 3.5 5 3.5H11C11.8284 3.5 12.5 4.17157 12.5 5V11C12.5 11.8284 11.8284 12.5 11 12.5ZM2 5C2 3.34315 3.34315 2 5 2H11C12.6569 2 14 3.34315 14 5V11C14 12.6569 12.6569 14 11 14H5C3.34315 14 2 12.6569 2 11V5Z",
"robot": "M12.3829 3.33776C11.9899 3.1336 11.5223 3.05284 10.8366 3.0209C10.4307 1.8448 9.31402 1 8 1C6.68598 1 5.56928 1.8448 5.1634 3.0209C4.47768 3.05284 4.01011 3.1336 3.61708 3.33776C3.06915 3.62239 2.62239 4.06915 2.33776 4.61708C2.1456 4.987 2.06277 5.42295 2.02706 6.04467C0.872483 6.26574 0 7.28098 0 8.5C0 9.71002 0.859646 10.7193 2.00153 10.9503C2.00923 12.1554 2.05584 12.8402 2.33776 13.3829C2.62239 13.9309 3.06915 14.3776 3.61708 14.6622C4.26729 15 5.12153 15 6.83 15H9.17C10.8785 15 11.7327 15 12.3829 14.6622C12.9309 14.3776 13.3776 13.9309 13.6622 13.3829C13.9442 12.8402 13.9908 12.1554 13.9985 10.9503C15.1404 10.7193 16 9.71002 16 8.5C16 7.28098 15.1275 6.26574 13.9729 6.04467C13.9372 5.42295 13.8544 4.987 13.6622 4.61708C13.3776 4.06915 12.9309 3.62239 12.3829 3.33776ZM6.83 4.5C5.95059 4.5 5.38275 4.50121 4.95083 4.53707C4.53687 4.57145 4.38385 4.62976 4.30854 4.66888C4.03457 4.81119 3.81119 5.03457 3.66888 5.30854C3.62976 5.38385 3.57145 5.53687 3.53707 5.95083C3.50121 6.38275 3.5 6.95059 3.5 7.83V10.17C3.5 11.0494 3.50121 11.6173 3.53707 12.0492C3.57145 12.4631 3.62976 12.6161 3.66888 12.6915C3.81119 12.9654 4.03457 13.1888 4.30854 13.3311C4.38385 13.3702 4.53687 13.4285 4.95083 13.4629C5.38275 13.4988 5.95059 13.5 6.83 13.5H9.17C10.0494 13.5 10.6173 13.4988 11.0492 13.4629C11.4631 13.4285 11.6161 13.3702 11.6915 13.3311C11.9654 13.1888 12.1888 12.9654 12.3311 12.6915C12.3702 12.6161 12.4285 12.4631 12.4629 12.0492C12.4988 11.6173 12.5 11.0494 12.5 10.17V7.83C12.5 6.95059 12.4988 6.38275 12.4629 5.95083C12.4285 5.53687 12.3702 5.38385 12.3311 5.30854C12.1888 5.03457 11.9654 4.81119 11.6915 4.66888C11.6161 4.62976 11.4631 4.57145 11.0492 4.53707C10.6173 4.50121 10.0494 4.5 9.17 4.5H6.83ZM5.75 10C5.33579 10 5 10.3358 5 10.75C5 11.1642 5.33579 11.5 5.75 11.5H10.25C10.6642 11.5 11 11.1642 11 10.75C11 10.3358 10.6642 10 10.25 10H5.75ZM6 9C6.55228 9 7 8.55228 7 8C7 7.44772 6.55228 7 6 7C5.44772 7 5 7.44772 5 8C5 8.55228 5.44772 9 6 9ZM11 8C11 8.55228 10.5523 9 10 9C9.44771 9 9 8.55228 9 8C9 7.44772 9.44771 7 10 7C10.5523 7 11 7.44772 11 8Z",
"doc": "M12 6.75H4V5.25H12V6.75Z M4 9.75H8V8.25H4V9.75Z M4 1C2.34315 1 1 2.34315 1 4V12C1 13.6569 2.34315 15 4 15H12C13.6569 15 15 13.6569 15 12V4C15 2.34315 13.6569 1 12 1H4ZM2.5 12C2.5 12.8284 3.17157 13.5 4 13.5H12C12.8284 13.5 13.5 12.8284 13.5 12V4C13.5 3.17157 12.8284 2.5 12 2.5H4C3.17157 2.5 2.5 3.17157 2.5 4V12Z"
"doc": "M12 6.75H4V5.25H12V6.75Z M4 9.75H8V8.25H4V9.75Z M4 1C2.34315 1 1 2.34315 1 4V12C1 13.6569 2.34315 15 4 15H12C13.6569 15 15 13.6569 15 12V4C15 2.34315 13.6569 1 12 1H4ZM2.5 12C2.5 12.8284 3.17157 13.5 4 13.5H12C12.8284 13.5 13.5 12.8284 13.5 12V4C13.5 3.17157 12.8284 2.5 12 2.5H4C3.17157 2.5 2.5 3.17157 2.5 4V12Z",
"clear-input": "M1 8C1 4.13401 4.13401 1 8 1C11.866 1 15 4.13401 15 8C15 11.866 11.866 15 8 15C4.13401 15 1 11.866 1 8ZM8 6.93934L10.4697 4.46967L11.5303 5.53033L9.06066 8L11.5303 10.4697L10.4697 11.5303L8 9.06066L5.53033 11.5303L4.46967 10.4697L6.93934 8L4.46967 5.53033L5.53033 4.46967L8 6.93934Z"
}

View File

@ -3,7 +3,7 @@
import BranchesHeader from './BranchesHeader.svelte';
import noBranchesSvg from '$lib/assets/empty-state/no-branches.svg?raw';
import { BranchService } from '$lib/branches/service';
import FilterPopupMenu from '$lib/components/FilterPopupMenu.svelte';
import FilterButton from '$lib/components/FilterBranchesButton.svelte';
import { GitHubService } from '$lib/github/service';
import { persisted } from '$lib/persisted/persisted';
import { storeToObservable } from '$lib/rxjs/store';
@ -18,12 +18,12 @@
const dispatch = createEventDispatcher<{ scrollbarDragging: boolean }>();
export let projectId: string;
export const textFilter$ = new BehaviorSubject<string | undefined>(undefined);
const branchService = getContext(BranchService);
const githubService = getContext(GitHubService);
// let contextMenu: ContextMenuActions;
let includePrs = persisted(true, 'includePrs_' + projectId);
let includeRemote = persisted(true, 'includeRemote_' + projectId);
let includeStashed = persisted(true, 'includeStashed_' + projectId);
@ -114,9 +114,9 @@
filteredBranchCount={$filteredBranches$?.length}
filtersActive={$filtersActive}
>
{#snippet contextMenu({ visible })}
<FilterPopupMenu
{visible}
{#snippet filterButton()}
<FilterButton
{filtersActive}
{includePrs}
{includeRemote}
{includeStashed}

View File

@ -1,26 +1,15 @@
<script lang="ts">
import { clickOutside } from '$lib/clickOutside';
import Badge from '$lib/shared/Badge.svelte';
import Button from '$lib/shared/Button.svelte';
import type { Snippet } from 'svelte';
interface Props {
filteredBranchCount?: number;
totalBranchCount: number;
filtersActive: boolean;
contextMenu: Snippet<[{ visible: boolean }]>;
filterButton: Snippet<[filtersActive: boolean]>;
}
const { filteredBranchCount, totalBranchCount, filtersActive, contextMenu }: Props = $props();
let visible = $state(false);
let filterButton = $state<HTMLDivElement>();
function onFilterClick(e: Event) {
visible = !visible;
e.preventDefault();
e.stopPropagation();
}
const { filteredBranchCount, totalBranchCount, filtersActive, filterButton }: Props = $props();
</script>
<div class="header">
@ -32,22 +21,7 @@
{/if}
</div>
{#if totalBranchCount > 0}
<div class="header__filter-btn" bind:this={filterButton}>
<Button
style="ghost"
outline
icon={filtersActive ? 'filter-applied-small' : 'filter-small'}
on:mousedown={onFilterClick}
>
Filter
</Button>
<div
class="filter-popup-menu"
use:clickOutside={{ trigger: filterButton, handler: () => (visible = false) }}
>
{@render contextMenu({ visible })}
</div>
</div>
{@render filterButton(filtersActive)}
{/if}
</div>
@ -64,16 +38,6 @@
transition: border-bottom var(--transition-fast);
position: relative;
}
.header__filter-btn {
position: relative;
}
.filter-popup-menu {
position: absolute;
top: calc(var(--size-button) + 4px);
right: 0;
z-index: var(--z-floating);
min-width: 160px;
}
.branches-title {
display: flex;
align-items: center;

View File

@ -1,11 +1,19 @@
<script lang="ts">
import { ProjectService } from '$lib/backend/projects';
import ListItem from '$lib/shared/ListItem.svelte';
import Icon from '$lib/shared/Icon.svelte';
import ScrollableContainer from '$lib/shared/ScrollableContainer.svelte';
import { getContext } from '$lib/utils/context';
import type iconsJson from '$lib/icons/icons.json';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
interface ItemSnippetProps {
label: string;
selected?: boolean;
icon?: string;
onclick: () => void;
}
export let isNavCollapsed: boolean;
const projectService = getContext(ProjectService);
@ -24,42 +32,61 @@
}
</script>
{#snippet itemSnippet(props: ItemSnippetProps)}
<button
disabled={props.selected}
class="list-item"
class:selected={props.selected}
on:click={props.onclick}
>
<div class="label text-base-14 text-bold">
{props.label}
</div>
{#if props.icon || props.selected}
<div class="icon">
{#if props.icon}
<Icon name={loading ? 'spinner' : props.icon as keyof typeof iconsJson} />
{:else}
<Icon name="tick" />
{/if}
</div>
{/if}
</button>
{/snippet}
{#if !hidden}
<div class="popup" class:collapsed={isNavCollapsed}>
{#if $projects.length > 0}
<ScrollableContainer maxHeight="20rem">
<div class="popup__projects">
{#each $projects as project}
<!-- eslint-disable-next-line svelte/valid-compile -->
{@const selected = project.id === $page.params.projectId}
<ListItem
{selected}
icon={selected ? 'tick' : undefined}
on:click={() => {
{@render itemSnippet({
label: project.title,
selected,
icon: selected ? 'tick' : undefined,
onclick: () => {
goto(`/${project.id}/`);
hide();
projectService.setLastOpenedProject(project.id);
goto(`/${project.id}/board`);
}}
>
{project.title}
</ListItem>
}
})}
{/each}
</div>
</ScrollableContainer>
{/if}
<div class="popup__actions">
<ListItem
icon="plus"
{loading}
on:click={async () => {
{@render itemSnippet({
label: 'Add new project',
icon: 'plus',
onclick: async () => {
loading = true;
try {
await projectService.addProject();
} finally {
loading = false;
}
}}>Add new project</ListItem
>
}
})}
</div>
</div>
{/if}
@ -76,6 +103,21 @@
background: var(--clr-bg-1);
/* shadow/s */
box-shadow: 0px 7px 14px 0px rgba(0, 0, 0, 0.1);
animation: fadeIn 0.2s ease-out forwards;
}
@keyframes fadeIn {
0% {
opacity: 0;
transform: translateY(-6px);
}
50% {
opacity: 1;
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.popup__actions {
padding: 8px;
@ -88,6 +130,40 @@
padding: 8px;
}
/* LIST ITEM */
.list-item {
display: flex;
align-items: center;
color: var(--clr-scale-ntrl-10);
font-weight: 700;
padding: 10px 10px;
justify-content: space-between;
border-radius: var(--radius-m);
width: 100%;
transition: background-color var(--transition-fast);
&:hover:enabled,
&:focus:enabled {
background-color: var(--clr-bg-1-muted);
& .icon {
color: var(--clr-scale-ntrl-40);
}
}
&:disabled {
background-color: var(--clr-bg-2);
color: var(--clr-text-2);
}
& .icon {
display: flex;
color: var(--clr-scale-ntrl-50);
}
& .label {
height: 16px;
text-overflow: ellipsis;
overflow: hidden;
}
}
/* MODIFIERS */
.popup.collapsed {
width: 240px;

View File

@ -1,5 +1,4 @@
<script lang="ts">
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import { MergeMethod } from '$lib/github/types';
@ -43,17 +42,15 @@
}}
>
{labels[$action]}
<ContextMenu slot="context-menu">
<ContextMenuSection>
{#each Object.values(MergeMethod) as method}
<ContextMenuItem
label={labels[method]}
on:click={() => {
$action = method;
dropDown.close();
}}
/>
{/each}
</ContextMenuSection>
</ContextMenu>
<ContextMenuSection slot="context-menu">
{#each Object.values(MergeMethod) as method}
<ContextMenuItem
label={labels[method]}
on:click={() => {
$action = method;
dropDown.close();
}}
/>
{/each}
</ContextMenuSection>
</DropDownButton>

View File

@ -1,5 +1,4 @@
<script lang="ts">
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import { persisted, type Persisted } from '$lib/persisted/persisted';
@ -40,17 +39,15 @@
}}
>
{labels[$action]}
<ContextMenu slot="context-menu">
<ContextMenuSection>
{#each Object.values(Action) as method}
<ContextMenuItem
label={labels[method]}
on:click={() => {
$action = method;
dropDown.close();
}}
/>
{/each}
</ContextMenuSection>
</ContextMenu>
<ContextMenuSection slot="context-menu">
{#each Object.values(Action) as method}
<ContextMenuItem
label={labels[method]}
on:click={() => {
$action = method;
dropDown.close();
}}
/>
{/each}
</ContextMenuSection>
</DropDownButton>

View File

@ -3,7 +3,6 @@
import InfoMessage from '../shared/InfoMessage.svelte';
import { Project } from '$lib/backend/projects';
import { BranchService } from '$lib/branches/service';
import ViewPrContextMenu from '$lib/components/ViewPrContextMenu.svelte';
import { GitHubService } from '$lib/github/service';
import Button from '$lib/shared/Button.svelte';
import { getContext, getContextStore } from '$lib/utils/context';
@ -13,7 +12,6 @@
import { BaseBranchService } from '$lib/vbranches/baseBranch';
import { Branch } from '$lib/vbranches/types';
import { distinctUntilChanged } from 'rxjs';
import { mount, onDestroy, unmount } from 'svelte';
import { derived, type Readable } from 'svelte/store';
import type { ChecksStatus, DetailedPullRequest } from '$lib/github/types';
import type iconsJson from '$lib/icons/icons.json';
@ -236,22 +234,6 @@
}
}
}
function updateContextMenu(copyablePrUrl: string) {
if (popupMenu) unmount(popupMenu);
return mount(ViewPrContextMenu, {
target: document.body,
props: { prUrl: copyablePrUrl }
});
}
$: popupMenu = updateContextMenu($pr$?.htmlUrl || '');
onDestroy(() => {
if (popupMenu) {
unmount(popupMenu);
}
});
</script>
{#if $pr$}
@ -307,10 +289,6 @@
e.preventDefault();
e.stopPropagation();
}}
on:contextmenu={(e) => {
e.preventDefault();
popupMenu.openByMouse(e, undefined);
}}
>
Open in browser
</Button>

View File

@ -0,0 +1,26 @@
<script lang="ts">
import { type Snippet } from 'svelte';
interface Props {
children: Snippet;
}
const { children }: Props = $props();
</script>
<div class="options__group">
{@render children()}
</div>
<style lang="postcss">
.options__group {
display: flex;
flex-direction: column;
padding: 6px;
gap: 2px;
&:not(&:last-child) {
border-bottom: 1px solid var(--clr-border-2);
}
}
</style>

View File

@ -0,0 +1,103 @@
<script lang="ts">
import { type SelectItem } from './Select.svelte';
import Icon from '$lib/shared/Icon.svelte';
interface Props {
placeholder?: string;
items: SelectItem[];
onSort?: (items: SelectItem[]) => void;
}
const { placeholder = 'Search…', items, onSort }: Props = $props();
let value = $state('');
let filteredItems = $state(items);
let inputEl: HTMLInputElement;
function handleFilter() {
filteredItems = items.filter((item) => item.label.toLowerCase().includes(value.toLowerCase()));
}
function resetFilter() {
value = '';
handleFilter();
inputEl.focus();
}
function handleInput(event: Event) {
value = (event.target as HTMLInputElement).value;
handleFilter();
}
$effect(() => {
onSort?.(filteredItems);
});
</script>
<div class="container">
{#if !value}
<i class="icon search-icon">
<Icon name="search" />
</i>
{:else}
<button class="icon" onclick={resetFilter}>
<Icon name="clear-input" />
</button>
{/if}
<input
bind:this={inputEl}
class="text-base-13 search-input"
type="text"
{placeholder}
bind:value
oninput={handleInput}
autocorrect="off"
autocomplete="off"
/>
</div>
<style lang="postcss">
.container {
position: relative;
user-select: text;
}
.search-input {
padding: 12px 34px 12px 12px;
width: 100%;
background-color: var(--clr-bg-1);
color: var(--clr-text-1);
border-bottom: 1px solid var(--clr-border-2);
transition: border-color var(--transition-fast);
&:hover,
&:focus-within {
background-color: var(--clr-bg-1-muted);
outline: none;
}
&::placeholder {
color: var(--clr-text-3);
}
}
.icon {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
color: var(--clr-scale-ntrl-50);
transition: color var(--transition-fast);
&:hover {
color: var(--clr-scale-ntrl-40);
}
}
.search-icon {
pointer-events: none;
}
</style>

View File

@ -0,0 +1,281 @@
<script lang="ts" context="module">
export type SelectItem = {
label: string;
value: string;
selectable?: boolean;
};
</script>
<script lang="ts">
import OptionsGroup from './OptionsGroup.svelte';
import SearchItem from './SearchItem.svelte';
import ScrollableContainer from '../shared/ScrollableContainer.svelte';
import TextBox from '../shared/TextBox.svelte';
import { KeyName } from '$lib/utils/hotkeys';
import { portal } from '$lib/utils/portal';
import { pxToRem } from '$lib/utils/pxToRem';
import { resizeObserver } from '$lib/utils/resizeObserver';
import type { Snippet } from 'svelte';
interface SelectProps {
id?: string;
label?: string;
disabled?: boolean;
loading?: boolean;
wide?: boolean;
options: SelectItem[];
value?: any;
placeholder?: string;
maxHeight?: number;
searchable?: boolean;
itemSnippet: Snippet<[{ item: any; highlighted: boolean }]>;
children?: Snippet;
onselect?: (value: string) => void;
}
const {
id,
label,
disabled,
loading,
wide,
options = [],
value,
placeholder,
maxHeight,
searchable,
itemSnippet,
children,
onselect
}: SelectProps = $props();
let selectWrapperEl: HTMLElement;
// let hightlighted = $state(false);
let highlightedIndex: number | undefined = $state(undefined);
let filteredOptions = $state(options);
let maxHeightState = $state(maxHeight);
let listOpen = $state(false);
let inputBoundingRect = $state<DOMRect>();
let optionsEl = $state<HTMLDivElement>();
const maxBottomPadding = 20;
function setMaxHeight() {
if (maxHeight) return;
maxHeightState =
window.innerHeight - selectWrapperEl.getBoundingClientRect().bottom - maxBottomPadding;
}
function openList() {
setMaxHeight();
listOpen = true;
}
function closeList() {
listOpen = false;
}
function clickOutside(e: MouseEvent) {
if (e.target === e.currentTarget) closeList();
}
function getInputBoundingRect() {
if (selectWrapperEl) {
inputBoundingRect = selectWrapperEl.getBoundingClientRect();
}
}
function toggleList() {
getInputBoundingRect();
if (listOpen) closeList();
else openList();
}
function handleSelect(item: { label: string; value: string }) {
const value = item.value;
onselect?.(value);
closeList();
}
function handleEnter() {
if (highlightedIndex !== undefined) {
handleSelect(filteredOptions[highlightedIndex]);
}
}
function handleArrowUp() {
if (filteredOptions.length === 0) return;
if (highlightedIndex === undefined) {
highlightedIndex = filteredOptions.length - 1;
} else {
highlightedIndex = highlightedIndex === 0 ? filteredOptions.length - 1 : highlightedIndex - 1;
}
}
function handleArrowDown() {
if (filteredOptions.length === 0) return;
if (highlightedIndex === undefined) {
highlightedIndex = 0;
} else {
highlightedIndex = highlightedIndex === filteredOptions.length - 1 ? 0 : highlightedIndex + 1;
}
}
function handleKeyDown(e: CustomEvent<KeyboardEvent>) {
if (!listOpen) {
return;
}
e.detail.stopPropagation();
e.detail.preventDefault();
const { key } = e.detail;
switch (key) {
case KeyName.Escape:
closeList();
break;
case KeyName.Up:
handleArrowUp();
break;
case KeyName.Down:
handleArrowDown();
break;
case KeyName.Enter:
handleEnter();
break;
}
}
</script>
<div class="select-wrapper" class:wide bind:this={selectWrapperEl}>
{#if label}
<label for={id} class="select__label text-base-body-13 text-semibold">{label}</label>
{/if}
<TextBox
{id}
{placeholder}
noselect
readonly
type="select"
reversedDirection
icon="select-chevron"
value={options.find((item) => item.value === value)?.label || 'Select an option...'}
disabled={disabled || loading}
on:mousedown={toggleList}
on:keydown={(ev) => handleKeyDown(ev)}
/>
{#if listOpen}
<div
role="presentation"
class="overlay-wrapper"
onclick={clickOutside}
use:portal={'body'}
use:resizeObserver={() => {
getInputBoundingRect();
setMaxHeight();
}}
>
<div
bind:this={optionsEl}
class="options card"
style:width="{inputBoundingRect?.width}px"
style:top={inputBoundingRect?.top
? pxToRem(inputBoundingRect.top + inputBoundingRect.height)
: undefined}
style:left={inputBoundingRect?.left ? pxToRem(inputBoundingRect.left) : undefined}
style:max-height={maxHeightState && pxToRem(maxHeightState)}
>
<ScrollableContainer initiallyVisible>
{#if searchable && options.length > 5}
<SearchItem
items={options}
onSort={(filtered) => {
filteredOptions = filtered;
}}
/>
{/if}
<OptionsGroup>
{#if filteredOptions.length === 0}
<div class="text-base-13 text-semibold option nothing-found">
<span class=""> Nothing found ¯\_(ツ)_/¯ </span>
</div>
{/if}
{#each filteredOptions as item, idx}
<div class="option" tabindex="-1" role="none" onmousedown={() => handleSelect(item)}>
{@render itemSnippet({ item, highlighted: idx === highlightedIndex })}
</div>
{/each}
</OptionsGroup>
{#if children}
{@render children()}
{/if}
</ScrollableContainer>
</div>
</div>
{/if}
</div>
<style lang="postcss">
.select-wrapper {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
}
.select__label {
text-align: left;
color: var(--clr-scale-ntrl-50);
}
.overlay-wrapper {
z-index: var(--z-blocker);
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* background-color: rgba(0, 0, 0, 0.1); */
}
.options {
user-select: none;
position: absolute;
z-index: var(--z-floating);
margin-top: 4px;
border-radius: var(--radius-m);
border: 1px solid var(--clr-border-2);
background: var(--clr-bg-1);
box-shadow: var(--fx-shadow-s);
overflow: hidden;
transform-origin: top;
animation: fadeIn 0.16s ease-out forwards;
}
@keyframes fadeIn {
0% {
opacity: 0;
transform: translateY(-6px);
}
50% {
opacity: 1;
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.wide {
width: 100%;
}
.nothing-found {
padding: 8px 8px;
color: var(--clr-text-3);
}
</style>

View File

@ -36,6 +36,7 @@
<style lang="postcss">
.button {
user-select: none;
display: flex;
align-items: center;
color: var(--clr-scale-ntrl-10);
@ -77,6 +78,6 @@
}
.highlighted {
background-color: var(--clr-bg-3);
background-color: var(--clr-bg-1-muted);
}
</style>

View File

@ -4,12 +4,12 @@
import SectionCard from '$lib/components/SectionCard.svelte';
import SectionCardDisclaimer from '$lib/components/SectionCardDisclaimer.svelte';
import { projectRunCommitHooks } from '$lib/config/config';
import Select from '$lib/select/Select.svelte';
import SelectItem from '$lib/select/SelectItem.svelte';
import Section from '$lib/settings/Section.svelte';
import Button from '$lib/shared/Button.svelte';
import InfoMessage from '$lib/shared/InfoMessage.svelte';
import Link from '$lib/shared/Link.svelte';
import Select from '$lib/shared/Select.svelte';
import SelectItem from '$lib/shared/SelectItem.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import { getContext } from '$lib/utils/context';
@ -58,11 +58,11 @@
const signingFormatOptions = [
{
name: 'GPG',
label: 'GPG',
value: 'openpgp'
},
{
name: 'SSH',
label: 'SSH',
value: 'ssh'
}
];
@ -152,16 +152,19 @@
{#if signCommits}
<SectionCard orientation="column">
<Select
items={signingFormatOptions}
bind:selectedItemId={signingFormat}
itemId="value"
labelId="name"
on:select={updateSigningInfo}
value={signingFormat}
options={signingFormatOptions}
label="Signing format"
onselect={(value) => {
signingFormat = value;
updateSigningInfo();
}}
>
<SelectItem slot="template" let:item let:selected {selected} let:highlighted {highlighted}>
{item.name}
</SelectItem>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === signingFormat} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
<TextBox

View File

@ -6,7 +6,7 @@
import type { ComponentColor, ComponentStyleKind } from '$lib/vbranches/types';
// Interaction props
export let element: HTMLAnchorElement | HTMLButtonElement | HTMLElement | null = null;
export let el: HTMLAnchorElement | HTMLButtonElement | HTMLElement | null = null;
export let disabled = false;
export let clickable = true;
export let id: string | undefined = undefined;
@ -52,7 +52,7 @@
text: help,
delay: helpShowDelay
}}
bind:this={element}
bind:this={el}
disabled={disabled || loading}
on:click
on:mousedown
@ -84,6 +84,7 @@
<style lang="postcss">
.btn {
user-select: none;
position: relative;
display: inline-flex;
align-items: center;

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { clickOutside } from '$lib/clickOutside';
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import Button from '$lib/shared/Button.svelte';
import type iconsJson from '$lib/icons/icons.json';
import type { ComponentColor, ComponentStyleKind } from '$lib/vbranches/types';
@ -13,23 +13,25 @@
export let wide = false;
export let help = '';
export let menuPosition: 'top' | 'bottom' = 'bottom';
let contextMenu: ContextMenu;
let iconEl: HTMLElement;
let visible = false;
export function show() {
visible = true;
contextMenu.open();
}
export function close() {
visible = false;
contextMenu.close();
}
let container: HTMLDivElement;
let contextMenuContainer: HTMLDivElement;
let iconEl: HTMLElement;
</script>
<div class="dropdown-wrapper" class:wide>
<div class="dropdown" bind:this={container}>
<div class="dropdown">
<Button
{style}
{icon}
@ -44,7 +46,7 @@
<slot />
</Button>
<Button
bind:element={iconEl}
bind:el={iconEl}
{style}
{kind}
{help}
@ -55,23 +57,20 @@
isDropdownChild
on:click={() => {
visible = !visible;
contextMenu.toggle();
}}
/>
</div>
<div
class="context-menu-container"
use:clickOutside={{
trigger: iconEl,
handler: () => (visible = false),
enabled: visible
<ContextMenu
bind:this={contextMenu}
target={iconEl}
verticalAlign={menuPosition}
onclose={() => {
visible = false;
}}
bind:this={contextMenuContainer}
style:display={visible ? 'block' : 'none'}
class:dropdown-top={menuPosition === 'top'}
class:dropdown-bottom={menuPosition === 'bottom'}
>
<slot name="context-menu" />
</div>
</ContextMenu>
</div>
<style lang="postcss">
@ -85,23 +84,6 @@
flex-grow: 1;
align-items: center;
}
.context-menu-container {
position: absolute;
right: 0;
z-index: var(--z-floating);
}
.dropdown-top {
bottom: 100%;
padding-bottom: 4px;
}
.dropdown-bottom {
top: 100%;
padding-top: 4px;
}
.wide {
width: 100%;
}

View File

@ -1,5 +1,4 @@
<script lang="ts">
import { clickOutside } from '$lib/clickOutside';
import Icon from '$lib/shared/Icon.svelte';
import { portal } from '$lib/utils/portal';
import type iconsJson from '$lib/icons/icons.json';
@ -33,16 +32,23 @@
</script>
{#if open}
<div class="modal-container" class:open bind:this={dialog} onclose={close} use:portal={'body'}>
<div
bind:this={dialog}
use:portal={'body'}
role="presentation"
class="modal-container"
class:open
onclick={(e) => {
// Close the modal if the user clicks outside of it
e.target === e.currentTarget && close();
}}
>
<div
class="modal-content"
class:s-default={width === 'default'}
class:s-small={width === 'small'}
class:s-large={width === 'large'}
use:clickOutside={{
trigger: dialog,
handler: () => close()
}}
class:round-top-corners={!title}
>
{#if title}
<div class="modal__header">

View File

@ -1,59 +0,0 @@
<script lang="ts">
import { clickOutside } from '$lib/clickOutside';
let pos = { x: 0, y: 0 };
let menu = { h: 0, w: 0 };
let browser = { h: 0, w: 0 };
let showMenu = false;
let item: any;
function onDismiss() {
showMenu = false;
}
export function openByMouse(e: MouseEvent, item: any) {
show(e.clientX, e.clientY, item);
}
export function openByElement(elt: HTMLElement, item: any) {
const rect = elt.getBoundingClientRect();
show(rect.left, rect.top + rect.height, item);
}
function show(x: number, y: number, newItem: any) {
item = newItem;
showMenu = true;
browser = {
w: window.innerWidth,
h: window.innerHeight
};
pos = {
x: x,
y: y
};
if (browser.h - pos.y < menu.h) pos.y = pos.y - menu.h;
if (browser.w - pos.x < menu.w) pos.x = pos.x - menu.w;
}
export function recordDimensions(node: HTMLDivElement) {
let height = node.offsetHeight;
let width = node.offsetWidth;
menu = {
h: height,
w: width
};
}
</script>
{#if showMenu}
<div
role="menu"
tabindex="0"
use:recordDimensions
use:clickOutside={{ handler: () => onDismiss() }}
style="z-index: var(--z-floating); position: absolute; top:{pos.y}px; left:{pos.x}px"
>
<slot {item} dismiss={onDismiss} />
</div>
{/if}

View File

@ -1,13 +0,0 @@
<script lang="ts">
export let disabled = false;
export let title = '';
</script>
<button
on:click|preventDefault
{disabled}
{title}
class="w-full px-3 py-0.5 text-left no-underline enabled:hover:bg-light-100 disabled:text-light-600 enabled:dark:hover:bg-dark-800"
>
<slot />
</button>

View File

@ -1,263 +0,0 @@
<script lang="ts" context="module">
export type Selectable<S extends string> = Record<S, unknown>;
</script>
<script lang="ts" generics="Selectable extends Record<string, unknown>">
import ScrollableContainer from './ScrollableContainer.svelte';
import TextBox from './TextBox.svelte';
import { clickOutside } from '$lib/clickOutside';
import { KeyName } from '$lib/utils/hotkeys';
import { throttle } from '$lib/utils/misc';
import { pxToRem } from '$lib/utils/pxToRem';
import { isChar, isStr } from '$lib/utils/string';
import { createEventDispatcher } from 'svelte';
const INPUT_THROTTLE_TIME = 100;
type SelectableKey = keyof Selectable;
export let id: undefined | string = undefined;
export let label = '';
export let disabled = false;
export let loading = false;
export let wide = false;
export let items: Selectable[];
export let labelId: SelectableKey = 'label';
export let itemId: SelectableKey = 'value';
export let value: any = undefined;
export let selectedItemId: any = undefined;
export let placeholder = '';
export let maxHeight: number | undefined = 260;
$: if (selectedItemId) value = items.find((item) => item[itemId] === selectedItemId);
const dispatch = createEventDispatcher<{ select: { value: any } }>();
const maxPadding = 10;
let listOpen = false;
let element: HTMLElement;
let options: HTMLDivElement;
let highlightIndex: number | undefined = undefined;
let highlightedItem: Selectable | undefined = undefined;
let filterText: string | undefined = undefined;
let filteredItems: Selectable[] = items;
const filterItems = throttle((items: Selectable[], filterText: string | undefined) => {
if (!filterText) {
return items;
}
return items.filter((it) => {
const property = it[labelId];
if (!isStr(property)) return false;
return property.includes(filterText);
});
}, INPUT_THROTTLE_TIME);
$: filteredItems = filterItems(items, filterText);
$: highlightedItem = highlightIndex !== undefined ? filteredItems[highlightIndex] : undefined;
function handleItemClick(item: Selectable) {
if (item?.selectable === false) return;
if (value && value[itemId] === item[itemId]) return closeList();
selectedItemId = item[itemId];
dispatch('select', { value });
closeList();
}
function setMaxHeight() {
if (maxHeight) return;
maxHeight = window.innerHeight - element.getBoundingClientRect().bottom - maxPadding;
}
function toggleList() {
if (listOpen) closeList();
else openList();
}
function openList() {
setMaxHeight();
listOpen = true;
}
function closeList() {
listOpen = false;
highlightIndex = undefined;
filterText = undefined;
}
function handleEnter() {
if (highlightIndex !== undefined) {
handleItemClick(filteredItems[highlightIndex]);
}
closeList();
}
function handleArrowUp() {
if (filteredItems.length === 0) return;
if (highlightIndex === undefined) {
highlightIndex = filteredItems.length - 1;
} else {
highlightIndex = highlightIndex === 0 ? filteredItems.length - 1 : highlightIndex - 1;
}
}
function handleArrowDown() {
if (filteredItems.length === 0) return;
if (highlightIndex === undefined) {
highlightIndex = 0;
} else {
highlightIndex = highlightIndex === filteredItems.length - 1 ? 0 : highlightIndex + 1;
}
}
function handleChar(char: string) {
highlightIndex = undefined;
filterText ??= '';
filterText += char;
}
function handleDelete() {
if (filterText === undefined) return;
if (filterText.length === 1) {
filterText = undefined;
return;
}
filterText = filterText.slice(0, -1);
}
function handleKeyDown(e: CustomEvent<KeyboardEvent>) {
if (!listOpen) {
return;
}
e.detail.stopPropagation();
e.detail.preventDefault();
const { key } = e.detail;
switch (key) {
case KeyName.Escape:
closeList();
break;
case KeyName.Up:
handleArrowUp();
break;
case KeyName.Down:
handleArrowDown();
break;
case KeyName.Enter:
handleEnter();
break;
case KeyName.Delete:
handleDelete();
break;
default:
if (isChar(key)) handleChar(key);
break;
}
}
</script>
<div class="select-wrapper" class:wide bind:this={element}>
{#if label}
<label for={id} class="select__label text-base-body-13 text-semibold">{label}</label>
{/if}
<TextBox
{id}
{placeholder}
noselect
readonly
type="select"
reversedDirection
icon="select-chevron"
value={filterText ?? value?.[labelId]}
disabled={disabled || loading}
on:mousedown={() => toggleList()}
on:keydown={(ev) => handleKeyDown(ev)}
/>
<div
class="options card"
style:display={listOpen ? undefined : 'none'}
bind:this={options}
style:max-height={maxHeight && pxToRem(maxHeight)}
use:clickOutside={{
trigger: element,
handler: closeList,
enabled: listOpen
}}
>
<ScrollableContainer initiallyVisible>
{#if filteredItems}
<div class="options__group">
{#each filteredItems as item}
<div
class="option"
class:selected={item === value}
tabindex="-1"
role="none"
on:mousedown={() => handleItemClick(item)}
on:keydown|preventDefault|stopPropagation
>
<slot
name="template"
{item}
selected={item === value}
highlighted={item === highlightedItem}
/>
</div>
{/each}
</div>
{/if}
{#if $$slots?.append}
<div class="options__group">
<slot name="append" />
</div>
{/if}
</ScrollableContainer>
</div>
</div>
<style lang="postcss">
.select-wrapper {
/* display set directly on element */
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
}
.select__label {
text-align: left;
color: var(--clr-scale-ntrl-50);
}
.options {
position: absolute;
right: 0;
top: 100%;
width: 100%;
z-index: var(--z-floating);
margin-top: 4px;
border-radius: var(--radius-m);
border: 1px solid var(--clr-border-2);
background: var(--clr-bg-1);
box-shadow: var(--fx-shadow-s);
overflow: hidden;
}
.options__group {
display: flex;
flex-direction: column;
padding: 6px;
gap: 2px;
&:not(&:first-child):last-child {
border-top: 1px solid var(--clr-border-2);
}
}
.wide {
width: 100%;
}
</style>

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { autoHeight } from '$lib/utils/autoHeight';
import { pxToRem } from '$lib/utils/pxToRem';
import { useAutoHeight } from '$lib/utils/useAutoHeight';
import { useResize } from '$lib/utils/useResize';
import { resizeObserver } from '$lib/utils/resizeObserver';
import { createEventDispatcher } from 'svelte';
export let value: string | undefined;
@ -42,16 +42,16 @@
{spellcheck}
on:input={(e) => {
dispatch('input', e.currentTarget.value);
useAutoHeight(e.currentTarget);
autoHeight(e.currentTarget);
}}
on:change={(e) => {
dispatch('change', e.currentTarget.value);
useAutoHeight(e.currentTarget);
autoHeight(e.currentTarget);
}}
use:useResize={(e) => {
useAutoHeight(e.currentTarget as HTMLTextAreaElement);
use:resizeObserver={(e) => {
autoHeight(e.currentTarget as HTMLTextAreaElement);
}}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:focus={(e) => autoHeight(e.currentTarget)}
style:max-height={maxHeight ? pxToRem(maxHeight) : undefined}
></textarea>
</div>

View File

@ -210,6 +210,7 @@
/* select */
.textbox__input[type='select']:not([disabled]),
.textbox__input[type='select']:not([readonly]) {
user-select: none;
cursor: pointer;
}

View File

@ -1,4 +1,4 @@
export function useAutoHeight(element: HTMLTextAreaElement) {
export function autoHeight(element: HTMLTextAreaElement) {
if (!element) return;
const elementBorder =

View File

@ -1,4 +1,4 @@
export function useResize(
export function resizeObserver(
element: HTMLElement,
callback: (data: { currentTarget: HTMLElement; frame: { width: number; height: number } }) => void
) {

View File

@ -6,12 +6,12 @@
import SectionCard from '$lib/components/SectionCard.svelte';
import WelcomeSigninAction from '$lib/components/WelcomeSigninAction.svelte';
import { getSecretsService } from '$lib/secrets/secretsService';
import Select from '$lib/select/Select.svelte';
import SelectItem from '$lib/select/SelectItem.svelte';
import ContentWrapper from '$lib/settings/ContentWrapper.svelte';
import Section from '$lib/settings/Section.svelte';
import InfoMessage from '$lib/shared/InfoMessage.svelte';
import RadioButton from '$lib/shared/RadioButton.svelte';
import Select from '$lib/shared/Select.svelte';
import SelectItem from '$lib/shared/SelectItem.svelte';
import Spacer from '$lib/shared/Spacer.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import { UserService } from '$lib/stores/user';
@ -86,45 +86,45 @@
const keyOptions = [
{
name: 'Use GitButler API',
label: 'Use GitButler API',
value: KeyOption.ButlerAPI
},
{
name: 'Your own key',
label: 'Your own key',
value: KeyOption.BringYourOwn
}
];
const openAIModelOptions = [
{
name: 'GPT 3.5 Turbo',
label: 'GPT 3.5 Turbo',
value: OpenAIModelName.GPT35Turbo
},
{
name: 'GPT 4',
label: 'GPT 4',
value: OpenAIModelName.GPT4
},
{
name: 'GPT 4 Turbo',
label: 'GPT 4 Turbo',
value: OpenAIModelName.GPT4Turbo
},
{
name: 'GPT 4 Omni',
label: 'GPT 4 Omni',
value: OpenAIModelName.GPT4o
}
];
const anthropicModelOptions = [
{
name: 'Sonnet',
label: 'Sonnet',
value: AnthropicModelName.Sonnet
},
{
name: 'Opus',
label: 'Opus',
value: AnthropicModelName.Opus
},
{
name: 'Haiku',
label: 'Haiku',
value: AnthropicModelName.Haiku
}
];
@ -167,22 +167,18 @@
<SectionCard roundedTop={false} roundedBottom={false} orientation="row" topDivider>
<div class="inputs-group">
<Select
items={keyOptions}
bind:selectedItemId={openAIKeyOption}
itemId="value"
labelId="name"
value={openAIKeyOption}
options={keyOptions}
label="Do you want to provide your own key?"
onselect={(value) => {
openAIKeyOption = value as KeyOption;
}}
>
<SelectItem
slot="template"
let:item
let:selected
{selected}
let:highlighted
{highlighted}
>
{item.name}
</SelectItem>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === openAIKeyOption} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
{#if openAIKeyOption === KeyOption.ButlerAPI}
@ -197,22 +193,18 @@
<TextBox label="API key" bind:value={openAIKey} required placeholder="sk-..." />
<Select
items={openAIModelOptions}
bind:selectedItemId={openAIModelName}
itemId="value"
labelId="name"
value={openAIModelName}
options={openAIModelOptions}
label="Model version"
onselect={(value) => {
openAIModelName = value as OpenAIModelName;
}}
>
<SelectItem
slot="template"
let:item
let:selected
{selected}
let:highlighted
{highlighted}
>
{item.name}
</SelectItem>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === openAIModelName} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
{:else if !$user}
<WelcomeSigninAction prompt="A user is required to make use of the GitButler API" />
@ -237,22 +229,18 @@
<SectionCard roundedTop={false} roundedBottom={false} orientation="row" topDivider>
<div class="inputs-group">
<Select
items={keyOptions}
bind:selectedItemId={anthropicKeyOption}
itemId="value"
labelId="name"
value={anthropicKeyOption}
options={keyOptions}
label="Do you want to provide your own key?"
onselect={(value) => {
anthropicKeyOption = value as KeyOption;
}}
>
<SelectItem
slot="template"
let:item
let:selected
{selected}
let:highlighted
{highlighted}
>
{item.name}
</SelectItem>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === anthropicKeyOption} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
{#if anthropicKeyOption === KeyOption.ButlerAPI}
@ -272,22 +260,18 @@
/>
<Select
items={anthropicModelOptions}
bind:selectedItemId={anthropicModelName}
itemId="value"
labelId="name"
value={anthropicModelName}
options={anthropicModelOptions}
label="Model version"
onselect={(value) => {
anthropicModelName = value as AnthropicModelName;
}}
>
<SelectItem
slot="template"
let:item
let:selected
{selected}
let:highlighted
{highlighted}
>
{item.name}
</SelectItem>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === anthropicModelName} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
{:else if !$user}
<WelcomeSigninAction prompt="A user is required to make use of the GitButler API" />

View File

@ -29,7 +29,8 @@ export default tsEslint.config(
$props: 'readonly',
$bindable: 'readonly',
$inspect: 'readonly',
$host: 'readonly'
$host: 'readonly',
$effect: 'readonly'
},
parser: svelteParser,
parserOptions: {