Improve UI of advanced commit options

This commit is contained in:
Caleb Owens 2024-05-03 19:10:28 +01:00
parent 8db8ec5641
commit f8109368f3
3 changed files with 320 additions and 282 deletions

View File

@ -1,6 +1,9 @@
<script lang="ts">
import BranchFilesList from './BranchFilesList.svelte';
import { Project } from '$lib/backend/projects';
import Button from '$lib/components/Button.svelte';
import CommitMessageInput from '$lib/components/CommitMessageInput.svelte';
import Modal from '$lib/components/Modal.svelte';
import Tag from '$lib/components/Tag.svelte';
import TimeAgo from '$lib/components/TimeAgo.svelte';
import { persistedCommitMessage } from '$lib/config/config';
@ -49,11 +52,7 @@
function toggleFiles() {
showFiles = !showFiles;
if (!showFiles && branch) {
if (commit.description != description) {
branchController.updateCommitMessage(branch.id, commit.id, description);
}
}
if (showFiles) loadFiles();
}
@ -89,9 +88,37 @@
$: isUndoable = isHeadCommit;
const hasCommitUrl = !commit.isLocal && commitUrl;
let description = commit.description;
let commitMessageModal: Modal;
let description = '';
function openCommitMessageModal(e: Event) {
e.preventDefault();
description = commit.description;
commitMessageModal.show();
}
function submitCommitMessageModal() {
commit.description = description;
if (branch) {
branchController.updateCommitMessage(branch.id, commit.id, description);
}
commitMessageModal.close();
}
</script>
<Modal bind:this={commitMessageModal}>
<CommitMessageInput bind:commitMessage={description} />
<svelte:fragment slot="controls">
<Button style="ghost" kind="solid" on:click={() => commitMessageModal.close()}>Cancel</Button>
<Button style="pop" kind="solid" grow on:click={submitCommitMessageModal}>Submit</Button>
</svelte:fragment>
</Modal>
<div
use:draggable={commit instanceof Commit
? {
@ -101,17 +128,6 @@
class="commit"
class:is-commit-open={showFiles}
>
{#if $advancedCommitOperations}
{#if isUndoable && showFiles}
<div class="commit__edit_description">
<textarea
placeholder="commit message here"
bind:value={description}
rows={commit.description.split('\n').length + 1}
></textarea>
</div>
{/if}
{/if}
<div class="commit__header" on:click={toggleFiles} on:keyup={onKeyup} role="button" tabindex="0">
<div class="commit__message">
{#if $advancedCommitOperations}
@ -154,6 +170,19 @@
</span>
{/if}
</div>
{#if showFiles}
{#if commit.descriptionBody}
<div class="commit__row" transition:slide={{ duration: 100 }}>
<span class="commit__body text-base-body-12">
{commit.descriptionBody}
</span>
</div>
{/if}
{#if $advancedCommitOperations}
<Tag clickable on:click={openCommitMessageModal}>Edit</Tag>
{/if}
{/if}
</div>
<div class="commit__row">
<div class="commit__author">
@ -326,16 +355,6 @@
gap: var(--size-8);
}
.commit__edit_description {
width: 100%;
}
.commit__edit_description textarea {
width: 100%;
padding: 10px 14px;
margin: 0;
border-bottom: 1px solid #dddddd;
}
.commit__id {
display: flex;
align-items: center;
@ -382,6 +401,7 @@
.files__footer {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--size-8);
padding: var(--size-14);
background-color: var(--clr-bg-1);

View File

@ -1,80 +1,29 @@
<script lang="ts">
import Button from './Button.svelte';
import { AIService } from '$lib/ai/service';
import Checkbox from '$lib/components/Checkbox.svelte';
import DropDownButton from '$lib/components/DropDownButton.svelte';
import Icon from '$lib/components/Icon.svelte';
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 {
projectAiGenEnabled,
projectCommitGenerationExtraConcise,
projectCommitGenerationUseEmojis,
projectRunCommitHooks,
persistedCommitMessage
} from '$lib/config/config';
import { showError } from '$lib/notifications/toasts';
import { User } from '$lib/stores/user';
import { splitMessage } from '$lib/utils/commitMessage';
import CommitMessageInput from '$lib/components/CommitMessageInput.svelte';
import { projectRunCommitHooks, persistedCommitMessage } from '$lib/config/config';
import { getContext, getContextStore } from '$lib/utils/context';
import { tooltip } from '$lib/utils/tooltip';
import { useAutoHeight } from '$lib/utils/useAutoHeight';
import { useResize } from '$lib/utils/useResize';
import { BranchController } from '$lib/vbranches/branchController';
import { Ownership } from '$lib/vbranches/ownership';
import { Branch, type LocalFile } from '$lib/vbranches/types';
import { createEventDispatcher, onMount } from 'svelte';
import { Branch } from '$lib/vbranches/types';
import { quintOut } from 'svelte/easing';
import { fly, slide } from 'svelte/transition';
import { slide } from 'svelte/transition';
import type { Writable } from 'svelte/store';
const aiService = getContext(AIService);
const dispatch = createEventDispatcher<{
action: 'generate-branch-name';
}>();
export let projectId: string;
export let expanded: Writable<boolean>;
const branchController = getContext(BranchController);
const selectedOwnership = getContextStore(Ownership);
const branch = getContextStore(Branch);
const user = getContextStore(User);
const aiGenEnabled = projectAiGenEnabled(projectId);
const runCommitHooks = projectRunCommitHooks(projectId);
const commitMessage = persistedCommitMessage(projectId, $branch.id);
const commitGenerationExtraConcise = projectCommitGenerationExtraConcise(projectId);
const commitGenerationUseEmojis = projectCommitGenerationUseEmojis(projectId);
let isCommitting = false;
let aiLoading = false;
let contextMenu: ContextMenu;
let titleTextArea: HTMLTextAreaElement;
let descriptionTextArea: HTMLTextAreaElement;
$: ({ title, description } = splitMessage($commitMessage));
$: if ($commitMessage) updateHeights();
function concatMessage(title: string, description: string) {
return `${title}\n\n${description}`;
}
function focusTextareaOnMount(el: HTMLTextAreaElement) {
el.focus();
}
function updateHeights() {
useAutoHeight(titleTextArea);
useAutoHeight(descriptionTextArea);
}
async function commit() {
const message = concatMessage(title, description);
const message = $commitMessage;
isCommitting = true;
try {
await branchController.commitBranch(
@ -88,158 +37,12 @@
isCommitting = false;
}
}
async function generateCommitMessage(files: LocalFile[]) {
const hunks = files.flatMap((f) =>
f.hunks.filter((h) => $selectedOwnership.contains(f.id, h.id))
);
// Branches get their names generated only if there are at least 4 lines of code
// If the change is a 'one-liner', the branch name is either left as "virtual branch"
// or the user has to manually trigger the name generation from the meatball menu
// This saves people this extra click
if ($branch.name.toLowerCase().includes('virtual branch')) {
dispatch('action', 'generate-branch-name');
}
aiLoading = true;
try {
const generatedMessage = await aiService.summarizeCommit({
hunks,
useEmojiStyle: $commitGenerationUseEmojis,
useBriefStyle: $commitGenerationExtraConcise,
userToken: $user?.access_token
});
if (generatedMessage) {
$commitMessage = generatedMessage;
} else {
throw new Error('Prompt generated no response');
}
} catch (e: any) {
showError('Failed to generate commit message', e);
} finally {
aiLoading = false;
}
setTimeout(() => {
updateHeights();
descriptionTextArea.focus();
}, 0);
}
let aiConfigurationValid = false;
onMount(async () => {
aiConfigurationValid = await aiService.validateConfiguration($user?.access_token);
});
</script>
<div class="commit-box" class:commit-box__expanded={$expanded}>
{#if $expanded}
<div class="commit-box__expander" transition:slide={{ duration: 150, easing: quintOut }}>
<div class="commit-box__textarea-wrapper text-input">
<textarea
value={title}
placeholder="Commit summary"
disabled={aiLoading}
class="text-base-body-13 text-semibold commit-box__textarea commit-box__textarea__title"
spellcheck="false"
rows="1"
bind:this={titleTextArea}
use:focusTextareaOnMount
use:useResize={() => {
useAutoHeight(titleTextArea);
}}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:input={(e) => {
$commitMessage = concatMessage(e.currentTarget.value, description);
}}
on:keydown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') commit();
if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
descriptionTextArea.focus();
}
}}
/>
{#if title.length > 0 || description}
<textarea
value={description}
disabled={aiLoading}
placeholder="Commit description (optional)"
class="text-base-body-13 commit-box__textarea commit-box__textarea__description"
spellcheck="false"
rows="1"
bind:this={descriptionTextArea}
use:useResize={() => useAutoHeight(descriptionTextArea)}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:input={(e) => {
$commitMessage = concatMessage(title, e.currentTarget.value);
}}
on:keydown={(e) => {
const value = e.currentTarget.value;
if (e.key == 'Backspace' && value.length == 0) {
e.preventDefault();
titleTextArea.focus();
useAutoHeight(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();
titleTextArea.select();
}
}}
/>
{/if}
{#if title.length > 50}
<div
transition:fly={{ y: 2, duration: 150 }}
class="commit-box__textarea-tooltip"
use:tooltip={{
text: '50 characters or less is best. Extra info can be added in the description.',
delay: 200
}}
>
<Icon name="blitz" />
</div>
{/if}
<div
class="commit-box__texarea-actions"
use:tooltip={$aiGenEnabled && aiConfigurationValid
? ''
: 'You must be logged in or have provided your own API key and have summary generation enabled to use this feature'}
>
<DropDownButton
style="ghost"
kind="solid"
icon="ai-small"
disabled={!($aiGenEnabled && aiConfigurationValid)}
loading={aiLoading}
on:click={async () => await generateCommitMessage($branch.files)}
>
Generate message
<ContextMenu type="checklist" slot="context-menu" bind:this={contextMenu}>
<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>
</DropDownButton>
</div>
</div>
<CommitMessageInput bind:commitMessage={$commitMessage} {commit} />
</div>
{/if}
<div class="actions">
@ -260,7 +63,7 @@
kind="solid"
grow
loading={isCommitting}
disabled={(isCommitting || !title || $selectedOwnership.isEmpty()) && $expanded}
disabled={(isCommitting || !$commitMessage || $selectedOwnership.isEmpty()) && $expanded}
id="commit-to-branch"
on:click={() => {
if ($expanded) {
@ -292,57 +95,6 @@
margin-bottom: var(--size-12);
}
.commit-box__textarea-wrapper {
display: flex;
position: relative;
padding: 0 0 var(--size-48);
flex-direction: column;
gap: var(--size-4);
}
.commit-box__textarea {
overflow: hidden;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--size-16);
background: none;
resize: none;
&:focus {
outline: none;
}
&::placeholder {
color: oklch(from var(--clr-scale-ntrl-30) l c h / 0.4);
}
}
.commit-box__textarea-tooltip {
position: absolute;
display: flex;
bottom: var(--size-12);
left: var(--size-12);
padding: var(--size-2);
border-radius: 100%;
background: var(--clr-bg-2);
color: var(--clr-scale-ntrl-40);
}
.commit-box__textarea__title {
padding: var(--size-12) var(--size-12) 0 var(--size-12);
}
.commit-box__textarea__description {
padding: 0 var(--size-12) 0 var(--size-12);
}
.commit-box__texarea-actions {
position: absolute;
display: flex;
right: var(--size-12);
bottom: var(--size-12);
}
.actions {
display: flex;
justify-content: right;

View File

@ -0,0 +1,266 @@
<script lang="ts">
import { AIService } from '$lib/ai/service';
import { Project } from '$lib/backend/projects';
import Checkbox from '$lib/components/Checkbox.svelte';
import DropDownButton from '$lib/components/DropDownButton.svelte';
import Icon from '$lib/components/Icon.svelte';
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 {
projectAiGenEnabled,
projectCommitGenerationExtraConcise,
projectCommitGenerationUseEmojis
} from '$lib/config/config';
import { showError } from '$lib/notifications/toasts';
import { User } from '$lib/stores/user';
import { splitMessage } from '$lib/utils/commitMessage';
import { getContext, getContextStore } from '$lib/utils/context';
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';
import { fly } from 'svelte/transition';
export let commitMessage: string;
export let commit: (() => void) | undefined = undefined;
const user = getContextStore(User);
const selectedOwnership = getContextStore(Ownership);
const aiService = getContext(AIService);
const branch = getContextStore(Branch);
const project = getContext(Project);
const dispatch = createEventDispatcher<{
action: 'generate-branch-name';
}>();
const aiGenEnabled = projectAiGenEnabled(project.id);
const commitGenerationExtraConcise = projectCommitGenerationExtraConcise(project.id);
const commitGenerationUseEmojis = projectCommitGenerationUseEmojis(project.id);
let aiLoading = false;
let aiConfigurationValid = false;
let contextMenu: ContextMenu;
let titleTextArea: HTMLTextAreaElement;
let descriptionTextArea: HTMLTextAreaElement;
$: ({ title, description } = splitMessage(commitMessage));
$: if (commitMessage) updateHeights();
function concatMessage(title: string, description: string) {
return `${title}\n\n${description}`;
}
function updateHeights() {
useAutoHeight(titleTextArea);
useAutoHeight(descriptionTextArea);
}
function focusTextareaOnMount(el: HTMLTextAreaElement) {
el.focus();
}
async function generateCommitMessage(files: LocalFile[]) {
const hunks = files.flatMap((f) =>
f.hunks.filter((h) => $selectedOwnership.contains(f.id, h.id))
);
// Branches get their names generated only if there are at least 4 lines of code
// If the change is a 'one-liner', the branch name is either left as "virtual branch"
// or the user has to manually trigger the name generation from the meatball menu
// This saves people this extra click
if ($branch.name.toLowerCase().includes('virtual branch')) {
dispatch('action', 'generate-branch-name');
}
aiLoading = true;
try {
const generatedMessage = await aiService.summarizeCommit({
hunks,
useEmojiStyle: $commitGenerationUseEmojis,
useBriefStyle: $commitGenerationExtraConcise,
userToken: $user?.access_token
});
if (generatedMessage) {
commitMessage = generatedMessage;
} else {
throw new Error('Prompt generated no response');
}
} catch (e: any) {
showError('Failed to generate commit message', e);
} finally {
aiLoading = false;
}
setTimeout(() => {
updateHeights();
descriptionTextArea.focus();
}, 0);
}
onMount(async () => {
aiConfigurationValid = await aiService.validateConfiguration($user?.access_token);
});
</script>
<div class="commit-box__textarea-wrapper text-input">
<textarea
value={title}
placeholder="Commit summary"
disabled={aiLoading}
class="text-base-body-13 text-semibold commit-box__textarea commit-box__textarea__title"
spellcheck="false"
rows="1"
bind:this={titleTextArea}
use:focusTextareaOnMount
use:useResize={() => {
useAutoHeight(titleTextArea);
}}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:input={(e) => {
commitMessage = concatMessage(e.currentTarget.value, description);
}}
on:keydown={(e) => {
if (commit && (e.ctrlKey || e.metaKey) && e.key === 'Enter') commit();
if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
descriptionTextArea.focus();
}
}}
/>
{#if title.length > 0 || description}
<textarea
value={description}
disabled={aiLoading}
placeholder="Commit description (optional)"
class="text-base-body-13 commit-box__textarea commit-box__textarea__description"
spellcheck="false"
rows="1"
bind:this={descriptionTextArea}
use:useResize={() => useAutoHeight(descriptionTextArea)}
on:focus={(e) => useAutoHeight(e.currentTarget)}
on:input={(e) => {
commitMessage = concatMessage(title, e.currentTarget.value);
}}
on:keydown={(e) => {
const value = e.currentTarget.value;
if (e.key == 'Backspace' && value.length == 0) {
e.preventDefault();
titleTextArea.focus();
useAutoHeight(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();
titleTextArea.select();
}
}}
/>
{/if}
{#if title.length > 50}
<div
transition:fly={{ y: 2, duration: 150 }}
class="commit-box__textarea-tooltip"
use:tooltip={{
text: '50 characters or less is best. Extra info can be added in the description.',
delay: 200
}}
>
<Icon name="blitz" />
</div>
{/if}
<div
class="commit-box__texarea-actions"
use:tooltip={$aiGenEnabled && aiConfigurationValid
? ''
: 'You must be logged in or have provided your own API key and have summary generation enabled to use this feature'}
>
<DropDownButton
style="ghost"
kind="solid"
icon="ai-small"
disabled={!($aiGenEnabled && aiConfigurationValid)}
loading={aiLoading}
on:click={async () => await generateCommitMessage($branch.files)}
>
Generate message
<ContextMenu type="checklist" slot="context-menu" bind:this={contextMenu}>
<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>
</DropDownButton>
</div>
</div>
<style lang="postcss">
.commit-box__textarea-wrapper {
display: flex;
position: relative;
padding: 0 0 var(--size-48);
flex-direction: column;
gap: var(--size-4);
}
.commit-box__textarea {
overflow: hidden;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--size-16);
background: none;
resize: none;
&:focus {
outline: none;
}
&::placeholder {
color: oklch(from var(--clr-scale-ntrl-30) l c h / 0.4);
}
}
.commit-box__textarea-tooltip {
position: absolute;
display: flex;
bottom: var(--size-12);
left: var(--size-12);
padding: var(--size-2);
border-radius: 100%;
background: var(--clr-bg-2);
color: var(--clr-scale-ntrl-40);
}
.commit-box__textarea__title {
padding: var(--size-12) var(--size-12) 0 var(--size-12);
}
.commit-box__textarea__description {
padding: 0 var(--size-12) 0 var(--size-12);
}
.commit-box__texarea-actions {
position: absolute;
display: flex;
right: var(--size-12);
bottom: var(--size-12);
}
</style>