mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-24 10:02:26 +03:00
Refactor prompt service to be easier to use
This commit is contained in:
parent
33e861af42
commit
0acb4c106f
@ -1,6 +1,20 @@
|
||||
import { listen } from '$lib/backend/ipc';
|
||||
import { invoke } from '$lib/backend/ipc';
|
||||
import { Subject } from 'rxjs';
|
||||
import { observableToStore } from '$lib/rxjs/store';
|
||||
import {
|
||||
Observable,
|
||||
Subject,
|
||||
catchError,
|
||||
filter,
|
||||
merge,
|
||||
mergeWith,
|
||||
of,
|
||||
switchMap,
|
||||
tap,
|
||||
throwError,
|
||||
timeout
|
||||
} from 'rxjs';
|
||||
import { writable, type Readable } from 'svelte/store';
|
||||
|
||||
export type SystemPrompt = {
|
||||
id: string;
|
||||
@ -10,7 +24,7 @@ export type SystemPrompt = {
|
||||
branch_id?: string;
|
||||
action?: string;
|
||||
};
|
||||
canceled?: boolean;
|
||||
handled?: boolean;
|
||||
};
|
||||
|
||||
type PromptResponse = {
|
||||
@ -18,32 +32,114 @@ type PromptResponse = {
|
||||
response: string | null;
|
||||
};
|
||||
|
||||
type FilterParams = {
|
||||
// This can be e.g. push, fetch, auto etc
|
||||
action?: string | undefined;
|
||||
// Filter to a specific `BranchLane`
|
||||
branchId?: string | undefined;
|
||||
// Time until auto closing prompt
|
||||
timeoutMs?: number | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* This service is used for handling CLI prompts from the back end.
|
||||
*/
|
||||
export class PromptService {
|
||||
prompt$ = new Subject<SystemPrompt>();
|
||||
// Used to reset the stream after submit/cancel
|
||||
private reset = new Subject<undefined | SystemPrompt>();
|
||||
|
||||
private unlisten = listen<SystemPrompt>('git_prompt', async (e) => {
|
||||
// You can send an action token to e.g. `fetch_from_target` and it will be echoed in
|
||||
// these events. The action `auto` is used by the `BaseBranchService` so we can not
|
||||
// respond to them.
|
||||
if (e.payload.context?.action != 'auto') {
|
||||
this.prompt$.next(e.payload);
|
||||
} else {
|
||||
// Always cancel actions that are marked "auto", e.g. periodic sync
|
||||
await this.cancel(e.payload.id);
|
||||
}
|
||||
});
|
||||
// This subject is used to reset timeouts
|
||||
private submission = new Subject<SystemPrompt | undefined>();
|
||||
|
||||
constructor() {}
|
||||
// Base observable that opens Tauri subscription on first subscriber, and tears
|
||||
// down automatically when the last subscriber disconnects.
|
||||
private promptStream = new Observable<SystemPrompt | undefined>((subscriber) => {
|
||||
this.unlistenTauri = this.listenTauri((prompt) => subscriber.next(prompt));
|
||||
return () => {
|
||||
if (this.unlistenTauri) this.unlistenTauri();
|
||||
};
|
||||
}).pipe(
|
||||
tap(() => {
|
||||
this.updatedAt.set(new Date());
|
||||
}),
|
||||
mergeWith(this.reset)
|
||||
);
|
||||
|
||||
// Feeds user supplied string as input to askpass
|
||||
async respond(payload: PromptResponse) {
|
||||
this.reset.next(undefined);
|
||||
return await invoke('submit_prompt_response', payload);
|
||||
}
|
||||
|
||||
// Cancels the executable input prompt
|
||||
async cancel(id: string) {
|
||||
this.reset.next(undefined);
|
||||
return await invoke('submit_prompt_response', { id: id, response: null });
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.unlisten();
|
||||
/**
|
||||
* When you first call this function it creates two stores out of one observable,
|
||||
* and when these stores are _first_ subscribed to we start listening to the backend
|
||||
* for prompt events. When the last of all stores created with this function unsubscribes
|
||||
* we also stop listening for events from tauri.
|
||||
*/
|
||||
filter({
|
||||
action = undefined,
|
||||
branchId = undefined,
|
||||
timeoutMs = 120 * 1000
|
||||
}: FilterParams): [Readable<SystemPrompt | undefined>, Readable<any>] {
|
||||
return observableToStore<SystemPrompt | undefined>(
|
||||
this.promptStream.pipe(
|
||||
filter((prompt) => {
|
||||
if (!prompt) return true;
|
||||
const promptBranchId = prompt?.context?.branch_id;
|
||||
const promptAction = prompt?.context?.action;
|
||||
return !!(
|
||||
(promptAction && promptAction == action) ||
|
||||
(promptBranchId && promptBranchId == branchId)
|
||||
);
|
||||
}),
|
||||
switchMap((prompt) => {
|
||||
if (!prompt) return of(undefined);
|
||||
return merge(
|
||||
of(prompt),
|
||||
this.submission.pipe(
|
||||
timeout(timeoutMs),
|
||||
catchError((err) => {
|
||||
if (prompt) this.cancel(prompt.id);
|
||||
return throwError(() => err);
|
||||
})
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// This can e.g. be used to disable interval polling
|
||||
readonly updatedAt = writable<Date | undefined>();
|
||||
|
||||
// Stop tauri subscription when last store subscriber unsubscribes
|
||||
unlistenTauri: (() => Promise<void>) | undefined = undefined;
|
||||
|
||||
// This is how we are notified of input prompts from the backend
|
||||
private listenTauri(next: (event: SystemPrompt) => void): () => Promise<void> {
|
||||
const unsubscribe = listen<SystemPrompt>('git_prompt', async (e) => {
|
||||
this.updatedAt.set(new Date());
|
||||
// You can send an action token to e.g. `fetch_from_target` and it will be echoed in
|
||||
// these events. The action `auto` is used by the `BaseBranchService` so we can not
|
||||
// respond to them.
|
||||
if (e.payload.context?.action == 'auto') {
|
||||
// Always cancel actions that are marked "auto", e.g. periodic sync
|
||||
await this.cancel(e.payload.id);
|
||||
// Note: ingore store update to avoid other side-effects
|
||||
} else {
|
||||
next(e.payload);
|
||||
}
|
||||
});
|
||||
return async () => {
|
||||
// Called after service stops listening for events
|
||||
await unsubscribe();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import PassphraseBox from './PassphraseBox.svelte';
|
||||
import PushButton, { BranchAction } from './PushButton.svelte';
|
||||
import { PromptService, type SystemPrompt } from '$lib/backend/prompt';
|
||||
import { PromptService } from '$lib/backend/prompt';
|
||||
import { BranchService } from '$lib/branches/service';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { GitHubService } from '$lib/github/service';
|
||||
@ -23,17 +23,16 @@
|
||||
const promptService = getContextByClass(PromptService);
|
||||
const baseBranch = getContextStoreByClass(BaseBranch);
|
||||
|
||||
const [prompt, promptError] = promptService.filter({
|
||||
branchId: branch.id,
|
||||
timeoutMs: 30000
|
||||
});
|
||||
|
||||
$: githubServiceState$ = githubService.getState(branch.id);
|
||||
$: pr$ = githubService.getPr$(branch.upstreamName);
|
||||
|
||||
$: prompt$ = promptService.prompt$;
|
||||
$: if ($prompt$) showPrompt($prompt$);
|
||||
|
||||
let prompt: SystemPrompt | undefined;
|
||||
let isPushing: boolean;
|
||||
let isMerging: boolean;
|
||||
let passphrase = '';
|
||||
let isSubmitting = false;
|
||||
|
||||
interface CreatePrOpts {
|
||||
draft: boolean;
|
||||
@ -68,30 +67,12 @@
|
||||
isPushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showPrompt(newPrompt: SystemPrompt) {
|
||||
if (newPrompt.context?.branch_id == branch.id) prompt = newPrompt;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !isUnapplied && type != 'integrated'}
|
||||
<div class="actions" class:hasCommits>
|
||||
{#if prompt && type == 'local'}
|
||||
<PassphraseBox
|
||||
bind:value={passphrase}
|
||||
{prompt}
|
||||
{isSubmitting}
|
||||
on:submit={async () => {
|
||||
if (!prompt) return;
|
||||
isSubmitting = true;
|
||||
await promptService.respond({ id: prompt.id, response: passphrase });
|
||||
isSubmitting = false;
|
||||
prompt = undefined;
|
||||
}}
|
||||
on:cancel={async () => {
|
||||
prompt = undefined;
|
||||
}}
|
||||
/>
|
||||
{#if $prompt && type == 'local'}
|
||||
<PassphraseBox prompt={$prompt} error={$promptError} />
|
||||
{:else if githubService.isEnabled && (type == 'local' || type == 'remote')}
|
||||
<PushButton
|
||||
wide
|
||||
|
@ -28,7 +28,7 @@
|
||||
{#if icon}
|
||||
<Icon name={icon} />
|
||||
{/if}
|
||||
<h2 class="text-base-13 text-semibold" title={hoverText}>
|
||||
<h2 class="text-base-14 text-semibold" title={hoverText}>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
@ -53,7 +53,6 @@
|
||||
|
||||
<style lang="postcss">
|
||||
.modal__header {
|
||||
color: var(--clr-theme-scale-ntrl-50);
|
||||
display: flex;
|
||||
padding: var(--size-16);
|
||||
gap: var(--size-8);
|
||||
|
@ -1,86 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Button from './Button.svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
import TextBox from './TextBox.svelte';
|
||||
import { PromptService, type SystemPrompt } from '$lib/backend/prompt';
|
||||
import { getContextByClass } from '$lib/utils/context';
|
||||
|
||||
const promptService = getContextByClass(PromptService);
|
||||
const prompt$ = promptService.prompt$;
|
||||
|
||||
let modal: Modal;
|
||||
let prompt: SystemPrompt | undefined;
|
||||
let isSubmitting = false;
|
||||
let value = '';
|
||||
|
||||
$: if ($prompt$) showPrompt($prompt$);
|
||||
|
||||
function showPrompt(newPrompt: SystemPrompt) {
|
||||
if (newPrompt.context?.action == 'modal' || newPrompt.context?.branch_id === null) {
|
||||
prompt = newPrompt;
|
||||
modal.show();
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!prompt) return;
|
||||
isSubmitting = true;
|
||||
try {
|
||||
await promptService.respond({ id: prompt.id, response: value });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (!prompt) return;
|
||||
try {
|
||||
await promptService.cancel(prompt.id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
modal.close();
|
||||
prompt = undefined;
|
||||
value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:this={modal} width="small" title={prompt?.prompt}>
|
||||
<TextBox
|
||||
focus
|
||||
type="password"
|
||||
bind:value
|
||||
on:keydown={(e) => {
|
||||
if (e.detail.key == 'Enter') submit();
|
||||
}}
|
||||
/>
|
||||
|
||||
<svelte:fragment slot="controls">
|
||||
<Button
|
||||
color="neutral"
|
||||
disabled={isSubmitting}
|
||||
kind="outlined"
|
||||
on:click={() => {
|
||||
cancel();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
grow
|
||||
on:click={async () => await submit()}
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
@ -3,63 +3,63 @@
|
||||
import TextBox from './TextBox.svelte';
|
||||
import { PromptService, type SystemPrompt } from '$lib/backend/prompt';
|
||||
import { getContextByClass } from '$lib/utils/context';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import * as toasts from '$lib/utils/toasts';
|
||||
|
||||
export let value: string = '';
|
||||
export let submitDisabled: boolean = false;
|
||||
export let isSubmitting: boolean = true;
|
||||
export let prompt: SystemPrompt | undefined;
|
||||
export let error: any;
|
||||
export let value: string = '';
|
||||
|
||||
let submitDisabled: boolean = false;
|
||||
let isSubmitting = false;
|
||||
|
||||
const promptService = getContextByClass(PromptService);
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
change: string;
|
||||
input: string;
|
||||
submit: string;
|
||||
cancel: void;
|
||||
}>();
|
||||
async function submit() {
|
||||
if (!prompt) return;
|
||||
isSubmitting = true;
|
||||
await promptService.respond({ id: prompt.id, response: value });
|
||||
isSubmitting = false;
|
||||
}
|
||||
|
||||
if (error) toasts.error(error);
|
||||
</script>
|
||||
|
||||
<div class="passbox">
|
||||
<span class="text-base-body-11 passbox__helper-text">
|
||||
{prompt?.prompt}
|
||||
</span>
|
||||
<TextBox
|
||||
focus
|
||||
type="password"
|
||||
bind:value
|
||||
on:change={(e) => dispatch('change', e.detail)}
|
||||
on:input={(e) => dispatch('input', e.detail)}
|
||||
on:keydown={(e) => {
|
||||
if (e.detail.key === 'Enter') dispatch('submit', value);
|
||||
}}
|
||||
/>
|
||||
<div class="passbox__actions">
|
||||
<Button
|
||||
color="neutral"
|
||||
disabled={isSubmitting}
|
||||
kind="outlined"
|
||||
on:click={async () => {
|
||||
if (!prompt) return;
|
||||
await promptService.cancel(prompt.id);
|
||||
prompt = undefined;
|
||||
dispatch('cancel');
|
||||
{#if prompt}
|
||||
<div class="passbox">
|
||||
<span class="text-base-body-11 passbox__helper-text">
|
||||
{prompt?.prompt}
|
||||
</span>
|
||||
<TextBox
|
||||
focus
|
||||
type="password"
|
||||
bind:value
|
||||
on:keydown={(e) => {
|
||||
if (e.detail.key === 'Enter') submit();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
grow
|
||||
on:click={() => {
|
||||
dispatch('submit', value);
|
||||
}}
|
||||
disabled={submitDisabled || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
/>
|
||||
<div class="passbox__actions">
|
||||
<Button
|
||||
color="neutral"
|
||||
disabled={isSubmitting}
|
||||
kind="outlined"
|
||||
on:click={async () => {
|
||||
if (!prompt) return;
|
||||
await promptService.cancel(prompt.id);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
grow
|
||||
on:click={async () => await submit()}
|
||||
disabled={submitDisabled || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.passbox {
|
||||
|
96
gitbutler-ui/src/lib/components/PromptModal.svelte
Normal file
96
gitbutler-ui/src/lib/components/PromptModal.svelte
Normal file
@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import Button from './Button.svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
import TextBox from './TextBox.svelte';
|
||||
import { PromptService } from '$lib/backend/prompt';
|
||||
import { getContextByClass } from '$lib/utils/context';
|
||||
import { TimeoutError } from 'rxjs';
|
||||
|
||||
const promptService = getContextByClass(PromptService);
|
||||
const [prompt, error] = promptService.filter({ action: 'modal', timeoutMs: 30000 });
|
||||
|
||||
let value = '';
|
||||
let modal: Modal;
|
||||
let loading = false;
|
||||
|
||||
$: if ($prompt) {
|
||||
modal?.show();
|
||||
}
|
||||
|
||||
// TODO: Notify user we are auto closing the modal
|
||||
$: if ($error) {
|
||||
console.error($error);
|
||||
if ($error instanceof TimeoutError) {
|
||||
setTimeout(() => modal.close(), 10000);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!$prompt) return;
|
||||
loading = true;
|
||||
try {
|
||||
await promptService.respond({ id: $prompt.id, response: value });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
try {
|
||||
if ($prompt) await promptService.cancel($prompt.id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
console.log('clearing');
|
||||
modal.close();
|
||||
value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:this={modal} width="small" title="Git fetch requires input">
|
||||
<div class="message">
|
||||
{#if $error}
|
||||
{$error.message}
|
||||
{:else}
|
||||
<code>{$prompt?.prompt}</code>
|
||||
{/if}
|
||||
</div>
|
||||
<TextBox
|
||||
focus
|
||||
type="password"
|
||||
bind:value
|
||||
on:keydown={(e) => {
|
||||
if (e.detail.key == 'Enter') submit();
|
||||
}}
|
||||
/>
|
||||
|
||||
<svelte:fragment slot="controls">
|
||||
<Button
|
||||
color="neutral"
|
||||
disabled={loading}
|
||||
kind="outlined"
|
||||
on:click={() => {
|
||||
cancel();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button grow disabled={!!$error || loading} on:click={async () => await submit()} {loading}>
|
||||
Submit
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
<style lang="postcss">
|
||||
.message {
|
||||
padding-bottom: var(--size-12);
|
||||
}
|
||||
</style>
|
@ -1,4 +1,4 @@
|
||||
import { Observable, Subscription, catchError, of } from 'rxjs';
|
||||
import { Observable, Subscription, catchError } from 'rxjs';
|
||||
import { writable, type Readable, type Writable } from 'svelte/store';
|
||||
|
||||
export function storeToObservable<T>(svelteStore: Writable<T> | Readable<T>): Observable<T> {
|
||||
@ -17,12 +17,15 @@ export function observableToStore<T>(
|
||||
// This runs when the store is first subscribed to
|
||||
subscription = observable
|
||||
.pipe(
|
||||
catchError((e: any) => {
|
||||
error.set(e.message);
|
||||
return of(undefined);
|
||||
catchError((e: any, caught) => {
|
||||
store.set(undefined);
|
||||
error.set(e);
|
||||
// We reconnect with the caught stream to keep going
|
||||
return caught;
|
||||
})
|
||||
)
|
||||
.subscribe((item) => {
|
||||
error.set(undefined);
|
||||
store.set(item);
|
||||
});
|
||||
unsubscribe = subscription.unsubscribe;
|
||||
@ -35,7 +38,7 @@ export function observableToStore<T>(
|
||||
}, 0);
|
||||
};
|
||||
});
|
||||
const error = writable<string>();
|
||||
const error = writable<any>();
|
||||
|
||||
return [store, error];
|
||||
}
|
||||
|
@ -4,10 +4,10 @@
|
||||
import { AIService } from '$lib/backend/aiService';
|
||||
import { GitConfigService } from '$lib/backend/gitConfigService';
|
||||
import { ProjectService } from '$lib/backend/projects';
|
||||
import { PromptService, type SystemPrompt } from '$lib/backend/prompt';
|
||||
import { PromptService } from '$lib/backend/prompt';
|
||||
import { UpdaterService } from '$lib/backend/updater';
|
||||
import AppUpdater from '$lib/components/AppUpdater.svelte';
|
||||
import ModalPrompt from '$lib/components/ModalPrompt.svelte';
|
||||
import PromptModal from '$lib/components/PromptModal.svelte';
|
||||
import ShareIssueModal from '$lib/components/ShareIssueModal.svelte';
|
||||
import { GitHubService } from '$lib/github/service';
|
||||
import ToastController from '$lib/notifications/ToastController.svelte';
|
||||
@ -43,15 +43,6 @@
|
||||
$: document.documentElement.style.fontSize = zoom + 'rem';
|
||||
$: userSettings.update((s) => ({ ...s, zoom: zoom }));
|
||||
|
||||
$: prompt$ = promptService.prompt$;
|
||||
if ($prompt$) processPrompt($prompt$);
|
||||
|
||||
function processPrompt(newPrompt: SystemPrompt) {
|
||||
if (newPrompt.context?.action == 'auto') {
|
||||
promptService.cancel(newPrompt.id);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
return unsubscribe(
|
||||
events.on('goto', (path: string) => goto(path)),
|
||||
@ -78,7 +69,7 @@
|
||||
<ShareIssueModal bind:this={shareIssueModal} {cloud} />
|
||||
<ToastController />
|
||||
<AppUpdater />
|
||||
<ModalPrompt />
|
||||
<PromptModal />
|
||||
|
||||
<style lang="postcss">
|
||||
.app-root {
|
||||
|
Loading…
Reference in New Issue
Block a user