Compare commits

..
12 Commits
Author SHA1 Message Date
Christoph 0af57f1818 Merge pull request 'Opt/light mode' (#23) from opt/light_mode into main
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 18m49s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 27m6s
Reviewed-on: #23
2026-07-10 16:22:06 +00:00
Christoph Brandau 8e6811b786 style(ui): refresh status panel color palette
The StatusPanel component has received a visual update to modernize its appearance. This change adjusts the primary color scheme across multiple elements, shifting from purples and magentas towards cooler blue and cyan tones. The goal is to improve aesthetic consistency and give the component a more cohesive look within the application's overall design system.

- Updated accent colors and gradients throughout the panel.
- Refined borders and shadows for a cleaner visual depth.
- Adjusted trace and overlay element colors for better harmony.
2026-07-10 17:33:52 +02:00
Christoph Brandau de8832a919 style(ui): update global theme colors and palette
This commit implements a comprehensive visual refresh by updating the core color scheme across the application. A new, cohesive dark mode aesthetic has been applied, shifting primary accents from purple/orange tones to cooler blue/cyan gradients. This ensures better consistency and modernizes the look of various components, including buttons and loading indicators.

- Updated global CSS variables for consistent theming
- Refreshed component styles with the new color palette
- Adjusted repository loading overlay visuals
2026-07-10 13:47:50 +02:00
Christoph Brandau 25b98d7c4d feat(ui): Implement theme persistence and system preference support
Adds comprehensive theme management across the application, allowing users to save their preferred color scheme (light/dark) or automatically follow the operating system's settings. This required updating state management in App.svelte to handle theme loading and applying changes dynamically via CSS variables.

The styling layer was significantly updated with new CSS variables and rules for both dark and light modes, ensuring visual consistency across all components like dialogs, buttons, and sidebars.

- Added logic to detect system color scheme preference
- Implemented full support for persistent light/dark mode switching
- Overhauled global CSS variables for improved theme adaptability
2026-07-10 12:24:14 +02:00
Christoph Brandau 7ef1a2dac8 refactor(status): Support batch and all changes discard functionality
The file discarding mechanism has been significantly refactored to improve handling of multi-file operations. Instead of processing discards on a per-file basis, the system now supports batch actions for selected files or reverting all tracked modifications simultaneously. This update centralizes complex discard logic into dedicated functions across the component suite.

- Updated state management and types to handle arrays of file changes.
- Added UI elements and handlers for discarding all staged/unstaged changes.
- Enhanced the confirmation dialog to display multiple discarded targets in a list view.
2026-07-10 11:36:15 +02:00
Christoph Brandau 44a5b776d7 fix(refresh): improve auto-refresh robustness against repo switches
The automatic data refreshing mechanism has been updated to handle asynchronous state changes more reliably. It now incorporates checks to determine if the active repository path has changed during background operations. This prevents stale or incorrect data from being displayed if a user navigates away from or switches repositories while a refresh cycle is in progress.

- Added path validation checks after status fetching and bundle opening
- Ensured all subsequent refresh calls use the validated current repository path
2026-07-10 11:15:47 +02:00
Christoph 55b4c9fc50 Update version to 2026.7.17 2026-07-09 20:58:50 +02:00
Christoph Brandau 35d9fc969c feat(status-panel): enhance UI and add new functionality
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 18m10s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 30m34s
This update introduces several improvements to the StatusPanel component, enhancing the user interface and experience. A new function has been added to simplify file path handling, and the overlay now features a more dynamic design with animated elements for better visual feedback during operations.

- Added baseName function for improved path handling
- Enhanced status overlay with animations and new design elements
- Updated file staging and unstaging messages for clarity
2026-07-09 19:51:51 +02:00
Christoph Brandau 710c6d4a53 feat(git): refactor Git commands to use async/await pattern
This update modifies several Git command functions to utilize the async/await
pattern, enhancing performance and responsiveness during operations. The changes
allow for non-blocking execution of commands, improving the overall user experience.

- Converted multiple Git command functions to async
- Added error handling for async operations
- Updated related tests to support async execution
2026-07-09 19:17:02 +02:00
Christoph Brandau 0877d5a568 feat(status-panel): add loading overlay for ongoing operations
This update introduces a loading overlay in the StatusPanel component
to indicate ongoing operations. The overlay displays a spinner and
the current operation status, enhancing user experience during
long-running tasks.

- Added a loading overlay with a spinner for busy states
- Included operation status text to inform users of current actions
- Improved visual feedback for ongoing processes in the UI
2026-07-09 15:20:52 +02:00
Christoph Brandau f79f3dc1ec feat(recent-repos): add functionality to remove repositories from recent
This update introduces a new function to remove repositories from the
recent list. The user can now manage their recent repositories more
effectively, enhancing the overall user experience.

- Implemented removal of repositories from the recent list
- Updated UI to reflect changes in the recent repositories management
2026-07-09 15:14:29 +02:00
Christoph cf2773b6f4 Update version to 2026.7.16 2026-07-09 09:54:57 +02:00
14 changed files with 1350 additions and 350 deletions
+5 -1
View File
@@ -96,7 +96,11 @@
"Bash(identify src-tauri/icons/GitCat.ico)",
"Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")",
"Bash(python3 *)",
"Bash(xxd -l 16 src-tauri/icons/GitCat.ico)"
"Bash(xxd -l 16 src-tauri/icons/GitCat.ico)",
"Bash(pkg-config --exists openssl)",
"Bash(sudo apt-get install -y libssl-dev pkg-config)",
"Bash(dpkg -L libssl3t64)",
"Bash(grep *)"
]
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitty",
"version": "0.200.5",
"version": "2026.7.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitty",
"version": "0.200.5",
"version": "2026.7.17",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitty",
"version": "0.200.5",
"version": "2026.7.17",
"private": true,
"type": "module",
"scripts": {
+63 -18
View File
@@ -789,7 +789,8 @@ fn validate_tag_ref_name(name: &str) -> Result<String, String> {
}
#[tauri::command]
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
pub async fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
validate_files(&files)?;
@@ -814,10 +815,14 @@ pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String
}
status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not stage files: {err}"))?
}
#[tauri::command]
pub fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
pub async fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
validate_files(&files)?;
@@ -827,10 +832,14 @@ pub fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, Stri
}
status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not unstage files: {err}"))?
}
#[tauri::command]
pub fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<GitStatus, String> {
pub async fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
validate_files(&files)?;
@@ -846,6 +855,9 @@ pub fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<G
}
status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not restore files: {err}"))?
}
#[tauri::command]
@@ -1055,7 +1067,8 @@ pub fn apply_file_patch(
}
#[tauri::command]
pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
pub async fn commit(path: String, message: String) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if message.trim().is_empty() {
return Err("Commit message must not be empty.".to_string());
@@ -1068,10 +1081,14 @@ pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
run_git(&repo, ["commit", "-m", message.as_str()])?;
status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not commit: {err}"))?
}
#[tauri::command]
pub fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> {
pub async fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if verify_commit(&repo, "HEAD").is_err() {
return Err("There is no commit to amend.".to_string());
@@ -1096,6 +1113,9 @@ pub fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus,
}
status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not amend commit: {err}"))?
}
#[tauri::command]
@@ -1132,11 +1152,12 @@ pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
}
#[tauri::command]
pub fn pull(
pub async fn pull(
path: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"];
let output = match (username.as_deref(), password.as_deref()) {
@@ -1165,6 +1186,9 @@ pub fn pull(
return Err(format!("AUTH_FAILED:{details}"));
}
Err(format!("Git command failed: {details}"))
})
.await
.map_err(|err| format!("Could not pull: {err}"))?
}
#[tauri::command]
@@ -1203,11 +1227,12 @@ pub async fn fetch(
}
#[tauri::command]
pub fn push(
pub async fn push(
path: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let push_args = push_args_for_repo(&repo)?;
match (username.as_deref(), password.as_deref()) {
@@ -1219,6 +1244,9 @@ pub fn push(
}
}
status_for_repo(&repo)
})
.await
.map_err(|err| format!("Could not push: {err}"))?
}
// ── Credential storage (OS keychain) ────────────────────────────────────────
@@ -1388,7 +1416,8 @@ pub fn cred_delete(key: String) -> Result<(), String> {
}
#[tauri::command]
pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = branch.trim();
if branch.is_empty() {
@@ -1425,10 +1454,14 @@ pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
};
Err(format!("Merge failed: {details}"))
})
.await
.map_err(|err| format!("Could not merge: {err}"))?
}
#[tauri::command]
pub fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = branch.trim();
if branch.is_empty() {
@@ -1443,6 +1476,9 @@ pub fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String>
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
rebase_status_or_error(&repo, output, "Rebase failed", true)
})
.await
.map_err(|err| format!("Could not rebase: {err}"))?
}
#[tauri::command]
@@ -1494,7 +1530,8 @@ fn rebase_status_or_error(
}
#[tauri::command]
pub fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
pub async fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let commit_hash = verify_commit(&repo, &commit)?;
@@ -1507,6 +1544,9 @@ pub fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, Str
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
cherry_pick_status_or_error(&repo, output, "Cherry-pick failed")
})
.await
.map_err(|err| format!("Could not cherry-pick: {err}"))?
}
#[tauri::command]
@@ -4841,12 +4881,12 @@ mod tests {
}));
}
#[test]
#[tokio::test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local pull tests with a sh signal pipe error"
)]
fn pull_merges_diverging_branch_instead_of_requiring_fast_forward() {
async fn pull_merges_diverging_branch_instead_of_requiring_fast_forward() {
let repo = init_temp_repo("pull_diverged");
commit_initial_file(&repo.path);
let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
@@ -4876,7 +4916,9 @@ mod tests {
],
);
let status = pull(repo.path.to_string_lossy().to_string(), None, None).unwrap();
let status = pull(repo.path.to_string_lossy().to_string(), None, None)
.await
.unwrap();
assert!(status.clean, "{:?}", status.files);
assert!(repo.path.join("remote.txt").exists());
@@ -4925,12 +4967,12 @@ mod tests {
);
}
#[test]
#[tokio::test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
)]
fn push_sets_upstream_for_branch_without_tracking_remote() {
async fn push_sets_upstream_for_branch_without_tracking_remote() {
let repo = init_temp_repo("push_sets_upstream_integration");
let remote = init_bare_temp_repo("push_sets_upstream_integration_remote");
commit_initial_file(&repo.path);
@@ -4947,7 +4989,9 @@ mod tests {
["remote", "add", "origin", remote.path.to_str().unwrap()],
);
let status = push(repo.path.to_string_lossy().to_string(), None, None).unwrap();
let status = push(repo.path.to_string_lossy().to_string(), None, None)
.await
.unwrap();
assert_eq!(
status.upstream.as_deref(),
@@ -5015,8 +5059,8 @@ mod tests {
assert_eq!(contents.replace("\r\n", "\n"), "resolved\n");
}
#[test]
fn commit_rejects_unresolved_merge_conflicts() {
#[tokio::test]
async fn commit_rejects_unresolved_merge_conflicts() {
let repo = init_temp_repo("commit_rejects_conflicts");
fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written");
run_git_test(&repo.path, ["add", "file.txt"]);
@@ -5042,6 +5086,7 @@ mod tests {
repo.path.to_string_lossy().to_string(),
"should not commit".to_string(),
)
.await
.unwrap_err();
assert!(err.contains("Merge conflicts"));
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty",
"version": "0.200.5",
"version": "2026.7.17",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run dev",
+134 -26
View File
@@ -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) {
@@ -938,6 +989,10 @@
return repoKey(left) === repoKey(right);
}
function baseName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function uniqueRepoPaths(paths: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
@@ -1939,6 +1994,21 @@
if (!activeStillOpen) await openRepo(path);
}
async function removeRepoFromRecent(path: string, event?: MouseEvent) {
event?.stopPropagation();
if (isBusy) return;
recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path));
persistRepoLists();
trackEvent("repository_removed_from_recent", {
recent_repositories: recentRepoPaths.length,
});
if (!repoTabs.some((tab) => sameRepoPath(tab.path, path)) && !isFavoriteRepo(path) && repoStatusCache[repoKey(path)]) {
const { [repoKey(path)]: _removed, ...rest } = repoStatusCache;
repoStatusCache = rest;
persistRepoStatusCache();
}
}
async function removeRepoFromManagement(path: string, event?: MouseEvent) {
event?.stopPropagation();
if (isBusy) return;
@@ -2539,7 +2609,7 @@
const targets = files.filter((file) => file.unstaged !== null);
if (targets.length === 0) return;
const paths = targets.map((file) => file.path);
await runOperation(targets.length === 1 ? `Staging ${targets[0].path}` : `Staging ${targets.length} files`, async () => {
await runOperation(targets.length === 1 ? `Staging ${baseName(targets[0].path)}` : `Staging ${targets.length} files`, async () => {
applyStatus(await stageFiles(activeRepoPath, paths));
await refreshExplorerFiles(activeRepoPath);
trackEvent("file_staged", {
@@ -2553,7 +2623,7 @@
const targets = files.filter((file) => file.staged !== null);
if (targets.length === 0) return;
const paths = targets.map((file) => file.path);
await runOperation(targets.length === 1 ? `Unstaging ${targets[0].path}` : `Unstaging ${targets.length} files`, async () => {
await runOperation(targets.length === 1 ? `Unstaging ${baseName(targets[0].path)}` : `Unstaging ${targets.length} files`, async () => {
applyStatus(await unstageFiles(activeRepoPath, paths));
await refreshExplorerFiles(activeRepoPath);
trackEvent("file_unstaged", {
@@ -2563,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;
@@ -2718,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);
}
@@ -3357,7 +3462,7 @@
</section>
{/if}
{#if operation && operation !== "Opening repository"}
{#if operation && operation !== "Opening repository" && !hasRepository}
<section class="notice busy" aria-live="polite">
<LoaderCircle class="spin" size={17} aria-hidden="true" />
<span>{operation}</span>
@@ -3516,7 +3621,7 @@
>
<Star size={14} aria-hidden="true" />
</button>
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}>
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromRecent(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}>
<X size={14} aria-hidden="true" />
</button>
</div>
@@ -3719,12 +3824,14 @@
{unstagedCount}
{hasRepository}
{isBusy}
{operation}
{status}
selectedFilePath={selectedExplorerPath}
onSelectFile={selectFileFromStatus}
onStage={stageFile}
onUnstage={unstageFile}
onDiscard={discardFile}
onDiscard={discardFiles}
onDiscardMany={discardChanges}
onPatch={openLinePatch}
onStageAll={stageAllFiles}
onUnstageAll={unstageAllFiles}
@@ -3869,6 +3976,7 @@
{#if appSettingsOpen}
<AppSettingsDialog
analytics={analyticsSettings}
theme={appTheme}
onSave={saveAppSettings}
onClose={() => { appSettingsOpen = false; }}
/>
@@ -3901,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}
+598 -92
View File
@@ -1,54 +1,112 @@
@import "tailwindcss";
@theme {
--color-ink: #f5f7ff;
--color-ink-muted: #a8b1d8;
--color-ink-faint: #687197;
--color-ink-dim: #838cb8;
--color-ink-quiet: #949bc8;
--color-ink: #edf3ff;
--color-ink-muted: #b1bad0;
--color-ink-faint: #6f7c96;
--color-ink-dim: #8895ad;
--color-ink-quiet: #98a4bb;
--color-surface: rgba(22, 22, 36, 0.82);
--color-surface-alt: #0b0b14;
--color-surface-dim: rgba(18, 18, 30, 0.9);
--color-surface-hover: rgba(47, 48, 78, 0.76);
--color-surface-raised: rgba(28, 29, 48, 0.88);
--color-surface-solid: #1c1d30;
--color-surface: rgba(18, 23, 34, 0.88);
--color-surface-alt: #090d14;
--color-surface-dim: rgba(15, 20, 30, 0.94);
--color-surface-hover: rgba(36, 45, 64, 0.78);
--color-surface-raised: rgba(23, 29, 43, 0.92);
--color-surface-solid: #171d2b;
--color-border: rgba(100, 108, 255, 0.28);
--color-border-subtle: rgba(255, 255, 255, 0.08);
--color-border-input: rgba(65, 209, 255, 0.26);
--color-border: rgba(90, 111, 154, 0.3);
--color-border-subtle: rgba(226, 232, 240, 0.08);
--color-border-input: rgba(77, 182, 214, 0.26);
--color-primary: #646cff;
--color-primary-dark: #535bf2;
--color-accent: #41d1ff;
--color-primary: #6f8cff;
--color-primary-dark: #5d74e8;
--color-accent: #4db6d6;
--color-bar: rgba(9, 9, 18, 0.92);
--color-bar-text: #f4f6ff;
--color-bar-muted: #7b84b2;
--color-bar: rgba(7, 10, 16, 0.94);
--color-bar-text: #f1f5ff;
--color-bar-muted: #7f8ba3;
--font-mono: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
}
:root {
--app-color-scheme: dark;
--app-bg: linear-gradient(135deg, #070a10 0%, #0c111a 48%, #090e16 100%);
--app-button-bg: linear-gradient(180deg, rgba(36, 45, 64, 0.92), rgba(22, 28, 42, 0.94));
--app-input-bg: rgba(10, 14, 22, 0.82);
--app-select-option-bg: #171d2b;
--app-panel-highlight: linear-gradient(180deg, rgba(255,255,255,0.038), transparent 70%);
--app-panel-shadow: 0 18px 48px rgba(0,0,0,0.28), inset 0 1px 0 rgba(255,255,255,0.045);
--app-dialog-backdrop: #070a10;
--app-dialog-bg: #101622;
--app-dialog-chrome: #141b29;
--app-dialog-shadow: 0 24px 68px rgba(0, 0, 0, 0.54), 0 2px 12px rgba(0,0,0,0.32);
--app-settings-row-bg: #141b29;
--app-scrollbar-thumb: #303a4f;
--app-scrollbar-thumb-hover: #44506a;
}
:root[data-theme="light"] {
--color-ink: #172033;
--color-ink-muted: #475569;
--color-ink-faint: #728098;
--color-ink-dim: #5f6f89;
--color-ink-quiet: #66758d;
--color-surface: rgba(255, 255, 255, 0.86);
--color-surface-alt: #f3f6fb;
--color-surface-dim: rgba(244, 247, 252, 0.92);
--color-surface-hover: rgba(226, 233, 245, 0.82);
--color-surface-raised: rgba(255, 255, 255, 0.94);
--color-surface-solid: #ffffff;
--color-border: rgba(61, 89, 142, 0.24);
--color-border-subtle: rgba(34, 49, 78, 0.11);
--color-border-input: rgba(46, 117, 182, 0.28);
--color-primary: #315fd6;
--color-primary-dark: #284cb4;
--color-accent: #0f8fb5;
--color-bar: rgba(248, 251, 255, 0.94);
--color-bar-text: #172033;
--color-bar-muted: #64728a;
--app-color-scheme: light;
--app-bg:
radial-gradient(circle at 12% 0%, rgba(49, 95, 214, 0.12), transparent 30%),
radial-gradient(circle at 82% 4%, rgba(15, 143, 181, 0.12), transparent 28%),
linear-gradient(135deg, #f7f9fd 0%, #eef3f9 48%, #f8fafc 100%);
--app-button-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(235, 240, 249, 0.95));
--app-input-bg: rgba(255, 255, 255, 0.9);
--app-select-option-bg: #ffffff;
--app-panel-highlight: linear-gradient(180deg, rgba(255,255,255,0.72), transparent 70%);
--app-panel-shadow: 0 18px 48px rgba(28, 44, 74, 0.12), inset 0 1px 0 rgba(255,255,255,0.86);
--app-dialog-backdrop: rgba(232, 238, 248, 0.96);
--app-dialog-bg: #ffffff;
--app-dialog-chrome: #f4f7fc;
--app-dialog-shadow: 0 24px 72px rgba(28, 44, 74, 0.2), 0 2px 12px rgba(28,44,74,0.1);
--app-settings-row-bg: #f8fafd;
--app-scrollbar-thumb: #c2cada;
--app-scrollbar-thumb-hover: #aeb8cb;
}
@layer base {
*, *::before, *::after { box-sizing: border-box; }
html, body, #app { width: 100%; height: 100%; margin: 0; }
* { scrollbar-width: thin; scrollbar-color: #2a2f45 transparent; }
* { scrollbar-width: thin; scrollbar-color: var(--app-scrollbar-thumb) transparent; }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #2a2f45; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #3a3f58; }
::-webkit-scrollbar-thumb { background: var(--app-scrollbar-thumb); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--app-scrollbar-thumb-hover); }
html { color-scheme: dark; }
html { color-scheme: var(--app-color-scheme); }
body {
overflow: hidden;
color: var(--color-ink);
background:
radial-gradient(circle at 22% 8%, rgba(189, 52, 254, 0.28), transparent 34%),
radial-gradient(circle at 74% 10%, rgba(65, 209, 255, 0.22), transparent 30%),
radial-gradient(circle at 88% 78%, rgba(255, 211, 67, 0.1), transparent 26%),
linear-gradient(135deg, #090912 0%, #111122 45%, #0b0b14 100%);
background: var(--app-bg);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -68,7 +126,7 @@
border: 1px solid var(--color-border);
border-radius: 8px;
color: var(--color-ink-muted);
background: linear-gradient(180deg, rgba(42, 43, 70, 0.9), rgba(27, 28, 48, 0.92));
background: var(--app-button-bg);
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
font-size: 13px;
@@ -85,7 +143,7 @@
border: 1px solid var(--color-border-input);
border-radius: 8px;
color: var(--color-ink);
background: rgba(13, 14, 26, 0.78);
background: var(--app-input-bg);
outline: none;
}
input:focus, textarea:focus, select:focus {
@@ -117,7 +175,7 @@
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(90, 140, 248, 0.18);
}
select option { background: #1c2033; color: var(--color-ink); }
select option { background: var(--app-select-option-bg); color: var(--color-ink); }
}
@layer components {
@@ -126,28 +184,28 @@
.btn-sm { min-height: 26px; padding: 0 8px; font-size: 12px; }
.btn-primary {
border-color: rgba(100, 108, 255, 0.75);
border-color: rgba(111, 140, 255, 0.72);
color: #ffffff;
background: linear-gradient(135deg, #646cff 0%, #bd34fe 100%);
box-shadow: 0 0 24px rgba(100, 108, 255, 0.2);
background: linear-gradient(135deg, #5f7df2 0%, #238eb4 100%);
box-shadow: 0 10px 24px rgba(77, 182, 214, 0.16);
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
border-color: rgba(65, 209, 255, 0.82);
background: linear-gradient(135deg, #747bff 0%, #c966ff 100%);
border-color: rgba(77, 182, 214, 0.78);
background: linear-gradient(135deg, #6f8cff 0%, #2da0c7 100%);
color: #ffffff;
}
.btn-secondary {
border-color: rgba(65, 209, 255, 0.32);
color: #b9e9ff;
background: linear-gradient(180deg, rgba(65, 209, 255, 0.12), rgba(100, 108, 255, 0.1));
border-color: rgba(77, 182, 214, 0.3);
color: #b8e7f6;
background: linear-gradient(180deg, rgba(77, 182, 214, 0.1), rgba(111, 140, 255, 0.07));
font-weight: 700;
}
.btn-secondary:hover:not(:disabled) {
border-color: rgba(65, 209, 255, 0.64);
border-color: rgba(77, 182, 214, 0.58);
color: #ffffff;
background: linear-gradient(180deg, rgba(65, 209, 255, 0.18), rgba(100, 108, 255, 0.16));
background: linear-gradient(180deg, rgba(77, 182, 214, 0.16), rgba(111, 140, 255, 0.12));
}
.btn-danger {
@@ -169,9 +227,9 @@
border: 1px solid var(--color-border);
border-radius: 12px;
background:
linear-gradient(180deg, rgba(255,255,255,0.045), transparent 72%),
var(--app-panel-highlight),
var(--color-surface);
box-shadow: 0 18px 60px rgba(0,0,0,0.24), inset 0 1px 0 rgba(255,255,255,0.05);
box-shadow: var(--app-panel-shadow);
backdrop-filter: blur(16px);
}
@@ -183,7 +241,7 @@
min-height: 46px;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border-subtle);
background: linear-gradient(90deg, rgba(100,108,255,0.09), rgba(65,209,255,0.025));
background: linear-gradient(90deg, rgba(111,140,255,0.07), rgba(77,182,214,0.025));
}
.section-head h2 { margin: 1px 0 0; color: var(--color-ink); font-size: 14px; line-height: 1.2; font-weight: 600; }
@@ -466,7 +524,7 @@
align-items: center;
height: var(--app-titlebar-height);
background:
linear-gradient(90deg, rgba(100,108,255,0.16), rgba(65,209,255,0.08), rgba(255,211,67,0.035)),
linear-gradient(90deg, rgba(111,140,255,0.08), rgba(77,182,214,0.045), rgba(255,255,255,0.012)),
var(--color-bar);
color: var(--color-bar-text);
user-select: none;
@@ -518,9 +576,9 @@
height: 100%;
overflow: hidden;
}
.titlebar-info svg { color: #41d1ff; flex-shrink: 0; }
.titlebar-info svg { color: var(--color-accent); flex-shrink: 0; }
.tb-repo { color: #8e95c9; font-size: 12px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px; }
.tb-repo { color: var(--color-bar-muted); font-size: 12px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px; }
.tb-sep { color: rgba(255,255,255,0.22); font-size: 13px; }
.tb-branch { color: #f5f7ff; font-size: 12px; font-weight: 700; font-family: var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
@@ -528,7 +586,7 @@
.tb-sync.ahead { color: #e0a040; background: rgba(224,160,64,0.13); }
.tb-sync.behind { color: #7aacff; background: rgba(122,172,255,0.13); }
.tb-no-repo { color: #7b84b2; font-size: 12px; font-style: italic; }
.tb-no-repo { color: var(--color-bar-muted); font-size: 12px; font-style: italic; }
.titlebar-right { display: flex; align-items: stretch; height: 100%; border-left: 1px solid rgba(255,255,255,0.06); }
.titlebar-actions { display: flex; align-items: stretch; }
@@ -549,7 +607,7 @@
transition: background 120ms ease, color 120ms ease;
-webkit-app-region: no-drag;
}
.tb-action:hover:not(:disabled) { background: rgba(255,255,255,0.07); border-color: transparent; color: #c0cce0; }
.tb-action:hover:not(:disabled) { background: rgba(255,255,255,0.06); border-color: transparent; color: #d6def0; }
.tb-action:disabled { opacity: 0.3; cursor: not-allowed; }
.tb-action.auto-toggle.active { color: #4eca76; }
.tb-action.auto-toggle.active:hover:not(:disabled) { color: #6ee090; background: rgba(78,202,118,0.1); }
@@ -570,22 +628,22 @@
border: none;
border-radius: 0;
background: transparent;
color: #3a4a5c;
color: #536078;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
-webkit-app-region: no-drag;
}
.tb-btn:hover:not(:disabled) { background: rgba(255,255,255,0.09); border-color: transparent; color: #c0cce0; }
.tb-btn:hover:not(:disabled) { background: rgba(255,255,255,0.08); border-color: transparent; color: #d6def0; }
.tb-btn.close:hover:not(:disabled) { background: #c0392b; color: #ffffff; }
/* --- Top bar (repo form) --- */
.topbar {
padding: 9px 10px;
border: 1px solid rgba(100,108,255,0.28);
border: 1px solid var(--color-border);
border-radius: 12px;
background:
linear-gradient(90deg, rgba(189,52,254,0.1), rgba(65,209,255,0.075), rgba(255,211,67,0.035)),
linear-gradient(90deg, rgba(111,140,255,0.055), rgba(77,182,214,0.045), rgba(255,255,255,0.012)),
var(--color-surface);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
backdrop-filter: blur(16px);
@@ -1049,7 +1107,7 @@
inset: 0 auto 0 0;
min-width: 0;
border-radius: inherit;
background: linear-gradient(90deg, #41d1ff, #646cff, #bd34fe);
background: linear-gradient(90deg, #4db6d6, #6f8cff, #238eb4);
transition: width 160ms ease;
}
.update-progress.indeterminate span {
@@ -1077,14 +1135,14 @@
font-weight: 800;
}
.update-toast-primary {
border-color: rgba(100,108,255,0.72);
border-color: rgba(111,140,255,0.72);
color: #ffffff;
background: linear-gradient(135deg, #646cff, #bd34fe);
background: linear-gradient(135deg, #5f7df2, #238eb4);
}
.update-toast-primary:hover:not(:disabled) {
border-color: rgba(65,209,255,0.74);
border-color: rgba(77,182,214,0.74);
color: #ffffff;
background: linear-gradient(135deg, #747bff, #c966ff);
background: linear-gradient(135deg, #6f8cff, #2da0c7);
}
.update-toast-secondary {
border-color: rgba(255,255,255,0.1);
@@ -2308,7 +2366,7 @@
padding: 22px;
overflow: hidden;
background:
radial-gradient(circle at center 42%, rgba(151, 118, 255, 0.12), transparent 52%),
radial-gradient(circle at center 42%, rgba(111, 140, 255, 0.1), transparent 52%),
linear-gradient(180deg, rgba(15, 18, 31, 0.55), transparent);
}
@@ -2324,7 +2382,7 @@
.file-history-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: 18px;
transform: rotate(45deg);
animation: fhl-halo-breathe 2.4s ease-in-out infinite;
@@ -2344,14 +2402,14 @@
stroke-linecap: round;
stroke-dasharray: 92;
stroke-dashoffset: 92;
filter: drop-shadow(0 0 6px rgba(151, 118, 255, 0.5));
filter: drop-shadow(0 0 6px rgba(77, 182, 214, 0.3));
animation: fhl-trace-draw 2.4s ease-in-out infinite;
}
.file-history-loading-traces .fhl-trace-main {
stroke: #9b70ff;
stroke: #6f8cff;
}
.file-history-loading-traces .fhl-trace-branch {
stroke: #ff6d26;
stroke: #4db6d6;
animation-delay: 0.28s;
}
@@ -2363,7 +2421,7 @@
object-fit: contain;
filter:
drop-shadow(0 10px 14px rgba(0, 0, 0, 0.45))
drop-shadow(0 0 12px rgba(151, 118, 255, 0.28));
drop-shadow(0 0 12px rgba(77, 182, 214, 0.2));
animation: fhl-icon-float 2.4s ease-in-out infinite;
}
@@ -2379,15 +2437,15 @@
height: 3px;
overflow: hidden;
border-radius: 999px;
background: rgba(151, 118, 255, 0.14);
background: rgba(111, 140, 255, 0.14);
}
.file-history-loading-bar span {
position: absolute;
inset: 0;
width: 46%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, #9b70ff 42%, #ff6d26 74%, transparent);
box-shadow: 0 0 12px rgba(255, 109, 38, 0.26);
background: linear-gradient(90deg, transparent, #6f8cff 42%, #4db6d6 74%, transparent);
box-shadow: 0 0 12px rgba(77, 182, 214, 0.24);
animation: fhl-bar-slide 1.35s ease-in-out infinite;
}
@@ -2441,8 +2499,10 @@
position: relative;
align-self: stretch;
min-width: 42px;
border-right: 1px solid rgba(94,110,156,0.12);
background: rgba(7,8,16,0.2);
border-right: 1px solid rgba(226,232,240,0.08);
background:
linear-gradient(90deg, rgba(111,140,255,0.025), transparent),
rgba(7,10,16,0.24);
}
.graph-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.graph-svg path {
@@ -2472,8 +2532,8 @@
height: 12px;
border-radius: 999px;
background: var(--dot-color, #5a8cf8);
border: 2px solid #111321;
box-shadow: 0 0 0 1px rgba(255,255,255,0.07);
border: 2px solid var(--app-dialog-bg);
box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 2px 8px rgba(0,0,0,0.22);
transform: translate(-50%, -50%);
transition: transform 120ms ease, box-shadow 120ms ease;
}
@@ -2495,7 +2555,7 @@
height: 15px;
background: var(--color-surface-alt);
border-color: var(--dot-color, #5a8cf8);
box-shadow: 0 0 0 1px rgba(255,255,255,0.08);
box-shadow: 0 0 0 1px rgba(255,255,255,0.1);
}
.graph-hover-branches {
position: absolute;
@@ -2525,8 +2585,8 @@
border: 1px solid rgba(91,209,138,0.28);
border-radius: 999px;
color: #b2f0c2;
background: rgba(20,35,29,0.94);
box-shadow: 0 8px 22px rgba(0,0,0,0.3);
background: rgba(14,36,27,0.94);
box-shadow: 0 8px 22px rgba(0,0,0,0.24);
font-family: var(--font-mono);
font-size: 9.5px;
font-weight: 750;
@@ -2559,28 +2619,28 @@
gap: 5px;
min-width: 0;
padding: 8px 10px;
background: rgba(28,29,48,0.34);
background: rgba(18,24,36,0.5);
transition: background 120ms ease;
}
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(94,110,156,0.1); }
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(226,232,240,0.075); }
.graph-row.graph-ahead-row .commit-body {
box-shadow: inset 3px 0 0 rgba(224,160,64,0.72);
}
.graph-row.graph-behind-row .commit-body {
box-shadow: inset 3px 0 0 rgba(122,172,255,0.72);
}
.graph-row:hover .commit-body { background: rgba(37,40,62,0.54); }
.graph-row:hover .commit-body { background: rgba(30,39,57,0.72); }
.graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; }
.graph-row:hover .graph-svg path.hidden-branch { opacity: 0.12; stroke-width: 2.2; }
.graph-row:hover .graph-dot { transform: translate(-50%, -50%) scale(1.12); }
.graph-row:hover .graph-dot.hidden-branch { transform: translate(-50%, -50%) scale(1); }
.graph-row.merge-row .commit-body {
background: rgba(36,31,54,0.46);
background: rgba(26,27,43,0.56);
}
.graph-row.tip-row .commit-body {
background:
linear-gradient(90deg, rgba(105,167,255,0.055), transparent 32%),
rgba(28,29,48,0.38);
linear-gradient(90deg, rgba(111,140,255,0.065), transparent 34%),
rgba(20,27,40,0.62);
}
/* --- Compare panel --- */
@@ -2630,7 +2690,7 @@
display: grid;
place-items: center;
padding: 24px;
background-color: #050712;
background-color: var(--app-dialog-backdrop);
background-image: none;
}
@@ -2645,9 +2705,9 @@
height: min(760px, 100%);
border: 1px solid var(--color-border);
border-radius: 12px;
background-color: #111321;
background-color: var(--app-dialog-bg);
background-image: none;
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.6), 0 2px 12px rgba(0,0,0,0.4);
box-shadow: var(--app-dialog-shadow);
overflow: hidden;
}
.compare-dialog {
@@ -2795,6 +2855,56 @@
font-size: 15px;
font-weight: 750;
}
.settings-segmented {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
min-width: 0;
padding: 4px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: var(--app-settings-row-bg);
}
.settings-segmented label {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 32px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 6px;
color: var(--color-ink-dim);
cursor: pointer;
font-size: 12px;
font-weight: 800;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
}
.settings-segmented label:hover {
border-color: var(--color-border-subtle);
color: var(--color-ink);
background: var(--color-surface-hover);
}
.settings-segmented label.active {
border-color: var(--color-border-input);
color: var(--color-ink);
background: var(--color-surface-raised);
box-shadow: 0 1px 4px rgba(28,44,74,0.08);
}
.settings-segmented input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.settings-segmented span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-toggle-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
@@ -2804,7 +2914,7 @@
padding: 12px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: #151827;
background: var(--app-settings-row-bg);
color: var(--color-ink-muted);
cursor: pointer;
}
@@ -2970,7 +3080,7 @@
gap: 8px;
}
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: #171a2b; }
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.dialog-header > div:first-child { min-width: 0; }
.dialog-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex: 0 0 auto; min-width: 0; }
.compare-restore { max-width: 170px; min-width: 0; }
@@ -2984,7 +3094,7 @@
.dialog-body { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 0; }
.compare-dialog .dialog-body { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); }
.dialog-files { display: grid; align-content: start; gap: 4px; padding: 8px; overflow: auto; border-right: 1px solid var(--color-border-subtle); background: #171a2b; }
.dialog-files { display: grid; align-content: start; gap: 4px; padding: 8px; overflow: auto; border-right: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.dialog-file-row {
display: grid;
@@ -3132,7 +3242,7 @@
letter-spacing: 0;
}
.dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-border-subtle); background: #171a2b; }
.dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.dialog-footer-info { color: var(--color-ink-dim); font-size: 13px; font-weight: 700; }
.prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; }
@@ -3188,6 +3298,23 @@
align-items: center;
gap: 6px;
}
.discard-target-list {
display: grid;
gap: 4px;
max-height: 168px;
overflow: auto;
margin: 0;
padding: 0;
list-style: none;
}
.discard-target-list .discard-target {
max-height: none;
}
.discard-target-more {
padding: 2px 2px 0;
color: var(--color-ink-muted);
font-size: 12px;
}
.discard-warning-text {
color: #ffb8bf;
font-weight: 650;
@@ -4161,9 +4288,9 @@
gap: 6px;
min-height: 36px;
padding: 0 16px;
border: 1px solid rgba(100,108,255,0.78);
border: 1px solid rgba(111,140,255,0.72);
border-radius: 7px;
background: linear-gradient(135deg, #646cff, #bd34fe);
background: linear-gradient(135deg, #5f7df2, #238eb4);
color: #ffffff;
font-size: 13px;
font-weight: 700;
@@ -4171,8 +4298,8 @@
transition: background 0.12s, border-color 0.12s;
}
.cred-submit:hover:not(:disabled) {
background: linear-gradient(135deg, #747bff, #c966ff);
border-color: rgba(65,209,255,0.7);
background: linear-gradient(135deg, #6f8cff, #2da0c7);
border-color: rgba(77,182,214,0.7);
}
.cred-submit:disabled { opacity: 0.45; cursor: not-allowed; }
@@ -4371,6 +4498,381 @@
.resolve-path { overflow: hidden; color: var(--color-ink-faint); font-family: var(--font-mono); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
}
:root[data-theme="light"] button:hover:not(:disabled) {
background: var(--color-surface-hover);
}
:root[data-theme="light"] input:focus,
:root[data-theme="light"] textarea:focus,
:root[data-theme="light"] select:focus {
box-shadow: 0 0 0 3px rgba(49, 95, 214, 0.14), 0 10px 24px rgba(28, 44, 74, 0.06);
}
:root[data-theme="light"] .btn-primary {
border-color: rgba(49, 95, 214, 0.72);
background: linear-gradient(135deg, #315fd6 0%, #0f8fb5 100%);
box-shadow: 0 10px 24px rgba(49, 95, 214, 0.18);
}
:root[data-theme="light"] .btn-primary:hover:not(:disabled) {
border-color: rgba(15, 143, 181, 0.72);
background: linear-gradient(135deg, #3d6ee4 0%, #139dc7 100%);
}
:root[data-theme="light"] .btn-secondary {
border-color: rgba(15, 143, 181, 0.28);
color: #0c6683;
background: linear-gradient(180deg, rgba(15, 143, 181, 0.08), rgba(49, 95, 214, 0.06));
}
:root[data-theme="light"] .btn-secondary:hover:not(:disabled) {
color: #0d4f9e;
background: linear-gradient(180deg, rgba(15, 143, 181, 0.13), rgba(49, 95, 214, 0.1));
}
:root[data-theme="light"] .titlebar {
background:
linear-gradient(90deg, rgba(49, 95, 214, 0.08), rgba(15, 143, 181, 0.05), rgba(214, 156, 49, 0.04)),
var(--color-bar);
}
:root[data-theme="light"] .titlebar-brand,
:root[data-theme="light"] .titlebar-right {
border-color: rgba(34,49,78,0.1);
}
:root[data-theme="light"] .titlebar-brand-icon img {
filter: drop-shadow(0 1px 2px rgba(28,44,74,0.18));
}
:root[data-theme="light"] .tb-version {
color: rgba(23,32,51,0.36);
}
:root[data-theme="light"] .titlebar-info svg {
color: var(--color-accent);
}
:root[data-theme="light"] .tb-repo,
:root[data-theme="light"] .tb-no-repo {
color: var(--color-bar-muted);
}
:root[data-theme="light"] .tb-sep {
color: rgba(23,32,51,0.28);
}
:root[data-theme="light"] .tb-branch {
color: var(--color-bar-text);
}
:root[data-theme="light"] .tb-action:hover:not(:disabled),
:root[data-theme="light"] .tb-btn:hover:not(:disabled) {
background: rgba(49,95,214,0.08);
color: var(--color-ink);
}
:root[data-theme="light"] .titlebar-divider {
background: rgba(34,49,78,0.1);
}
:root[data-theme="light"] .tb-btn {
color: var(--color-bar-muted);
}
:root[data-theme="light"] .topbar {
border-color: var(--color-border);
background:
linear-gradient(90deg, rgba(49,95,214,0.06), rgba(15,143,181,0.045), rgba(214,156,49,0.035)),
var(--color-surface);
}
:root[data-theme="light"] .repo-tabbar,
:root[data-theme="light"] .repo-management-tools {
background: rgba(255,255,255,0.78);
}
:root[data-theme="light"] .repo-tab-wrap {
background: rgba(244,247,252,0.72);
}
:root[data-theme="light"] .repo-tab-wrap.active {
background: rgba(49,95,214,0.1);
}
:root[data-theme="light"] .repo-row {
border-bottom-color: rgba(34,49,78,0.08);
}
:root[data-theme="light"] .repo-row-main:hover:not(:disabled) {
background: rgba(49,95,214,0.07);
}
:root[data-theme="light"] .repo-row-icon {
border-left-color: rgba(34,49,78,0.08);
}
:root[data-theme="light"] .notice.error,
:root[data-theme="light"] .cred-error {
color: #b33445;
background: rgba(210, 55, 75, 0.08);
}
:root[data-theme="light"] .notice.busy {
color: #315fd6;
}
:root[data-theme="light"] .notice.conflict,
:root[data-theme="light"] .notice-action.conflict,
:root[data-theme="light"] .resolve-status,
:root[data-theme="light"] .resolve-conflict-label {
color: #96620f;
}
:root[data-theme="light"] .notice.rebase,
:root[data-theme="light"] .notice-action.rebase {
color: #6f3ca8;
}
:root[data-theme="light"] .update-toast {
box-shadow: 0 22px 56px rgba(28,44,74,0.18), 0 0 0 1px rgba(255,255,255,0.75) inset;
}
:root[data-theme="light"] .branch-filter-backdrop {
background: rgba(232,238,248,0.96);
}
:root[data-theme="light"] .branch-filter-dialog {
background:
linear-gradient(180deg, #ffffff 0%, #f4f7fc 72%),
#f4f7fc;
box-shadow: var(--app-dialog-shadow);
}
:root[data-theme="light"] .graph-branch-dialog-button,
:root[data-theme="light"] .branch-filter-option {
color: #16723b;
}
:root[data-theme="light"] .graph-branch-dialog-button span {
color: #ffffff;
background: #22834a;
}
:root[data-theme="light"] .main-panel {
background: var(--color-surface);
box-shadow: var(--app-panel-shadow);
}
:root[data-theme="light"] .repo-management {
background: var(--color-surface);
box-shadow: var(--app-panel-shadow);
}
:root[data-theme="light"] .repo-section {
background: rgba(255,255,255,0.62);
}
:root[data-theme="light"] .repo-section > header {
background: rgba(244,247,252,0.88);
}
:root[data-theme="light"] .repo-empty {
background: rgba(255,255,255,0.48);
}
:root[data-theme="light"] .repo-summary,
:root[data-theme="light"] .lane-header,
:root[data-theme="light"] .stash-toolbar,
:root[data-theme="light"] .dialog-footer,
:root[data-theme="light"] .cred-header {
background: var(--color-surface-dim);
}
:root[data-theme="light"] .file-row.selected,
:root[data-theme="light"] .branch-row.current,
:root[data-theme="light"] .file-history-row.active {
background: rgba(49,95,214,0.08);
}
:root[data-theme="light"] .graph-list {
background: rgba(248,250,253,0.9);
}
:root[data-theme="light"] .graph-gutter {
border-right-color: rgba(34,49,78,0.1);
background:
linear-gradient(90deg, rgba(49,95,214,0.035), rgba(255,255,255,0.18)),
rgba(238,243,249,0.78);
}
:root[data-theme="light"] .graph-dot {
border-color: #ffffff;
box-shadow: 0 0 0 1px rgba(34,49,78,0.16), 0 2px 6px rgba(28,44,74,0.12);
}
:root[data-theme="light"] .graph-dot.merge {
background: #ffffff;
box-shadow: 0 0 0 1px rgba(34,49,78,0.14);
}
:root[data-theme="light"] .commit-body {
background: rgba(255,255,255,0.72);
}
:root[data-theme="light"] .graph-row + .graph-row .commit-body {
border-top-color: rgba(34,49,78,0.08);
}
:root[data-theme="light"] .graph-row:hover .commit-body {
background: rgba(235,241,250,0.9);
}
:root[data-theme="light"] .graph-row.merge-row .commit-body {
background: rgba(248,244,252,0.82);
}
:root[data-theme="light"] .graph-row.tip-row .commit-body {
background:
linear-gradient(90deg, rgba(49,95,214,0.075), transparent 34%),
rgba(255,255,255,0.82);
}
:root[data-theme="light"] .commit-summary {
color: var(--color-ink);
}
:root[data-theme="light"] .commit-avatar {
border-color: rgba(49,95,214,0.18);
color: #315fd6;
background: rgba(232,238,248,0.92);
}
:root[data-theme="light"] .commit-kind {
background: rgba(244,247,252,0.82);
}
:root[data-theme="light"] .commit-hash {
color: #315fd6;
}
:root[data-theme="light"] .commit-branch-chip,
:root[data-theme="light"] .graph-hover-branches span {
border-color: rgba(31,128,76,0.22);
color: #146b3b;
background: rgba(224,246,233,0.92);
box-shadow: 0 8px 22px rgba(28,44,74,0.12);
}
:root[data-theme="light"] .graph-hover-branches span.remote,
:root[data-theme="light"] .branch-filter-option.remote {
border-color: rgba(49,95,214,0.22);
color: #315fd6;
background: rgba(231,237,255,0.92);
}
:root[data-theme="light"] .graph-hover-branches span.ahead {
border-color: rgba(150,98,15,0.28);
color: #96620f;
background: rgba(255,244,224,0.95);
}
:root[data-theme="light"] .graph-hover-branches span.behind {
border-color: rgba(49,95,214,0.26);
color: #315fd6;
background: rgba(231,237,255,0.95);
}
:root[data-theme="light"] .ref-list .ref-chip.head {
color: #ffffff;
background: linear-gradient(135deg, #0f8fb5, #315fd6);
}
:root[data-theme="light"] .ref-list .ref-chip.branch {
color: #16723b;
background: rgba(78,202,118,0.12);
}
:root[data-theme="light"] .ref-list .ref-chip.remote {
color: #315fd6;
background: rgba(49,95,214,0.09);
}
:root[data-theme="light"] .commit-files,
:root[data-theme="light"] .commit-file-button {
background: rgba(248,250,253,0.78);
}
:root[data-theme="light"] .file-history-rail-toggle {
background: linear-gradient(180deg, rgba(49,95,214,0.08), rgba(15,143,181,0.04));
}
:root[data-theme="light"] .file-history-rail-toggle:hover:not(:disabled) {
background: linear-gradient(180deg, rgba(49,95,214,0.12), rgba(15,143,181,0.08));
}
:root[data-theme="light"] .file-history-loading {
background:
radial-gradient(circle at center 42%, rgba(49,95,214,0.1), transparent 52%),
linear-gradient(180deg, rgba(244,247,252,0.88), transparent);
}
:root[data-theme="light"] .file-history-loading-icon {
filter:
drop-shadow(0 10px 14px rgba(28,44,74,0.18))
drop-shadow(0 0 12px rgba(49,95,214,0.18));
}
:root[data-theme="light"] .branch-context-menu {
box-shadow: 0 18px 50px rgba(28,44,74,0.18);
}
:root[data-theme="light"] .dialog-file-row:hover,
:root[data-theme="light"] .branch-row:hover,
:root[data-theme="light"] .explorer-row:hover,
:root[data-theme="light"] .commit-files-toggle:hover:not(:disabled) {
background: var(--color-surface-hover);
}
:root[data-theme="light"] .split-span.split-meta {
background: #e9eef7;
}
:root[data-theme="light"] .resolve-split,
:root[data-theme="light"] .resolve-num,
:root[data-theme="light"] .resolve-cell.empty,
:root[data-theme="light"] .resolve-num.empty {
background: rgba(234,239,248,0.72);
}
:root[data-theme="light"] .diff-line.meta,
:root[data-theme="light"] .resolve-split-marker {
color: #64728a;
}
:root[data-theme="light"] .cred-input input,
:root[data-theme="light"] .cred-expiry input[type="date"] {
background: rgba(255,255,255,0.92);
color-scheme: light;
}
:root[data-theme="light"] .cred-token-hint code {
color: #0d78a0;
}
:root[data-theme="light"] .status-panel-overlay {
background:
radial-gradient(circle at 50% 38%, rgba(49, 95, 214, 0.12), transparent 55%),
rgba(244, 247, 252, 0.68);
}
:root[data-theme="light"] .status-panel-overlay-card {
background:
linear-gradient(180deg, rgba(255,255,255,0.96), rgba(244,247,252,0.94)),
var(--color-surface-raised);
box-shadow: 0 18px 44px rgba(28,44,74,0.16), inset 0 1px 0 rgba(255,255,255,0.9);
}
/* Custom tooltip — appended to body, not clipped by overflow:hidden parents */
.path-tooltip {
position: fixed;
@@ -4459,6 +4961,10 @@
@media (max-width: 740px) {
body { overflow: auto; }
.shell-body { min-height: 100%; gap: 6px; }
.titlebar-info { padding: 0 8px; }
.titlebar-actions .tb-action:not(.auto-toggle):not(:last-child) { display: none; }
.tb-action { width: 36px; padding: 0 8px; }
.tb-btn { width: 34px; }
.workspace { grid-template-columns: 1fr; gap: 6px; }
.left-sidebar-resize-handle { display: none; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(200px, 1fr) minmax(150px, 0.5fr); min-height: 380px; }
+27 -5
View File
@@ -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 () => {
try {
win = getCurrentWindow();
isMaximized = await win.isMaximized();
unlisten = await win.onResized(async () => {
isMaximized = await win.isMaximized();
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>
@@ -200,12 +222,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 +242,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>
+32 -4
View File
@@ -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" />
+27 -10
View File
@@ -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>
+58 -19
View File
@@ -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 {
+219 -6
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png";
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
interface Props {
@@ -8,12 +9,14 @@
unstagedCount: number;
hasRepository: boolean;
isBusy: boolean;
operation: string;
status: GitStatus | null;
selectedFilePath: string;
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;
@@ -25,12 +28,14 @@
unstagedCount = 0,
hasRepository = false,
isBusy = false,
operation = "",
status = null,
selectedFilePath = "",
onSelectFile = () => {},
onStage = () => {},
onUnstage = () => {},
onDiscard = () => {},
onDiscardMany = () => {},
onPatch = () => {},
onStageAll = () => {},
onUnstageAll = () => {},
@@ -122,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);
@@ -136,7 +156,7 @@
});
</script>
<section class="panel grid grid-rows-[auto_auto_1fr] overflow-hidden" aria-label="Working tree status">
<section class="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>
@@ -170,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
@@ -192,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}
@@ -238,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>
@@ -264,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>
@@ -278,4 +318,177 @@
{/each}
</div>
{/if}
{#if isBusy && hasRepository}
<div class="status-panel-overlay" role="status" aria-live="polite">
<div class="status-panel-overlay-card">
<div class="status-panel-overlay-mark">
<span class="status-panel-overlay-halo halo-one"></span>
<span class="status-panel-overlay-halo halo-two"></span>
<svg class="status-panel-overlay-traces" viewBox="0 0 220 220" aria-hidden="true">
<path class="trace trace-main" d="M28 154 C72 114, 88 108, 110 110 S156 116, 192 68" />
<path class="trace trace-branch" d="M62 74 C100 88, 126 124, 158 166" />
<path class="trace trace-cut" d="M46 180 L174 180" />
</svg>
<img src={iconUrl} alt="" class="status-panel-overlay-icon" />
</div>
<span class="status-panel-overlay-label">{operation || "Working"}</span>
<div class="status-panel-overlay-bar"><span></span></div>
</div>
</div>
{/if}
</section>
<style>
.status-panel-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
background:
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;
}
.status-panel-overlay-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(300px, calc(100% - 32px));
padding: 26px 28px 26px;
border: 1px solid rgba(90, 111, 154, 0.28);
border-radius: 16px;
background:
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.34), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.status-panel-overlay-mark {
position: relative;
width: 104px;
height: 104px;
display: grid;
place-items: center;
isolation: isolate;
}
.status-panel-overlay-halo {
position: absolute;
inset: 6px;
border: 1px solid rgba(111, 140, 255, 0.22);
border-radius: 22px;
transform: rotate(45deg);
}
.status-panel-overlay-halo.halo-one {
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite;
}
.status-panel-overlay-halo.halo-two {
inset: 16px;
border-color: rgba(77, 182, 214, 0.24);
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite reverse;
}
.status-panel-overlay-traces {
position: absolute;
inset: -12px;
width: 128px;
height: 128px;
overflow: visible;
z-index: 0;
}
.status-panel-overlay-traces .trace {
fill: none;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 165;
stroke-dashoffset: 165;
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: #6f8cff; }
.status-panel-overlay-traces .trace-branch {
stroke: #4db6d6;
animation-delay: 0.28s;
}
.status-panel-overlay-traces .trace-cut {
stroke: rgba(177, 186, 208, 0.42);
stroke-dasharray: 128;
stroke-dashoffset: 128;
animation-delay: 0.55s;
}
.status-panel-overlay-icon {
position: relative;
z-index: 1;
width: 64px;
height: 64px;
object-fit: contain;
filter:
drop-shadow(0 8px 12px rgba(0, 0, 0, 0.5))
drop-shadow(0 0 10px rgba(77, 182, 214, 0.2));
animation: status-panel-overlay-icon-float 2.4s ease-in-out infinite;
}
.status-panel-overlay-label {
max-width: 100%;
overflow: hidden;
color: var(--color-ink);
font-size: 14px;
font-weight: 700;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-panel-overlay-bar {
position: relative;
width: min(190px, 100%);
height: 4px;
overflow: hidden;
border-radius: 999px;
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, #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;
}
@keyframes status-panel-overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes status-panel-overlay-icon-float {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-3px) scale(1.015); }
}
@keyframes status-panel-overlay-halo-breathe {
0%, 100% { opacity: 0.35; transform: rotate(45deg) scale(0.95); }
50% { opacity: 0.8; transform: rotate(45deg) scale(1.04); }
}
@keyframes status-panel-overlay-bar-slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
@keyframes status-panel-overlay-trace-draw {
0% { stroke-dashoffset: 165; opacity: 0; }
36% { opacity: 1; }
64%, 100% { stroke-dashoffset: 0; opacity: 0.72; }
}
@media (prefers-reduced-motion: reduce) {
.status-panel-overlay,
.status-panel-overlay-halo,
.status-panel-overlay-traces .trace,
.status-panel-overlay-icon,
.status-panel-overlay-bar span { animation: none; }
.status-panel-overlay-traces .trace { stroke-dashoffset: 0; opacity: 0.72; }
}
</style>
+1
View File
@@ -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
View File
@@ -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;