ui : share one download confirmation dialog across chips and rows

Extract DialogConfirmDownload for the destructive download actions (cancel an
in-flight download, delete a downloaded model), keyed by a DownloadConfirmAction
enum, and mount a single instance per surface instead of one per quant chip or
download row. The chips and download rows now only signal intent; the discover
options panel and the selector list own the dialog.

Assisted-by: llama-ui:Qwen3.8-Flash-Next
This commit is contained in:
Aleksander Grygier
2026-09-04 20:07:38 +02:00
parent 2a822e4531
commit 52ba27f3e9
7 changed files with 132 additions and 53 deletions
@@ -0,0 +1,60 @@
<script lang="ts">
import DialogConfirmation from '$lib/components/app/dialogs/DialogConfirmation.svelte';
import { DownloadConfirmAction } from '$lib/enums';
import { modelsStore } from '$lib/stores';
interface Props {
/** Action being confirmed; drives the wording. */
action: DownloadConfirmAction;
/** `<repo>:<tag>` the action targets. */
repoWithTag: string;
onClose: () => void;
/** Overrides the default store removal; defaults to removing the entry. */
onConfirm?: (repoWithTag: string) => void;
open?: boolean;
}
let { action, onClose, onConfirm, open = true, repoWithTag }: Props = $props();
// Both actions resolve through the same store removal (cancelDownload drops a
// running download's partial files or a cached model's files); only the copy
// differs. One component so the discover chips and the selector rows word the
// destructive confirmations identically.
const COPY = {
[DownloadConfirmAction.CANCEL]: {
cancelText: 'Keep downloading',
confirmText: 'Cancel download',
description: (name: string) =>
`This stops the download of ${name} and removes the partial files. Pause it instead to keep the progress.`,
title: 'Cancel download'
},
[DownloadConfirmAction.DELETE]: {
cancelText: 'Keep model',
confirmText: 'Delete',
description: (name: string) =>
`This permanently removes ${name} from disk. You can download it again later.`,
title: 'Delete model'
}
} as const;
let copy = $derived(COPY[action]);
let displayName = $derived(modelsStore.toDisplayName(repoWithTag));
function confirm() {
if (onConfirm) onConfirm(repoWithTag);
else void modelsStore.status.cancelDownload(repoWithTag);
onClose();
}
</script>
<DialogConfirmation
cancelText={copy.cancelText}
confirmText={copy.confirmText}
description={copy.description(displayName)}
onCancel={onClose}
onConfirm={confirm}
{open}
title={copy.title}
variant="destructive"
/>
@@ -108,6 +108,17 @@ export { default as DialogExportSettings } from './DialogExportSettings.svelte';
*/
export { default as DialogConfirmation } from './DialogConfirmation.svelte';
/**
* **DialogConfirmDownload** - Confirm a destructive download action
*
* Shared confirmation for stopping/cancelling an in-flight download or deleting
* a downloaded model, used by the discover quant chips and the model selector's
* download rows so both word the action identically. Owns the copy and the
* default store removal; render one instance per surface keyed by the acted-on
* repo:tag.
*/
export { default as DialogConfirmDownload } from './DialogConfirmDownload.svelte';
/**
* **DialogConversationRename** - Rename a conversation
*
@@ -3,7 +3,6 @@
import ModelsDiscoverAvatar from '../discover/ModelsDiscoverAvatar.svelte';
import { Loader2, Pause, Play, X } from '@lucide/svelte';
import { ModelId } from '$lib/components/app';
import DialogConfirmation from '$lib/components/app/dialogs/DialogConfirmation.svelte';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { ModelDownloadProgress } from '$lib/types';
@@ -11,12 +10,14 @@
interface Props {
/** One entry from the status feed: an in-flight or paused download. */
entry: { isPaused: boolean; progress: ModelDownloadProgress | null; repoWithTag: string };
/**
* Ask the list to confirm cancelling this download. The row owns no dialog;
* the list renders a single shared confirmation.
*/
onRequestCancel?: (repoWithTag: string) => void;
}
let { entry }: Props = $props();
// cancel confirmation state
let confirmCancelOpen = $state(false);
let { entry, onRequestCancel }: Props = $props();
let percent = $derived(
entry.progress && entry.progress.totalBytes > 0
@@ -105,7 +106,7 @@
<button
aria-label="Cancel downloading"
class="inline-flex h-4 w-4 shrink-0 scale-75 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/70 opacity-0 transition-[opacity,transform,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-destructive group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
onclick={() => (confirmCancelOpen = true)}
onclick={() => onRequestCancel?.(entry.repoWithTag)}
type="button"
>
<X class="h-4 w-4" />
@@ -119,18 +120,3 @@
/>
{/if}
</div>
<DialogConfirmation
cancelText="Keep downloading"
confirmText="Cancel download"
description={`This stops the download of ${modelsStore.toDisplayName(entry.repoWithTag)} and removes the partial files. Pause it instead to keep the progress.`}
onCancel={() => (confirmCancelOpen = false)}
onConfirm={() => {
confirmCancelOpen = false;
void modelsStore.status.cancelDownload(entry.repoWithTag);
}}
open={confirmCancelOpen}
title="Cancel download"
variant="destructive"
/>
@@ -1,7 +1,9 @@
<script lang="ts">
import ModelsSelectorDownloadItem from './ModelsSelectorDownloadItem.svelte';
import { ModelsSelectorOption } from '$lib/components/app';
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/navigation/utils';
import { DownloadConfirmAction } from '$lib/enums';
import { modelsStore } from '$lib/stores';
interface Props {
@@ -27,6 +29,17 @@
/** In-flight / paused downloads, tracked by the status feed. */
let downloadEntries = $derived(modelsStore.status.downloadEntries());
// Cancel is confirmed once for the whole list rather than per download row, so
// a single dialog instance is mounted however many downloads are in flight.
// The target is kept while the dialog closes so its copy stays rendered.
let pendingCancel = $state('');
let cancelOpen = $state(false);
function requestCancel(repoWithTag: string) {
pendingCancel = repoWithTag;
cancelOpen = true;
}
</script>
{#snippet defaultOption(item: ModelItem, _hideOrgName: boolean)}
@@ -60,7 +73,7 @@
<p class={sectionHeaderClass}>Download in progress</p>
{#each downloadEntries as entry (entry.repoWithTag)}
<ModelsSelectorDownloadItem {entry} />
<ModelsSelectorDownloadItem {entry} onRequestCancel={requestCancel} />
{/each}
{/if}
@@ -81,3 +94,10 @@
{/each}
{/each}
{/if}
<DialogConfirmDownload
action={DownloadConfirmAction.CANCEL}
onClose={() => (cancelOpen = false)}
open={cancelOpen}
repoWithTag={pendingCancel}
/>
@@ -2,8 +2,8 @@
import { classify, labelFor } from './download-options.utils';
import ModelsDiscoverDetailsDownloadOptionsDownloadCommand from './ModelsDiscoverDetailsDownloadOptionsDownloadCommand.svelte';
import ModelsDiscoverDetailsDownloadOptionsRow from './ModelsDiscoverDetailsDownloadOptionsRow.svelte';
import DialogConfirmation from '$lib/components/app/dialogs/DialogConfirmation.svelte';
import { SelectableFileKind } from '$lib/enums';
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
import { DownloadConfirmAction, SelectableFileKind } from '$lib/enums';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { BitDepthRow, DownloadEntryState, QuantOption, SelectableFile } from '$lib/types';
@@ -24,26 +24,25 @@
let { bitDepthRows, getDownloadState, modelId }: Props = $props();
// Destructive chip actions (delete a downloaded model, cancel a download) are
// confirmed here rather than inside each chip: one pair of dialogs owned by
// the options panel, keyed by the repo+tag the user acted on.
let pending: { action: 'cancel' | 'delete'; repoWithTag: string } | null = $state(null);
// confirmed here rather than inside each chip: a single shared dialog owned by
// the options panel, keyed by the repo+tag the user acted on, so one dialog is
// mounted for the whole panel instead of one per chip.
// The acted-on target is kept after closing so the copy stays rendered through
// the dialog's close transition.
let pending: { action: DownloadConfirmAction; repoWithTag: string } = $state({
action: DownloadConfirmAction.CANCEL,
repoWithTag: ''
});
let confirmOpen = $state(false);
function requestCancel(repoWithTag: string) {
pending = { action: 'cancel', repoWithTag };
pending = { action: DownloadConfirmAction.CANCEL, repoWithTag };
confirmOpen = true;
}
function requestDelete(repoWithTag: string) {
pending = { action: 'delete', repoWithTag };
}
function closePending() {
pending = null;
}
function confirmPending() {
if (pending) void modelsStore.status.cancelDownload(pending.repoWithTag);
pending = null;
pending = { action: DownloadConfirmAction.DELETE, repoWithTag };
confirmOpen = true;
}
function stateFor(repoWithTag: string, filePath: string, isSidecar: boolean): DownloadEntryState {
@@ -157,16 +156,9 @@
</section>
{/if}
{#if pending}
<DialogConfirmation
confirmText={pending.action === 'delete' ? 'Delete' : 'Cancel download'}
description={pending.action === 'delete'
? `This permanently removes ${modelsStore.toDisplayName(pending.repoWithTag)} from disk. You can download it again later.`
: `This stops the download of ${modelsStore.toDisplayName(pending.repoWithTag)} and removes the partial files. Pause it instead to keep the progress.`}
onCancel={closePending}
onConfirm={confirmPending}
open
title={pending.action === 'delete' ? 'Delete model' : 'Cancel download'}
variant="destructive"
/>
{/if}
<DialogConfirmDownload
action={pending.action}
onClose={() => (confirmOpen = false)}
open={confirmOpen}
repoWithTag={pending.repoWithTag}
/>
+1 -1
View File
@@ -77,7 +77,7 @@ export {
SelectableFileKind
} from './model.enums';
export { DownloadStopRequest } from './model.enums';
export { DownloadConfirmAction, DownloadStopRequest } from './model.enums';
export { ServerRole, ServerModelStatus, ServerModelsSseEventType } from './server.enums';
+10
View File
@@ -56,3 +56,13 @@ export enum DownloadStopRequest {
CANCEL = 'cancel',
PAUSE = 'pause'
}
/**
* Destructive download action the user is asked to confirm: stop and discard an
* in-flight download, or delete an already-downloaded model from disk. Both
* resolve through the same store removal call, differing only in the copy.
*/
export enum DownloadConfirmAction {
CANCEL = 'cancel',
DELETE = 'delete'
}