Compare commits

...
23 Commits
Author SHA1 Message Date
Christoph Brandau 6729d61dca feat(language): implement internationalization and help overlay
This update introduces comprehensive language support (English/German) across the application, enabling localization for UI elements and settings dialogs. It also adds a dedicated Help Overlay component with detailed guides on using Gitty's core Git features.

- Adds language selection to App Settings
- Implements German translations in key areas
- Introduces global help documentation accessible via Ctrl+/
2026-07-11 15:03:01 +02:00
Christoph b181b384e7 Update version to 2026.7.19 2026-07-10 23:09:04 +02:00
Christoph Brandau c174fb3a80 Merge branch 'opt/light_mode'
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 18m59s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 27m53s
2026-07-10 22:59:20 +02:00
Christoph Brandau c747e02f29 feat(git): improve interactive rebase base handling and reflog display
The git module now correctly handles scenarios where the selected base branch is diverged from HEAD. This removes unnecessary ancestor checks during rebase planning, improving overall robustness of the feature. Additionally, the ReflogDialog component was updated to reliably retrieve the current HEAD hash for accurate display in the UI.

- Removed strict ancestor checking when starting interactive rebase operations.
- Updated App.svelte to accurately find and use the current HEAD entry from reflog data.
2026-07-10 22:56:48 +02:00
Christoph Brandau 791275f341 refactor(git): improve interactive rebase workflow robustness
This commit removes the dedicated feature QA page and significantly refactors the internal Git library logic for handling complex workflows, particularly interactive rebase. The changes introduce helper functions to manage temporary files and ensure proper cleanup of rebase artifacts regardless of success or failure. This improves the reliability of advanced git operations within the application.

- Added comprehensive cleanup routines for rebase helpers
- Centralized constants for rebase file names
- Removed obsolete QA feature code
2026-07-10 22:53:24 +02:00
Christoph Brandau f7b8beaad4 feat(git): add interactive rebase and reflog recovery features
This update significantly expands Git functionality by implementing support for advanced workflows, including interactive rebasing and recovering lost commits via the reflog. New logic handles preparing the necessary environment files (todo lists and reword queues) required by Git's internal editors. The frontend components are also updated to expose these new capabilities to the user interface.

- Implements full planning and execution flow for interactive rebase
- Adds functionality to list and restore commits using the reflog history
- Updates Rust backend commands to support advanced git operations
2026-07-10 22:49:08 +02:00
Christoph Brandau d0bca62362 refactor(ui): Improve title bar layout using flex and grid
The structure of the application title bar has been updated to utilize modern CSS layout techniques like Grid and Flexbox. This refactoring improves the responsiveness and organization of key elements such as repository name, branch indicator, and synchronization status. The changes ensure better alignment and handling of varying content lengths across different viewports.

- Restructures repo/branch info into a dedicated context container
- Uses flex properties for dynamic sizing of title bar sections
- Groups sync indicators into a cohesive unit
2026-07-10 22:21:53 +02:00
Christoph Brandau d3cde83518 style(css): standardize code surface and highlight colors
Introduces a comprehensive set of CSS variables defining surfaces, syntax elements, and diff coloring across both light and dark themes. This refactoring replaces numerous hardcoded color values throughout the stylesheet with these new variables, ensuring that all components consistently adhere to the defined theme palette.

- Added detailed variable definitions for code surfaces (e.g., `--code-surface`, `--code-input-bg`).
- Updated diff, hunk, and blame sections to use variable colors for better thematic consistency.
- Replaced fixed hex codes with variables across various component backgrounds and text colors.
2026-07-10 22:16:06 +02:00
Christoph 3fdd6d3467 Update version to 0.200.10 2026-07-10 20:27:29 +02:00
Christoph 900a6fa230 feat(core): improve platform compatibility and startup robustness
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 18m21s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Failing after 1h43m55s
This update enhances cross-platform reliability by addressing potential hangs during application startup on Linux environments using WebKitGTK. Additionally, native taskbar badge functionality is improved for macOS and Linux, ensuring accurate display of sync status. Configuration settings are also updated to include more specific internal crate reads and web search capabilities.

