Use SectionCard instead of ClickableCard

- commit grew a bit in scope
- lots of refactoring to settings pages
- clickable card dropped
This commit is contained in:
Mattias Granlund 2024-03-08 00:16:49 +01:00
parent 0311052f24
commit 645e076a78
15 changed files with 395 additions and 513 deletions

View File

@ -0,0 +1,12 @@
import { invoke } from './ipc';
export type GitCredentialCheck = {
error?: string;
ok: boolean;
};
export class AuthService {
async getPublicKey() {
return await invoke<string>('get_public_key');
}
}

View File

@ -16,14 +16,13 @@ import { get } from 'svelte/store';
import type { Project as CloudProject } from '$lib/backend/cloud'; import type { Project as CloudProject } from '$lib/backend/cloud';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
export type Key = export type KeyType = 'default' | 'generated' | 'gitCredentialsHelper' | 'local';
| 'default' export type LocalKey = {
| 'generated'
| 'gitCredentialsHelper'
| {
local: { private_key_path: string; passphrase?: string }; local: { private_key_path: string; passphrase?: string };
}; };
export type Key = Exclude<KeyType, 'local'> | LocalKey;
export type Project = { export type Project = {
id: string; id: string;
title: string; title: string;

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import ClickableCard from './ClickableCard.svelte';
import InfoMessage from './InfoMessage.svelte'; import InfoMessage from './InfoMessage.svelte';
import Link from './Link.svelte'; import Link from './Link.svelte';
import SectionCard from './SectionCard.svelte';
import Toggle from './Toggle.svelte'; import Toggle from './Toggle.svelte';
import { appErrorReportingEnabled, appMetricsEnabled } from '$lib/config/appSettings'; import { appErrorReportingEnabled, appMetricsEnabled } from '$lib/config/appSettings';
@ -39,7 +39,7 @@
</div> </div>
<div class="analytics-settings__actions"> <div class="analytics-settings__actions">
<ClickableCard on:click={toggleErrorReporting}> <SectionCard on:click={toggleErrorReporting} orientation="row">
<svelte:fragment slot="title">Error reporting</svelte:fragment> <svelte:fragment slot="title">Error reporting</svelte:fragment>
<svelte:fragment slot="body"> <svelte:fragment slot="body">
Toggle reporting of application crashes and errors. Toggle reporting of application crashes and errors.
@ -47,15 +47,15 @@
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
<Toggle checked={$errorReportingEnabled} on:change={toggleErrorReporting} /> <Toggle checked={$errorReportingEnabled} on:change={toggleErrorReporting} />
</svelte:fragment> </svelte:fragment>
</ClickableCard> </SectionCard>
<ClickableCard on:click={toggleMetrics}> <SectionCard on:click={toggleMetrics} orientation="row">
<svelte:fragment slot="title">Usage metrics</svelte:fragment> <svelte:fragment slot="title">Usage metrics</svelte:fragment>
<svelte:fragment slot="body">Toggle sharing of usage statistics.</svelte:fragment> <svelte:fragment slot="body">Toggle sharing of usage statistics.</svelte:fragment>
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
<Toggle checked={$metricsEnabled} on:change={toggleMetrics} /> <Toggle checked={$metricsEnabled} on:change={toggleMetrics} />
</svelte:fragment> </svelte:fragment>
</ClickableCard> </SectionCard>
{#if updatedTelemetrySettings} {#if updatedTelemetrySettings}
<InfoMessage>Changes will take effect on the next application start.</InfoMessage> <InfoMessage>Changes will take effect on the next application start.</InfoMessage>

View File

@ -1,119 +0,0 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let padding: string = 'var(--space-16)';
export let disabled = false;
export let checked = false;
export let hasTopRadius = true;
export let hasBottomRadius = true;
export let hasBottomLine = true;
const SLOTS = $$props.$$slots;
const dispatchClick = createEventDispatcher<{
click: void;
}>();
const dispatchChange = createEventDispatcher<{
change: boolean;
}>();
</script>
<button
class="clickable-card"
class:has-top-radius={hasTopRadius}
class:has-bottom-radius={hasBottomRadius}
class:has-bottom-line={hasBottomLine}
style="padding: {padding}"
on:click={() => {
dispatchClick('click');
dispatchChange('change', checked);
checked = !checked;
}}
class:card-disabled={disabled}
{disabled}
>
<div class="clickable-card__content">
{#if SLOTS.title}
<h3 class="text-base-15 text-bold clickable-card__title">
<slot name="title" />
</h3>
{/if}
{#if SLOTS.body}
<p class="text-base-body-12 clickable-card__text">
<slot name="body" />
</p>
{/if}
</div>
{#if SLOTS.actions}
<div class="clickable-card__actions">
<slot name="actions" />
</div>
{/if}
</button>
<style lang="post-css">
.clickable-card {
display: flex;
gap: var(--space-16);
border-left: 1px solid var(--clr-theme-container-outline-light);
border-right: 1px solid var(--clr-theme-container-outline-light);
background-color: var(--clr-theme-container-light);
transition:
background-color var(--transition-fast),
border-color var(--transition-fast);
text-align: left;
&:hover {
background-color: color-mix(
in srgb,
var(--clr-theme-container-light),
var(--darken-tint-extralight)
);
}
}
.card-disabled {
opacity: 0.6;
pointer-events: none;
}
.clickable-card__content {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.clickable-card__title {
color: var(--clr-theme-scale-ntrl-0);
}
.clickable-card__text {
color: var(--clr-theme-scale-ntrl-30);
}
.clickable-card__actions {
display: flex;
flex-shrink: 0;
}
/* MODIFIERS */
.has-top-radius {
border-top: 1px solid var(--clr-theme-container-outline-light);
border-top-left-radius: var(--radius-l);
border-top-right-radius: var(--radius-l);
}
.has-bottom-radius {
border-bottom-left-radius: var(--radius-l);
border-bottom-right-radius: var(--radius-l);
}
.has-bottom-line {
border-bottom: 1px solid var(--clr-theme-container-outline-light);
}
</style>

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import SectionCard from './SectionCard.svelte';
import { getCloudApiClient, type User } from '$lib/backend/cloud'; import { getCloudApiClient, type User } from '$lib/backend/cloud';
import ClickableCard from '$lib/components/ClickableCard.svelte';
import Link from '$lib/components/Link.svelte'; import Link from '$lib/components/Link.svelte';
import Spacer from '$lib/components/Spacer.svelte'; import Spacer from '$lib/components/Spacer.svelte';
import Toggle from '$lib/components/Toggle.svelte'; import Toggle from '$lib/components/Toggle.svelte';
@ -33,7 +33,7 @@
dispatch('updated', { ...project, api: { ...cloudProject, sync: project.api.sync } }); dispatch('updated', { ...project, api: { ...cloudProject, sync: project.api.sync } });
}); });
const onSyncChange = async (event: CustomEvent<boolean>) => { async function onSyncChange(sync: boolean) {
if (!user) return; if (!user) return;
try { try {
const cloudProject = const cloudProject =
@ -43,26 +43,26 @@
description: project.description, description: project.description,
uid: project.id uid: project.id
})); }));
dispatch('updated', { ...project, api: { ...cloudProject, sync: event.detail } }); dispatch('updated', { ...project, api: { ...cloudProject, sync } });
} catch (error) { } catch (error) {
console.error(`Failed to update project sync status: ${error}`); console.error(`Failed to update project sync status: ${error}`);
toasts.error('Failed to update project sync status'); toasts.error('Failed to update project sync status');
} }
}; }
const aiGenToggle = () => { function aiGenToggle() {
$aiGenEnabled = !$aiGenEnabled; $aiGenEnabled = !$aiGenEnabled;
$aiGenAutoBranchNamingEnabled = $aiGenEnabled; $aiGenAutoBranchNamingEnabled = $aiGenEnabled;
}; }
const aiGenBranchNamesToggle = () => { function aiGenBranchNamesToggle() {
$aiGenAutoBranchNamingEnabled = !$aiGenAutoBranchNamingEnabled; $aiGenAutoBranchNamingEnabled = !$aiGenAutoBranchNamingEnabled;
}; }
</script> </script>
{#if user} {#if user}
<div class="aigen-wrap"> <div class="aigen-wrap">
<ClickableCard on:click={aiGenToggle}> <SectionCard on:click={aiGenToggle} orientation="row">
<svelte:fragment slot="title">Enable branch and commit message generation</svelte:fragment> <svelte:fragment slot="title">Enable branch and commit message generation</svelte:fragment>
<svelte:fragment slot="body"> <svelte:fragment slot="body">
Uses OpenAI's API. If enabled, diffs will sent to OpenAI's servers when pressing the Uses OpenAI's API. If enabled, diffs will sent to OpenAI's servers when pressing the
@ -71,9 +71,9 @@
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
<Toggle checked={$aiGenEnabled} on:change={aiGenToggle} /> <Toggle checked={$aiGenEnabled} on:change={aiGenToggle} />
</svelte:fragment> </svelte:fragment>
</ClickableCard> </SectionCard>
<ClickableCard disabled={!$aiGenEnabled} on:click={aiGenBranchNamesToggle}> <SectionCard disabled={!$aiGenEnabled} on:click={aiGenBranchNamesToggle} orientation="row">
<svelte:fragment slot="title">Automatically generate branch names</svelte:fragment> <svelte:fragment slot="title">Automatically generate branch names</svelte:fragment>
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
<Toggle <Toggle
@ -82,7 +82,7 @@
on:change={aiGenBranchNamesToggle} on:change={aiGenBranchNamesToggle}
/> />
</svelte:fragment> </svelte:fragment>
</ClickableCard> </SectionCard>
</div> </div>
<Spacer /> <Spacer />
@ -90,14 +90,14 @@
{#if user.role === 'admin'} {#if user.role === 'admin'}
<h3 class="text-base-15 text-bold">Full data synchronization</h3> <h3 class="text-base-15 text-bold">Full data synchronization</h3>
<ClickableCard on:change={onSyncChange}> <SectionCard on:change={(e) => onSyncChange(e.detail)} orientation="row">
<svelte:fragment slot="body"> <svelte:fragment slot="body">
Sync my history, repository and branch data for backup, sharing and team features. Sync my history, repository and branch data for backup, sharing and team features.
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
<Toggle checked={project.api?.sync || false} on:change={onSyncChange} /> <Toggle checked={project.api?.sync || false} on:change={(e) => onSyncChange(e.detail)} />
</svelte:fragment> </svelte:fragment>
</ClickableCard> </SectionCard>
{#if project.api} {#if project.api}
<div class="api-link"> <div class="api-link">

View File

@ -1,19 +1,17 @@
<script lang="ts"> <script lang="ts">
import ClickableCard from './ClickableCard.svelte';
import RadioButton from './RadioButton.svelte'; import RadioButton from './RadioButton.svelte';
import SectionCard from './SectionCard.svelte'; import SectionCard from './SectionCard.svelte';
import Spacer from './Spacer.svelte'; import Spacer from './Spacer.svelte';
import TextBox from './TextBox.svelte'; import TextBox from './TextBox.svelte';
import { invoke } from '$lib/backend/ipc';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import Link from '$lib/components/Link.svelte'; import Link from '$lib/components/Link.svelte';
import { copyToClipboard } from '$lib/utils/clipboard'; import { copyToClipboard } from '$lib/utils/clipboard';
import { debounce } from '$lib/utils/debounce';
import { openExternalUrl } from '$lib/utils/url'; import { openExternalUrl } from '$lib/utils/url';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import type { Key, Project } from '$lib/backend/projects'; import type { Key, KeyType, Project } from '$lib/backend/projects';
export let project: Project; export let project: Project;
export let sshKey = '';
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
updated: { updated: {
@ -21,131 +19,91 @@
}; };
}>(); }>();
export function get_public_key() { let selectedType: KeyType =
return invoke<string>('get_public_key'); typeof project.preferred_key == 'string' ? project.preferred_key : 'local';
}
let sshKey = '';
get_public_key().then((key) => {
sshKey = key;
});
let selectedOption =
project.preferred_key === 'generated'
? 'generated'
: project.preferred_key === 'default'
? 'default'
: project.preferred_key === 'gitCredentialsHelper'
? 'gitCredentialsHelper'
: 'local';
let privateKeyPath = let privateKeyPath =
project.preferred_key === 'generated' || typeof project.preferred_key == 'string' ? '' : project.preferred_key.local.private_key_path;
project.preferred_key === 'default' ||
project.preferred_key === 'gitCredentialsHelper'
? ''
: project.preferred_key.local.private_key_path;
let privateKeyPassphrase = let privateKeyPassphrase =
project.preferred_key === 'generated' || typeof project.preferred_key == 'string' ? '' : project.preferred_key.local.passphrase;
project.preferred_key === 'default' ||
project.preferred_key === 'gitCredentialsHelper'
? ''
: project.preferred_key.local.passphrase;
function setLocalKey() { function setLocalKey() {
if (privateKeyPath.length) { if (privateKeyPath.trim().length == 0) return;
dispatch('updated', { dispatch('updated', {
preferred_key: { preferred_key: {
local: { local: {
private_key_path: privateKeyPath, private_key_path: privateKeyPath.trim(),
passphrase: passphrase: privateKeyPassphrase || undefined
privateKeyPassphrase && privateKeyPassphrase.length > 0
? privateKeyPassphrase
: undefined
} }
} }
}); });
} }
}
function setGitCredentialsHelperKey() {
dispatch('updated', {
preferred_key: 'gitCredentialsHelper'
});
}
function setDefaultKey() {
dispatch('updated', {
preferred_key: 'default'
});
}
function setGeneratedKey() {
dispatch('updated', {
preferred_key: 'generated'
});
}
let showPassphrase = false; let showPassphrase = false;
let form: HTMLFormElement;
function onFormChange(form: HTMLFormElement) {
const formData = new FormData(form);
selectedType = formData.get('credentialType') as KeyType;
if (selectedType != 'local') {
dispatch('updated', { preferred_key: selectedType });
} else {
setLocalKey();
}
}
onMount(() => {
form.credentialType.value = selectedType;
});
</script> </script>
<section class="git-auth-wrap"> <div class="git-auth-wrap">
<h3 class="text-base-15 text-bold">Git Authentication</h3> <h3 class="text-base-15 text-bold">Git Authentication</h3>
<p class="text-base-body-12"> <p class="text-base-body-12">
Configure the authentication flow for GitButler when authenticating with your Git remote Configure the authentication flow for GitButler when authenticating with your Git remote
provider. provider.
</p> </p>
<form> <form class="git-radio" bind:this={form} on:change={(e) => onFormChange(e.currentTarget)}>
<fieldset class="git-radio"> <SectionCard roundedBottom={false} orientation="row" labelFor="credential-default">
<ClickableCard
hasBottomRadius={false}
on:click={() => {
if (selectedOption == 'default') return;
selectedOption = 'default';
setDefaultKey();
}}
>
<svelte:fragment slot="title">Auto detect</svelte:fragment> <svelte:fragment slot="title">Auto detect</svelte:fragment>
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
<RadioButton bind:group={selectedOption} value="default" on:input={setDefaultKey} /> <RadioButton name="credentialType" id="credential-default" value="default" />
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="body"> <svelte:fragment slot="body">
GitButler will attempt all available authentication flows automatically. GitButler will attempt all available authentication flows automatically.
</svelte:fragment> </svelte:fragment>
</ClickableCard> </SectionCard>
<ClickableCard <SectionCard
hasTopRadius={false} roundedTop={false}
hasBottomRadius={false} roundedBottom={false}
hasBottomLine={selectedOption !== 'local'} bottomBorder={selectedType !== 'local'}
on:click={() => { orientation="row"
selectedOption = 'local'; labelFor="credential-local"
}}
> >
<svelte:fragment slot="title">Use existing SSH key</svelte:fragment> <svelte:fragment slot="title">Use existing SSH key</svelte:fragment>
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
<RadioButton bind:group={selectedOption} value="local" /> <RadioButton name="credentialType" id="credential-local" value="local" />
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="body"> <svelte:fragment slot="body">
Add the path to an existing SSH key that GitButler can use. Add the path to an existing SSH key that GitButler can use.
</svelte:fragment> </svelte:fragment>
</ClickableCard> </SectionCard>
{#if selectedOption === 'local'} {#if selectedType == 'local'}
<SectionCard hasTopRadius={false} hasBottomRadius={false}> <SectionCard hasTopRadius={false} hasBottomRadius={false} orientation="row">
<div class="inputs-group"> <div class="inputs-group">
<TextBox <TextBox
label="Path to private key" label="Path to private key"
placeholder="for example: ~/.ssh/id_rsa" placeholder="for example: ~/.ssh/id_rsa"
bind:value={privateKeyPath} bind:value={privateKeyPath}
on:input={debounce(setLocalKey, 600)}
/> />
<div class="input-with-button"> <div class="input-with-button">
@ -153,7 +111,6 @@
label="Passphrase (optional)" label="Passphrase (optional)"
type={showPassphrase ? 'text' : 'password'} type={showPassphrase ? 'text' : 'password'}
bind:value={privateKeyPassphrase} bind:value={privateKeyPassphrase}
on:input={debounce(setLocalKey, 600)}
wide wide
/> />
<Button <Button
@ -171,31 +128,27 @@
</SectionCard> </SectionCard>
{/if} {/if}
<ClickableCard <SectionCard
hasTopRadius={false} roundedTop={false}
hasBottomRadius={false} roundedBottom={false}
hasBottomLine={selectedOption !== 'generated'} bottomBorder={selectedType !== 'generated'}
on:click={() => { orientation="row"
if (selectedOption == 'generated') return; labelFor="credential-generated"
selectedOption = 'generated';
setGeneratedKey();
}}
> >
<svelte:fragment slot="title">Use locally generated SSH key</svelte:fragment> <svelte:fragment slot="title">Use locally generated SSH key</svelte:fragment>
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
<RadioButton bind:group={selectedOption} value="generated" on:input={setGeneratedKey} /> <RadioButton name="credentialType" id="credential-generated" value="generated" />
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="body"> <svelte:fragment slot="body">
GitButler will use a locally generated SSH key. For this to work you need to add the GitButler will use a locally generated SSH key. For this to work you need to add the
following public key to your Git remote provider: following public key to your Git remote provider:
</svelte:fragment> </svelte:fragment>
</ClickableCard> </SectionCard>
{#if selectedOption === 'generated'} {#if selectedType === 'generated'}
<SectionCard hasTopRadius={false} hasBottomRadius={false}> <SectionCard hasTopRadius={false} hasBottomRadius={false} orientation="row">
<TextBox readonly selectall bind:value={sshKey} /> <TextBox readonly selectall bind:value={sshKey} />
<div class="row-buttons"> <div class="row-buttons">
<Button <Button
@ -220,14 +173,11 @@
</SectionCard> </SectionCard>
{/if} {/if}
<ClickableCard <SectionCard
hasTopRadius={false} roundedTop={false}
on:click={() => { roundedBottom={false}
if (selectedOption == 'gitCredentialsHelper') return; orientation="row"
labelFor="credential-helper"
selectedOption = 'gitCredentialsHelper';
setGitCredentialsHelperKey();
}}
> >
<svelte:fragment slot="title">Use a Git credentials helper</svelte:fragment> <svelte:fragment slot="title">Use a Git credentials helper</svelte:fragment>
@ -239,16 +189,16 @@
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
<RadioButton <RadioButton name="credentialType" value="gitCredentialsHelper" id="credential-helper" />
bind:group={selectedOption}
value="gitCredentialsHelper"
on:input={setGitCredentialsHelperKey}
/>
</svelte:fragment> </svelte:fragment>
</ClickableCard> </SectionCard>
</fieldset> <SectionCard roundedTop={false} orientation="row">
<svelte:fragment slot="body">
<Button wide>Test credentials</Button>
</svelte:fragment>
</SectionCard>
</form> </form>
</section> </div>
<Spacer /> <Spacer />

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import SectionCard from './SectionCard.svelte';
import Spacer from './Spacer.svelte'; import Spacer from './Spacer.svelte';
import ClickableCard from '$lib/components/ClickableCard.svelte';
import Toggle from '$lib/components/Toggle.svelte'; import Toggle from '$lib/components/Toggle.svelte';
import { projectRunCommitHooks } from '$lib/config/config'; import { projectRunCommitHooks } from '$lib/config/config';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
@ -18,62 +18,48 @@
omit_certificate_check?: boolean; omit_certificate_check?: boolean;
}; };
}>(); }>();
const onAllowForcePushingChange = () => {
dispatch('updated', { ok_with_force_push: allowForcePushing });
};
const onOmitCertificateCheckChange = () => {
dispatch('updated', { omit_certificate_check: omitCertificateCheck });
};
const onRunCommitHooksChange = () => {
$runCommitHooks = !$runCommitHooks;
};
</script> </script>
<section class="wrapper"> <section class="wrapper">
<ClickableCard <SectionCard orientation="row" labelFor="allowForcePush">
on:click={() => {
allowForcePushing = !allowForcePushing;
onAllowForcePushingChange();
}}
>
<svelte:fragment slot="title">Allow force pushing</svelte:fragment> <svelte:fragment slot="title">Allow force pushing</svelte:fragment>
<svelte:fragment slot="body"> <svelte:fragment slot="body">
Force pushing allows GitButler to override branches even if they were pushed to remote. We Force pushing allows GitButler to override branches even if they were pushed to remote. We
will never force push to the trunk. will never force push to the trunk.
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
<Toggle bind:checked={allowForcePushing} on:change={onAllowForcePushingChange} /> <Toggle
id="allowForcePush"
bind:checked={allowForcePushing}
on:change={() => dispatch('updated', { ok_with_force_push: allowForcePushing })}
/>
</svelte:fragment> </svelte:fragment>
</ClickableCard> </SectionCard>
<ClickableCard <SectionCard orientation="row" labelFor="omitCertificateCheck">
on:click={() => {
omitCertificateCheck = !omitCertificateCheck;
onOmitCertificateCheckChange();
}}
>
<svelte:fragment slot="title">Ignore host certificate checks</svelte:fragment> <svelte:fragment slot="title">Ignore host certificate checks</svelte:fragment>
<svelte:fragment slot="body"> <svelte:fragment slot="body">
Enabling this will ignore host certificate checks when authenticating with ssh. Enabling this will ignore host certificate checks when authenticating with ssh.
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
<Toggle bind:checked={omitCertificateCheck} on:change={onOmitCertificateCheckChange} /> <Toggle
id="omitCertificateCheck"
bind:checked={omitCertificateCheck}
on:change={() => dispatch('updated', { omit_certificate_check: omitCertificateCheck })}
/>
</svelte:fragment> </svelte:fragment>
</ClickableCard> </SectionCard>
<ClickableCard on:click={onRunCommitHooksChange}> <SectionCard labelFor="runHooks" orientation="row">
<svelte:fragment slot="title">Run commit hooks</svelte:fragment> <svelte:fragment slot="title">Run commit hooks</svelte:fragment>
<svelte:fragment slot="body"> <svelte:fragment slot="body">
Enabling this will run any git pre and post commit hooks you have configured in your Enabling this will run any git pre and post commit hooks you have configured in your
repository. repository.
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
<Toggle bind:checked={$runCommitHooks} on:change={onRunCommitHooksChange} /> <Toggle id="runHooks" bind:checked={$runCommitHooks} />
</svelte:fragment> </svelte:fragment>
</ClickableCard> </SectionCard>
</section> </section>
<Spacer /> <Spacer />

