Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0bca62362 | ||
|
|
d3cde83518 | ||
|
|
8e6811b786 | ||
|
|
de8832a919 | ||
|
|
25b98d7c4d | ||
|
|
7ef1a2dac8 | ||
|
|
44a5b776d7 | ||
|
|
55b4c9fc50 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "2026.7.16",
|
||||
"version": "2026.7.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitty",
|
||||
"version": "2026.7.16",
|
||||
"version": "2026.7.17",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "2026.7.16",
|
||||
"version": "2026.7.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gitty",
|
||||
"version": "2026.7.16",
|
||||
"version": "2026.7.17",
|
||||
"identifier": "com.gitty",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+110
-22
@@ -98,6 +98,7 @@
|
||||
|
||||
import type {
|
||||
AiSettings,
|
||||
AppTheme,
|
||||
AnalyticsSettings,
|
||||
CommitAiPhase,
|
||||
ConflictFile,
|
||||
@@ -133,7 +134,8 @@
|
||||
type AppView = "management" | "repository";
|
||||
type CredentialAction = "push" | "pull" | "fetch" | "clone";
|
||||
type PendingDiscard =
|
||||
| { kind: "file"; file: GitFileStatus; staged: boolean }
|
||||
| { kind: "file"; files: GitFileStatus[]; staged: boolean }
|
||||
| { kind: "all-changes"; files: GitFileStatus[] }
|
||||
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
|
||||
|
||||
interface RepoTab {
|
||||
@@ -164,6 +166,7 @@
|
||||
const REPO_STATUS_CACHE_KEY = "gitlite.repoStatusCache.v1";
|
||||
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
|
||||
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
|
||||
const APP_THEME_KEY = "gitlite.theme.v1";
|
||||
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
|
||||
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
|
||||
const LEFT_BRANCH_PANEL_HEIGHT_KEY = "gitlite.leftBranchPanelHeight.v1";
|
||||
@@ -238,6 +241,7 @@
|
||||
let appSettingsOpen = false;
|
||||
let analyticsNoticeOpen = false;
|
||||
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
|
||||
let appTheme: AppTheme = loadThemePreference();
|
||||
let localModelOptions: LocalModelOption[] = [];
|
||||
let errorMessage = "";
|
||||
let operation = "";
|
||||
@@ -336,6 +340,7 @@
|
||||
let fileHistoryResizeStartX = 0;
|
||||
let fileHistoryResizeStartWidth = 0;
|
||||
let fileHistoryCollapsed = true;
|
||||
let themeMediaQuery: MediaQueryList | undefined;
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -388,13 +393,18 @@
|
||||
$: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed);
|
||||
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
|
||||
|
||||
$: applyThemePreference(appTheme);
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
onMount(() => {
|
||||
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
|
||||
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||
void runStartupSequence();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
themeMediaQuery?.removeEventListener("change", handleSystemThemeChange);
|
||||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||||
if (backgroundRepoStatusTimer) clearInterval(backgroundRepoStatusTimer);
|
||||
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
|
||||
@@ -631,26 +641,32 @@
|
||||
|
||||
async function autoRefreshTick() {
|
||||
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
|
||||
const path = activeRepoPath;
|
||||
autoRefreshInFlight = true;
|
||||
try {
|
||||
// Cheap fast path: only fetch status; skip the heavy reload if nothing changed.
|
||||
const nextStatus = await getStatus(activeRepoPath);
|
||||
const nextStatus = await getStatus(path);
|
||||
// The user may have switched repos (or closed this one) while the status
|
||||
// call was in flight — applying a stale result would flash/overwrite the
|
||||
// now-active repo's name and data with this one's.
|
||||
if (!sameRepoPath(path, activeRepoPath)) return;
|
||||
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
|
||||
applyStatus(nextStatus);
|
||||
// Something changed — reload branches, commits and files in one bundled call.
|
||||
const bundle = await openRepositoryBundle(activeRepoPath, 100);
|
||||
const bundle = await openRepositoryBundle(path, 100);
|
||||
if (!sameRepoPath(path, activeRepoPath)) return;
|
||||
const previousHeadHash = lastFileHistoryHeadHash;
|
||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||
await refreshTags(activeRepoPath, bundle.tags);
|
||||
await refreshStashes(activeRepoPath, bundle.stashes);
|
||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||
await refreshBranchList(path, bundle.branches);
|
||||
await refreshTags(path, bundle.tags);
|
||||
await refreshStashes(path, bundle.stashes);
|
||||
await refreshCommitHistory(path, bundle.commits);
|
||||
await refreshExplorerFiles(path, bundle.files);
|
||||
// File history reflects `git log`, which only changes when HEAD actually moves
|
||||
// (new commit, checkout, merge, ...) — skip the reload otherwise so a plain
|
||||
// working-tree/status change (staging, edits) doesn't keep re-fetching and
|
||||
// flickering the currently viewed file's history.
|
||||
if (lastFileHistoryHeadHash !== previousHeadHash) {
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
if (lastFileHistoryHeadHash !== previousHeadHash && sameRepoPath(path, activeRepoPath)) {
|
||||
await refreshFileHistory(path);
|
||||
}
|
||||
} catch { /* ignore transient errors */ } finally {
|
||||
autoRefreshInFlight = false;
|
||||
@@ -747,11 +763,46 @@
|
||||
trackEvent("app_started", { first_run: 1 });
|
||||
}
|
||||
|
||||
function saveAppSettings(next: AnalyticsSettings) {
|
||||
function loadThemePreference(): AppTheme {
|
||||
try {
|
||||
const stored = localStorage.getItem(APP_THEME_KEY);
|
||||
if (stored === "system" || stored === "light" || stored === "dark") return stored;
|
||||
} catch {
|
||||
// Local storage is optional; system theme is a sane default.
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
function persistThemePreference(next: AppTheme) {
|
||||
try {
|
||||
localStorage.setItem(APP_THEME_KEY, next);
|
||||
} catch {
|
||||
// Ignore storage quota/private-mode errors.
|
||||
}
|
||||
}
|
||||
|
||||
function applyThemePreference(next: AppTheme) {
|
||||
const prefersLight = themeMediaQuery?.matches ?? window.matchMedia("(prefers-color-scheme: light)").matches;
|
||||
const resolved = next === "system"
|
||||
? prefersLight
|
||||
? "light"
|
||||
: "dark"
|
||||
: next;
|
||||
document.documentElement.dataset.themePreference = next;
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
}
|
||||
|
||||
function handleSystemThemeChange() {
|
||||
if (appTheme === "system") applyThemePreference(appTheme);
|
||||
}
|
||||
|
||||
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme) {
|
||||
analyticsSettings = next;
|
||||
appTheme = nextTheme;
|
||||
persistAnalyticsSettings(next);
|
||||
persistThemePreference(nextTheme);
|
||||
appSettingsOpen = false;
|
||||
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1 });
|
||||
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme });
|
||||
}
|
||||
|
||||
function updateCommitMessage(message: string) {
|
||||
@@ -2582,26 +2633,59 @@
|
||||
});
|
||||
}
|
||||
|
||||
function discardFile(file: GitFileStatus, staged: boolean) {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
pendingDiscard = { kind: "file", file, staged };
|
||||
function discardFiles(files: GitFileStatus[], staged: boolean) {
|
||||
if (!activeRepoPath || isBusy || files.length === 0) return;
|
||||
pendingDiscard = { kind: "file", files, staged };
|
||||
trackEvent("discard_confirm_opened", {
|
||||
kind: "file",
|
||||
staged: staged ? 1 : 0,
|
||||
files: files.length,
|
||||
});
|
||||
}
|
||||
|
||||
async function runDiscardFile(file: GitFileStatus, staged: boolean) {
|
||||
await runOperation(`Discarding ${file.path}`, async () => {
|
||||
applyStatus(await restoreFiles(activeRepoPath, [file.path], staged));
|
||||
function discardChanges(files: GitFileStatus[]) {
|
||||
if (!activeRepoPath || isBusy || files.length === 0) return;
|
||||
pendingDiscard = { kind: "all-changes", files };
|
||||
trackEvent("discard_confirm_opened", {
|
||||
kind: "all",
|
||||
files: files.length,
|
||||
});
|
||||
}
|
||||
|
||||
async function runDiscardFiles(files: GitFileStatus[], staged: boolean) {
|
||||
if (files.length === 0) return;
|
||||
const paths = files.map((file) => file.path);
|
||||
await runOperation(files.length === 1 ? `Discarding ${baseName(files[0].path)}` : `Discarding ${files.length} files`, async () => {
|
||||
applyStatus(await restoreFiles(activeRepoPath, paths, staged));
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
trackEvent("file_discarded", {
|
||||
files: files.length,
|
||||
staged: staged ? 1 : 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Discards both the staged and unstaged changes for each given file (used
|
||||
// by "Discard all" and "Discard selected", which don't distinguish lanes).
|
||||
async function runDiscardAllChanges(files: GitFileStatus[]) {
|
||||
const stagedPaths = files.filter((file) => file.staged !== null).map((file) => file.path);
|
||||
const unstagedPaths = files.filter((file) => file.unstaged !== null).map((file) => file.path);
|
||||
if (stagedPaths.length === 0 && unstagedPaths.length === 0) return;
|
||||
await runOperation(files.length === 1 ? `Discarding ${baseName(files[0].path)}` : `Discarding ${files.length} files`, async () => {
|
||||
let nextStatus: GitStatus | null = null;
|
||||
if (stagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, stagedPaths, true);
|
||||
if (unstagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, unstagedPaths, false);
|
||||
if (nextStatus) applyStatus(nextStatus);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
trackEvent("file_discarded", {
|
||||
files: files.length,
|
||||
staged: 2,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openLinePatch(file: GitFileStatus, staged: boolean) {
|
||||
if (!activeRepoPath) return;
|
||||
linePatchOpen = true;
|
||||
@@ -2737,7 +2821,9 @@
|
||||
if (!discard || !activeRepoPath || isBusy) return;
|
||||
|
||||
if (discard.kind === "file") {
|
||||
await runDiscardFile(discard.file, discard.staged);
|
||||
await runDiscardFiles(discard.files, discard.staged);
|
||||
} else if (discard.kind === "all-changes") {
|
||||
await runDiscardAllChanges(discard.files);
|
||||
} else {
|
||||
await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged);
|
||||
}
|
||||
@@ -3744,7 +3830,8 @@
|
||||
onSelectFile={selectFileFromStatus}
|
||||
onStage={stageFile}
|
||||
onUnstage={unstageFile}
|
||||
onDiscard={discardFile}
|
||||
onDiscard={discardFiles}
|
||||
onDiscardMany={discardChanges}
|
||||
onPatch={openLinePatch}
|
||||
onStageAll={stageAllFiles}
|
||||
onUnstageAll={unstageAllFiles}
|
||||
@@ -3889,6 +3976,7 @@
|
||||
{#if appSettingsOpen}
|
||||
<AppSettingsDialog
|
||||
analytics={analyticsSettings}
|
||||
theme={appTheme}
|
||||
onSave={saveAppSettings}
|
||||
onClose={() => { appSettingsOpen = false; }}
|
||||
/>
|
||||
@@ -3921,8 +4009,8 @@
|
||||
|
||||
{#if pendingDiscard}
|
||||
<DiscardConfirmDialog
|
||||
file={pendingDiscard.file}
|
||||
staged={pendingDiscard.staged}
|
||||
files={pendingDiscard.kind === "hunk" ? [pendingDiscard.file] : pendingDiscard.files}
|
||||
staged={pendingDiscard.kind === "all-changes" ? null : pendingDiscard.staged}
|
||||
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"}
|
||||
{isBusy}
|
||||
onConfirm={confirmDiscard}
|
||||
|
||||
+768
-157
File diff suppressed because it is too large
Load Diff
+47
-19
@@ -24,22 +24,44 @@
|
||||
export let onToggleAutoRefresh: () => void = () => {};
|
||||
export let onOpenSettings: () => void = () => {};
|
||||
|
||||
const win = getCurrentWindow();
|
||||
let win: ReturnType<typeof getCurrentWindow> | null = null;
|
||||
let isMaximized = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
let appVersion = "";
|
||||
|
||||
onMount(async () => {
|
||||
isMaximized = await win.isMaximized();
|
||||
unlisten = await win.onResized(async () => {
|
||||
try {
|
||||
win = getCurrentWindow();
|
||||
isMaximized = await win.isMaximized();
|
||||
});
|
||||
appVersion = await getVersion();
|
||||
unlisten = await win.onResized(async () => {
|
||||
isMaximized = await win?.isMaximized() ?? false;
|
||||
});
|
||||
} catch {
|
||||
win = null;
|
||||
}
|
||||
|
||||
try {
|
||||
appVersion = await getVersion();
|
||||
} catch {
|
||||
appVersion = "";
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unlisten?.();
|
||||
});
|
||||
|
||||
function minimizeWindow() {
|
||||
void win?.minimize();
|
||||
}
|
||||
|
||||
function toggleMaximizeWindow() {
|
||||
void win?.toggleMaximize();
|
||||
}
|
||||
|
||||
function closeWindow() {
|
||||
void win?.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="titlebar" data-tauri-drag-region>
|
||||
@@ -57,17 +79,23 @@
|
||||
<!-- Center: repo + branch info -->
|
||||
<div class="titlebar-info" data-tauri-drag-region>
|
||||
{#if hasRepository}
|
||||
{#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>{branch}</span>
|
||||
{#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>
|
||||
<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>
|
||||
@@ -200,12 +228,12 @@
|
||||
<div class="titlebar-divider" aria-hidden="true"></div>
|
||||
|
||||
<div class="titlebar-controls">
|
||||
<button class="tb-btn" onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
||||
<button class="tb-btn" onclick={minimizeWindow} title="Minimize" aria-label="Minimize">
|
||||
<Minus size={12} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="tb-btn"
|
||||
onclick={() => win.toggleMaximize()}
|
||||
onclick={toggleMaximizeWindow}
|
||||
title={isMaximized ? "Restore" : "Maximize"}
|
||||
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
@@ -220,7 +248,7 @@
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="tb-btn close" onclick={() => win.close()} title="Close" aria-label="Close">
|
||||
<button class="tb-btn close" onclick={closeWindow} title="Close" aria-label="Close">
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Check, Settings, X } from "@lucide/svelte";
|
||||
import type { AnalyticsSettings } from "../types";
|
||||
import type { AnalyticsSettings, AppTheme } from "../types";
|
||||
|
||||
interface Props {
|
||||
analytics: AnalyticsSettings;
|
||||
onSave: (settings: AnalyticsSettings) => void;
|
||||
theme: AppTheme;
|
||||
onSave: (settings: AnalyticsSettings, theme: AppTheme) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { analytics, onSave = () => {}, onClose = () => {} }: Props = $props();
|
||||
let { analytics, theme = "system", onSave = () => {}, onClose = () => {} }: Props = $props();
|
||||
|
||||
let analyticsEnabled = $state(true);
|
||||
let selectedTheme = $state<AppTheme>("system");
|
||||
|
||||
$effect(() => {
|
||||
analyticsEnabled = analytics.enabled;
|
||||
selectedTheme = theme;
|
||||
});
|
||||
|
||||
function save() {
|
||||
@@ -21,7 +24,7 @@
|
||||
...analytics,
|
||||
enabled: analyticsEnabled,
|
||||
noticeSeen: true,
|
||||
});
|
||||
}, selectedTheme);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -38,6 +41,31 @@
|
||||
</header>
|
||||
|
||||
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">Appearance</span>
|
||||
<h3>Theme</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="settings-segmented" role="radiogroup" aria-label="Theme">
|
||||
<label class:active={selectedTheme === "system"}>
|
||||
<input type="radio" bind:group={selectedTheme} value="system" />
|
||||
<span>System</span>
|
||||
</label>
|
||||
<label class:active={selectedTheme === "light"}>
|
||||
<input type="radio" bind:group={selectedTheme} value="light" />
|
||||
<span>Light</span>
|
||||
</label>
|
||||
<label class:active={selectedTheme === "dark"}>
|
||||
<input type="radio" bind:group={selectedTheme} value="dark" />
|
||||
<span>Dark</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import type { GitFileStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
file: GitFileStatus;
|
||||
staged: boolean;
|
||||
files: GitFileStatus[];
|
||||
staged: boolean | null;
|
||||
scope: "file" | "hunk";
|
||||
isBusy: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
file,
|
||||
files,
|
||||
staged = false,
|
||||
scope = "file",
|
||||
isBusy = false,
|
||||
@@ -20,10 +20,16 @@
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let targetPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||
let title = $derived(scope === "hunk" ? "Discard hunk?" : "Discard file changes?");
|
||||
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
|
||||
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
|
||||
function targetPath(file: GitFileStatus): string {
|
||||
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
||||
}
|
||||
|
||||
let count = $derived(files.length);
|
||||
let title = $derived(
|
||||
scope === "hunk" ? "Discard hunk?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
|
||||
);
|
||||
let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : count > 1 ? `${count} files` : "file");
|
||||
let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes");
|
||||
|
||||
</script>
|
||||
|
||||
@@ -46,11 +52,22 @@
|
||||
|
||||
<div class="discard-confirm-copy">
|
||||
<p>
|
||||
This will reset the {sourceLabel} for the {scopeLabel.toLowerCase()} below.
|
||||
This will reset the {sourceLabel} for the {scopeLabel} below.
|
||||
</p>
|
||||
<code class="discard-target" title={targetPath}>{targetPath}</code>
|
||||
{#if count > 1}
|
||||
<ul class="discard-target-list">
|
||||
{#each files.slice(0, 8) as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||
<li><code class="discard-target" title={targetPath(file)}>{targetPath(file)}</code></li>
|
||||
{/each}
|
||||
{#if files.length > 8}
|
||||
<li class="discard-target-more">+{files.length - 8} more</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{:else if count === 1}
|
||||
<code class="discard-target" title={targetPath(files[0])}>{targetPath(files[0])}</code>
|
||||
{/if}
|
||||
<p class="discard-warning-text">
|
||||
This cannot be undone. If the file only exists in your working tree, it can be deleted entirely.
|
||||
This cannot be undone. If a file only exists in your working tree, it can be deleted entirely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,9 +41,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 50% 42%, rgba(137, 92, 255, 0.18), transparent 34%),
|
||||
linear-gradient(135deg, #050712 0%, #080b16 46%, #120b18 100%);
|
||||
background: linear-gradient(135deg, #070a10 0%, #0c111a 48%, #090e16 100%);
|
||||
animation: overlay-in 180ms ease;
|
||||
}
|
||||
|
||||
@@ -52,8 +50,8 @@
|
||||
inset: 0;
|
||||
opacity: 0.28;
|
||||
background-image:
|
||||
linear-gradient(rgba(151, 118, 255, 0.12) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 109, 38, 0.08) 1px, transparent 1px);
|
||||
linear-gradient(rgba(111, 140, 255, 0.09) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(77, 182, 214, 0.07) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
mask-image: radial-gradient(circle at center, black 0%, transparent 68%);
|
||||
animation: grid-drift 10s linear infinite;
|
||||
@@ -67,14 +65,14 @@
|
||||
gap: 18px;
|
||||
width: min(420px, calc(100vw - 42px));
|
||||
padding: 30px 34px 28px;
|
||||
border: 1px solid rgba(160, 124, 255, 0.24);
|
||||
border: 1px solid rgba(90, 111, 154, 0.28);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(23, 26, 43, 0.9), rgba(12, 15, 27, 0.92)),
|
||||
linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)),
|
||||
var(--color-surface-raised);
|
||||
box-shadow:
|
||||
0 26px 90px rgba(0, 0, 0, 0.62),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
0 26px 78px rgba(0, 0, 0, 0.54),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.repo-loading-mark {
|
||||
@@ -89,7 +87,7 @@
|
||||
.repo-loading-halo {
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
border: 1px solid rgba(151, 118, 255, 0.24);
|
||||
border: 1px solid rgba(111, 140, 255, 0.22);
|
||||
border-radius: 34px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
@@ -98,7 +96,7 @@
|
||||
}
|
||||
.repo-loading-halo.halo-two {
|
||||
inset: 24px;
|
||||
border-color: rgba(255, 109, 38, 0.26);
|
||||
border-color: rgba(77, 182, 214, 0.24);
|
||||
animation: halo-breathe 2.4s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
@@ -116,18 +114,18 @@
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 165;
|
||||
stroke-dashoffset: 165;
|
||||
filter: drop-shadow(0 0 8px rgba(151, 118, 255, 0.55));
|
||||
filter: drop-shadow(0 0 8px rgba(77, 182, 214, 0.32));
|
||||
animation: trace-draw 2.6s ease-in-out infinite;
|
||||
}
|
||||
.repo-loading-traces .trace-main {
|
||||
stroke: #9b70ff;
|
||||
stroke: #6f8cff;
|
||||
}
|
||||
.repo-loading-traces .trace-branch {
|
||||
stroke: #ff6d26;
|
||||
stroke: #4db6d6;
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
.repo-loading-traces .trace-cut {
|
||||
stroke: rgba(255, 255, 255, 0.42);
|
||||
stroke: rgba(177, 186, 208, 0.42);
|
||||
stroke-dasharray: 128;
|
||||
stroke-dashoffset: 128;
|
||||
animation-delay: 0.55s;
|
||||
@@ -141,7 +139,7 @@
|
||||
object-fit: contain;
|
||||
filter:
|
||||
drop-shadow(0 18px 24px rgba(0, 0, 0, 0.55))
|
||||
drop-shadow(0 0 18px rgba(151, 118, 255, 0.28));
|
||||
drop-shadow(0 0 18px rgba(77, 182, 214, 0.2));
|
||||
animation: icon-float 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -175,18 +173,59 @@
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(151, 118, 255, 0.14);
|
||||
background: rgba(111, 140, 255, 0.14);
|
||||
}
|
||||
.repo-loading-bar span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 46%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, transparent, #9b70ff 40%, #ff6d26 74%, transparent);
|
||||
box-shadow: 0 0 16px rgba(255, 109, 38, 0.32);
|
||||
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent);
|
||||
box-shadow: 0 0 16px rgba(77, 182, 214, 0.26);
|
||||
animation: bar-slide 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .repo-loading {
|
||||
background:
|
||||
radial-gradient(circle at 50% 42%, rgba(49, 95, 214, 0.12), transparent 34%),
|
||||
linear-gradient(135deg, #f7f9fd 0%, #eef3f9 48%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .repo-loading-grid {
|
||||
opacity: 0.38;
|
||||
background-image:
|
||||
linear-gradient(rgba(49, 95, 214, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(15, 143, 181, 0.08) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .repo-loading-card {
|
||||
border-color: rgba(61, 89, 142, 0.2);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.96), rgba(244,247,252,0.94)),
|
||||
var(--color-surface-raised);
|
||||
box-shadow:
|
||||
0 26px 72px rgba(28,44,74,0.18),
|
||||
inset 0 1px 0 rgba(255,255,255,0.9);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .repo-loading-traces .trace {
|
||||
filter: drop-shadow(0 0 8px rgba(49,95,214,0.22));
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .repo-loading-traces .trace-cut {
|
||||
stroke: rgba(49,95,214,0.34);
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .repo-loading-icon {
|
||||
filter:
|
||||
drop-shadow(0 18px 24px rgba(28,44,74,0.2))
|
||||
drop-shadow(0 0 18px rgba(49,95,214,0.18));
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .repo-loading-bar {
|
||||
background: rgba(49,95,214,0.12);
|
||||
}
|
||||
|
||||
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes grid-drift { to { transform: translate3d(42px, 42px, 0); } }
|
||||
@keyframes icon-float {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
onSelectFile: (file: GitFileStatus) => void;
|
||||
onStage: (files: GitFileStatus[]) => void;
|
||||
onUnstage: (files: GitFileStatus[]) => void;
|
||||
onDiscard: (file: GitFileStatus, staged: boolean) => void;
|
||||
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
|
||||
onDiscardMany: (files: GitFileStatus[]) => void;
|
||||
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
||||
onStageAll: () => void;
|
||||
onUnstageAll: () => void;
|
||||
@@ -34,6 +35,7 @@
|
||||
onStage = () => {},
|
||||
onUnstage = () => {},
|
||||
onDiscard = () => {},
|
||||
onDiscardMany = () => {},
|
||||
onPatch = () => {},
|
||||
onStageAll = () => {},
|
||||
onUnstageAll = () => {},
|
||||
@@ -125,6 +127,21 @@
|
||||
onUnstage(targets);
|
||||
}
|
||||
|
||||
// Discard mirrors the stage/unstage target selection: if the clicked row is
|
||||
// part of the current multi-selection, the whole selection (filtered to the
|
||||
// relevant lane) is discarded; otherwise just that one file.
|
||||
function discardStagedFromFile(file: GitFileStatus) {
|
||||
const targets = selectedUnstageTargets(file);
|
||||
if (targets.length === 0) return;
|
||||
onDiscard(targets, true);
|
||||
}
|
||||
|
||||
function discardUnstagedFromFile(file: GitFileStatus) {
|
||||
const targets = selectedStageTargets(file);
|
||||
if (targets.length === 0) return;
|
||||
onDiscard(targets, false);
|
||||
}
|
||||
|
||||
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
||||
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
||||
let selectedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f))).length);
|
||||
@@ -173,6 +190,16 @@
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
Unstage all
|
||||
</button>
|
||||
<button
|
||||
class="btn-sm danger"
|
||||
type="button"
|
||||
onclick={() => onDiscardMany(changedFiles)}
|
||||
disabled={isBusy || changedFiles.length === 0}
|
||||
title="Discard all changes"
|
||||
>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Discard all
|
||||
</button>
|
||||
{#if selectedCount > 1}
|
||||
<span class="status-selection-count">{selectedCount} selected</span>
|
||||
<button
|
||||
@@ -195,6 +222,16 @@
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
Unstage selected
|
||||
</button>
|
||||
<button
|
||||
class="btn-sm danger"
|
||||
type="button"
|
||||
onclick={() => onDiscardMany(selectedFiles())}
|
||||
disabled={isBusy || selectedCount === 0}
|
||||
title="Discard changes in selected files"
|
||||
>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Discard selected
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -241,9 +278,9 @@
|
||||
<FileDiff size={14} aria-hidden="true" />
|
||||
Details
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
|
||||
<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" />
|
||||
Discard
|
||||
{unstageTargets.length > 1 ? `Discard ${unstageTargets.length}` : "Discard"}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="quiet">No staged change</span>
|
||||
@@ -267,9 +304,9 @@
|
||||
<FileDiff size={14} aria-hidden="true" />
|
||||
Details
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
|
||||
<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" />
|
||||
Discard
|
||||
{stageTargets.length > 1 ? `Discard ${stageTargets.length}` : "Discard"}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="quiet">No unstaged change</span>
|
||||
@@ -310,8 +347,8 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background:
|
||||
radial-gradient(circle at 50% 38%, rgba(137, 92, 255, 0.16), transparent 55%),
|
||||
rgba(8, 9, 16, 0.58);
|
||||
radial-gradient(circle at 50% 38%, rgba(111, 140, 255, 0.1), transparent 55%),
|
||||
rgba(7, 10, 16, 0.62);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: status-panel-overlay-in 120ms ease;
|
||||
}
|
||||
@@ -323,13 +360,13 @@
|
||||
gap: 12px;
|
||||
width: min(300px, calc(100% - 32px));
|
||||
padding: 26px 28px 26px;
|
||||
border: 1px solid rgba(160, 124, 255, 0.24);
|
||||
border: 1px solid rgba(90, 111, 154, 0.28);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(23, 26, 43, 0.9), rgba(12, 15, 27, 0.92)),
|
||||
linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)),
|
||||
var(--color-surface-raised);
|
||||
color: var(--color-ink);
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.status-panel-overlay-mark {
|
||||
@@ -344,7 +381,7 @@
|
||||
.status-panel-overlay-halo {
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border: 1px solid rgba(151, 118, 255, 0.24);
|
||||
border: 1px solid rgba(111, 140, 255, 0.22);
|
||||
border-radius: 22px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
@@ -353,7 +390,7 @@
|
||||
}
|
||||
.status-panel-overlay-halo.halo-two {
|
||||
inset: 16px;
|
||||
border-color: rgba(255, 109, 38, 0.26);
|
||||
border-color: rgba(77, 182, 214, 0.24);
|
||||
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
@@ -371,16 +408,16 @@
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 165;
|
||||
stroke-dashoffset: 165;
|
||||
filter: drop-shadow(0 0 6px rgba(151, 118, 255, 0.55));
|
||||
filter: drop-shadow(0 0 6px rgba(77, 182, 214, 0.32));
|
||||
animation: status-panel-overlay-trace-draw 2.6s ease-in-out infinite;
|
||||
}
|
||||
.status-panel-overlay-traces .trace-main { stroke: #9b70ff; }
|
||||
.status-panel-overlay-traces .trace-main { stroke: #6f8cff; }
|
||||
.status-panel-overlay-traces .trace-branch {
|
||||
stroke: #ff6d26;
|
||||
stroke: #4db6d6;
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
.status-panel-overlay-traces .trace-cut {
|
||||
stroke: rgba(255, 255, 255, 0.42);
|
||||
stroke: rgba(177, 186, 208, 0.42);
|
||||
stroke-dasharray: 128;
|
||||
stroke-dashoffset: 128;
|
||||
animation-delay: 0.55s;
|
||||
@@ -394,7 +431,7 @@
|
||||
object-fit: contain;
|
||||
filter:
|
||||
drop-shadow(0 8px 12px rgba(0, 0, 0, 0.5))
|
||||
drop-shadow(0 0 10px rgba(151, 118, 255, 0.28));
|
||||
drop-shadow(0 0 10px rgba(77, 182, 214, 0.2));
|
||||
animation: status-panel-overlay-icon-float 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -415,15 +452,15 @@
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(151, 118, 255, 0.14);
|
||||
background: rgba(111, 140, 255, 0.14);
|
||||
}
|
||||
.status-panel-overlay-bar span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 46%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, transparent, #9b70ff 40%, #ff6d26 74%, transparent);
|
||||
box-shadow: 0 0 12px rgba(255, 109, 38, 0.32);
|
||||
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent);
|
||||
box-shadow: 0 0 12px rgba(77, 182, 214, 0.26);
|
||||
animation: status-panel-overlay-bar-slide 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export type FileStatusKind =
|
||||
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
||||
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
||||
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
|
||||
export type AppTheme = "system" | "light" | "dark";
|
||||
|
||||
export interface CommitAiStatus {
|
||||
phase: CommitAiPhase;
|
||||
|
||||
+17
@@ -3,12 +3,29 @@ import { mount } from "svelte";
|
||||
import App from "./App.svelte";
|
||||
import "./app.css";
|
||||
|
||||
const THEME_KEY = "gitlite.theme.v1";
|
||||
const target = document.getElementById("app");
|
||||
|
||||
if (!target) {
|
||||
throw new Error("App target element was not found.");
|
||||
}
|
||||
|
||||
try {
|
||||
const storedTheme = localStorage.getItem(THEME_KEY);
|
||||
const preference = storedTheme === "light" || storedTheme === "dark" || storedTheme === "system"
|
||||
? storedTheme
|
||||
: "system";
|
||||
const resolved = preference === "system" && window.matchMedia("(prefers-color-scheme: light)").matches
|
||||
? "light"
|
||||
: preference === "system"
|
||||
? "dark"
|
||||
: preference;
|
||||
document.documentElement.dataset.themePreference = preference;
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
} catch {
|
||||
// Storage is best-effort; the CSS system theme fallback still applies.
|
||||
}
|
||||
|
||||
const app = mount(App, { target });
|
||||
|
||||
export default app;
|
||||
|
||||
Reference in New Issue
Block a user