- Added timeout fallback to waitForAnimationFrame in Svelte
- Improved set_sync_badge logic for macOS/Linux platforms
- Updated local configuration with detailed dependency reads
2026-07-10 20:24:48 +02:00
Christoph 2d7946f6fd Update version to 2026.7.18 2026-07-10 18:38:11 +02:00
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
20 changed files with 3662 additions and 455 deletions
+9 -1
View File
@@ -96,7 +96,15 @@
"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 *)",
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/**)",
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-runtime-wry-2.11.4/src/**)",
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/window/**)",
"WebSearch"
]
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitty",
"version": "0.200.5",
"version": "2026.7.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitty",
"version": "0.200.5",
"version": "2026.7.19",
"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.19",
"private": true,
"type": "module",
"scripts": {
+15 -1
View File
@@ -177,7 +177,21 @@ pub fn set_sync_badge(
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
}
#[cfg(not(target_os = "windows"))]
// Native badge count: works on Linux (via the desktop's launcher API, e.g. Unity's
// LauncherEntry, also honored by GNOME/KDE) and macOS (dock badge). Matched against the
// app's `<product-name>.desktop` file on Linux, so it's a silent no-op on launchers that
// don't implement the protocol.
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let Some(window) = app.get_webview_window("main") else {
return Ok(());
};
window
.set_badge_count(if count > 0 { Some(count as i64) } else { None })
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
let _ = (app, count);
}
+803 -185
View File
File diff suppressed because it is too large Load Diff
+23 -8
View File
@@ -12,14 +12,16 @@ use git::{
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame,
get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits,
list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
stash_push, undo_last_commit, unstage_files,
list_file_history, list_interactive_rebase_commits, list_reflog, list_repository_files,
list_stashes, list_tags, merge_branch, open_repo_in_explorer, open_repository,
open_repository_bundle, open_repository_file, pull, push, push_tag, read_conflict,
rebase_abort, rebase_branch, rebase_continue, rename_branch, resolve_conflict,
resolve_conflict_side, restore_file_from_commit, restore_files, restore_reflog_entry,
restore_to_commit, run_sequence_editor_if_requested, search_code_introductions, stage_files,
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit,
unstage_files,
};
use tauri::{Manager, AppHandle};
use tauri::Manager;
#[tauri::command]
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
@@ -42,10 +44,19 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
#[tokio::main]
async fn main() {
if let Some(result) = run_sequence_editor_if_requested() {
if let Err(error) = result {
eprintln!("{error}");
std::process::exit(1);
}
return;
}
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
#[cfg(desktop)]
let _ = app.get_webview_window("main")
let _ = app
.get_webview_window("main")
.expect("no main window")
.set_focus();
}))
@@ -107,6 +118,10 @@ async fn main() {
rebase_branch,
rebase_continue,
rebase_abort,
list_interactive_rebase_commits,
start_interactive_rebase,
list_reflog,
restore_reflog_entry,
list_repository_files,
open_repository_bundle,
list_file_history,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty",
"version": "0.200.5",
"version": "2026.7.19",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run dev",
+355 -28
View File
@@ -21,10 +21,13 @@
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
import HelpOverlay from "./lib/components/HelpOverlay.svelte";
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
import InteractiveRebaseDialog from "./lib/components/InteractiveRebaseDialog.svelte";
import LinePatchDialog from "./lib/components/LinePatchDialog.svelte";
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
import ReflogDialog from "./lib/components/ReflogDialog.svelte";
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StashPanel from "./lib/components/StashPanel.svelte";
@@ -62,6 +65,8 @@
listTags,
listCommits,
listFileHistory,
listInteractiveRebaseCommits,
listReflog,
listRepositoryFiles,
mergeBranch,
openRepoInExplorer,
@@ -83,9 +88,11 @@
resolveConflict,
resolveConflictSide,
restoreFileFromCommit,
restoreReflogEntry,
restoreFiles,
restoreToCommit,
searchCodeIntroductions,
startInteractiveRebase,
setSyncBadge,
stageFiles,
stashApply,
@@ -98,6 +105,8 @@
import type {
AiSettings,
AppLanguage,
AppTheme,
AnalyticsSettings,
CommitAiPhase,
ConflictFile,
@@ -118,6 +127,9 @@
LocalModelOption,
PatchApplyAction,
PreparedResolution,
RebaseCommit,
RebasePlanItem,
ReflogEntry,
StoredCredential,
} from "./lib/types";
@@ -133,7 +145,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 +177,8 @@
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 APP_LANGUAGE_KEY = "gitlite.language.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";
@@ -236,8 +251,11 @@
let aiSettings: AiSettings = defaultAiSettings();
let aiSettingsOpen = false;
let appSettingsOpen = false;
let helpOpen = false;
let analyticsNoticeOpen = false;
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
let appTheme: AppTheme = loadThemePreference();
let appLanguage: AppLanguage = loadLanguagePreference();
let localModelOptions: LocalModelOption[] = [];
let errorMessage = "";
let operation = "";
@@ -250,6 +268,15 @@
let deleteBranchForce = false;
let compareSelectOpen = false;
let compareDialogOpen = false;
let interactiveRebaseOpen = false;
let interactiveRebaseBase = "";
let interactiveRebaseCommits: RebaseCommit[] = [];
let interactiveRebaseLoading = false;
let interactiveRebaseError = "";
let reflogOpen = false;
let reflogEntries: ReflogEntry[] = [];
let reflogLoading = false;
let reflogError = "";
let selectedDiffPath = "";
let diffHighlightQuery = "";
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
@@ -336,6 +363,7 @@
let fileHistoryResizeStartX = 0;
let fileHistoryResizeStartWidth = 0;
let fileHistoryCollapsed = true;
let themeMediaQuery: MediaQueryList | undefined;
// ── Derived ────────────────────────────────────────────────────────────────
@@ -388,13 +416,19 @@
$: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed);
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
$: applyThemePreference(appTheme);
$: applyLanguagePreference(appLanguage);
// ── 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);
@@ -411,7 +445,21 @@
}
function waitForAnimationFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
// requestAnimationFrame never fires for an unmapped/invisible window on
// WebKitGTK (Linux) — the compositor frame clock only runs for realized
// windows. The main window starts hidden until the splashscreen closes,
// so without a timeout fallback this promise (and the whole startup
// sequence, including closing the splashscreen) would hang forever on Linux.
return new Promise((resolve) => {
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
resolve();
};
requestAnimationFrame(() => settle());
window.setTimeout(settle, 50);
});
}
async function waitForStartupPaint() {
@@ -630,27 +678,33 @@
}
async function autoRefreshTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || newBranchCommit || globalSearchOpen || helpOpen) 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 +801,71 @@
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 loadLanguagePreference(): AppLanguage {
try {
const stored = localStorage.getItem(APP_LANGUAGE_KEY);
if (stored === "en" || stored === "de") return stored;
} catch {
// Local storage is optional; English is the first-start default.
}
return "en";
}
function persistLanguagePreference(next: AppLanguage) {
try {
localStorage.setItem(APP_LANGUAGE_KEY, next);
} catch {
// Ignore storage quota/private-mode errors.
}
}
function applyLanguagePreference(next: AppLanguage) {
document.documentElement.lang = next;
document.documentElement.dataset.language = next;
}
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, nextLanguage: AppLanguage) {
analyticsSettings = next;
appTheme = nextTheme;
appLanguage = nextLanguage;
persistAnalyticsSettings(next);
persistThemePreference(nextTheme);
persistLanguagePreference(nextLanguage);
appSettingsOpen = false;
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1 });
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme, language: nextLanguage });
}
function updateCommitMessage(message: string) {
@@ -938,6 +1052,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[] = [];
@@ -1511,6 +1629,13 @@
comparison = null;
compareSelectOpen = false;
compareDialogOpen = false;
interactiveRebaseOpen = false;
interactiveRebaseBase = "";
interactiveRebaseCommits = [];
interactiveRebaseError = "";
reflogOpen = false;
reflogEntries = [];
reflogError = "";
selectedDiffPath = "";
pendingRestoreFile = null;
newBranchCommit = null;
@@ -1939,6 +2064,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;
@@ -2166,6 +2306,99 @@
});
}
function preferredInteractiveRebaseBase(): string {
const candidates = [status?.upstream, "origin/main", "main", "origin/master", "master"]
.filter((value): value is string => Boolean(value) && value !== status?.current_branch);
for (const candidate of candidates) {
if (branches.some((branch) => branch.name === candidate)) return candidate;
}
return branches.find((branch) => !branch.current)?.name ?? "";
}
async function loadInteractiveRebaseRange(base: string) {
interactiveRebaseBase = base;
interactiveRebaseCommits = [];
interactiveRebaseError = "";
if (!activeRepoPath || !base) return;
interactiveRebaseLoading = true;
try {
const result = await listInteractiveRebaseCommits(activeRepoPath, base);
if (interactiveRebaseBase === base) interactiveRebaseCommits = result;
} catch (error) {
if (interactiveRebaseBase === base) interactiveRebaseError = errorToMessage(error);
} finally {
if (interactiveRebaseBase === base) interactiveRebaseLoading = false;
}
}
function openInteractiveRebase() {
if (!hasRepository || rebaseInProgress || cherryPickInProgress || isBusy) return;
interactiveRebaseOpen = true;
const base = preferredInteractiveRebaseBase();
void loadInteractiveRebaseRange(base);
trackEvent("interactive_rebase_opened");
}
async function runInteractiveRebase(plan: RebasePlanItem[]) {
if (!activeRepoPath || !interactiveRebaseBase || isBusy) return;
interactiveRebaseError = "";
await runOperation("Starting interactive rebase", async () => {
applyStatus(await startInteractiveRebase(activeRepoPath, interactiveRebaseBase, plan));
interactiveRebaseOpen = false;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("interactive_rebase_started", { commits: plan.length });
});
if (interactiveRebaseOpen && errorMessage) interactiveRebaseError = errorMessage;
}
async function openReflog() {
if (!hasRepository || isBusy) return;
reflogOpen = true;
reflogEntries = [];
reflogError = "";
reflogLoading = true;
try {
reflogEntries = await listReflog(activeRepoPath, 300);
trackEvent("reflog_opened", { entries: reflogEntries.length });
} catch (error) {
reflogError = errorToMessage(error);
} finally {
reflogLoading = false;
}
}
async function previewReflogEntry(entry: ReflogEntry) {
if (!activeRepoPath || isBusy) return;
await runOperation("Previewing reflog entry", async () => {
const result = await compareCommits(activeRepoPath, entry.hash, "HEAD");
comparison = result;
selectedDiffPath = result.files[0]?.path ?? "";
diffHighlightQuery = "";
pendingRestoreFile = null;
reflogOpen = false;
compareDialogOpen = true;
trackEvent("reflog_previewed", { files: result.files.length });
});
}
async function recoverReflogEntry(entry: ReflogEntry, branch: string) {
if (!activeRepoPath || !branch.trim() || isBusy) return;
reflogError = "";
await runOperation("Restoring reflog entry", async () => {
applyStatus(await restoreReflogEntry(activeRepoPath, entry.hash, branch.trim()));
reflogOpen = false;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("reflog_recovered");
});
if (reflogOpen && errorMessage) reflogError = errorMessage;
}
async function createNewTag(name: string, message: string) {
const trimmed = name.trim();
if (!activeRepoPath || !trimmed) return;
@@ -2539,7 +2772,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 +2786,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 +2796,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 +2984,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);
}
@@ -3022,6 +3290,11 @@
trackEvent("global_search_opened");
}
function openHelp() {
helpOpen = true;
trackEvent("help_opened");
}
async function compareSelectedCommits() {
if (!canCompare) return;
await runOperation("Comparing commits", async () => {
@@ -3212,12 +3485,23 @@
// ── Event handlers ─────────────────────────────────────────────────────────
function handleWindowKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key === "/") {
event.preventDefault();
openHelp();
return;
}
if (event.key === "Escape" && helpOpen) {
helpOpen = false;
return;
}
if (event.key === "Escape" && repoTabContextMenu) closeRepoTabContextMenu();
else if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm();
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false;
else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false;
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
}
@@ -3255,9 +3539,13 @@
onRefresh={refreshRepo}
onSearch={openGlobalSearchDialog}
onCompare={openCompareSelect}
onInteractiveRebase={openInteractiveRebase}
onReflog={openReflog}
onOpenInExplorer={openActiveRepoInExplorer}
onToggleAutoRefresh={toggleAutoRefresh}
onOpenSettings={() => { appSettingsOpen = true; }}
onOpenHelp={openHelp}
language={appLanguage}
/>
<div class="shell-body">
@@ -3357,7 +3645,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 +3804,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 +4007,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,11 +4159,17 @@
{#if appSettingsOpen}
<AppSettingsDialog
analytics={analyticsSettings}
theme={appTheme}
language={appLanguage}
onSave={saveAppSettings}
onClose={() => { appSettingsOpen = false; }}
/>
{/if}
{#if helpOpen}
<HelpOverlay language={appLanguage} onClose={() => { helpOpen = false; }} />
{/if}
{#if linePatchOpen && linePatchFile}
<LinePatchDialog
file={linePatchFile}
@@ -3901,8 +4197,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}
@@ -3971,6 +4267,37 @@
/>
{/if}
<!-- Compare: pick the two commits to diff -->
{#if interactiveRebaseOpen}
<InteractiveRebaseDialog
{branches}
currentBranch={status?.current_branch ?? ""}
base={interactiveRebaseBase}
commits={interactiveRebaseCommits}
isLoading={interactiveRebaseLoading}
{isBusy}
{operation}
error={interactiveRebaseError}
onBaseChange={loadInteractiveRebaseRange}
onStart={runInteractiveRebase}
onClose={() => { if (!isBusy) interactiveRebaseOpen = false; }}
/>
{/if}
{#if reflogOpen}
<ReflogDialog
entries={reflogEntries}
currentHash={reflogEntries.find((entry) => entry.selector === "HEAD@{0}")?.hash ?? ""}
isLoading={reflogLoading}
{isBusy}
{operation}
error={reflogError}
onPreview={previewReflogEntry}
onRestore={recoverReflogEntry}
onClose={() => { if (!isBusy) reflogOpen = false; }}
/>
{/if}
<!-- Compare: pick the two commits to diff -->
{#if compareSelectOpen}
<CompareSelectDialog
+903 -157
View File
File diff suppressed because it is too large Load Diff
+87 -23
View File
@@ -2,7 +2,7 @@
import { onDestroy, onMount } from "svelte";
import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte";
import { CircleHelp, CloudDownload, Download, FolderOpen, GitBranch, GitCompare, History, ListRestart, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte";
import iconUrl from "../../src-tauri/icons/icon.png";
export let branch: string = "";
@@ -20,26 +20,52 @@
export let onRefresh: () => void = () => {};
export let onSearch: () => void = () => {};
export let onCompare: () => void = () => {};
export let onInteractiveRebase: () => void = () => {};
export let onReflog: () => void = () => {};
export let onOpenInExplorer: () => void = () => {};
export let onToggleAutoRefresh: () => void = () => {};
export let onOpenHelp: () => void = () => {};
export let onOpenSettings: () => void = () => {};
export let language: "en" | "de" = "en";
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 +83,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>
@@ -110,6 +142,28 @@
<span class="tb-action-label">Compare</span>
</button>
<button
class="tb-action"
onclick={onInteractiveRebase}
disabled={!hasRepository || isBusy}
title="Interactive rebase"
aria-label="Interactive rebase"
>
<ListRestart size={14} aria-hidden="true" />
<span class="tb-action-label">Rebase</span>
</button>
<button
class="tb-action"
onclick={onReflog}
disabled={!hasRepository || isBusy}
title="Reflog"
aria-label="Reflog"
>
<History size={14} aria-hidden="true" />
<span class="tb-action-label">Reflog</span>
</button>
<button
class="tb-action"
onclick={onFetch}
@@ -186,26 +240,36 @@
<span class="tb-action-label">Auto</span>
</button>
<button
class="tb-action"
onclick={onOpenHelp}
title={language === "de" ? "Hilfe (Ctrl+/)" : "Help (Ctrl+/)"}
aria-label={language === "de" ? "Hilfe öffnen" : "Open help"}
>
<CircleHelp size={14} aria-hidden="true" />
<span class="tb-action-label">{language === "de" ? "Hilfe" : "Help"}</span>
</button>
<button
class="tb-action"
onclick={onOpenSettings}
title="Settings"
aria-label="Settings"
title={language === "de" ? "Einstellungen" : "Settings"}
aria-label={language === "de" ? "Einstellungen" : "Settings"}
>
<Settings size={14} aria-hidden="true" />
<span class="tb-action-label">Settings</span>
<span class="tb-action-label">{language === "de" ? "Einstellungen" : "Settings"}</span>
</button>
</div>
<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 +284,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>
+66 -13
View File
@@ -1,19 +1,26 @@
<script lang="ts">
import { Check, Settings, X } from "@lucide/svelte";
import type { AnalyticsSettings } from "../types";
import { Check, Languages, Settings, X } from "@lucide/svelte";
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types";
interface Props {
analytics: AnalyticsSettings;
onSave: (settings: AnalyticsSettings) => void;
theme: AppTheme;
language: AppLanguage;
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage) => void;
onClose: () => void;
}
let { analytics, onSave = () => {}, onClose = () => {} }: Props = $props();
let { analytics, theme = "system", language = "en", onSave = () => {}, onClose = () => {} }: Props = $props();
let analyticsEnabled = $state(true);
let selectedTheme = $state<AppTheme>("system");
let selectedLanguage = $state<AppLanguage>("en");
const isGerman = $derived(selectedLanguage === "de");
$effect(() => {
analyticsEnabled = analytics.enabled;
selectedTheme = theme;
selectedLanguage = language;
});
function save() {
@@ -21,46 +28,92 @@
...analytics,
enabled: analyticsEnabled,
noticeSeen: true,
});
}, selectedTheme, selectedLanguage);
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1">
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Gitty</span>
<h2 class="dialog-title">Settings</h2>
<h2 class="dialog-title">{isGerman ? "Einstellungen" : "Settings"}</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"}>
<X size={18} aria-hidden="true" />
</button>
</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">{isGerman ? "Darstellung" : "Appearance"}</span>
<h3>{isGerman ? "Farbschema" : "Theme"}</h3>
</div>
</header>
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "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>{isGerman ? "Hell" : "Light"}</span>
</label>
<label class:active={selectedTheme === "dark"}>
<input type="radio" bind:group={selectedTheme} value="dark" />
<span>{isGerman ? "Dunkel" : "Dark"}</span>
</label>
</div>
</section>
<section class="settings-section">
<header>
<Languages size={16} aria-hidden="true" />
<div>
<span class="eyebrow">{isGerman ? "Sprache" : "Language"}</span>
<h3>{isGerman ? "App-Sprache" : "App language"}</h3>
</div>
</header>
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
<label class:active={selectedLanguage === "en"}>
<input type="radio" bind:group={selectedLanguage} value="en" />
<span>EN · English</span>
</label>
<label class:active={selectedLanguage === "de"}>
<input type="radio" bind:group={selectedLanguage} value="de" />
<span>DE · Deutsch</span>
</label>
</div>
</section>
<section class="settings-section">
<header>
<Settings size={16} aria-hidden="true" />
<div>
<span class="eyebrow">Analytics</span>
<h3>Anonymous usage analytics</h3>
<h3>{isGerman ? "Anonyme Nutzungsanalyse" : "Anonymous usage analytics"}</h3>
</div>
</header>
<label class="settings-toggle-row">
<input type="checkbox" bind:checked={analyticsEnabled} />
<span>
<strong>Allow anonymous Aptabase events</strong>
<small>No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent.</small>
<strong>{isGerman ? "Anonyme Aptabase-Ereignisse erlauben" : "Allow anonymous Aptabase events"}</strong>
<small>{isGerman ? "Es werden keine Repository-Pfade, Remotes, Branches, Commit-Nachrichten, Dateinamen, Diffs, Zugangsdaten oder Code übertragen." : "No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent."}</small>
</span>
</label>
</section>
<div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose}>Cancel</button>
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="submit">
<Check size={16} aria-hidden="true" />
Save
{isGerman ? "Speichern" : "Save"}
</button>
</div>
</form>
+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>
+798
View File
@@ -0,0 +1,798 @@
<script lang="ts">
import { onMount, tick } from "svelte";
import {
AlertTriangle,
BookOpen,
Check,
ChevronRight,
CircleHelp,
Clipboard,
Cloud,
Command,
GitBranch,
GitCommitHorizontal,
Home,
Keyboard,
Lightbulb,
Search,
Wrench,
X,
} from "@lucide/svelte";
interface CommandExample {
command: string;
description: string;
}
interface HelpSection {
id: string;
title: string;
summary: string;
steps?: string[];
commands?: CommandExample[];
note?: string;
}
interface HelpCategory {
id: string;
label: string;
description: string;
sections: HelpSection[];
}
interface Props {
language: "en" | "de";
onClose: () => void;
}
const deCategories: HelpCategory[] = [
{
id: "start",
label: "Erste Schritte",
description: "Repository öffnen, Oberfläche verstehen und den ersten Commit erstellen.",
sections: [
{
id: "start-workflow",
title: "Dein erster Gitty-Workflow",
summary: "Vom Repository bis zum veröffentlichten Commit in fünf klaren Schritten.",
steps: [
"Öffne ein vorhandenes Repository oder klone ein Projekt über die Repository-Verwaltung.",
"Bearbeite deine Dateien. Gitty zeigt Änderungen im Arbeitsverzeichnis automatisch an.",
"Prüfe den Diff und stage einzelne Dateien, Zeilen oder alle passenden Änderungen.",
"Schreibe eine aussagekräftige Commit-Nachricht und erstelle den Commit.",
"Nutze Fetch, Pull und Push, um deinen Stand mit dem Remote-Repository abzugleichen.",
],
},
{
id: "start-layout",
title: "Die Oberfläche im Überblick",
summary: "Links findest du Branches, Stashes und Dateien. In der Mitte prüfst und commitest du Änderungen; rechts siehst du Verlauf und Dateihistorie.",
note: "Fast alle Bereiche lassen sich über die Trennlinien in der Größe anpassen oder über ihren Kopf einklappen.",
},
{
id: "start-safety",
title: "Sicher arbeiten",
summary: "Prüfe vor Commit, Pull, Rebase oder Verwerfen immer den aktuellen Branch und die betroffenen Dateien. Gitty fragt bei destruktiven Aktionen nochmals nach.",
},
],
},
{
id: "app",
label: "Arbeiten mit Gitty",
description: "Die wichtigsten Funktionen der App Schritt für Schritt.",
sections: [
{
id: "app-changes",
title: "Änderungen prüfen und stagen",
summary: "Wähle eine geänderte Datei, lies den Diff und verschiebe gezielt Änderungen in den Staging-Bereich.",
steps: [
"Unstaged enthält noch nicht vorbereitete Änderungen; Staged enthält den nächsten Commit-Inhalt.",
"Öffne eine Datei, um hinzugefügte und entfernte Zeilen zu prüfen.",
"Stage ganze Dateien oder nutze die Zeilen-/Hunk-Aktionen für kleinere, saubere Commits.",
"Verwerfen entfernt lokale Änderungen. Diese Aktion lässt sich nicht immer rückgängig machen.",
],
},
{
id: "app-history",
title: "Verlauf, Suche und Vergleich",
summary: "Der Commit-Verlauf zeigt Branches und Merges. Mit der globalen Suche findest du die Einführung von Code oder den Verlauf einer Datei.",
steps: [
"Klappe einen Commit auf, um seine Dateien zu sehen.",
"Vergleiche zwei Commits über Compare in der Titelleiste.",
"Nutze Reflog, um auch verschobene oder nicht mehr sichtbare Referenzen wiederzufinden.",
],
},
{
id: "app-stash",
title: "Zwischenstände mit Stash sichern",
summary: "Ein Stash parkt unvollständige Änderungen, ohne einen Commit zu erzeugen. Apply behält den Stash, Pop wendet ihn an und entfernt ihn.",
},
],
},
{
id: "basics",
label: "Git-Grundlagen",
description: "Alltägliche Befehle für Status, Änderungen, Commits und Wiederherstellung.",
sections: [
{
id: "basics-inspect",
title: "Repository prüfen",
summary: "Diese Befehle verändern nichts und eignen sich immer als erster Blick auf den aktuellen Zustand.",
commands: [
{ command: "git status", description: "Arbeitsverzeichnis und Staging-Bereich anzeigen" },
{ command: "git diff", description: "Noch nicht gestagte Änderungen anzeigen" },
{ command: "git diff --staged", description: "Inhalt des nächsten Commits anzeigen" },
{ command: "git log --oneline --graph --decorate --all", description: "Kompakten Branch- und Commit-Verlauf anzeigen" },
],
},
{
id: "basics-commit",
title: "Änderungen speichern",
summary: "Stage nur zusammengehörige Änderungen und beschreibe im Commit, warum die Änderung nötig ist.",
commands: [
{ command: "git add <datei>", description: "Eine Datei für den Commit vormerken" },
{ command: "git add -p", description: "Änderungen interaktiv und abschnittsweise stagen" },
{ command: "git commit -m \"Kurze Beschreibung\"", description: "Einen Commit erstellen" },
{ command: "git commit --amend", description: "Den letzten lokalen Commit ergänzen oder umbenennen" },
],
},
{
id: "basics-undo",
title: "Änderungen rückgängig machen",
summary: "Restore arbeitet am Arbeitsverzeichnis oder Staging-Bereich; Revert erzeugt einen neuen Gegen-Commit und ist für veröffentlichte Historie sicherer.",
commands: [
{ command: "git restore <datei>", description: "Lokale, noch nicht gestagte Änderung verwerfen" },
{ command: "git restore --staged <datei>", description: "Datei aus dem Staging-Bereich entfernen" },
{ command: "git revert <commit>", description: "Wirkung eines Commits durch neuen Commit umkehren" },
],
note: "Vorsicht: git reset --hard verwirft lokale Änderungen. Verwende den Befehl nur, wenn du den Verlust bewusst akzeptierst.",
},
],
},
{
id: "branches",
label: "Branches & Merges",
description: "Parallele Arbeit organisieren, zusammenführen und aufräumen.",
sections: [
{
id: "branches-manage",
title: "Branches verwalten",
summary: "Ein Branch ist ein beweglicher Zeiger auf eine Commit-Linie. Erstelle für jede getrennte Aufgabe einen eigenen Branch.",
commands: [
{ command: "git switch -c feature/meine-aenderung", description: "Neuen Branch erstellen und wechseln" },
{ command: "git switch main", description: "Zu einem vorhandenen Branch wechseln" },
{ command: "git branch -vv", description: "Lokale Branches mit Upstream-Status anzeigen" },
{ command: "git branch -d <branch>", description: "Bereits zusammengeführten Branch löschen" },
],
},
{
id: "branches-integrate",
title: "Merge oder Rebase?",
summary: "Merge bewahrt die tatsächliche Verzweigung. Rebase setzt lokale Commits auf eine neue Basis und erzeugt eine lineare Historie.",
commands: [
{ command: "git merge <branch>", description: "Einen Branch in den aktuellen Branch integrieren" },
{ command: "git rebase main", description: "Lokale Commits auf den aktuellen Stand von main setzen" },
{ command: "git rebase --continue", description: "Rebase nach gelöstem Konflikt fortsetzen" },
{ command: "git rebase --abort", description: "Rebase abbrechen und Ausgangszustand wiederherstellen" },
],
note: "Rebase keine Commits, an denen andere bereits weiterarbeiten. Das Umschreiben veröffentlichter Historie verursacht unnötige Konflikte.",
},
],
},
{
id: "remote",
label: "Remote & Sync",
description: "Änderungen sicher mit GitHub, GitLab oder anderen Remotes austauschen.",
sections: [
{
id: "remote-sync",
title: "Fetch, Pull und Push",
summary: "Fetch lädt Referenzen ohne deine Dateien zu ändern. Pull integriert Remote-Änderungen. Push veröffentlicht deine Commits.",
commands: [
{ command: "git fetch --all --prune", description: "Remote-Stände laden und entfernte Referenzen aufräumen" },
{ command: "git pull --rebase", description: "Remote-Änderungen laden und lokale Commits darauf neu abspielen" },
{ command: "git push -u origin <branch>", description: "Branch erstmals veröffentlichen und Upstream setzen" },
{ command: "git remote -v", description: "Konfigurierte Remote-Adressen anzeigen" },
],
},
{
id: "remote-ahead",
title: "Ahead und Behind verstehen",
summary: "Ahead bedeutet: lokale Commits wurden noch nicht gepusht. Behind bedeutet: im Remote liegen neue Commits, die lokal fehlen. Beides gleichzeitig weist auf auseinanderlaufende Historien hin.",
},
],
},
{
id: "troubleshooting",
label: "Probleme lösen",
description: "Konflikte, verlorene Commits und typische Fehlermeldungen verstehen.",
sections: [
{
id: "trouble-conflicts",
title: "Merge-Konflikte lösen",
summary: "Ein Konflikt entsteht, wenn Git Änderungen nicht eindeutig kombinieren kann. Gitty bietet dafür einen eigenen Resolve-Dialog.",
steps: [
"Öffne jede Konfliktdatei und vergleiche Current, Incoming und das kombinierte Ergebnis.",
"Übernimm eine Seite oder bearbeite den Zielinhalt manuell.",
"Markiere die Datei als gelöst und prüfe anschließend den vollständigen Diff.",
"Führe Merge, Rebase oder Cherry-pick fort oder brich die Operation vollständig ab.",
],
commands: [
{ command: "git status", description: "Konfliktdateien und laufende Operation anzeigen" },
{ command: "git merge --abort", description: "Laufenden Merge abbrechen" },
{ command: "git cherry-pick --abort", description: "Laufenden Cherry-pick abbrechen" },
],
},
{
id: "trouble-reflog",
title: "Verlorene Commits wiederfinden",
summary: "Reflog protokolliert lokale Bewegungen von HEAD und Branches. Kopiere den Hash des gesuchten Eintrags und erstelle daraus einen Sicherungs-Branch.",
commands: [
{ command: "git reflog", description: "Lokale Referenzbewegungen anzeigen" },
{ command: "git branch recovery/<name> <commit>", description: "Gefundenen Commit dauerhaft über Branch sichern" },
],
},
],
},
{
id: "shortcuts",
label: "Tastenkürzel",
description: "Gitty schneller und ohne Maus bedienen.",
sections: [
{
id: "shortcuts-main",
title: "Globale Bedienung",
summary: "Die Hilfe ist überall erreichbar. Dialoge lassen sich konsistent schließen und Suchfelder direkt fokussieren.",
commands: [
{ command: "Ctrl + /", description: "Diese Hilfe öffnen" },
{ command: "Escape", description: "Aktuelles Overlay oder Dialogfenster schließen" },
{ command: "Tab / Shift + Tab", description: "Zwischen Bedienelementen wechseln" },
{ command: "Enter / Leertaste", description: "Fokussierte Aktion ausführen" },
],
note: "Auf macOS kannst du für Ctrl in der Regel die Command-Taste verwenden.",
},
],
},
];
const enCategories: HelpCategory[] = [
{
id: "start",
label: "Getting started",
description: "Open a repository, understand the interface, and create your first commit.",
sections: [
{
id: "start-workflow",
title: "Your first Gitty workflow",
summary: "From repository to published commit in five clear steps.",
steps: [
"Open an existing repository or clone a project from Repository Management.",
"Edit your files. Gitty automatically displays working-tree changes.",
"Review the diff and stage individual files, lines, or all related changes.",
"Write a meaningful commit message and create the commit.",
"Use Fetch, Pull, and Push to synchronize with the remote repository.",
],
},
{
id: "start-layout",
title: "Interface overview",
summary: "Branches, stashes, and files are on the left. Review and commit changes in the center; history and file history are on the right.",
note: "Most areas can be resized using their dividers or collapsed from their header.",
},
{
id: "start-safety",
title: "Work safely",
summary: "Before committing, pulling, rebasing, or discarding, always check the current branch and affected files. Gitty confirms destructive actions.",
},
],
},
{
id: "app",
label: "Working with Gitty",
description: "The app's most important features, explained step by step.",
sections: [
{
id: "app-changes",
title: "Review and stage changes",
summary: "Select a changed file, review its diff, and move changes into the staging area.",
steps: [
"Unstaged contains changes not yet prepared; Staged contains the next commit.",
"Open a file to review added and removed lines.",
"Stage whole files or use line and hunk actions for focused commits.",
"Discard removes local changes and cannot always be undone.",
],
},
{
id: "app-history",
title: "History, search, and comparison",
summary: "Commit History shows branches and merges. Global Search finds where code was introduced or displays a file's history.",
steps: [
"Expand a commit to inspect its files.",
"Compare two commits with Compare in the title bar.",
"Use Reflog to recover moved or otherwise hidden references.",
],
},
{
id: "app-stash",
title: "Save temporary work with Stash",
summary: "A stash parks unfinished changes without creating a commit. Apply keeps the stash; Pop applies and removes it.",
},
],
},
{
id: "basics",
label: "Git basics",
description: "Everyday commands for status, changes, commits, and recovery.",
sections: [
{
id: "basics-inspect",
title: "Inspect a repository",
summary: "These commands do not change anything and are always a good first look at the current state.",
commands: [
{ command: "git status", description: "Show the working tree and staging area" },
{ command: "git diff", description: "Show changes that have not been staged" },
{ command: "git diff --staged", description: "Show the contents of the next commit" },
{ command: "git log --oneline --graph --decorate --all", description: "Show a compact branch and commit graph" },
],
},
{
id: "basics-commit",
title: "Save changes",
summary: "Stage only related changes and explain why the change is needed in the commit message.",
commands: [
{ command: "git add <file>", description: "Stage one file" },
{ command: "git add -p", description: "Stage changes interactively by hunk" },
{ command: "git commit -m \"Short description\"", description: "Create a commit" },
{ command: "git commit --amend", description: "Update or rename the latest local commit" },
],
},
{
id: "basics-undo",
title: "Undo changes",
summary: "Restore changes the working tree or staging area. Revert creates a new inverse commit and is safer for published history.",
commands: [
{ command: "git restore <file>", description: "Discard an unstaged local change" },
{ command: "git restore --staged <file>", description: "Remove a file from the staging area" },
{ command: "git revert <commit>", description: "Undo a commit through a new commit" },
],
note: "Caution: git reset --hard discards local changes. Use it only when that data loss is intentional.",
},
],
},
{
id: "branches",
label: "Branches & merges",
description: "Organize parallel work, integrate it, and clean up afterward.",
sections: [
{
id: "branches-manage",
title: "Manage branches",
summary: "A branch is a movable pointer to a commit line. Create a separate branch for each independent task.",
commands: [
{ command: "git switch -c feature/my-change", description: "Create and switch to a new branch" },
{ command: "git switch main", description: "Switch to an existing branch" },
{ command: "git branch -vv", description: "Show local branches and upstream status" },
{ command: "git branch -d <branch>", description: "Delete a branch that has already been merged" },
],
},
{
id: "branches-integrate",
title: "Merge or rebase?",
summary: "Merge preserves the actual branch structure. Rebase moves local commits onto a new base for a linear history.",
commands: [
{ command: "git merge <branch>", description: "Integrate a branch into the current branch" },
{ command: "git rebase main", description: "Move local commits onto the latest main" },
{ command: "git rebase --continue", description: "Continue after resolving a conflict" },
{ command: "git rebase --abort", description: "Abort and restore the original state" },
],
note: "Do not rebase commits other people are already using. Rewriting published history creates avoidable conflicts.",
},
],
},
{
id: "remote",
label: "Remote & sync",
description: "Exchange changes safely with GitHub, GitLab, or other remotes.",
sections: [
{
id: "remote-sync",
title: "Fetch, pull, and push",
summary: "Fetch downloads references without changing files. Pull integrates remote changes. Push publishes your commits.",
commands: [
{ command: "git fetch --all --prune", description: "Download remote state and remove stale references" },
{ command: "git pull --rebase", description: "Download changes and replay local commits on top" },
{ command: "git push -u origin <branch>", description: "Publish a branch and set its upstream" },
{ command: "git remote -v", description: "Show configured remote URLs" },
],
},
{
id: "remote-ahead",
title: "Understanding Ahead and Behind",
summary: "Ahead means local commits have not been pushed. Behind means remote commits are missing locally. Both at once means the histories have diverged.",
},
],
},
{
id: "troubleshooting",
label: "Troubleshooting",
description: "Resolve conflicts, recover lost commits, and understand common errors.",
sections: [
{
id: "trouble-conflicts",
title: "Resolve merge conflicts",
summary: "A conflict occurs when Git cannot combine changes unambiguously. Gitty provides a dedicated Resolve dialog.",
steps: [
"Open each conflicted file and compare Current, Incoming, and the combined result.",
"Accept one side or edit the final content manually.",
"Mark the file resolved and review the complete diff.",
"Continue the merge, rebase, or cherry-pick—or abort the operation.",
],
commands: [
{ command: "git status", description: "Show conflicts and the active operation" },
{ command: "git merge --abort", description: "Abort the current merge" },
{ command: "git cherry-pick --abort", description: "Abort the current cherry-pick" },
],
},
{
id: "trouble-reflog",
title: "Recover lost commits",
summary: "Reflog records local movements of HEAD and branches. Copy the desired hash and create a recovery branch from it.",
commands: [
{ command: "git reflog", description: "Show local reference movements" },
{ command: "git branch recovery/<name> <commit>", description: "Preserve the recovered commit with a branch" },
],
},
],
},
{
id: "shortcuts",
label: "Keyboard shortcuts",
description: "Use Gitty quickly without reaching for the mouse.",
sections: [
{
id: "shortcuts-main",
title: "Global controls",
summary: "Help is available everywhere. Dialogs close consistently and search fields receive focus automatically.",
commands: [
{ command: "Ctrl + /", description: "Open this help center" },
{ command: "Escape", description: "Close the current overlay or dialog" },
{ command: "Tab / Shift + Tab", description: "Move between controls" },
{ command: "Enter / Space", description: "Activate the focused control" },
],
note: "On macOS, you can generally use Command instead of Ctrl.",
},
],
},
];
let { language = "en", onClose = () => {} }: Props = $props();
const isGerman = $derived(language === "de");
const categories = $derived(isGerman ? deCategories : enCategories);
let selectedCategoryId = $state("start");
let searchQuery = $state("");
let copiedCommand = $state("");
let searchInput: HTMLInputElement;
let contentElement: HTMLElement;
let copyTimer: ReturnType<typeof setTimeout> | undefined;
const normalizedQuery = $derived(searchQuery.trim().toLocaleLowerCase(language));
const selectedCategory = $derived(categories.find((category) => category.id === selectedCategoryId) ?? categories[0]);
const visibleGroups = $derived.by(() => {
if (!normalizedQuery) return [{ category: selectedCategory, sections: selectedCategory.sections }];
return categories.flatMap((category) => {
const sections = category.sections.filter((section) => sectionSearchText(category, section).includes(normalizedQuery));
return sections.length > 0 ? [{ category, sections }] : [];
});
});
const resultCount = $derived(visibleGroups.reduce((sum, group) => sum + group.sections.length, 0));
$effect(() => {
normalizedQuery;
selectedCategoryId;
if (contentElement) contentElement.scrollTop = 0;
});
onMount(() => {
void tick().then(() => searchInput?.focus());
return () => {
if (copyTimer) clearTimeout(copyTimer);
};
});
function sectionSearchText(category: HelpCategory, section: HelpSection): string {
return [
category.label,
category.description,
section.title,
section.summary,
...(section.steps ?? []),
...(section.commands?.flatMap((entry) => [entry.command, entry.description]) ?? []),
section.note ?? "",
].join(" ").toLocaleLowerCase(language);
}
function selectCategory(id: string) {
selectedCategoryId = id;
searchQuery = "";
}
function isWarningNote(note: string): boolean {
return /^(Vorsicht|Rebase|Caution|Do not)/.test(note);
}
async function copyCommand(command: string) {
try {
await navigator.clipboard.writeText(command);
copiedCommand = command;
if (copyTimer) clearTimeout(copyTimer);
copyTimer = setTimeout(() => { copiedCommand = ""; }, 1800);
} catch {
copiedCommand = "";
}
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) onClose();
}
</script>
<div class="help-backdrop" role="presentation" onclick={handleBackdropClick}>
<div class="help-overlay" role="dialog" aria-modal="true" aria-labelledby="help-title">
<header class="help-header">
<div class="help-title-wrap">
<span class="help-mark"><CircleHelp size={19} aria-hidden="true" /></span>
<div>
<h2 id="help-title">{isGerman ? "Gitty Hilfe" : "Gitty Help"}</h2>
<p>{isGerman ? "App-Anleitung und Git-Wissen an einem Ort" : "App guidance and Git knowledge in one place"}</p>
</div>
</div>
<label class="help-search">
<span class="help-search-icon"><Search size={17} aria-hidden="true" /></span>
<input bind:this={searchInput} bind:value={searchQuery} type="search" placeholder={isGerman ? "Hilfe und Git-Befehle durchsuchen …" : "Search help and Git commands …"} aria-label={isGerman ? "Hilfe durchsuchen" : "Search help"} />
<kbd>Ctrl /</kbd>
</label>
<button class="help-close" type="button" onclick={onClose} title={isGerman ? "Hilfe schließen" : "Close help"} aria-label={isGerman ? "Hilfe schließen" : "Close help"}>
<X size={19} aria-hidden="true" />
</button>
</header>
<div class="help-layout">
<nav class="help-nav" aria-label={isGerman ? "Hilfethemen" : "Help topics"}>
<p class="help-nav-label">{isGerman ? "Themen" : "Topics"}</p>
{#each categories as category, index}
<button
type="button"
class:active={!normalizedQuery && category.id === selectedCategoryId}
onclick={() => selectCategory(category.id)}
>
<span class="help-nav-icon">
{#if index === 0}<Home size={17} aria-hidden="true" />
{:else if index === 1}<BookOpen size={17} aria-hidden="true" />
{:else if index === 2}<GitCommitHorizontal size={17} aria-hidden="true" />
{:else if index === 3}<GitBranch size={17} aria-hidden="true" />
{:else if index === 4}<Cloud size={17} aria-hidden="true" />
{:else if index === 5}<Wrench size={17} aria-hidden="true" />
{:else}<Keyboard size={17} aria-hidden="true" />{/if}
</span>
<span>{category.label}</span>
<span class="help-nav-chevron"><ChevronRight size={14} aria-hidden="true" /></span>
</button>
{/each}
<div class="help-nav-tip">
<span class="help-tip-icon"><Lightbulb size={16} aria-hidden="true" /></span>
<span>{isGerman ? "Suche auch nach Befehlen wie" : "Try commands such as"} <code>rebase</code>, <code>stash</code> {isGerman ? "oder" : "or"} <code>reflog</code>.</span>
</div>
</nav>
<main class="help-content" bind:this={contentElement}>
{#if normalizedQuery}
<header class="help-result-header">
<div>
<span>{isGerman ? "Suchergebnisse" : "Search results"}</span>
<h3>{resultCount} {isGerman ? "Treffer für" : resultCount === 1 ? "result for" : "results for"}{searchQuery.trim()}</h3>
</div>
<button type="button" onclick={() => { searchQuery = ""; }}>{isGerman ? "Suche löschen" : "Clear search"}</button>
</header>
{/if}
{#if visibleGroups.length === 0}
<div class="help-empty">
<span class="help-empty-icon"><Search size={28} aria-hidden="true" /></span>
<h3>{isGerman ? "Kein Hilfethema gefunden" : "No help topic found"}</h3>
<p>{isGerman ? "Versuche einen allgemeineren Begriff wie „Commit“, „Branch“, „Remote“ oder „Konflikt“." : "Try a broader term such as “commit”, “branch”, “remote”, or “conflict”."}</p>
<button class="btn-secondary" type="button" onclick={() => { searchQuery = ""; }}>{isGerman ? "Alle Themen anzeigen" : "Show all topics"}</button>
</div>
{:else}
{#each visibleGroups as group}
<div class="help-group">
<header class="help-group-header">
<span>{normalizedQuery ? group.category.label : isGerman ? "Gitty Handbuch" : "Gitty handbook"}</span>
<h3>{normalizedQuery ? group.category.label : group.category.label}</h3>
<p>{group.category.description}</p>
</header>
{#each group.sections as section, sectionIndex}
<article class="help-section" id={section.id}>
<div class="help-section-number">{String(sectionIndex + 1).padStart(2, "0")}</div>
<div class="help-section-body">
<h4>{section.title}</h4>
<p>{section.summary}</p>
{#if section.steps}
<ol class="help-steps">
{#each section.steps as step}
<li><span>{step}</span></li>
{/each}
</ol>
{/if}
{#if section.commands}
<div class="help-commands">
{#each section.commands as entry}
<div class="help-command-row">
<code>{entry.command}</code>
<span>{entry.description}</span>
<button type="button" onclick={() => copyCommand(entry.command)} aria-label={(isGerman ? "Befehl kopieren: " : "Copy command: ") + entry.command} title={isGerman ? "Befehl kopieren" : "Copy command"}>
{#if copiedCommand === entry.command}
<Check size={15} aria-hidden="true" /><span>{isGerman ? "Kopiert" : "Copied"}</span>
{:else}
<Clipboard size={15} aria-hidden="true" /><span>{isGerman ? "Kopieren" : "Copy"}</span>
{/if}
</button>
</div>
{/each}
</div>
{/if}
{#if section.note}
<div class="help-note" class:warning={isWarningNote(section.note)}>
{#if isWarningNote(section.note)}
<span class="help-note-icon"><AlertTriangle size={16} aria-hidden="true" /></span>
{:else}
<span class="help-note-icon"><Lightbulb size={16} aria-hidden="true" /></span>
{/if}
<span>{section.note}</span>
</div>
{/if}
</div>
</article>
{/each}
</div>
{/each}
{/if}
</main>
</div>
<footer class="help-footer">
<span><Command size={14} aria-hidden="true" /> <kbd>Ctrl</kbd><kbd>/</kbd> {isGerman ? "Hilfe öffnen" : "open help"}</span>
<span><kbd>Esc</kbd> {isGerman ? "schließen" : "close"}</span>
</footer>
</div>
</div>
<style>
.help-backdrop {
position: fixed;
inset: var(--app-titlebar-height, 42px) 0 0;
z-index: 70;
display: grid;
place-items: center;
padding: 24px;
background: rgba(3, 7, 13, 0.82);
backdrop-filter: blur(3px);
}
.help-overlay {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(1240px, 100%);
height: min(820px, 100%);
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 12px;
color: var(--color-ink);
background: var(--app-dialog-bg);
box-shadow: var(--app-dialog-shadow);
}
.help-header {
display: grid;
grid-template-columns: minmax(220px, 0.72fr) minmax(320px, 1.2fr) minmax(44px, 0.72fr);
align-items: center;
gap: 22px;
min-height: 74px;
padding: 12px 16px 12px 20px;
border-bottom: 1px solid var(--color-border);
background: var(--app-dialog-chrome);
}
.help-title-wrap { display: flex; align-items: center; gap: 11px; min-width: 0; }
.help-mark { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid rgba(90, 140, 248, 0.28); border-radius: 8px; color: var(--color-accent); background: rgba(90, 140, 248, 0.09); }
.help-title-wrap h2 { margin: 0; font-size: 17px; line-height: 1.2; }
.help-title-wrap p { margin: 3px 0 0; overflow: hidden; color: var(--color-ink-faint); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
.help-search { position: relative; display: flex; align-items: center; min-width: 0; }
.help-search-icon { position: absolute; left: 12px; display: grid; color: var(--color-ink-faint); pointer-events: none; }
.help-search input { width: 100%; height: 40px; padding: 0 68px 0 39px; border-color: var(--color-border-input); border-radius: 8px; background: var(--app-input-bg); color: var(--color-ink); font-size: 12.5px; }
.help-search kbd { position: absolute; right: 8px; }
.help-close { justify-self: end; width: 36px; min-height: 36px; padding: 0; border-color: transparent; border-radius: 7px; background: transparent; color: var(--color-ink-muted); }
.help-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: 0; }
.help-nav { display: flex; flex-direction: column; min-height: 0; padding: 14px 10px 12px; border-right: 1px solid var(--color-border); background: var(--color-surface-dim); }
.help-nav-label { margin: 0 10px 8px; color: var(--color-ink-faint); font-size: 9.5px; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; }
.help-nav > button { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; min-height: 42px; padding: 5px 9px; border-color: transparent; border-radius: 7px; background: transparent; color: var(--color-ink-muted); font-size: 11.5px; font-weight: 650; text-align: left; }
.help-nav > button:hover { background: var(--color-surface-hover); color: var(--color-ink); }
.help-nav > button.active { border-color: rgba(90, 140, 248, 0.22); background: rgba(90, 140, 248, 0.12); color: var(--color-accent); }
.help-nav-icon { display: grid; place-items: center; color: currentColor; }
.help-nav-chevron { display: grid; color: var(--color-ink-faint); }
.help-nav-tip { display: flex; align-items: flex-start; gap: 8px; margin: auto 6px 0; padding: 11px; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-faint); font-size: 10.5px; line-height: 1.5; }
.help-tip-icon { flex: 0 0 auto; display: grid; margin-top: 2px; color: #d8a13d; }
.help-nav-tip code { color: var(--color-ink-muted); font-family: var(--font-mono); }
.help-content { min-width: 0; min-height: 0; padding: 0 34px 48px; overflow: auto; outline: none; scroll-behavior: smooth; }
.help-group { max-width: 850px; margin: 0 auto; }
.help-group + .help-group { margin-top: 22px; border-top: 1px solid var(--color-border); }
.help-group-header { padding: 34px 0 26px; border-bottom: 1px solid var(--color-border); }
.help-group-header > span, .help-result-header span { color: var(--color-accent); font-size: 9.5px; font-weight: 800; letter-spacing: 0.11em; text-transform: uppercase; }
.help-group-header h3 { margin: 7px 0 8px; font-size: clamp(24px, 3vw, 32px); letter-spacing: -0.025em; }
.help-group-header p { max-width: 650px; margin: 0; color: var(--color-ink-muted); font-size: 13px; line-height: 1.55; }
.help-result-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; max-width: 850px; margin: 0 auto; padding: 22px 0 0; }
.help-result-header h3 { margin: 4px 0 0; font-size: 17px; }
.help-result-header button { min-height: 30px; padding: 0 10px; border-color: var(--color-border-subtle); background: transparent; color: var(--color-ink-muted); font-size: 10.5px; }
.help-section { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 2px; padding: 26px 0; border-bottom: 1px solid var(--color-border-subtle); }
.help-section-number { padding-top: 3px; color: var(--color-ink-faint); font-family: var(--font-mono); font-size: 10px; }
.help-section-body h4 { margin: 0 0 7px; font-size: 16px; }
.help-section-body > p { margin: 0; color: var(--color-ink-muted); font-size: 12.5px; line-height: 1.6; }
.help-steps { display: grid; gap: 10px; margin: 18px 0 0; padding: 0; list-style: none; counter-reset: help-step; }
.help-steps li { display: grid; grid-template-columns: 24px minmax(0, 1fr); align-items: start; gap: 9px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; counter-increment: help-step; }
.help-steps li::before { content: counter(help-step); display: grid; place-items: center; width: 22px; height: 22px; border: 1px solid rgba(90, 140, 248, 0.35); border-radius: 50%; color: var(--color-accent); font-family: var(--font-mono); font-size: 9px; font-weight: 800; }
.help-commands { display: grid; gap: 1px; margin-top: 17px; overflow: hidden; border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-border-subtle); }
.help-command-row { display: grid; grid-template-columns: minmax(210px, 0.9fr) minmax(190px, 1.2fr) auto; align-items: center; gap: 14px; min-height: 47px; padding: 6px 7px 6px 13px; background: var(--code-surface); }
.help-command-row:hover { background: var(--code-hover-bg); }
.help-command-row > code { overflow: hidden; color: var(--color-accent); font-family: var(--font-mono); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
.help-command-row > span { color: var(--color-ink-faint); font-size: 10.5px; line-height: 1.35; }
.help-command-row button { display: inline-flex; align-items: center; gap: 6px; min-width: 83px; min-height: 31px; padding: 0 9px; border-color: var(--color-border-subtle); border-radius: 6px; background: var(--color-surface-raised); color: var(--color-ink-muted); font-size: 10px; }
.help-command-row button:hover { border-color: rgba(90, 140, 248, 0.32); color: var(--color-accent); }
.help-note { display: flex; align-items: flex-start; gap: 9px; margin-top: 15px; padding: 11px 12px; border: 1px solid rgba(90, 140, 248, 0.2); border-radius: 7px; background: rgba(90, 140, 248, 0.07); color: var(--color-ink-muted); font-size: 11px; line-height: 1.5; }
.help-note-icon { flex: 0 0 auto; display: grid; margin-top: 1px; color: var(--color-accent); }
.help-note.warning { border-color: rgba(224, 160, 64, 0.24); background: rgba(224, 160, 64, 0.07); }
.help-note.warning .help-note-icon { color: #d8a13d; }
.help-empty { display: grid; justify-items: center; max-width: 520px; margin: 90px auto 0; text-align: center; }
.help-empty-icon { display: grid; color: var(--color-ink-faint); }
.help-empty h3 { margin: 14px 0 5px; font-size: 18px; }
.help-empty p { margin: 0 0 18px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.55; }
.help-footer { display: flex; align-items: center; justify-content: space-between; min-height: 38px; padding: 0 16px 0 20px; border-top: 1px solid var(--color-border); background: var(--app-dialog-chrome); color: var(--color-ink-faint); font-size: 9.5px; }
.help-footer span { display: inline-flex; align-items: center; gap: 5px; }
kbd { display: inline-grid; place-items: center; min-width: 20px; height: 21px; padding: 0 5px; border: 1px solid var(--color-border-input); border-radius: 5px; background: var(--color-surface-raised); color: var(--color-ink-muted); box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.04); font-family: var(--font-mono); font-size: 9px; }
@media (max-width: 820px) {
.help-backdrop { padding: 10px; }
.help-header { grid-template-columns: minmax(0, 1fr) auto; gap: 10px; }
.help-title-wrap { display: none; }
.help-layout { grid-template-columns: 190px minmax(0, 1fr); }
.help-content { padding-inline: 20px; }
.help-command-row { grid-template-columns: minmax(0, 1fr) auto; gap: 5px 10px; }
.help-command-row > span { grid-column: 1; }
.help-command-row button { grid-column: 2; grid-row: 1 / 3; }
}
@media (max-width: 600px) {
.help-layout { grid-template-columns: 1fr; }
.help-nav { flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--color-border); }
.help-nav-label, .help-nav-tip, .help-nav-chevron { display: none; }
.help-nav > button { flex: 0 0 auto; width: auto; grid-template-columns: 24px auto; }
.help-content { padding-inline: 15px; }
.help-section { grid-template-columns: 1fr; }
.help-section-number { display: none; }
.help-footer { display: none; }
}
</style>
@@ -0,0 +1,148 @@
<script lang="ts">
import { AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
interface PlanRow extends RebaseCommit {
action: RebaseAction;
message: string;
}
interface Props {
branches: GitBranchInfo[];
currentBranch: string;
base: string;
commits: RebaseCommit[];
isLoading: boolean;
isBusy: boolean;
operation: string;
error: string;
onBaseChange: (base: string) => void;
onStart: (plan: RebasePlanItem[]) => void;
onClose: () => void;
}
let {
branches = [], currentBranch = "", base = "", commits = [], isLoading = false,
isBusy = false, operation = "", error = "", onBaseChange = () => {},
onStart = () => {}, onClose = () => {},
}: Props = $props();
let rows = $state<PlanRow[]>([]);
$effect(() => {
rows = commits.map((commit) => ({ ...commit, action: "pick", message: commit.summary }));
});
let availableBases = $derived(branches.filter((branch) => !branch.current));
let keptCount = $derived(rows.filter((row) => row.action !== "drop").length);
let invalidSquash = $derived(rows.some((row, index) =>
(row.action === "squash" || row.action === "fixup")
&& rows.slice(0, index).every((previous) => previous.action === "drop")
));
let invalidReword = $derived(rows.some((row) => row.action === "reword" && !row.message.trim()));
let canStart = $derived(Boolean(base) && rows.length > 0 && keptCount > 0 && !invalidSquash && !invalidReword && !isLoading && !isBusy);
function updateAction(index: number, action: RebaseAction) {
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, action } : row);
}
function updateMessage(index: number, message: string) {
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, message } : row);
}
function move(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= rows.length) return;
const next = [...rows];
[next[index], next[target]] = [next[target], next[index]];
rows = next;
}
function start() {
if (!canStart) return;
onStart(rows.map((row) => ({
hash: row.hash,
action: row.action,
message: row.action === "reword" ? row.message.trim() : null,
})));
}
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog interactive-rebase-dialog" role="dialog" aria-modal="true" aria-label="Interactive rebase" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Rewrite local history</span>
<h2 class="dialog-title">Interactive rebase</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
</header>
<div class="interactive-rebase-body">
<section class="rebase-base-bar">
<label>
<span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span>
<select value={base} onchange={(event) => onBaseChange((event.target as HTMLSelectElement).value)} disabled={isBusy || isLoading}>
<option value="" disabled>Select a base branch</option>
{#each availableBases as branch (branch.name)}
<option value={branch.name}>{branch.remote ? "Remote · " : "Local · "}{branch.name}</option>
{/each}
</select>
</label>
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
</section>
{#if error}
<div class="rebase-warning error"><AlertTriangle size={16} aria-hidden="true" /><span>{error}</span></div>
{/if}
{#if isLoading}
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading rebase range…</div>
{:else if !base}
<div class="blank-state">Select the branch or commit that should become the new base.</div>
{:else if rows.length === 0}
<div class="blank-state">No linear commits are available above this base.</div>
{:else}
<div class="rebase-plan" role="list" aria-label="Interactive rebase plan">
{#each rows as row, index (row.hash)}
<article class:drop={row.action === "drop"} class="rebase-plan-row" role="listitem">
<div class="rebase-order-actions">
<button type="button" onclick={() => move(index, -1)} disabled={isBusy || index === 0} title="Move up"><ArrowUp size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title="Move down"><ArrowDown size={14} aria-hidden="true" /></button>
</div>
<select class={`rebase-action ${row.action}`} value={row.action} onchange={(event) => updateAction(index, (event.target as HTMLSelectElement).value as RebaseAction)} disabled={isBusy} aria-label={`Action for ${row.short_hash}`}>
<option value="pick">pick</option><option value="reword">reword</option><option value="squash">squash</option><option value="fixup">fixup</option><option value="drop">drop</option>
</select>
<code>{row.short_hash}</code>
<div class="rebase-commit-copy">
{#if row.action === "reword"}
<input value={row.message} oninput={(event) => updateMessage(index, (event.target as HTMLInputElement).value)} disabled={isBusy} aria-label={`New message for ${row.short_hash}`} maxlength="240" />
{:else}
<strong>{row.summary}</strong>
{/if}
<span>{row.author_name} · {new Date(row.date).toLocaleString()}</span>
</div>
</article>
{/each}
</div>
{/if}
{#if invalidSquash}
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Squash and fixup need an earlier commit that is not dropped.</div>
{:else if invalidReword}
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Reword messages cannot be empty.</div>
{/if}
</div>
<footer class="dialog-footer">
<span class="dialog-footer-info">{keptCount} of {rows.length} commits kept</span>
<div class="rebase-footer-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-primary" type="button" onclick={start} disabled={!canStart}>
{#if operation === "Starting interactive rebase"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Play size={16} aria-hidden="true" />{/if}
Start rebase
</button>
</div>
</footer>
</div>
</div>
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import { GitBranch, History, LoaderCircle, Search, ShieldCheck, X } from "@lucide/svelte";
import type { ReflogEntry } from "../types";
interface Props {
entries: ReflogEntry[];
currentHash: string;
isLoading: boolean;
isBusy: boolean;
operation: string;
error: string;
onPreview: (entry: ReflogEntry) => void;
onRestore: (entry: ReflogEntry, branch: string) => void;
onClose: () => void;
}
let { entries = [], currentHash = "", isLoading = false, isBusy = false, operation = "", error = "", onPreview = () => {}, onRestore = () => {}, onClose = () => {} }: Props = $props();
let query = $state("");
let selectedHash = $state("");
let recoveryBranch = $state("");
let filteredEntries = $derived(entries.filter((entry) => `${entry.selector} ${entry.action} ${entry.short_hash} ${entry.author_name}`.toLowerCase().includes(query.trim().toLowerCase())));
let selected = $derived(entries.find((entry) => entry.hash === selectedHash) ?? filteredEntries[0] ?? null);
$effect(() => {
if (!selectedHash && entries.length > 0) select(entries[0]);
});
function select(entry: ReflogEntry) {
selectedHash = entry.hash;
recoveryBranch = `recovery/${entry.short_hash}`;
}
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog reflog-dialog" role="dialog" aria-modal="true" aria-label="Reflog" tabindex="-1">
<header class="dialog-header">
<div><span class="eyebrow">Recovery history</span><h2 class="dialog-title">Reflog</h2></div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
</header>
<div class="reflog-body">
<aside class="reflog-list-pane">
<label class="reflog-search"><Search size={15} aria-hidden="true" /><input bind:value={query} placeholder="Search actions, hashes or authors" aria-label="Search reflog" /></label>
{#if isLoading}
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading reflog…</div>
{:else if filteredEntries.length === 0}
<div class="blank-state">No reflog entries match this search.</div>
{:else}
<div class="reflog-list" role="listbox" aria-label="Reflog entries">
{#each filteredEntries as entry (`${entry.selector}:${entry.hash}`)}
<button class:active={selected?.selector === entry.selector} type="button" role="option" aria-selected={selected?.selector === entry.selector} onclick={() => select(entry)}>
<span class="reflog-row-top"><code>{entry.selector}</code><span>{new Date(entry.date).toLocaleString()}</span></span>
<strong>{entry.action}</strong>
<span class="reflog-row-bottom"><code>{entry.short_hash}</code><span>{entry.author_name}</span></span>
</button>
{/each}
</div>
{/if}
</aside>
<section class="reflog-detail">
{#if error}<div class="rebase-warning error">{error}</div>{/if}
{#if selected}
<div class="reflog-detail-head"><History size={20} aria-hidden="true" /><div><span class="eyebrow">{selected.selector}</span><h3>{selected.action}</h3></div></div>
<dl><div><dt>Commit</dt><dd><code>{selected.hash}</code></dd></div><div><dt>Author</dt><dd>{selected.author_name}</dd></div><div><dt>Date</dt><dd>{new Date(selected.date).toLocaleString()}</dd></div></dl>
<button class="btn-secondary reflog-preview" type="button" onclick={() => onPreview(selected)} disabled={isBusy || selected.hash === currentHash}><History size={15} aria-hidden="true" /> Preview changes to current HEAD</button>
<div class="reflog-recovery-card">
<div class="reflog-recovery-title"><ShieldCheck size={18} aria-hidden="true" /><div><strong>Safe recovery</strong><span>Create a new branch here. The current branch is not reset or deleted.</span></div></div>
<label><span>Recovery branch</span><div><GitBranch size={15} aria-hidden="true" /><input bind:value={recoveryBranch} disabled={isBusy} spellcheck="false" /></div></label>
<button class="btn-primary" type="button" onclick={() => onRestore(selected, recoveryBranch.trim())} disabled={isBusy || !recoveryBranch.trim()}>
{#if operation === "Restoring reflog entry"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<ShieldCheck size={16} aria-hidden="true" />{/if}
Create and checkout recovery branch
</button>
</div>
{:else}
<div class="blank-state">Select a reflog entry to inspect or recover it.</div>
{/if}
</section>
</div>
</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>
+23
View File
@@ -10,6 +10,9 @@ import type {
GitCommit,
GitCommitComparison,
GitRepositoryFile,
RebaseCommit,
RebasePlanItem,
ReflogEntry,
GitSearchHit,
GitStash,
GitStatus,
@@ -306,6 +309,26 @@ export function rebaseAbort(path: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_abort", { path });
}
export function listInteractiveRebaseCommits(path: string, base: string): Promise<RebaseCommit[]> {
return invoke<RebaseCommit[]>("list_interactive_rebase_commits", { path, base });
}
export function startInteractiveRebase(
path: string,
base: string,
plan: RebasePlanItem[],
): Promise<GitStatus> {
return invoke<GitStatus>("start_interactive_rebase", { path, base, plan });
}
export function listReflog(path: string, limit = 250): Promise<ReflogEntry[]> {
return invoke<ReflogEntry[]>("list_reflog", { path, limit });
}
export function restoreReflogEntry(path: string, commit: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("restore_reflog_entry", { path, commit, branch });
}
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
}
+27
View File
@@ -10,6 +10,8 @@ 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 type AppLanguage = "en" | "de";
export interface CommitAiStatus {
phase: CommitAiPhase;
@@ -197,6 +199,31 @@ export interface GitBlameResult {
lines: GitBlameLine[];
}
export type RebaseAction = "pick" | "reword" | "squash" | "fixup" | "drop";
export interface RebaseCommit {
hash: string;
short_hash: string;
summary: string;
author_name: string;
date: string;
}
export interface RebasePlanItem {
hash: string;
action: RebaseAction;
message: string | null;
}
export interface ReflogEntry {
hash: string;
short_hash: string;
selector: string;
action: string;
author_name: string;
date: string;
}
export interface StoredCredential {
username: string;
password: string;
+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;