refactor(ui): modernize status bar and component structure

This commit introduces a comprehensive overhaul of the application's UI, focusing on modernizing the global workspace status bar and improving overall theming consistency. Several components were refactored to simplify prop handling and improve separation of concerns, particularly within the TitleBar and RepoToolbar. The CSS includes extensive new variables and styles for better visual fidelity across light and dark themes.

- Overhauled the main application footer to display version, branch status, and sync metrics.
- Added global CSS variables and component styling for a modern look.
- Simplified repository state management by removing redundant props from TitleBar.
This commit is contained in:
Christoph Brandau
2026-07-12 23:32:55 +02:00
parent f9c0c00618
commit 735acd2551
8 changed files with 565 additions and 175 deletions
+16 -5
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount, tick } from "svelte"; import { onDestroy, onMount, tick } from "svelte";
import { getVersion } from "@tauri-apps/api/app";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
@@ -366,6 +367,7 @@
let fileHistoryResizeStartWidth = 0; let fileHistoryResizeStartWidth = 0;
let fileHistoryCollapsed = true; let fileHistoryCollapsed = true;
let themeMediaQuery: MediaQueryList | undefined; let themeMediaQuery: MediaQueryList | undefined;
let appVersion = "";
// ── Derived ──────────────────────────────────────────────────────────────── // ── Derived ────────────────────────────────────────────────────────────────
@@ -427,6 +429,7 @@
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)"); themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
themeMediaQuery.addEventListener("change", handleSystemThemeChange); themeMediaQuery.addEventListener("change", handleSystemThemeChange);
void runStartupSequence(); void runStartupSequence();
void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; });
}); });
onDestroy(() => { onDestroy(() => {
@@ -3526,11 +3529,6 @@
<main class="shell"> <main class="shell">
<TitleBar <TitleBar
branch={status?.current_branch ?? ""}
ahead={status?.ahead ?? 0}
behind={status?.behind ?? 0}
repoName={activeRepoPath ? (activeRepoPath.split(/[\\/]/).filter(Boolean).slice(-1)[0] ?? "") : ""}
hasRepository={workspaceActive}
onOpenSettings={() => { appSettingsOpen = true; }} onOpenSettings={() => { appSettingsOpen = true; }}
onOpenHelp={openHelp} onOpenHelp={openHelp}
language={appLanguage} language={appLanguage}
@@ -4099,6 +4097,19 @@
</aside> </aside>
</section> </section>
{/if} {/if}
<footer class="workspace-statusbar" aria-label="Application status summary">
{#if workspaceActive}
<span class:clean={status?.clean} class="workspace-health"><span aria-hidden="true"></span>{status?.clean ? "Working tree clean" : `${changedFiles.length} changed ${changedFiles.length === 1 ? "file" : "files"}`}</span>
{/if}
<span class="workspace-status-spacer"></span>
{#if workspaceActive}
<span><GitBranch size={12} aria-hidden="true" />{status?.current_branch ?? "No branch"}</span>
<span class="ahead">↑ {status?.ahead ?? 0}</span>
<span class="behind">↓ {status?.behind ?? 0}</span>
<span class:active={autoRefreshEnabled} class="workspace-auto">Auto <i aria-hidden="true"></i></span>
{/if}
{#if appVersion}<span class="app-version" title={`Gitty version ${appVersion}`}>Gitty v{appVersion}</span>{/if}
</footer>
</div> </div>
</main> </main>
+389 -3
View File
@@ -2145,15 +2145,15 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
width: 100%; width: 100%;
min-height: 32px; min-height: 28px;
padding: 4px 8px 4px calc(8px + var(--depth, 0) * 14px); padding: 2px 7px 2px calc(7px + var(--depth, 0) * 14px);
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
text-align: left; text-align: left;
transition: background 120ms ease, border-color 120ms ease; transition: background 120ms ease, border-color 120ms ease;
} }
.explorer-row + .explorer-row { margin-top: 2px; } .explorer-row + .explorer-row { margin-top: 0; }
.explorer-row:hover { background: var(--color-surface-hover); border-color: var(--color-border-subtle); } .explorer-row:hover { background: var(--color-surface-hover); border-color: var(--color-border-subtle); }
.explorer-row.active { background: rgba(90,140,248,0.1); border-color: rgba(90,140,248,0.28); } .explorer-row.active { background: rgba(90,140,248,0.1); border-color: rgba(90,140,248,0.28); }
.explorer-row.folder { font-weight: 700; } .explorer-row.folder { font-weight: 700; }
@@ -4974,6 +4974,290 @@
background: rgba(247,249,252,0.96); background: rgba(247,249,252,0.96);
} }
/* 2026 workspace refresh --------------------------------------------------
A single, quiet product system shared by management, repository workspace,
dialogs and both color themes. Kept as an override layer so existing Git
behavior and component ownership stay unchanged. */
:root {
--ui-radius: 7px;
--ui-radius-sm: 5px;
--ui-control-height: 32px;
--ui-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
--app-bg: #0d1118;
--app-button-bg: #171d27;
--app-input-bg: #101620;
--app-panel-highlight: none;
--app-panel-shadow: none;
--color-surface: #111720;
--color-surface-alt: #0d121a;
--color-surface-dim: #141b25;
--color-surface-hover: #1b2431;
--color-surface-raised: #151c27;
--color-surface-solid: #111720;
--color-border: #2a3546;
--color-border-subtle: #222c3a;
--color-primary: #4d8dff;
--color-primary-dark: #3477ed;
--color-accent: #65a2ff;
}
:root[data-theme="light"] {
--ui-shadow: 0 10px 28px rgba(23, 40, 72, 0.09);
--app-bg: #f4f6f9;
--app-button-bg: #ffffff;
--app-input-bg: #ffffff;
--app-panel-highlight: none;
--app-panel-shadow: none;
--color-surface: #ffffff;
--color-surface-alt: #f7f8fa;
--color-surface-dim: #f8f9fb;
--color-surface-hover: #f0f4fa;
--color-surface-raised: #ffffff;
--color-surface-solid: #ffffff;
--color-border: #cfd6e1;
--color-border-subtle: #e3e7ed;
--color-border-input: #c9d1dd;
--color-primary: #0b63e6;
--color-primary-dark: #0755c8;
--color-accent: #0b63e6;
}
body { background: var(--app-bg); }
button {
min-height: var(--ui-control-height);
border-radius: var(--ui-radius-sm);
box-shadow: none;
font-size: 12px;
font-weight: 650;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid color-mix(in srgb, var(--color-primary) 65%, transparent);
outline-offset: 1px;
}
input, textarea, select { border-radius: var(--ui-radius-sm); }
input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 16%, transparent); }
.shell { --app-titlebar-height: 40px; --app-toolbar-height: 64px; background: var(--app-bg); }
.shell-body { gap: 0; padding: 0; }
.titlebar {
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface-solid);
}
.titlebar-brand { min-width: 140px; padding-left: 14px; border-right: 0; font-size: 14px; }
.titlebar-brand-icon,
.titlebar-brand-icon img { width: 22px; height: 22px; }
.titlebar-brand-icon { flex-basis: 22px; }
.titlebar-drag { align-self: stretch; min-width: 0; }
.titlebar-info { justify-self: center; width: min(480px, 100%); }
.titlebar-context { justify-content: center; }
.tb-repo, .tb-branch { color: var(--color-ink); font-size: 13px; }
.tb-version { color: var(--color-ink-faint); }
.titlebar-globals { margin-left: auto; }
.titlebar-divider { background: var(--color-border-subtle); }
.repo-tabbar {
min-height: 46px;
padding: 0 16px;
border-bottom: 1px solid var(--color-border);
border-radius: 0;
background: var(--color-surface-dim);
}
.repo-tab-wrap { min-height: 45px; border-right: 1px solid var(--color-border-subtle); border-radius: 0; }
.repo-tab-wrap:first-child { border-left: 1px solid var(--color-border-subtle); }
.repo-tab-wrap.active { background: var(--color-surface-solid); box-shadow: inset 0 -3px 0 var(--color-primary); }
.repo-tab { min-height: 45px; padding-left: 14px; font-size: 12.5px; }
.repo-tab-add { min-height: 45px; }
.repo-toolbar {
height: var(--app-toolbar-height);
padding: 7px 18px 8px;
border: 0;
border-bottom: 1px solid var(--color-border);
border-radius: 0;
background: var(--color-surface-solid);
}
.repo-toolbar-sync { grid-template-rows: 10px 36px; }
.repo-toolbar-group-label { padding-left: 1px; font-size: 8.5px; letter-spacing: .13em; }
.repo-action-group { height: 36px; border-color: var(--color-border); background: transparent; }
.repo-action { height: 34px; min-height: 34px; padding-inline: 15px; font-weight: 650; }
.repo-action.sync-primary { color: var(--color-primary); }
.repo-toolbar-divider { height: 34px; margin-inline: 6px; }
.workspace {
grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 7px minmax(360px, 1fr) 7px minmax(560px, var(--history-aside-width, 620px));
flex: 1 1 0;
padding: 0;
background: var(--color-border-subtle);
row-gap: 0;
}
.left-sidebar { background: var(--color-surface-solid); }
.main-panel { border: 0; border-radius: 0; background: var(--color-surface-solid); }
.history-aside { background: var(--color-border-subtle); row-gap: 0; }
.panel {
border: 0;
border-radius: 0;
background: var(--color-surface-solid);
box-shadow: none;
backdrop-filter: none;
}
.left-sidebar .panel + .panel { border-top: 1px solid var(--color-border); }
.section-head {
min-height: 50px;
padding: 8px 12px;
border-bottom-color: var(--color-border-subtle);
background: var(--color-surface-solid);
}
.section-head h2 { font-size: 14px; font-weight: 750; }
.eyebrow { font-size: 9px; font-weight: 750; letter-spacing: .11em; }
.pill { min-height: 19px; padding-inline: 6px; border-radius: 5px; }
.pill-count { color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
.repo-summary { height: 48px; padding-inline: 16px; background: var(--color-surface-dim); }
.repo-branch { font-size: 14px; }
.repo-path { opacity: .82; }
.sync-stats strong, .sync-stats span { border-radius: 5px; background: transparent !important; }
.top-section { grid-template-rows: minmax(160px, 1fr) 7px var(--commit-panel-height, 220px); padding: 0; }
.left-sidebar-resize-handle,
.history-resize-handle,
.file-history-resize-handle,
.panel-resize-handle,
.left-panel-resize-handle { background: var(--color-border-subtle); }
.left-sidebar-resize-handle::before,
.history-resize-handle::before,
.file-history-resize-handle::before { width: 1px; height: 48px; background: var(--color-border); }
.panel-resize-handle::before { width: 44px; height: 2px; }
.status-toolbar {
min-height: 40px;
padding: 5px 10px;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface-dim);
}
.status-toolbar .danger { margin-left: auto; }
.status-lanes { display: grid; align-content: start; min-height: 0; }
.status-lane + .status-lane { border-top: 1px solid var(--color-border); }
.status-lane-head {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 42px;
padding: 5px 12px;
background: var(--color-surface-dim);
}
.status-lane-head > div { display: flex; align-items: center; gap: 7px; }
.status-lane-head strong { color: var(--color-ink); font-size: 12px; }
.status-lane-head span { display: grid; place-items: center; min-width: 20px; height: 20px; border-radius: 5px; color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); font-size: 10.5px; font-weight: 750; }
.status-file-list { padding: 5px 0; }
.status-file-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
min-height: 42px;
padding: 3px 8px 3px 12px;
border-left: 3px solid transparent;
border-bottom: 1px solid var(--color-border-subtle);
}
.status-file-row:last-child { border-bottom: 0; }
.status-file-row:hover { background: var(--color-surface-hover); }
.status-file-row.selected { background: color-mix(in srgb, var(--color-primary) 9%, var(--color-surface-solid)); }
.status-file-row.active { border-left-color: var(--color-primary); }
.status-file-main { display: grid; justify-content: start; gap: 1px; min-width: 0; padding: 3px 8px; border: 0; background: transparent; text-align: left; }
.status-file-main:hover:not(:disabled) { background: transparent; }
.status-file-main strong, .status-file-main span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.status-file-main strong { color: var(--color-ink); font: 650 12px/1.3 var(--font-mono); }
.status-file-main span { color: var(--color-ink-faint); font: 10px/1.2 var(--font-mono); }
.status-file-actions { display: flex; margin-left: 5px; opacity: 0; transition: opacity 120ms ease; }
.status-file-row:hover .status-file-actions, .status-file-row:focus-within .status-file-actions { opacity: 1; }
.status-file-actions button { width: 28px; min-width: 28px; min-height: 28px; padding: 0; border-color: transparent; background: transparent; }
.status-file-actions .danger:hover:not(:disabled) { color: #e86060; background: rgba(232,96,96,.1); }
.status-lane-empty { margin: 0; padding: 14px 16px; color: var(--color-ink-faint); font-size: 11.5px; }
.status-badge { min-width: 58px; padding-inline: 5px; border: 0; border-radius: 4px; font-size: 9px; letter-spacing: .04em; }
.commit-panel { border-top: 1px solid var(--color-border); }
.commit-head-actions { display: flex; align-items: center; gap: 5px; }
.commit-generate-button { color: var(--color-primary); background: transparent; }
.commit-settings-button { width: 30px; min-width: 30px; padding: 0; background: transparent; }
.commit-form { padding: 10px 12px 12px; }
.commit-form textarea { min-height: 72px; resize: none; }
.commit-amend-row { min-height: 34px; }
.commit-actions-row .btn-primary { min-height: 38px; border-radius: var(--ui-radius-sm); background: var(--color-primary); box-shadow: none; }
.commit-actions-row .btn-primary:hover:not(:disabled) { background: var(--color-primary-dark); }
.branch-list, .explorer-list, .stash-list { padding: 5px !important; }
.branch-row, .explorer-row, .stash-row { border-radius: var(--ui-radius-sm); }
.branch-row.current, .explorer-row.active { background: color-mix(in srgb, var(--color-primary) 11%, var(--color-surface-solid)); box-shadow: inset 3px 0 0 var(--color-primary); }
.branch-group-toggle { background: transparent; }
.explorer-row .status-badge { min-width: 18px; overflow: hidden; color: #d9891b; background: transparent; text-indent: -999px; }
.explorer-row .status-badge::after { content: "M"; float: right; text-indent: 0; }
.history-panel, .file-history-panel { min-height: 0; }
.graph-list { background: var(--color-surface-solid); }
.graph-gutter { background: var(--color-surface-dim); }
.commit-body { border-radius: var(--ui-radius-sm); background: transparent; box-shadow: none; }
.graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); }
.graph-row:hover .commit-body { background: var(--color-surface-hover); }
.graph-row.tip-row .commit-body { background: color-mix(in srgb, var(--color-primary) 7%, var(--color-surface-solid)); }
.commit-avatar { border-radius: 50%; }
.commit-kind, .commit-branch-chip, .ref-chip { border-radius: 4px !important; }
.file-history-row { margin: 0; border: 0; border-bottom: 1px solid var(--color-border-subtle); border-radius: 0; background: transparent; }
.workspace-statusbar {
display: flex;
align-items: center;
gap: 14px;
min-height: 30px;
padding: 0 14px;
border-top: 1px solid var(--color-border);
color: var(--color-ink-faint);
background: var(--color-surface-solid);
font-size: 10.5px;
}
.workspace-statusbar > span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.workspace-status-spacer { flex: 1 1 auto; }
.workspace-health > span { width: 8px; height: 8px; border-radius: 50%; background: #d9891b; }
.workspace-health.clean > span { background: #2da44e; }
.workspace-statusbar .ahead { color: #d9891b; }
.workspace-statusbar .behind { color: var(--color-primary); }
.workspace-auto i { width: 7px; height: 7px; border-radius: 50%; background: var(--color-ink-faint); }
.workspace-auto.active i { background: #2da44e; }
.app-version {
padding-left: 12px;
border-left: 1px solid var(--color-border-subtle);
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 9.5px;
}
.repo-management { margin: 14px; border-radius: var(--ui-radius); background: var(--color-surface-solid); box-shadow: var(--ui-shadow); }
.repo-management-head, .repo-management-tools { background: var(--color-surface-solid); }
.repo-section { border-radius: var(--ui-radius-sm); background: var(--color-surface-solid); }
.repo-section > header { background: var(--color-surface-dim); }
.repo-row-main:hover:not(:disabled) { background: var(--color-surface-hover); }
@media (max-width: 1180px) {
.repo-action { padding-inline: 10px; }
.repo-toolbar-divider { margin-inline: 2px; }
.repo-action-label { display: none; }
.repo-action.sync-primary .repo-action-label { display: inline; }
}
@media (max-width: 900px) {
.shell-body { overflow: auto; }
.workspace { grid-template-columns: minmax(190px, 230px) 7px minmax(420px, 1fr); min-height: 760px; }
.history-resize-handle, .history-aside { display: none; }
.workspace-statusbar { position: sticky; bottom: 0; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
:root[data-theme="light"] .repo-tab-wrap { :root[data-theme="light"] .repo-tab-wrap {
background: rgba(255,255,255,0.5); background: rgba(255,255,255,0.5);
} }
@@ -5480,3 +5764,105 @@
.repo-toolbar-divider { margin-inline: 0; } .repo-toolbar-divider { margin-inline: 0; }
.repo-auto-toggle { padding-left: 7px; gap: 6px; } .repo-auto-toggle { padding-left: 7px; gap: 6px; }
} }
/* Keep the refreshed workspace rules authoritative after legacy theme/media
compatibility declarations above. */
.commit-actions-row .btn-primary,
:root[data-theme="light"] .commit-actions-row .btn-primary {
border-color: var(--color-primary);
color: #fff;
background: var(--color-primary);
box-shadow: none;
}
.commit-actions-row .btn-primary:hover:not(:disabled),
:root[data-theme="light"] .commit-actions-row .btn-primary:hover:not(:disabled) {
border-color: var(--color-primary-dark);
color: #fff;
background: var(--color-primary-dark);
}
:root[data-theme="light"] .panel,
:root[data-theme="light"] .main-panel,
:root[data-theme="light"] .graph-list,
:root[data-theme="light"] .commit-body { box-shadow: none; }
@media (max-width: 1100px) {
.shell-body { overflow: auto; }
.workspace {
grid-template-columns: minmax(200px, 250px) 7px minmax(480px, 1fr);
min-width: 760px;
min-height: 680px;
}
.history-resize-handle,
.history-aside { display: none; }
.workspace-statusbar { position: sticky; bottom: 0; z-index: 20; }
}
/* Repository workflow order: inspect first, then remote synchronization. */
.repo-toolbar > .repo-inspect-actions { order: 1; }
.repo-toolbar > .repo-toolbar-divider:not(.utility) { order: 2; }
.repo-toolbar > .repo-toolbar-sync {
order: 3;
display: block;
align-self: auto;
margin-bottom: 1px;
}
.repo-toolbar > .repo-toolbar-spacer { order: 4; }
.repo-toolbar > .repo-toolbar-divider.utility { order: 5; }
.repo-toolbar > .repo-utility-actions { order: 6; }
/* Compact change map for long compare and line-patch views. */
.split-diff-shell,
.line-patch-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 16px;
flex: 1 1 0;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.split-diff-shell .split-diff { min-height: 0; }
.line-patch-workspace .line-patch-scroll { min-height: 0; }
.diff-overview {
position: relative;
min-width: 16px;
min-height: 0;
overflow: hidden;
border-left: 1px solid var(--color-border-subtle);
background: var(--code-surface-subtle);
}
.diff-overview::before {
content: "";
position: absolute;
inset: 5px 6px;
border-radius: 999px;
background: var(--color-border-subtle);
}
.diff-overview-marker {
position: absolute;
z-index: 1;
top: var(--marker-position);
left: 2px;
width: 11px;
min-width: 11px;
height: 5px;
min-height: 5px;
padding: 0;
border: 0;
border-radius: 2px;
transform: translateY(-50%);
box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 28%, transparent);
}
.diff-overview-marker.add { color: var(--code-add-strong); background: var(--code-add-strong); }
.diff-overview-marker.delete { color: var(--code-delete-strong); background: var(--code-delete-strong); }
.diff-overview-marker.mixed {
color: var(--color-accent);
background: linear-gradient(90deg, var(--code-delete-strong) 0 48%, var(--code-add-strong) 52% 100%);
}
.diff-overview-marker:hover:not(:disabled),
.diff-overview-marker:focus-visible {
width: 13px;
left: 1px;
filter: brightness(1.18);
outline: 1px solid var(--color-ink);
outline-offset: 1px;
}
-1
View File
@@ -60,7 +60,6 @@
<div bind:this={toolbarElement} class="repo-toolbar" role="toolbar" aria-label={isGerman ? "Repository-Aktionen" : "Repository actions"}> <div bind:this={toolbarElement} class="repo-toolbar" role="toolbar" aria-label={isGerman ? "Repository-Aktionen" : "Repository actions"}>
<div class="repo-toolbar-sync"> <div class="repo-toolbar-sync">
<span class="repo-toolbar-group-label">Sync</span>
<div class="repo-action-group repo-sync-actions"> <div class="repo-action-group repo-sync-actions">
<button <button
class="repo-action fetch" class="repo-action fetch"
+2 -42
View File
@@ -1,15 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { CircleHelp, GitBranch, Minus, Settings, X } from "@lucide/svelte"; import { CircleHelp, Minus, Settings, X } from "@lucide/svelte";
import iconUrl from "../../src-tauri/icons/icon.png"; import iconUrl from "../../src-tauri/icons/icon.png";
export let branch: string = "";
export let ahead: number = 0;
export let behind: number = 0;
export let repoName: string = "";
export let hasRepository: boolean = false;
export let onOpenHelp: () => void = () => {}; export let onOpenHelp: () => void = () => {};
export let onOpenSettings: () => void = () => {}; export let onOpenSettings: () => void = () => {};
export let language: "en" | "de" = "en"; export let language: "en" | "de" = "en";
@@ -17,7 +11,6 @@
let win: ReturnType<typeof getCurrentWindow> | null = null; let win: ReturnType<typeof getCurrentWindow> | null = null;
let isMaximized = false; let isMaximized = false;
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined;
let appVersion = "";
onMount(async () => { onMount(async () => {
try { try {
@@ -29,12 +22,6 @@
} catch { } catch {
win = null; win = null;
} }
try {
appVersion = await getVersion();
} catch {
appVersion = "";
}
}); });
onDestroy(() => { onDestroy(() => {
@@ -61,36 +48,9 @@
<img src={iconUrl} alt="" data-tauri-drag-region /> <img src={iconUrl} alt="" data-tauri-drag-region />
</span> </span>
<span data-tauri-drag-region>Gitty</span> <span data-tauri-drag-region>Gitty</span>
{#if appVersion}
<span class="tb-version" data-tauri-drag-region title="Version {appVersion}">v{appVersion}</span>
{/if}
</div> </div>
<!-- Center: repo + branch info --> <div class="titlebar-drag" data-tauri-drag-region aria-hidden="true"></div>
<div class="titlebar-info" data-tauri-drag-region>
{#if hasRepository}
<div class="titlebar-context" data-tauri-drag-region>
{#if repoName}
<span class="tb-repo" data-tauri-drag-region>{repoName}</span>
<span class="tb-sep" data-tauri-drag-region aria-hidden="true">/</span>
{/if}
<GitBranch size={12} aria-hidden="true" />
<span class="tb-branch" data-tauri-drag-region title={branch}>{branch}</span>
</div>
{#if ahead > 0 || behind > 0}
<div class="tb-sync-group" aria-label="Branch synchronization status">
{#if ahead > 0}
<span class="tb-sync ahead" title="{ahead} commits ahead">{ahead}</span>
{/if}
{#if behind > 0}
<span class="tb-sync behind" title="{behind} commits behind">{behind}</span>
{/if}
</div>
{/if}
{:else}
<span class="tb-no-repo" data-tauri-drag-region>No repository open</span>
{/if}
</div>
<!-- Right: app-global actions + window controls --> <!-- Right: app-global actions + window controls -->
<div class="titlebar-globals"> <div class="titlebar-globals">
+18 -27
View File
@@ -69,17 +69,32 @@
<section class="panel commit-panel" aria-label="Commit"> <section class="panel commit-panel" aria-label="Commit">
<div class="section-head"> <div class="section-head">
<div> <div>
<span class="eyebrow">Commit</span> <span class="eyebrow">Create revision</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Message</h2> <h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commit</h2>
</div> </div>
<div class="commit-head-actions">
<span class="pill pill-count">{stagedCount} staged</span> <span class="pill pill-count">{stagedCount} staged</span>
<button
class="commit-generate-button"
type="button"
onclick={onGenerateCommitMessage}
disabled={!canGenerate}
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
>
{#if commitAiGenerating || localModelLoading}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<Sparkles size={14} aria-hidden="true" />{/if}
Generate
</button>
<button class="commit-settings-button" type="button" onclick={onOpenAiSettings} disabled={isBusy} title="AI settings" aria-label="AI settings">
<Settings size={14} aria-hidden="true" />
</button>
</div>
</div> </div>
<form class="commit-form" onsubmit={handleSubmit}> <form class="commit-form" onsubmit={handleSubmit}>
<textarea <textarea
value={commitMessage} value={commitMessage}
oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)} oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)}
placeholder="Commit message..." placeholder="Commit message"
disabled={!hasRepository || isBusy} disabled={!hasRepository || isBusy}
></textarea> ></textarea>
{#if commitBlockReason} {#if commitBlockReason}
@@ -117,30 +132,6 @@
{/if} {/if}
{amendMode ? "Amend" : "Commit"} {amendMode ? "Amend" : "Commit"}
</button> </button>
<button
class="btn-secondary commit-ai-button"
type="button"
onclick={onGenerateCommitMessage}
disabled={!canGenerate}
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
>
{#if commitAiGenerating || localModelLoading}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Sparkles size={16} aria-hidden="true" />
{/if}
AI
</button>
<button
class="btn-secondary commit-ai-settings-button"
type="button"
onclick={onOpenAiSettings}
disabled={isBusy}
title="AI settings"
aria-label="AI settings"
>
<Settings size={16} aria-hidden="true" />
</button>
</div> </div>
</form> </form>
</section> </section>
+50
View File
@@ -10,6 +10,12 @@
rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty"; rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty";
}; };
interface DiffMarker {
start: number;
end: number;
kind: "add" | "delete" | "mixed";
}
interface Props { interface Props {
comparison: GitCommitComparison; comparison: GitCommitComparison;
selectedDiffPath: string; selectedDiffPath: string;
@@ -173,10 +179,40 @@
return rows; return rows;
} }
function buildDiffMarkers(rows: SplitRow[]): DiffMarker[] {
const markers: DiffMarker[] = [];
let current: DiffMarker | null = null;
for (let index = 0; index < rows.length; index++) {
const row = rows[index];
if (row.type !== "pair" || (row.leftKind === "context" && row.rightKind === "context")) {
current = null;
continue;
}
const kind = row.leftKind === "del" && row.rightKind === "add"
? "mixed"
: row.rightKind === "add" ? "add" : "delete";
if (current && current.end === index - 1 && current.kind === kind) {
current.end = index;
} else {
current = { start: index, end: index, kind };
markers.push(current);
}
}
return markers;
}
function scrollToDiffMarker(rowIndex: number) {
const ratio = rowIndex / Math.max(splitRows.length - 1, 1);
for (const pane of [beforePane, afterPane]) {
if (pane) pane.scrollTop = ratio * Math.max(pane.scrollHeight - pane.clientHeight, 0);
}
}
let diffByPath = $derived(buildDiffByPath(comparison.patch)); let diffByPath = $derived(buildDiffByPath(comparison.patch));
let selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null); let selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : ""); let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : "");
let splitRows = $derived(buildSplitRows(selectedPatch)); let splitRows = $derived(buildSplitRows(selectedPatch));
let diffMarkers = $derived(buildDiffMarkers(splitRows));
</script> </script>
<div <div
@@ -262,6 +298,7 @@
</div> </div>
<!-- Split diff grid --> <!-- Split diff grid -->
<div class="split-diff-shell">
<div class="split-diff" role="table" aria-label="Side-by-side diff"> <div class="split-diff" role="table" aria-label="Side-by-side diff">
<div <div
class="split-pane" class="split-pane"
@@ -298,6 +335,19 @@
</div> </div>
</div> </div>
</div> </div>
<nav class="diff-overview" aria-label="Change overview">
{#each diffMarkers as marker, index (`${marker.start}-${marker.end}-${marker.kind}`)}
<button
class="diff-overview-marker {marker.kind}"
type="button"
style={`--marker-position: ${(marker.start / Math.max(splitRows.length - 1, 1)) * 100}%`}
onclick={() => scrollToDiffMarker(marker.start)}
title={`Jump to change ${index + 1} of ${diffMarkers.length}`}
aria-label={`Jump to change ${index + 1} of ${diffMarkers.length}`}
></button>
{/each}
</nav>
</div>
{/if} {/if}
</div> </div>
+34 -2
View File
@@ -47,6 +47,7 @@
}: Props = $props(); }: Props = $props();
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false }); let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
let patchScroll = $state<HTMLDivElement | null>(null);
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes"); let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path); let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
@@ -118,6 +119,23 @@
if (isBusy || isLoading) return; if (isBusy || isLoading) return;
await onApply(action, buildHunkPatch(hunk)); await onApply(action, buildHunkPatch(hunk));
} }
function hunkPosition(index: number): number {
const totalLines = parsed.hunks.reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
const precedingLines = parsed.hunks.slice(0, index).reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
return (precedingLines / Math.max(totalLines - 1, 1)) * 100;
}
function hunkKind(hunk: PatchHunk): "add" | "delete" | "mixed" {
const hasAdd = hunk.lines.some((line) => line.kind === "add");
const hasDelete = hunk.lines.some((line) => line.kind === "delete");
return hasAdd && hasDelete ? "mixed" : hasAdd ? "add" : "delete";
}
function scrollToHunk(hunkId: string) {
const target = patchScroll?.querySelector<HTMLElement>(`[data-hunk-id="${hunkId}"]`);
if (patchScroll && target) patchScroll.scrollTop = Math.max(target.offsetTop - 8, 0);
}
</script> </script>
<div class="dialog-backdrop" role="presentation"> <div class="dialog-backdrop" role="presentation">
@@ -148,9 +166,10 @@
{:else if parsed.binary || parsed.hunks.length === 0} {:else if parsed.binary || parsed.hunks.length === 0}
<div class="blank-state">This change cannot be split into text lines.</div> <div class="blank-state">This change cannot be split into text lines.</div>
{:else} {:else}
<div class="line-patch-scroll"> <div class="line-patch-workspace">
<div class="line-patch-scroll" bind:this={patchScroll}>
{#each parsed.hunks as hunk (hunk.id)} {#each parsed.hunks as hunk (hunk.id)}
<section class="line-patch-hunk"> <section class="line-patch-hunk" data-hunk-id={hunk.id}>
<div class="line-patch-hunk-head"> <div class="line-patch-hunk-head">
<code>{hunk.header}</code> <code>{hunk.header}</code>
<div class="line-patch-hunk-actions"> <div class="line-patch-hunk-actions">
@@ -183,6 +202,19 @@
</section> </section>
{/each} {/each}
</div> </div>
<nav class="diff-overview line-patch-overview" aria-label="Change overview">
{#each parsed.hunks as hunk, index (hunk.id)}
<button
class="diff-overview-marker {hunkKind(hunk)}"
type="button"
style={`--marker-position: ${hunkPosition(index)}%`}
onclick={() => scrollToHunk(hunk.id)}
title={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
aria-label={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
></button>
{/each}
</nav>
</div>
{/if} {/if}
</div> </div>
</div> </div>
+51 -90
View File
@@ -156,11 +156,11 @@
}); });
</script> </script>
<section class="panel relative grid grid-rows-[auto_auto_1fr] overflow-hidden" aria-label="Working tree status"> <section class="panel status-panel relative grid grid-rows-[auto_auto_1fr] overflow-hidden" aria-label="Working tree status">
<div class="section-head"> <div class="section-head">
<div> <div>
<span class="eyebrow">Working tree</span> <span class="eyebrow">Workspace</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Status</h2> <h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span class="pill pill-count">{stagedCount} staged</span> <span class="pill pill-count">{stagedCount} staged</span>
@@ -170,26 +170,6 @@
{#if hasRepository && changedFiles.length > 0} {#if hasRepository && changedFiles.length > 0}
<div class="status-toolbar"> <div class="status-toolbar">
<button
class="btn-sm"
type="button"
onclick={onStageAll}
disabled={isBusy || !hasUnstaged}
title="Stage all unstaged files"
>
<Check size={14} aria-hidden="true" />
Stage all
</button>
<button
class="btn-sm"
type="button"
onclick={onUnstageAll}
disabled={isBusy || !hasStaged}
title="Unstage all staged files"
>
<Undo2 size={14} aria-hidden="true" />
Unstage all
</button>
<button <button
class="btn-sm danger" class="btn-sm danger"
type="button" type="button"
@@ -243,79 +223,60 @@
{:else if changedFiles.length === 0} {:else if changedFiles.length === 0}
<div class="blank-state">No file changes returned.</div> <div class="blank-state">No file changes returned.</div>
{:else} {:else}
<div class="overflow-auto p-2"> <div class="status-lanes overflow-auto">
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)} <section class="status-lane" aria-label="Unstaged changes">
<article <header class="status-lane-head">
class="file-row" <div><strong>Unstaged</strong><span>{unstagedCount}</span></div>
class:selected={isStatusSelected(file)} <button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
class:active={selectedFilePath === file.path} <Check size={13} aria-hidden="true" /> Stage all
>
<div class="file-title">
<button
class="file-title-button"
type="button"
onclick={(event) => handleFileSelect(event, file)}
title={`Select ${displayPath(file)} in Explorer`}
>
<strong>{fileName(file)}</strong>
</button> </button>
</div> </header>
<div class="status-file-list">
<div class="change-lanes"> {#each changedFiles.filter((file) => file.unstaged !== null) as file (`unstaged:${fileKey(file)}`)}
<div class="change-lane" class:inactive={!file.staged}>
<div class="lane-header">
<span class="lane-name">Staged</span>
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
</div>
<div class="lane-actions">
{#if file.staged}
{@const unstageTargets = selectedUnstageTargets(file)}
<button class="btn-sm" type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={unstageTargets.length > 1 ? `Unstage ${unstageTargets.length} selected files` : "Unstage file"}>
<Undo2 size={14} aria-hidden="true" />
{unstageTargets.length > 1 ? `Unstage ${unstageTargets.length}` : "Unstage"}
</button>
<button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
<FileDiff size={14} aria-hidden="true" />
Details
</button>
<button class="btn-sm" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={unstageTargets.length > 1 ? `Discard staged changes in ${unstageTargets.length} selected files` : "Discard staged changes"}>
<RotateCcw size={14} aria-hidden="true" />
{unstageTargets.length > 1 ? `Discard ${unstageTargets.length}` : "Discard"}
</button>
{:else}
<span class="quiet">No staged change</span>
{/if}
</div>
</div>
<div class="change-lane" class:inactive={!file.unstaged}>
<div class="lane-header">
<span class="lane-name">Unstaged</span>
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
</div>
<div class="lane-actions">
{#if file.unstaged}
{@const stageTargets = selectedStageTargets(file)} {@const stageTargets = selectedStageTargets(file)}
<button class="btn-sm" type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={stageTargets.length > 1 ? `Stage ${stageTargets.length} selected files` : "Stage file"}> <article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<Check size={14} aria-hidden="true" /> <button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
{stageTargets.length > 1 ? `Stage ${stageTargets.length}` : "Stage"} <strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
</button> </button>
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines"> <span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
<FileDiff size={14} aria-hidden="true" /> <div class="status-file-actions">
Details <button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><Check size={14} aria-hidden="true" /></button>
</button> <button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="btn-sm" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={stageTargets.length > 1 ? `Discard unstaged changes in ${stageTargets.length} selected files` : "Discard unstaged changes"}> <button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
<RotateCcw size={14} aria-hidden="true" />
{stageTargets.length > 1 ? `Discard ${stageTargets.length}` : "Discard"}
</button>
{:else}
<span class="quiet">No unstaged change</span>
{/if}
</div>
</div>
</div> </div>
</article> </article>
{/each} {/each}
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
</div>
</section>
<section class="status-lane" aria-label="Staged changes">
<header class="status-lane-head">
<div><strong>Staged</strong><span>{stagedCount}</span></div>
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
<Undo2 size={13} aria-hidden="true" /> Unstage all
</button>
</header>
<div class="status-file-list">
{#each changedFiles.filter((file) => file.staged !== null) as file (`staged:${fileKey(file)}`)}
{@const unstageTargets = selectedUnstageTargets(file)}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
</button>
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
<div class="status-file-actions">
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><Undo2 size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
</div>
</article>
{/each}
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
</div>
</section>
</div> </div>
{/if} {/if}