Introduce a reusable SelectMenu component and replace native select controls across the UI. This centralizes select behavior and styling, enabling grouped options, placeholders, and a consistent popup interaction. Add comprehensive CSS and light-theme tweaks, and update rebase action styling to integrate the new control. - Add a unified SelectMenu component and wire change handlers. - Implement .select-menu styles, popup behavior, and theme overrides. - Replace ad-hoc native selects in dialogs and rebase UI for consistency.
301 lines
14 KiB
Svelte
301 lines
14 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
Download,
|
|
GitCommitHorizontal,
|
|
Info,
|
|
LoaderCircle,
|
|
Save,
|
|
StickyNote,
|
|
Trash2,
|
|
Upload,
|
|
X,
|
|
} from "@lucide/svelte";
|
|
import type { AppLanguage, GitCommit, GitRemote } from "../types";
|
|
import SelectMenu from "./SelectMenu.svelte";
|
|
|
|
interface Props {
|
|
commit: GitCommit;
|
|
note: string;
|
|
remotes: GitRemote[];
|
|
preferredRemote: string;
|
|
language: AppLanguage;
|
|
isLoading: boolean;
|
|
isBusy: boolean;
|
|
error: string;
|
|
status: string;
|
|
onSave: (note: string) => void | Promise<void>;
|
|
onDelete: () => void | Promise<void>;
|
|
onFetch: (remote: string) => void | Promise<void>;
|
|
onPush: (remote: string) => void | Promise<void>;
|
|
onClose: () => void;
|
|
}
|
|
|
|
let {
|
|
commit,
|
|
note = "",
|
|
remotes = [],
|
|
preferredRemote = "",
|
|
language = "en",
|
|
isLoading = false,
|
|
isBusy = false,
|
|
error = "",
|
|
status = "",
|
|
onSave = () => {},
|
|
onDelete = () => {},
|
|
onFetch = () => {},
|
|
onPush = () => {},
|
|
onClose = () => {},
|
|
}: Props = $props();
|
|
|
|
let draft = $state("");
|
|
let selectedRemote = $state("");
|
|
let deleteConfirmOpen = $state(false);
|
|
let lastLoadedKey = $state("");
|
|
let hasChanges = $derived(draft !== note);
|
|
let canSave = $derived(!isLoading && !isBusy && draft.trim().length > 0 && hasChanges);
|
|
|
|
const text = $derived(language === "de" ? {
|
|
eyebrow: "Interne Git-Notiz",
|
|
title: "Commit-Notiz",
|
|
commit: "Commit",
|
|
explanation: "Die Notiz wird separat unter refs/notes/commits gespeichert. Hash und Commit-Historie bleiben unverändert.",
|
|
label: "Notiz",
|
|
placeholder: "Zum Beispiel Review-Hinweise, Ticket-Kontext, Build-ID oder Freigabestatus …",
|
|
loading: "Notiz wird geladen …",
|
|
syncTitle: "Mit Remote synchronisieren",
|
|
syncHelp: "Git Notes reisen nicht automatisch mit Branches. Lade sie gezielt vom Remote oder sende deine lokalen Notizen dorthin.",
|
|
noRemotes: "Für dieses Repository ist kein Remote eingerichtet.",
|
|
remote: "Remote",
|
|
fetch: "Vom Remote laden",
|
|
push: "Zum Remote senden",
|
|
delete: "Notiz löschen",
|
|
deleteQuestion: "Diese Notiz wirklich löschen?",
|
|
deleteConfirm: "Ja, löschen",
|
|
cancel: "Abbrechen",
|
|
close: "Schließen",
|
|
save: "Notiz speichern",
|
|
characters: "Zeichen",
|
|
} : {
|
|
eyebrow: "Internal Git note",
|
|
title: "Commit note",
|
|
commit: "Commit",
|
|
explanation: "The note is stored separately under refs/notes/commits. The commit hash and history stay unchanged.",
|
|
label: "Note",
|
|
placeholder: "For example review findings, ticket context, a build ID, or approval status…",
|
|
loading: "Loading note…",
|
|
syncTitle: "Sync with a remote",
|
|
syncHelp: "Git Notes do not travel with branches automatically. Fetch them explicitly from a remote or push your local notes there.",
|
|
noRemotes: "No remote is configured for this repository.",
|
|
remote: "Remote",
|
|
fetch: "Fetch from remote",
|
|
push: "Push to remote",
|
|
delete: "Delete note",
|
|
deleteQuestion: "Delete this note?",
|
|
deleteConfirm: "Yes, delete",
|
|
cancel: "Cancel",
|
|
close: "Close",
|
|
save: "Save note",
|
|
characters: "characters",
|
|
});
|
|
|
|
$effect(() => {
|
|
const loadedKey = `${commit.hash}:${note}`;
|
|
if (loadedKey === lastLoadedKey) return;
|
|
draft = note;
|
|
lastLoadedKey = loadedKey;
|
|
deleteConfirmOpen = false;
|
|
});
|
|
|
|
$effect(() => {
|
|
if (selectedRemote && remotes.some((remote) => remote.name === selectedRemote)) return;
|
|
selectedRemote = remotes.some((remote) => remote.name === preferredRemote)
|
|
? preferredRemote
|
|
: (remotes[0]?.name ?? "");
|
|
});
|
|
|
|
function submit(event: SubmitEvent) {
|
|
event.preventDefault();
|
|
if (!canSave) return;
|
|
void onSave(draft);
|
|
}
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if (event.key === "Escape" && !isBusy) onClose();
|
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
|
event.preventDefault();
|
|
if (canSave) void onSave(draft);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleKeydown} />
|
|
|
|
<div class="dialog-backdrop app-chrome-backdrop commit-note-backdrop" role="presentation">
|
|
<div class="commit-note-dialog" role="dialog" aria-modal="true" aria-labelledby="commit-note-title" tabindex="-1">
|
|
<header class="commit-note-head">
|
|
<div class="commit-note-heading">
|
|
<span class="commit-note-icon"><StickyNote size={20} aria-hidden="true" /></span>
|
|
<div>
|
|
<span class="eyebrow">{text.eyebrow}</span>
|
|
<h2 id="commit-note-title">{text.title}</h2>
|
|
</div>
|
|
</div>
|
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={text.close} aria-label={text.close}>
|
|
<X size={18} aria-hidden="true" />
|
|
</button>
|
|
</header>
|
|
|
|
<div class="commit-note-body">
|
|
<div class="commit-note-target">
|
|
<span class="commit-target-icon"><GitCommitHorizontal size={18} aria-hidden="true" /></span>
|
|
<div>
|
|
<span>{text.commit} <code>{commit.short_hash}</code></span>
|
|
<strong title={commit.summary}>{commit.summary}</strong>
|
|
<small>{commit.author_name} · {new Date(commit.date).toLocaleString()}</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="commit-note-info">
|
|
<Info size={16} aria-hidden="true" />
|
|
<span>{text.explanation}</span>
|
|
</div>
|
|
|
|
{#if error}
|
|
<div class="commit-note-message error" role="alert">{error}</div>
|
|
{:else if status}
|
|
<div class="commit-note-message success" role="status">{status}</div>
|
|
{/if}
|
|
|
|
<form class="commit-note-form" onsubmit={submit}>
|
|
<label for="commit-note-editor">
|
|
<span>{text.label}</span>
|
|
<small>{draft.length.toLocaleString()} {text.characters}</small>
|
|
</label>
|
|
<div class="commit-note-editor-wrap">
|
|
{#if isLoading}
|
|
<div class="commit-note-loading"><LoaderCircle class="spin" size={18} aria-hidden="true" />{text.loading}</div>
|
|
{/if}
|
|
<!-- svelte-ignore a11y_autofocus -->
|
|
<textarea
|
|
id="commit-note-editor"
|
|
bind:value={draft}
|
|
placeholder={text.placeholder}
|
|
disabled={isLoading || isBusy}
|
|
maxlength={262144}
|
|
spellcheck="true"
|
|
autofocus
|
|
></textarea>
|
|
</div>
|
|
</form>
|
|
|
|
<section class="commit-note-sync" aria-labelledby="commit-note-sync-title">
|
|
<div class="commit-note-sync-copy">
|
|
<strong id="commit-note-sync-title">{text.syncTitle}</strong>
|
|
<span>{text.syncHelp}</span>
|
|
</div>
|
|
{#if remotes.length > 0}
|
|
<div class="commit-note-sync-controls">
|
|
<label>
|
|
<span>{text.remote}</span>
|
|
<SelectMenu value={selectedRemote} options={remotes.map((remote) => ({ value: remote.name, label: remote.name }))} disabled={isBusy} onChange={(value) => { selectedRemote = value; }} />
|
|
</label>
|
|
<button type="button" onclick={() => onFetch(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
|
|
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Download size={15} aria-hidden="true" />{/if}
|
|
{text.fetch}
|
|
</button>
|
|
<button type="button" onclick={() => onPush(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
|
|
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Upload size={15} aria-hidden="true" />{/if}
|
|
{text.push}
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<span class="commit-note-no-remotes">{text.noRemotes}</span>
|
|
{/if}
|
|
</section>
|
|
</div>
|
|
|
|
<footer class="commit-note-footer">
|
|
<div class="commit-note-delete">
|
|
{#if deleteConfirmOpen}
|
|
<span>{text.deleteQuestion}</span>
|
|
<button class="btn-danger" type="button" onclick={() => onDelete()} disabled={isBusy}>{text.deleteConfirm}</button>
|
|
<button type="button" onclick={() => { deleteConfirmOpen = false; }} disabled={isBusy}>{text.cancel}</button>
|
|
{:else}
|
|
<button type="button" class="commit-note-delete-trigger" onclick={() => { deleteConfirmOpen = true; }} disabled={isBusy || isLoading || !note}>
|
|
<Trash2 size={15} aria-hidden="true" />{text.delete}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
<div class="commit-note-actions">
|
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{text.close}</button>
|
|
<button class="btn-primary" type="button" onclick={() => onSave(draft)} disabled={!canSave}>
|
|
{#if isBusy}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Save size={16} aria-hidden="true" />{/if}
|
|
{text.save}
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.commit-note-backdrop { z-index: 72; }
|
|
.commit-note-dialog {
|
|
display: grid;
|
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
width: min(720px, calc(100vw - 32px));
|
|
max-height: min(780px, calc(100vh - 74px));
|
|
overflow: hidden;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 14px;
|
|
background: var(--app-dialog-bg);
|
|
box-shadow: var(--app-dialog-shadow);
|
|
}
|
|
.commit-note-head,
|
|
.commit-note-footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; background: var(--app-dialog-chrome); }
|
|
.commit-note-head { padding: 16px 18px; border-bottom: 1px solid var(--color-border-subtle); }
|
|
.commit-note-heading { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
|
.commit-note-heading h2 { margin: 2px 0 0; color: var(--color-ink); font-size: 18px; line-height: 1.2; }
|
|
.commit-note-icon,
|
|
.commit-target-icon { display: grid; flex: 0 0 auto; place-items: center; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
|
|
.commit-note-icon { width: 40px; height: 40px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 11px; }
|
|
.commit-note-body { display: grid; align-content: start; gap: 13px; min-height: 0; padding: 16px 18px; overflow: auto; }
|
|
.commit-note-target { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 11px; padding: 11px 12px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--color-surface-raised); }
|
|
.commit-target-icon { width: 34px; height: 34px; border-radius: 8px; }
|
|
.commit-note-target > div { display: grid; gap: 3px; min-width: 0; }
|
|
.commit-note-target span,
|
|
.commit-note-target small { color: var(--color-ink-faint); font-size: 10.5px; }
|
|
.commit-note-target code { margin-left: 4px; color: var(--color-accent); font: 700 10.5px var(--font-mono); }
|
|
.commit-note-target strong { overflow: hidden; color: var(--color-ink); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
|
.commit-note-info { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 8px; padding: 10px 11px; border: 1px solid color-mix(in srgb, var(--color-accent) 22%, var(--color-border-subtle)); border-radius: 9px; color: var(--color-ink-muted); background: color-mix(in srgb, var(--color-accent) 6%, transparent); font-size: 11px; line-height: 1.45; }
|
|
.commit-note-info :global(svg) { margin-top: 1px; color: var(--color-accent); }
|
|
.commit-note-message { padding: 9px 11px; border: 1px solid; border-radius: 8px; font-size: 11px; font-weight: 700; }
|
|
.commit-note-message.error { border-color: rgba(232, 96, 96, .32); color: #ef8888; background: rgba(232, 96, 96, .08); }
|
|
.commit-note-message.success { border-color: color-mix(in srgb, #5bd18a 34%, var(--color-border)); color: #70dc99; background: color-mix(in srgb, #5bd18a 8%, transparent); }
|
|
.commit-note-form { display: grid; gap: 6px; }
|
|
.commit-note-form > label { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--color-ink-muted); font-size: 11px; font-weight: 800; }
|
|
.commit-note-form > label small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 600; }
|
|
.commit-note-editor-wrap { position: relative; }
|
|
.commit-note-editor-wrap textarea { min-height: 164px; max-height: 320px; resize: vertical; font-size: 12.5px; line-height: 1.55; }
|
|
.commit-note-loading { position: absolute; inset: 0; z-index: 1; display: flex; align-items: center; justify-content: center; gap: 8px; border-radius: 8px; color: var(--color-ink-muted); background: color-mix(in srgb, var(--app-input-bg) 92%, transparent); font-size: 11px; font-weight: 700; }
|
|
.commit-note-sync { display: grid; gap: 11px; padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--color-surface-raised); }
|
|
.commit-note-sync-copy { display: grid; gap: 4px; }
|
|
.commit-note-sync-copy strong { color: var(--color-ink); font-size: 12px; }
|
|
.commit-note-sync-copy span,
|
|
.commit-note-no-remotes { color: var(--color-ink-faint); font-size: 10.5px; line-height: 1.45; }
|
|
.commit-note-sync-controls { display: grid; grid-template-columns: minmax(130px, 1fr) auto auto; align-items: end; gap: 8px; }
|
|
.commit-note-sync-controls label { display: grid; gap: 5px; color: var(--color-ink-muted); font-size: 10px; font-weight: 800; }
|
|
.commit-note-sync-controls button { min-height: 34px; font-size: 10.5px; font-weight: 750; }
|
|
.commit-note-footer { min-height: 66px; padding: 12px 18px; border-top: 1px solid var(--color-border-subtle); }
|
|
.commit-note-delete,
|
|
.commit-note-actions { display: flex; align-items: center; gap: 8px; }
|
|
.commit-note-delete > span { color: var(--color-ink-muted); font-size: 10.5px; font-weight: 700; }
|
|
.commit-note-delete-trigger { border-color: transparent; color: #e87a7a; background: transparent; }
|
|
.commit-note-delete-trigger:hover:not(:disabled) { border-color: rgba(232, 96, 96, .24); color: #ff9a9a; background: rgba(232, 96, 96, .08); }
|
|
@media (max-width: 660px) {
|
|
.commit-note-sync-controls { grid-template-columns: 1fr; }
|
|
.commit-note-footer { align-items: stretch; flex-direction: column; }
|
|
.commit-note-delete,
|
|
.commit-note-actions { justify-content: flex-end; }
|
|
}
|
|
</style>
|