From 3eb554fee761f4fd2b9673b23979fedbd26e6839 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 13 Aug 2026 07:53:19 +0200 Subject: [PATCH] feat(ui): add command palette and commit selection sync Introduce a global command palette for quick access to common repository actions, branches, files, and commits. Wire it into the main shell so it can open settings, help, and other dialogs while keeping keyboard access consistent. Also add commit selection state to the history view so the active commit is highlighted and brought into view when chosen from either the palette or the history panel. --- src/App.svelte | 72 +++++++++- src/app.css | 1 + src/lib/components/CommandPalette.svelte | 159 +++++++++++++++++++++++ src/lib/components/HistoryPanel.svelte | 15 +++ 4 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/CommandPalette.svelte diff --git a/src/App.svelte b/src/App.svelte index e8f4dc5..46a9b0e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -17,6 +17,7 @@ import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte"; import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte"; + import CommandPalette from "./lib/components/CommandPalette.svelte"; import CommitPanel from "./lib/components/CommitPanel.svelte"; import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte"; import CredentialDialog from "./lib/components/CredentialDialog.svelte"; @@ -287,6 +288,7 @@ let selectedExplorerKind: ExplorerNodeKind = "file"; let expandedExplorerPaths = new Set(); let expandedCommitHashes = new Set(); + let selectedCommitHash = ""; let fileHistory: GitCommit[] = []; let fileHistoryLoading = false; let fileHistoryError = ""; @@ -312,6 +314,7 @@ let aiSettingsOpen = false; let appSettingsOpen = false; let helpOpen = false; + let commandPaletteOpen = false; let analyticsNoticeOpen = false; let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings(); let appTheme: AppTheme = loadThemePreference(); @@ -3749,6 +3752,24 @@ } } + async function openFileFromCommandPalette(file: GitRepositoryFile) { + if (!activeRepoPath) return; + selectedExplorerPath = file.path; + selectedExplorerKind = "file"; + expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]); + try { + await openRepositoryFile(activeRepoPath, file.path); + trackEvent("explorer_file_opened", { source: "command_palette", tracked: file.tracked ? 1 : 0 }); + } catch (error) { + errorMessage = errorToMessage(error); + } + } + + function selectCommitFromCommandPalette(target: GitCommit) { + selectedCommitHash = target.hash; + trackEvent("commit_selected", { source: "command_palette" }); + } + async function restoreSelectedFileFromCommit(target: GitCommit) { if (!activeRepoPath || !selectedExplorerPath) return; const kind = selectedExplorerKind === "folder" ? "folder" : "file"; @@ -3781,6 +3802,14 @@ trackEvent("help_opened"); } + function openAppSettings() { + appSettingsOpen = true; + } + + function openAiSettings() { + aiSettingsOpen = true; + } + async function compareSelectedCommits() { if (!canCompare) return; await runOperation("Comparing commits", async () => { @@ -3970,11 +3999,20 @@ // ── Event handlers ───────────────────────────────────────────────────────── function handleWindowKeydown(event: KeyboardEvent) { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + if (!event.repeat) commandPaletteOpen = !commandPaletteOpen; + return; + } if ((event.ctrlKey || event.metaKey) && event.key === "/") { event.preventDefault(); openHelp(); return; } + if (event.key === "Escape" && commandPaletteOpen) { + commandPaletteOpen = false; + return; + } if (event.key === "Escape" && helpOpen) { helpOpen = false; return; @@ -4012,7 +4050,7 @@
{ appSettingsOpen = true; }} + onOpenSettings={openAppSettings} onOpenHelp={openHelp} language={appLanguage} /> @@ -4544,7 +4582,7 @@ onGenerateCommitMessage={generateCommitMessageWithAi} onReviewStaged={reviewStagedWithAi} onSplitStaged={splitStagedWithAi} - onOpenAiSettings={() => { aiSettingsOpen = true; }} + onOpenAiSettings={openAiSettings} onToggleAmend={toggleAmendMode} onUndoLastCommit={undoLastCommitChange} /> @@ -4574,6 +4612,7 @@
+{#if commandPaletteOpen} + { commandPaletteOpen = false; }} + onCheckoutBranch={checkout} + onOpenFile={openFileFromCommandPalette} + onSelectCommit={selectCommitFromCommandPalette} + onFetch={fetchRepo} + onPull={pullRepo} + onPush={pushRepo} + onRefresh={refreshRepo} + onOpenSearch={openGlobalSearchDialog} + onOpenCompare={openCompareSelect} + onOpenReflog={openReflog} + onOpenInteractiveRebase={openInteractiveRebase} + onOpenWorktrees={openWorktreeDialog} + onOpenSyncSettings={openSyncOptions} + onOpenSettings={openAppSettings} + onOpenAiSettings={openAiSettings} + onOpenHelp={openHelp} + /> +{/if} + {#if updateToastOpen} + import { onMount } from "svelte"; + import { + ArrowDownToLine, ArrowUpFromLine, Boxes, CircleHelp, FileCode, GitBranch, + GitCompare, History, RefreshCw, Search, Settings, SlidersHorizontal, Sparkles, + } from "@lucide/svelte"; + import type { AppLanguage, GitBranch as GitBranchInfo, GitCommit, GitRepositoryFile } from "../types"; + + type ItemKind = "fetch" | "pull" | "push" | "refresh" | "search" | "compare" | "reflog" | "rebase" | "worktrees" | "sync" | "settings" | "ai-settings" | "help" | "branch" | "file" | "commit"; + interface Item { id: string; group: string; kind: ItemKind; title: string; subtitle: string; search: string; disabled?: boolean; run: () => void | Promise; } + interface Props { + language: AppLanguage; hasRepository: boolean; isBusy: boolean; + branches: GitBranchInfo[]; files: GitRepositoryFile[]; commits: GitCommit[]; + onClose: () => void; + onCheckoutBranch: (branch: GitBranchInfo) => void | Promise; + onOpenFile: (file: GitRepositoryFile) => void | Promise; + onSelectCommit: (commit: GitCommit) => void | Promise; + onFetch: () => void | Promise; onPull: () => void | Promise; onPush: () => void | Promise; + onRefresh: () => void | Promise; onOpenSearch: () => void; onOpenCompare: () => void; + onOpenReflog: () => void | Promise; onOpenInteractiveRebase: () => void; + onOpenWorktrees: () => void | Promise; onOpenSyncSettings: () => void | Promise; + onOpenSettings: () => void; onOpenAiSettings: () => void; onOpenHelp: () => void; + } + + let { + language = "en", hasRepository = false, isBusy = false, branches = [], files = [], commits = [], onClose = () => {}, + onCheckoutBranch = () => {}, onOpenFile = () => {}, onSelectCommit = () => {}, onFetch = () => {}, onPull = () => {}, + onPush = () => {}, onRefresh = () => {}, onOpenSearch = () => {}, onOpenCompare = () => {}, onOpenReflog = () => {}, + onOpenInteractiveRebase = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {}, + onOpenAiSettings = () => {}, onOpenHelp = () => {}, + }: Props = $props(); + + let query = $state(""); + let activeIndex = $state(0); + let inputElement = $state(null); + let listElement = $state(null); + const isGerman = $derived(language === "de"); + const repositoryActionDisabled = $derived(!hasRepository || isBusy); + + function action(id: string, kind: ItemKind, title: string, subtitle: string, run: () => void | Promise, requiresRepository = true): Item { + return { id, group: isGerman ? "Aktionen" : "Actions", kind, title, subtitle, search: `${title} ${subtitle}`.toLowerCase(), disabled: requiresRepository ? repositoryActionDisabled : false, run }; + } + + const actionItems = $derived([ + action("fetch", "fetch", "Fetch", isGerman ? "Remote-Änderungen abrufen" : "Download remote changes", onFetch), + action("pull", "pull", "Pull", isGerman ? "Änderungen abrufen und integrieren" : "Download and integrate changes", onPull), + action("push", "push", "Push", isGerman ? "Lokale Commits veröffentlichen" : "Publish local commits", onPush), + action("refresh", "refresh", isGerman ? "Repository aktualisieren" : "Refresh repository", isGerman ? "Status und Historie neu laden" : "Reload status and history", onRefresh), + action("search", "search", isGerman ? "Globale Codesuche" : "Global code search", isGerman ? "Code und Dateihistorie durchsuchen" : "Search code and file history", onOpenSearch), + action("compare", "compare", isGerman ? "Commits vergleichen" : "Compare commits", isGerman ? "Unterschiede zwischen zwei Revisionen" : "Diff two revisions", onOpenCompare), + action("reflog", "reflog", "Reflog", isGerman ? "Verlorene Commits finden und wiederherstellen" : "Find and recover lost commits", onOpenReflog), + action("rebase", "rebase", "Interactive Rebase", isGerman ? "Commit-Historie bearbeiten" : "Edit commit history", onOpenInteractiveRebase), + action("worktrees", "worktrees", "Worktrees", isGerman ? "Arbeitsverzeichnisse verwalten" : "Manage linked working trees", onOpenWorktrees), + action("sync", "sync", isGerman ? "Synchronisierung konfigurieren" : "Configure synchronization", isGerman ? "Remote, Upstream und Pull-Strategie" : "Remote, upstream and pull strategy", onOpenSyncSettings), + action("settings", "settings", isGerman ? "Einstellungen" : "Settings", isGerman ? "Darstellung, Sprache und Verhalten" : "Appearance, language and behavior", onOpenSettings, false), + action("ai-settings", "ai-settings", "AI Settings", isGerman ? "Provider und Modell konfigurieren" : "Configure provider and model", onOpenAiSettings, false), + action("help", "help", isGerman ? "Hilfe öffnen" : "Open help", isGerman ? "Git-Dokumentation und Tastenkürzel" : "Git documentation and keyboard shortcuts", onOpenHelp, false), + ]); + + const dynamicItems = $derived.by(() => { + if (!hasRepository) return []; + const branchItems: Item[] = branches.map((branch) => ({ + id: `branch:${branch.remote ? "remote" : "local"}:${branch.name}`, group: "Branches", kind: "branch", title: branch.name, + subtitle: branch.current ? (isGerman ? "Aktueller Branch" : "Current branch") : branch.remote ? (isGerman ? "Remote-Branch auschecken" : "Check out remote branch") : (isGerman ? "Branch auschecken" : "Check out branch"), + search: `${branch.name} branch ${branch.remote ? "remote" : "local"}`.toLowerCase(), disabled: isBusy || branch.current, run: () => onCheckoutBranch(branch), + })); + const fileItems: Item[] = files.map((file) => ({ + id: `file:${file.path}`, group: isGerman ? "Dateien" : "Files", kind: "file", title: file.path.split(/[\\/]/).pop() ?? file.path, + subtitle: file.path, search: `${file.path} file datei`.toLowerCase(), disabled: isBusy, run: () => onOpenFile(file), + })); + const commitItems: Item[] = commits.map((commit) => ({ + id: `commit:${commit.hash}`, group: isGerman ? "Geladene Commits" : "Loaded commits", kind: "commit", title: commit.summary || (isGerman ? "Ohne Commit-Nachricht" : "No commit message"), + subtitle: `${commit.short_hash} · ${commit.author_name}`, search: `${commit.hash} ${commit.short_hash} ${commit.summary} ${commit.author_name} ${commit.author_email} ${commit.refs.join(" ")}`.toLowerCase(), run: () => onSelectCommit(commit), + })); + return [...branchItems, ...fileItems, ...commitItems]; + }); + + const visibleItems = $derived.by(() => { + const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean); + const allItems = [...actionItems, ...dynamicItems]; + if (terms.length === 0) return allItems.slice(0, 35); + return allItems.filter((item) => terms.every((term) => item.search.includes(term))).slice(0, 80); + }); + + $effect(() => { query; activeIndex = 0; }); + $effect(() => { if (activeIndex >= visibleItems.length) activeIndex = Math.max(0, visibleItems.length - 1); }); + $effect(() => { activeIndex; queueMicrotask(() => listElement?.querySelector("[data-active='true']")?.scrollIntoView({ block: "nearest" })); }); + onMount(() => inputElement?.focus()); + + function execute(item: Item | undefined) { if (!item || item.disabled) return; onClose(); queueMicrotask(() => { void item.run(); }); } + function handleKeydown(event: KeyboardEvent) { + if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); return; } + if (event.key === "ArrowDown") { event.preventDefault(); activeIndex = Math.min(activeIndex + 1, visibleItems.length - 1); return; } + if (event.key === "ArrowUp") { event.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); return; } + if (event.key === "Enter") { event.preventDefault(); execute(visibleItems[activeIndex]); } + } + + + + + diff --git a/src/lib/components/HistoryPanel.svelte b/src/lib/components/HistoryPanel.svelte index 156bc8c..6371395 100644 --- a/src/lib/components/HistoryPanel.svelte +++ b/src/lib/components/HistoryPanel.svelte @@ -44,6 +44,7 @@ isLoadingMore: boolean; loadMoreError: string; expandedCommitHashes: Set; + selectedCommitHash: string; onLoadMore: () => void | Promise; onRestoreCommit: (commit: GitCommit) => void; onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void; @@ -51,6 +52,7 @@ onCreateBranchFromCommit: (commit: GitCommit) => void; onCherryPickCommit: (commit: GitCommit) => void; onRevertCommit: (commit: GitCommit) => void; + onSelectCommit: (commit: GitCommit) => void; } let { @@ -65,6 +67,7 @@ isLoadingMore = false, loadMoreError = "", expandedCommitHashes = new Set(), + selectedCommitHash = "", onLoadMore = () => {}, onRestoreCommit = () => {}, onPreviewCommitFile = () => {}, @@ -72,6 +75,7 @@ onCreateBranchFromCommit = () => {}, onCherryPickCommit = () => {}, onRevertCommit = () => {}, + onSelectCommit = () => {}, }: Props = $props(); let hiddenGraphBranches = $state>(new Set()); @@ -501,6 +505,15 @@ } }); + $effect(() => { + if (!selectedCommitHash) return; + queueMicrotask(() => { + panelElement + ?.querySelector(`[data-commit-hash="${CSS.escape(selectedCommitHash)}"]`) + ?.scrollIntoView({ block: "center", behavior: "smooth" }); + }); + }); + $effect(() => { const defaultBranch = activeBranch && localBranchNames.includes(activeBranch) ? activeBranch @@ -577,6 +590,8 @@ {@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
1}