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.
This commit is contained in:
Christoph Brandau
2026-08-13 07:53:19 +02:00
parent 1fa57eea6f
commit 3eb554fee7
4 changed files with 245 additions and 2 deletions
+70 -2
View File
@@ -17,6 +17,7 @@
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte"; import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte";
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte"; import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
import CommandPalette from "./lib/components/CommandPalette.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte"; import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte"; import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
import CredentialDialog from "./lib/components/CredentialDialog.svelte"; import CredentialDialog from "./lib/components/CredentialDialog.svelte";
@@ -287,6 +288,7 @@
let selectedExplorerKind: ExplorerNodeKind = "file"; let selectedExplorerKind: ExplorerNodeKind = "file";
let expandedExplorerPaths = new Set<string>(); let expandedExplorerPaths = new Set<string>();
let expandedCommitHashes = new Set<string>(); let expandedCommitHashes = new Set<string>();
let selectedCommitHash = "";
let fileHistory: GitCommit[] = []; let fileHistory: GitCommit[] = [];
let fileHistoryLoading = false; let fileHistoryLoading = false;
let fileHistoryError = ""; let fileHistoryError = "";
@@ -312,6 +314,7 @@
let aiSettingsOpen = false; let aiSettingsOpen = false;
let appSettingsOpen = false; let appSettingsOpen = false;
let helpOpen = false; let helpOpen = false;
let commandPaletteOpen = false;
let analyticsNoticeOpen = false; let analyticsNoticeOpen = false;
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings(); let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
let appTheme: AppTheme = loadThemePreference(); 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) { async function restoreSelectedFileFromCommit(target: GitCommit) {
if (!activeRepoPath || !selectedExplorerPath) return; if (!activeRepoPath || !selectedExplorerPath) return;
const kind = selectedExplorerKind === "folder" ? "folder" : "file"; const kind = selectedExplorerKind === "folder" ? "folder" : "file";
@@ -3781,6 +3802,14 @@
trackEvent("help_opened"); trackEvent("help_opened");
} }
function openAppSettings() {
appSettingsOpen = true;
}
function openAiSettings() {
aiSettingsOpen = true;
}
async function compareSelectedCommits() { async function compareSelectedCommits() {
if (!canCompare) return; if (!canCompare) return;
await runOperation("Comparing commits", async () => { await runOperation("Comparing commits", async () => {
@@ -3970,11 +3999,20 @@
// ── Event handlers ───────────────────────────────────────────────────────── // ── Event handlers ─────────────────────────────────────────────────────────
function handleWindowKeydown(event: KeyboardEvent) { 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 === "/") { if ((event.ctrlKey || event.metaKey) && event.key === "/") {
event.preventDefault(); event.preventDefault();
openHelp(); openHelp();
return; return;
} }
if (event.key === "Escape" && commandPaletteOpen) {
commandPaletteOpen = false;
return;
}
if (event.key === "Escape" && helpOpen) { if (event.key === "Escape" && helpOpen) {
helpOpen = false; helpOpen = false;
return; return;
@@ -4012,7 +4050,7 @@
<main class="shell"> <main class="shell">
<TitleBar <TitleBar
onOpenSettings={() => { appSettingsOpen = true; }} onOpenSettings={openAppSettings}
onOpenHelp={openHelp} onOpenHelp={openHelp}
language={appLanguage} language={appLanguage}
/> />
@@ -4544,7 +4582,7 @@
onGenerateCommitMessage={generateCommitMessageWithAi} onGenerateCommitMessage={generateCommitMessageWithAi}
onReviewStaged={reviewStagedWithAi} onReviewStaged={reviewStagedWithAi}
onSplitStaged={splitStagedWithAi} onSplitStaged={splitStagedWithAi}
onOpenAiSettings={() => { aiSettingsOpen = true; }} onOpenAiSettings={openAiSettings}
onToggleAmend={toggleAmendMode} onToggleAmend={toggleAmendMode}
onUndoLastCommit={undoLastCommitChange} onUndoLastCommit={undoLastCommitChange}
/> />
@@ -4574,6 +4612,7 @@
<aside class="history-aside" aria-label="Commit history"> <aside class="history-aside" aria-label="Commit history">
<HistoryPanel <HistoryPanel
{commits} {commits}
{selectedCommitHash}
{localBranchNames} {localBranchNames}
activeBranch={status?.current_branch ?? ""} activeBranch={status?.current_branch ?? ""}
activeUpstream={status?.upstream ?? ""} activeUpstream={status?.upstream ?? ""}
@@ -4590,6 +4629,7 @@
onCreateBranchFromCommit={openNewBranchDialog} onCreateBranchFromCommit={openNewBranchDialog}
onCherryPickCommit={cherryPickFromCommit} onCherryPickCommit={cherryPickFromCommit}
onRevertCommit={revertHistoryCommit} onRevertCommit={revertHistoryCommit}
onSelectCommit={(commit) => { selectedCommitHash = commit.hash; }}
onToggleCommitFiles={(hash) => { onToggleCommitFiles={(hash) => {
const next = new Set(expandedCommitHashes); const next = new Set(expandedCommitHashes);
if (next.has(hash)) next.delete(hash); else next.add(hash); if (next.has(hash)) next.delete(hash); else next.add(hash);
@@ -4615,6 +4655,34 @@
</div> </div>
</main> </main>
{#if commandPaletteOpen}
<CommandPalette
language={appLanguage}
{hasRepository}
{isBusy}
{branches}
files={repoFiles}
{commits}
onClose={() => { 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} {#if updateToastOpen}
<UpdateToast <UpdateToast
state={updateToastState} state={updateToastState}
+1
View File
@@ -2408,6 +2408,7 @@
.commit-row { display: grid; min-width: 0; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); transition: border-color 120ms; } .commit-row { display: grid; min-width: 0; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); transition: border-color 120ms; }
.commit-row + .commit-row { margin-top: 5px; } .commit-row + .commit-row { margin-top: 5px; }
.commit-row:hover { border-color: var(--color-border); } .commit-row:hover { border-color: var(--color-border); }
.commit-row.selected { border-color: color-mix(in srgb, var(--color-primary) 58%, var(--color-border)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent) inset; }
.commit-row.compact { padding: 8px; } .commit-row.compact { padding: 8px; }
.commit-line { display: flex; align-items: flex-start; min-width: 0; gap: 8px; } .commit-line { display: flex; align-items: flex-start; min-width: 0; gap: 8px; }
+159
View File
@@ -0,0 +1,159 @@
<script lang="ts">
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<void>; }
interface Props {
language: AppLanguage; hasRepository: boolean; isBusy: boolean;
branches: GitBranchInfo[]; files: GitRepositoryFile[]; commits: GitCommit[];
onClose: () => void;
onCheckoutBranch: (branch: GitBranchInfo) => void | Promise<void>;
onOpenFile: (file: GitRepositoryFile) => void | Promise<void>;
onSelectCommit: (commit: GitCommit) => void | Promise<void>;
onFetch: () => void | Promise<void>; onPull: () => void | Promise<void>; onPush: () => void | Promise<void>;
onRefresh: () => void | Promise<void>; onOpenSearch: () => void; onOpenCompare: () => void;
onOpenReflog: () => void | Promise<void>; onOpenInteractiveRebase: () => void;
onOpenWorktrees: () => void | Promise<void>; onOpenSyncSettings: () => void | Promise<void>;
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<HTMLInputElement | null>(null);
let listElement = $state<HTMLElement | null>(null);
const isGerman = $derived(language === "de");
const repositoryActionDisabled = $derived(!hasRepository || isBusy);
function action(id: string, kind: ItemKind, title: string, subtitle: string, run: () => void | Promise<void>, 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<HTMLElement>("[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]); }
}
</script>
<div class="command-palette-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) onClose(); }}>
<div class="command-palette" role="dialog" aria-modal="true" aria-label={isGerman ? "Befehlspalette" : "Command palette"}>
<div class="command-palette-search">
<Search size={19} aria-hidden="true" />
<input bind:this={inputElement} bind:value={query} onkeydown={handleKeydown} placeholder={isGerman ? "Aktion, Branch, Datei oder Commit suchen…" : "Search actions, branches, files, or commits…"} aria-label={isGerman ? "Befehl suchen" : "Search commands"} autocomplete="off" spellcheck="false" />
<kbd>ESC</kbd>
</div>
<div class="command-palette-results" bind:this={listElement} role="listbox" aria-label={isGerman ? "Ergebnisse" : "Results"}>
{#if visibleItems.length === 0}
<div class="command-palette-empty"><Search size={24} aria-hidden="true" /><strong>{isGerman ? "Keine Treffer" : "No results"}</strong><span>{isGerman ? "Versuche einen anderen Suchbegriff." : "Try a different search term."}</span></div>
{:else}
{#each visibleItems as item, index (item.id)}
{#if index === 0 || visibleItems[index - 1].group !== item.group}<div class="command-palette-group">{item.group}</div>{/if}
<button class="command-palette-item" class:active={index === activeIndex} type="button" role="option" aria-selected={index === activeIndex} data-active={index === activeIndex} disabled={item.disabled} onmouseenter={() => { activeIndex = index; }} onclick={() => execute(item)}>
<span class={`command-palette-icon ${item.kind}`}>
{#if item.kind === "fetch" || item.kind === "pull"}<ArrowDownToLine size={16} />
{:else if item.kind === "push"}<ArrowUpFromLine size={16} />
{:else if item.kind === "refresh"}<RefreshCw size={16} />
{:else if item.kind === "search"}<Search size={16} />
{:else if item.kind === "compare"}<GitCompare size={16} />
{:else if item.kind === "reflog" || item.kind === "commit"}<History size={16} />
{:else if item.kind === "rebase" || item.kind === "branch"}<GitBranch size={16} />
{:else if item.kind === "worktrees"}<Boxes size={16} />
{:else if item.kind === "sync"}<SlidersHorizontal size={16} />
{:else if item.kind === "settings"}<Settings size={16} />
{:else if item.kind === "ai-settings"}<Sparkles size={16} />
{:else if item.kind === "help"}<CircleHelp size={16} />
{:else}<FileCode size={16} />{/if}
</span>
<span class="command-palette-copy"><strong>{item.title}</strong><small>{item.subtitle}</small></span>
{#if item.kind === "branch" && item.disabled && !isBusy}<span class="command-palette-current">{isGerman ? "AKTUELL" : "CURRENT"}</span>{/if}
</button>
{/each}
{/if}
</div>
<footer class="command-palette-footer"><span><kbd></kbd><kbd></kbd>{isGerman ? "Navigieren" : "Navigate"}</span><span><kbd></kbd>{isGerman ? "Öffnen" : "Open"}</span>{#if commits.length > 0}<span class="command-palette-hint">{isGerman ? `${commits.length} geladene Commits` : `${commits.length} loaded commits`}</span>{/if}</footer>
</div>
</div>
<style>
.command-palette-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: start center; padding: min(14vh, 120px) 20px 20px; background: color-mix(in srgb, var(--app-dialog-backdrop) 76%, transparent); backdrop-filter: blur(7px) saturate(.82); }
.command-palette { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(720px, 100%); max-height: min(650px, 76vh); overflow: hidden; border: 1px solid color-mix(in srgb, var(--color-primary) 22%, var(--color-border)); border-radius: 14px; background: var(--app-dialog-bg); box-shadow: 0 28px 90px rgba(0,0,0,.42); }
.command-palette-search { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 15px 17px; border-bottom: 1px solid var(--color-border); color: var(--color-primary); }
.command-palette-search input { min-width: 0; border: 0; outline: 0; color: var(--color-ink); background: transparent; font: inherit; font-size: 15px; }
.command-palette-search input::placeholder { color: var(--color-ink-dim); }
kbd { display: inline-grid; place-items: center; min-width: 23px; height: 21px; padding: 0 5px; border: 1px solid var(--color-border); border-radius: 5px; color: var(--color-ink-dim); background: var(--color-surface-raised); font-family: var(--font-mono); font-size: 9px; font-weight: 700; }
.command-palette-results { min-height: 120px; overflow-y: auto; padding: 7px; }
.command-palette-group { padding: 10px 9px 5px; color: var(--color-ink-dim); font-size: 10px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
.command-palette-item { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; width: 100%; gap: 10px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; text-align: left; color: var(--color-ink); background: transparent; cursor: pointer; }
.command-palette-item.active:not(:disabled) { border-color: color-mix(in srgb, var(--color-primary) 24%, transparent); background: color-mix(in srgb, var(--color-primary) 11%, var(--color-surface-raised)); }
.command-palette-item:disabled { cursor: default; opacity: .48; }
.command-palette-icon { display: grid; place-items: center; width: 31px; height: 31px; border: 1px solid var(--color-border-subtle); border-radius: 8px; color: var(--color-ink-muted); background: var(--color-surface-raised); }
.command-palette-icon.branch { color: #65c98b; } .command-palette-icon.file { color: #69a7ff; } .command-palette-icon.commit { color: #ba82ff; }
.command-palette-copy { display: grid; min-width: 0; gap: 2px; } .command-palette-copy strong, .command-palette-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.command-palette-copy strong { font-size: 12.5px; font-weight: 700; } .command-palette-copy small { color: var(--color-ink-dim); font-size: 10.5px; }
.command-palette-current { color: var(--color-ink-dim); font-family: var(--font-mono); font-size: 9px; font-weight: 700; }
.command-palette-empty { display: grid; place-items: center; gap: 5px; padding: 54px 20px; color: var(--color-ink-dim); } .command-palette-empty strong { margin-top: 5px; color: var(--color-ink); font-size: 13px; } .command-palette-empty span { font-size: 11px; }
.command-palette-footer { display: flex; align-items: center; gap: 16px; min-height: 38px; padding: 7px 12px; border-top: 1px solid var(--color-border); color: var(--color-ink-dim); font-size: 10px; }
.command-palette-footer span { display: flex; align-items: center; gap: 5px; } .command-palette-footer span kbd + kbd { margin-left: -3px; } .command-palette-hint { margin-left: auto; }
@media (max-width: 640px) { .command-palette-backdrop { padding: 60px 10px 10px; } .command-palette { max-height: calc(100vh - 80px); } .command-palette-hint { display: none !important; } }
</style>
+15
View File
@@ -44,6 +44,7 @@
isLoadingMore: boolean; isLoadingMore: boolean;
loadMoreError: string; loadMoreError: string;
expandedCommitHashes: Set<string>; expandedCommitHashes: Set<string>;
selectedCommitHash: string;
onLoadMore: () => void | Promise<void>; onLoadMore: () => void | Promise<void>;
onRestoreCommit: (commit: GitCommit) => void; onRestoreCommit: (commit: GitCommit) => void;
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void; onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
@@ -51,6 +52,7 @@
onCreateBranchFromCommit: (commit: GitCommit) => void; onCreateBranchFromCommit: (commit: GitCommit) => void;
onCherryPickCommit: (commit: GitCommit) => void; onCherryPickCommit: (commit: GitCommit) => void;
onRevertCommit: (commit: GitCommit) => void; onRevertCommit: (commit: GitCommit) => void;
onSelectCommit: (commit: GitCommit) => void;
} }
let { let {
@@ -65,6 +67,7 @@
isLoadingMore = false, isLoadingMore = false,
loadMoreError = "", loadMoreError = "",
expandedCommitHashes = new Set(), expandedCommitHashes = new Set(),
selectedCommitHash = "",
onLoadMore = () => {}, onLoadMore = () => {},
onRestoreCommit = () => {}, onRestoreCommit = () => {},
onPreviewCommitFile = () => {}, onPreviewCommitFile = () => {},
@@ -72,6 +75,7 @@
onCreateBranchFromCommit = () => {}, onCreateBranchFromCommit = () => {},
onCherryPickCommit = () => {}, onCherryPickCommit = () => {},
onRevertCommit = () => {}, onRevertCommit = () => {},
onSelectCommit = () => {},
}: Props = $props(); }: Props = $props();
let hiddenGraphBranches = $state<Set<string>>(new Set()); let hiddenGraphBranches = $state<Set<string>>(new Set());
@@ -501,6 +505,15 @@
} }
}); });
$effect(() => {
if (!selectedCommitHash) return;
queueMicrotask(() => {
panelElement
?.querySelector<HTMLElement>(`[data-commit-hash="${CSS.escape(selectedCommitHash)}"]`)
?.scrollIntoView({ block: "center", behavior: "smooth" });
});
});
$effect(() => { $effect(() => {
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch) const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
? activeBranch ? activeBranch
@@ -577,6 +590,8 @@
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])} {@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
<article <article
class="commit-row graph-row" class="commit-row graph-row"
class:selected={selectedCommitHash === item.hash}
data-commit-hash={item.hash}
class:graph-ahead-row={rowSyncClass === "ahead"} class:graph-ahead-row={rowSyncClass === "ahead"}
class:graph-behind-row={rowSyncClass === "behind"} class:graph-behind-row={rowSyncClass === "behind"}
class:merge-row={item.parents.length > 1} class:merge-row={item.parents.length > 1}