feat(language): implement internationalization and help overlay

This update introduces comprehensive language support (English/German) across the application, enabling localization for UI elements and settings dialogs. It also adds a dedicated Help Overlay component with detailed guides on using Gitty's core Git features.

- Adds language selection to App Settings
- Implements German translations in key areas
- Introduces global help documentation accessible via Ctrl+/
This commit is contained in:
Christoph Brandau
2026-07-11 15:03:01 +02:00
parent b181b384e7
commit 6729d61dca
6 changed files with 917 additions and 25 deletions
+16 -4
View File
@@ -2,7 +2,7 @@
import { onDestroy, onMount } from "svelte";
import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, History, ListRestart, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte";
import { CircleHelp, CloudDownload, Download, FolderOpen, GitBranch, GitCompare, History, ListRestart, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte";
import iconUrl from "../../src-tauri/icons/icon.png";
export let branch: string = "";
@@ -24,7 +24,9 @@
export let onReflog: () => void = () => {};
export let onOpenInExplorer: () => void = () => {};
export let onToggleAutoRefresh: () => void = () => {};
export let onOpenHelp: () => void = () => {};
export let onOpenSettings: () => void = () => {};
export let language: "en" | "de" = "en";
let win: ReturnType<typeof getCurrentWindow> | null = null;
let isMaximized = false;
@@ -238,14 +240,24 @@
<span class="tb-action-label">Auto</span>
</button>
<button
class="tb-action"
onclick={onOpenHelp}
title={language === "de" ? "Hilfe (Ctrl+/)" : "Help (Ctrl+/)"}
aria-label={language === "de" ? "Hilfe öffnen" : "Open help"}
>
<CircleHelp size={14} aria-hidden="true" />
<span class="tb-action-label">{language === "de" ? "Hilfe" : "Help"}</span>
</button>
<button
class="tb-action"
onclick={onOpenSettings}
title="Settings"
aria-label="Settings"
title={language === "de" ? "Einstellungen" : "Settings"}
aria-label={language === "de" ? "Einstellungen" : "Settings"}
>
<Settings size={14} aria-hidden="true" />
<span class="tb-action-label">Settings</span>
<span class="tb-action-label">{language === "de" ? "Einstellungen" : "Settings"}</span>
</button>
</div>
+43 -18
View File
@@ -1,22 +1,26 @@
<script lang="ts">
import { Check, Settings, X } from "@lucide/svelte";
import type { AnalyticsSettings, AppTheme } from "../types";
import { Check, Languages, Settings, X } from "@lucide/svelte";
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types";
interface Props {
analytics: AnalyticsSettings;
theme: AppTheme;
onSave: (settings: AnalyticsSettings, theme: AppTheme) => void;
language: AppLanguage;
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage) => void;
onClose: () => void;
}
let { analytics, theme = "system", onSave = () => {}, onClose = () => {} }: Props = $props();
let { analytics, theme = "system", language = "en", onSave = () => {}, onClose = () => {} }: Props = $props();
let analyticsEnabled = $state(true);
let selectedTheme = $state<AppTheme>("system");
let selectedLanguage = $state<AppLanguage>("en");
const isGerman = $derived(selectedLanguage === "de");
$effect(() => {
analyticsEnabled = analytics.enabled;
selectedTheme = theme;
selectedLanguage = language;
});
function save() {
@@ -24,18 +28,18 @@
...analytics,
enabled: analyticsEnabled,
noticeSeen: true,
}, selectedTheme);
}, selectedTheme, selectedLanguage);
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1">
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Gitty</span>
<h2 class="dialog-title">Settings</h2>
<h2 class="dialog-title">{isGerman ? "Einstellungen" : "Settings"}</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"}>
<X size={18} aria-hidden="true" />
</button>
</header>
@@ -45,23 +49,44 @@
<header>
<Settings size={16} aria-hidden="true" />
<div>
<span class="eyebrow">Appearance</span>
<h3>Theme</h3>
<span class="eyebrow">{isGerman ? "Darstellung" : "Appearance"}</span>
<h3>{isGerman ? "Farbschema" : "Theme"}</h3>
</div>
</header>
<div class="settings-segmented" role="radiogroup" aria-label="Theme">
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}>
<label class:active={selectedTheme === "system"}>
<input type="radio" bind:group={selectedTheme} value="system" />
<span>System</span>
</label>
<label class:active={selectedTheme === "light"}>
<input type="radio" bind:group={selectedTheme} value="light" />
<span>Light</span>
<span>{isGerman ? "Hell" : "Light"}</span>
</label>
<label class:active={selectedTheme === "dark"}>
<input type="radio" bind:group={selectedTheme} value="dark" />
<span>Dark</span>
<span>{isGerman ? "Dunkel" : "Dark"}</span>
</label>
</div>
</section>
<section class="settings-section">
<header>
<Languages size={16} aria-hidden="true" />
<div>
<span class="eyebrow">{isGerman ? "Sprache" : "Language"}</span>
<h3>{isGerman ? "App-Sprache" : "App language"}</h3>
</div>
</header>
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
<label class:active={selectedLanguage === "en"}>
<input type="radio" bind:group={selectedLanguage} value="en" />
<span>EN · English</span>
</label>
<label class:active={selectedLanguage === "de"}>
<input type="radio" bind:group={selectedLanguage} value="de" />
<span>DE · Deutsch</span>
</label>
</div>
</section>
@@ -71,24 +96,24 @@
<Settings size={16} aria-hidden="true" />
<div>
<span class="eyebrow">Analytics</span>
<h3>Anonymous usage analytics</h3>
<h3>{isGerman ? "Anonyme Nutzungsanalyse" : "Anonymous usage analytics"}</h3>
</div>
</header>
<label class="settings-toggle-row">
<input type="checkbox" bind:checked={analyticsEnabled} />
<span>
<strong>Allow anonymous Aptabase events</strong>
<small>No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent.</small>
<strong>{isGerman ? "Anonyme Aptabase-Ereignisse erlauben" : "Allow anonymous Aptabase events"}</strong>
<small>{isGerman ? "Es werden keine Repository-Pfade, Remotes, Branches, Commit-Nachrichten, Dateinamen, Diffs, Zugangsdaten oder Code übertragen." : "No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent."}</small>
</span>
</label>
</section>
<div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose}>Cancel</button>
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="submit">
<Check size={16} aria-hidden="true" />
Save
{isGerman ? "Speichern" : "Save"}
</button>
</div>
</form>
+798
View File
@@ -0,0 +1,798 @@
<script lang="ts">
import { onMount, tick } from "svelte";
import {
AlertTriangle,
BookOpen,
Check,
ChevronRight,
CircleHelp,
Clipboard,
Cloud,
Command,
GitBranch,
GitCommitHorizontal,
Home,
Keyboard,
Lightbulb,
Search,
Wrench,
X,
} from "@lucide/svelte";
interface CommandExample {
command: string;
description: string;
}
interface HelpSection {
id: string;
title: string;
summary: string;
steps?: string[];
commands?: CommandExample[];
note?: string;
}
interface HelpCategory {
id: string;
label: string;
description: string;
sections: HelpSection[];
}
interface Props {
language: "en" | "de";
onClose: () => void;
}
const deCategories: HelpCategory[] = [
{
id: "start",
label: "Erste Schritte",
description: "Repository öffnen, Oberfläche verstehen und den ersten Commit erstellen.",
sections: [
{
id: "start-workflow",
title: "Dein erster Gitty-Workflow",
summary: "Vom Repository bis zum veröffentlichten Commit in fünf klaren Schritten.",
steps: [
"Öffne ein vorhandenes Repository oder klone ein Projekt über die Repository-Verwaltung.",
"Bearbeite deine Dateien. Gitty zeigt Änderungen im Arbeitsverzeichnis automatisch an.",
"Prüfe den Diff und stage einzelne Dateien, Zeilen oder alle passenden Änderungen.",
"Schreibe eine aussagekräftige Commit-Nachricht und erstelle den Commit.",
"Nutze Fetch, Pull und Push, um deinen Stand mit dem Remote-Repository abzugleichen.",
],
},
{
id: "start-layout",
title: "Die Oberfläche im Überblick",
summary: "Links findest du Branches, Stashes und Dateien. In der Mitte prüfst und commitest du Änderungen; rechts siehst du Verlauf und Dateihistorie.",
note: "Fast alle Bereiche lassen sich über die Trennlinien in der Größe anpassen oder über ihren Kopf einklappen.",
},
{
id: "start-safety",
title: "Sicher arbeiten",
summary: "Prüfe vor Commit, Pull, Rebase oder Verwerfen immer den aktuellen Branch und die betroffenen Dateien. Gitty fragt bei destruktiven Aktionen nochmals nach.",
},
],
},
{
id: "app",
label: "Arbeiten mit Gitty",
description: "Die wichtigsten Funktionen der App Schritt für Schritt.",
sections: [
{
id: "app-changes",
title: "Änderungen prüfen und stagen",
summary: "Wähle eine geänderte Datei, lies den Diff und verschiebe gezielt Änderungen in den Staging-Bereich.",
steps: [
"Unstaged enthält noch nicht vorbereitete Änderungen; Staged enthält den nächsten Commit-Inhalt.",
"Öffne eine Datei, um hinzugefügte und entfernte Zeilen zu prüfen.",
"Stage ganze Dateien oder nutze die Zeilen-/Hunk-Aktionen für kleinere, saubere Commits.",
"Verwerfen entfernt lokale Änderungen. Diese Aktion lässt sich nicht immer rückgängig machen.",
],
},
{
id: "app-history",
title: "Verlauf, Suche und Vergleich",
summary: "Der Commit-Verlauf zeigt Branches und Merges. Mit der globalen Suche findest du die Einführung von Code oder den Verlauf einer Datei.",
steps: [
"Klappe einen Commit auf, um seine Dateien zu sehen.",
"Vergleiche zwei Commits über Compare in der Titelleiste.",
"Nutze Reflog, um auch verschobene oder nicht mehr sichtbare Referenzen wiederzufinden.",
],
},
{
id: "app-stash",
title: "Zwischenstände mit Stash sichern",
summary: "Ein Stash parkt unvollständige Änderungen, ohne einen Commit zu erzeugen. Apply behält den Stash, Pop wendet ihn an und entfernt ihn.",
},
],
},
{
id: "basics",
label: "Git-Grundlagen",
description: "Alltägliche Befehle für Status, Änderungen, Commits und Wiederherstellung.",
sections: [
{
id: "basics-inspect",
title: "Repository prüfen",
summary: "Diese Befehle verändern nichts und eignen sich immer als erster Blick auf den aktuellen Zustand.",
commands: [
{ command: "git status", description: "Arbeitsverzeichnis und Staging-Bereich anzeigen" },
{ command: "git diff", description: "Noch nicht gestagte Änderungen anzeigen" },
{ command: "git diff --staged", description: "Inhalt des nächsten Commits anzeigen" },
{ command: "git log --oneline --graph --decorate --all", description: "Kompakten Branch- und Commit-Verlauf anzeigen" },
],
},
{
id: "basics-commit",
title: "Änderungen speichern",
summary: "Stage nur zusammengehörige Änderungen und beschreibe im Commit, warum die Änderung nötig ist.",
commands: [
{ command: "git add <datei>", description: "Eine Datei für den Commit vormerken" },
{ command: "git add -p", description: "Änderungen interaktiv und abschnittsweise stagen" },
{ command: "git commit -m \"Kurze Beschreibung\"", description: "Einen Commit erstellen" },
{ command: "git commit --amend", description: "Den letzten lokalen Commit ergänzen oder umbenennen" },
],
},
{
id: "basics-undo",
title: "Änderungen rückgängig machen",
summary: "Restore arbeitet am Arbeitsverzeichnis oder Staging-Bereich; Revert erzeugt einen neuen Gegen-Commit und ist für veröffentlichte Historie sicherer.",
commands: [
{ command: "git restore <datei>", description: "Lokale, noch nicht gestagte Änderung verwerfen" },
{ command: "git restore --staged <datei>", description: "Datei aus dem Staging-Bereich entfernen" },
{ command: "git revert <commit>", description: "Wirkung eines Commits durch neuen Commit umkehren" },
],
note: "Vorsicht: git reset --hard verwirft lokale Änderungen. Verwende den Befehl nur, wenn du den Verlust bewusst akzeptierst.",
},
],
},
{
id: "branches",
label: "Branches & Merges",
description: "Parallele Arbeit organisieren, zusammenführen und aufräumen.",
sections: [
{
id: "branches-manage",
title: "Branches verwalten",
summary: "Ein Branch ist ein beweglicher Zeiger auf eine Commit-Linie. Erstelle für jede getrennte Aufgabe einen eigenen Branch.",
commands: [
{ command: "git switch -c feature/meine-aenderung", description: "Neuen Branch erstellen und wechseln" },
{ command: "git switch main", description: "Zu einem vorhandenen Branch wechseln" },
{ command: "git branch -vv", description: "Lokale Branches mit Upstream-Status anzeigen" },
{ command: "git branch -d <branch>", description: "Bereits zusammengeführten Branch löschen" },
],
},
{
id: "branches-integrate",
title: "Merge oder Rebase?",
summary: "Merge bewahrt die tatsächliche Verzweigung. Rebase setzt lokale Commits auf eine neue Basis und erzeugt eine lineare Historie.",
commands: [
{ command: "git merge <branch>", description: "Einen Branch in den aktuellen Branch integrieren" },
{ command: "git rebase main", description: "Lokale Commits auf den aktuellen Stand von main setzen" },
{ command: "git rebase --continue", description: "Rebase nach gelöstem Konflikt fortsetzen" },
{ command: "git rebase --abort", description: "Rebase abbrechen und Ausgangszustand wiederherstellen" },
],
note: "Rebase keine Commits, an denen andere bereits weiterarbeiten. Das Umschreiben veröffentlichter Historie verursacht unnötige Konflikte.",
},
],
},
{
id: "remote",
label: "Remote & Sync",
description: "Änderungen sicher mit GitHub, GitLab oder anderen Remotes austauschen.",
sections: [
{
id: "remote-sync",
title: "Fetch, Pull und Push",
summary: "Fetch lädt Referenzen ohne deine Dateien zu ändern. Pull integriert Remote-Änderungen. Push veröffentlicht deine Commits.",
commands: [
{ command: "git fetch --all --prune", description: "Remote-Stände laden und entfernte Referenzen aufräumen" },
{ command: "git pull --rebase", description: "Remote-Änderungen laden und lokale Commits darauf neu abspielen" },
{ command: "git push -u origin <branch>", description: "Branch erstmals veröffentlichen und Upstream setzen" },
{ command: "git remote -v", description: "Konfigurierte Remote-Adressen anzeigen" },
],
},
{
id: "remote-ahead",
title: "Ahead und Behind verstehen",
summary: "Ahead bedeutet: lokale Commits wurden noch nicht gepusht. Behind bedeutet: im Remote liegen neue Commits, die lokal fehlen. Beides gleichzeitig weist auf auseinanderlaufende Historien hin.",
},
],
},
{
id: "troubleshooting",
label: "Probleme lösen",
description: "Konflikte, verlorene Commits und typische Fehlermeldungen verstehen.",
sections: [
{
id: "trouble-conflicts",
title: "Merge-Konflikte lösen",
summary: "Ein Konflikt entsteht, wenn Git Änderungen nicht eindeutig kombinieren kann. Gitty bietet dafür einen eigenen Resolve-Dialog.",
steps: [
"Öffne jede Konfliktdatei und vergleiche Current, Incoming und das kombinierte Ergebnis.",
"Übernimm eine Seite oder bearbeite den Zielinhalt manuell.",
"Markiere die Datei als gelöst und prüfe anschließend den vollständigen Diff.",
"Führe Merge, Rebase oder Cherry-pick fort oder brich die Operation vollständig ab.",
],
commands: [
{ command: "git status", description: "Konfliktdateien und laufende Operation anzeigen" },
{ command: "git merge --abort", description: "Laufenden Merge abbrechen" },
{ command: "git cherry-pick --abort", description: "Laufenden Cherry-pick abbrechen" },
],
},
{
id: "trouble-reflog",
title: "Verlorene Commits wiederfinden",
summary: "Reflog protokolliert lokale Bewegungen von HEAD und Branches. Kopiere den Hash des gesuchten Eintrags und erstelle daraus einen Sicherungs-Branch.",
commands: [
{ command: "git reflog", description: "Lokale Referenzbewegungen anzeigen" },
{ command: "git branch recovery/<name> <commit>", description: "Gefundenen Commit dauerhaft über Branch sichern" },
],
},
],
},
{
id: "shortcuts",
label: "Tastenkürzel",
description: "Gitty schneller und ohne Maus bedienen.",
sections: [
{
id: "shortcuts-main",
title: "Globale Bedienung",
summary: "Die Hilfe ist überall erreichbar. Dialoge lassen sich konsistent schließen und Suchfelder direkt fokussieren.",
commands: [
{ command: "Ctrl + /", description: "Diese Hilfe öffnen" },
{ command: "Escape", description: "Aktuelles Overlay oder Dialogfenster schließen" },
{ command: "Tab / Shift + Tab", description: "Zwischen Bedienelementen wechseln" },
{ command: "Enter / Leertaste", description: "Fokussierte Aktion ausführen" },
],
note: "Auf macOS kannst du für Ctrl in der Regel die Command-Taste verwenden.",
},
],
},
];
const enCategories: HelpCategory[] = [
{
id: "start",
label: "Getting started",
description: "Open a repository, understand the interface, and create your first commit.",
sections: [
{
id: "start-workflow",
title: "Your first Gitty workflow",
summary: "From repository to published commit in five clear steps.",
steps: [
"Open an existing repository or clone a project from Repository Management.",
"Edit your files. Gitty automatically displays working-tree changes.",
"Review the diff and stage individual files, lines, or all related changes.",
"Write a meaningful commit message and create the commit.",
"Use Fetch, Pull, and Push to synchronize with the remote repository.",
],
},
{
id: "start-layout",
title: "Interface overview",
summary: "Branches, stashes, and files are on the left. Review and commit changes in the center; history and file history are on the right.",
note: "Most areas can be resized using their dividers or collapsed from their header.",
},
{
id: "start-safety",
title: "Work safely",
summary: "Before committing, pulling, rebasing, or discarding, always check the current branch and affected files. Gitty confirms destructive actions.",
},
],
},
{
id: "app",
label: "Working with Gitty",
description: "The app's most important features, explained step by step.",
sections: [
{
id: "app-changes",
title: "Review and stage changes",
summary: "Select a changed file, review its diff, and move changes into the staging area.",
steps: [
"Unstaged contains changes not yet prepared; Staged contains the next commit.",
"Open a file to review added and removed lines.",
"Stage whole files or use line and hunk actions for focused commits.",
"Discard removes local changes and cannot always be undone.",
],
},
{
id: "app-history",
title: "History, search, and comparison",
summary: "Commit History shows branches and merges. Global Search finds where code was introduced or displays a file's history.",
steps: [
"Expand a commit to inspect its files.",
"Compare two commits with Compare in the title bar.",
"Use Reflog to recover moved or otherwise hidden references.",
],
},
{
id: "app-stash",
title: "Save temporary work with Stash",
summary: "A stash parks unfinished changes without creating a commit. Apply keeps the stash; Pop applies and removes it.",
},
],
},
{
id: "basics",
label: "Git basics",
description: "Everyday commands for status, changes, commits, and recovery.",
sections: [
{
id: "basics-inspect",
title: "Inspect a repository",
summary: "These commands do not change anything and are always a good first look at the current state.",
commands: [
{ command: "git status", description: "Show the working tree and staging area" },
{ command: "git diff", description: "Show changes that have not been staged" },
{ command: "git diff --staged", description: "Show the contents of the next commit" },
{ command: "git log --oneline --graph --decorate --all", description: "Show a compact branch and commit graph" },
],
},
{
id: "basics-commit",
title: "Save changes",
summary: "Stage only related changes and explain why the change is needed in the commit message.",
commands: [
{ command: "git add <file>", description: "Stage one file" },
{ command: "git add -p", description: "Stage changes interactively by hunk" },
{ command: "git commit -m \"Short description\"", description: "Create a commit" },
{ command: "git commit --amend", description: "Update or rename the latest local commit" },
],
},
{
id: "basics-undo",
title: "Undo changes",
summary: "Restore changes the working tree or staging area. Revert creates a new inverse commit and is safer for published history.",
commands: [
{ command: "git restore <file>", description: "Discard an unstaged local change" },
{ command: "git restore --staged <file>", description: "Remove a file from the staging area" },
{ command: "git revert <commit>", description: "Undo a commit through a new commit" },
],
note: "Caution: git reset --hard discards local changes. Use it only when that data loss is intentional.",
},
],
},
{
id: "branches",
label: "Branches & merges",
description: "Organize parallel work, integrate it, and clean up afterward.",
sections: [
{
id: "branches-manage",
title: "Manage branches",
summary: "A branch is a movable pointer to a commit line. Create a separate branch for each independent task.",
commands: [
{ command: "git switch -c feature/my-change", description: "Create and switch to a new branch" },
{ command: "git switch main", description: "Switch to an existing branch" },
{ command: "git branch -vv", description: "Show local branches and upstream status" },
{ command: "git branch -d <branch>", description: "Delete a branch that has already been merged" },
],
},
{
id: "branches-integrate",
title: "Merge or rebase?",
summary: "Merge preserves the actual branch structure. Rebase moves local commits onto a new base for a linear history.",
commands: [
{ command: "git merge <branch>", description: "Integrate a branch into the current branch" },
{ command: "git rebase main", description: "Move local commits onto the latest main" },
{ command: "git rebase --continue", description: "Continue after resolving a conflict" },
{ command: "git rebase --abort", description: "Abort and restore the original state" },
],
note: "Do not rebase commits other people are already using. Rewriting published history creates avoidable conflicts.",
},
],
},
{
id: "remote",
label: "Remote & sync",
description: "Exchange changes safely with GitHub, GitLab, or other remotes.",
sections: [
{
id: "remote-sync",
title: "Fetch, pull, and push",
summary: "Fetch downloads references without changing files. Pull integrates remote changes. Push publishes your commits.",
commands: [
{ command: "git fetch --all --prune", description: "Download remote state and remove stale references" },
{ command: "git pull --rebase", description: "Download changes and replay local commits on top" },
{ command: "git push -u origin <branch>", description: "Publish a branch and set its upstream" },
{ command: "git remote -v", description: "Show configured remote URLs" },
],
},
{
id: "remote-ahead",
title: "Understanding Ahead and Behind",
summary: "Ahead means local commits have not been pushed. Behind means remote commits are missing locally. Both at once means the histories have diverged.",
},
],
},
{
id: "troubleshooting",
label: "Troubleshooting",
description: "Resolve conflicts, recover lost commits, and understand common errors.",
sections: [
{
id: "trouble-conflicts",
title: "Resolve merge conflicts",
summary: "A conflict occurs when Git cannot combine changes unambiguously. Gitty provides a dedicated Resolve dialog.",
steps: [
"Open each conflicted file and compare Current, Incoming, and the combined result.",
"Accept one side or edit the final content manually.",
"Mark the file resolved and review the complete diff.",
"Continue the merge, rebase, or cherry-pick—or abort the operation.",
],
commands: [
{ command: "git status", description: "Show conflicts and the active operation" },
{ command: "git merge --abort", description: "Abort the current merge" },
{ command: "git cherry-pick --abort", description: "Abort the current cherry-pick" },
],
},
{
id: "trouble-reflog",
title: "Recover lost commits",
summary: "Reflog records local movements of HEAD and branches. Copy the desired hash and create a recovery branch from it.",
commands: [
{ command: "git reflog", description: "Show local reference movements" },
{ command: "git branch recovery/<name> <commit>", description: "Preserve the recovered commit with a branch" },
],
},
],
},
{
id: "shortcuts",
label: "Keyboard shortcuts",
description: "Use Gitty quickly without reaching for the mouse.",
sections: [
{
id: "shortcuts-main",
title: "Global controls",
summary: "Help is available everywhere. Dialogs close consistently and search fields receive focus automatically.",
commands: [
{ command: "Ctrl + /", description: "Open this help center" },
{ command: "Escape", description: "Close the current overlay or dialog" },
{ command: "Tab / Shift + Tab", description: "Move between controls" },
{ command: "Enter / Space", description: "Activate the focused control" },
],
note: "On macOS, you can generally use Command instead of Ctrl.",
},
],
},
];
let { language = "en", onClose = () => {} }: Props = $props();
const isGerman = $derived(language === "de");
const categories = $derived(isGerman ? deCategories : enCategories);
let selectedCategoryId = $state("start");
let searchQuery = $state("");
let copiedCommand = $state("");
let searchInput: HTMLInputElement;
let contentElement: HTMLElement;
let copyTimer: ReturnType<typeof setTimeout> | undefined;
const normalizedQuery = $derived(searchQuery.trim().toLocaleLowerCase(language));
const selectedCategory = $derived(categories.find((category) => category.id === selectedCategoryId) ?? categories[0]);
const visibleGroups = $derived.by(() => {
if (!normalizedQuery) return [{ category: selectedCategory, sections: selectedCategory.sections }];
return categories.flatMap((category) => {
const sections = category.sections.filter((section) => sectionSearchText(category, section).includes(normalizedQuery));
return sections.length > 0 ? [{ category, sections }] : [];
});
});
const resultCount = $derived(visibleGroups.reduce((sum, group) => sum + group.sections.length, 0));
$effect(() => {
normalizedQuery;
selectedCategoryId;
if (contentElement) contentElement.scrollTop = 0;
});
onMount(() => {
void tick().then(() => searchInput?.focus());
return () => {
if (copyTimer) clearTimeout(copyTimer);
};
});
function sectionSearchText(category: HelpCategory, section: HelpSection): string {
return [
category.label,
category.description,
section.title,
section.summary,
...(section.steps ?? []),
...(section.commands?.flatMap((entry) => [entry.command, entry.description]) ?? []),
section.note ?? "",
].join(" ").toLocaleLowerCase(language);
}
function selectCategory(id: string) {
selectedCategoryId = id;
searchQuery = "";
}
function isWarningNote(note: string): boolean {
return /^(Vorsicht|Rebase|Caution|Do not)/.test(note);
}
async function copyCommand(command: string) {
try {
await navigator.clipboard.writeText(command);
copiedCommand = command;
if (copyTimer) clearTimeout(copyTimer);
copyTimer = setTimeout(() => { copiedCommand = ""; }, 1800);
} catch {
copiedCommand = "";
}
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) onClose();
}
</script>
<div class="help-backdrop" role="presentation" onclick={handleBackdropClick}>
<div class="help-overlay" role="dialog" aria-modal="true" aria-labelledby="help-title">
<header class="help-header">
<div class="help-title-wrap">
<span class="help-mark"><CircleHelp size={19} aria-hidden="true" /></span>
<div>
<h2 id="help-title">{isGerman ? "Gitty Hilfe" : "Gitty Help"}</h2>
<p>{isGerman ? "App-Anleitung und Git-Wissen an einem Ort" : "App guidance and Git knowledge in one place"}</p>
</div>
</div>
<label class="help-search">
<span class="help-search-icon"><Search size={17} aria-hidden="true" /></span>
<input bind:this={searchInput} bind:value={searchQuery} type="search" placeholder={isGerman ? "Hilfe und Git-Befehle durchsuchen …" : "Search help and Git commands …"} aria-label={isGerman ? "Hilfe durchsuchen" : "Search help"} />
<kbd>Ctrl /</kbd>
</label>
<button class="help-close" type="button" onclick={onClose} title={isGerman ? "Hilfe schließen" : "Close help"} aria-label={isGerman ? "Hilfe schließen" : "Close help"}>
<X size={19} aria-hidden="true" />
</button>
</header>
<div class="help-layout">
<nav class="help-nav" aria-label={isGerman ? "Hilfethemen" : "Help topics"}>
<p class="help-nav-label">{isGerman ? "Themen" : "Topics"}</p>
{#each categories as category, index}
<button
type="button"
class:active={!normalizedQuery && category.id === selectedCategoryId}
onclick={() => selectCategory(category.id)}
>
<span class="help-nav-icon">
{#if index === 0}<Home size={17} aria-hidden="true" />
{:else if index === 1}<BookOpen size={17} aria-hidden="true" />
{:else if index === 2}<GitCommitHorizontal size={17} aria-hidden="true" />
{:else if index === 3}<GitBranch size={17} aria-hidden="true" />
{:else if index === 4}<Cloud size={17} aria-hidden="true" />
{:else if index === 5}<Wrench size={17} aria-hidden="true" />
{:else}<Keyboard size={17} aria-hidden="true" />{/if}
</span>
<span>{category.label}</span>
<span class="help-nav-chevron"><ChevronRight size={14} aria-hidden="true" /></span>
</button>
{/each}
<div class="help-nav-tip">
<span class="help-tip-icon"><Lightbulb size={16} aria-hidden="true" /></span>
<span>{isGerman ? "Suche auch nach Befehlen wie" : "Try commands such as"} <code>rebase</code>, <code>stash</code> {isGerman ? "oder" : "or"} <code>reflog</code>.</span>
</div>
</nav>
<main class="help-content" bind:this={contentElement}>
{#if normalizedQuery}
<header class="help-result-header">
<div>
<span>{isGerman ? "Suchergebnisse" : "Search results"}</span>
<h3>{resultCount} {isGerman ? "Treffer für" : resultCount === 1 ? "result for" : "results for"}{searchQuery.trim()}</h3>
</div>
<button type="button" onclick={() => { searchQuery = ""; }}>{isGerman ? "Suche löschen" : "Clear search"}</button>
</header>
{/if}
{#if visibleGroups.length === 0}
<div class="help-empty">
<span class="help-empty-icon"><Search size={28} aria-hidden="true" /></span>
<h3>{isGerman ? "Kein Hilfethema gefunden" : "No help topic found"}</h3>
<p>{isGerman ? "Versuche einen allgemeineren Begriff wie „Commit“, „Branch“, „Remote“ oder „Konflikt“." : "Try a broader term such as “commit”, “branch”, “remote”, or “conflict”."}</p>
<button class="btn-secondary" type="button" onclick={() => { searchQuery = ""; }}>{isGerman ? "Alle Themen anzeigen" : "Show all topics"}</button>
</div>
{:else}
{#each visibleGroups as group}
<div class="help-group">
<header class="help-group-header">
<span>{normalizedQuery ? group.category.label : isGerman ? "Gitty Handbuch" : "Gitty handbook"}</span>
<h3>{normalizedQuery ? group.category.label : group.category.label}</h3>
<p>{group.category.description}</p>
</header>
{#each group.sections as section, sectionIndex}
<article class="help-section" id={section.id}>
<div class="help-section-number">{String(sectionIndex + 1).padStart(2, "0")}</div>
<div class="help-section-body">
<h4>{section.title}</h4>
<p>{section.summary}</p>
{#if section.steps}
<ol class="help-steps">
{#each section.steps as step}
<li><span>{step}</span></li>
{/each}
</ol>
{/if}
{#if section.commands}
<div class="help-commands">
{#each section.commands as entry}
<div class="help-command-row">
<code>{entry.command}</code>
<span>{entry.description}</span>
<button type="button" onclick={() => copyCommand(entry.command)} aria-label={(isGerman ? "Befehl kopieren: " : "Copy command: ") + entry.command} title={isGerman ? "Befehl kopieren" : "Copy command"}>
{#if copiedCommand === entry.command}
<Check size={15} aria-hidden="true" /><span>{isGerman ? "Kopiert" : "Copied"}</span>
{:else}
<Clipboard size={15} aria-hidden="true" /><span>{isGerman ? "Kopieren" : "Copy"}</span>
{/if}
</button>
</div>
{/each}
</div>
{/if}
{#if section.note}
<div class="help-note" class:warning={isWarningNote(section.note)}>
{#if isWarningNote(section.note)}
<span class="help-note-icon"><AlertTriangle size={16} aria-hidden="true" /></span>
{:else}
<span class="help-note-icon"><Lightbulb size={16} aria-hidden="true" /></span>
{/if}
<span>{section.note}</span>
</div>
{/if}
</div>
</article>
{/each}
</div>
{/each}
{/if}
</main>
</div>
<footer class="help-footer">
<span><Command size={14} aria-hidden="true" /> <kbd>Ctrl</kbd><kbd>/</kbd> {isGerman ? "Hilfe öffnen" : "open help"}</span>
<span><kbd>Esc</kbd> {isGerman ? "schließen" : "close"}</span>
</footer>
</div>
</div>
<style>
.help-backdrop {
position: fixed;
inset: var(--app-titlebar-height, 42px) 0 0;
z-index: 70;
display: grid;
place-items: center;
padding: 24px;
background: rgba(3, 7, 13, 0.82);
backdrop-filter: blur(3px);
}
.help-overlay {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(1240px, 100%);
height: min(820px, 100%);
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 12px;
color: var(--color-ink);
background: var(--app-dialog-bg);
box-shadow: var(--app-dialog-shadow);
}
.help-header {
display: grid;
grid-template-columns: minmax(220px, 0.72fr) minmax(320px, 1.2fr) minmax(44px, 0.72fr);
align-items: center;
gap: 22px;
min-height: 74px;
padding: 12px 16px 12px 20px;
border-bottom: 1px solid var(--color-border);
background: var(--app-dialog-chrome);
}
.help-title-wrap { display: flex; align-items: center; gap: 11px; min-width: 0; }
.help-mark { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid rgba(90, 140, 248, 0.28); border-radius: 8px; color: var(--color-accent); background: rgba(90, 140, 248, 0.09); }
.help-title-wrap h2 { margin: 0; font-size: 17px; line-height: 1.2; }
.help-title-wrap p { margin: 3px 0 0; overflow: hidden; color: var(--color-ink-faint); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
.help-search { position: relative; display: flex; align-items: center; min-width: 0; }
.help-search-icon { position: absolute; left: 12px; display: grid; color: var(--color-ink-faint); pointer-events: none; }
.help-search input { width: 100%; height: 40px; padding: 0 68px 0 39px; border-color: var(--color-border-input); border-radius: 8px; background: var(--app-input-bg); color: var(--color-ink); font-size: 12.5px; }
.help-search kbd { position: absolute; right: 8px; }
.help-close { justify-self: end; width: 36px; min-height: 36px; padding: 0; border-color: transparent; border-radius: 7px; background: transparent; color: var(--color-ink-muted); }
.help-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: 0; }
.help-nav { display: flex; flex-direction: column; min-height: 0; padding: 14px 10px 12px; border-right: 1px solid var(--color-border); background: var(--color-surface-dim); }
.help-nav-label { margin: 0 10px 8px; color: var(--color-ink-faint); font-size: 9.5px; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; }
.help-nav > button { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; min-height: 42px; padding: 5px 9px; border-color: transparent; border-radius: 7px; background: transparent; color: var(--color-ink-muted); font-size: 11.5px; font-weight: 650; text-align: left; }
.help-nav > button:hover { background: var(--color-surface-hover); color: var(--color-ink); }
.help-nav > button.active { border-color: rgba(90, 140, 248, 0.22); background: rgba(90, 140, 248, 0.12); color: var(--color-accent); }
.help-nav-icon { display: grid; place-items: center; color: currentColor; }
.help-nav-chevron { display: grid; color: var(--color-ink-faint); }
.help-nav-tip { display: flex; align-items: flex-start; gap: 8px; margin: auto 6px 0; padding: 11px; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-faint); font-size: 10.5px; line-height: 1.5; }
.help-tip-icon { flex: 0 0 auto; display: grid; margin-top: 2px; color: #d8a13d; }
.help-nav-tip code { color: var(--color-ink-muted); font-family: var(--font-mono); }
.help-content { min-width: 0; min-height: 0; padding: 0 34px 48px; overflow: auto; outline: none; scroll-behavior: smooth; }
.help-group { max-width: 850px; margin: 0 auto; }
.help-group + .help-group { margin-top: 22px; border-top: 1px solid var(--color-border); }
.help-group-header { padding: 34px 0 26px; border-bottom: 1px solid var(--color-border); }
.help-group-header > span, .help-result-header span { color: var(--color-accent); font-size: 9.5px; font-weight: 800; letter-spacing: 0.11em; text-transform: uppercase; }
.help-group-header h3 { margin: 7px 0 8px; font-size: clamp(24px, 3vw, 32px); letter-spacing: -0.025em; }
.help-group-header p { max-width: 650px; margin: 0; color: var(--color-ink-muted); font-size: 13px; line-height: 1.55; }
.help-result-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; max-width: 850px; margin: 0 auto; padding: 22px 0 0; }
.help-result-header h3 { margin: 4px 0 0; font-size: 17px; }
.help-result-header button { min-height: 30px; padding: 0 10px; border-color: var(--color-border-subtle); background: transparent; color: var(--color-ink-muted); font-size: 10.5px; }
.help-section { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 2px; padding: 26px 0; border-bottom: 1px solid var(--color-border-subtle); }
.help-section-number { padding-top: 3px; color: var(--color-ink-faint); font-family: var(--font-mono); font-size: 10px; }
.help-section-body h4 { margin: 0 0 7px; font-size: 16px; }
.help-section-body > p { margin: 0; color: var(--color-ink-muted); font-size: 12.5px; line-height: 1.6; }
.help-steps { display: grid; gap: 10px; margin: 18px 0 0; padding: 0; list-style: none; counter-reset: help-step; }
.help-steps li { display: grid; grid-template-columns: 24px minmax(0, 1fr); align-items: start; gap: 9px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; counter-increment: help-step; }
.help-steps li::before { content: counter(help-step); display: grid; place-items: center; width: 22px; height: 22px; border: 1px solid rgba(90, 140, 248, 0.35); border-radius: 50%; color: var(--color-accent); font-family: var(--font-mono); font-size: 9px; font-weight: 800; }
.help-commands { display: grid; gap: 1px; margin-top: 17px; overflow: hidden; border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-border-subtle); }
.help-command-row { display: grid; grid-template-columns: minmax(210px, 0.9fr) minmax(190px, 1.2fr) auto; align-items: center; gap: 14px; min-height: 47px; padding: 6px 7px 6px 13px; background: var(--code-surface); }
.help-command-row:hover { background: var(--code-hover-bg); }
.help-command-row > code { overflow: hidden; color: var(--color-accent); font-family: var(--font-mono); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
.help-command-row > span { color: var(--color-ink-faint); font-size: 10.5px; line-height: 1.35; }
.help-command-row button { display: inline-flex; align-items: center; gap: 6px; min-width: 83px; min-height: 31px; padding: 0 9px; border-color: var(--color-border-subtle); border-radius: 6px; background: var(--color-surface-raised); color: var(--color-ink-muted); font-size: 10px; }
.help-command-row button:hover { border-color: rgba(90, 140, 248, 0.32); color: var(--color-accent); }
.help-note { display: flex; align-items: flex-start; gap: 9px; margin-top: 15px; padding: 11px 12px; border: 1px solid rgba(90, 140, 248, 0.2); border-radius: 7px; background: rgba(90, 140, 248, 0.07); color: var(--color-ink-muted); font-size: 11px; line-height: 1.5; }
.help-note-icon { flex: 0 0 auto; display: grid; margin-top: 1px; color: var(--color-accent); }
.help-note.warning { border-color: rgba(224, 160, 64, 0.24); background: rgba(224, 160, 64, 0.07); }
.help-note.warning .help-note-icon { color: #d8a13d; }
.help-empty { display: grid; justify-items: center; max-width: 520px; margin: 90px auto 0; text-align: center; }
.help-empty-icon { display: grid; color: var(--color-ink-faint); }
.help-empty h3 { margin: 14px 0 5px; font-size: 18px; }
.help-empty p { margin: 0 0 18px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.55; }
.help-footer { display: flex; align-items: center; justify-content: space-between; min-height: 38px; padding: 0 16px 0 20px; border-top: 1px solid var(--color-border); background: var(--app-dialog-chrome); color: var(--color-ink-faint); font-size: 9.5px; }
.help-footer span { display: inline-flex; align-items: center; gap: 5px; }
kbd { display: inline-grid; place-items: center; min-width: 20px; height: 21px; padding: 0 5px; border: 1px solid var(--color-border-input); border-radius: 5px; background: var(--color-surface-raised); color: var(--color-ink-muted); box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.04); font-family: var(--font-mono); font-size: 9px; }
@media (max-width: 820px) {
.help-backdrop { padding: 10px; }
.help-header { grid-template-columns: minmax(0, 1fr) auto; gap: 10px; }
.help-title-wrap { display: none; }
.help-layout { grid-template-columns: 190px minmax(0, 1fr); }
.help-content { padding-inline: 20px; }
.help-command-row { grid-template-columns: minmax(0, 1fr) auto; gap: 5px 10px; }
.help-command-row > span { grid-column: 1; }
.help-command-row button { grid-column: 2; grid-row: 1 / 3; }
}
@media (max-width: 600px) {
.help-layout { grid-template-columns: 1fr; }
.help-nav { flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--color-border); }
.help-nav-label, .help-nav-tip, .help-nav-chevron { display: none; }
.help-nav > button { flex: 0 0 auto; width: auto; grid-template-columns: 24px auto; }
.help-content { padding-inline: 15px; }
.help-section { grid-template-columns: 1fr; }
.help-section-number { display: none; }
.help-footer { display: none; }
}
</style>
+1
View File
@@ -11,6 +11,7 @@ export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
export type AppTheme = "system" | "light" | "dark";
export type AppLanguage = "en" | "de";
export interface CommitAiStatus {
phase: CommitAiPhase;