From d3cde835186a4103a1e03580cb06a37ef503f3d8 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 10 Jul 2026 22:16:06 +0200 Subject: [PATCH 1/5] style(css): standardize code surface and highlight colors Introduces a comprehensive set of CSS variables defining surfaces, syntax elements, and diff coloring across both light and dark themes. This refactoring replaces numerous hardcoded color values throughout the stylesheet with these new variables, ensuring that all components consistently adhere to the defined theme palette. - Added detailed variable definitions for code surfaces (e.g., `--code-surface`, `--code-input-bg`). - Updated diff, hunk, and blame sections to use variable colors for better thematic consistency. - Replaced fixed hex codes with variables across various component backgrounds and text colors. --- src/app.css | 242 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 169 insertions(+), 73 deletions(-) diff --git a/src/app.css b/src/app.css index 71fcbe3..6dc8864 100644 --- a/src/app.css +++ b/src/app.css @@ -44,6 +44,27 @@ --app-settings-row-bg: #141b29; --app-scrollbar-thumb: #303a4f; --app-scrollbar-thumb-hover: #44506a; + + --code-surface: #111321; + --code-surface-raised: #171a2b; + --code-surface-subtle: #0d101a; + --code-surface-muted: #10131e; + --code-surface-meta: #0c0e18; + --code-input-bg: #0b0e18; + --code-hover-bg: #151a2b; + --code-add-text: #5dd88a; + --code-add-strong: #4eca76; + --code-add-bg: rgba(78, 202, 118, 0.09); + --code-add-gutter-bg: rgba(78, 202, 118, 0.1); + --code-delete-text: #ef8080; + --code-delete-strong: #e86060; + --code-delete-bg: rgba(232, 96, 96, 0.1); + --code-delete-gutter-bg: rgba(232, 96, 96, 0.12); + --code-hunk-text: #7aacff; + --code-hunk-bg: rgba(122, 172, 255, 0.08); + --code-match-text: #f3c969; + --code-match-bg: rgba(240, 182, 72, 0.22); + --code-match-gutter-bg: rgba(240, 182, 72, 0.2); } :root[data-theme="light"] { @@ -89,6 +110,27 @@ --app-settings-row-bg: #f8fafd; --app-scrollbar-thumb: #c2cada; --app-scrollbar-thumb-hover: #aeb8cb; + + --code-surface: #fbfcfe; + --code-surface-raised: #f1f5fa; + --code-surface-subtle: #f3f6fb; + --code-surface-muted: #edf1f7; + --code-surface-meta: #e9eef7; + --code-input-bg: #ffffff; + --code-hover-bg: #e8eef7; + --code-add-text: #146c37; + --code-add-strong: #19723d; + --code-add-bg: rgba(25, 114, 61, 0.1); + --code-add-gutter-bg: rgba(25, 114, 61, 0.14); + --code-delete-text: #a91f36; + --code-delete-strong: #b4233b; + --code-delete-bg: rgba(180, 35, 59, 0.09); + --code-delete-gutter-bg: rgba(180, 35, 59, 0.13); + --code-hunk-text: #245cc7; + --code-hunk-bg: rgba(36, 92, 199, 0.09); + --code-match-text: #744500; + --code-match-bg: rgba(230, 158, 31, 0.2); + --code-match-gutter-bg: rgba(230, 158, 31, 0.24); } @layer base { @@ -3124,7 +3166,7 @@ gap: 8px; padding: 7px 12px; border-bottom: 1px solid var(--color-border-subtle); - background: #171a2b; + background: var(--code-surface-raised); font-family: var(--font-mono); font-size: 12px; color: var(--color-ink-muted); @@ -3146,7 +3188,7 @@ font-size: 12px; line-height: 1.5; tab-size: 2; - background: #111321; + background: var(--code-surface); } .split-pane { @@ -3170,8 +3212,8 @@ white-space: pre-wrap; word-break: break-all; } - .split-span.split-meta { color: var(--color-ink-faint); background: #0c0e18; font-size: 11px; } - .split-span.split-hunk { color: #7aacff; background: rgba(122,172,255,0.08); padding: 3px 10px; } + .split-span.split-meta { color: var(--color-ink-faint); background: var(--code-surface-meta); font-size: 11px; } + .split-span.split-hunk { color: var(--code-hunk-text); background: var(--code-hunk-bg); padding: 3px 10px; } .split-num { padding: 0 6px 0 4px; @@ -3180,11 +3222,11 @@ font-size: 11px; user-select: none; border-right: 1px solid var(--color-border-subtle); - background: #0d101a; + background: var(--code-surface-subtle); } - .split-num.del { background: rgba(232,96,96,0.12); color: rgba(232,96,96,0.6); border-right-color: rgba(232,96,96,0.2); } - .split-num.add { background: rgba(78,202,118,0.1); color: rgba(78,202,118,0.6); border-right-color: rgba(78,202,118,0.2); } - .split-num.empty { background: #10131e; } + .split-num.del { background: var(--code-delete-gutter-bg); color: var(--code-delete-strong); border-right-color: rgba(232,96,96,0.2); } + .split-num.add { background: var(--code-add-gutter-bg); color: var(--code-add-strong); border-right-color: rgba(78,202,118,0.2); } + .split-num.empty { background: var(--code-surface-muted); } .split-cell { padding: 0 8px; @@ -3193,20 +3235,20 @@ min-width: 0; overflow: visible; } - .split-cell.del { background: rgba(232,96,96,0.1); color: #ef8080; } - .split-cell.add { background: rgba(78,202,118,0.09); color: #5dd88a; } - .split-cell.empty { background: #10131e; } + .split-cell.del { background: var(--code-delete-bg); color: var(--code-delete-text); } + .split-cell.add { background: var(--code-add-bg); color: var(--code-add-text); } + .split-cell.empty { background: var(--code-surface-muted); } /* Search-hit highlight: amber, distinct from add (green) / del (red). Higher specificity so it overrides the add/del backgrounds on a matched line. */ .split-diff .split-cell.match { - background: rgba(240,182,72,0.22); - color: #f3c969; + background: var(--code-match-bg); + color: var(--code-match-text); box-shadow: inset 2px 0 0 rgba(240,182,72,0.9); } .split-diff .split-num.match { - background: rgba(240,182,72,0.2); - color: rgba(240,182,72,0.9); + background: var(--code-match-gutter-bg); + color: var(--code-match-text); border-right-color: rgba(240,182,72,0.35); } @@ -3226,7 +3268,7 @@ text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-ink-faint); - background: #0d101a; + background: var(--code-surface-subtle); } .split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); } .split-col-hash { @@ -3338,7 +3380,7 @@ .line-patch-scroll { min-height: 0; overflow: auto; - background: #0b0b14; + background: var(--code-surface); } .line-patch-hunk { @@ -3358,7 +3400,7 @@ min-width: 100%; padding: 7px 10px; border-bottom: 1px solid var(--color-border-subtle); - background: rgba(20, 22, 36, 0.96); + background: color-mix(in srgb, var(--code-surface-raised) 96%, transparent); } .line-patch-hunk-head code { color: var(--color-accent); @@ -3378,23 +3420,23 @@ padding: 0 8px; border: 1px solid var(--color-border-subtle); border-radius: 3px; - background: rgba(255, 255, 255, 0.03); + background: var(--code-surface-subtle); color: var(--color-ink); font-size: 12px; font-weight: 700; line-height: 1; } .line-patch-hunk-button:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.08); + background: var(--code-hover-bg); } .line-patch-hunk-button.discard { border-color: rgba(255, 90, 103, 0.7); - color: #ffccd1; + color: var(--code-delete-text); } .line-patch-hunk-button.stage, .line-patch-hunk-button.unstage { border-color: rgba(78, 202, 118, 0.72); - color: #bff1ce; + color: var(--code-add-text); } .line-patch-lines { @@ -3412,12 +3454,12 @@ color: var(--color-ink-muted); } .line-patch-row.add { - background: rgba(78, 202, 118, 0.09); - color: #bff1ce; + background: var(--code-add-bg); + color: var(--code-add-text); } .line-patch-row.delete { - background: rgba(255, 90, 103, 0.1); - color: #ffccd1; + background: var(--code-delete-bg); + color: var(--code-delete-text); } .line-patch-row.meta { color: var(--color-ink-faint); @@ -3427,8 +3469,8 @@ text-align: center; user-select: none; } - .line-patch-row.add .line-patch-prefix { color: #4eca76; } - .line-patch-row.delete .line-patch-prefix { color: #ff6b7a; } + .line-patch-row.add .line-patch-prefix { color: var(--code-add-strong); } + .line-patch-row.delete .line-patch-prefix { color: var(--code-delete-strong); } .line-patch-row code { white-space: pre; font-family: var(--font-mono); @@ -3437,7 +3479,7 @@ .blame-body { min-height: 0; overflow: hidden; - background: #111321; + background: var(--code-surface); } .blame-code-header strong { @@ -3455,7 +3497,7 @@ min-height: 38px; padding: 6px 10px; border-bottom: 1px solid var(--color-border-subtle); - background: #111321; + background: var(--code-surface); } .blame-search-bar svg { position: absolute; @@ -3469,7 +3511,7 @@ padding: 0 34px; border: 1px solid var(--color-border-subtle); border-radius: 6px; - background: #0b0e18; + background: var(--code-input-bg); color: var(--color-ink); font-size: 12px; } @@ -3522,7 +3564,7 @@ border-bottom: 1px solid var(--color-border-subtle); } .blame-group.uncommitted { - background: #171725; + background: var(--code-surface-raised); } .blame-meta { @@ -3535,8 +3577,8 @@ min-width: 0; padding: 8px 12px; border-right: 1px solid var(--color-border-subtle); - background: #0d101a; - box-shadow: 8px 0 18px rgba(0, 0, 0, 0.18); + background: var(--code-surface-subtle); + box-shadow: 8px 0 18px color-mix(in srgb, var(--color-ink) 8%, transparent); } .blame-hash { align-self: flex-start; @@ -3545,7 +3587,7 @@ padding: 2px 7px; border: 1px solid rgba(90,140,248,0.2); border-radius: 5px; - background: #141b2d; + background: var(--code-surface-raised); color: var(--color-accent); font-size: 10.5px; font-weight: 700; @@ -3562,7 +3604,7 @@ } .blame-summary { overflow: hidden; - color: #aeb6d8; + color: var(--color-ink-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; @@ -3572,7 +3614,7 @@ font-size: 10.5px; } .blame-group:hover .blame-meta { - background: #111728; + background: var(--code-hover-bg); } .blame-group.uncommitted .blame-hash, .blame-group.uncommitted .blame-author { @@ -3580,7 +3622,7 @@ } .blame-group.uncommitted .blame-hash { border-color: rgba(232, 180, 90, 0.26); - background: #271f14; + background: color-mix(in srgb, #e8b45a 13%, var(--code-surface)); } .blame-lines { @@ -3595,16 +3637,16 @@ min-height: 20px; } .blame-group:hover .blame-line-number { - background: #111728; + background: var(--code-hover-bg); } .blame-group:hover .blame-line-code { - background: #151a2b; + background: var(--code-hover-bg); } .blame-search-hit { padding: 0 1px; border-radius: 3px; - background: rgba(240,182,72,0.28); - color: #f3d487; + background: var(--code-match-bg); + color: var(--code-match-text); } .global-search-body { @@ -3800,8 +3842,9 @@ margin: 0; padding: 8px 10px; border-radius: 7px; - color: #5dd88a; - background: rgba(78,202,118,0.08); + border: 1px solid color-mix(in srgb, var(--code-add-strong) 18%, transparent); + color: var(--code-add-text); + background: var(--code-add-bg); font-family: var(--font-mono); font-size: 12px; line-height: 1.45; @@ -4321,14 +4364,14 @@ } .diff-line { display: block; white-space: pre-wrap; word-break: break-word; } .diff-line.meta { color: var(--color-ink-faint); } - .diff-line.hunk { color: #7aacff; background: rgba(122,172,255,0.07); } - .diff-line.add { color: #4eca76; background: rgba(78,202,118,0.09); } - .diff-line.del { color: #e86060; background: rgba(232,96,96,0.09); } + .diff-line.hunk { color: var(--code-hunk-text); background: var(--code-hunk-bg); } + .diff-line.add { color: var(--code-add-text); background: var(--code-add-bg); } + .diff-line.del { color: var(--code-delete-text); background: var(--code-delete-bg); } .diff-line.context { color: var(--color-ink-muted); } .diff-counts { display: flex; gap: 8px; font-family: var(--font-mono); font-size: 12px; font-weight: 700; } - .diff-counts .adds { color: #4eca76; } - .diff-counts .dels { color: #e86060; } + .diff-counts .adds { color: var(--code-add-strong); } + .diff-counts .dels { color: var(--code-delete-strong); } /* --- Conflict resolver --- */ @@ -4399,7 +4442,7 @@ overflow: hidden; border: 1px solid var(--color-border-subtle); border-radius: 6px; - background: rgba(0,0,0,0.12); + background: var(--code-surface); font-family: var(--font-mono); font-size: 12px; line-height: 1.5; @@ -4431,19 +4474,19 @@ font-weight: 800; text-transform: uppercase; } - .resolve-split-marker.unresolved { color: #ef8080; background: rgba(232,96,96,0.12); } + .resolve-split-marker.unresolved { color: var(--code-delete-text); background: var(--code-delete-gutter-bg); } .resolve-num { padding: 0 6px 0 4px; border-right: 1px solid var(--color-border-subtle); color: var(--color-ink-faint); - background: rgba(0,0,0,0.14); + background: var(--code-surface-subtle); text-align: right; user-select: none; } - .resolve-num.ours { color: rgba(78,202,118,0.65); background: rgba(78,202,118,0.11); border-right-color: rgba(78,202,118,0.2); } - .resolve-num.theirs { color: rgba(122,172,255,0.65); background: rgba(122,172,255,0.11); border-right-color: rgba(122,172,255,0.2); } - .resolve-num.empty { background: rgba(0,0,0,0.07); } + .resolve-num.ours { color: var(--code-add-strong); background: var(--code-add-gutter-bg); border-right-color: rgba(78,202,118,0.2); } + .resolve-num.theirs { color: var(--code-hunk-text); background: var(--code-hunk-bg); border-right-color: rgba(122,172,255,0.2); } + .resolve-num.empty { background: var(--code-surface-muted); } .resolve-cell { min-width: 0; @@ -4452,9 +4495,9 @@ color: var(--color-ink-muted); white-space: pre; } - .resolve-cell.ours { color: #5dd88a; background: rgba(78,202,118,0.1); } - .resolve-cell.theirs { color: #8fb4ff; background: rgba(90,140,248,0.11); } - .resolve-cell.empty { background: rgba(0,0,0,0.06); } + .resolve-cell.ours { color: var(--code-add-text); background: var(--code-add-bg); } + .resolve-cell.theirs { color: var(--code-hunk-text); background: var(--code-hunk-bg); } + .resolve-cell.empty { background: var(--code-surface-muted); } .resolve-cell.dimmed { opacity: 0.42; filter: grayscale(0.5); } .resolve-context { margin: 0; padding: 2px 8px; overflow-x: auto; font-family: var(--font-mono); font-size: 12px; line-height: 1.5; tab-size: 2; color: var(--color-ink-muted); } @@ -4474,13 +4517,13 @@ .resolve-side.dimmed { opacity: 0.4; filter: grayscale(0.5); } .resolve-side-label { font-size: 11px; font-weight: 800; text-transform: uppercase; } - .resolve-side.ours .resolve-side-label { color: #4eca76; } - .resolve-side.theirs .resolve-side-label { color: #6a9aff; } + .resolve-side.ours .resolve-side-label { color: var(--code-add-strong); } + .resolve-side.theirs .resolve-side-label { color: var(--code-hunk-text); } .resolve-lines { margin: 0; overflow-x: auto; font-family: var(--font-mono); font-size: 12px; line-height: 1.5; tab-size: 2; } .resolve-line { display: block; white-space: pre-wrap; word-break: break-word; } - .resolve-line.ours { color: #4eca76; } - .resolve-line.theirs { color: #7aacff; } + .resolve-line.ours { color: var(--code-add-text); } + .resolve-line.theirs { color: var(--code-hunk-text); } .resolve-line.context { color: var(--color-ink-muted); } .resolve-binary { display: grid; align-content: start; gap: 12px; padding: 4px; } @@ -4834,22 +4877,75 @@ background: var(--color-surface-hover); } -:root[data-theme="light"] .split-span.split-meta { - background: #e9eef7; -} - -:root[data-theme="light"] .resolve-split, -:root[data-theme="light"] .resolve-num, -:root[data-theme="light"] .resolve-cell.empty, -:root[data-theme="light"] .resolve-num.empty { - background: rgba(234,239,248,0.72); -} - :root[data-theme="light"] .diff-line.meta, :root[data-theme="light"] .resolve-split-marker { color: #64728a; } +:root[data-theme="light"] .global-search-tabs, +:root[data-theme="light"] .file-search-history, +:root[data-theme="light"] .file-search-history-head { + background: var(--color-surface-dim); +} + +:root[data-theme="light"] .discard-target { + background: var(--code-surface-subtle); +} + +:root[data-theme="light"] .commit-amend-toggle input { + border-color: var(--color-border-input); + background: var(--app-input-bg); +} + +:root[data-theme="light"] .commit-amend-toggle input:checked { + border-color: var(--color-primary); + box-shadow: inset 0 0 0 2px #ffffff; +} + +:root[data-theme="light"] .cred-segment { + border-color: rgba(49,95,214,0.2); + background: #eef3f9; +} + +:root[data-theme="light"] .cred-seg-btn.active { + border-color: rgba(49,95,214,0.26); + background: linear-gradient(135deg, rgba(49,95,214,0.12), rgba(15,143,181,0.08)); + box-shadow: 0 8px 18px rgba(28,44,74,0.1), inset 0 1px 0 rgba(255,255,255,0.9); +} + +:root[data-theme="light"] .cred-close { + border-color: var(--color-border-subtle); + color: var(--color-ink-dim); + background: rgba(255,255,255,0.76); +} + +:root[data-theme="light"] .cred-close:hover:not(:disabled) { + border-color: var(--color-border-input); + color: var(--color-ink); + background: var(--color-surface-hover); +} + +:root[data-theme="light"] .status-badge.modified, +:root[data-theme="light"] .resolve-status, +:root[data-theme="light"] .resolve-conflict-label { + color: #8a580a; +} + +:root[data-theme="light"] .status-badge.added, +:root[data-theme="light"] .status-badge.untracked, +:root[data-theme="light"] .pill-active, +:root[data-theme="light"] .prepared-tag { + color: #19723d; +} + +:root[data-theme="light"] .status-badge.deleted { + color: #b4233b; +} + +:root[data-theme="light"] .status-badge.renamed { + color: #245cc7; +} + :root[data-theme="light"] .cred-input input, :root[data-theme="light"] .cred-expiry input[type="date"] { background: rgba(255,255,255,0.92); From d0bca6236206b41694873aefc6772cecd02fa1e1 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 10 Jul 2026 22:21:53 +0200 Subject: [PATCH 2/5] refactor(ui): Improve title bar layout using flex and grid The structure of the application title bar has been updated to utilize modern CSS layout techniques like Grid and Flexbox. This refactoring improves the responsiveness and organization of key elements such as repository name, branch indicator, and synchronization status. The changes ensure better alignment and handling of varying content lengths across different viewports. - Restructures repo/branch info into a dedicated context container - Uses flex properties for dynamic sizing of title bar sections - Groups sync indicators into a cohesive unit --- src/app.css | 19 ++++++++++++++----- src/lib/TitleBar.svelte | 28 +++++++++++++++++----------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/app.css b/src/app.css index 6dc8864..d673c57 100644 --- a/src/app.css +++ b/src/app.css @@ -610,7 +610,8 @@ } .titlebar-info { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 6px; padding: 0 12px; @@ -618,12 +619,20 @@ height: 100%; overflow: hidden; } - .titlebar-info svg { color: var(--color-accent); flex-shrink: 0; } + .titlebar-context { + display: flex; + align-items: center; + min-width: 0; + gap: 6px; + overflow: hidden; + } + .titlebar-context svg { color: var(--color-accent); flex: 0 0 auto; } - .tb-repo { color: var(--color-bar-muted); font-size: 12px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px; } - .tb-sep { color: rgba(255,255,255,0.22); font-size: 13px; } - .tb-branch { color: #f5f7ff; font-size: 12px; font-weight: 700; font-family: var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; } + .tb-repo { flex: 0 1 160px; min-width: 0; color: var(--color-bar-muted); font-size: 12px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .tb-sep { flex: 0 0 auto; color: rgba(255,255,255,0.22); font-size: 13px; } + .tb-branch { flex: 1 1 auto; min-width: 0; max-width: 220px; color: #f5f7ff; font-size: 12px; font-weight: 700; font-family: var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .tb-sync-group { display: inline-flex; align-items: center; gap: 4px; min-width: max-content; } .tb-sync { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 800; white-space: nowrap; flex-shrink: 0; } .tb-sync.ahead { color: #e0a040; background: rgba(224,160,64,0.13); } .tb-sync.behind { color: #7aacff; background: rgba(122,172,255,0.13); } diff --git a/src/lib/TitleBar.svelte b/src/lib/TitleBar.svelte index 3d14bea..32eff55 100644 --- a/src/lib/TitleBar.svelte +++ b/src/lib/TitleBar.svelte @@ -79,17 +79,23 @@
{#if hasRepository} - {#if repoName} - {repoName} - - {/if} -
+ diff --git a/src/lib/components/ReflogDialog.svelte b/src/lib/components/ReflogDialog.svelte new file mode 100644 index 0000000..764fe4c --- /dev/null +++ b/src/lib/components/ReflogDialog.svelte @@ -0,0 +1,80 @@ + + + diff --git a/src/lib/git.ts b/src/lib/git.ts index 56e7ee4..6bf434c 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -10,6 +10,9 @@ import type { GitCommit, GitCommitComparison, GitRepositoryFile, + RebaseCommit, + RebasePlanItem, + ReflogEntry, GitSearchHit, GitStash, GitStatus, @@ -306,6 +309,26 @@ export function rebaseAbort(path: string): Promise { return invoke("rebase_abort", { path }); } +export function listInteractiveRebaseCommits(path: string, base: string): Promise { + return invoke("list_interactive_rebase_commits", { path, base }); +} + +export function startInteractiveRebase( + path: string, + base: string, + plan: RebasePlanItem[], +): Promise { + return invoke("start_interactive_rebase", { path, base, plan }); +} + +export function listReflog(path: string, limit = 250): Promise { + return invoke("list_reflog", { path, limit }); +} + +export function restoreReflogEntry(path: string, commit: string, branch: string): Promise { + return invoke("restore_reflog_entry", { path, commit, branch }); +} + export function listRepositoryFiles(path: string): Promise { return invoke("list_repository_files", { path }); } diff --git a/src/lib/types.ts b/src/lib/types.ts index b761c75..0b9a25e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -198,6 +198,31 @@ export interface GitBlameResult { lines: GitBlameLine[]; } +export type RebaseAction = "pick" | "reword" | "squash" | "fixup" | "drop"; + +export interface RebaseCommit { + hash: string; + short_hash: string; + summary: string; + author_name: string; + date: string; +} + +export interface RebasePlanItem { + hash: string; + action: RebaseAction; + message: string | null; +} + +export interface ReflogEntry { + hash: string; + short_hash: string; + selector: string; + action: string; + author_name: string; + date: string; +} + export interface StoredCredential { username: string; password: string; From 791275f34178e1c4b2bd700fe4d64b2cacb6ffc2 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 10 Jul 2026 22:53:24 +0200 Subject: [PATCH 4/5] refactor(git): improve interactive rebase workflow robustness This commit removes the dedicated feature QA page and significantly refactors the internal Git library logic for handling complex workflows, particularly interactive rebase. The changes introduce helper functions to manage temporary files and ensure proper cleanup of rebase artifacts regardless of success or failure. This improves the reliability of advanced git operations within the application. - Added comprehensive cleanup routines for rebase helpers - Centralized constants for rebase file names - Removed obsolete QA feature code --- feature-qa.html | 1 - src-tauri/src/git.rs | 94 ++++++++++++++++++++++++++++++++------------ src/feature-qa.ts | 27 ------------- 3 files changed, 69 insertions(+), 53 deletions(-) delete mode 100644 feature-qa.html delete mode 100644 src/feature-qa.ts diff --git a/feature-qa.html b/feature-qa.html deleted file mode 100644 index 11cb091..0000000 --- a/feature-qa.html +++ /dev/null @@ -1 +0,0 @@ -Git features QA
diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 59c03a7..d67b573 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -210,6 +210,10 @@ pub struct ReflogEntry { const SEQUENCE_EDITOR_PLAN_ENV: &str = "GITTY_SEQUENCE_EDITOR_PLAN"; const COMMIT_EDITOR_QUEUE_ENV: &str = "GITTY_COMMIT_EDITOR_QUEUE"; +const REBASE_TODO_FILE: &str = "gitty-interactive-rebase-todo"; +const REWORD_QUEUE_FILE: &str = "gitty-interactive-rebase-messages"; +const SEQUENCE_HELPER_STEM: &str = ".gitty-sequence-editor"; +const COMMIT_HELPER_STEM: &str = ".gitty-commit-editor"; pub fn run_sequence_editor_if_requested() -> Option> { let executable = env::current_exe().ok()?; @@ -1632,33 +1636,24 @@ pub async fn start_interactive_rebase( let todo = build_rebase_todo(&available, &plan)?; let reword_queue = build_reword_queue(&available, &plan)?; let git_dir = git_dir_for_repo(&repo)?; - let todo_path = git_dir.join("gitty-interactive-rebase-todo"); - let reword_queue_path = git_dir.join("gitty-interactive-rebase-messages"); + cleanup_interactive_rebase_helpers(&repo); + let todo_path = git_dir.join(REBASE_TODO_FILE); + let reword_queue_path = git_dir.join(REWORD_QUEUE_FILE); fs::write(&todo_path, todo) .map_err(|err| format!("Could not prepare interactive rebase plan: {err}"))?; fs::write(&reword_queue_path, reword_queue) .map_err(|err| format!("Could not prepare reword messages: {err}"))?; - let sequence_helper_name = if cfg!(windows) { - format!(".gitty-sequence-editor-{}.exe", std::process::id()) - } else { - format!(".gitty-sequence-editor-{}", std::process::id()) - }; - let commit_helper_name = if cfg!(windows) { - format!(".gitty-commit-editor-{}.exe", std::process::id()) - } else { - format!(".gitty-commit-editor-{}", std::process::id()) - }; - let sequence_helper_path = repo.join(&sequence_helper_name); - let commit_helper_path = repo.join(&commit_helper_name); + let sequence_helper_path = repo.join(sequence_helper_name()); + let commit_helper_path = repo.join(commit_helper_name()); let current_exe = env::current_exe() .map_err(|err| format!("Could not locate the Gitty executable: {err}"))?; fs::copy(¤t_exe, &sequence_helper_path) .and_then(|_| fs::copy(¤t_exe, &commit_helper_path)) .map_err(|err| format!("Could not prepare interactive rebase helpers: {err}"))?; - let sequence_editor_command = format!("./{sequence_helper_name}"); - let commit_editor_command = format!("./{commit_helper_name}"); + let sequence_editor_command = format!("./{}", sequence_helper_name()); + let commit_editor_command = format!("./{}", commit_helper_name()); let output = git_command() .arg("-C") .arg(&repo) @@ -1670,11 +1665,14 @@ pub async fn start_interactive_rebase( .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}")); + let result = rebase_status_or_error(&repo, output?, "Interactive rebase failed", true); let _ = fs::remove_file(&todo_path); - let _ = fs::remove_file(&reword_queue_path); let _ = fs::remove_file(&sequence_helper_path); - let _ = fs::remove_file(&commit_helper_path); - rebase_status_or_error(&repo, output?, "Interactive rebase failed", true) + if !matches!(&result, Ok(status) if status.rebase_in_progress) { + let _ = fs::remove_file(&reword_queue_path); + let _ = fs::remove_file(&commit_helper_path); + } + result }) .await .map_err(|err| format!("Could not run interactive rebase: {err}"))? @@ -1687,15 +1685,27 @@ pub fn rebase_continue(path: String) -> Result { return Err("No rebase is currently in progress.".to_string()); } - let output = git_command() - .arg("-C") - .arg(&repo) - .args(["rebase", "--continue"]) - .env("GIT_EDITOR", "true") + let git_dir = git_dir_for_repo(&repo)?; + let queue_path = git_dir.join(REWORD_QUEUE_FILE); + let helper_path = repo.join(commit_helper_name()); + let mut command = git_command(); + command.arg("-C").arg(&repo).args(["rebase", "--continue"]); + if queue_path.exists() && helper_path.exists() { + command + .env("GIT_EDITOR", format!("./{}", commit_helper_name())) + .env(COMMIT_EDITOR_QUEUE_ENV, &queue_path); + } else { + command.env("GIT_EDITOR", "true"); + } + let output = command .output() .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; - rebase_status_or_error(&repo, output, "Rebase continue failed", false) + let result = rebase_status_or_error(&repo, output, "Rebase continue failed", false); + if !matches!(&result, Ok(status) if status.rebase_in_progress) { + cleanup_interactive_rebase_helpers(&repo); + } + result } #[tauri::command] @@ -1706,6 +1716,7 @@ pub fn rebase_abort(path: String) -> Result { } run_git(&repo, ["rebase", "--abort"])?; + cleanup_interactive_rebase_helpers(&repo); status_for_repo(&repo) } @@ -1911,6 +1922,38 @@ fn git_dir_for_repo(repo: &Path) -> Result { } } +fn sequence_helper_name() -> &'static str { + if cfg!(windows) { + ".gitty-sequence-editor.exe" + } else { + SEQUENCE_HELPER_STEM + } +} + +fn commit_helper_name() -> &'static str { + if cfg!(windows) { + ".gitty-commit-editor.exe" + } else { + COMMIT_HELPER_STEM + } +} + +fn cleanup_interactive_rebase_helpers(repo: &Path) { + if let Ok(git_dir) = git_dir_for_repo(repo) { + let _ = fs::remove_file(git_dir.join(REBASE_TODO_FILE)); + let _ = fs::remove_file(git_dir.join(REWORD_QUEUE_FILE)); + } + let _ = fs::remove_file(repo.join(sequence_helper_name())); + let _ = fs::remove_file(repo.join(commit_helper_name())); +} + +fn is_interactive_rebase_helper_path(path: &str) -> bool { + matches!( + path.replace('\\', "/").rsplit('/').next(), + Some(name) if name == sequence_helper_name() || name == commit_helper_name() + ) +} + fn parse_reflog(output: &[u8]) -> Result, String> { let mut entries = Vec::new(); for raw in output.split(|byte| *byte == 0x1e) { @@ -2946,6 +2989,7 @@ fn status_for_repo(repo: &Path) -> Result { )?; let (branch, mut files) = parse_status_output(&output)?; detect_worktree_renames(repo, &mut files); + files.retain(|file| !is_interactive_rebase_helper_path(&file.path)); Ok(GitStatus { repo_path: repo.to_string_lossy().to_string(), diff --git a/src/feature-qa.ts b/src/feature-qa.ts deleted file mode 100644 index 37701a2..0000000 --- a/src/feature-qa.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { mount } from "svelte"; -import "./app.css"; -import InteractiveRebaseDialog from "./lib/components/InteractiveRebaseDialog.svelte"; -import ReflogDialog from "./lib/components/ReflogDialog.svelte"; - -const target = document.getElementById("qa")!; -const branches = [ - { name: "features/rewrite-history", current: true, remote: false }, - { name: "main", current: false, remote: false }, - { name: "origin/main", current: false, remote: true }, -]; -const commits = [ - { hash: "1111111111111111111111111111111111111111", short_hash: "1111111", summary: "Add reflog backend", author_name: "Ada", date: "2026-07-10T09:10:00+02:00" }, - { hash: "2222222222222222222222222222222222222222", short_hash: "2222222", summary: "Build interactive rebase dialog", author_name: "Linus", date: "2026-07-10T10:20:00+02:00" }, - { hash: "3333333333333333333333333333333333333333", short_hash: "3333333", summary: "Polish recovery workflow", author_name: "Grace", date: "2026-07-10T11:30:00+02:00" }, -]; -const entries = [ - { hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", short_hash: "aaaaaaa", selector: "HEAD@{0}", action: "commit: Add recovery workflow", author_name: "Ada", date: "2026-07-10T12:00:00+02:00" }, - { hash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", short_hash: "bbbbbbb", selector: "HEAD@{1}", action: "rebase (finish): returning to refs/heads/feature", author_name: "Ada", date: "2026-07-10T11:00:00+02:00" }, - { hash: "cccccccccccccccccccccccccccccccccccccccc", short_hash: "ccccccc", selector: "HEAD@{2}", action: "checkout: moving from main to feature", author_name: "Ada", date: "2026-07-10T10:00:00+02:00" }, -]; - -if (location.hash === "#reflog") { - mount(ReflogDialog, { target, props: { entries, currentHash: entries[0].hash, isLoading: false, isBusy: false, operation: "", error: "", onPreview: () => {}, onRestore: (_entry, branch) => { document.title = `Recovered ${branch}`; }, onClose: () => {} } }); -} else { - mount(InteractiveRebaseDialog, { target, props: { branches, currentBranch: branches[0].name, base: "main", commits, isLoading: false, isBusy: false, operation: "", error: "", onBaseChange: () => {}, onStart: (plan) => { document.title = `Rebase ${plan.length} commits`; }, onClose: () => {} } }); -} From c747e02f298e73b5a56357fb78562b6fc2dbaed5 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 10 Jul 2026 22:56:48 +0200 Subject: [PATCH 5/5] feat(git): improve interactive rebase base handling and reflog display The git module now correctly handles scenarios where the selected base branch is diverged from HEAD. This removes unnecessary ancestor checks during rebase planning, improving overall robustness of the feature. Additionally, the ReflogDialog component was updated to reliably retrieve the current HEAD hash for accurate display in the UI. - Removed strict ancestor checking when starting interactive rebase operations. - Updated App.svelte to accurately find and use the current HEAD entry from reflog data. --- src-tauri/src/git.rs | 47 ++++++++++++++++++++++---------------------- src/App.svelte | 2 +- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index d67b573..4179308 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1625,11 +1625,6 @@ pub async fn start_interactive_rebase( } let base_hash = verify_commit(&repo, &base)?; - ensure_ancestor( - &repo, - &base_hash, - "The selected base must be an ancestor of HEAD.", - )?; let available = interactive_rebase_commits_for_repo(&repo, &base_hash)?; validate_rebase_plan(&available, &plan)?; @@ -1765,11 +1760,6 @@ fn interactive_rebase_commits_for_repo( base: &str, ) -> Result, String> { let base_hash = verify_commit(repo, base)?; - ensure_ancestor( - repo, - &base_hash, - "The selected base must be an ancestor of HEAD.", - )?; let range = format!("{base_hash}..HEAD"); let merges = run_git(repo, ["rev-list", "--merges", range.as_str()])?; @@ -1809,20 +1799,6 @@ fn interactive_rebase_commits_for_repo( Ok(commits) } -fn ensure_ancestor(repo: &Path, commit: &str, message: &str) -> Result<(), String> { - let output = git_command() - .arg("-C") - .arg(repo) - .args(["merge-base", "--is-ancestor", commit, "HEAD"]) - .output() - .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; - if output.status.success() { - Ok(()) - } else { - Err(message.to_string()) - } -} - fn validate_rebase_plan(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result<(), String> { if commits.is_empty() { return Err("There are no commits to rebase onto the selected base.".to_string()); @@ -5126,6 +5102,29 @@ mod tests { ); } + #[test] + fn interactive_rebase_accepts_a_diverged_base_branch() { + let repo = init_temp_repo("interactive_rebase_diverged"); + commit_initial_file(&repo.path); + let main_branch = git_output_test(&repo.path, ["branch", "--show-current"]); + run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]); + fs::write(repo.path.join("feature.txt"), "feature\n") + .expect("feature file should be written"); + run_git_test(&repo.path, ["add", "feature.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "feature commit"]); + run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]); + fs::write(repo.path.join("main.txt"), "main\n").expect("main file should be written"); + run_git_test(&repo.path, ["add", "main.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "main advanced"]); + run_git_test(&repo.path, ["checkout", "-q", "feature"]); + + let commits = interactive_rebase_commits_for_repo(&repo.path, &main_branch) + .expect("diverged base should be accepted"); + + assert_eq!(commits.len(), 1); + assert_eq!(commits[0].summary, "feature commit"); + } + #[test] fn reflog_restore_creates_a_recovery_branch_without_resetting_existing_branch() { let repo = init_temp_repo("reflog_restore"); diff --git a/src/App.svelte b/src/App.svelte index bf6d18a..abac9f0 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -4221,7 +4221,7 @@ {#if reflogOpen} entry.selector === "HEAD@{0}")?.hash ?? ""} isLoading={reflogLoading} {isBusy} {operation}