Refactor prompt service to be easier to use

This commit is contained in:
Mattias Granlund 2024-03-21 12:01:13 +01:00
parent 33e861af42
commit 0acb4c106f
8 changed files with 277 additions and 197 deletions

View File

@ -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();
};
}
}

View File

@ -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

View File

@ -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);

View File

@ -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>

View File

@ -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 {

View 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>

View File

@ -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];
}

View File

@ -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 {