Introduce dedicated merge handling for integration review merges: - Add src-tauri/src/integrations/merge.rs: implements merge_options (read provider repo settings), merge_payload (build provider-specific merge body) and a Tauri command get_integration_review_merge_options. Includes unit tests for behavior. - Wire merge module into integrations.rs and pass an optional merge_method into provider-specific review action functions (GitHub, GitLab, Gitea, Azure DevOps). run_integration_review_action now accepts an optional merge_method, validates it early, and includes provider-specific merge payloads when performing a merge. - Export the new command in src-tauri/src/main.rs so the frontend can request merge options. Frontend changes to support selecting a merge method before merging: - ConfirmDialog.svelte: add SelectMenu support and a select field to confirm requests. - ReviewCenter.svelte: fetch integration merge options, show a merge-method selector in the merge confirmation, and pass the chosen method to the review action. - Update types and git bindings to surface IntegrationMergeOptions / IntegrationMergeMethod and the getIntegrationReviewMergeOptions call (git.ts / types.ts changes staged). Effect: users can pick a merge method appropriate to the provider/project; the integration layer generates the correct API payload per provider. Tests added for merge logic.
274 lines
10 KiB
Svelte
274 lines
10 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* Generic confirmation dialog. Replaces window.confirm so confirmations use
|
|
* the app's own styling, translation and focus handling instead of a native,
|
|
* untranslated, event-blocking browser dialog.
|
|
*/
|
|
import { AlertTriangle, Check, LoaderCircle, Trash2, X } from "@lucide/svelte";
|
|
import SelectMenu from "./SelectMenu.svelte";
|
|
import { t } from "../i18n.svelte";
|
|
|
|
export interface ConfirmRequest {
|
|
/** Small label above the title. */
|
|
eyebrow?: string;
|
|
title: string;
|
|
/** Leading sentence explaining what happens. */
|
|
message: string;
|
|
/** Items the action applies to, rendered as a scrollable list. */
|
|
items?: string[];
|
|
/** Extra warning below the list. */
|
|
note?: string;
|
|
confirmLabel?: string;
|
|
cancelLabel?: string;
|
|
/** Optional opt-in, e.g. "delete anyway" (required) or "include untracked". */
|
|
checkbox?: { label: string; note?: string; required?: boolean; defaultChecked?: boolean };
|
|
/** Optional single-line input, e.g. a stash message. */
|
|
input?: { label: string; placeholder?: string; value?: string; optional?: boolean };
|
|
/** Destructive actions get the red confirm button and warning icon. */
|
|
danger?: boolean;
|
|
select?: { label: string; value: string; options: { value: string; label: string }[] };
|
|
}
|
|
|
|
interface Props {
|
|
request: ConfirmRequest;
|
|
isBusy?: boolean;
|
|
/** Carries the state of the optional checkbox and input. */
|
|
onConfirm: (result: { checked: boolean; value: string }) => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
let { request, isBusy = false, onConfirm, onCancel }: Props = $props();
|
|
|
|
const MAX_VISIBLE_ITEMS = 8;
|
|
|
|
let dialogElement = $state<HTMLElement | null>(null);
|
|
let confirmButton = $state<HTMLButtonElement | null>(null);
|
|
let danger = $derived(request.danger !== false);
|
|
let items = $derived(request.items ?? []);
|
|
let checked = $state(false);
|
|
let value = $state("");
|
|
let inputElement = $state<HTMLInputElement | null>(null);
|
|
let missingInput = $derived(Boolean(request.input) && request.input?.optional !== true && value.trim().length === 0);
|
|
let blocked = $derived((Boolean(request.checkbox?.required) && !checked) || missingInput || (Boolean(request.select) && !request.select?.options.some(option => option.value === value)));
|
|
|
|
$effect(() => {
|
|
// Start from the defaults again whenever a different confirmation is shown.
|
|
request.title;
|
|
checked = request.checkbox?.defaultChecked ?? false;
|
|
value = request.select?.value ?? request.input?.value ?? "";
|
|
});
|
|
|
|
$effect(() => {
|
|
// The input is the first thing to fill in when there is one.
|
|
if (inputElement) inputElement.select();
|
|
else confirmButton?.focus();
|
|
});
|
|
|
|
function submit() {
|
|
if (!isBusy && !blocked) onConfirm({ checked, value });
|
|
}
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if (event.key === "Escape") {
|
|
event.stopPropagation();
|
|
if (!isBusy) onCancel();
|
|
return;
|
|
}
|
|
|
|
if (event.key !== "Tab" || !dialogElement) return;
|
|
|
|
const focusable = [...dialogElement.querySelectorAll<HTMLElement>("button:not(:disabled)")];
|
|
if (focusable.length === 0) return;
|
|
|
|
const first = focusable[0];
|
|
const last = focusable[focusable.length - 1];
|
|
const active = document.activeElement;
|
|
|
|
if (event.shiftKey && active === first) {
|
|
event.preventDefault();
|
|
last.focus();
|
|
} else if (!event.shiftKey && active === last) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleKeydown} />
|
|
|
|
<div class="dialog-backdrop" role="presentation">
|
|
<div bind:this={dialogElement} class:danger class="dialog confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
|
|
<header class="dialog-header unified-dialog-header">
|
|
<span class="unified-dialog-icon" aria-hidden="true">
|
|
{#if danger}<Trash2 size={18} />{:else}<Check size={18} />{/if}
|
|
</span>
|
|
<div class="unified-dialog-text">
|
|
<span class="eyebrow">{request.eyebrow ?? t("confirm.eyebrow")}</span>
|
|
<p class="dialog-title" id="confirm-dialog-title">{request.title}</p>
|
|
</div>
|
|
<button class="btn-sm dialog-close" type="button" onclick={onCancel} disabled={isBusy} aria-label={t("common.close")}>
|
|
<X size={16} aria-hidden="true" />
|
|
</button>
|
|
</header>
|
|
|
|
<div class="discard-confirm-body">
|
|
<div class="discard-warning-icon" aria-hidden="true">
|
|
<AlertTriangle size={22} />
|
|
</div>
|
|
|
|
<div class="discard-confirm-copy">
|
|
<p class="confirm-lead">{request.message}</p>
|
|
|
|
{#if items.length > 0}
|
|
<ul class="discard-target-list">
|
|
{#each items.slice(0, MAX_VISIBLE_ITEMS) as item (item)}
|
|
<li><code class="discard-target" title={item}>{item}</code></li>
|
|
{/each}
|
|
{#if items.length > MAX_VISIBLE_ITEMS}
|
|
<li class="discard-target-more">{items.length - MAX_VISIBLE_ITEMS === 1 ? t("confirm.moreOne") : t("confirm.more", { count: items.length - MAX_VISIBLE_ITEMS })}</li>
|
|
{/if}
|
|
</ul>
|
|
{/if}
|
|
|
|
{#if request.input}
|
|
<label class="confirm-input">
|
|
<span>{request.input.label}</span>
|
|
<input
|
|
bind:this={inputElement}
|
|
bind:value
|
|
type="text"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
placeholder={request.input.placeholder ?? ""}
|
|
disabled={isBusy}
|
|
onkeydown={(event) => { if (event.key === "Enter") { event.preventDefault(); submit(); } }}
|
|
/>
|
|
</label>
|
|
{/if}
|
|
|
|
{#if request.select}
|
|
<div class="confirm-input">
|
|
<span>{request.select.label}</span>
|
|
<SelectMenu {value} options={request.select.options} ariaLabel={request.select.label} disabled={isBusy} onChange={(selected) => value = selected} />
|
|
</div>
|
|
{/if}
|
|
|
|
{#if request.checkbox}
|
|
<label class="confirm-check">
|
|
<input type="checkbox" bind:checked disabled={isBusy} />
|
|
<span>
|
|
<strong>{request.checkbox.label}</strong>
|
|
{#if request.checkbox.note}<small>{request.checkbox.note}</small>{/if}
|
|
</span>
|
|
</label>
|
|
{/if}
|
|
|
|
{#if request.note}
|
|
<p class="discard-warning-text">{request.note}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<footer class="discard-confirm-actions">
|
|
<button class="btn-secondary" type="button" onclick={onCancel} disabled={isBusy}>
|
|
{request.cancelLabel ?? t("common.cancel")}
|
|
</button>
|
|
<button
|
|
bind:this={confirmButton}
|
|
class={`confirm-action ${danger ? "btn-danger" : "btn-primary"}`}
|
|
type="button"
|
|
onclick={submit}
|
|
disabled={isBusy || blocked}
|
|
>
|
|
{#if isBusy}
|
|
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
|
{:else if danger}
|
|
<Trash2 size={15} aria-hidden="true" />
|
|
{:else}
|
|
<Check size={15} aria-hidden="true" />
|
|
{/if}
|
|
{request.confirmLabel ?? (danger ? t("common.delete") : t("common.confirm"))}
|
|
</button>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
/* Matches .discard-confirm-dialog / .branch-delete-dialog so every confirmation
|
|
in the app has the same size, chrome and rhythm. */
|
|
.confirm-dialog {
|
|
display: grid;
|
|
grid-template-rows: auto auto auto;
|
|
width: min(500px, calc(100vw - 32px));
|
|
height: auto;
|
|
max-height: calc(100vh - 32px);
|
|
overflow: auto;
|
|
}
|
|
.confirm-dialog.danger {
|
|
border-color: rgba(255, 90, 103, 0.22);
|
|
box-shadow: var(--app-dialog-shadow), 0 0 0 1px rgba(255, 90, 103, 0.04);
|
|
}
|
|
.confirm-dialog.danger .dialog-header {
|
|
background:
|
|
linear-gradient(90deg, rgba(255, 90, 103, 0.08), transparent 42%),
|
|
var(--app-dialog-chrome);
|
|
}
|
|
.confirm-dialog .discard-confirm-body { padding: 20px 18px 18px; }
|
|
.confirm-dialog .confirm-lead {
|
|
color: var(--color-ink);
|
|
font-weight: 600;
|
|
}
|
|
.confirm-dialog.danger .unified-dialog-icon {
|
|
border-color: rgba(255, 90, 103, 0.28);
|
|
color: var(--color-danger);
|
|
background: rgba(255, 90, 103, 0.09);
|
|
}
|
|
.confirm-dialog .discard-target-list { max-height: 148px; }
|
|
.confirm-dialog .discard-warning-text {
|
|
padding: 9px 10px;
|
|
border-left: 2px solid rgba(255, 90, 103, 0.55);
|
|
color: var(--color-danger);
|
|
background: rgba(255, 90, 103, 0.055);
|
|
font-size: 11.5px;
|
|
font-weight: 600;
|
|
}
|
|
.confirm-dialog:not(.danger) .discard-warning-text {
|
|
border-left-color: color-mix(in srgb, var(--color-accent) 55%, transparent);
|
|
color: var(--color-ink-muted);
|
|
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
|
|
}
|
|
.confirm-dialog:not(.danger) .discard-warning-icon {
|
|
border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border));
|
|
color: var(--color-accent);
|
|
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
|
|
}
|
|
.confirm-dialog .confirm-action { min-width: 116px; }
|
|
.confirm-dialog .confirm-input { display: grid; gap: 5px; }
|
|
.confirm-dialog .confirm-input span {
|
|
color: var(--color-ink-muted);
|
|
font-size: 11.5px;
|
|
font-weight: 650;
|
|
}
|
|
.confirm-dialog .confirm-input input { height: 32px; font-size: 12.5px; }
|
|
.confirm-dialog .confirm-check {
|
|
display: grid;
|
|
grid-template-columns: auto minmax(0, 1fr);
|
|
align-items: start;
|
|
gap: 9px;
|
|
padding: 9px 10px;
|
|
border: 1px solid rgba(255, 90, 103, 0.28);
|
|
border-radius: 8px;
|
|
background: rgba(255, 90, 103, 0.05);
|
|
cursor: pointer;
|
|
}
|
|
.confirm-dialog:not(.danger) .confirm-check {
|
|
border-color: var(--color-border-subtle);
|
|
background: color-mix(in srgb, var(--color-accent) 5%, transparent);
|
|
}
|
|
.confirm-dialog:not(.danger) .confirm-check input { accent-color: var(--color-accent); }
|
|
.confirm-dialog .confirm-check input { width: 15px; height: 15px; margin-top: 1px; accent-color: var(--color-danger); }
|
|
.confirm-dialog .confirm-check span { display: grid; gap: 2px; min-width: 0; }
|
|
.confirm-dialog .confirm-check strong { color: var(--color-ink); font-size: 12.5px; font-weight: 650; }
|
|
.confirm-dialog .confirm-check small { color: var(--color-ink-dim); font-size: 11.5px; }
|
|
</style>
|