From f71a07ab11493f94216e5ca1939cd1dbb0f40c06 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 17 Sep 2026 17:37:41 +0200 Subject: [PATCH 01/10] feat(branch-panel): add branch filtering, persistent view state, and UI polish - Add a text filter with highlight and clear (Esc to clear) that narrows visible branches and auto-opens folders while filtering. - Persist panel view state (local/remote open + collapsed folders) to localStorage under "gitlite.branchPanelView.v2" so folder open/collapse and section visibility survive reloads. - Reveal current branch action: clears filter, expands path to current branch, scrolls it into view and briefly flashes it. - Rework branch list rendering: unified folder/branch ids, scope-aware rows, tracking status computation (tracked/gone/local/remote-tracked), richer titles/tooltips, updated icons, and better context-menu positioning/behavior (separate showBranchMenuAt + open-from-button). - Prevent toggling folder collapse while filtering; folder rows are implicitly open when a filter is active. - Add slim, rounded custom scrollbars for the left sidebar in src/app.css. No external APIs changed; behavior is additive and intended to improve branch navigation and discoverability. --- src/app.css | 40 ++ src/lib/components/BranchPanel.svelte | 881 ++++++++++++++++++++------ 2 files changed, 719 insertions(+), 202 deletions(-) diff --git a/src/app.css b/src/app.css index a6b338a..5ebcbeb 100644 --- a/src/app.css +++ b/src/app.css @@ -9253,3 +9253,43 @@ section > header.page-header.page-header { .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; } + + +/* --- Sidebar scrollbars --------------------------------------------------- + Slim, rounded thumb that brightens while hovering a scroll area. + scrollbar-width/color are reset so WebKit uses the pseudo-element styling. + The panel lists are named explicitly so they win over any other rule. */ +.left-sidebar, +.left-sidebar *, +.left-sidebar .sidebar-tags-list, +.left-sidebar .sidebar-worktree-list, +.left-sidebar .stash-list, +.left-sidebar .explorer-list { + scrollbar-width: auto !important; + scrollbar-color: auto !important; +} +.left-sidebar ::-webkit-scrollbar, +.left-sidebar .sidebar-tags-list::-webkit-scrollbar, +.left-sidebar .sidebar-worktree-list::-webkit-scrollbar, +.left-sidebar .stash-list::-webkit-scrollbar, +.left-sidebar .explorer-list::-webkit-scrollbar { width: 8px !important; height: 8px !important; background: transparent; } +.left-sidebar ::-webkit-scrollbar-track, +.left-sidebar .sidebar-tags-list::-webkit-scrollbar-track, +.left-sidebar .sidebar-worktree-list::-webkit-scrollbar-track, +.left-sidebar .stash-list::-webkit-scrollbar-track, +.left-sidebar .explorer-list::-webkit-scrollbar-track { margin: 6px 0; background: transparent; } +.left-sidebar ::-webkit-scrollbar-corner { background: transparent; } +.left-sidebar ::-webkit-scrollbar-thumb, +.left-sidebar .sidebar-tags-list::-webkit-scrollbar-thumb, +.left-sidebar .sidebar-worktree-list::-webkit-scrollbar-thumb, +.left-sidebar .stash-list::-webkit-scrollbar-thumb, +.left-sidebar .explorer-list::-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); +} +.left-sidebar :hover::-webkit-scrollbar-thumb { background-color: var(--app-scrollbar-thumb); } +.left-sidebar ::-webkit-scrollbar-thumb:hover, +.left-sidebar ::-webkit-scrollbar-thumb:active { background-color: var(--app-scrollbar-thumb-hover); } diff --git a/src/lib/components/BranchPanel.svelte b/src/lib/components/BranchPanel.svelte index 2fadcdb..7f996f0 100644 --- a/src/lib/components/BranchPanel.svelte +++ b/src/lib/components/BranchPanel.svelte @@ -1,8 +1,31 @@ - + + +{#snippet branchRows(rows: BranchRow[])} + {#each rows as row (row.id)} + {#if row.kind === "folder"} + {@const open = isBranchFolderOpen(row.id)} + + {:else} + {@const tracking = trackingFor(row.branch)} + +
checkoutOnDoubleClick(event, row.branch)} + oncontextmenu={(event) => openBranchContextMenu(event, row.branch)} + title={branchTitle(row.branch)} + > + + + + {#each highlightParts(row.displayName) as part, index (index)} + {#if part.match}{part.text}{:else}{part.text}{/if} + {/each} + + + {#if tracking.kind === "tracked"} + + {:else if tracking.kind === "gone"} + + {:else if tracking.kind === "local"} + + {:else if tracking.kind === "remote-tracked"} + + {/if} + {#if row.branch.current} + HEAD + {/if} + + +
+ {/if} + {/each} +{/snippet}
@@ -387,165 +628,118 @@ {:else if !hasRepository}

Open a repository to list branches.

{:else} -
- {#if createOpen} -
-
+ + -- 2.54.0 From a9af02a69700226b71c907d56c70a012f1607c5e Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 17 Sep 2026 18:15:55 +0200 Subject: [PATCH 02/10] feat(repository-dashboard): add compact PR badge styles with hover/focus and error state Add CSS rules for the PR badge in RepositoryDashboard.svelte to provide a compact, padded hit area and a soft outlined hover/focus treatment (using color-mix) instead of a hard filled block. Sizes are tuned for list- and tiles-view, transitions are added for color/border/background, and a .pr-error hover variant applies an error accent. This is a purely presentational change. --- src/lib/components/RepositoryDashboard.svelte | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/lib/components/RepositoryDashboard.svelte b/src/lib/components/RepositoryDashboard.svelte index 6652248..7b70906 100644 --- a/src/lib/components/RepositoryDashboard.svelte +++ b/src/lib/components/RepositoryDashboard.svelte @@ -320,4 +320,13 @@ .tiles-view .favorite-category .card-title{padding-right:30px} .list-view .card-status .changed,.list-view .card-status .clean{color:var(--color-ink-muted)} .list-view .card-status .changed>:global(svg){color:#eeb94e}.list-view .card-status .clean>:global(svg){color:#68c878} + /* PR badge: compact, padded hit area with a soft outlined hover instead of a hard filled block. */ + .card-actions .pr-badge{box-sizing:border-box;border:1px solid transparent;border-radius:4px;transition:color 120ms ease,background-color 120ms ease,border-color 120ms ease} + .card-actions .pr-badge:hover:not(:disabled){border-color:color-mix(in srgb,var(--color-accent) 28%,transparent);background:color-mix(in srgb,var(--color-accent) 7%,transparent)} + .card-actions .pr-badge:focus-visible{outline:none;border-color:color-mix(in srgb,var(--color-accent) 55%,transparent)} + .list-view .card-actions .pr-badge{min-width:0;height:26px;margin-left:-7px;padding:0 7px;gap:7px} + .list-view .card-actions .pr-badge span{transition:border-color 120ms ease,color 120ms ease} + .list-view .card-actions .pr-badge:hover:not(:disabled) span{border-color:color-mix(in srgb,var(--color-accent) 35%,transparent);color:var(--color-ink)} + .tiles-view .card-actions .pr-badge{width:auto;min-width:42px;padding:0 6px} + .card-actions .pr-badge.pr-error:hover:not(:disabled){border-color:color-mix(in srgb,#e0a35b 30%,transparent);background:color-mix(in srgb,#e0a35b 8%,transparent);color:#e0a35b} -- 2.54.0 From b00e3e5c1802c8d8880b860bed75c26c7594ba2d Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 17 Sep 2026 19:39:40 +0200 Subject: [PATCH 03/10] feat(submodules): support auth and checkout submodule revisions Add credential-aware submodule operations and a command to checkout a specific tag or commit in a submodule without staging the parent repository. - Backend (src-tauri): - Export checkout_submodule_revision and implement checkout_revision which validates tag vs commit inputs, verifies refs locally, and checks out the submodule in detached mode without modifying the parent's index. - Add optional username/password parameters to add_submodule and submodule_action flows. Implement submodule_git to call run_git_authenticated when credentials are supplied and classify auth failures by prefixing errors with "AUTH_FAILED:". - Wire authenticated variants (operate_authenticated, add_authenticated) and update fetch/update actions to use credentials where needed. - Add unit tests covering authenticated submodule commands, auth failure classification, and checkout-by-tag/commit behavior. - Frontend: - App.svelte: introduce credential prompt flow (withSubmoduleCredentials, submit/cancel handlers), surface credential dialog on auth failures, and wire credentialed calls for initialize/add/update/fetch operations. Hook up checkoutSubmoduleRevision and listTags to the submodule dialog. - SubmoduleDialog.svelte: add UI for selecting destination folder, loading tags and checking out revisions; expose fetch action. - CredentialDialog.svelte: include "submodule" action and adjust labels. - Docs: - README: document "Change commit or tag" and "Fetch tags & commits" behaviors. The commit focuses only on enabling credentialed submodule interactions and safe local checkouts of tags/commits; no other git behavior changes are made. --- README.md | 3 + src-tauri/src/git/submodules.rs | 265 ++++++++++++++++++++- src-tauri/src/main.rs | 3 +- src/App.svelte | 68 +++++- src/lib/components/CredentialDialog.svelte | 8 +- src/lib/components/SubmoduleDialog.svelte | 103 +++++++- src/lib/git.ts | 12 +- 7 files changed, 433 insertions(+), 29 deletions(-) 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..857c7b8 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -55,6 +55,7 @@ listSubmodules, addSubmodule, submoduleAction, + checkoutSubmoduleRevision, checkoutBranch, cherryPickAbort, cherryPickCommit, @@ -440,6 +441,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; @@ -3379,6 +3383,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 +3456,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) { @@ -6764,12 +6815,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 +6833,10 @@ error={submoduleInitializationError} onInitialize={initializePendingSubmodules} onLater={dismissSubmoduleInitialization} /> {/await} {/if} + +{#if submoduleAuthRequest} + +{/if} diff --git a/src/lib/components/CredentialDialog.svelte b/src/lib/components/CredentialDialog.svelte index 68bb5c3..c4645e9 100644 --- a/src/lib/components/CredentialDialog.svelte +++ b/src/lib/components/CredentialDialog.svelte @@ -15,7 +15,7 @@ } from "@lucide/svelte"; interface Props { - action: "push" | "pull" | "fetch" | "clone" | "rename" | "delete"; + action: "push" | "pull" | "fetch" | "clone" | "rename" | "delete" | "submodule"; error: string; isBusy: boolean; initialUsername?: string; @@ -47,9 +47,11 @@ password.trim().length > 0 && username.trim().length > 0, ); - let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : action === "delete" ? "Delete" : "Pull"); + let actionLabel = $derived(action === "submodule" ? "Submodule" : action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : action === "delete" ? "Delete" : "Pull"); let actionTitle = $derived( - action === "push" + action === "submodule" + ? "Authenticate submodule" + : action === "push" ? "Authenticate push" : action === "rename" ? "Authenticate remote rename" diff --git a/src/lib/components/SubmoduleDialog.svelte b/src/lib/components/SubmoduleDialog.svelte index 450cdb3..2c47a85 100644 --- a/src/lib/components/SubmoduleDialog.svelte +++ b/src/lib/components/SubmoduleDialog.svelte @@ -1,22 +1,77 @@ @@ -67,12 +123,17 @@

{t("Submodul hinzufügen", "Add submodule")}

+ +
+ {#if browseError}{/if}
- +
+

{t("Zielpfad", "Destination")}: {destination || "—"}

+ {#if destination && !validDestination}{/if}

{t("Die .gitmodules-Datei und der neue Verweis werden zum Commit vorgemerkt.", "The .gitmodules file and new reference will be staged for commit.")}

-
+
{/if} {#if isLoading && !modules.length} @@ -100,6 +161,20 @@ {:else if selected.local_commit !== selected.recorded_commit}{t("Der lokale Commit weicht vom gespeicherten Verweis ab. Checke den gespeicherten Stand aus oder stage den lokalen Verweis im übergeordneten Repository.", "The local commit differs from the recorded reference. Check out the recorded commit or stage the local reference in the parent repository.")} {:else}{t("Der lokale Stand entspricht dem gespeicherten Commit.", "The local state matches the recorded commit.")}{/if} + {#if selected.local_commit} +
{ event.preventDefault(); if (selected && revision.trim()) void onCheckout(selected, revision.trim(), revisionKind); }}> +
{t("Commit oder Tag wechseln", "Change commit or tag")}
+ + {#if revisionKind === "tag"} + + {:else} + + {/if} + {#if tagsError}{/if} + +

{t("Danach den neuen Verweis stagen und im übergeordneten Repository committen.", "Then stage the new reference and commit it in the parent repository.")}

+
+ {/if}
{#if selected.local_commit && selected.local_commit !== selected.recorded_commit}{/if} @@ -135,6 +210,11 @@ dd { margin: 0; display: flex; align-items: center; gap: 9px; flex-wrap: wrap; font-size: 12px; } dd span { margin-left: auto; color: var(--color-ink-muted); } .submodule-notice { padding: 12px; margin: 20px 0; background: var(--color-surface); border-radius: 6px; font-size: 12px; line-height: 1.6; color: var(--color-ink-muted); } + .submodule-revision { border-top: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border); padding: 16px 0; margin-bottom: 16px; } + .revision-heading { display: flex; justify-content: space-between; gap: 8px; align-items: center; flex-wrap: wrap; } + .submodule-revision label { display: flex; flex-direction: column; gap: 6px; margin: 12px 0; font-size: 12px; } + .submodule-revision input, .submodule-revision select { width: 100%; min-width: 0; } + .submodule-revision p { font-size: 12px; color: var(--color-ink-muted); margin: 10px 0 0; line-height: 1.5; } .submodule-actions { display: flex; flex-wrap: wrap; gap: 8px; } .submodule-actions button { white-space: normal; } .submodule-footer { border-top: 1px solid var(--color-border); padding: 14px 20px; font-size: 11px; line-height: 1.5; color: var(--color-ink-muted); } @@ -144,6 +224,9 @@ .submodule-add label { display: flex; flex-direction: column; gap: 7px; font-size: 12px; margin: 13px 0; min-width: 0; } .submodule-add input { width: 100%; min-width: 0; } .submodule-add p { font-size: 12px; color: var(--color-ink-muted); margin: 4px 0 16px; } + .submodule-folder-picker { display: flex; gap: 8px; align-items: center; } + .submodule-folder-picker input { flex: 1; } + .submodule-folder-picker button { flex-shrink: 0; } .submodule-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } @media (max-width: 650px) { .submodule-workspace, .submodule-fields { grid-template-columns: 1fr; } .submodule-list { border-right: 0; } .submodule-content { padding: 12px; } } diff --git a/src/lib/git.ts b/src/lib/git.ts index 72507fa..447166b 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -737,9 +737,13 @@ export function pullRequestAiGenerate(path: string, remote: string, sourceBranch export function listSubmodules(path: string, recursive = true): Promise { return invoke("list_submodules", { path, recursive }); } -export function addSubmodule(path: string, url: string, destination: string, branch?: string): Promise { - return invoke("add_submodule", { path, url, destination, branch: branch || null }); +export function addSubmodule(path: string, url: string, destination: string, branch?: string, username?: string, password?: string): Promise { + return invoke("add_submodule", { path, url, destination, branch: branch || null, username: username ?? null, password: password ?? null }); } -export function submoduleAction(path: string, modulePath: string, action: "update" | "stage" | "sync" | "initialize", recursive: boolean): Promise { - return invoke("submodule_action", { path, modulePath, action, recursive }); +export function submoduleAction(path: string, modulePath: string, action: "update" | "stage" | "sync" | "initialize" | "fetch", recursive: boolean, username?: string, password?: string): Promise { + return invoke("submodule_action", { path, modulePath, action, recursive, username: username ?? null, password: password ?? null }); +} + +export function checkoutSubmoduleRevision(path: string, modulePath: string, revision: string, kind: "tag" | "commit"): Promise { + return invoke("checkout_submodule_revision", { path, modulePath, revision, kind }); } -- 2.54.0 From 3e87c8f6a963e48a2284854806fce4133bf39da5 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 17 Sep 2026 21:41:35 +0200 Subject: [PATCH 04/10] feat(confirm): centralize confirmation dialogs and add i18n Introduce a generic ConfirmDialog and a promise-based requestConfirmation API in App.svelte so callers can await user responses instead of using window.confirm. Provide helper builders (branchDeleteConfirmRequest, discardConfirmRequest) to create dialog content for common cases. Many call sites were switched to use requestConfirmation and now render the in-app ConfirmDialog; the previous specialized confirm components (BranchDeleteConfirmDialog, DiscardConfirmDialog) were removed. Add lightweight i18n support (setLanguage, t()) and new messages/i18n modules, and replace hardcoded English strings in several components (e.g. AiSettingsPage, BlameDialog and many confirmation prompts) with translated keys. Summary of effects: - Replaces native window.confirm with awaitable in-app ConfirmDialog dialogs. - Centralizes confirmation UI and content construction in App.svelte. - Adds i18n plumbing and updates UI text to use t(). - Removes two specialized confirm dialog components and adds src/lib/components/ConfirmDialog.svelte. --- src/App.svelte | 226 ++++++-- src/lib/components/AiSettingsPage.svelte | 27 +- src/lib/components/BlameDialog.svelte | 29 +- .../BranchDeleteConfirmDialog.svelte | 92 ---- src/lib/components/BranchPanel.svelte | 99 ++-- src/lib/components/ConfirmDialog.svelte | 224 ++++++++ .../components/DiscardConfirmDialog.svelte | 88 ---- src/lib/components/GitLfsDialog.svelte | 27 +- src/lib/components/HistoryPanel.svelte | 117 ++--- .../components/InteractiveRebaseDialog.svelte | 41 +- src/lib/components/ReflogDialog.svelte | 27 +- src/lib/components/ReviewCenter.svelte | 41 +- src/lib/components/StashPanel.svelte | 39 +- src/lib/components/StatusPanel.svelte | 121 ++--- src/lib/components/TagsPanel.svelte | 31 +- src/lib/components/WorktreeDialog.svelte | 174 +++---- src/lib/components/WorktreePanel.svelte | 29 +- src/lib/i18n.svelte.ts | 45 ++ src/lib/messages.ts | 485 ++++++++++++++++++ 19 files changed, 1373 insertions(+), 589 deletions(-) delete mode 100644 src/lib/components/BranchDeleteConfirmDialog.svelte create mode 100644 src/lib/components/ConfirmDialog.svelte delete mode 100644 src/lib/components/DiscardConfirmDialog.svelte create mode 100644 src/lib/i18n.svelte.ts create mode 100644 src/lib/messages.ts diff --git a/src/App.svelte b/src/App.svelte index 857c7b8..d4c42ab 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"; @@ -427,6 +428,8 @@ let externalToolsDetectionUnavailable = false; let errorMessage = ""; let operation = ""; + let confirmDialogRequest: ConfirmRequest | null = null; + let confirmDialogResolve: ((confirmed: boolean) => void) | null = null; let compareFrom = ""; let compareTo = ""; let comparison: GitCommitComparison | null = null; @@ -1283,6 +1286,81 @@ function applyLanguagePreference(next: AppLanguage) { document.documentElement.lang = next; document.documentElement.dataset.language = next; + setLanguage(next); + } + + /** Show the confirmation dialog and resolve once the user answers. */ + function requestConfirmation(request: ConfirmRequest): Promise { + confirmDialogResolve?.(false); + confirmDialogRequest = request; + return new Promise((resolve) => { + confirmDialogResolve = resolve; + }); + } + + /** 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) { + const resolve = confirmDialogResolve; + confirmDialogRequest = null; + confirmDialogResolve = null; + resolve?.(confirmed); } function applyThemePreference(next: AppTheme) { @@ -3281,9 +3359,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) { @@ -3828,7 +3918,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 () => { @@ -3995,7 +4089,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 () => { @@ -4188,7 +4286,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 () => { @@ -4366,9 +4468,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." @@ -4432,9 +4538,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."; @@ -4620,7 +4730,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 }); @@ -4636,7 +4754,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); @@ -4649,7 +4773,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"); } @@ -4747,7 +4877,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 () => { @@ -5095,9 +5230,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 () => { @@ -5116,7 +5255,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)); @@ -5127,7 +5272,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)); @@ -5406,7 +5556,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)); @@ -6481,14 +6636,20 @@ {/await} {/if} +{#if confirmDialogRequest} + answerConfirmation(true)} + onCancel={() => answerConfirmation(false)} + /> +{/if} + {#if pendingDiscard} - {/if} @@ -6623,12 +6784,11 @@ {#if deleteBranchTarget} - {/if} 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")}