Merge pull request #5220 from gitbutlerapp/pr-modal-template-selector

PR details modal: Allow user to choose the PR template
This commit is contained in:
Esteban Vega 2024-10-21 12:13:47 +02:00 committed by GitHub
commit a55e2fcee3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 454 additions and 359 deletions

View File

@ -8,12 +8,12 @@
import { projectAiGenEnabled } from '$lib/config/config';
import { stackingFeature } from '$lib/config/uiFeatureFlags';
import TextBox from '$lib/shared/TextBox.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import { BranchController } from '$lib/vbranches/branchController';
import { VirtualBranch } from '$lib/vbranches/types';
import { getContext, getContextStore } from '@gitbutler/shared/context';
import Button from '@gitbutler/ui/Button.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
import Toggle from '@gitbutler/ui/Toggle.svelte';
import Tooltip from '@gitbutler/ui/Tooltip.svelte';
interface Props {
@ -157,7 +157,7 @@
<ContextMenuItem label="Allow rebasing" onclick={toggleAllowRebasing}>
{#snippet control()}
<Tooltip text={'Allows changing commits after push\n(force push needed)'}>
<Toggle small bind:checked={allowRebasing} on:click={toggleAllowRebasing} />
<Toggle small bind:checked={allowRebasing} onclick={toggleAllowRebasing} />
</Tooltip>
{/snippet}
</ContextMenuItem>

View File

@ -2,9 +2,9 @@
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 Toggle from '$lib/shared/Toggle.svelte';
import Button from '@gitbutler/ui/Button.svelte';
import Checkbox from '@gitbutler/ui/Checkbox.svelte';
import Toggle from '@gitbutler/ui/Toggle.svelte';
import type { Writable, Readable } from 'svelte/store';
export let filtersActive: Readable<boolean>;

View File

@ -8,12 +8,12 @@
import Select from '$lib/select/Select.svelte';
import SelectItem from '$lib/select/SelectItem.svelte';
import GithubIntegration from '$lib/settings/GithubIntegration.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import { UserService } from '$lib/stores/user';
import { unique } from '$lib/utils/array';
import { getBestBranch, getBestRemote, getBranchRemoteFromRef } from '$lib/utils/branch';
import { getContext } from '@gitbutler/shared/context';
import Button from '@gitbutler/ui/Button.svelte';
import Toggle from '@gitbutler/ui/Toggle.svelte';
import { createEventDispatcher } from 'svelte';
import type { RemoteBranchInfo } from '$lib/baseBranch/baseBranchService';
import { goto } from '$app/navigation';
@ -178,7 +178,7 @@
bind:this={aiGenCheckbox}
checked={$aiGenEnabled}
id="aiGenEnabled"
on:change={() => {
onclick={() => {
$aiGenEnabled = !$aiGenEnabled;
}}
/>

View File

@ -7,13 +7,16 @@
</script>
<script lang="ts">
import PrDetailsModalHeader from './PrDetailsModalHeader.svelte';
import PrTemplateSection from './PrTemplateSection.svelte';
import { getPreferredPRAction, PRAction } from './pr';
import { AIService } from '$lib/ai/service';
import { ForgeService } from '$lib/backend/forge';
import { Project } from '$lib/backend/projects';
import { BaseBranch } from '$lib/baseBranch/baseBranch';
import { BaseBranchService } from '$lib/baseBranch/baseBranchService';
import Markdown from '$lib/components/Markdown.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import { projectAiGenEnabled } from '$lib/config/config';
import { mapErrorToToast } from '$lib/gitHost/github/errorMap';
import { getGitHost } from '$lib/gitHost/interface/gitHost';
@ -22,8 +25,8 @@
import { showError, showToast } from '$lib/notifications/toasts';
import { isFailure } from '$lib/result';
import ScrollableContainer from '$lib/scroll/ScrollableContainer.svelte';
import DropDownButton from '$lib/shared/DropDownButton.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import { getBranchNameFromRef } from '$lib/utils/branch';
import { KeyName, onMetaEnter } from '$lib/utils/hotkeys';
import { sleep } from '$lib/utils/sleep';
@ -35,8 +38,7 @@
import BorderlessTextarea from '@gitbutler/ui/BorderlessTextarea.svelte';
import Button from '@gitbutler/ui/Button.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
import Segment from '@gitbutler/ui/segmentControl/Segment.svelte';
import SegmentControl from '@gitbutler/ui/segmentControl/SegmentControl.svelte';
import ToggleButton from '@gitbutler/ui/ToggleButton.svelte';
import { tick } from 'svelte';
import type { DetailedPullRequest, PullRequest } from '$lib/gitHost/interface/types';
@ -69,7 +71,6 @@
const branchStore = getContextStore(VirtualBranch);
const branchController = getContext(BranchController);
const baseBranchService = getContext(BaseBranchService);
const forgeService = getContext(ForgeService);
const gitListService = getGitHostListingService();
const prService = getGitHostPrService();
const aiService = getContext(AIService);
@ -84,10 +85,12 @@
props.type === 'preview-series' ? props.upstreamName : branch.upstreamName
);
const baseBranchName = $derived($baseBranch.shortName);
const prTemplatePath = $derived(project.git_host.reviewTemplatePath);
let createPrDropDown = $state<ReturnType<typeof DropDownButton>>();
let isDraft = $state<boolean>($preferredPRAction === PRAction.CreateDraft);
let modal = $state<ReturnType<typeof Modal>>();
let templateSelector = $state<ReturnType<typeof PrTemplateSection>>();
let isEditing = $state<boolean>(true);
let isLoading = $state<boolean>(false);
let pullRequestTemplateBody = $state<string | undefined>(undefined);
@ -96,6 +99,12 @@
let aiDescriptionDirective = $state<string | undefined>(undefined);
let showAiBox = $state<boolean>(false);
async function handleToggleUseTemplate() {
if (!templateSelector) return;
const displaying = templateSelector.imports.showing;
await templateSelector.setUsePullRequestTemplate(!displaying);
}
const canUseAI = $derived.by(() => {
return aiConfigurationValid || $aiGenEnabled;
});
@ -127,15 +136,6 @@
const actualBody = $derived<string>(inputBody ?? defaultBody);
const actualTitle = $derived<string>(inputTitle ?? defaultTitle);
// Fetch PR template content
$effect(() => {
if (modal?.imports.open && pullRequestTemplateBody === undefined && prTemplatePath) {
forgeService.getReviewTemplateContent(prTemplatePath).then((template) => {
pullRequestTemplateBody = template;
});
}
});
$effect(() => {
if (modal?.imports.open) {
aiService.validateConfiguration().then((valid) => {
@ -216,10 +216,6 @@
close();
}
function handleCheckDraft() {
isDraft = !isDraft;
}
async function handleAIButtonPressed() {
if (props.type === 'display') return;
if (!aiGenEnabled) return;
@ -316,59 +312,71 @@
}
};
const isPreviewOnly = props.type === 'display';
const isDisplay = props.type === 'display';
</script>
<Modal bind:this={modal} width={580} noPadding {onClose} onKeyDown={handleModalKeydown}>
<div class="pr-header">
{#if !isPreviewOnly}
<h3 class="text-14 text-semibold pr-title">
{!isEditing ? actualTitle : 'Create a pull request'}
</h3>
<SegmentControl
defaultIndex={isPreviewOnly ? 1 : 0}
onselect={(id) => {
if (id === 'write') {
isEditing = true;
} else {
isEditing = false;
}
}}
>
<Segment id="write">Edit</Segment>
<Segment id="preview">Preview</Segment>
</SegmentControl>
{:else}
<h3 class="text-14 text-semibold pr-title">{actualTitle}</h3>
{/if}
</div>
<!-- HEADER -->
{#if !isDisplay}
<PrDetailsModalHeader {isDisplay} bind:isEditing />
{/if}
<!-- MAIN FIELDS -->
<ScrollableContainer wide maxHeight="66vh" onscroll={showBorderOnScroll}>
<div class="pr-content">
{#if isPreviewOnly || !isEditing}
{#if isDisplay || !isEditing}
<div class="pr-preview" class:display={isDisplay} class:preview={!isDisplay}>
<h1 class="text-head-22 pr-preview-title">
{actualTitle}
</h1>
{#if actualBody}
<div class="pr-description-preview">
<Markdown content={actualBody} />
</div>
{/if}
</div>
{:else}
<div class="pr-fields">
<TextBox
placeholder="PR title"
value={actualTitle}
readonly={!isEditing || isPreviewOnly}
readonly={!isEditing || isDisplay}
on:input={(e) => {
inputTitle = e.detail;
}}
/>
<!-- FEATURES -->
<div class="features-section">
<ToggleButton
icon="doc"
label="Use PR template"
checked={!!templateSelector?.imports.showing}
onclick={handleToggleUseTemplate}
disabled={!templateSelector?.imports.hasTemplates}
/>
<ToggleButton
icon="ai-small"
label="AI generation"
checked={showAiBox}
tooltip={!aiConfigurationValid ? 'AI service is not configured' : undefined}
disabled={!canUseAI || aiIsLoading}
onclick={() => {
showAiBox = !showAiBox;
}}
/>
</div>
<!-- PR TEMPLATE SELECT -->
<PrTemplateSection bind:this={templateSelector} bind:pullRequestTemplateBody />
<!-- DESCRIPTION FIELD -->
<div class="pr-description-field text-input">
<BorderlessTextarea
value={actualBody}
rows={2}
autofocus
padding={{ top: 12, right: 12, bottom: 0, left: 12 }}
padding={{ top: 12, right: 12, bottom: 12, left: 12 }}
placeholder="Add description…"
oninput={(e: InputEvent) => {
const target = e.target as HTMLTextAreaElement;
@ -377,7 +385,6 @@
/>
<!-- AI GENRATION -->
{#if !isPreviewOnly && canUseAI && isEditing}
<div class="pr-ai" class:show-ai-box={showAiBox}>
{#if showAiBox}
<BorderlessTextarea
@ -392,14 +399,6 @@
}}
/>
<div class="pr-ai__actions">
<Button
style="ghost"
outline
onclick={() => {
showAiBox = false;
aiDescriptionDirective = undefined;
}}>Hide</Button
>
<Button
style="neutral"
kind="solid"
@ -410,35 +409,14 @@
? 'You must have summary generation enabled'
: undefined}
disabled={!canUseAI || aiIsLoading}
isLoading={aiIsLoading}
loading={aiIsLoading}
onclick={handleAIButtonPressed}
>
Generate
</Button>
</div>
{:else}
<div class="pr-ai__actions">
<Button
style="ghost"
outline
icon="ai-small"
tooltip={!aiConfigurationValid
? 'You must be logged in or have provided your own API key'
: !$aiGenEnabled
? 'You must have summary generation enabled'
: undefined}
disabled={!canUseAI || aiIsLoading}
isLoading={aiIsLoading}
onclick={() => {
showAiBox = true;
}}
>
Generate description
</Button>
</div>
{/if}
</div>
{/if}
</div>
</div>
{/if}
@ -448,25 +426,39 @@
<!-- FOOTER -->
{#snippet controls(close)}
<div class="pr-footer">
{#if props.type !== 'display'}
<label class="draft-toggle__wrap">
<Toggle id="is-draft-toggle" small checked={isDraft} on:click={handleCheckDraft} />
<label class="text-12 draft-toggle__label" for="is-draft-toggle">Create as a draft</label>
</label>
<div class="pr-footer__actions">
<Button style="ghost" outline onclick={close}>Cancel</Button>
<Button
<DropDownButton
bind:this={createPrDropDown}
style="pop"
kind="solid"
disabled={isLoading || aiIsLoading}
{isLoading}
disabled={isLoading || aiIsLoading || !actualTitle}
loading={isLoading}
type="submit"
onclick={async () => await handleCreatePR(close)}
>{isDraft ? 'Create draft pull request' : 'Create pull request'}</Button
>
</div>
{isDraft ? 'Create pull request draft' : 'Create pull request'}
{#snippet contextMenuSlot()}
<ContextMenuSection>
<ContextMenuItem
label="Create pull request"
onclick={() => {
isDraft = false;
createPrDropDown?.close();
}}
/>
<ContextMenuItem
label="Create draft pull request"
onclick={() => {
isDraft = true;
createPrDropDown?.close();
}}
/>
</ContextMenuSection>
{/snippet}
</DropDownButton>
{:else}
<div class="pr-footer__actions">
<Button
@ -489,7 +481,6 @@
</div>
<Button style="ghost" outline onclick={close}>Close</Button>
{/if}
</div>
{/snippet}
</Modal>
@ -500,19 +491,12 @@
padding: 0 16px 16px;
}
.pr-header {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 16px 14px;
}
/* FIELDS */
.pr-fields {
display: flex;
flex-direction: column;
gap: 10px;
gap: 14px;
}
.pr-description-field {
@ -525,11 +509,6 @@
/* PREVIEW */
.pr-title {
flex: 1;
margin-top: 4px;
}
.pr-description-preview {
overflow-y: auto;
display: flex;
@ -543,37 +522,44 @@
}
.show-ai-box {
margin-top: 12px;
border-top: 1px solid var(--clr-border-3);
}
.pr-ai__actions {
width: 100%;
display: flex;
justify-content: flex-end;
gap: 6px;
padding: 12px;
}
/* FOOTER */
.pr-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.pr-footer__actions {
width: 100%;
display: flex;
gap: 6px;
}
.draft-toggle__wrap {
.features-section {
display: flex;
align-items: center;
gap: 10px;
gap: 6px;
}
.draft-toggle__label {
color: var(--clr-text-2);
/* PREVIEW */
.pr-preview {
display: flex;
flex-direction: column;
gap: 16px;
&.display {
padding-top: 16px;
}
&.preview {
padding: 16px;
background-color: var(--clr-bg-1-muted);
border-radius: var(--radius-m);
}
}
</style>

View File

@ -0,0 +1,48 @@
<script lang="ts">
import Segment from '@gitbutler/ui/segmentControl/Segment.svelte';
import SegmentControl from '@gitbutler/ui/segmentControl/SegmentControl.svelte';
interface Props {
isDisplay: boolean;
isEditing: boolean;
}
let { isDisplay, isEditing = $bindable() }: Props = $props();
</script>
<!-- MAIN -->
<div class="pr-header">
<div class="pr-header__row">
<h3 class="text-14 text-body text-semibold pr-title">Create a pull request</h3>
<SegmentControl
defaultIndex={isDisplay ? 1 : 0}
onselect={(id) => {
isEditing = id === 'write';
}}
>
<Segment id="write">Edit</Segment>
<Segment id="preview">Preview</Segment>
</SegmentControl>
</div>
</div>
<style>
.pr-header {
display: flex;
flex-direction: column;
justify-content: center;
gap: 16px;
padding: 16px 16px 14px;
}
.pr-header__row {
width: 100%;
display: flex;
align-items: center;
}
.pr-title {
flex: 1;
}
</style>

View File

@ -0,0 +1,110 @@
<script lang="ts">
import { ForgeService } from '$lib/backend/forge';
import { ProjectService, ProjectsService } from '$lib/backend/projects';
import Select from '$lib/select/Select.svelte';
import SelectItem from '$lib/select/SelectItem.svelte';
import { getContext } from '@gitbutler/shared/context';
interface Props {
pullRequestTemplateBody: string | undefined;
}
let { pullRequestTemplateBody = $bindable() }: Props = $props();
const projectsService = getContext(ProjectsService);
const projectService = getContext(ProjectService);
const forgeService = getContext(ForgeService);
let allAvailableTemplates = $state<{ label: string; value: string }[]>([]);
const projectStore = projectService.project;
const project = $derived($projectStore);
const reviewTemplatePath = $derived(project?.git_host.reviewTemplatePath);
const show = $derived(!!reviewTemplatePath);
// Fetch PR template content
$effect(() => {
if (!project) return;
if (reviewTemplatePath) {
forgeService.getReviewTemplateContent(reviewTemplatePath).then((template) => {
pullRequestTemplateBody = template;
});
}
});
// Fetch available PR templates
$effect(() => {
if (!project) return;
forgeService.getAvailableReviewTemplates().then((availableTemplates) => {
if (availableTemplates) {
allAvailableTemplates = availableTemplates.map((availableTemplate) => {
return {
label: availableTemplate,
value: availableTemplate
};
});
}
});
});
async function setPullRequestTemplatePath(value: string) {
if (!project) return;
project.git_host.reviewTemplatePath = value;
await projectsService.updateProject(project);
}
export async function setUsePullRequestTemplate(value: boolean) {
if (!project) return;
setTemplate: {
if (!value) {
project.git_host.reviewTemplatePath = undefined;
pullRequestTemplateBody = undefined;
break setTemplate;
}
if (allAvailableTemplates[0]) {
project.git_host.reviewTemplatePath = allAvailableTemplates[0].value;
break setTemplate;
}
}
await projectsService.updateProject(project);
}
export const imports = {
get showing() {
return show;
},
get hasTemplates() {
return allAvailableTemplates.length > 0;
}
};
</script>
{#if show}
<div class="pr-template__wrap">
<Select
value={reviewTemplatePath}
options={allAvailableTemplates.map(({ label, value }) => ({ label, value }))}
placeholder="No PR templates found ¯\_(ツ)_/¯"
flex="1"
searchable
disabled={allAvailableTemplates.length <= 1}
onselect={setPullRequestTemplatePath}
>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === reviewTemplatePath} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
</div>
{/if}
<style lang="postcss">
.pr-template__wrap {
display: flex;
gap: 6px;
}
</style>

View File

@ -22,6 +22,7 @@
disabled?: boolean;
loading?: boolean;
wide?: boolean;
flex?: string;
options: SelectItem<T>[];
value?: T;
placeholder?: string;
@ -38,6 +39,7 @@
disabled,
loading,
wide,
flex,
options = [],
value,
placeholder = 'Select an option...',
@ -150,7 +152,7 @@
}
</script>
<div class="select-wrapper" class:wide bind:this={selectWrapperEl}>
<div class="select-wrapper" class:wide bind:this={selectWrapperEl} style:flex>
{#if label}
<label for={id} class="select__label text-13 text-body text-semibold">{label}</label>
{/if}

View File

@ -6,7 +6,7 @@
appNonAnonMetricsEnabled
} from '$lib/config/appSettings';
import Link from '$lib/shared/Link.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import Toggle from '@gitbutler/ui/Toggle.svelte';
const errorReportingEnabled = appErrorReportingEnabled();
const metricsEnabled = appMetricsEnabled();
@ -46,7 +46,7 @@
<Toggle
id="errorReportingToggle"
checked={$errorReportingEnabled}
on:click={() => ($errorReportingEnabled = !$errorReportingEnabled)}
onclick={() => ($errorReportingEnabled = !$errorReportingEnabled)}
/>
</svelte:fragment>
</SectionCard>
@ -58,7 +58,7 @@
<Toggle
id="metricsEnabledToggle"
checked={$metricsEnabled}
on:click={() => ($metricsEnabled = !$metricsEnabled)}
onclick={() => ($metricsEnabled = !$metricsEnabled)}
/>
</svelte:fragment>
</SectionCard>
@ -72,7 +72,7 @@
<Toggle
id="nonAnonMetricsEnabledToggle"
checked={$nonAnonMetricsEnabled}
on:click={() => ($nonAnonMetricsEnabled = !$nonAnonMetricsEnabled)}
onclick={() => ($nonAnonMetricsEnabled = !$nonAnonMetricsEnabled)}
/>
</svelte:fragment>
</SectionCard>

View File

@ -6,10 +6,10 @@
import { projectAiGenEnabled } from '$lib/config/config';
import Section from '$lib/settings/Section.svelte';
import Spacer from '$lib/shared/Spacer.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import { UserService } from '$lib/stores/user';
import { getContext } from '@gitbutler/shared/context';
import Button from '@gitbutler/ui/Button.svelte';
import Toggle from '@gitbutler/ui/Toggle.svelte';
import { goto } from '$app/navigation';
const userService = getContext(UserService);
@ -44,7 +44,7 @@
<Toggle
id="aiGenEnabled"
checked={$aiGenEnabled}
on:click={() => {
onclick={() => {
$aiGenEnabled = !$aiGenEnabled;
}}
/>

View File

@ -9,9 +9,9 @@
import InfoMessage from '$lib/shared/InfoMessage.svelte';
import Link from '$lib/shared/Link.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import { getContext } from '@gitbutler/shared/context';
import Button from '@gitbutler/ui/Button.svelte';
import Toggle from '@gitbutler/ui/Toggle.svelte';
import { invoke } from '@tauri-apps/api/tauri';
import { onMount } from 'svelte';
@ -110,7 +110,7 @@
GitButler will sign commits as per your git configuration.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle id="signCommits" checked={signCommits} on:click={handleSignCommitsClick} />
<Toggle id="signCommits" checked={signCommits} onclick={handleSignCommitsClick} />
</svelte:fragment>
</SectionCard>
{#if signCommits}

View File

@ -6,10 +6,10 @@
import Spacer from '$lib/shared/Spacer.svelte';
import TextArea from '$lib/shared/TextArea.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import { User } from '$lib/stores/user';
import * as toasts from '$lib/utils/toasts';
import { getContext, getContextStore } from '@gitbutler/shared/context';
import Toggle from '@gitbutler/ui/Toggle.svelte';
import { onMount } from 'svelte';
import { PUBLIC_API_BASE_URL } from '$env/static/public';
@ -112,7 +112,7 @@
<Toggle
id="historySync"
checked={project.api?.sync || false}
on:click={async () => await onSyncChange(!project.api?.sync)}
onclick={async () => await onSyncChange(!project.api?.sync)}
/>
</svelte:fragment>
</SectionCard>
@ -124,7 +124,7 @@
<Toggle
id="branchesySync"
checked={project.api?.sync_code || false}
on:click={async () => await onSyncCodeChange(!project.api?.sync_code)}
onclick={async () => await onSyncCodeChange(!project.api?.sync_code)}
/>
</svelte:fragment>
</SectionCard>

View File

@ -4,8 +4,8 @@
import { projectRunCommitHooks } from '$lib/config/config';
import Section from '$lib/settings/Section.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import { getContext } from '@gitbutler/shared/context';
import Toggle from '@gitbutler/ui/Toggle.svelte';
const projectsService = getContext(ProjectsService);
const project = getContext(Project);
@ -56,11 +56,7 @@
GitButler will never force push to the target branch.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle
id="allowForcePush"
checked={allowForcePushing}
on:click={handleAllowForcePushClick}
/>
<Toggle id="allowForcePush" checked={allowForcePushing} onclick={handleAllowForcePushClick} />
</svelte:fragment>
</SectionCard>
@ -73,7 +69,7 @@
<Toggle
id="omitCertificateCheck"
checked={omitCertificateCheck}
on:click={handleOmitCertificateCheckClick}
onclick={handleOmitCertificateCheckClick}
/>
</svelte:fragment>
</SectionCard>

View File

@ -1,133 +0,0 @@
<script lang="ts">
import notFoundSvg from '$lib/assets/empty-state/not-found.svg?raw';
import { ForgeService } from '$lib/backend/forge';
import { ProjectService, ProjectsService } 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 Link from '$lib/shared/Link.svelte';
import Spacer from '$lib/shared/Spacer.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import { getContext } from '@gitbutler/shared/context';
import EmptyStatePlaceholder from '@gitbutler/ui/EmptyStatePlaceholder.svelte';
const projectsService = getContext(ProjectsService);
const projectService = getContext(ProjectService);
const forgeService = getContext(ForgeService);
const projectStore = projectService.project;
const project = $derived($projectStore);
const useTemplate = $derived(!!project?.git_host.reviewTemplatePath);
const selectedTemplate = $derived(project?.git_host.reviewTemplatePath);
let allAvailableTemplates = $state<{ label: string; value: string }[]>([]);
let isTemplatesAvailable = $state(false);
$effect(() => {
if (!project) {
return;
}
forgeService.getAvailableReviewTemplates().then((availableTemplates) => {
if (availableTemplates) {
allAvailableTemplates = availableTemplates.map((availableTemplate) => {
return {
label: availableTemplate,
value: availableTemplate
};
});
isTemplatesAvailable = allAvailableTemplates.length > 0;
}
});
});
async function setUsePullRequestTemplate(value: boolean) {
if (!project) return;
setTemplate: {
if (!value) {
project.git_host.reviewTemplatePath = undefined;
break setTemplate;
}
if (allAvailableTemplates[0]) {
project.git_host.reviewTemplatePath = allAvailableTemplates[0].value;
break setTemplate;
}
}
await projectsService.updateProject(project);
}
async function setPullRequestTemplatePath(value: string) {
if (!project) return;
project.git_host.reviewTemplatePath = value;
await projectsService.updateProject(project);
}
</script>
<Section>
<div class="stack-v">
<SectionCard
roundedBottom={!useTemplate}
orientation="row"
labelFor="use-pull-request-template-boolean"
>
<svelte:fragment slot="title">Pull request templates</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle
id="use-pull-request-template-boolean"
checked={useTemplate}
disabled={!isTemplatesAvailable}
on:click={(e) => {
setUsePullRequestTemplate(
(e.target as MouseEvent['target'] & { checked: boolean }).checked
);
}}
/>
</svelte:fragment>
<svelte:fragment slot="caption">
If enabled, we will use the path below to set the initial body of any pull requested created
on this project through GitButler.
</svelte:fragment>
</SectionCard>
{#if useTemplate}
<SectionCard roundedTop={false} orientation="row">
{#if isTemplatesAvailable}
<Select
value={selectedTemplate}
options={allAvailableTemplates.map(({ label, value }) => ({ label, value }))}
label="Available Templates"
wide={true}
searchable
disabled={allAvailableTemplates.length === 0}
onselect={(value) => {
setPullRequestTemplatePath(value);
}}
>
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={item.value === selectedTemplate} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
{:else}
<EmptyStatePlaceholder image={notFoundSvg} topBottomPadding={20}>
{#snippet caption()}
No templates found in the project
<span class="text-11">
<Link
href="https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/creating-a-pull-request-template-for-your-repository"
>How to create a template</Link
></span
>
{/snippet}
</EmptyStatePlaceholder>
{/if}
</SectionCard>
{/if}
</div>
</Section>
<Spacer />

View File

@ -15,6 +15,7 @@
loading?: boolean;
wide?: boolean;
tooltip?: string;
type?: 'button' | 'submit' | 'reset';
menuPosition?: 'top' | 'bottom';
children: Snippet;
contextMenuSlot: Snippet;
@ -29,6 +30,7 @@
disabled = false,
loading = false,
wide = false,
type,
tooltip,
menuPosition = 'bottom',
children,
@ -58,6 +60,7 @@
{style}
{icon}
{kind}
{type}
{outline}
reversedDirection
disabled={disabled || loading}

View File

@ -13,7 +13,6 @@
import CommitSigningForm from '$lib/settings/userPreferences/CommitSigningForm.svelte';
import DetailsForm from '$lib/settings/userPreferences/DetailsForm.svelte';
import PreferencesForm from '$lib/settings/userPreferences/PreferencesForm.svelte';
import PullRequestTemplateForm from '$lib/settings/userPreferences/PullRequestTemplateForm.svelte';
import RemoveProjectForm from '$lib/settings/userPreferences/RemoveProjectForm.svelte';
import Spacer from '$lib/shared/Spacer.svelte';
@ -47,8 +46,6 @@
{#if $baseBranchSwitching}
<BaseBranchSwitch />
{/if}
<PullRequestTemplateForm />
<RemoveProjectForm />
</Section>
</TabContent>

View File

@ -14,9 +14,9 @@
} from '$lib/settings/userSettings';
import RadioButton from '$lib/shared/RadioButton.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import { type Hunk } from '$lib/vbranches/types';
import { getContextStoreBySymbol } from '@gitbutler/shared/context';
import Toggle from '@gitbutler/ui/Toggle.svelte';
import type { ContentSection } from '$lib/utils/fileSections';
import type { Writable } from 'svelte/store';
@ -153,7 +153,7 @@
<Toggle
id="allowDiffLigatures"
checked={$userSettings.diffLigatures}
on:click={() => {
onclick={() => {
userSettings.update((s) => ({
...s,
diffLigatures: !$userSettings.diffLigatures
@ -199,7 +199,7 @@
<Toggle
id="wrapText"
checked={$userSettings.wrapText}
on:click={() => {
onclick={() => {
userSettings.update((s) => ({
...s,
wrapText: !s.wrapText
@ -219,7 +219,7 @@
<Toggle
id="inlineUnifiedDiffs"
checked={$userSettings.inlineUnifiedDiffs}
on:click={() => {
onclick={() => {
userSettings.update((s) => ({
...s,
inlineUnifiedDiffs: !s.inlineUnifiedDiffs
@ -288,7 +288,7 @@
<Toggle
id="branchLaneContents"
checked={$autoSelectBranchNameFeature}
on:click={() => ($autoSelectBranchNameFeature = !$autoSelectBranchNameFeature)}
onclick={() => ($autoSelectBranchNameFeature = !$autoSelectBranchNameFeature)}
/>
</svelte:fragment>
</SectionCard>

View File

@ -7,7 +7,7 @@
featureTopics
} from '$lib/config/uiFeatureFlags';
import SettingsPage from '$lib/layout/SettingsPage.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import Toggle from '@gitbutler/ui/Toggle.svelte';
const baseBranchSwitching = featureBaseBranchSwitching();
const topicsEnabled = featureTopics();
@ -30,7 +30,7 @@
<Toggle
id="baseBranchSwitching"
checked={$baseBranchSwitching}
on:click={() => ($baseBranchSwitching = !$baseBranchSwitching)}
onclick={() => ($baseBranchSwitching = !$baseBranchSwitching)}
/>
</svelte:fragment>
</SectionCard>
@ -45,7 +45,7 @@
<Toggle
id="stackingFeature"
checked={$stackingFeature}
on:click={() => ($stackingFeature = !$stackingFeature)}
onclick={() => ($stackingFeature = !$stackingFeature)}
/>
</svelte:fragment>
</SectionCard>
@ -64,7 +64,7 @@
<Toggle
id="stackingFeatureMultipleSeries"
checked={$stackingFeatureMultipleSeries}
on:click={() => ($stackingFeatureMultipleSeries = !$stackingFeatureMultipleSeries)}
onclick={() => ($stackingFeatureMultipleSeries = !$stackingFeatureMultipleSeries)}
/>
</svelte:fragment>
</SectionCard>
@ -79,7 +79,7 @@
<Toggle
id="topics"
checked={$topicsEnabled}
on:click={() => ($topicsEnabled = !$topicsEnabled)}
onclick={() => ($topicsEnabled = !$topicsEnabled)}
/>
</svelte:fragment>
</SectionCard>

View File

@ -3,8 +3,8 @@
import SectionCard from '$lib/components/SectionCard.svelte';
import SettingsPage from '$lib/layout/SettingsPage.svelte';
import Link from '$lib/shared/Link.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import { getContext } from '@gitbutler/shared/context';
import Toggle from '@gitbutler/ui/Toggle.svelte';
import { onMount } from 'svelte';
const gitConfig = getContext(GitConfigService);
@ -36,7 +36,7 @@
</Link>
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle id="committerSigning" checked={annotateCommits} on:click={toggleCommitterSigning} />
<Toggle id="committerSigning" checked={annotateCommits} onclick={toggleCommitterSigning} />
</svelte:fragment>
</SectionCard>
</SettingsPage>

View File

@ -80,7 +80,7 @@ fn get_github_directory_path(root_path: &path::Path) -> path::PathBuf {
fn is_review_template_github(path_str: &str) -> bool {
path_str == "PULL_REQUEST_TEMPLATE.md"
|| path_str == "pull_request_template.md"
|| path_str.contains("PULL_REQUEST_TEMPLATE/")
|| path_str.contains("PULL_REQUEST_TEMPLATE/") && path_str.ends_with(".md")
}
fn is_valid_review_template_path_github(path: &path::Path, root_path: &path::Path) -> bool {
@ -147,7 +147,7 @@ mod tests {
fn test_is_review_template_github() {
assert!(is_review_template_github("PULL_REQUEST_TEMPLATE.md"));
assert!(is_review_template_github("pull_request_template.md"));
assert!(is_review_template_github("PULL_REQUEST_TEMPLATE/"));
assert!(is_review_template_github("PULL_REQUEST_TEMPLATE/other.md"));
assert!(!is_review_template_github("README.md"));
}

View File

@ -6,6 +6,7 @@
readonly?: boolean;
fontSize?: number;
maxHeight?: string;
rows?: number;
autofocus?: boolean;
padding?: {
top: number;
@ -32,6 +33,7 @@
readonly,
fontSize = 14,
maxHeight = 'none',
rows = 1,
autofocus = false,
padding = { top: 0, right: 0, bottom: 0, left: 0 },
oninput,
@ -49,6 +51,15 @@
}
}, 0);
});
$effect(() => {
if (ref) {
// reference the value to trigger
// the effect when it changes
value;
autoHeight(ref);
}
});
</script>
<textarea
@ -59,11 +70,10 @@
autoHeight(e.currentTarget as HTMLTextAreaElement);
}}
class="borderless-textarea scrollbar"
rows={1}
{rows}
{placeholder}
{readonly}
oninput={(e) => {
autoHeight(e.currentTarget);
oninput?.(e);
}}
onfocus={(e) => {

View File

@ -1,14 +1,22 @@
<script lang="ts">
export let small = false;
export let disabled = false;
export let checked = false;
export let value = '';
export let id = '';
interface Props {
small?: boolean;
disabled?: boolean;
checked?: boolean;
value?: string;
id?: string;
onclick?: (e: MouseEvent) => void;
}
let { small, disabled, checked = $bindable(), value, id, onclick }: Props = $props();
</script>
<input
bind:checked
on:click|stopPropagation
onclick={(e) => {
e.stopPropagation();
onclick?.(e);
}}
type="checkbox"
class="toggle"
class:small

View File

@ -0,0 +1,68 @@
<script lang="ts">
import Icon from '$lib/Icon.svelte';
import Toggle from '$lib/Toggle.svelte';
import Tooltip from '$lib/Tooltip.svelte';
import iconsJson from '$lib/data/icons.json';
interface Props {
id?: string;
label: string;
checked: boolean;
icon?: keyof typeof iconsJson;
tooltip?: string;
disabled?: boolean;
onclick?: (e: MouseEvent) => void;
}
let { id, label, checked = $bindable(), icon, tooltip, disabled, onclick }: Props = $props();
const toggleId = id || label.toLowerCase().replace(/\s/g, '-');
</script>
<Tooltip text={tooltip}>
<label class="toggle-btn" class:disabled for={toggleId}>
{#if icon}
<div class="toggle-icon">
<Icon name={icon} />
</div>
{/if}
<span class="text-12 text-semibold toggle-btn__label">{label}</span>
<Toggle
id={toggleId}
small
{checked}
onclick={(e) => {
onclick?.(e);
}}
/>
</label>
</Tooltip>
<style lang="postcss">
.toggle-btn {
width: fit-content;
display: flex;
align-items: center;
gap: 10px;
height: var(--size-button);
padding: 4px 8px;
border: 1px solid var(--clr-border-2);
border-radius: var(--radius-m);
transition: border-color 0.2s;
&:hover {
border-color: var(--clr-border-1);
}
}
.toggle-icon {
display: flex;
opacity: 0.5;
margin-left: -2px;
margin-right: -2px;
}
.toggle-btn__label {
color: var(--clr-text-2);
}
</style>