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}