diff --git a/README.md b/README.md index afdaa50..c28971a 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,9 @@ shows each submodule's recorded commit (from its parent's index), checked-out commit, and local changes. You can add a submodule, initialize it, check out its recorded commit, stage a changed reference, synchronize its URL from `.gitmodules`, or open it as a repository tab. Nested submodules are included by default. +Use **Change commit or tag** to select a local tag or enter a commit hash; +**Fetch tags & commits** downloads remote revisions using the submodule login. +Checking out a revision leaves the parent index unchanged until you stage its reference. Checking out a recorded commit is blocked when the submodule has local changes; commit or stash them in that repository first. Adding a submodule stages diff --git a/src-tauri/src/git/submodules.rs b/src-tauri/src/git/submodules.rs index 391e412..e11b99b 100644 --- a/src-tauri/src/git/submodules.rs +++ b/src-tauri/src/git/submodules.rs @@ -185,7 +185,19 @@ pub async fn list_submodules(path: String, recursive: bool) -> Result Result<(), String> { + operate_authenticated(repo, module_path, action, recursive, None, None) +} + +fn operate_authenticated( + repo: &Path, + module_path: &str, + action: &str, + recursive: bool, + username: Option<&str>, + password: Option<&str>, +) -> Result<(), String> { let module = list(repo, true)? .into_iter() .find(|m| m.path == module_path) @@ -214,7 +226,18 @@ fn operate(repo: &Path, module_path: &str, action: &str, recursive: bool) -> Res args.push("--recursive"); } args.extend(["--", module.relative_path.as_str()]); - submodule_git(owner, &args)?; + submodule_git(owner, &args, username, password)?; + } + "fetch" => { + if module.local_commit.is_none() { + return Err("Initialize the submodule first.".into()); + } + submodule_git( + Path::new(&module.full_path), + &["fetch", "--tags"], + username, + password, + )?; } "stage" => { if module.local_commit.is_none() { @@ -231,7 +254,7 @@ fn operate(repo: &Path, module_path: &str, action: &str, recursive: bool) -> Res args.push("--recursive"); } args.extend(["--", module.relative_path.as_str()]); - submodule_git(owner, &args)?; + submodule_git(owner, &args, username, password)?; } _ => return Err("Unknown submodule action.".into()), } @@ -244,14 +267,103 @@ pub async fn submodule_action( module_path: String, action: String, recursive: bool, + username: Option, + password: Option, ) -> Result<(), String> { run_git_task("Could not update submodule", move || { - operate(&resolve_repo(&path)?, &module_path, &action, recursive) + operate_authenticated( + &resolve_repo(&path)?, + &module_path, + &action, + recursive, + username.as_deref(), + password.as_deref(), + ) }) .await } +fn checkout_revision( + repo: &Path, + module_path: &str, + revision: &str, + kind: &str, +) -> Result<(), String> { + let module = list(repo, true)? + .into_iter() + .find(|m| m.path == module_path) + .ok_or("Submodule no longer exists. Refresh the list.")?; + if module.local_commit.is_none() { + return Err("Initialize the submodule first.".into()); + } + if module.dirty || module.conflicted { + return Err("Commit or stash local changes and resolve conflicts before changing the submodule revision.".into()); + } + let target = Path::new(&module.full_path); + let revision = revision.trim(); + let reference = match kind { + "commit" + if (4..=64).contains(&revision.len()) + && revision.bytes().all(|c| c.is_ascii_hexdigit()) => + { + revision.to_owned() + } + "tag" => { + let reference = format!("refs/tags/{revision}"); + run_git(target, ["check-ref-format", &reference])?; + reference + } + _ => return Err("Choose a tag or enter a valid commit hash.".into()), + }; + let hash = run_git( + target, + [ + "rev-parse", + "--verify", + "--end-of-options", + &format!("{reference}^{{commit}}"), + ], + ) + .map_err(|_| "Commit or tag was not found locally. Fetch tags and commits first.".to_owned())?; + let hash = String::from_utf8_lossy(&hash); + run_git( + target, + [ + "checkout", + "--detach", + "--no-recurse-submodules", + hash.trim(), + ], + )?; + Ok(()) +} + +#[tauri::command] +pub async fn checkout_submodule_revision( + path: String, + module_path: String, + revision: String, + kind: String, +) -> Result<(), String> { + run_git_task("Could not change submodule revision", move || { + checkout_revision(&resolve_repo(&path)?, &module_path, &revision, &kind) + }) + .await +} + +#[cfg(test)] fn add(repo: &Path, url: &str, destination: &str, branch: Option<&str>) -> Result<(), String> { + add_authenticated(repo, url, destination, branch, None, None) +} + +fn add_authenticated( + repo: &Path, + url: &str, + destination: &str, + branch: Option<&str>, + username: Option<&str>, + password: Option<&str>, +) -> Result<(), String> { safe_path(repo, destination)?; if url.trim().is_empty() || url.starts_with('-') { return Err("Enter a valid repository URL.".into()); @@ -262,11 +374,19 @@ fn add(repo: &Path, url: &str, destination: &str, branch: Option<&str>) -> Resul args.extend(["--branch", branch]); } args.extend(["--", url, destination]); - submodule_git(repo, &args)?; + submodule_git(repo, &args, username, password)?; Ok(()) } -fn submodule_git(repo: &Path, args: &[&str]) -> Result<(), String> { +fn submodule_git( + repo: &Path, + args: &[&str], + username: Option<&str>, + password: Option<&str>, +) -> Result<(), String> { + if let (Some(username), Some(password)) = (username, password) { + return super::run_git_authenticated(repo, args, username, password).map(|_| ()); + } let output = super::git_command() .arg("-C") .arg(repo) @@ -277,7 +397,12 @@ fn submodule_git(repo: &Path, args: &[&str]) -> Result<(), String> { if output.status.success() { Ok(()) } else { - Err(super::command_output_details(&output)) + let details = super::command_output_details(&output); + if super::is_auth_error(&details) { + Err(format!("AUTH_FAILED:{details}")) + } else { + Err(details) + } } } @@ -287,9 +412,18 @@ pub async fn add_submodule( url: String, destination: String, branch: Option, + username: Option, + password: Option, ) -> Result<(), String> { run_git_task("Could not add submodule", move || { - add(&resolve_repo(&path)?, &url, &destination, branch.as_deref()) + add_authenticated( + &resolve_repo(&path)?, + &url, + &destination, + branch.as_deref(), + username.as_deref(), + password.as_deref(), + ) }) .await } @@ -345,6 +479,123 @@ mod tests { git(&parent, &["commit", "-qam", "submodule"]); Fixture(path) } + #[test] + #[cfg(unix)] + fn submodules_authenticated_commands_receive_askpass_credentials() { + let f = fixture(); + let repo = f.0.join("parent"); + let probe = r#"alias.auth-probe=!test "$("$GIT_ASKPASS" Username)" = 'fixture-user' && test "$("$GIT_ASKPASS" Password)" = 'fixture-token'"#; + submodule_git( + &repo, + &["-c", probe, "auth-probe"], + Some("fixture-user"), + Some("fixture-token"), + ) + .unwrap(); + } + + #[test] + #[cfg(unix)] + fn submodules_auth_failures_are_classified_for_the_login_dialog() { + let f = fixture(); + let repo = f.0.join("parent"); + let probe = "alias.auth-probe=!echo 'fatal: could not read Username: terminal prompts disabled' >&2; exit 1"; + let error = submodule_git(&repo, &["-c", probe, "auth-probe"], None, None).unwrap_err(); + assert!(error.starts_with("AUTH_FAILED:")); + let error = submodule_git( + &repo, + &["-c", probe, "auth-probe"], + Some("user"), + Some("token"), + ) + .unwrap_err(); + assert!(error.starts_with("AUTH_FAILED:")); + let error = submodule_git(&repo, &["not-a-command"], None, None).unwrap_err(); + assert!(!error.starts_with("AUTH_FAILED:")); + } + + #[test] + fn submodules_checkout_tags_and_commits_without_staging_parent() { + let f = fixture(); + let repo = f.0.join("parent"); + let child = repo.join("libs/with spaces"); + let original = list(&repo, true).unwrap()[0].recorded_commit.clone(); + git(&child, &["tag", "v1.0"]); + fs::write(child.join("file.txt"), "version two\n").unwrap(); + git( + &child, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-qam", + "version two", + ], + ); + git( + &child, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "tag", + "-a", + "v2.0", + "-m", + "version two", + ], + ); + let latest = list(&repo, true).unwrap()[0].local_commit.clone().unwrap(); + checkout_revision(&repo, "libs/with spaces", "v1.0", "tag").unwrap(); + assert_eq!( + list(&repo, true).unwrap()[0].local_commit.as_deref(), + Some(original.as_str()) + ); + checkout_revision(&repo, "libs/with spaces", "v2.0", "tag").unwrap(); + let module = list(&repo, true).unwrap().remove(0); + assert_eq!(module.local_commit.as_deref(), Some(latest.as_str())); + assert_eq!(module.recorded_commit, original); + assert!(module.branch.is_none()); + checkout_revision(&repo, "libs/with spaces", &original[..8], "commit").unwrap(); + assert_eq!( + list(&repo, true).unwrap()[0].local_commit.as_deref(), + Some(original.as_str()) + ); + } + + #[test] + fn submodules_revision_rejects_unknown_refs_options_and_local_changes() { + let f = fixture(); + let repo = f.0.join("parent"); + let child = repo.join("libs/with spaces"); + let before = list(&repo, true).unwrap()[0].local_commit.clone(); + for (revision, kind) in [ + ("--force", "commit"), + ("HEAD~1", "commit"), + ("../bad", "tag"), + ("missing", "tag"), + ("deadbeef", "commit"), + ("main", "branch"), + ] { + assert!(checkout_revision(&repo, "libs/with spaces", revision, kind).is_err()); + } + assert_eq!(list(&repo, true).unwrap()[0].local_commit, before); + git(&child, &["tag", "valid"]); + fs::write(child.join("file.txt"), "local changes\n").unwrap(); + assert!( + checkout_revision(&repo, "libs/with spaces", "valid", "tag") + .unwrap_err() + .contains("stash") + ); + assert_eq!( + fs::read_to_string(child.join("file.txt")).unwrap(), + "local changes\n" + ); + } + #[test] fn submodules_list_clean_and_uninitialized() { let f = fixture(); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 90c425b..fe5faf2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,7 +10,7 @@ use badge::set_sync_badge; use external_tools::{ detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool, }; -use git::submodules::{add_submodule, list_submodules, submodule_action}; +use git::submodules::{checkout_submodule_revision, add_submodule, list_submodules, submodule_action}; use git::{ SearchCancellationState, add_remote, add_to_gitignore, add_worktree, amend_commit, apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort, @@ -366,6 +366,7 @@ async fn main() { list_submodules, add_submodule, submodule_action, + checkout_submodule_revision, list_worktrees, add_worktree, remove_worktree, diff --git a/src/App.svelte b/src/App.svelte index 588058e..41d45b2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -7,6 +7,8 @@ import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte"; import { beginFrontendShutdown, resumeFrontend } from "./lib/telemetry"; + import { setLanguage, t } from "./lib/i18n.svelte"; + import type { ConfirmRequest } from "./lib/components/ConfirmDialog.svelte"; import TitleBar from "./lib/TitleBar.svelte"; import RepoToolbar from "./lib/RepoToolbar.svelte"; @@ -22,17 +24,16 @@ import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte"; import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte"; import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte"; - import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte"; import TagsPanel from "./lib/components/TagsPanel.svelte"; import WorktreePanel from "./lib/components/WorktreePanel.svelte"; import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte"; + import ConfirmDialog from "./lib/components/ConfirmDialog.svelte"; import CommandPalette from "./lib/components/CommandPalette.svelte"; import CommitNoteDialog from "./lib/components/CommitNoteDialog.svelte"; import CommitPanel from "./lib/components/CommitPanel.svelte"; import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte"; import CredentialDialog from "./lib/components/CredentialDialog.svelte"; - import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte"; import ExplorerPanel from "./lib/components/ExplorerPanel.svelte"; import HistoryPanel from "./lib/components/HistoryPanel.svelte"; import InitRepositoryDialog from "./lib/components/InitRepositoryDialog.svelte"; @@ -55,6 +56,7 @@ listSubmodules, addSubmodule, submoduleAction, + checkoutSubmoduleRevision, checkoutBranch, cherryPickAbort, cherryPickCommit, @@ -294,10 +296,7 @@ const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.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"; - const LEFT_TAGS_PANEL_HEIGHT_KEY = "gitlite.leftTagsPanelHeight.v1"; - const LEFT_WORKTREE_PANEL_HEIGHT_KEY = "gitlite.leftWorktreePanelHeight.v1"; - const LEFT_STASH_PANEL_HEIGHT_KEY = "gitlite.leftStashPanelHeight.v1"; + const SIDEBAR_PANEL_HEIGHTS_KEY = "gitlite.sidebarPanelHeights.v2"; const BRANCH_PANEL_COLLAPSED_KEY = "gitlite.branchPanelCollapsed.v1"; const STASH_PANEL_COLLAPSED_KEY = "gitlite.stashPanelCollapsed.v2"; const EXPLORER_PANEL_COLLAPSED_KEY = "gitlite.explorerPanelCollapsed.v1"; @@ -308,13 +307,26 @@ const LEFT_SIDEBAR_DEFAULT_WIDTH = 280; const LEFT_SIDEBAR_MIN_WIDTH = 220; const LEFT_SIDEBAR_MAX_WIDTH = 420; - const LEFT_BRANCH_PANEL_DEFAULT_HEIGHT = 260; - const LEFT_BRANCH_PANEL_MIN_HEIGHT = 180; - const LEFT_BRANCH_PANEL_MAX_HEIGHT = 560; - const LEFT_STASH_PANEL_DEFAULT_HEIGHT = 190; - const LEFT_STASH_PANEL_MIN_HEIGHT = 150; - const LEFT_STASH_PANEL_MAX_HEIGHT = 420; - const LEFT_EXPLORER_PANEL_MIN_HEIGHT = 220; + // Sidebar panels, top to bottom. A drag handle moves the border between two + // neighbours: the panel above grows, the next expanded panel below gives way. + const SIDEBAR_PANEL_ORDER = ["branch", "worktree", "tags", "stash", "explorer"] as const; + type SidebarPanelId = (typeof SIDEBAR_PANEL_ORDER)[number]; + const SIDEBAR_PANEL_MIN_HEIGHT: Record = { + branch: 120, + worktree: 96, + tags: 96, + stash: 110, + explorer: 140, + }; + const SIDEBAR_PANEL_DEFAULT_HEIGHT: Record = { + branch: 260, + worktree: 150, + tags: 150, + stash: 190, + explorer: 260, + }; + const SIDEBAR_PANEL_MAX_HEIGHT = 900; + const SIDEBAR_RESIZE_STEP = 24; const HISTORY_ASIDE_DEFAULT_WIDTH = 620; const HISTORY_ASIDE_MIN_WIDTH = 420; const HISTORY_ASIDE_MAX_WIDTH = 920; @@ -426,6 +438,9 @@ let externalToolsDetectionUnavailable = false; let errorMessage = ""; let operation = ""; + type ConfirmAnswer = { confirmed: boolean; value: string; checked: boolean }; + let confirmDialogRequest: ConfirmRequest | null = null; + let confirmDialogResolve: ((answer: ConfirmAnswer) => void) | null = null; let compareFrom = ""; let compareTo = ""; let comparison: GitCommitComparison | null = null; @@ -440,6 +455,9 @@ let submoduleNoticeRequest = 0; let pendingSubmoduleInitialization: { repoPath: string; modules: GitSubmodule[] } | null = null; let submoduleInitializationError = ""; + let submoduleAuthRequest: { url: string; key: string | null; credential: StoredCredential | null; resolve: (credential: StoredCredential | null) => void } | null = null; + let submoduleAuthError = ""; + let submoduleAuthSaving = false; let submoduleDialogOpen = false; let submodules: GitSubmodule[] = []; let submodulesLoading = false; @@ -546,22 +564,12 @@ let resizingLeftSidebar = false; let leftSidebarResizeStartX = 0; let leftSidebarResizeStartWidth = 0; - let leftBranchPanelHeight = loadLeftBranchPanelHeight(); - let resizingLeftBranchPanel = false; - let leftBranchResizeStartY = 0; - let leftBranchResizeStartHeight = 0; - let leftWorktreePanelHeight = loadLeftWorktreePanelHeight(); - let resizingLeftWorktreePanel = false; - let leftWorktreeResizeStartY = 0; - let leftWorktreeResizeStartHeight = 0; - let leftTagsPanelHeight = loadLeftTagsPanelHeight(); - let resizingLeftTagsPanel = false; - let leftTagsResizeStartY = 0; - let leftTagsResizeStartHeight = 0; - let leftStashPanelHeight = loadLeftStashPanelHeight(); - let resizingLeftStashPanel = false; - let leftStashResizeStartY = 0; - let leftStashResizeStartHeight = 0; + let sidebarPanelHeights: Record = loadSidebarPanelHeights(); + let resizingSidebarPanel: SidebarPanelId | null = null; + let sidebarResizeStartY = 0; + let sidebarResizeAboveStart = 0; + let sidebarResizeBelowStart = 0; + let sidebarResizeBelow: SidebarPanelId | null = null; let tagsPanelCollapsed = loadStoredBoolean("gitlite.tagsPanelCollapsed.v1", true); let branchPanelCollapsed = loadStoredBoolean(BRANCH_PANEL_COLLAPSED_KEY, false); let stashPanelCollapsed = loadStoredBoolean(STASH_PANEL_COLLAPSED_KEY, true); @@ -617,7 +625,7 @@ ) as Record; $: remoteBranches = branches.filter((b) => b.remote); $: currentBranchIsLocalOnly = Boolean(status?.current_branch) && !status?.upstream; - $: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed); + $: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, sidebarPanelHeights); $: allLeftPanelsCollapsed = branchPanelCollapsed && worktreePanelCollapsed && tagsPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed; $: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools); $: diffToolName = externalToolDisplayName("diff", externalToolsSettings.diff, detectedExternalTools); @@ -1279,6 +1287,86 @@ function applyLanguagePreference(next: AppLanguage) { document.documentElement.lang = next; document.documentElement.dataset.language = next; + setLanguage(next); + } + + /** Show the dialog and resolve with the answer, including input and checkbox. */ + function askConfirmation(request: ConfirmRequest): Promise { + confirmDialogResolve?.({ confirmed: false, value: "", checked: false }); + confirmDialogRequest = request; + return new Promise((resolve) => { + confirmDialogResolve = resolve; + }); + } + + /** Yes/no only, for the many confirmations that need nothing else. */ + async function requestConfirmation(request: ConfirmRequest): Promise { + return (await askConfirmation(request)).confirmed; + } + + /** Confirmation shown before deleting a local or remote branch. */ + function branchDeleteConfirmRequest(branch: GitBranchInfo, force: boolean): ConfirmRequest { + const remoteName = branch.remote ? branch.name.split("/")[0] : ""; + return { + eyebrow: branch.remote + ? t("confirm.branchDelete.eyebrowRemote") + : force ? t("confirm.branchDelete.eyebrowForce") : t("confirm.branchDelete.eyebrow"), + title: branch.remote + ? t("confirm.branchDelete.titleRemote") + : force ? t("confirm.branchDelete.titleForce") : t("confirm.branchDelete.title"), + message: branch.remote + ? t("confirm.branchDelete.messageRemote") + : force ? t("confirm.branchDelete.messageForce") : t("confirm.branchDelete.message"), + items: [branch.name], + note: branch.remote + ? t("confirm.branchDelete.noteRemote", { remote: remoteName || t("common.remote") }) + : force ? t("confirm.branchDelete.noteForce") : t("confirm.branchDelete.note"), + confirmLabel: branch.remote + ? t("confirm.branchDelete.actionRemote") + : force ? t("confirm.branchDelete.actionForce") : t("confirm.branchDelete.action"), + }; + } + + /** Confirmation shown before discarding working-tree changes. */ + function discardConfirmRequest(discard: PendingDiscard): ConfirmRequest { + const files = discard.kind === "patch" ? [discard.file] : discard.files; + const staged = discard.kind === "all-changes" ? null : discard.staged; + const source = staged === null + ? t("confirm.discard.sourceBoth") + : staged ? t("confirm.discard.sourceStaged") : t("confirm.discard.sourceUnstaged"); + const scope = discard.kind === "patch" ? discard.scope : "file"; + + const title = scope === "hunk" + ? t("confirm.discard.titleHunk") + : scope === "lines" + ? t("confirm.discard.titleLines") + : files.length > 1 + ? t("confirm.discard.titleFiles", { count: files.length }) + : t("confirm.discard.titleFile"); + + const message = scope === "hunk" + ? t("confirm.discard.messageHunk", { source }) + : scope === "lines" + ? t("confirm.discard.messageLines", { source }) + : files.length > 1 + ? t("confirm.discard.messageFiles", { source, count: files.length }) + : t("confirm.discard.messageFile", { source }); + + return { + eyebrow: t("confirm.discard.eyebrow"), + title, + message, + items: files.map((file) => (file.old_path ? `${file.old_path} -> ${file.path}` : file.path)), + note: t("confirm.discard.note"), + confirmLabel: t("confirm.discard.action"), + }; + } + + function answerConfirmation(confirmed: boolean, value = "", checked = false) { + const resolve = confirmDialogResolve; + confirmDialogRequest = null; + confirmDialogResolve = null; + resolve?.({ confirmed, value, checked }); } function applyThemePreference(next: AppTheme) { @@ -1894,50 +1982,6 @@ } } - function clampLeftBranchPanelHeight(value: number): number { - return Math.min(LEFT_BRANCH_PANEL_MAX_HEIGHT, Math.max(LEFT_BRANCH_PANEL_MIN_HEIGHT, Math.round(value))); - } - - function loadLeftBranchPanelHeight(): number { - try { - const stored = Number(localStorage.getItem(LEFT_BRANCH_PANEL_HEIGHT_KEY)); - if (Number.isFinite(stored) && stored > 0) return clampLeftBranchPanelHeight(stored); - } catch { - // Fall through to the default below. - } - return LEFT_BRANCH_PANEL_DEFAULT_HEIGHT; - } - - function persistLeftBranchPanelHeight(value: number) { - try { - localStorage.setItem(LEFT_BRANCH_PANEL_HEIGHT_KEY, String(value)); - } catch { - // Local storage is best-effort only; resizing must keep working without it. - } - } - - function clampLeftStashPanelHeight(value: number): number { - return Math.min(LEFT_STASH_PANEL_MAX_HEIGHT, Math.max(LEFT_STASH_PANEL_MIN_HEIGHT, Math.round(value))); - } - - function loadLeftStashPanelHeight(): number { - try { - const stored = Number(localStorage.getItem(LEFT_STASH_PANEL_HEIGHT_KEY)); - if (Number.isFinite(stored) && stored > 0) return clampLeftStashPanelHeight(stored); - } catch { - // Fall through to the default below. - } - return LEFT_STASH_PANEL_DEFAULT_HEIGHT; - } - - function persistLeftStashPanelHeight(value: number) { - try { - localStorage.setItem(LEFT_STASH_PANEL_HEIGHT_KEY, String(value)); - } catch { - // Local storage is best-effort only; resizing must keep working without it. - } - } - function clampHistoryAsideWidth(value: number): number { return Math.min(HISTORY_ASIDE_MAX_WIDTH, Math.max(HISTORY_ASIDE_MIN_WIDTH, Math.round(value))); } @@ -2016,176 +2060,172 @@ persistLeftSidebarWidth(leftSidebarWidth); } - function buildLeftSidebarRows(branchCollapsed: boolean, stashCollapsed: boolean, explorerCollapsed: boolean, worktreeCollapsed: boolean, tagsCollapsed: boolean): string { - const branchRow = branchCollapsed - ? "auto" - : `minmax(${LEFT_BRANCH_PANEL_MIN_HEIGHT}px, var(--branch-panel-height, ${LEFT_BRANCH_PANEL_DEFAULT_HEIGHT}px))`; - const branchHandleRow = branchCollapsed ? "0" : "6px"; - const stashRow = stashCollapsed - ? "auto" - : `minmax(${LEFT_STASH_PANEL_MIN_HEIGHT}px, var(--stash-panel-height, ${LEFT_STASH_PANEL_DEFAULT_HEIGHT}px))`; - const stashHandleRow = explorerCollapsed || stashCollapsed ? "0" : "6px"; - const explorerRow = explorerCollapsed ? "auto" : `minmax(${LEFT_EXPLORER_PANEL_MIN_HEIGHT}px, 1fr)`; + // ── Sidebar panel sizing ─────────────────────────────────────────────────── - return `${branchRow} ${branchHandleRow} ${worktreeCollapsed ? "42px 0" : "var(--worktree-panel-height, 140px) 6px"} ${tagsCollapsed ? "42px 0" : "var(--tags-panel-height, 140px) 6px"} ${stashRow} ${stashHandleRow} ${explorerRow}`; + function clampSidebarPanelHeight(panel: SidebarPanelId, value: number): number { + return Math.min(SIDEBAR_PANEL_MAX_HEIGHT, Math.max(SIDEBAR_PANEL_MIN_HEIGHT[panel], Math.round(value))); } - function clampLeftWorktreePanelHeight(value: number) { - return Math.min(420, Math.max(80, Math.round(value))); - } - - function loadLeftWorktreePanelHeight() { + function loadSidebarPanelHeights(): Record { + const heights = { ...SIDEBAR_PANEL_DEFAULT_HEIGHT }; try { - const stored = Number(localStorage.getItem(LEFT_WORKTREE_PANEL_HEIGHT_KEY)); - if (Number.isFinite(stored) && stored > 0) return clampLeftWorktreePanelHeight(stored); - } catch { /* Preferences are optional. */ } - return 140; + const stored: unknown = JSON.parse(localStorage.getItem(SIDEBAR_PANEL_HEIGHTS_KEY) ?? "{}"); + if (stored && typeof stored === "object") { + for (const panel of SIDEBAR_PANEL_ORDER) { + const value = Number((stored as Record)[panel]); + if (Number.isFinite(value) && value > 0) heights[panel] = clampSidebarPanelHeight(panel, value); + } + } + } catch { + // Stored sizes are a convenience; the defaults above always work. + } + return heights; } - function persistLeftWorktreePanelHeight() { - try { localStorage.setItem(LEFT_WORKTREE_PANEL_HEIGHT_KEY, String(leftWorktreePanelHeight)); } - catch { /* Preferences are optional. */ } - } - - function startLeftWorktreePanelResize(event: PointerEvent) { - event.preventDefault(); - resizingLeftWorktreePanel = true; - leftWorktreeResizeStartY = event.clientY; - leftWorktreeResizeStartHeight = leftWorktreePanelHeight; - (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); - } - - function onLeftWorktreePanelResizeMove(event: PointerEvent) { - if (!resizingLeftWorktreePanel) return; - leftWorktreePanelHeight = clampLeftWorktreePanelHeight(leftWorktreeResizeStartHeight + event.clientY - leftWorktreeResizeStartY); - } - - function endLeftWorktreePanelResize(event: PointerEvent) { - if (!resizingLeftWorktreePanel) return; - resizingLeftWorktreePanel = false; - persistLeftWorktreePanelHeight(); - const target = event.currentTarget as HTMLElement; - if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId); - } - - function onLeftWorktreePanelResizeKeydown(event: KeyboardEvent) { - if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; - event.preventDefault(); - leftWorktreePanelHeight = clampLeftWorktreePanelHeight(leftWorktreePanelHeight + (event.key === "ArrowDown" ? 20 : -20)); - persistLeftWorktreePanelHeight(); - } - - function clampLeftTagsPanelHeight(value: number) { - return Math.min(420, Math.max(80, Math.round(value))); - } - - function loadLeftTagsPanelHeight() { + function persistSidebarPanelHeights() { try { - const stored = Number(localStorage.getItem(LEFT_TAGS_PANEL_HEIGHT_KEY)); - if (Number.isFinite(stored) && stored > 0) return clampLeftTagsPanelHeight(stored); - } catch { /* Preferences are optional. */ } - return 140; - } - - function persistLeftTagsPanelHeight() { - try { localStorage.setItem(LEFT_TAGS_PANEL_HEIGHT_KEY, String(leftTagsPanelHeight)); } - catch { /* Preferences are optional. */ } - } - - function startLeftTagsPanelResize(event: PointerEvent) { - event.preventDefault(); - resizingLeftTagsPanel = true; - leftTagsResizeStartY = event.clientY; - leftTagsResizeStartHeight = leftTagsPanelHeight; - (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); - } - - function onLeftTagsPanelResizeMove(event: PointerEvent) { - if (!resizingLeftTagsPanel) return; - leftTagsPanelHeight = clampLeftTagsPanelHeight(leftTagsResizeStartHeight + event.clientY - leftTagsResizeStartY); - } - - function endLeftTagsPanelResize(event: PointerEvent) { - if (!resizingLeftTagsPanel) return; - resizingLeftTagsPanel = false; - persistLeftTagsPanelHeight(); - const target = event.currentTarget as HTMLElement; - if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId); - } - - function onLeftTagsPanelResizeKeydown(event: KeyboardEvent) { - if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; - event.preventDefault(); - leftTagsPanelHeight = clampLeftTagsPanelHeight(leftTagsPanelHeight + (event.key === "ArrowDown" ? 20 : -20)); - persistLeftTagsPanelHeight(); - } - - function startLeftBranchPanelResize(event: PointerEvent) { - if (branchPanelCollapsed) return; - event.preventDefault(); - resizingLeftBranchPanel = true; - leftBranchResizeStartY = event.clientY; - leftBranchResizeStartHeight = leftBranchPanelHeight; - (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); - } - - function onLeftBranchPanelResizeMove(event: PointerEvent) { - if (!resizingLeftBranchPanel) return; - leftBranchPanelHeight = clampLeftBranchPanelHeight(leftBranchResizeStartHeight + (event.clientY - leftBranchResizeStartY)); - } - - function endLeftBranchPanelResize(event: PointerEvent) { - if (!resizingLeftBranchPanel) return; - resizingLeftBranchPanel = false; - persistLeftBranchPanelHeight(leftBranchPanelHeight); - const target = event.currentTarget as HTMLElement; - if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId); - } - - function onLeftBranchPanelResizeKeydown(event: KeyboardEvent) { - if (branchPanelCollapsed || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return; - event.preventDefault(); - leftBranchPanelHeight = clampLeftBranchPanelHeight(leftBranchPanelHeight + (event.key === "ArrowDown" ? 20 : -20)); - persistLeftBranchPanelHeight(leftBranchPanelHeight); - } - - function startLeftStashPanelResize(event: PointerEvent) { - if (stashPanelCollapsed && branchPanelCollapsed) return; - event.preventDefault(); - resizingLeftStashPanel = true; - leftStashResizeStartY = event.clientY; - leftStashResizeStartHeight = stashPanelCollapsed ? leftBranchPanelHeight : leftStashPanelHeight; - (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); - } - - function onLeftStashPanelResizeMove(event: PointerEvent) { - if (!resizingLeftStashPanel) return; - if (stashPanelCollapsed) { - leftBranchPanelHeight = clampLeftBranchPanelHeight(leftStashResizeStartHeight + (event.clientY - leftStashResizeStartY)); - } else { - leftStashPanelHeight = clampLeftStashPanelHeight(leftStashResizeStartHeight + (event.clientY - leftStashResizeStartY)); + localStorage.setItem(SIDEBAR_PANEL_HEIGHTS_KEY, JSON.stringify(sidebarPanelHeights)); + } catch { + // Local storage is best-effort only; resizing must keep working without it. } } - function endLeftStashPanelResize(event: PointerEvent) { - if (!resizingLeftStashPanel) return; - resizingLeftStashPanel = false; - if (stashPanelCollapsed) persistLeftBranchPanelHeight(leftBranchPanelHeight); - else persistLeftStashPanelHeight(leftStashPanelHeight); + function sidebarPanelIsCollapsed(panel: SidebarPanelId): boolean { + if (panel === "branch") return branchPanelCollapsed; + if (panel === "worktree") return worktreePanelCollapsed; + if (panel === "tags") return tagsPanelCollapsed; + if (panel === "stash") return stashPanelCollapsed; + return explorerPanelCollapsed; + } + + /** Expanded panels, top to bottom. The last one always fills the leftover space. */ + function expandedSidebarPanels(): SidebarPanelId[] { + return SIDEBAR_PANEL_ORDER.filter((panel) => !sidebarPanelIsCollapsed(panel)); + } + + /** The first expanded panel below `panel` — the one that gives way while dragging. */ + function nextExpandedSidebarPanel(panel: SidebarPanelId): SidebarPanelId | null { + const expanded = expandedSidebarPanels(); + const index = expanded.indexOf(panel); + return index >= 0 && index < expanded.length - 1 ? expanded[index + 1] : null; + } + + /** A handle only makes sense between two expanded panels. */ + function sidebarHandleVisible(panel: SidebarPanelId, ...markers: boolean[]): boolean { + void markers; + return !sidebarPanelIsCollapsed(panel) && nextExpandedSidebarPanel(panel) !== null; + } + + function buildLeftSidebarRows( + branchCollapsed: boolean, + worktreeCollapsed: boolean, + tagsCollapsed: boolean, + stashCollapsed: boolean, + explorerCollapsed: boolean, + heights: Record, + ): string { + void branchCollapsed; void worktreeCollapsed; void tagsCollapsed; void stashCollapsed; void explorerCollapsed; + + const expanded = expandedSidebarPanels(); + const flexible = expanded[expanded.length - 1]; + const rows: string[] = []; + + for (const panel of SIDEBAR_PANEL_ORDER) { + if (sidebarPanelIsCollapsed(panel)) rows.push("auto"); + else if (panel === flexible) rows.push(`minmax(${SIDEBAR_PANEL_MIN_HEIGHT[panel]}px, 1fr)`); + else rows.push(`${heights[panel]}px`); + + if (panel !== "explorer") rows.push(sidebarHandleVisible(panel) ? "8px" : "0"); + } + + return rows.join(" "); + } + + function startSidebarPanelResize(event: PointerEvent, panel: SidebarPanelId) { + const below = nextExpandedSidebarPanel(panel); + if (!below) return; + event.preventDefault(); + resizingSidebarPanel = panel; + sidebarResizeBelow = below; + sidebarResizeStartY = event.clientY; + sidebarResizeAboveStart = sidebarPanelHeights[panel]; + sidebarResizeBelowStart = sidebarPanelHeights[below]; + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + } + + function onSidebarPanelResizeMove(event: PointerEvent) { + const panel = resizingSidebarPanel; + if (!panel) return; + applySidebarPanelResize(panel, event.clientY - sidebarResizeStartY); + } + + /** + * Move the border by `delta`: the panel above grows, the neighbour below + * shrinks by the same amount. The last expanded panel is sized with 1fr, so + * it follows on its own and only the panel above needs a new height. + */ + function applySidebarPanelResize(panel: SidebarPanelId, delta: number) { + const below = sidebarResizeBelow; + const expanded = expandedSidebarPanels(); + const belowIsFlexible = below !== null && below === expanded[expanded.length - 1]; + + if (below === null) return; + + if (belowIsFlexible) { + sidebarPanelHeights = { + ...sidebarPanelHeights, + [panel]: clampSidebarPanelHeight(panel, sidebarResizeAboveStart + delta), + }; + return; + } + + const maxGrow = sidebarResizeBelowStart - SIDEBAR_PANEL_MIN_HEIGHT[below]; + const maxShrink = sidebarResizeAboveStart - SIDEBAR_PANEL_MIN_HEIGHT[panel]; + const applied = Math.max(-maxShrink, Math.min(maxGrow, Math.round(delta))); + + sidebarPanelHeights = { + ...sidebarPanelHeights, + [panel]: clampSidebarPanelHeight(panel, sidebarResizeAboveStart + applied), + [below]: clampSidebarPanelHeight(below, sidebarResizeBelowStart - applied), + }; + } + + function endSidebarPanelResize(event: PointerEvent) { + if (!resizingSidebarPanel) return; + resizingSidebarPanel = null; + sidebarResizeBelow = null; + persistSidebarPanelHeights(); const target = event.currentTarget as HTMLElement; if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId); } - function onLeftStashPanelResizeKeydown(event: KeyboardEvent) { - if ((stashPanelCollapsed && branchPanelCollapsed) || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return; - event.preventDefault(); - if (stashPanelCollapsed) { - leftBranchPanelHeight = clampLeftBranchPanelHeight(leftBranchPanelHeight + (event.key === "ArrowDown" ? 20 : -20)); - persistLeftBranchPanelHeight(leftBranchPanelHeight); - } else { - leftStashPanelHeight = clampLeftStashPanelHeight(leftStashPanelHeight + (event.key === "ArrowDown" ? 20 : -20)); - persistLeftStashPanelHeight(leftStashPanelHeight); + function onSidebarPanelResizeKeydown(event: KeyboardEvent, panel: SidebarPanelId) { + const below = nextExpandedSidebarPanel(panel); + if (!below) return; + + if (event.key === "Home") { + event.preventDefault(); + resetSidebarPanelHeight(panel); + return; } + + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; + event.preventDefault(); + + sidebarResizeBelow = below; + sidebarResizeAboveStart = sidebarPanelHeights[panel]; + sidebarResizeBelowStart = sidebarPanelHeights[below]; + applySidebarPanelResize(panel, event.key === "ArrowDown" ? SIDEBAR_RESIZE_STEP : -SIDEBAR_RESIZE_STEP); + sidebarResizeBelow = null; + persistSidebarPanelHeights(); + } + + /** Double-click on a handle puts the panel above back to its default height. */ + function resetSidebarPanelHeight(panel: SidebarPanelId) { + const below = nextExpandedSidebarPanel(panel); + sidebarPanelHeights = { ...sidebarPanelHeights, [panel]: SIDEBAR_PANEL_DEFAULT_HEIGHT[panel] }; + if (below) sidebarPanelHeights = { ...sidebarPanelHeights, [below]: SIDEBAR_PANEL_DEFAULT_HEIGHT[below] }; + persistSidebarPanelHeights(); } function toggleBranchPanelCollapsed() { @@ -3277,9 +3317,21 @@ return; } - const scope = remoteFolder ? "remote" : "local"; - const currentNote = currentBranchKept ? "\n\nThe current branch will be kept." : ""; - if (!window.confirm(`Delete ${deletableBranches.length} ${scope} branches in “${folderName}”?${currentNote}`)) return; + const notes = [ + currentBranchKept ? t("confirm.branchFolder.noteCurrent") : "", + remoteFolder ? t("confirm.branchFolder.noteRemote") : "", + ].filter(Boolean); + const folderConfirmed = await requestConfirmation({ + title: deletableBranches.length === 1 + ? t("confirm.branchFolder.titleOne") + : t("confirm.branchFolder.title", { count: deletableBranches.length }), + message: deletableBranches.length === 1 + ? t(remoteFolder ? "confirm.branchFolder.messageRemoteOne" : "confirm.branchFolder.messageLocalOne", { folder: folderName }) + : t(remoteFolder ? "confirm.branchFolder.messageRemote" : "confirm.branchFolder.messageLocal", { folder: folderName }), + items: deletableBranches.map((branch) => branch.name), + note: notes.join(" ") || undefined, + }); + if (!folderConfirmed) return; const repoPath = activeRepoPath; if (remoteFolder) { @@ -3379,6 +3431,53 @@ deleteBranchForce = false; } + async function withSubmoduleCredentials(url: string, task: (username?: string, password?: string) => Promise) { + const key = orgKeyFromUrl(url); + let credential = key && !rejectedCredentialKeys.has(key) ? await loadStoredCredential(key) : null; + while (true) { + try { + await task(credential?.username, credential?.password); + if (key) rejectedCredentialKeys.delete(key); + return; + } catch (error) { + const message = errorToMessage(error); + if (!isAuthError(message)) throw error; + if (credential && key) rejectedCredentialKeys.add(key); + submoduleAuthError = stripAuthPrefix(message); + const previous = credential; + credential = await new Promise(resolve => { + submoduleAuthRequest = { url, key, credential: previous, resolve }; + }); + if (!credential) throw new Error(appLanguage === "de" ? "Submodule-Anmeldung abgebrochen." : "Submodule sign-in cancelled."); + } + } + } + + async function submitSubmoduleCredential(username: string, password: string, save: boolean, mode: CredentialMode) { + const request = submoduleAuthRequest; + if (!request || submoduleAuthSaving) return; + submoduleAuthSaving = true; + try { + const credential = { username, password, mode }; + if (save && request.key) { + await credSave(request.key, username, password, mode); + credentialCache.set(request.key, credential); + } + submoduleAuthRequest = null; + submoduleAuthError = ""; + request.resolve(credential); + } catch (error) { submoduleAuthError = errorToMessage(error); } + finally { submoduleAuthSaving = false; } + } + + function cancelSubmoduleCredential() { + if (submoduleAuthSaving) return; + const request = submoduleAuthRequest; + submoduleAuthRequest = null; + submoduleAuthError = ""; + request?.resolve(null); + } + async function refreshSubmoduleNotice(path: string): Promise { const request = ++submoduleNoticeRequest; try { @@ -3405,7 +3504,7 @@ submoduleInitializationError = ""; try { for (const module of pending.modules) { - await submoduleAction(pending.repoPath, module.path, "initialize", true); + await withSubmoduleCredentials(module.url, (username, password) => submoduleAction(pending.repoPath, module.path, "initialize", true, username, password)); } pendingSubmoduleInitialization = null; } catch (error) { @@ -3777,7 +3876,11 @@ async function abortRebase() { if (!activeRepoPath || !rebaseInProgress) return; - const confirmed = window.confirm("Abort the current rebase and return to the previous state?"); + const confirmed = await requestConfirmation({ + title: t("confirm.rebaseAbort.title"), + message: t("confirm.rebaseAbort.message"), + confirmLabel: t("confirm.rebaseAbort.action"), + }); if (!confirmed) return; await runOperation("Aborting rebase", async () => { @@ -3944,7 +4047,11 @@ async function deleteLocalTag(tag: GitTag) { if (!activeRepoPath || isBusy) return; - const confirmed = window.confirm(`Delete tag '${tag.name}'?\n\nThis only removes the local tag, not any copy already pushed to a remote.`); + const confirmed = await requestConfirmation({ + title: t("confirm.tagDelete.title", { name: tag.name }), + message: t("confirm.tagDelete.message"), + note: t("confirm.tagDelete.note"), + }); if (!confirmed) return; await runOperation(`Deleting tag ${tag.name}`, async () => { @@ -4137,7 +4244,11 @@ async function abortCherryPick() { if (!activeRepoPath || !cherryPickInProgress) return; - const confirmed = window.confirm("Abort the current cherry-pick and return to the previous state?"); + const confirmed = await requestConfirmation({ + title: t("confirm.cherryPickAbort.title"), + message: t("confirm.cherryPickAbort.message"), + confirmLabel: t("confirm.cherryPickAbort.action"), + }); if (!confirmed) return; await runOperation("Aborting cherry-pick", async () => { @@ -4315,9 +4426,13 @@ } errorMessage = ""; - const confirmed = window.confirm(appLanguage === "de" - ? "Das lokale und das entfernte Repository besitzen getrennte Commit-Historien.\n\nTrotzdem zusammenführen? Dabei können Merge-Konflikte entstehen." - : "The local and remote repositories have separate commit histories.\n\nMerge them anyway? This may produce merge conflicts."); + const confirmed = await requestConfirmation({ + title: t("confirm.unrelatedHistories.title"), + message: t("confirm.unrelatedHistories.message"), + note: t("confirm.unrelatedHistories.note"), + confirmLabel: t("confirm.unrelatedHistories.action"), + danger: false, + }); if (!confirmed) { errorMessage = appLanguage === "de" ? "Pull abgebrochen: Die getrennten Historien wurden nicht verändert." @@ -4381,9 +4496,13 @@ if (errorMessage && !isAuthError(errorMessage) && isNonFastForwardPushError(errorMessage)) { errorMessage = ""; - const shouldSync = window.confirm( - "The remote has newer commits, so the push was rejected.\n\nRun Pull/Merge now and try pushing again afterwards?", - ); + const shouldSync = await requestConfirmation({ + title: t("confirm.pushRejected.title"), + message: t("confirm.pushRejected.message"), + note: t("confirm.pushRejected.note"), + confirmLabel: t("confirm.pushRejected.action"), + danger: false, + }); if (!shouldSync) { const message = "Push rejected: the remote has newer commits. Pull first, then push again."; @@ -4569,7 +4688,15 @@ } async function revertHistoryCommit(commit: GitCommit) { - if (!activeRepoPath || !window.confirm(`Revert commit ${commit.short_hash} (${commit.summary}) with a new commit?`)) return; + if (!activeRepoPath) return; + const confirmed = await requestConfirmation({ + title: t("confirm.revert.title", { hash: commit.short_hash }), + message: t("confirm.revert.message"), + items: [commit.summary], + confirmLabel: t("confirm.revert.action"), + danger: false, + }); + if (!confirmed) return; await runOperation(`Reverting ${commit.short_hash}`, async () => { applyStatus(await revertCommit(activeRepoPath, commit.hash)); await refreshRepositoryViews(activeRepoPath, { branches: false }); @@ -4585,7 +4712,13 @@ } async function abortMerge() { - if (!activeRepoPath || !window.confirm("Abort the current merge and restore the pre-merge state?")) return; + if (!activeRepoPath) return; + const confirmed = await requestConfirmation({ + title: t("confirm.mergeAbort.title"), + message: t("confirm.mergeAbort.message"), + confirmLabel: t("confirm.mergeAbort.action"), + }); + if (!confirmed) return; await runOperation("Aborting merge", async () => { applyStatus(await mergeAbort(activeRepoPath)); await refreshRepositoryViews(activeRepoPath); @@ -4598,7 +4731,13 @@ } async function forcePushRepo() { - if (!window.confirm("Push the current branch with --force-with-lease? This is intended for a branch whose history you rebased.")) return; + const confirmed = await requestConfirmation({ + title: t("confirm.forcePush.title"), + message: t("confirm.forcePush.message"), + note: t("confirm.forcePush.note"), + confirmLabel: t("confirm.forcePush.action"), + }); + if (!confirmed) return; remoteActionForceWithLease = true; await startRemoteAction("push"); } @@ -4664,8 +4803,34 @@ } async function stashStatusFiles(files: GitFileStatus[], label: string) { - const suffix = files.length === 1 ? files[0].path : `${label} (${files.length} files)`; - await saveStash(`Gitty: ${suffix}`, true, files); + if (files.length === 0) return; + const suffix = files.length === 1 + ? files[0].path + : label ? `${label} (${files.length} files)` : `${files.length} files`; + const fallbackMessage = `Gitty: ${suffix}`; + + const answer = await askConfirmation({ + eyebrow: t("confirm.stashFiles.eyebrow"), + title: files.length === 1 ? t("confirm.stashFiles.titleOne") : t("confirm.stashFiles.title", { count: files.length }), + message: t("confirm.stashFiles.message"), + items: files.map((file) => file.path), + input: { + label: t("confirm.stashFiles.inputLabel"), + placeholder: fallbackMessage, + value: fallbackMessage, + optional: true, + }, + checkbox: { + label: t("confirm.stashFiles.untracked"), + note: t("confirm.stashFiles.untrackedNote"), + defaultChecked: true, + }, + confirmLabel: t("stashes.save"), + danger: false, + }); + + if (!answer.confirmed) return; + await saveStash(answer.value.trim() || fallbackMessage, answer.checked, files); } async function applyStashEntry(stash: GitStash) { @@ -4696,7 +4861,12 @@ async function dropStashEntry(stash: GitStash) { if (!activeRepoPath) return; - const confirmed = window.confirm(`Delete ${stash.selector}?\n\n"${stash.message || stash.selector}"`); + const confirmed = await requestConfirmation({ + title: t("confirm.stashDrop.title", { name: stash.selector }), + message: t("confirm.stashDrop.message"), + items: [stash.message || stash.selector], + note: t("confirm.stashDrop.note"), + }); if (!confirmed) return; await runOperation(`Dropping ${stash.selector}`, async () => { @@ -4746,12 +4916,13 @@ }); } - async function stopTrackingTarget(target: string, kind: "file" | "folder") { - if (!activeRepoPath || !target) return; + async function stopTrackingTarget(targets: string[], kind: "file" | "folder" | "selection") { + const paths = targets.filter(Boolean); + if (!activeRepoPath || paths.length === 0) return; await runOperation(`Stopping tracking for ${kind}`, async () => { - applyStatus(await untrackPaths(activeRepoPath, [target])); + applyStatus(await untrackPaths(activeRepoPath, paths)); await refreshExplorerFiles(activeRepoPath); - trackEvent("git_paths_untracked", { kind }); + trackEvent("git_paths_untracked", { kind, paths: paths.length }); }); } @@ -5044,9 +5215,13 @@ async function undoLastCommitChange() { if (!activeRepoPath || !canAmend || isBusy) return; - const confirmed = window.confirm( - "Undo the last commit?\n\nIts changes remain staged, ready to commit again. Your working tree files are preserved.", - ); + const confirmed = await requestConfirmation({ + title: t("confirm.undoCommit.title"), + message: t("confirm.undoCommit.message"), + note: t("confirm.undoCommit.note"), + confirmLabel: t("confirm.undoCommit.action"), + danger: false, + }); if (!confirmed) return; await runOperation("Undoing last commit", async () => { @@ -5065,7 +5240,13 @@ async function restoreCommit(target: GitCommit) { if (!activeRepoPath) return; - const confirmed = window.confirm(`Restore working tree to ${target.short_hash}?\n\nThis brings back the files from that commit as unstaged changes so you can review and commit them. No commit is removed and the branch stays where it is.`); + const confirmed = await requestConfirmation({ + title: t("confirm.restoreTree.title", { hash: target.short_hash }), + message: t("confirm.restoreTree.message"), + note: t("confirm.restoreTree.note"), + confirmLabel: t("confirm.restoreTree.action"), + danger: false, + }); if (!confirmed) return; await runOperation(`Restoring ${target.short_hash}`, async () => { applyStatus(await restoreToCommit(activeRepoPath, target.hash)); @@ -5076,7 +5257,12 @@ async function restoreCommitFile(target: GitCommit, file: GitCommitFile): Promise { if (!activeRepoPath) return false; - const confirmed = window.confirm(`Restore ${file.path} from ${target.short_hash}?\n\nThis changes the file in your working tree so you can review and commit it.`); + const confirmed = await requestConfirmation({ + title: t("confirm.restoreFile.title", { path: file.path, hash: target.short_hash }), + message: t("confirm.restoreFile.message"), + confirmLabel: t("confirm.restoreTree.action"), + danger: false, + }); if (!confirmed) return false; await runOperation(`Restoring ${file.path}`, async () => { applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, file.path)); @@ -5355,7 +5541,12 @@ async function restoreSelectedFileFromCommit(target: GitCommit) { if (!activeRepoPath || !selectedExplorerPath) return; const kind = selectedExplorerKind === "folder" ? "folder" : "file"; - const confirmed = window.confirm(`Restore ${kind} ${selectedExplorerPath} from ${target.short_hash}?\n\nThis changes the selected ${kind} in your working tree so you can review and commit it.`); + const confirmed = await requestConfirmation({ + title: t(kind === "folder" ? "confirm.restoreFile.titleFolder" : "confirm.restoreFile.title", { path: selectedExplorerPath, hash: target.short_hash }), + message: t("confirm.restoreFile.message"), + confirmLabel: t("confirm.restoreTree.action"), + danger: false, + }); if (!confirmed) return; await runOperation(`Restoring ${selectedExplorerPath}`, async () => { applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, selectedExplorerPath)); @@ -5947,8 +6138,9 @@ class:stash-collapsed={stashPanelCollapsed} class:explorer-collapsed={explorerPanelCollapsed} class:all-collapsed={allLeftPanelsCollapsed} + class:resizing-panels={resizingSidebarPanel !== null} aria-label="Repository navigation" - style="--tags-panel-height: {leftTagsPanelHeight}px; --worktree-panel-height: {leftWorktreePanelHeight}px; --branch-panel-height: {leftBranchPanelHeight}px; --stash-panel-height: {leftStashPanelHeight}px; grid-template-rows: {leftSidebarRows};" + style="grid-template-rows: {leftSidebarRows};" > - {#if !branchPanelCollapsed} + {#if sidebarHandleVisible("branch", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed)} {:else} @@ -6006,20 +6200,26 @@ onManage={() => { void openWorktreeDialog(); }} onRefresh={() => { void refreshWorktrees(); }} /> - {#if !worktreePanelCollapsed} + {#if sidebarHandleVisible("worktree", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed)} {:else} @@ -6035,20 +6235,26 @@ onDeleteTag={deleteLocalTag} onPushTag={pushLocalTag} /> - {#if !tagsPanelCollapsed} + {#if sidebarHandleVisible("tags", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed)} {:else} @@ -6065,24 +6271,26 @@ collapsed={stashPanelCollapsed} onToggleCollapsed={toggleStashPanelCollapsed} /> - {#if !explorerPanelCollapsed && !stashPanelCollapsed} + {#if sidebarHandleVisible("stash", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed)} {:else} @@ -6430,14 +6638,20 @@ {/await} {/if} +{#if confirmDialogRequest} + answerConfirmation(true, result.value, result.checked)} + onCancel={() => answerConfirmation(false)} + /> +{/if} + {#if pendingDiscard} - {/if} @@ -6572,12 +6786,11 @@ {#if deleteBranchTarget} - {/if} @@ -6764,12 +6977,14 @@ {#if submoduleDialogOpen} {#await import("./lib/components/SubmoduleDialog.svelte") then module} { submoduleRecursive = value; void refreshSubmodules(); }} onRefresh={refreshSubmodules} onClose={closeSubmoduleDialog} onOpen={openSubmoduleTab} - onAdd={(url, destination, branch) => runSubmoduleOperation(path => addSubmodule(path, url, destination, branch))} - onAction={(selected, action) => runSubmoduleOperation(path => submoduleAction(path, selected.path, action, submoduleRecursive))} + onLoadTags={(selected) => listTags(selected.full_path)} + onCheckout={(selected, revision, kind) => runSubmoduleOperation(path => checkoutSubmoduleRevision(path, selected.path, revision, kind))} + onAdd={(url, destination, branch) => runSubmoduleOperation(path => withSubmoduleCredentials(url, (username, password) => addSubmodule(path, url, destination, branch, username, password)))} + onAction={(selected, action) => runSubmoduleOperation(path => (action === "update" || action === "fetch") ? withSubmoduleCredentials(selected.url, (username, password) => submoduleAction(path, selected.path, action, submoduleRecursive, username, password)) : submoduleAction(path, selected.path, action, submoduleRecursive))} /> {/await} {/if} @@ -6780,3 +6995,10 @@ error={submoduleInitializationError} onInitialize={initializePendingSubmodules} onLater={dismissSubmoduleInitialization} /> {/await} {/if} + +{#if submoduleAuthRequest} + +{/if} diff --git a/src/app.css b/src/app.css index a6b338a..cb3f754 100644 --- a/src/app.css +++ b/src/app.css @@ -3,7 +3,7 @@ @theme { --color-ink: #f0f1f2; --color-ink-muted: #c1c3c6; - --color-ink-faint: #7f8389; + --color-ink-faint: #92979e; --color-ink-dim: #9da1a6; --color-ink-quiet: #aeb1b5; @@ -73,9 +73,9 @@ :root[data-theme="light"] { --color-ink: #172033; --color-ink-muted: #475569; - --color-ink-faint: #728098; - --color-ink-dim: #5f6f89; - --color-ink-quiet: #66758d; + --color-ink-faint: #647287; + --color-ink-dim: #52607a; + --color-ink-quiet: #5c6a80; --color-surface: rgba(255, 255, 255, 0.86); --color-surface-alt: #f3f6fb; @@ -90,7 +90,7 @@ --color-primary: #315fd6; --color-primary-dark: #284cb4; - --color-accent: #0f8fb5; + --color-accent: #0c7691; --color-bar: rgba(248, 251, 255, 0.94); --color-bar-text: #172033; @@ -141,11 +141,7 @@ html, body, #app { width: 100%; height: 100%; margin: 0; } - * { scrollbar-width: thin; scrollbar-color: var(--app-scrollbar-thumb) transparent; } - ::-webkit-scrollbar { width: 5px; height: 5px; } - ::-webkit-scrollbar-track { background: transparent; } - ::-webkit-scrollbar-thumb { background: var(--app-scrollbar-thumb); border-radius: 3px; } - ::-webkit-scrollbar-thumb:hover { background: var(--app-scrollbar-thumb-hover); } + /* Scrollbars are styled app-wide at the end of this file. */ html { color-scheme: var(--app-color-scheme); } body { @@ -302,9 +298,9 @@ text-align: center; } .select-menu-option { - display: grid; - grid-template-columns: minmax(0, 1fr) 14px; + display: flex; align-items: center; + gap: 8px; justify-content: initial; width: 100%; min-height: 30px; @@ -318,6 +314,29 @@ text-align: left; } .select-menu-option > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .select-menu-option-label { flex: 1 1 auto; min-width: 0; } + .select-menu-option-icon { + display: grid; + flex: 0 0 auto; + place-items: center; + width: 18px; + overflow: visible; + color: var(--color-ink-faint); + } + .select-menu-option.selected .select-menu-option-icon, + .select-menu-option:hover .select-menu-option-icon { color: var(--color-accent); } + .select-menu-option-icon svg { color: inherit; } + .select-menu-option-meta { + display: inline-flex; + align-items: center; + gap: 5px; + flex: 0 0 auto; + color: var(--color-ink-faint); + font-size: 9.5px; + font-weight: 650; + letter-spacing: 0; + } + .select-menu-option-meta svg { color: inherit; } .select-menu-option svg { color: var(--color-primary); } .select-menu-option:hover:not(:disabled), .select-menu-option.active:not(:disabled) { border-color: var(--color-border-subtle); @@ -1717,16 +1736,18 @@ } .left-sidebar { + /* The row template is built in App.svelte from the panel heights. */ display: grid; - grid-template-rows: minmax(180px, var(--branch-panel-height, 260px)) 14px minmax(150px, var(--stash-panel-height, 190px)) 14px minmax(220px, 1fr); align-content: start; min-width: 0; min-height: 0; gap: 0; + overflow-x: hidden; + overflow-y: auto; } .left-panel-resize-handle { - min-height: 14px; + min-height: 8px; margin: 0; } @@ -1736,34 +1757,12 @@ min-width: 0; } - .left-sidebar.branch-collapsed { - grid-template-rows: auto 0 minmax(150px, var(--stash-panel-height, 190px)) 14px minmax(220px, 1fr); - } - .left-sidebar.explorer-collapsed { - grid-template-rows: minmax(180px, var(--branch-panel-height, 260px)) 14px minmax(150px, var(--stash-panel-height, 190px)) 0 auto; - } - .left-sidebar.branch-collapsed.explorer-collapsed { - grid-template-rows: auto 0 minmax(150px, var(--stash-panel-height, 190px)) 0 auto; - } - .left-sidebar:has(.stash-panel.collapsed) { - grid-template-rows: minmax(180px, var(--branch-panel-height, 260px)) 14px auto 0 minmax(220px, 1fr); - } - .left-sidebar.branch-collapsed:has(.stash-panel.collapsed) { - grid-template-rows: auto 0 auto 0 minmax(220px, 1fr); - } - .left-sidebar.explorer-collapsed:has(.stash-panel.collapsed) { - grid-template-rows: minmax(180px, var(--branch-panel-height, 260px)) 14px auto 0 auto; - } - .left-sidebar.branch-collapsed.explorer-collapsed:has(.stash-panel.collapsed) { - grid-template-rows: auto 0 auto 0 auto; - align-content: start; - } /* --- Main panel --- */ @@ -6179,7 +6178,7 @@ :root[data-theme="light"] .btn-primary { border-color: rgba(49, 95, 214, 0.72); - background: linear-gradient(135deg, #315fd6 0%, #0f8fb5 100%); + background: linear-gradient(135deg, #315fd6 0%, #0c7691 100%); box-shadow: 0 10px 24px rgba(49, 95, 214, 0.18); } @@ -7370,7 +7369,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s :root[data-theme="light"] .cred-hero-icon { border-color: rgba(49,95,214,0.2); color: #ffffff; - background: linear-gradient(135deg, #4d8dff, #0f8fb5); + background: linear-gradient(135deg, #4d8dff, #0c7691); box-shadow: 0 12px 26px rgba(49,95,214,0.2), inset 0 1px 0 rgba(255,255,255,0.24); } @@ -7384,7 +7383,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s background: rgba(15,143,181,0.07); } -:root[data-theme="light"] .cred-security-note svg { color: #0f8fb5; } +:root[data-theme="light"] .cred-security-note svg { color: #0c7691; } :root[data-theme="light"] .cred-body { background: #ffffff; @@ -7512,31 +7511,6 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s .history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr); row-gap: 0; } .history-resize-handle { display: none; } .shell-body { gap: 6px; } - .left-sidebar { - grid-template-rows: minmax(180px, var(--branch-panel-height, 230px)) 14px minmax(150px, var(--stash-panel-height, 170px)) 14px minmax(220px, 1fr); - } - .left-sidebar:has(.stash-panel.collapsed) { - grid-template-rows: minmax(180px, var(--branch-panel-height, 230px)) 14px auto 0 minmax(220px, 1fr); - } - .left-sidebar.branch-collapsed { - grid-template-rows: auto 0 minmax(150px, var(--stash-panel-height, 170px)) 14px minmax(220px, 1fr); - } - .left-sidebar.explorer-collapsed { - grid-template-rows: minmax(180px, var(--branch-panel-height, 230px)) 14px minmax(150px, var(--stash-panel-height, 170px)) 0 auto; - } - .left-sidebar.branch-collapsed:has(.stash-panel.collapsed) { - grid-template-rows: auto 0 auto 0 minmax(220px, 1fr); - } - .left-sidebar.explorer-collapsed:has(.stash-panel.collapsed) { - grid-template-rows: minmax(180px, var(--branch-panel-height, 230px)) 14px auto 0 auto; - } - .left-sidebar.branch-collapsed.explorer-collapsed { - grid-template-rows: auto 0 minmax(150px, var(--stash-panel-height, 170px)) 0 auto; - } - .left-sidebar.branch-collapsed.explorer-collapsed:has(.stash-panel.collapsed) { - grid-template-rows: auto 0 auto 0 auto; - align-content: start; - } .section-head { min-height: 40px; padding: 6px 10px; } .repo-summary { height: 40px; padding: 0 10px; } .repo-branch { max-width: 160px; } @@ -7566,32 +7540,6 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s .file-history-dialog-row time { display: none; } .file-history-dialog-actions { display: grid; } .file-history-dialog-actions button { width: 32px; min-width: 32px; padding: 0; overflow: hidden; color: var(--color-ink-muted); font-size: 0; gap: 0; } - .left-sidebar { - grid-template-rows: minmax(180px, var(--branch-panel-height, 240px)) 14px minmax(150px, var(--stash-panel-height, 180px)) 14px minmax(220px, 1fr); - min-height: 560px; - } - .left-sidebar:has(.stash-panel.collapsed) { - grid-template-rows: minmax(180px, var(--branch-panel-height, 240px)) 14px auto 0 minmax(220px, 1fr); - } - .left-sidebar.branch-collapsed { - grid-template-rows: auto 0 minmax(150px, var(--stash-panel-height, 180px)) 14px minmax(220px, 1fr); - } - .left-sidebar.explorer-collapsed { - grid-template-rows: minmax(120px, var(--branch-panel-height, 240px)) 14px minmax(150px, var(--stash-panel-height, 180px)) 0 auto; - } - .left-sidebar.branch-collapsed:has(.stash-panel.collapsed) { - grid-template-rows: auto 0 auto 0 minmax(0, 1fr); - } - .left-sidebar.explorer-collapsed:has(.stash-panel.collapsed) { - grid-template-rows: minmax(120px, var(--branch-panel-height, 240px)) 14px auto 0 auto; - } - .left-sidebar.branch-collapsed.explorer-collapsed { - grid-template-rows: auto 0 minmax(150px, var(--stash-panel-height, 180px)) 0 auto; - } - .left-sidebar.branch-collapsed.explorer-collapsed:has(.stash-panel.collapsed) { - grid-template-rows: auto 0 auto 0 auto; - align-content: start; - } .top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); } .repo-form { grid-template-columns: 1fr; } .repo-tabbar { grid-template-columns: auto minmax(0, 1fr) auto; } @@ -8384,8 +8332,8 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s --app-settings-row-bg: #f8fafd; --color-ink: #172033; --color-ink-muted: #475569; - --color-ink-faint: #728098; - --color-ink-dim: #5f6f89; + --color-ink-faint: #647287; + --color-ink-dim: #52607a; --color-surface: #ffffff; --color-surface-alt: #f7f8fa; --color-surface-dim: #f8f9fb; @@ -8465,7 +8413,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s :root[data-theme="light"][data-appearance="classic"] .btn-primary { border-color: rgba(49, 95, 214, 0.72); color: #ffffff; - background: linear-gradient(135deg, #315fd6 0%, #0f8fb5 100%); + background: linear-gradient(135deg, #315fd6 0%, #0c7691 100%); box-shadow: 0 10px 24px rgba(49, 95, 214, 0.18); } @@ -9176,6 +9124,10 @@ section > header.page-header.page-header { /* Compact accordion navigation (layout A). */ .left-sidebar { overflow-y: auto; overflow-x: hidden; } +/* While a border is being dragged, keep the cursor and stop text selection. */ +.left-sidebar.resizing-panels { cursor: row-resize; user-select: none; } +.left-sidebar.resizing-panels * { pointer-events: none; } +.left-sidebar.resizing-panels .panel-resize-handle { pointer-events: auto; } .left-sidebar > .panel { min-width: 0; } .left-sidebar .section-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; @@ -9197,7 +9149,7 @@ section > header.page-header.page-header { .left-sidebar :is(.branch-create-toggle, .stash-toggle, .explorer-bulk-button):hover:not(:disabled) { background: var(--color-surface-hover); color: var(--color-ink); } -.left-sidebar .left-panel-resize-handle { min-height: 6px; } +.left-sidebar .left-panel-resize-handle { min-height: 8px; } .left-sidebar .branch-list, .left-sidebar .explorer-list { padding: 4px 0; } .left-sidebar .branch-group-toggle { min-height: 30px; padding: 5px 10px; border-radius: 0; @@ -9219,8 +9171,6 @@ section > header.page-header.page-header { .left-sidebar .stash-input { grid-column: 1 / -1; } .left-sidebar .explorer-tool-action, .left-sidebar .explorer-action-divider { display: none; } /* File actions remain available through the file context menu. */ -.left-sidebar .worktree-panel { display: grid; grid-template-rows: 42px minmax(0, 1fr); min-height: 0; overflow: hidden; } -.left-sidebar .worktree-panel.collapsed { grid-template-rows: 42px; } .sidebar-worktree-list { min-height: 0; overflow: auto; padding: 4px 0; } .sidebar-worktree-row { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 46px; @@ -9248,8 +9198,33 @@ section > header.page-header.page-header { .left-sidebar .section-head button svg { width: 14px; height: 14px; stroke-width: 1.75; } .left-sidebar .section-head .pill-count { min-width: 24px; justify-content: center; } -.left-sidebar .tags-panel { display: grid; grid-template-rows: 42px minmax(0, 1fr); min-height: 0; overflow: hidden; } -.left-sidebar .tags-panel.collapsed { grid-template-rows: 42px; } .sidebar-tags-list { min-height: 0; overflow: auto; padding: 4px 0; } .left-sidebar .tags-panel .tag-create-form { grid-template-columns: auto minmax(0, 1fr) auto auto; margin: 4px 8px; } .left-sidebar .tags-panel .tag-create-form input[aria-label="Tag message"] { grid-column: 2 / -1; grid-row: 2; } + + +/* --- Scrollbars ----------------------------------------------------------- + One look everywhere: a slim, rounded thumb that brightens while the pointer + is over the scrolling area. scrollbar-width/color are reset to auto so + WebKit uses the pseudo-element styling below instead of the native bar. */ +:root, +* { + scrollbar-width: auto; + scrollbar-color: auto; +} +::-webkit-scrollbar { width: 8px; height: 8px; background: transparent; } +::-webkit-scrollbar-track { margin: 6px 0; background: transparent; } +::-webkit-scrollbar-corner { background: transparent; } +::-webkit-scrollbar-thumb { + min-height: 28px; + border: 2px solid transparent; + border-radius: 999px; + background-clip: padding-box; + background-color: color-mix(in srgb, var(--app-scrollbar-thumb) 55%, transparent); +} +:hover::-webkit-scrollbar-thumb { background-color: var(--app-scrollbar-thumb); } +::-webkit-scrollbar-thumb:hover, +::-webkit-scrollbar-thumb:active { background-color: var(--app-scrollbar-thumb-hover); } + +/* Tab strips keep their hidden scrollbars. */ +.repo-tabs-scroll { scrollbar-width: none; } diff --git a/src/lib/components/AiSettingsPage.svelte b/src/lib/components/AiSettingsPage.svelte index b178e40..03ac616 100644 --- a/src/lib/components/AiSettingsPage.svelte +++ b/src/lib/components/AiSettingsPage.svelte @@ -3,6 +3,7 @@ import { Bot, Eye, EyeOff, Globe, Key } from "@lucide/svelte"; import { credDelete, credLoad, credSave } from "../git"; import type { AiSettings, CommitAiProvider } from "../types"; + import { t } from "../i18n.svelte"; interface Props { settings: AiSettings; @@ -81,7 +82,7 @@ async function persistKey(target: CloudProvider, value: string) { if (value === originalKeys[target]) return; - if (!keysLoaded) throw new Error("API keys could not be loaded. Existing credentials have been preserved."); + if (!keysLoaded) throw new Error(t("ai.keysNotLoaded")); const key = CRED_KEYS[target]; const trimmed = value.trim(); if (trimmed) { @@ -92,7 +93,7 @@ } export async function saveSettings(): Promise { - if (loadingKeys) throw new Error("Please wait for AI settings to load."); + if (loadingKeys) throw new Error(t("ai.waitForSettings")); saving = true; error = ""; try { @@ -119,7 +120,7 @@
-
+
{#if provider === "openai"}
- API key + {t("ai.apiKey")}