View File

@ -3,8 +3,8 @@
export let small = false; export let small = false;
export let disabled = false; export let disabled = false;
export let group = '';
export let value = ''; export let value = '';
export let id = '';
</script> </script>
<input <input
@ -14,10 +14,10 @@
type="radio" type="radio"
class="radio" class="radio"
class:small class:small
{id}
{value} {value}
{name} {name}
{disabled} {disabled}
bind:group
/> />
<style lang="postcss"> <style lang="postcss">

View File

@ -1,18 +1,32 @@
<script lang="ts" context="module">
export type SectionCardBackground = 'loading' | 'success' | 'error' | undefined;
</script>
<script lang="ts"> <script lang="ts">
export let orientation: 'row' | 'column' = 'column'; export let orientation: 'row' | 'column' = 'column';
export let hasTopRadius = true; export let extraPadding = false;
export let hasBottomRadius = true; export let roundedTop = true;
export let hasBottomLine = true; export let roundedBottom = true;
export let bottomBorder = true;
export let background: SectionCardBackground = undefined;
export let noBorder = false;
export let labelFor = '';
const SLOTS = $$props.$$slots; const SLOTS = $$props.$$slots;
</script> </script>
<section <label
for={labelFor}
class="section-card" class="section-card"
style:flex-direction={orientation} style:flex-direction={orientation}
class:has-top-radius={hasTopRadius} class:extra-padding={extraPadding}
class:has-bottom-radius={hasBottomRadius} class:rounded-top={roundedTop}
class:has-bottom-line={hasBottomLine} class:rounded-bottom={roundedBottom}
class:bottom-border={bottomBorder}
class:no-border={noBorder}
class:loading={background == 'loading'}
class:success={background == 'success'}
class:error={background == 'error'}
> >
{#if SLOTS.iconSide} {#if SLOTS.iconSide}
<div class="section-card__icon-side"> <div class="section-card__icon-side">
@ -35,7 +49,12 @@
</div> </div>
{/if} {/if}
<slot /> <slot />
</section> {#if SLOTS.actions}
<div class="clickable-card__actions">
<slot name="actions" />
</div>
{/if}
</label>
<style lang="post-css"> <style lang="post-css">
.section-card { .section-card {
@ -45,17 +64,34 @@
border-left: 1px solid var(--clr-theme-container-outline-light); border-left: 1px solid var(--clr-theme-container-outline-light);
border-right: 1px solid var(--clr-theme-container-outline-light); border-right: 1px solid var(--clr-theme-container-outline-light);
background-color: var(--clr-theme-container-light); background-color: var(--clr-theme-container-light);
cursor: pointer;
transition: transition:
background-color var(--transition-fast), background-color var(--transition-fast),
border-color var(--transition-fast); border-color var(--transition-fast);
text-align: left; text-align: left;
} }
.loading {
background: var(--clr-theme-container-pale);
}
.success {
background: var(--clr-theme-pop-container);
}
.error {
background: var(--clr-theme-warn-container);
}
.extra-padding {
padding: var(--space-20);
}
.section-card__content { .section-card__content {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-8); gap: var(--space-8);
user-select: text;
} }
.section-card__title { .section-card__title {
@ -68,20 +104,22 @@
/* MODIFIERS */ /* MODIFIERS */
.has-top-radius { .rounded-top {
border-top: 1px solid var(--clr-theme-container-outline-light); border-top: 1px solid var(--clr-theme-container-outline-light);
border-top-left-radius: var(--radius-m); border-top-left-radius: var(--radius-m);
border-top-right-radius: var(--radius-m); border-top-right-radius: var(--radius-m);
} }
.has-bottom-radius { .rounded-bottom {
border-bottom-left-radius: var(--radius-m); border-bottom-left-radius: var(--radius-m);
border-bottom-right-radius: var(--radius-m); border-bottom-right-radius: var(--radius-m);
} }
.has-bottom-line { .bottom-border {
border-bottom: 1px solid var(--clr-theme-container-outline-light); border-bottom: 1px solid var(--clr-theme-container-outline-light);
user-select: text; }
cursor: text;
.no-border {
border: none;
} }
</style> </style>

View File

@ -9,6 +9,7 @@
export let checked = false; export let checked = false;
export let value = ''; export let value = '';
export let help = ''; export let help = '';
export let id = '';
let input: HTMLInputElement; let input: HTMLInputElement;
const dispatch = createEventDispatcher<{ change: boolean }>(); const dispatch = createEventDispatcher<{ change: boolean }>();
@ -26,7 +27,7 @@
class:small class:small
{value} {value}
{name} {name}
id={name} {id}
{disabled} {disabled}
use:tooltip={help} use:tooltip={help}
/> />

View File

@ -1,5 +1,6 @@
import { initPostHog } from '$lib/analytics/posthog'; import { initPostHog } from '$lib/analytics/posthog';
import { initSentry } from '$lib/analytics/sentry'; import { initSentry } from '$lib/analytics/sentry';
import { AuthService } from '$lib/backend/auth';
import { getCloudApiClient } from '$lib/backend/cloud'; import { getCloudApiClient } from '$lib/backend/cloud';
import { ProjectService } from '$lib/backend/projects'; import { ProjectService } from '$lib/backend/projects';
import { UpdaterService } from '$lib/backend/updater'; import { UpdaterService } from '$lib/backend/updater';
@ -33,7 +34,6 @@ export const load: LayoutLoad = async ({ fetch: realFetch }: { fetch: typeof fet
if (enabled) initPostHog(); if (enabled) initPostHog();
}); });
const userService = new UserService(); const userService = new UserService();
const updaterService = new UpdaterService();
// TODO: Find a workaround to avoid this dynamic import // TODO: Find a workaround to avoid this dynamic import
// https://github.com/sveltejs/kit/issues/905 // https://github.com/sveltejs/kit/issues/905
@ -41,9 +41,10 @@ export const load: LayoutLoad = async ({ fetch: realFetch }: { fetch: typeof fet
const defaultPath = await homeDir(); const defaultPath = await homeDir();
return { return {
authService: new AuthService(),
projectService: new ProjectService(defaultPath), projectService: new ProjectService(defaultPath),
cloud: getCloudApiClient({ fetch: realFetch }), cloud: getCloudApiClient({ fetch: realFetch }),
updaterService, updaterService: new UpdaterService(),
userService, userService,
user$: userService.user$ user$: userService.user$
}; };

View File

@ -11,7 +11,7 @@ import type { LayoutLoad } from './$types';
export const prerender = false; export const prerender = false;
export const load: LayoutLoad = async ({ params, parent }) => { export const load: LayoutLoad = async ({ params, parent }) => {
const { user$, projectService, userService } = await parent(); const { authService, projectService, userService } = await parent();
const projectId = params.projectId; const projectId = params.projectId;
const project$ = projectService.getProject(projectId); const project$ = projectService.getProject(projectId);
const fetches$ = getFetchNotifications(projectId); const fetches$ = getFetchNotifications(projectId);
@ -41,8 +41,11 @@ export const load: LayoutLoad = async ({ params, parent }) => {
branchController branchController
); );
const user$ = userService.user$;
return { return {
projectId, projectId,
authService,
branchController, branchController,
baseBranchService, baseBranchService,
githubService, githubService,

View File

@ -8,7 +8,6 @@
import SectionCard from '$lib/components/SectionCard.svelte'; import SectionCard from '$lib/components/SectionCard.svelte';
import ContentWrapper from '$lib/components/settings/ContentWrapper.svelte'; import ContentWrapper from '$lib/components/settings/ContentWrapper.svelte';
import * as toasts from '$lib/utils/toasts'; import * as toasts from '$lib/utils/toasts';
import type { UserError } from '$lib/backend/ipc';
import type { Key, Project } from '$lib/backend/projects'; import type { Key, Project } from '$lib/backend/projects';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@ -24,34 +23,41 @@
let deleteConfirmationModal: RemoveProjectButton; let deleteConfirmationModal: RemoveProjectButton;
let isDeleting = false; let isDeleting = false;
const onDeleteClicked = () => async function onDeleteClicked() {
Promise.resolve() isDeleting = true;
.then(() => (isDeleting = true)) try {
.then(() => projectService.deleteProject($project$?.id)) projectService.deleteProject($project$?.id);
.catch((e) => { toasts.success('Project deleted');
console.error(e); goto('/');
} catch (err: any) {
console.error(err);
toasts.error('Failed to delete project'); toasts.error('Failed to delete project');
}) } finally {
.then(() => toasts.success('Project deleted'))
.then(() => goto('/'))
.finally(() => {
isDeleting = false; isDeleting = false;
projectService.reload(); projectService.reload();
}); }
}
const onKeysUpdated = (e: { detail: { preferred_key: Key } }) => async function onKeysUpdated(e: { detail: { preferred_key: Key } }) {
projectService try {
.updateProject({ ...$project$, ...e.detail })
.then(() => toasts.success('Preferred key updated'))
.catch((e: UserError) => {
toasts.error(e.message);
});
const onCloudUpdated = (e: { detail: Project }) =>
projectService.updateProject({ ...$project$, ...e.detail }); projectService.updateProject({ ...$project$, ...e.detail });
const onPreferencesUpdated = (e: { toasts.success('Preferred key updated');
} catch (err: any) {
toasts.error(err.message);
}
}
async function onCloudUpdated(e: { detail: Project }) {
projectService.updateProject({ ...$project$, ...e.detail });
}
async function onPreferencesUpdated(e: {
detail: { ok_with_force_push?: boolean; omit_certificate_check?: boolean }; detail: { ok_with_force_push?: boolean; omit_certificate_check?: boolean };
}) => projectService.updateProject({ ...$project$, ...e.detail }); }) {
const onDetailsUpdated = async (e: { detail: Project }) => { await projectService.updateProject({ ...$project$, ...e.detail });
}
async function onDetailsUpdated(e: { detail: Project }) {
const api = const api =
$user$ && e.detail.api $user$ && e.detail.api
? await cloud.projects.update($user$?.access_token, e.detail.api.repository_id, { ? await cloud.projects.update($user$?.access_token, e.detail.api.repository_id, {
@ -59,12 +65,11 @@
description: e.detail.description description: e.detail.description
}) })
: undefined; : undefined;
projectService.updateProject({ projectService.updateProject({
...e.detail, ...e.detail,
api: api ? { ...api, sync: e.detail.api?.sync || false } : undefined api: api ? { ...api, sync: e.detail.api?.sync || false } : undefined
}); });
}; }
</script> </script>
{#if !$project$} {#if !$project$}

View File

@ -2,7 +2,6 @@
import { deleteAllData } from '$lib/backend/data'; import { deleteAllData } from '$lib/backend/data';
import AnalyticsSettings from '$lib/components/AnalyticsSettings.svelte'; import AnalyticsSettings from '$lib/components/AnalyticsSettings.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import ClickableCard from '$lib/components/ClickableCard.svelte';
import GithubIntegration from '$lib/components/GithubIntegration.svelte'; import GithubIntegration from '$lib/components/GithubIntegration.svelte';
import Link from '$lib/components/Link.svelte'; import Link from '$lib/components/Link.svelte';
import Login from '$lib/components/Login.svelte'; import Login from '$lib/components/Login.svelte';
@ -19,37 +18,33 @@
import * as toasts from '$lib/utils/toasts'; import * as toasts from '$lib/utils/toasts';
import { openExternalUrl } from '$lib/utils/url'; import { openExternalUrl } from '$lib/utils/url';
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { onMount } from 'svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
export let data: PageData; export let data: PageData;
const { cloud, user$, userService } = data;
$: saving = false;
$: userPicture = $user$?.picture;
const { cloud, user$, userService, authService } = data;
const fileTypes = ['image/jpeg', 'image/png']; const fileTypes = ['image/jpeg', 'image/png'];
const validFileType = (file: File) => { // TODO: Maybe break these into components?
return fileTypes.includes(file.type); let currentSection: 'profile' | 'git-stuff' | 'telemetry' | 'integrations' = 'profile';
};
const onPictureChange = (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file && validFileType(file)) {
userPicture = URL.createObjectURL(file);
} else {
userPicture = $user$?.picture;
toasts.error('Please use a valid image file');
}
};
// TODO: Refactor such that this variable isn't needed
let newName = ''; let newName = '';
let loaded = false; let loaded = false;
let isDeleting = false;
let signCommits = false;
let annotateCommits = true;
let sshKey = '';
let deleteConfirmationModal: Modal;
$: saving = false;
$: userPicture = $user$?.picture;
$: if ($user$ && !loaded) { $: if ($user$ && !loaded) {
loaded = true; loaded = true;
cloud.user.get($user$?.access_token).then((cloudUser) => { cloud.user.get($user$?.access_token).then((cloudUser) => {
@ -59,7 +54,19 @@
newName = $user$?.name || ''; newName = $user$?.name || '';
} }
const onSubmit = async (e: SubmitEvent) => { function onPictureChange(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file && fileTypes.includes(file.type)) {
userPicture = URL.createObjectURL(file);
} else {
userPicture = $user$?.picture;
toasts.error('Please use a valid image file');
}
}
async function onSubmit(e: SubmitEvent) {
if (!$user$) return; if (!$user$) return;
saving = true; saving = true;
@ -80,74 +87,55 @@
toasts.error('Failed to update user'); toasts.error('Failed to update user');
} }
saving = false; saving = false;
}; }
let isDeleting = false; // TODO: These kinds of functions should be implemented on an injected service
let deleteConfirmationModal: Modal; function gitGetConfig(params: { key: string }) {
export function git_get_config(params: { key: string }) {
return invoke<string>('git_get_global_config', params); return invoke<string>('git_get_global_config', params);
} }
export function git_set_config(params: { key: string; value: string }) { function gitSetConfig(params: { key: string; value: string }) {
return invoke<string>('git_set_global_config', params); return invoke<string>('git_set_global_config', params);
} }
const setCommitterSetting = (value: boolean) => { function toggleCommitterSigning() {
annotateCommits = value; annotateCommits = !annotateCommits;
git_set_config({ gitSetConfig({
key: 'gitbutler.gitbutlerCommitter', key: 'gitbutler.gitbutlerCommitter',
value: value ? '1' : '0' value: annotateCommits ? '1' : '0'
}); });
};
const setSigningSetting = (value: boolean) => {
signCommits = value;
git_set_config({
key: 'gitbutler.signCommits',
value: value ? 'true' : 'false'
});
};
export function get_public_key() {
return invoke<string>('get_public_key');
} }
let sshKey = ''; function toggleSigningSetting() {
get_public_key().then((key) => { signCommits = !signCommits;
sshKey = key; gitSetConfig({
key: 'gitbutler.signCommits',
value: signCommits ? 'true' : 'false'
}); });
}
$: annotateCommits = true; async function onDeleteClicked() {
$: signCommits = false; isDeleting = true;
try {
git_get_config({ key: 'gitbutler.gitbutlerCommitter' }).then((value) => { deleteAllData();
annotateCommits = value ? value === '1' : false; await userService.logout();
});
git_get_config({ key: 'gitbutler.signCommits' }).then((value) => {
signCommits = value ? value === 'true' : false;
});
const onDeleteClicked = () =>
Promise.resolve()
.then(() => (isDeleting = true))
.then(() => deleteAllData())
// TODO: Delete user from observable!!! // TODO: Delete user from observable!!!
.then(() => userService.logout()) toasts.success('All data deleted');
.then(() => toasts.success('All data deleted')) goto('/', { replaceState: true, invalidateAll: true });
.catch((e) => { } catch (err: any) {
console.error(e); console.error(err);
toasts.error('Failed to delete project'); toasts.error('Failed to delete project');
}) } finally {
.then(() => deleteConfirmationModal.close()) deleteConfirmationModal.close();
.then(() => goto('/', { replaceState: true, invalidateAll: true })) isDeleting = false;
.finally(() => (isDeleting = false)); }
}
let currentSection: 'profile' | 'git-stuff' | 'telemetry' | 'integrations' = 'profile'; onMount(async () => {
sshKey = await authService.getPublicKey();
const toggleGBCommiter = () => setCommitterSetting(!annotateCommits); annotateCommits = (await gitGetConfig({ key: 'gitbutler.gitbutlerCommitter' })) == '1';
const toggleGBSigner = () => setSigningSetting(!signCommits); signCommits = (await gitGetConfig({ key: 'gitbutler.signCommits' })) == 'true';
});
</script> </script>
<section class="profile-page"> <section class="profile-page">
@ -233,7 +221,7 @@
</ContentWrapper> </ContentWrapper>
{:else if currentSection === 'git-stuff'} {:else if currentSection === 'git-stuff'}
<ContentWrapper title="Git stuff"> <ContentWrapper title="Git stuff">
<ClickableCard on:click={toggleGBCommiter}> <SectionCard labelFor="committerSigning" orientation="row">
<svelte:fragment slot="title">Credit GitButler as the Committer</svelte:fragment> <svelte:fragment slot="title">Credit GitButler as the Committer</svelte:fragment>
<svelte:fragment slot="body"> <svelte:fragment slot="body">
By default, everything in the GitButler client is free to use. You can opt in to crediting By default, everything in the GitButler client is free to use. You can opt in to crediting
@ -247,9 +235,13 @@
</Link> </Link>
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
<Toggle checked={annotateCommits} on:change={toggleGBCommiter} /> <Toggle
id="commiterSigning"
checked={annotateCommits}
on:change={toggleCommitterSigning}
/>
</svelte:fragment> </svelte:fragment>
</ClickableCard> </SectionCard>
<Spacer /> <Spacer />
@ -283,7 +275,7 @@
</div> </div>
</SectionCard> </SectionCard>
<ClickableCard on:click={toggleGBSigner}> <SectionCard labelFor="signingSetting" orientation="row">
<svelte:fragment slot="title">Sign Commits with the above SSH Key</svelte:fragment> <svelte:fragment slot="title">Sign Commits with the above SSH Key</svelte:fragment>
<svelte:fragment slot="body"> <svelte:fragment slot="body">
If you want GitButler to sign your commits with the SSH key we generated, then you can add If you want GitButler to sign your commits with the SSH key we generated, then you can add
@ -297,9 +289,9 @@
</Link> </Link>
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="actions"> <svelte:fragment slot="actions">
<Toggle checked={signCommits} on:change={toggleGBSigner} /> <Toggle id="signingSetting" checked={signCommits} on:change={toggleSigningSetting} />
</svelte:fragment> </svelte:fragment>
</ClickableCard> </SectionCard>
</ContentWrapper> </ContentWrapper>
{:else if currentSection === 'telemetry'} {:else if currentSection === 'telemetry'}
<ContentWrapper title="Telemetry"> <ContentWrapper title="Telemetry">

View File

@ -0,0 +1,14 @@
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 14.5V7.5C15 4.73858 12.7614 2.5 10 2.5V2.5C7.23858 2.5 5 4.73858 5 7.5V14.5" stroke="#67C2BE" stroke-width="2"/>
<rect x="1" y="8.5" width="18" height="11" rx="2" fill="url(#paint0_linear_1756_54140)"/>
<rect x="1" y="8.5" width="18" height="11" rx="2" fill="#67C2BE"/>
<path d="M7 14C7 14.8284 6.32843 15.5 5.5 15.5C4.67157 15.5 4 14.8284 4 14C4 13.1716 4.67157 12.5 5.5 12.5C6.32843 12.5 7 13.1716 7 14Z" fill="black"/>
<path d="M11.5 14C11.5 14.8284 10.8284 15.5 10 15.5C9.17157 15.5 8.5 14.8284 8.5 14C8.5 13.1716 9.17157 12.5 10 12.5C10.8284 12.5 11.5 13.1716 11.5 14Z" fill="black"/>
<path d="M16 14C16 14.8284 15.3284 15.5 14.5 15.5C13.6716 15.5 13 14.8284 13 14C13 13.1716 13.6716 12.5 14.5 12.5C15.3284 12.5 16 13.1716 16 14Z" fill="black"/>
<defs>
<linearGradient id="paint0_linear_1756_54140" x1="10" y1="8.5" x2="-0.60513" y2="33.6738" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFBA52"/>
<stop offset="1" stop-color="#B8873E"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB