Compare commits
2
Commits
ff00925b13
...
735acd2551
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
735acd2551 | ||
|
|
f9c0c00618 |
+30
-65
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from "svelte";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||||
import { AlertCircle, BookOpen, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
|
||||
import { AlertCircle, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
|
||||
|
||||
import TitleBar from "./lib/TitleBar.svelte";
|
||||
import RepoToolbar from "./lib/RepoToolbar.svelte";
|
||||
import RepoTabs from "./lib/RepoTabs.svelte";
|
||||
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
|
||||
import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
|
||||
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
|
||||
@@ -365,6 +367,7 @@
|
||||
let fileHistoryResizeStartWidth = 0;
|
||||
let fileHistoryCollapsed = true;
|
||||
let themeMediaQuery: MediaQueryList | undefined;
|
||||
let appVersion = "";
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -426,6 +429,7 @@
|
||||
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
|
||||
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||
void runStartupSequence();
|
||||
void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; });
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -3525,11 +3529,6 @@
|
||||
|
||||
<main class="shell">
|
||||
<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; }}
|
||||
onOpenHelp={openHelp}
|
||||
language={appLanguage}
|
||||
@@ -3537,65 +3536,18 @@
|
||||
|
||||
<div class="shell-body">
|
||||
|
||||
<header class="repo-tabbar" aria-label="Repository tabs">
|
||||
<button
|
||||
class="repo-tab management"
|
||||
class:active={activeView === "management"}
|
||||
type="button"
|
||||
onclick={openRepoManagement}
|
||||
disabled={isBusy}
|
||||
title="Repository Management"
|
||||
>
|
||||
<BookOpen size={14} aria-hidden="true" />
|
||||
Repository Management
|
||||
</button>
|
||||
|
||||
<div class="repo-tabs-scroll">
|
||||
{#each repoTabs as repo (repo.path)}
|
||||
<div
|
||||
class="repo-tab-wrap"
|
||||
class:active={activeView === "repository" && sameRepoPath(activeRepoPath, repo.path)}
|
||||
role="presentation"
|
||||
oncontextmenu={(event) => openRepoTabContextMenu(repo.path, event)}
|
||||
>
|
||||
<button
|
||||
class="repo-tab"
|
||||
type="button"
|
||||
onclick={() => selectRepoTab(repo.path)}
|
||||
disabled={isBusy}
|
||||
title={repo.path}
|
||||
>
|
||||
<FolderOpen size={14} aria-hidden="true" />
|
||||
<span>{repo.name}</span>
|
||||
{#if repo.branch}
|
||||
<strong>{repo.branch}</strong>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="repo-tab-close"
|
||||
type="button"
|
||||
onclick={(event) => closeRepoTab(repo.path, event)}
|
||||
disabled={isBusy}
|
||||
aria-label={`Close ${repo.name}`}
|
||||
title="Close repository tab"
|
||||
>
|
||||
<X size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="repo-tab-add"
|
||||
type="button"
|
||||
onclick={chooseRepositoryFolder}
|
||||
disabled={isBusy}
|
||||
title="Open repository folder"
|
||||
aria-label="Open repository folder"
|
||||
>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<RepoTabs
|
||||
{activeView}
|
||||
{repoTabs}
|
||||
{isBusy}
|
||||
language={appLanguage}
|
||||
onOpenManagement={openRepoManagement}
|
||||
isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)}
|
||||
onSelect={selectRepoTab}
|
||||
onClose={closeRepoTab}
|
||||
onContextMenu={openRepoTabContextMenu}
|
||||
onAdd={chooseRepositoryFolder}
|
||||
/>
|
||||
|
||||
<!-- Repo actions live under the tab bar and disappear in Repository
|
||||
Management, where none of them are applicable. -->
|
||||
@@ -4145,6 +4097,19 @@
|
||||
</aside>
|
||||
</section>
|
||||
{/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>
|
||||
</main>
|
||||
|
||||
|
||||
+448
-39
@@ -896,10 +896,10 @@
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: stretch;
|
||||
min-height: 40px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 10px;
|
||||
background: rgba(16, 17, 29, 0.9);
|
||||
background: rgba(13, 15, 25, 0.92);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -907,76 +907,89 @@
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--color-border-subtle);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.repo-tab-wrap {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
flex: 0 1 230px;
|
||||
min-width: 150px;
|
||||
max-width: 260px;
|
||||
position: relative;
|
||||
border-right: 1px solid var(--color-border-subtle);
|
||||
background: rgba(255,255,255,0.015);
|
||||
background: rgba(255,255,255,0.012);
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.repo-tab-wrap:hover { background: rgba(255,255,255,0.035); }
|
||||
.repo-tab-wrap.active {
|
||||
background: rgba(90, 140, 248, 0.16);
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
background: linear-gradient(180deg, rgba(90,140,248,0.14), rgba(23,29,43,0.96));
|
||||
box-shadow: inset 0 -3px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
.repo-tab {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 42px;
|
||||
min-width: 0;
|
||||
max-width: 250px;
|
||||
padding: 0 10px;
|
||||
max-width: none;
|
||||
padding: 0 8px 0 13px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--color-border-subtle);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-weight: 720;
|
||||
text-align: left;
|
||||
}
|
||||
.repo-tab > svg { flex: 0 0 auto; color: var(--color-ink-faint); }
|
||||
.repo-tab.management {
|
||||
max-width: none;
|
||||
min-width: 190px;
|
||||
min-width: 184px;
|
||||
border: 0;
|
||||
box-shadow: inset 0 -3px 0 transparent;
|
||||
}
|
||||
.repo-tab.active,
|
||||
.repo-tab.management.active {
|
||||
color: var(--color-ink);
|
||||
background: linear-gradient(180deg, rgba(90,140,248,0.14), rgba(23,29,43,0.96));
|
||||
box-shadow: inset 0 -3px 0 var(--color-primary);
|
||||
}
|
||||
.repo-tab.management:hover:not(:disabled) { background: var(--color-surface-hover); }
|
||||
.repo-tab-wrap.active .repo-tab {
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.repo-tab-wrap.active .repo-tab > svg,
|
||||
.repo-tab.management.active > svg { color: var(--color-accent); }
|
||||
.repo-tab span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.repo-tab strong {
|
||||
flex: 0 0 auto;
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(94, 110, 156, 0.18);
|
||||
color: var(--color-ink-dim);
|
||||
font-size: 10.5px;
|
||||
font-family: var(--font-mono);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.repo-tab-close,
|
||||
.repo-tab-add {
|
||||
min-height: 38px;
|
||||
min-width: 38px;
|
||||
min-height: 42px;
|
||||
min-width: 36px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--color-ink-faint);
|
||||
}
|
||||
.repo-tab-close { opacity: 0.58; transition: opacity 120ms ease, color 120ms ease, background 120ms ease; }
|
||||
.repo-tab-wrap:hover .repo-tab-close,
|
||||
.repo-tab-wrap.active .repo-tab-close { opacity: 1; }
|
||||
.repo-tab-close:hover:not(:disabled) { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.repo-tab-add {
|
||||
min-width: 44px;
|
||||
border-left: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.repo-tab-add:hover:not(:disabled) { color: var(--color-accent); background: var(--color-surface-hover); }
|
||||
|
||||
.repo-management {
|
||||
display: grid;
|
||||
@@ -2132,15 +2145,15 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 4px 8px 4px calc(8px + var(--depth, 0) * 14px);
|
||||
min-height: 28px;
|
||||
padding: 2px 7px 2px calc(7px + var(--depth, 0) * 14px);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
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.active { background: rgba(90,140,248,0.1); border-color: rgba(90,140,248,0.28); }
|
||||
.explorer-row.folder { font-weight: 700; }
|
||||
@@ -4958,15 +4971,308 @@
|
||||
|
||||
:root[data-theme="light"] .repo-tabbar,
|
||||
:root[data-theme="light"] .repo-management-tools {
|
||||
background: rgba(255,255,255,0.78);
|
||||
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 {
|
||||
background: rgba(244,247,252,0.72);
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .repo-tab-wrap.active {
|
||||
background: rgba(49,95,214,0.1);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .repo-tab-wrap:hover,
|
||||
:root[data-theme="light"] .repo-tab.management:hover:not(:disabled) {
|
||||
background: rgba(255,255,255,0.9);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .repo-tab.management.active {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .repo-row {
|
||||
@@ -5419,9 +5725,10 @@
|
||||
}
|
||||
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); }
|
||||
.repo-form { grid-template-columns: 1fr; }
|
||||
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.repo-tab.management { min-width: 0; }
|
||||
.repo-tabs-scroll { grid-column: 1 / -1; order: 2; border-top: 1px solid var(--color-border-subtle); }
|
||||
.repo-tabbar { grid-template-columns: auto minmax(0, 1fr) auto; }
|
||||
.repo-tab.management { min-width: 44px; width: 44px; padding: 0; justify-content: center; }
|
||||
.repo-tab.management span { display: none; }
|
||||
.repo-tabs-scroll { grid-column: auto; order: initial; border-top: 0; }
|
||||
.repo-management-head,
|
||||
.repo-management-tools { align-items: stretch; flex-direction: column; }
|
||||
.repo-management-actions { justify-content: flex-start; }
|
||||
@@ -5457,3 +5764,105 @@
|
||||
.repo-toolbar-divider { margin-inline: 0; }
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { BookOpen, Database, Plus, X } from "@lucide/svelte";
|
||||
|
||||
interface RepositoryTabItem {
|
||||
path: string;
|
||||
name: string;
|
||||
branch: string | null;
|
||||
}
|
||||
|
||||
export let activeView: "management" | "repository" = "management";
|
||||
export let repoTabs: RepositoryTabItem[] = [];
|
||||
export let isBusy: boolean = false;
|
||||
export let language: "en" | "de" = "en";
|
||||
export let onOpenManagement: () => void = () => {};
|
||||
export let isActive: (path: string) => boolean = () => false;
|
||||
export let onSelect: (path: string) => void | Promise<void> = () => {};
|
||||
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
|
||||
export let onContextMenu: (path: string, event: MouseEvent) => void = () => {};
|
||||
export let onAdd: () => void | Promise<void> = () => {};
|
||||
</script>
|
||||
|
||||
<header class="repo-tabbar" aria-label={language === "de" ? "Repository-Reiter" : "Repository tabs"}>
|
||||
<button
|
||||
class="repo-tab management"
|
||||
class:active={activeView === "management"}
|
||||
type="button"
|
||||
onclick={onOpenManagement}
|
||||
disabled={isBusy}
|
||||
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
||||
>
|
||||
<BookOpen size={14} aria-hidden="true" />
|
||||
<span>{language === "de" ? "Repository-Verwaltung" : "Repository Management"}</span>
|
||||
</button>
|
||||
|
||||
<div class="repo-tabs-scroll">
|
||||
{#each repoTabs as repo (repo.path)}
|
||||
<div
|
||||
class="repo-tab-wrap"
|
||||
class:active={isActive(repo.path)}
|
||||
role="presentation"
|
||||
oncontextmenu={(event) => onContextMenu(repo.path, event)}
|
||||
>
|
||||
<button
|
||||
class="repo-tab"
|
||||
type="button"
|
||||
onclick={() => onSelect(repo.path)}
|
||||
disabled={isBusy}
|
||||
title={repo.path}
|
||||
>
|
||||
<Database size={15} aria-hidden="true" />
|
||||
<span>{repo.name}</span>
|
||||
</button>
|
||||
<button
|
||||
class="repo-tab-close"
|
||||
type="button"
|
||||
onclick={(event) => onClose(repo.path, event)}
|
||||
disabled={isBusy}
|
||||
aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}
|
||||
title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"}
|
||||
>
|
||||
<X size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="repo-tab-add"
|
||||
type="button"
|
||||
onclick={onAdd}
|
||||
disabled={isBusy}
|
||||
title={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
|
||||
aria-label={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
|
||||
>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
@@ -60,7 +60,6 @@
|
||||
|
||||
<div bind:this={toolbarElement} class="repo-toolbar" role="toolbar" aria-label={isGerman ? "Repository-Aktionen" : "Repository actions"}>
|
||||
<div class="repo-toolbar-sync">
|
||||
<span class="repo-toolbar-group-label">Sync</span>
|
||||
<div class="repo-action-group repo-sync-actions">
|
||||
<button
|
||||
class="repo-action fetch"
|
||||
|
||||
+2
-42
@@ -1,15 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
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";
|
||||
|
||||
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 onOpenSettings: () => void = () => {};
|
||||
export let language: "en" | "de" = "en";
|
||||
@@ -17,7 +11,6 @@
|
||||
let win: ReturnType<typeof getCurrentWindow> | null = null;
|
||||
let isMaximized = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
let appVersion = "";
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
@@ -29,12 +22,6 @@
|
||||
} catch {
|
||||
win = null;
|
||||
}
|
||||
|
||||
try {
|
||||
appVersion = await getVersion();
|
||||
} catch {
|
||||
appVersion = "";
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -61,36 +48,9 @@
|
||||
<img src={iconUrl} alt="" data-tauri-drag-region />
|
||||
</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>
|
||||
|
||||
<!-- Center: repo + branch info -->
|
||||
<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>
|
||||
<div class="titlebar-drag" data-tauri-drag-region aria-hidden="true"></div>
|
||||
|
||||
<!-- Right: app-global actions + window controls -->
|
||||
<div class="titlebar-globals">
|
||||
|
||||
@@ -69,17 +69,32 @@
|
||||
<section class="panel commit-panel" aria-label="Commit">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Commit</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Message</h2>
|
||||
<span class="eyebrow">Create revision</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commit</h2>
|
||||
</div>
|
||||
<div class="commit-head-actions">
|
||||
<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>
|
||||
<span class="pill pill-count">{stagedCount} staged</span>
|
||||
</div>
|
||||
|
||||
<form class="commit-form" onsubmit={handleSubmit}>
|
||||
<textarea
|
||||
value={commitMessage}
|
||||
oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)}
|
||||
placeholder="Commit message..."
|
||||
placeholder="Commit message"
|
||||
disabled={!hasRepository || isBusy}
|
||||
></textarea>
|
||||
{#if commitBlockReason}
|
||||
@@ -117,30 +132,6 @@
|
||||
{/if}
|
||||
{amendMode ? "Amend" : "Commit"}
|
||||
</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>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty";
|
||||
};
|
||||
|
||||
interface DiffMarker {
|
||||
start: number;
|
||||
end: number;
|
||||
kind: "add" | "delete" | "mixed";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
comparison: GitCommitComparison;
|
||||
selectedDiffPath: string;
|
||||
@@ -173,10 +179,40 @@
|
||||
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 selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
|
||||
let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : "");
|
||||
let splitRows = $derived(buildSplitRows(selectedPatch));
|
||||
let diffMarkers = $derived(buildDiffMarkers(splitRows));
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -262,6 +298,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Split diff grid -->
|
||||
<div class="split-diff-shell">
|
||||
<div class="split-diff" role="table" aria-label="Side-by-side diff">
|
||||
<div
|
||||
class="split-pane"
|
||||
@@ -298,6 +335,19 @@
|
||||
</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}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||
let patchScroll = $state<HTMLDivElement | null>(null);
|
||||
|
||||
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
||||
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||
@@ -118,6 +119,23 @@
|
||||
if (isBusy || isLoading) return;
|
||||
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>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
@@ -148,9 +166,10 @@
|
||||
{:else if parsed.binary || parsed.hunks.length === 0}
|
||||
<div class="blank-state">This change cannot be split into text lines.</div>
|
||||
{: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)}
|
||||
<section class="line-patch-hunk">
|
||||
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
|
||||
<div class="line-patch-hunk-head">
|
||||
<code>{hunk.header}</code>
|
||||
<div class="line-patch-hunk-actions">
|
||||
@@ -183,6 +202,19 @@
|
||||
</section>
|
||||
{/each}
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -156,11 +156,11 @@
|
||||
});
|
||||
</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>
|
||||
<span class="eyebrow">Working tree</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Status</h2>
|
||||
<span class="eyebrow">Workspace</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="pill pill-count">{stagedCount} staged</span>
|
||||
@@ -170,26 +170,6 @@
|
||||
|
||||
{#if hasRepository && changedFiles.length > 0}
|
||||
<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
|
||||
class="btn-sm danger"
|
||||
type="button"
|
||||
@@ -243,79 +223,60 @@
|
||||
{:else if changedFiles.length === 0}
|
||||
<div class="blank-state">No file changes returned.</div>
|
||||
{:else}
|
||||
<div class="overflow-auto p-2">
|
||||
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||
<article
|
||||
class="file-row"
|
||||
class:selected={isStatusSelected(file)}
|
||||
class:active={selectedFilePath === file.path}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div class="status-lanes overflow-auto">
|
||||
<section class="status-lane" aria-label="Unstaged changes">
|
||||
<header class="status-lane-head">
|
||||
<div><strong>Unstaged</strong><span>{unstagedCount}</span></div>
|
||||
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
|
||||
<Check size={13} aria-hidden="true" /> Stage all
|
||||
</button>
|
||||
</header>
|
||||
<div class="status-file-list">
|
||||
{#each changedFiles.filter((file) => file.unstaged !== null) as file (`unstaged:${fileKey(file)}`)}
|
||||
{@const stageTargets = selectedStageTargets(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.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
||||
<div class="status-file-actions">
|
||||
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><Check size={14} aria-hidden="true" /></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="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="change-lanes">
|
||||
<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>
|
||||
<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>
|
||||
<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)}
|
||||
<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"}>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
{stageTargets.length > 1 ? `Stage ${stageTargets.length}` : "Stage"}
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
|
||||
<FileDiff size={14} aria-hidden="true" />
|
||||
Details
|
||||
</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"}>
|
||||
<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>
|
||||
</article>
|
||||
{/each}
|
||||
</article>
|
||||
{/each}
|
||||
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user