feat(external-tools): add cross-platform external tool discovery

Adds a new external tools subsystem to detect and launch
diff and editor tools across Windows, macOS, and Linux.
It exposes data models for tools, commands, and results to the UI
and serializes them for consumption by the app.

- Implement cross-platform discovery of editors and diff tools
- Expose serialized results to the UI for user selection
- Centralize per-OS known tool lists and overrides
This commit is contained in:
Christoph Brandau
2026-08-13 14:08:02 +02:00
parent 3eb554fee7
commit 15d1f2bfd6
15 changed files with 3721 additions and 125 deletions
+303
View File
@@ -0,0 +1,303 @@
<script lang="ts">
import {
Download,
GitCommitHorizontal,
Info,
LoaderCircle,
Save,
StickyNote,
Trash2,
Upload,
X,
} from "@lucide/svelte";
import type { AppLanguage, GitCommit, GitRemote } from "../types";
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>
<select bind:value={selectedRemote} disabled={isBusy}>
{#each remotes as remote (remote.name)}
<option value={remote.name}>{remote.name}</option>
{/each}
</select>
</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>