Auth for submodule ops, revision checkout, central confirm dialog, unified sidebar sizing #46

Merged
Christoph merged 10 commits from UI/UX-enhance into main 2026-09-18 08:26:46 +00:00
30 changed files with 3027 additions and 1194 deletions
+3
View File
@@ -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
+258 -7
View File
@@ -185,7 +185,19 @@ pub async fn list_submodules(path: String, recursive: bool) -> Result<Vec<GitSub
.await
}
#[cfg(test)]
fn operate(repo: &Path, module_path: &str, action: &str, recursive: bool) -> 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<String>,
password: Option<String>,
) -> 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<String>,
username: Option<String>,
password: Option<String>,
) -> 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();
+2 -1
View File
@@ -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,
+536 -314
View File
File diff suppressed because it is too large Load Diff
+73 -98
View File
@@ -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; }
+14 -13
View File
@@ -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<AiSettings> {
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 @@
</script>
<div class="ai-settings-form">
<div class="ai-provider-options" role="radiogroup" aria-label="AI provider">
<div class="ai-provider-options" role="radiogroup" aria-label={t("ai.providerLabel")}>
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
<Bot size={16} aria-hidden="true" />
OpenAI
@@ -130,17 +131,17 @@
</button>
<button type="button" class="ai-provider-option" class:active={provider === "custom"} onclick={() => { provider = "custom"; }}>
<Globe size={16} aria-hidden="true" />
Custom endpoint
{t("ai.custom")}
</button>
</div>
{#if provider === "openai"}
<label class="cred-field">
<span class="cred-field-label">Model</span>
<span class="cred-field-label">{t("ai.model")}</span>
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
</label>
<div class="cred-field">
<span class="cred-field-label">API key</span>
<span class="cred-field-label">{t("ai.apiKey")}</span>
<div class="cred-input">
<Key size={15} class="cred-field-icon" aria-hidden="true" />
<input
@@ -158,11 +159,11 @@
</div>
{:else if provider === "anthropic"}
<label class="cred-field">
<span class="cred-field-label">Model</span>
<span class="cred-field-label">{t("ai.model")}</span>
<input type="text" bind:value={anthropicModel} placeholder="claude-3-5-haiku-latest" autocomplete="off" spellcheck="false" />
</label>
<div class="cred-field">
<span class="cred-field-label">API key</span>
<span class="cred-field-label">{t("ai.apiKey")}</span>
<div class="cred-input">
<Key size={15} class="cred-field-icon" aria-hidden="true" />
<input
@@ -180,21 +181,21 @@
</div>
{:else}
<label class="cred-field">
<span class="cred-field-label">Endpoint URL</span>
<span class="cred-field-label">{t("ai.endpointUrl")}</span>
<input type="text" bind:value={customBaseUrl} placeholder="http://localhost:11434/v1" autocomplete="off" spellcheck="false" />
</label>
<label class="cred-field">
<span class="cred-field-label">Model</span>
<span class="cred-field-label">{t("ai.model")}</span>
<input type="text" bind:value={customModel} placeholder="llama3.1" autocomplete="off" spellcheck="false" />
</label>
<div class="cred-field">
<span class="cred-field-label">API key (optional)</span>
<span class="cred-field-label">{t("ai.apiKeyOptional")}</span>
<div class="cred-input">
<Key size={15} class="cred-field-icon" aria-hidden="true" />
<input
type={showKey ? "text" : "password"}
bind:value={customApiKey}
placeholder="Optional"
placeholder={t("ai.optional")}
autocomplete="off"
spellcheck="false"
disabled={loadingKeys}
@@ -206,7 +207,7 @@
</div>
<div class="cred-token-hint">
<Globe size={13} aria-hidden="true" />
<span>For local OpenAI-compatible servers like Ollama or LM Studio. The base URL should end in /v1.</span>
<span>{t("ai.customHint")}</span>
</div>
{/if}
+15 -14
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import {FileText, FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
import type { GitBlameLine } from "../types";
import { t } from "../i18n.svelte";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
@@ -75,7 +76,7 @@
}
function groupTooltip(group: BlameGroup): string {
if (group.isUncommitted) return "Not committed yet";
if (group.isUncommitted) return t("blame.uncommitted");
return `${group.authorName} <${group.authorEmail}>\n${group.summary}\n${group.hash}`;
}
@@ -132,16 +133,16 @@
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog blame-dialog" role="dialog" aria-modal="true" aria-label="File blame">
<div class="dialog blame-dialog" role="dialog" aria-modal="true" aria-label={t("blame.dialogLabel")}>
<header class="dialog-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><FileText size={18} /></span>
<div class="unified-dialog-text">
<span class="eyebrow">Blame</span>
<span class="eyebrow">{t("blame.eyebrow")}</span>
<p class="dialog-title" title={filePath}>{filePath}</p>
</div>
<div class="dialog-header-actions">
<span class="pill pill-count">{lines.length}</span>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label={t("common.close")}>
<X size={16} aria-hidden="true" />
</button>
</div>
@@ -151,12 +152,12 @@
{#if isLoading}
<div class="blank-state">
<LoaderCircle class="spin" size={18} aria-hidden="true" />
Loading blame...
{t("blame.loading")}
</div>
{:else if error}
<div class="blank-state">{error}</div>
{:else if lines.length === 0}
<div class="blank-state">No blame information available for this file.</div>
<div class="blank-state">{t("blame.empty")}</div>
{:else}
<div class="diff-header blame-code-header">
<FileCode size={13} aria-hidden="true" />
@@ -169,23 +170,23 @@
bind:value={blameSearch}
autocomplete="off"
spellcheck="false"
placeholder="Search blame"
aria-label="Search blame"
placeholder={t("blame.searchPlaceholder")}
aria-label={t("blame.searchPlaceholder")}
/>
{#if searchActive}
<button class="btn-sm blame-search-clear" type="button" onclick={() => { blameSearch = ""; }} aria-label="Clear blame search">
<button class="btn-sm blame-search-clear" type="button" onclick={() => { blameSearch = ""; }} aria-label={t("blame.searchClear")}>
<X size={14} aria-hidden="true" />
</button>
{/if}
</div>
<div class="split-col-headers blame-column-headers">
<div class="split-col-label blame-commit-col-label">Commit</div>
<div class="split-col-label blame-code-col-label">Code</div>
<div class="split-col-label blame-commit-col-label">{t("blame.columnCommit")}</div>
<div class="split-col-label blame-code-col-label">{t("blame.columnCode")}</div>
</div>
<div class="split-diff blame-diff" role="table" aria-label="File blame">
<div class="split-diff blame-diff" role="table" aria-label={t("blame.dialogLabel")}>
<div class="split-pane blame-scroll">
{#if groups.length === 0}
<div class="blank-state">No matches found.</div>
<div class="blank-state">{t("blame.noMatches")}</div>
{:else}
<div class="blame-code-table">
{#each groups as group (group.id)}
@@ -197,7 +198,7 @@
{/each}
</span>
<span class="blame-author">
{#each textSegments(group.isUncommitted ? "Not committed yet" : group.authorName) as segment, index (`author-${group.id}-${index}`)}
{#each textSegments(group.isUncommitted ? t("blame.uncommitted") : group.authorName) as segment, index (`author-${group.id}-${index}`)}
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
{/each}
</span>
@@ -1,92 +0,0 @@
<script lang="ts">
import { AlertTriangle, GitBranch, LoaderCircle, Trash2, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types";
interface Props {
branch: GitBranchInfo;
force: boolean;
isBusy: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
}
let {
branch,
force = false,
isBusy = false,
onConfirm = () => {},
onClose = () => {},
}: Props = $props();
let title = $derived(branch.remote ? "Delete remote branch?" : force ? "Force delete branch?" : "Delete branch?");
let remoteParts = $derived(branch.remote ? branch.name.split(/\/(.+)/) : []);
let branchName = $derived(branch.remote ? remoteParts[1] || branch.name : branch.name);
let branchLocation = $derived(branch.remote ? remoteParts[0] || "Remote" : "Local repository");
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="branch-delete-title">
<header class="dialog-header branch-delete-header unified-dialog-header">
<div class="branch-delete-heading unified-dialog-heading">
<span class:force class="branch-delete-heading-icon unified-dialog-icon" aria-hidden="true">
<Trash2 size={16} />
</span>
<div class="unified-dialog-text">
<span class="eyebrow">{branch.remote ? "Remote branch" : force ? "Force delete" : "Delete branch"}</span>
<p class="dialog-title" id="branch-delete-title">{title}</p>
</div>
</div>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="branch-delete-body">
<div class:force class="discard-warning-icon branch-delete-warning-icon" aria-hidden="true">
<AlertTriangle size={22} />
</div>
<div class="discard-confirm-copy">
<p class="branch-delete-lead">
{#if branch.remote}
This branch will be removed from the shared remote repository.
{:else if force}
This branch is not fully merged. Some commits may only exist here.
{:else}
This branch will be removed from your local repository.
{/if}
</p>
<div class="branch-delete-target" title={branch.name}>
<span class="branch-delete-target-icon" aria-hidden="true"><GitBranch size={16} /></span>
<span class="branch-delete-target-copy">
<code>{branchName}</code>
<span>{branchLocation}</span>
</span>
<span class:remote={branch.remote} class="branch-delete-scope">{branch.remote ? "Remote" : "Local"}</span>
</div>
<p class="discard-warning-text">
{#if branch.remote}
This affects everyone using <strong>{remoteParts[0] || "the remote"}</strong>. Your local branch is kept.
{:else if force}
Force deletion can make unmerged commits difficult to recover.
{:else}
Git will stop the deletion if the branch contains unmerged commits.
{/if}
</p>
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-danger branch-delete-confirm" type="button" onclick={onConfirm} disabled={isBusy}>
{#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else}
<Trash2 size={15} aria-hidden="true" />
{/if}
{branch.remote ? "Delete from remote" : force ? "Force delete" : "Delete"}
</button>
</footer>
</div>
</div>
File diff suppressed because it is too large Load Diff
+264
View File
@@ -0,0 +1,264 @@
<script lang="ts">
/**
* Generic confirmation dialog. Replaces window.confirm so confirmations use
* the app's own styling, translation and focus handling instead of a native,
* untranslated, event-blocking browser dialog.
*/
import { AlertTriangle, Check, LoaderCircle, Trash2, X } from "@lucide/svelte";
import { t } from "../i18n.svelte";
export interface ConfirmRequest {
/** Small label above the title. */
eyebrow?: string;
title: string;
/** Leading sentence explaining what happens. */
message: string;
/** Items the action applies to, rendered as a scrollable list. */
items?: string[];
/** Extra warning below the list. */
note?: string;
confirmLabel?: string;
cancelLabel?: string;
/** Optional opt-in, e.g. "delete anyway" (required) or "include untracked". */
checkbox?: { label: string; note?: string; required?: boolean; defaultChecked?: boolean };
/** Optional single-line input, e.g. a stash message. */
input?: { label: string; placeholder?: string; value?: string; optional?: boolean };
/** Destructive actions get the red confirm button and warning icon. */
danger?: boolean;
}
interface Props {
request: ConfirmRequest;
isBusy?: boolean;
/** Carries the state of the optional checkbox and input. */
onConfirm: (result: { checked: boolean; value: string }) => void;
onCancel: () => void;
}
let { request, isBusy = false, onConfirm, onCancel }: Props = $props();
const MAX_VISIBLE_ITEMS = 8;
let dialogElement = $state<HTMLElement | null>(null);
let confirmButton = $state<HTMLButtonElement | null>(null);
let danger = $derived(request.danger !== false);
let items = $derived(request.items ?? []);
let checked = $state(false);
let value = $state("");
let inputElement = $state<HTMLInputElement | null>(null);
let missingInput = $derived(Boolean(request.input) && request.input?.optional !== true && value.trim().length === 0);
let blocked = $derived((Boolean(request.checkbox?.required) && !checked) || missingInput);
$effect(() => {
// Start from the defaults again whenever a different confirmation is shown.
request.title;
checked = request.checkbox?.defaultChecked ?? false;
value = request.input?.value ?? "";
});
$effect(() => {
// The input is the first thing to fill in when there is one.
if (inputElement) inputElement.select();
else confirmButton?.focus();
});
function submit() {
if (!isBusy && !blocked) onConfirm({ checked, value });
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.stopPropagation();
if (!isBusy) onCancel();
return;
}
if (event.key !== "Tab" || !dialogElement) return;
const focusable = [...dialogElement.querySelectorAll<HTMLElement>("button:not(:disabled)")];
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="dialog-backdrop" role="presentation">
<div bind:this={dialogElement} class:danger class="dialog confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
<header class="dialog-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true">
{#if danger}<Trash2 size={18} />{:else}<Check size={18} />{/if}
</span>
<div class="unified-dialog-text">
<span class="eyebrow">{request.eyebrow ?? t("confirm.eyebrow")}</span>
<p class="dialog-title" id="confirm-dialog-title">{request.title}</p>
</div>
<button class="btn-sm dialog-close" type="button" onclick={onCancel} disabled={isBusy} aria-label={t("common.close")}>
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="discard-confirm-body">
<div class="discard-warning-icon" aria-hidden="true">
<AlertTriangle size={22} />
</div>
<div class="discard-confirm-copy">
<p class="confirm-lead">{request.message}</p>
{#if items.length > 0}
<ul class="discard-target-list">
{#each items.slice(0, MAX_VISIBLE_ITEMS) as item (item)}
<li><code class="discard-target" title={item}>{item}</code></li>
{/each}
{#if items.length > MAX_VISIBLE_ITEMS}
<li class="discard-target-more">{items.length - MAX_VISIBLE_ITEMS === 1 ? t("confirm.moreOne") : t("confirm.more", { count: items.length - MAX_VISIBLE_ITEMS })}</li>
{/if}
</ul>
{/if}
{#if request.input}
<label class="confirm-input">
<span>{request.input.label}</span>
<input
bind:this={inputElement}
bind:value
type="text"
autocomplete="off"
spellcheck="false"
placeholder={request.input.placeholder ?? ""}
disabled={isBusy}
onkeydown={(event) => { if (event.key === "Enter") { event.preventDefault(); submit(); } }}
/>
</label>
{/if}
{#if request.checkbox}
<label class="confirm-check">
<input type="checkbox" bind:checked disabled={isBusy} />
<span>
<strong>{request.checkbox.label}</strong>
{#if request.checkbox.note}<small>{request.checkbox.note}</small>{/if}
</span>
</label>
{/if}
{#if request.note}
<p class="discard-warning-text">{request.note}</p>
{/if}
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={onCancel} disabled={isBusy}>
{request.cancelLabel ?? t("common.cancel")}
</button>
<button
bind:this={confirmButton}
class={`confirm-action ${danger ? "btn-danger" : "btn-primary"}`}
type="button"
onclick={submit}
disabled={isBusy || blocked}
>
{#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else if danger}
<Trash2 size={15} aria-hidden="true" />
{:else}
<Check size={15} aria-hidden="true" />
{/if}
{request.confirmLabel ?? (danger ? t("common.delete") : t("common.confirm"))}
</button>
</footer>
</div>
</div>
<style>
/* Matches .discard-confirm-dialog / .branch-delete-dialog so every confirmation
in the app has the same size, chrome and rhythm. */
.confirm-dialog {
display: grid;
grid-template-rows: auto auto auto;
width: min(500px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.confirm-dialog.danger {
border-color: rgba(255, 90, 103, 0.22);
box-shadow: var(--app-dialog-shadow), 0 0 0 1px rgba(255, 90, 103, 0.04);
}
.confirm-dialog.danger .dialog-header {
background:
linear-gradient(90deg, rgba(255, 90, 103, 0.08), transparent 42%),
var(--app-dialog-chrome);
}
.confirm-dialog .discard-confirm-body { padding: 20px 18px 18px; }
.confirm-dialog .confirm-lead {
color: var(--color-ink);
font-weight: 600;
}
.confirm-dialog.danger .unified-dialog-icon {
border-color: rgba(255, 90, 103, 0.28);
color: #ff9aa4;
background: rgba(255, 90, 103, 0.09);
}
.confirm-dialog .discard-target-list { max-height: 148px; }
.confirm-dialog .discard-warning-text {
padding: 9px 10px;
border-left: 2px solid rgba(255, 90, 103, 0.55);
color: #f2aeb5;
background: rgba(255, 90, 103, 0.055);
font-size: 11.5px;
font-weight: 600;
}
.confirm-dialog:not(.danger) .discard-warning-text {
border-left-color: color-mix(in srgb, var(--color-accent) 55%, transparent);
color: var(--color-ink-muted);
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
}
.confirm-dialog:not(.danger) .discard-warning-icon {
border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border));
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
}
.confirm-dialog .confirm-action { min-width: 116px; }
.confirm-dialog .confirm-input { display: grid; gap: 5px; }
.confirm-dialog .confirm-input span {
color: var(--color-ink-muted);
font-size: 11.5px;
font-weight: 650;
}
.confirm-dialog .confirm-input input { height: 32px; font-size: 12.5px; }
.confirm-dialog .confirm-check {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 9px;
padding: 9px 10px;
border: 1px solid rgba(255, 90, 103, 0.28);
border-radius: 8px;
background: rgba(255, 90, 103, 0.05);
cursor: pointer;
}
.confirm-dialog:not(.danger) .confirm-check {
border-color: var(--color-border-subtle);
background: color-mix(in srgb, var(--color-accent) 5%, transparent);
}
.confirm-dialog:not(.danger) .confirm-check input { accent-color: var(--color-accent); }
.confirm-dialog .confirm-check input { width: 15px; height: 15px; margin-top: 1px; accent-color: #e86060; }
.confirm-dialog .confirm-check span { display: grid; gap: 2px; min-width: 0; }
.confirm-dialog .confirm-check strong { color: var(--color-ink); font-size: 12.5px; font-weight: 650; }
.confirm-dialog .confirm-check small { color: var(--color-ink-dim); font-size: 11.5px; }
</style>
+29 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import SelectMenu from "./SelectMenu.svelte";
import { onMount } from "svelte";
import { GitPullRequest, X, LoaderCircle, ArrowRight, Sparkles } from "@lucide/svelte";
import { GitBranch, GitPullRequest, LockKeyhole, X, LoaderCircle, ArrowRight, Sparkles } from "@lucide/svelte";
import { credLoad, pullRequestAiGenerate, createIntegrationReviewRequest, listIntegrationRepositories, listIntegrationRepositoryBranches, listRemotes, listBranches } from "../git";
import { integrationCredentialKey } from "../integrations";
import type { AiSettings, GitIntegrationSource, GitIntegrationRepository, IntegrationReviewRequest, StoredCredential } from "../types";
@@ -15,6 +15,26 @@
let dialog: HTMLDialogElement;
let titleInput: HTMLInputElement;
let repositories = $state<GitIntegrationRepository[]>([]);
/** Repository options grouped by owner, like the clone dialog's list. */
const repositoryOptions = $derived(repositories.map((repository) => {
const separator = repository.fullName.lastIndexOf("/");
return {
value: repository.id,
label: separator > 0 ? repository.fullName.slice(separator + 1) : repository.fullName,
group: separator > 0 ? repository.fullName.slice(0, separator) : source.label,
};
}));
function repositoryById(id: string): GitIntegrationRepository | undefined {
return repositories.find((repository) => repository.id === id);
}
function formatUpdatedAt(value: string): string {
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "" : new Intl.DateTimeFormat(de ? "de-DE" : "en-US", { dateStyle: "medium" }).format(date);
}
let repositoryId = $state("");
let sourceBranch = $state("");
let targetBranch = $state("");
@@ -129,7 +149,14 @@
<div class="body">
{#if error}<div class="error" role="alert">{error}{#if !repositories.length && !loading}<button type="button" onclick={loadRepositories}>{de ? "Erneut laden" : "Retry"}</button>{/if}</div>{/if}
<div class="repository-field"><div class="field-heading"><span>Repository</span><small>{loading ? "…" : `${repositories.length} ${de ? "verfügbar" : "available"}`}</small></div>
<SelectMenu value={repositoryId} options={repositories.map(repository => ({value:repository.id,label:repository.fullName}))} disabled={loading || busy} ariaLabel="Repository" placeholder={loading ? (de ? "Repositories werden geladen …" : "Loading repositories…") : (de ? "Repository auswählen" : "Select repository")} searchable searchPlaceholder={de ? "Repositories durchsuchen …" : "Search repositories…"} emptyText={de ? "Keine passenden Repositories" : "No matching repositories"} onChange={value => repositoryId = value}/>
<SelectMenu value={repositoryId} options={repositoryOptions} disabled={loading || busy} ariaLabel="Repository" placeholder={loading ? (de ? "Repositories werden geladen …" : "Loading repositories…") : (de ? "Repository auswählen" : "Select repository")} searchable searchPlaceholder={de ? "Repositories durchsuchen …" : "Search repositories…"} emptyText={de ? "Keine passenden Repositories" : "No matching repositories"} showSelectedGroup onChange={value => repositoryId = value}>
{#snippet optionIcon()}<GitBranch size={14} aria-hidden="true" />{/snippet}
{#snippet optionMeta(option)}
{@const repository = repositoryById(option.value)}
{#if repository?.private}<LockKeyhole size={11} aria-label={de ? "Privat" : "Private"} />{/if}
{formatUpdatedAt(repository?.updatedAt ?? "")}
{/snippet}
</SelectMenu>
</div>
{#if !loading && !error && !repositories.length}<p>{de ? "Keine Repositories für diese Integration gefunden." : "No repositories found for this integration."}</p>{/if}
<div class="branches">
+5 -3
View File
@@ -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"
@@ -1,88 +0,0 @@
<script lang="ts">
import {Trash2, AlertTriangle, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
import type { GitFileStatus } from "../types";
interface Props {
files: GitFileStatus[];
staged: boolean | null;
scope: "file" | "hunk" | "lines";
isBusy: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
}
let {
files,
staged = false,
scope = "file",
isBusy = false,
onConfirm = () => {},
onClose = () => {},
}: Props = $props();
function targetPath(file: GitFileStatus): string {
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
}
let count = $derived(files.length);
let title = $derived(
scope === "hunk" ? "Discard hunk?" : scope === "lines" ? "Discard selected lines?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
);
let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : scope === "lines" ? "selected lines" : count > 1 ? `${count} files` : "file");
let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes");
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><Trash2 size={18} /></span>
<div class="unified-dialog-text">
<span class="eyebrow">Confirm discard</span>
<p class="dialog-title">{title}</p>
</div>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="discard-confirm-body">
<div class="discard-warning-icon" aria-hidden="true">
<AlertTriangle size={22} />
</div>
<div class="discard-confirm-copy">
<p>
This will reset the {sourceLabel} for the {scopeLabel} below.
</p>
{#if count > 1}
<ul class="discard-target-list">
{#each files.slice(0, 8) as file (`${file.old_path ?? ""}:${file.path}`)}
<li><code class="discard-target" title={targetPath(file)}>{targetPath(file)}</code></li>
{/each}
{#if files.length > 8}
<li class="discard-target-more">+{files.length - 8} more</li>
{/if}
</ul>
{:else if count === 1}
<code class="discard-target" title={targetPath(files[0])}>{targetPath(files[0])}</code>
{/if}
<p class="discard-warning-text">
This cannot be undone. If a file only exists in your working tree, it can be deleted entirely.
</p>
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-danger" type="button" onclick={onConfirm} disabled={isBusy}>
{#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else}
<RotateCcw size={15} aria-hidden="true" />
{/if}
Discard
</button>
</footer>
</div>
</div>
+2 -2
View File
@@ -51,7 +51,7 @@
onFileHistory: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void;
onIgnore: (target: string, kind: GitIgnoreKind) => void;
onStopTracking: (target: string, kind: "file" | "folder") => void;
onStopTracking: (targets: string[], kind: "file" | "folder") => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
@@ -268,7 +268,7 @@
const node = contextNode;
if (!node) return;
closeFileContextMenu();
onStopTracking(node.path, node.kind);
onStopTracking([node.path], node.kind);
}
function handleWindowKeydown(event: KeyboardEvent) {
+21 -6
View File
@@ -17,6 +17,8 @@
X,
} from "@lucide/svelte";
import type { AppLanguage, GitLfsPattern, GitLfsStatus } from "../types";
import ConfirmDialog from "./ConfirmDialog.svelte";
import { t } from "../i18n.svelte";
interface Props {
status: GitLfsStatus | null;
@@ -81,11 +83,11 @@
}
}
async function confirmPrune() {
const confirmed = window.confirm(isGerman
? "Nicht mehr benötigte lokale LFS-Objekte sicher bereinigen? Nicht gepushte und aktuell verwendete Objekte bleiben erhalten."
: "Safely prune unused local LFS objects? Unpushed and currently used objects are retained.");
if (confirmed) await onPrune();
let pruneConfirmOpen = $state(false);
async function runPrune() {
pruneConfirmOpen = false;
await onPrune();
}
</script>
@@ -199,7 +201,7 @@
<footer class="lfs-footer">
<div><ShieldCheck size={14} aria-hidden="true" /><span>{isGerman ? ".gitattributes bleibt als normale Änderung sichtbar und muss committed werden." : ".gitattributes remains a normal change and must be committed."}</span></div>
<div>
<button class="btn-secondary" type="button" onclick={confirmPrune} disabled={isBusy || !status?.available}><Trash2 size={14} aria-hidden="true" />{isGerman ? "Cache bereinigen" : "Prune cache"}</button>
<button class="btn-secondary" type="button" onclick={() => { pruneConfirmOpen = true; }} disabled={isBusy || !status?.available}><Trash2 size={14} aria-hidden="true" />{isGerman ? "Cache bereinigen" : "Prune cache"}</button>
<button class="btn-primary" type="button" onclick={onPull} disabled={isBusy || !setupReady}><HardDriveDownload size={15} aria-hidden="true" />{isGerman ? "Objekte laden" : "Pull objects"}</button>
</div>
</footer>
@@ -293,3 +295,16 @@
.lfs-footer button { flex: 1; }
}
</style>
{#if pruneConfirmOpen}
<ConfirmDialog
request={{
title: t("confirm.lfsPrune.title"),
message: t("confirm.lfsPrune.message"),
note: t("confirm.lfsPrune.note"),
confirmLabel: t("confirm.lfsPrune.action"),
}}
onConfirm={runPrune}
onCancel={() => { pruneConfirmOpen = false; }}
/>
{/if}
+59 -58
View File
@@ -2,6 +2,7 @@
import { Cherry, ChevronDown, ChevronRight, CloudOff, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, Tag, X } from "@lucide/svelte";
import { visibleParentResolver } from "../graphParents";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
import { t } from "../i18n.svelte";
interface GraphSegment {
fromCol: number;
@@ -310,11 +311,11 @@
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
const directBranches = graphBranchRefs(commit).filter(branchIsVisible);
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
if (directBranches.length > 0) return t("history.hoverBranches", { list: directBranches.join(", ") });
const containingBranches = (row?.branchLabels ?? []).filter(branchIsVisible);
if (containingBranches.length === 0) return commit.short_hash;
return `Branches containing this commit: ${containingBranches.join(", ")}`;
return t("history.hoverContaining", { list: containingBranches.join(", ") });
}
function segmentIsVisible(segment: GraphSegment): boolean {
@@ -516,7 +517,7 @@
const note = await onLoadCommitNote(commit);
notePreviews = {
...notePreviews,
[commit.hash]: note?.trim() || "This Git note is empty.",
[commit.hash]: note?.trim() || t("history.noteEmpty"),
};
} catch {
const nextErrors = new Set(notePreviewErrors);
@@ -684,14 +685,14 @@
function branchDecorationTitle(branch: CommitBranchDecoration): string {
if (branch.localOnly) {
const status = branchStatusLabel(branch);
return `${branch.label} · Local only — not published yet${status ? ` · ${status}` : ""}`;
return `${t("history.branchLocalOnlyTitle", { name: branch.label })}${status ? ` · ${status}` : ""}`;
}
const status = branchStatusLabel(branch);
if (branch.trackedRemote) {
return `${branch.label} · Tracks ${branch.trackedRemote}${status ? ` · ${status}` : ""}`;
return `${t("history.branchTracksTitle", { name: branch.label, upstream: branch.trackedRemote })}${status ? ` · ${status}` : ""}`;
}
if (status) return `${branch.label} · ${status}`;
return branch.kind === "remote" ? `Remote branch ${branch.label}` : `Local branch ${branch.label}`;
return branch.kind === "remote" ? t("history.branchRemoteTitle", { name: branch.label }) : t("history.branchLocalTitle", { name: branch.label });
}
function formatCommitDate(value: string): string {
@@ -778,11 +779,11 @@
<svelte:window onclick={closeCommitContextMenu} onkeydown={handleWindowKeydown} on:contextmenu|capture={closeCommitContextMenu} />
<section bind:this={panelElement} class="panel history-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
<section bind:this={panelElement} class="panel history-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label={t("history.panelLabel")}>
<div class="section-head">
<div>
<span class="eyebrow">History</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
<span class="eyebrow">{t("history.eyebrow")}</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{t("history.title")}</h2>
</div>
{#if graphBranchNames.length > 0}
<div class="section-head-actions">
@@ -790,8 +791,8 @@
class="graph-branch-dialog-button"
type="button"
onclick={openBranchDialog}
title="Customize visible branches"
aria-label={`${visibleBranchCount} of ${graphBranchNames.length} branches visible. Customize branches.`}
title={t("history.customizeBranches")}
aria-label={t("history.visibleBranches", { visible: visibleBranchCount, total: graphBranchNames.length })}
>
<GitBranch size={13} aria-hidden="true" />
Branches
@@ -802,13 +803,13 @@
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
<div class="blank-state">{t("history.noRepo")}</div>
{:else if commits.length === 0}
<div class="blank-state">No commits returned.</div>
<div class="blank-state">{t("history.noCommits")}</div>
{:else}
<div class="history-list graph-list overflow-auto">
{#if visibleCommits.length === 0}
<div class="blank-state">No loaded commits match the selected branches.</div>
<div class="blank-state">{t("history.noMatchingCommits")}</div>
{/if}
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
{@const item = entry.commit}
@@ -884,7 +885,7 @@
{/if}
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
<div class="commit-ref-area">
<div class="commit-ref-strip" aria-label="Commit references">
<div class="commit-ref-strip" aria-label={t("history.refs")}>
{#if refSummary.primaryBranch}
<span class="branch-ref-cluster" class:local-only={refSummary.primaryBranch.localOnly}>
<span
@@ -900,14 +901,14 @@
{/if}
</span>
{#if refSummary.primaryBranch.localOnly}
<span class="compact-ref-local-marker" title="This branch exists only locally and has not been published yet">
LOCAL
<span class="compact-ref-local-marker" title={t("history.localOnlyHint")}>
{t("history.localOnlyBadge")}
</span>
{/if}
</span>
{/if}
{#if refSummary.primaryTag}
<span class="compact-ref-chip tag" title={`Tag ${refSummary.primaryTag}`}>
<span class="compact-ref-chip tag" title={t("history.tagTitle", { name: refSummary.primaryTag })}>
<Tag size={10} aria-hidden="true" />
<span>{refSummary.primaryTag}</span>
</span>
@@ -919,7 +920,7 @@
onclick={() => toggleCommitRefs(item)}
aria-expanded={expandedRefsCommitHash === item.hash}
aria-controls={`commit-refs-${item.hash}`}
title={`Show ${refSummary.overflowCount} more ${refSummary.overflowCount === 1 ? "reference" : "references"}`}
title={refSummary.overflowCount === 1 ? t("history.showMoreRefsOne") : t("history.showMoreRefs", { count: refSummary.overflowCount })}
>
+{refSummary.overflowCount}
</button>
@@ -928,17 +929,17 @@
{#if refSummary.overflowCount > 0 && expandedRefsCommitHash === item.hash}
<div class="commit-ref-details" id={`commit-refs-${item.hash}`}>
<strong>References on this commit</strong>
<strong>{t("history.refsOnCommit")}</strong>
{#if refSummary.branches.some((branch) => branch.kind !== "remote")}
<section>
<span>Local</span>
<span>{t("common.local")}</span>
<div>
{#each refSummary.branches.filter((branch) => branch.kind !== "remote") as branch}
<span class="commit-ref-detail-item local" title={branchDecorationTitle(branch)}>
<i aria-hidden="true"></i>{branch.label}
{#if branch.current}<small>Current</small>{/if}
{#if branch.current}<small>{t("history.current")}</small>{/if}
{#if branch.trackedRemote}<small>{branch.trackedRemote}</small>{/if}
{#if branch.localOnly}<small class="local-only"><CloudOff size={9} aria-hidden="true" />Local only</small>{/if}
{#if branch.localOnly}<small class="local-only"><CloudOff size={9} aria-hidden="true" />{t("history.localOnly")}</small>{/if}
</span>
{/each}
</div>
@@ -946,7 +947,7 @@
{/if}
{#if refSummary.branches.some((branch) => branch.kind === "remote")}
<section>
<span>Remote</span>
<span>{t("common.remote")}</span>
<div>
{#each refSummary.branches.filter((branch) => branch.kind === "remote") as branch}
<span class="commit-ref-detail-item remote"><i aria-hidden="true"></i>{branch.label}</span>
@@ -956,7 +957,7 @@
{/if}
{#if refSummary.tags.length > 0}
<section>
<span>Tags</span>
<span>{t("history.tags")}</span>
<div>
{#each refSummary.tags as tag}
<span class="commit-ref-detail-item tag"><Tag size={10} aria-hidden="true" />{tag}</span>
@@ -966,7 +967,7 @@
{/if}
{#if refSummary.other.length > 0}
<section>
<span>Other</span>
<span>{t("history.other")}</span>
<div>
{#each refSummary.other as ref}
<span class="commit-ref-detail-item">{ref}</span>
@@ -1005,11 +1006,11 @@
onfocus={() => void loadCommitNotePreview(item)}
onclick={() => openCommitNote(item)}
disabled={isBusy}
aria-label={`Open Git note for ${item.short_hash}`}
aria-label={t("history.openNote", { hash: item.short_hash })}
aria-describedby={`commit-note-preview-${item.hash}`}
>
<StickyNote size={11} aria-hidden="true" />
<span>Note</span>
<span>{t("history.note")}</span>
</button>
<span
class="commit-note-tooltip"
@@ -1018,8 +1019,8 @@
>
<span class="commit-note-tooltip-head">
<StickyNote size={12} aria-hidden="true" />
Git Note
<small>Click to open</small>
{t("history.gitNote")}
<small>{t("history.clickToOpen")}</small>
</span>
<span class="commit-note-tooltip-body">
{#if notePreviewLoading.has(item.hash)}
@@ -1054,14 +1055,14 @@
</button>
{#if expandedCommitHashes.has(item.hash)}
<div class="commit-file-list" aria-label="Changed files">
<div class="commit-file-list" aria-label={t("history.changedFiles")}>
{#each item.files as file (`${item.hash}:${file.old_path ?? ""}:${file.path}`)}
<button
class="commit-file-button"
type="button"
onclick={() => onPreviewCommitFile(item, file)}
disabled={isBusy}
title={`Show differences before restoring - ${displayCommitFile(file)}`}
title={t("history.diffBeforeRestore", { file: displayCommitFile(file) })}
>
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
<strong>{commitFileName(file)}</strong>
@@ -1081,8 +1082,8 @@
type="button"
onclick={() => openCommitNote(item)}
disabled={isBusy}
title={`Add a Git note to ${item.short_hash}`}
aria-label={`Add a Git note to ${item.short_hash}`}
title={t("history.addNote", { hash: item.short_hash })}
aria-label={t("history.addNote", { hash: item.short_hash })}
>
<StickyNote size={14} aria-hidden="true" />
</button>
@@ -1092,8 +1093,8 @@
type="button"
onclick={(event) => openCommitActionMenu(event, item)}
disabled={isBusy}
title="Commit actions"
aria-label={`Actions for ${item.short_hash}`}
title={t("history.commitActions")}
aria-label={t("history.actionsFor", { hash: item.short_hash })}
aria-haspopup="menu"
aria-expanded={contextCommit?.hash === item.hash}
>
@@ -1108,13 +1109,13 @@
<div class="history-load-more" use:observeHistoryEnd aria-live="polite">
{#if isLoadingMore}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
<span>Loading older commits…</span>
<span>{t("history.loadingOlder")}</span>
{:else if loadMoreError}
<span title={loadMoreError}>Older commits could not be loaded.</span>
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>Retry</button>
<span title={loadMoreError}>{t("history.loadOlderFailed")}</span>
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>{t("history.retry")}</button>
{:else}
<button type="button" class="history-load-more-button" onclick={() => { void onLoadMore(); }} disabled={isBusy}>
Load older commits
{t("history.loadOlder")}
</button>
{/if}
</div>
@@ -1128,32 +1129,32 @@
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextCommit.short_hash}`}
aria-label={t("history.actionsFor", { hash: contextCommit.short_hash })}
>
<button type="button" role="menuitem" onclick={createBranchFromContextCommit} disabled={isBusy}>
<GitBranch size={14} aria-hidden="true" />
Branch
{t("history.menuBranch")}
</button>
<button type="button" role="menuitem" onclick={openContextCommitNote} disabled={isBusy}>
<StickyNote size={14} aria-hidden="true" />
Note
{t("history.note")}
</button>
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
<RotateCcw size={14} aria-hidden="true" />
Restore
{t("history.menuRestore")}
</button>
<button
type="button"
role="menuitem"
onclick={cherryPickContextCommit}
disabled={isBusy}
title="Apply this commit's changes on top of the current branch"
title={t("history.menuCherryPickHint")}
>
<Cherry size={14} aria-hidden="true" />
Cherry-pick
{t("history.menuCherryPick")}
</button>
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title="Create a new commit that reverses this commit">
<RotateCcw size={14} aria-hidden="true" /> Revert
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title={t("history.menuRevertHint")}>
<RotateCcw size={14} aria-hidden="true" /> {t("history.menuRevert")}
</button>
</div>
{/if}
@@ -1165,25 +1166,25 @@
class="branch-filter-dialog"
role="dialog"
aria-modal="true"
aria-label="Select visible branches"
aria-label={t("history.branchDialogLabel")}
>
<header class="branch-filter-dialog-head unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><GitBranch size={18} /></span>
<div class="unified-dialog-text">
<span class="eyebrow">Git graph</span>
<h3>Visible branches</h3>
<span class="eyebrow">{t("history.graphEyebrow")}</span>
<h3>{t("history.graphTitle")}</h3>
</div>
<button class="dialog-icon-button" type="button" onclick={closeBranchDialog} aria-label="Close branch selection">
<button class="dialog-icon-button" type="button" onclick={closeBranchDialog} aria-label={t("history.closeBranchDialog")}>
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="branch-filter-summary">
<span>{visibleBranchCount} of {graphBranchNames.length} branches selected</span>
<span>{t("history.branchesSelected", { visible: visibleBranchCount, total: graphBranchNames.length })}</span>
<div class="branch-filter-actions">
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>Focus</button>
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>Show all</button>
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>{t("history.focus")}</button>
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>{t("history.showAll")}</button>
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>{t("history.hideAll")}</button>
</div>
</div>
@@ -1197,7 +1198,7 @@
onclick={() => { localBranchGroupOpen = !localBranchGroupOpen; }}
>
{#if localBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
<span>Local</span>
<span>{t("common.local")}</span>
<em>{localBranchNames.filter(branchIsVisible).length}/{localBranchNames.length}</em>
</button>
{#if localBranchGroupOpen}
@@ -1226,7 +1227,7 @@
onclick={() => { remoteBranchGroupOpen = !remoteBranchGroupOpen; }}
>
{#if remoteBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
<span>Remote</span>
<span>{t("common.remote")}</span>
<em>{remoteBranchNames.filter(branchIsVisible).length}/{remoteBranchNames.length}</em>
</button>
{#if remoteBranchGroupOpen}
@@ -1,6 +1,7 @@
<script lang="ts">
import {GitMerge, AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
import { t } from "../i18n.svelte";
import SelectMenu from "./SelectMenu.svelte";
interface PlanRow extends RebaseCommit {
@@ -71,23 +72,23 @@
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog interactive-rebase-dialog" role="dialog" aria-modal="true" aria-label="Interactive rebase" tabindex="-1">
<div class="dialog interactive-rebase-dialog" role="dialog" aria-modal="true" aria-label={t("rebase.dialogLabel")} tabindex="-1">
<header class="dialog-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><GitMerge size={18} /></span>
<div class="unified-dialog-text">
<span class="eyebrow">Rewrite local history</span>
<h2 class="dialog-title">Interactive rebase</h2>
<span class="eyebrow">{t("rebase.eyebrow")}</span>
<h2 class="dialog-title">{t("rebase.dialogLabel")}</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={t("common.close")}><X size={18} aria-hidden="true" /></button>
</header>
<div class="interactive-rebase-body">
<section class="rebase-base-bar">
<label>
<span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span>
<SelectMenu value={base} options={availableBases.map((branch) => ({ value: branch.name, label: `${branch.remote ? "Remote - " : "Local - "}${branch.name}` }))} placeholder="Select a base branch" disabled={isBusy || isLoading} onChange={onBaseChange} />
<span>{t("rebase.rebaseOnto")} <strong>{currentBranch || t("rebase.currentBranch")}</strong> {t("rebase.onto")}</span>
<SelectMenu value={base} options={availableBases.map((branch) => ({ value: branch.name, label: branch.remote ? t("rebase.baseRemote", { name: branch.name }) : t("rebase.baseLocal", { name: branch.name }) }))} placeholder={t("rebase.selectBase")} disabled={isBusy || isLoading} onChange={onBaseChange} />
</label>
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
<p>{t("rebase.hint")}</p>
</section>
{#if error}
@@ -95,24 +96,24 @@
{/if}
{#if isLoading}
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading rebase range…</div>
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> {t("rebase.loading")}</div>
{:else if !base}
<div class="blank-state">Select the branch or commit that should become the new base.</div>
<div class="blank-state">{t("rebase.selectBaseHint")}</div>
{:else if rows.length === 0}
<div class="blank-state">No linear commits are available above this base.</div>
<div class="blank-state">{t("rebase.noCommits")}</div>
{:else}
<div class="rebase-plan" role="list" aria-label="Interactive rebase plan">
<div class="rebase-plan" role="list" aria-label={t("rebase.planLabel")}>
{#each rows as row, index (row.hash)}
<article class:drop={row.action === "drop"} class="rebase-plan-row" role="listitem">
<div class="rebase-order-actions">
<button type="button" onclick={() => move(index, -1)} disabled={isBusy || index === 0} title="Move up"><ArrowUp size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title="Move down"><ArrowDown size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => move(index, -1)} disabled={isBusy || index === 0} title={t("rebase.moveUp")}><ArrowUp size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title={t("rebase.moveDown")}><ArrowDown size={14} aria-hidden="true" /></button>
</div>
<SelectMenu class={`rebase-action ${row.action}`} value={row.action} options={rebaseActions.map((action) => ({ value: action, label: action }))} disabled={isBusy} ariaLabel={`Action for ${row.short_hash}`} onChange={(value) => updateAction(index, value as RebaseAction)} />
<SelectMenu class={`rebase-action ${row.action}`} value={row.action} options={rebaseActions.map((action) => ({ value: action, label: action }))} disabled={isBusy} ariaLabel={t("rebase.actionFor", { hash: row.short_hash })} onChange={(value) => updateAction(index, value as RebaseAction)} />
<code>{row.short_hash}</code>
<div class="rebase-commit-copy">
{#if row.action === "reword"}
<input value={row.message} oninput={(event) => updateMessage(index, (event.target as HTMLInputElement).value)} disabled={isBusy} aria-label={`New message for ${row.short_hash}`} maxlength="240" />
<input value={row.message} oninput={(event) => updateMessage(index, (event.target as HTMLInputElement).value)} disabled={isBusy} aria-label={t("rebase.newMessageFor", { hash: row.short_hash })} maxlength="240" />
{:else}
<strong>{row.summary}</strong>
{/if}
@@ -124,19 +125,19 @@
{/if}
{#if invalidSquash}
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Squash and fixup need an earlier commit that is not dropped.</div>
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> {t("rebase.invalidSquash")}</div>
{:else if invalidReword}
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Reword messages cannot be empty.</div>
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> {t("rebase.invalidReword")}</div>
{/if}
</div>
<footer class="dialog-footer">
<span class="dialog-footer-info">{keptCount} of {rows.length} commits kept</span>
<span class="dialog-footer-info">{t("rebase.keptCount", { kept: keptCount, total: rows.length })}</span>
<div class="rebase-footer-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{t("common.cancel")}</button>
<button class="btn-primary" type="button" onclick={start} disabled={!canStart}>
{#if operation === "Starting interactive rebase"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Play size={16} aria-hidden="true" />{/if}
Start rebase
{t("rebase.start")}
</button>
</div>
</footer>
+14 -13
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { GitBranch, History, LoaderCircle, Search, ShieldCheck, X } from "@lucide/svelte";
import type { ReflogEntry } from "../types";
import { t } from "../i18n.svelte";
interface Props {
entries: ReflogEntry[];
@@ -32,21 +33,21 @@
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog reflog-dialog" role="dialog" aria-modal="true" aria-label="Reflog" tabindex="-1">
<div class="dialog reflog-dialog" role="dialog" aria-modal="true" aria-label={t("reflog.title")} tabindex="-1">
<header class="dialog-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><History size={18} /></span>
<div class="unified-dialog-text"><span class="eyebrow">Recovery history</span><h2 class="dialog-title">Reflog</h2></div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
<div class="unified-dialog-text"><span class="eyebrow">{t("reflog.eyebrow")}</span><h2 class="dialog-title">{t("reflog.title")}</h2></div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={t("common.close")}><X size={18} aria-hidden="true" /></button>
</header>
<div class="reflog-body">
<aside class="reflog-list-pane">
<label class="reflog-search"><Search size={15} aria-hidden="true" /><input bind:value={query} placeholder="Search actions, hashes or authors" aria-label="Search reflog" /></label>
<label class="reflog-search"><Search size={15} aria-hidden="true" /><input bind:value={query} placeholder={t("reflog.searchPlaceholder")} aria-label={t("reflog.searchLabel")} /></label>
{#if isLoading}
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading reflog…</div>
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> {t("reflog.loading")}</div>
{:else if filteredEntries.length === 0}
<div class="blank-state">No reflog entries match this search.</div>
<div class="blank-state">{t("reflog.noMatch")}</div>
{:else}
<div class="reflog-list" role="listbox" aria-label="Reflog entries">
<div class="reflog-list" role="listbox" aria-label={t("reflog.listLabel")}>
{#each filteredEntries as entry (`${entry.selector}:${entry.hash}`)}
<button class:active={selected?.selector === entry.selector} type="button" role="option" aria-selected={selected?.selector === entry.selector} onclick={() => select(entry)}>
<span class="reflog-row-top"><code>{entry.selector}</code><span>{new Date(entry.date).toLocaleString()}</span></span>
@@ -62,18 +63,18 @@
{#if error}<div class="rebase-warning error">{error}</div>{/if}
{#if selected}
<div class="reflog-detail-head"><History size={20} aria-hidden="true" /><div><span class="eyebrow">{selected.selector}</span><h3>{selected.action}</h3></div></div>
<dl><div><dt>Commit</dt><dd><code>{selected.hash}</code></dd></div><div><dt>Author</dt><dd>{selected.author_name}</dd></div><div><dt>Date</dt><dd>{new Date(selected.date).toLocaleString()}</dd></div></dl>
<button class="btn-secondary reflog-preview" type="button" onclick={() => onPreview(selected)} disabled={isBusy || selected.hash === currentHash}><History size={15} aria-hidden="true" /> Preview changes to current HEAD</button>
<dl><div><dt>{t("common.commit")}</dt><dd><code>{selected.hash}</code></dd></div><div><dt>{t("reflog.author")}</dt><dd>{selected.author_name}</dd></div><div><dt>{t("reflog.date")}</dt><dd>{new Date(selected.date).toLocaleString()}</dd></div></dl>
<button class="btn-secondary reflog-preview" type="button" onclick={() => onPreview(selected)} disabled={isBusy || selected.hash === currentHash}><History size={15} aria-hidden="true" /> {t("reflog.preview")}</button>
<div class="reflog-recovery-card">
<div class="reflog-recovery-title"><ShieldCheck size={18} aria-hidden="true" /><div><strong>Safe recovery</strong><span>Create a new branch here. The current branch is not reset or deleted.</span></div></div>
<label><span>Recovery branch</span><div><GitBranch size={15} aria-hidden="true" /><input bind:value={recoveryBranch} disabled={isBusy} spellcheck="false" /></div></label>
<div class="reflog-recovery-title"><ShieldCheck size={18} aria-hidden="true" /><div><strong>{t("reflog.safeRecovery")}</strong><span>{t("reflog.safeRecoveryNote")}</span></div></div>
<label><span>{t("reflog.recoveryBranch")}</span><div><GitBranch size={15} aria-hidden="true" /><input bind:value={recoveryBranch} disabled={isBusy} spellcheck="false" /></div></label>
<button class="btn-primary" type="button" onclick={() => onRestore(selected, recoveryBranch.trim())} disabled={isBusy || !recoveryBranch.trim()}>
{#if operation === "Restoring reflog entry"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<ShieldCheck size={16} aria-hidden="true" />{/if}
Create and checkout recovery branch
{t("reflog.createBranch")}
</button>
</div>
{:else}
<div class="blank-state">Select a reflog entry to inspect or recover it.</div>
<div class="blank-state">{t("reflog.selectEntry")}</div>
{/if}
</section>
</div>
@@ -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}
</style>
+110 -19
View File
@@ -2,6 +2,8 @@
import { onMount } from "svelte";
import type { AiSettings } from "../types";
import CreateReviewDialog from "./CreateReviewDialog.svelte";
import ConfirmDialog, { type ConfirmRequest } from "./ConfirmDialog.svelte";
import { t } from "../i18n.svelte";
import CommentEditor from "./CommentEditor.svelte";
import SelectMenu from "./SelectMenu.svelte";
import { cubicOut } from "svelte/easing";
@@ -43,6 +45,7 @@
let errors = $state<Array<{ source: string; message: string }>>([]);
let query = $state("");
let stateFilter = $state<IntegrationReviewState>("open");
let repositoryFilter = $state("");
let selectedSourceId = $state("");
let selectedId = $state("");
let detailOpen = $state(false);
@@ -65,8 +68,49 @@
const normalizedQuery = $derived(query.trim().toLocaleLowerCase());
const filtered = $derived(requests.filter((request) => {
const haystack = `${request.title} ${request.repositoryName} ${request.author} ${request.number} ${request.sourceBranch} ${request.targetBranch}`.toLocaleLowerCase();
return request.state === stateFilter && (!normalizedQuery || haystack.includes(normalizedQuery));
return request.state === stateFilter
&& (!repositoryFilter || request.repositoryName === repositoryFilter)
&& (!normalizedQuery || haystack.includes(normalizedQuery));
}));
/**
* Repositories of the requests loaded for the current state tab, grouped by
* owner and carrying the number of requests as the right-hand meta text.
*/
const repositoryOptions = $derived.by(() => {
const counts = new Map<string, number>();
for (const request of requests) {
if (request.state !== stateFilter || !request.repositoryName) continue;
counts.set(request.repositoryName, (counts.get(request.repositoryName) ?? 0) + 1);
}
const entries = [...counts.entries()]
.sort(([left], [right]) => left.localeCompare(right, undefined, { sensitivity: "base" }))
.map(([name, count]) => {
const separator = name.lastIndexOf("/");
return {
value: name,
label: separator > 0 ? name.slice(separator + 1) : name,
group: separator > 0 ? name.slice(0, separator) : (activeSource?.label ?? ""),
meta: String(count),
};
});
entries.sort((left, right) => left.group.localeCompare(right.group, undefined, { sensitivity: "base" })
|| left.label.localeCompare(right.label, undefined, { sensitivity: "base" }));
return [
{ value: "", label: de ? "Alle Repositories" : "All repositories", meta: String(counts.size) },
...entries,
];
});
$effect(() => {
// Drop the filter as soon as the chosen repository is no longer in the list.
if (repositoryFilter && !repositoryOptions.some((option) => option.value === repositoryFilter)) {
repositoryFilter = "";
}
});
const groupedRequests = $derived.by(() => {
const groups = new Map<string, IntegrationReviewRequest[]>();
for (const request of filtered) {
@@ -340,16 +384,35 @@
return de ? "Request wieder öffnen" : "Reopen request";
}
let reviewConfirmRequest = $state<ConfirmRequest | null>(null);
let reviewConfirmResolve: ((confirmed: boolean) => void) | null = null;
function askReviewConfirmation(request: IntegrationReviewRequest, action: IntegrationReviewAction): Promise<boolean> {
const values = { number: request.number };
reviewConfirmRequest = action === "merge"
? { title: t("confirm.review.mergeTitle", values), message: t("confirm.review.mergeMessage"), items: [request.title], confirmLabel: t("confirm.review.mergeAction"), danger: false }
: action === "close"
? { title: t("confirm.review.closeTitle", values), message: t("confirm.review.closeMessage"), items: [request.title], confirmLabel: t("confirm.review.closeAction") }
: { title: t("confirm.review.reopenTitle", values), message: t("confirm.review.reopenMessage"), items: [request.title], confirmLabel: t("confirm.review.reopenAction"), danger: false };
return new Promise<boolean>((resolve) => {
reviewConfirmResolve = resolve;
});
}
function answerReviewConfirmation(confirmed: boolean) {
const resolve = reviewConfirmResolve;
reviewConfirmRequest = null;
reviewConfirmResolve = null;
resolve?.(confirmed);
}
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
const source = activeSource;
if (!source || actionBusyId) return;
if (action !== "approve") {
const prompt = action === "merge"
? (de ? `Request #${request.number} wirklich zusammenführen?` : `Merge request #${request.number}?`)
: action === "close"
? (de ? `Request #${request.number} wirklich schließen?` : `Close request #${request.number}?`)
: (de ? `Request #${request.number} wieder öffnen?` : `Reopen request #${request.number}?`);
if (!window.confirm(prompt)) return;
const confirmed = await askReviewConfirmation(request, action);
if (!confirmed) return;
}
actionMenuId = "";
actionNotice = "";
@@ -419,6 +482,20 @@
{/each}
</nav>
<label class="review-search"><Search size={14} /><input bind:value={query} placeholder={de ? "Requests durchsuchen …" : "Search requests …"} /></label>
<div class="repository-picker">
<SelectMenu
value={repositoryFilter}
options={repositoryOptions}
placeholder={de ? "Alle Repositories" : "All repositories"}
searchable={repositoryOptions.length > 8}
searchPlaceholder={de ? "Repository suchen …" : "Search repository …"}
emptyText={de ? "Kein passendes Repository" : "No matching repository"}
ariaLabel={de ? "Repository filtern" : "Filter repository"}
onChange={(value) => { repositoryFilter = value; selectedId = ""; }}
>
{#snippet optionIcon()}<GitBranch size={14} aria-hidden="true" />{/snippet}
</SelectMenu>
</div>
<div class="integration-picker">
<SelectMenu value={selectedSourceId} options={sources.map(source => ({ value: source.id, label: source.label }))} ariaLabel={de ? "Integration auswählen" : "Select integration"} onChange={selectSource} />
</div>
@@ -565,7 +642,7 @@
/* Review Center B — compact hierarchy, overlay inspector, semantic actions. */
.review-center{font-size:11px}.review-header{min-height:44px}.review-heading h1{font-size:14px}
.review-toolbar{display:grid;min-height:50px;grid-template-columns:auto minmax(220px,1fr) 190px auto;align-items:center;gap:10px;padding:0 14px;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))}
.review-toolbar{display:grid;min-height:50px;grid-template-columns:auto minmax(200px,1fr) 170px 190px auto;align-items:center;gap:10px;padding:0 14px;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))}
.state-tabs{height:100%;min-height:0;gap:3px;padding:0;border:0;background:transparent}.state-tabs button{min-width:58px;justify-content:center;padding:0 9px;font-size:10.5px}.state-tabs button.active{color:var(--color-ink)}.state-tabs button.active:after{right:7px;left:7px}.state-tabs span,.group-header>span{min-width:17px;height:17px;padding:0 4px;border:1px solid var(--color-border-subtle);border-radius:1px;font-size:9px}
.review-search{min-width:0;height:30px}.review-search:focus-within{box-shadow:inset 2px 0 0 var(--color-accent)}.group-actions{gap:4px}.group-actions .icon-button{width:30px;margin:0;padding:0;border-color:var(--color-border-subtle)}
.table-head,.request-row{grid-template-columns:92px minmax(285px,1.65fr) 132px 126px minmax(220px,1fr) 190px}.table-head{min-height:34px;padding:0 16px;border-top:1px solid var(--color-border-subtle);border-bottom-color:var(--color-border);color:color-mix(in srgb,var(--color-ink-faint) 82%,transparent);background:color-mix(in srgb,var(--color-surface-raised) 80%,var(--app-bg));font-size:8.5px;letter-spacing:.07em}.request-groups{min-width:1080px}.request-group{border-bottom:0}.group-header{height:39px;gap:8px;padding:0 16px;border-bottom-color:var(--color-border);color:var(--color-ink);background:color-mix(in srgb,var(--color-surface) 78%,var(--app-bg))}.group-header:hover{background:color-mix(in srgb,var(--color-surface-hover) 82%,var(--app-bg))}.group-header>:global(svg){color:var(--color-accent)}.group-header strong{font-size:11.5px;font-weight:680;letter-spacing:.005em}.group-header>span{margin-left:4px;color:var(--color-ink-muted);background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg))}
@@ -577,19 +654,19 @@
.detail-main>.description{padding:12px 0}.detail-main>.comments-section{padding:12px 0 14px}.comment-list{gap:8px}.comment-list article{gap:8px}.comment-list article>div{padding:8px 9px;border-radius:0;background:color-mix(in srgb,var(--color-surface) 48%,var(--app-bg))}
.detail-sidebar{padding:15px 0 15px 16px;background:color-mix(in srgb,var(--color-surface) 58%,var(--app-bg))}.detail-sidebar .detail-actions{gap:6px;margin-bottom:9px}.detail-action{height:31px}.detail-sidebar section{padding:14px 0}.people{grid-template-columns:23px minmax(0,1fr);gap:8px;margin-top:10px}.people strong,.detail-meta strong,.detail-meta span{overflow:hidden;color:var(--color-ink-muted);font-size:10px;font-weight:500;text-overflow:ellipsis}.detail-meta{display:grid;gap:8px}.detail-actions>.danger-action:first-child{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 8%,var(--color-surface))}
.local-resolution{margin:8px 0 0;border-color:color-mix(in srgb,#d6a64f 55%,var(--color-border));background:color-mix(in srgb,#d6a64f 7%,var(--color-surface))}.local-resolution>div>:global(svg){color:#d6a64f}.local-resolution button{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 10%,var(--color-surface))}
@media(max-width:1280px){.review-toolbar{grid-template-columns:auto minmax(180px,1fr) 155px auto}.state-tabs button{min-width:52px;padding:0 6px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 110px minmax(190px,1fr) 180px}.table-head>span:nth-child(4),.collaborators{display:none}.table-head,.request-groups{min-width:980px}.detail-panel{width:58%;min-width:680px}}
@media(max-width:900px){.review-toolbar{grid-template-columns:1fr 150px auto;grid-template-rows:38px 38px;padding:0 10px}.state-tabs{grid-column:1/-1;grid-row:1}.review-search{grid-column:1;grid-row:2}.group-actions{grid-column:3;grid-row:2}.detail-panel{width:76%;min-width:620px}.detail-content{grid-template-columns:minmax(0,1fr) 220px;gap:14px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 180px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
@media(max-width:1280px){.review-toolbar{grid-template-columns:auto minmax(160px,1fr) 150px 155px auto}.state-tabs button{min-width:52px;padding:0 6px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 110px minmax(190px,1fr) 180px}.table-head>span:nth-child(4),.collaborators{display:none}.table-head,.request-groups{min-width:980px}.detail-panel{width:58%;min-width:680px}}
@media(max-width:900px){.review-toolbar{grid-template-columns:minmax(120px,1fr) 150px 150px auto;grid-template-rows:38px 38px;padding:0 10px}.state-tabs{grid-column:1/-1;grid-row:1}.review-search{grid-column:1;grid-row:2}.group-actions{grid-column:3;grid-row:2}.detail-panel{width:76%;min-width:620px}.detail-content{grid-template-columns:minmax(0,1fr) 220px;gap:14px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 180px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
@media(max-width:680px){.state-tabs button{min-width:0;flex:1}.group-actions .icon-button:nth-child(-n+2){display:none}.detail-panel{width:100%;min-width:0;max-width:none}.detail-content{display:block;padding:0 13px}.detail-main{height:100%}.detail-sidebar{display:none}}
/* Accepted Review Center concept — faithful final layout. */
.review-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.review-heading{gap:10px}.review-heading h1{font-size:14px;font-weight:700}
.review-toolbar{min-height:55px;grid-template-columns:350px minmax(220px,1fr) 160px 34px;gap:10px;padding:0 16px;border-bottom-color:var(--color-border);background:color-mix(in srgb,var(--app-bg) 88%,var(--color-surface))}.state-tabs{gap:8px}.state-tabs button{min-width:72px;justify-content:flex-start;padding:0 8px;color:var(--color-ink-muted);font-size:11px}.state-tabs button.active{color:var(--color-ink)}.state-tabs button.active:after{right:0;left:0;height:2px}.state-tabs span{min-width:17px;height:17px;margin-left:auto;border:0;background:var(--color-surface-raised)}.review-search,.group-actions .icon-button{height:34px;background:color-mix(in srgb,var(--app-input-bg) 92%,#11171c)}.review-search{padding:0 11px}.group-actions .icon-button{width:34px}
.review-toolbar{min-height:55px;grid-template-columns:350px minmax(200px,1fr) 155px 160px 34px;gap:10px;padding:0 16px;border-bottom-color:var(--color-border);background:color-mix(in srgb,var(--app-bg) 88%,var(--color-surface))}.state-tabs{gap:8px}.state-tabs button{min-width:72px;justify-content:flex-start;padding:0 8px;color:var(--color-ink-muted);font-size:11px}.state-tabs button.active{color:var(--color-ink)}.state-tabs button.active:after{right:0;left:0;height:2px}.state-tabs span{min-width:17px;height:17px;margin-left:auto;border:0;background:var(--color-surface-raised)}.review-search,.group-actions .icon-button{height:34px;background:color-mix(in srgb,var(--app-input-bg) 92%,#11171c)}.review-search{padding:0 11px}.group-actions .icon-button{width:34px}
.table-head,.request-row{grid-template-columns:90px minmax(255px,1.65fr) 105px 120px minmax(180px,1fr) 150px}.table-head{min-height:37px;padding:0 16px;background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg));font-size:8.5px}.table-head,.request-groups{min-width:980px}.group-header{height:45px;padding:0 17px;background:color-mix(in srgb,var(--app-bg) 72%,var(--color-surface))}.group-header strong{font-size:11.5px}.request-row{min-height:78px;padding:0 16px}.request-row.selected{background:linear-gradient(90deg,color-mix(in srgb,var(--color-accent) 7%,var(--color-surface-raised)),color-mix(in srgb,var(--color-surface-raised) 72%,var(--app-bg)))}.request-title{gap:8px}.request-title strong{font-size:12px}.request-status{font-size:10.5px}.request-author i,.avatar,.collaborators i{width:28px;height:28px;background:color-mix(in srgb,var(--color-accent) 58%,#26333a)}.provider-button,.action-toggle,.panel-button{height:34px}.provider-button{min-width:102px}.action-toggle{width:34px}.panel-button{width:34px}.action-menu{top:38px;width:154px}.action-menu button{height:36px}
.detail-panel{width:44%;min-width:680px;max-width:none;background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface))}.detail-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.detail-provider strong{font-size:13px}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:18px;padding:0 0 0 24px}.detail-main{padding-right:0}.detail-title{padding:18px 0 16px}.detail-title-line{display:flex;min-width:0;align-items:baseline;gap:10px}.detail-title-line>span{flex:0 0 auto;color:var(--color-accent);font-size:17px;font-weight:700}.detail-title h2{min-width:0;margin:0;overflow:hidden;font-size:19px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.detail-title .detail-summary{min-height:33px;gap:7px}.detail-summary .avatar{width:23px;height:23px;margin-left:5px}.detail-summary strong{font-size:10.5px}.summary-separator{color:var(--color-ink-faint)}.detail-branch-route{display:flex;min-width:0;align-items:center;gap:6px;color:var(--color-ink-muted)}.detail-branch-route>:global(svg){color:var(--color-ink-muted)}.detail-branch-route code{max-width:95px;overflow:hidden;color:var(--color-ink-muted);font-size:10.5px;text-overflow:ellipsis;white-space:nowrap}.detail-branch-route>span{color:var(--color-ink-faint)}.state-badge{padding:0;border:0;font-size:10.5px}
.detail-main>.merge-summary{min-height:55px;margin:0 0 4px;padding:0 12px;border-color:color-mix(in srgb,#63c783 65%,var(--color-border));background:color-mix(in srgb,#63c783 5%,var(--app-bg))}.detail-main>.description{padding:14px 0 18px}.description h3,.comments-section h3{font-size:11.5px}.description p{font-size:10.5px}.detail-main>.comments-section{padding:13px 0 0}.comments-section>header{min-height:28px;margin:0 0 8px}.comments-section>header>span{border-radius:1px}.comment-list{gap:10px;padding-right:0}.comment-list article{display:block}.comment-card{padding:0!important;border:1px solid var(--color-border)!important;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))!important}.comment-card header{min-height:42px;margin:0!important;padding:0 10px;border-bottom:0}.comment-card header .avatar{width:25px;height:25px;margin-right:2px}.comment-card header strong{font-size:10.5px}.comment-card header time{margin-left:auto}.owner-badge{padding:3px 6px;border:1px solid var(--color-border);color:var(--color-ink-faint);font-size:8.5px}.comment-card p{padding:0 44px 13px!important;color:var(--color-ink)!important}
.detail-sidebar{padding:28px 17px 16px;border-left-color:var(--color-border);background:color-mix(in srgb,var(--color-surface) 46%,var(--app-bg))}.detail-sidebar .detail-actions{gap:10px;margin:0 0 12px}.detail-action{height:39px;font-size:11px}.detail-sidebar section{padding:17px 0}.detail-sidebar section h3{font-size:11.5px}.people{margin-top:12px}.detail-meta{gap:11px}
@media(max-width:1280px){.review-toolbar{grid-template-columns:310px minmax(180px,1fr) 145px 34px}.state-tabs{gap:3px}.state-tabs button{min-width:64px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 105px minmax(190px,1fr) 150px}.request-groups{min-width:840px}.detail-panel{width:50%;min-width:650px}}
@media(max-width:900px){.review-toolbar{grid-template-columns:1fr 150px 34px;grid-template-rows:40px 40px}.detail-panel{width:72%;min-width:600px}.detail-content{grid-template-columns:minmax(0,1fr) 205px;gap:14px;padding-left:16px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 150px}.request-groups{min-width:720px}}
@media(max-width:1280px){.review-toolbar{grid-template-columns:310px minmax(160px,1fr) 140px 145px 34px}.state-tabs{gap:3px}.state-tabs button{min-width:64px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 105px minmax(190px,1fr) 150px}.request-groups{min-width:840px}.detail-panel{width:50%;min-width:650px}}
@media(max-width:900px){.review-toolbar{grid-template-columns:minmax(120px,1fr) 145px 145px 34px;grid-template-rows:40px 40px}.detail-panel{width:72%;min-width:600px}.detail-content{grid-template-columns:minmax(0,1fr) 205px;gap:14px;padding-left:16px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 150px}.request-groups{min-width:720px}}
@media(max-width:680px){.detail-panel{width:100%;min-width:0}.detail-content{display:block;padding:0 14px}.detail-sidebar{display:none}.detail-title h2{font-size:16px}.detail-title-line>span{font-size:14px}}
/* Match the approved reference at its 1672px desktop width. */
@@ -606,22 +683,28 @@
/* The inspector overlays an unchanged background; only viewport size reflows it. */
.review-toolbar{box-sizing:border-box;width:100%;height:56px;min-height:56px;grid-template-columns:316px minmax(140px,1fr) 145px 30px;grid-template-rows:1fr;gap:10px;padding:0 14px 0 8px}
.review-toolbar{box-sizing:border-box;width:100%;height:56px;min-height:56px;grid-template-columns:316px minmax(140px,1fr) 150px 145px 30px;grid-template-rows:1fr;gap:10px;padding:0 14px 0 8px}
.review-toolbar .state-tabs{grid-column:1;grid-row:1;gap:8px;align-items:stretch}
.review-center .state-tabs button{min-width:0;flex:1;gap:6px;padding:0 6px;justify-content:center;font-size:12px;white-space:nowrap}
.state-tabs span{min-width:14px;height:16px;margin-left:0;padding:0 3px;font-size:10px}
.state-tabs button.active span{background:transparent}
.review-search{grid-column:2;grid-row:1;height:32px;order:0}
.review-search input{font-size:12px}
.integration-picker{position:relative;grid-column:3;grid-row:1;height:32px;min-width:0}
.repository-picker{position:relative;grid-column:3;grid-row:1;height:32px;min-width:0}
.integration-picker{position:relative;grid-column:4;grid-row:1;height:32px;min-width:0}
.review-toolbar .group-actions{grid-column:4;grid-row:1;order:0}
.review-toolbar .group-actions{grid-column:5;grid-row:1;order:0}
.review-toolbar .group-actions .icon-button{width:30px;height:32px}
@media(max-width:720px){.review-toolbar{grid-template-columns:minmax(120px,1fr) 145px 30px;grid-template-rows:36px 38px;height:80px;min-height:80px;gap:0 10px}.review-toolbar .state-tabs{grid-column:1/-1;grid-row:1;max-width:316px}.review-search{grid-column:1;grid-row:2}.integration-picker{grid-column:2;grid-row:2}.review-toolbar .group-actions{grid-column:3;grid-row:2}}
@media(max-width:720px){.review-toolbar{grid-template-columns:minmax(110px,1fr) 130px 130px 30px;grid-template-rows:36px 38px;height:80px;min-height:80px;gap:0 10px}.review-toolbar .state-tabs{grid-column:1/-1;grid-row:1;max-width:316px}.review-search{grid-column:1;grid-row:2}.repository-picker{grid-column:2;grid-row:2}.integration-picker{grid-column:3;grid-row:2}.review-toolbar .group-actions{grid-column:4;grid-row:2}}
.integration-picker :global(.select-menu){width:100%;height:32px}
.integration-picker :global(.select-menu-trigger){height:32px;min-height:32px;font-size:12px}
.integration-picker :global(.select-menu),
.repository-picker :global(.select-menu){width:100%;height:32px}
.integration-picker :global(.select-menu-trigger),
.repository-picker :global(.select-menu-trigger){height:32px;min-height:32px;font-size:12px}
.repository-picker :global(.select-menu-popup){min-width:290px;max-height:420px;padding:4px}
.repository-picker :global(.select-menu-option){min-height:34px;font-weight:650}
.repository-picker :global(.select-menu-group){margin:6px 3px 2px;padding:7px 6px 5px}
/* Compact request actions, matching the approved menu reference. */
.review-center .row-actions .provider-button{min-width:54px;height:27px;min-height:27px;padding:0 8px;font-size:11px;font-weight:500}
.review-center .row-actions .action-toggle{width:25px;height:27px;min-height:27px;background:transparent}
@@ -636,3 +719,11 @@
.review-center .action-menu button:hover:not(:disabled){background:var(--color-surface-hover)}
.review-comment-editor{flex:0 0 auto;min-width:0;padding:14px 0 18px;border-top:1px solid var(--color-border-subtle)}
</style>
{#if reviewConfirmRequest}
<ConfirmDialog
request={reviewConfirmRequest}
onConfirm={() => answerReviewConfirmation(true)}
onCancel={() => answerReviewConfirmation(false)}
/>
{/if}
+16 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { tick } from "svelte";
import { Check, ChevronDown, Search } from "@lucide/svelte";
@@ -7,6 +8,8 @@
label: string;
group?: string;
disabled?: boolean;
/** Small right-aligned text, e.g. a count. */
meta?: string;
}
interface Props {
@@ -20,6 +23,10 @@
searchable?: boolean;
searchPlaceholder?: string;
emptyText?: string;
/** Optional icon in front of every option, e.g. a branch or repository mark. */
optionIcon?: Snippet<[SelectMenuOption]>;
/** Optional right-aligned content per option, richer than `option.meta`. */
optionMeta?: Snippet<[SelectMenuOption]>;
onChange: (value: string) => void;
}
@@ -34,6 +41,8 @@
searchable = false,
searchPlaceholder = "Search…",
emptyText = "No results",
optionIcon = undefined,
optionMeta = undefined,
onChange,
}: Props = $props();
@@ -187,7 +196,13 @@
onmouseenter={() => { if (!option.disabled) activeIndex = index; }}
onclick={() => choose(index)}
>
<span>{option.label}</span>
{#if optionIcon}<span class="select-menu-option-icon">{@render optionIcon(option)}</span>{/if}
<span class="select-menu-option-label">{option.label}</span>
{#if optionMeta}
<small class="select-menu-option-meta">{@render optionMeta(option)}</small>
{:else if option.meta}
<small class="select-menu-option-meta">{option.meta}</small>
{/if}
{#if option.value === value}<Check size={14} aria-hidden="true" />{/if}
</button>
{/each}
+20 -19
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { Archive, ChevronDown, ChevronRight, Download, Plus, Trash2, Upload } from "@lucide/svelte";
import type { GitStash } from "../types";
import { t } from "../i18n.svelte";
interface Props {
stashes: GitStash[];
@@ -42,11 +43,11 @@
}
</script>
<section class="panel stash-panel overflow-hidden" class:collapsed aria-label="Git stash">
<section class="panel stash-panel overflow-hidden" class:collapsed aria-label={t("stashes.title")}>
<div class="section-head">
<h2 class="sidebar-section-title"><Archive size={16} aria-hidden="true" />Stashes</h2>
<h2 class="sidebar-section-title"><Archive size={16} aria-hidden="true" />{t("stashes.title")}</h2>
<div class="stash-head-actions">
<button class="stash-toggle" type="button" title="Create stash" aria-label="Create stash"
<button class="stash-toggle" type="button" title={t("stashes.create")} aria-label={t("stashes.create")}
disabled={!hasRepository || isBusy || changedCount === 0}
onclick={() => { createOpen = !createOpen; if (collapsed) { createOpen = true; onToggleCollapsed(); } }}>
<Plus size={14} aria-hidden="true" />
@@ -57,8 +58,8 @@
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title={collapsed ? "Expand stash panel" : "Collapse stash panel"}
aria-label={collapsed ? "Expand stash panel" : "Collapse stash panel"}
title={collapsed ? t("stashes.expand") : t("stashes.collapse")}
aria-label={collapsed ? t("stashes.expand") : t("stashes.collapse")}
>
{#if collapsed}
<ChevronRight size={14} aria-hidden="true" />
@@ -72,7 +73,7 @@
{#if collapsed}
<!-- collapsed -->
{:else if !hasRepository}
<div class="blank-state">No repository loaded.</div>
<div class="blank-state">{t("stashes.noRepo")}</div>
{:else}
{#if createOpen}
<div class="stash-create">
@@ -80,8 +81,8 @@
class="stash-input"
type="text"
bind:value={message}
placeholder="Optional message"
aria-label="Stash message"
placeholder={t("stashes.messagePlaceholder")}
aria-label={t("stashes.messageLabel")}
disabled={isBusy || changedCount === 0}
onkeydown={(event) => {
if (event.key === "Enter" && changedCount > 0 && !isBusy) submitPush();
@@ -89,24 +90,24 @@
/>
<label class="stash-check">
<input type="checkbox" bind:checked={includeUntracked} disabled={isBusy || changedCount === 0} />
Untracked
{t("stashes.untracked")}
</label>
<button
class="btn-sm stash-save-button"
type="button"
onclick={submitPush}
disabled={isBusy || changedCount === 0}
title="Save current working tree changes to a stash"
title={t("stashes.saveHint")}
>
<Archive size={14} aria-hidden="true" />
Stash
{t("stashes.save")}
</button>
</div>
{/if}
{#if stashes.length === 0}
<div class="blank-state stash-empty">No stashes saved.</div>
<div class="blank-state stash-empty">{t("stashes.empty")}</div>
{:else}
<div class="stash-list">
{#each stashes as stash (stash.selector)}
@@ -116,7 +117,7 @@
<span>
{stash.selector}
{#if stash.branch}
on {stash.branch}
{t("stashes.on", { branch: stash.branch })}
{/if}
{#if stash.date}
- {stash.date}
@@ -124,17 +125,17 @@
</span>
</div>
<div class="stash-actions">
<button class="btn-sm" type="button" onclick={() => onApply(stash)} disabled={isBusy} title="Apply stash and keep it">
<button class="btn-sm" type="button" onclick={() => onApply(stash)} disabled={isBusy} title={t("stashes.applyHint")}>
<Download size={13} aria-hidden="true" />
Apply
{t("stashes.apply")}
</button>
<button class="btn-sm" type="button" onclick={() => onPop(stash)} disabled={isBusy} title="Apply stash and remove it if successful">
<button class="btn-sm" type="button" onclick={() => onPop(stash)} disabled={isBusy} title={t("stashes.popHint")}>
<Upload size={13} aria-hidden="true" />
Pop
{t("stashes.pop")}
</button>
<button class="btn-sm danger" type="button" onclick={() => onDrop(stash)} disabled={isBusy} title="Delete stash">
<button class="btn-sm danger" type="button" onclick={() => onDrop(stash)} disabled={isBusy} title={t("stashes.dropHint")}>
<Trash2 size={13} aria-hidden="true" />
Drop
{t("stashes.drop")}
</button>
</div>
</article>
+95 -71
View File
@@ -3,7 +3,7 @@
Archive,
ArrowLeft,
ArrowRight,
FileDiff,
CopyCheck, FileDiff,
FileMinus2,
FileType,
FileX,
@@ -16,6 +16,7 @@
} from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png";
import type { FileStatusKind, GitFileStatus, GitIgnoreKind, GitStatus } from "../types";
import { t } from "../i18n.svelte";
interface Props {
changedFiles: GitFileStatus[];
@@ -33,7 +34,7 @@
onDiscardMany: (files: GitFileStatus[]) => void;
onStash: (files: GitFileStatus[], label: string) => void;
onIgnore: (target: string, kind: GitIgnoreKind) => void;
onStopTracking: (target: string, kind: "file" | "folder") => void;
onStopTracking: (targets: string[], kind: "file" | "folder" | "selection") => void;
onPatch: (file: GitFileStatus, staged: boolean) => void;
onStageAll: () => void;
onUnstageAll: () => void;
@@ -63,7 +64,8 @@
interface StatusContextTarget {
lane: StatusLaneKind;
kind: "file" | "folder";
/** "selection" is a right-click on one row of a multi-selection. */
kind: "file" | "folder" | "selection";
label: string;
files: GitFileStatus[];
}
@@ -227,9 +229,22 @@
event.preventDefault();
event.stopPropagation();
if (files.length === 0) return;
// Right-clicking a row that belongs to the current multi-selection acts on
// the whole selection, the same way the row buttons already do.
let targetKind: StatusContextTarget["kind"] = kind;
let targetFiles = files;
if (kind === "file" && files.length === 1 && isStatusSelected(files[0])) {
const laneFiles = selectedFiles().filter((file) => (lane === "unstaged" ? file.unstaged !== null : file.staged !== null));
if (laneFiles.length > 1) {
targetKind = "selection";
targetFiles = laneFiles;
}
}
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - 288));
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174));
statusContextTarget = { lane, kind, label, files };
statusContextTarget = { lane, kind: targetKind, label, files: targetFiles };
requestAnimationFrame(() => {
if (!statusContextMenuElement) return;
const bounds = statusContextMenuElement.getBoundingClientRect();
@@ -246,7 +261,7 @@
function statusContextParent(label: string): string {
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
const separator = normalized.lastIndexOf("/");
return separator > 0 ? normalized.slice(0, separator) : "Repository root";
return separator > 0 ? normalized.slice(0, separator) : t("status.repositoryRoot");
}
function closeStatusContextMenu() {
@@ -265,7 +280,7 @@
const target = statusContextTarget;
if (!target) return;
closeStatusContextMenu();
onStash(target.files, target.label);
onStash(target.files, target.kind === "selection" ? "" : target.label);
}
function isIgnoreableNewFile(file: GitFileStatus): boolean {
@@ -304,7 +319,8 @@
const target = statusContextTarget;
if (!target) return;
closeStatusContextMenu();
onStopTracking(target.label, target.kind);
const targets = target.kind === "selection" ? target.files.map((file) => file.path) : [target.label];
onStopTracking(targets, target.kind);
}
function handleStatusWindowKeydown(event: KeyboardEvent) {
@@ -395,10 +411,10 @@
let visibleStagedRows = $derived(statusView === "tree" ? flattenStatusTree(stagedTree, "staged") : listStatusRows(stagedFiles));
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
let statusContextCanIgnore = $derived(statusContextTarget?.files.some(isIgnoreableNewFile) ?? false);
let statusContextCanIgnore = $derived(statusContextTarget?.kind !== "selection" && (statusContextTarget?.files.some(isIgnoreableNewFile) ?? false));
let statusContextCanStopTracking = $derived(statusContextTarget?.files.some(isTrackedStatusFile) ?? false);
let statusContextIgnoreExtension = $derived(statusContextTarget?.kind === "file" ? statusContextExtension(statusContextTarget.label) : "");
let statusContextIgnoreFolder = $derived(statusContextTarget ? statusContextFolder(statusContextTarget) : "");
let statusContextIgnoreFolder = $derived(statusContextTarget && statusContextTarget.kind !== "selection" ? statusContextFolder(statusContextTarget) : "");
$effect(() => {
const validKeys = new Set(changedFiles.map(fileKey));
@@ -410,58 +426,58 @@
<svelte:window onclick={closeStatusContextMenu} onkeydown={handleStatusWindowKeydown} />
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Working tree status">
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label={t("status.panelLabel")}>
<div class="section-head">
<div>
<span class="eyebrow">Workspace</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
<span class="eyebrow">{t("status.eyebrow")}</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{t("status.title")}</h2>
</div>
<div class="status-view-switch" role="group" aria-label="Changes view">
<button class:active={statusView === "list"} type="button" onclick={() => statusView = "list"} title="List view" aria-label="List view" aria-pressed={statusView === "list"}>
<i class="status-view-icon list-icon" aria-hidden="true"></i><span>List</span>
<div class="status-view-switch" role="group" aria-label={t("status.viewGroup")}>
<button class:active={statusView === "list"} type="button" onclick={() => statusView = "list"} title={t("status.viewList")} aria-label={t("status.viewList")} aria-pressed={statusView === "list"}>
<i class="status-view-icon list-icon" aria-hidden="true"></i><span>{t("status.viewListShort")}</span>
</button>
<button class:active={statusView === "tree"} type="button" onclick={() => statusView = "tree"} title="Tree view" aria-label="Tree view" aria-pressed={statusView === "tree"}>
<FolderTree size={13} aria-hidden="true" /><span>Tree</span>
<button class:active={statusView === "tree"} type="button" onclick={() => statusView = "tree"} title={t("status.viewTree")} aria-label={t("status.viewTree")} aria-pressed={statusView === "tree"}>
<FolderTree size={13} aria-hidden="true" /><span>{t("status.viewTreeShort")}</span>
</button>
</div>
<div class="status-head-actions">
<span class="pill pill-count">{stagedCount} staged</span>
<span class="pill pill-count">{unstagedCount} unstaged</span>
<span class="pill pill-count">{t("status.staged", { count: stagedCount })}</span>
<span class="pill pill-count">{t("status.unstaged", { count: unstagedCount })}</span>
{#if hasRepository && changedFiles.length > 0}
<button class="btn-sm status-discard-all" type="button" onclick={() => onDiscardMany(changedFiles)} disabled={isBusy} title="Discard all staged and unstaged changes">
<RotateCcw size={13} aria-hidden="true" /> Discard all
<button class="btn-sm status-discard-all" type="button" onclick={() => onDiscardMany(changedFiles)} disabled={isBusy} title={t("status.discardAllHint")}>
<RotateCcw size={13} aria-hidden="true" /> {t("status.discardAll")}
</button>
{/if}
</div>
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
<div class="blank-state">{t("status.noRepo")}</div>
{:else if status?.clean}
<div class="blank-state">Working tree is clean.</div>
<div class="blank-state">{t("status.clean")}</div>
{:else if changedFiles.length === 0}
<div class="blank-state">No file changes returned.</div>
<div class="blank-state">{t("status.noChanges")}</div>
{:else}
<div class="status-lanes">
<section class="status-lane unstaged-lane" aria-label="Unstaged changes">
<section class="status-lane unstaged-lane" aria-label={t("status.laneUnstaged")}>
<header class="status-lane-head">
<div class="status-lane-title">
<div class="status-lane-copy"><strong>Unstaged</strong><small>Working tree</small></div>
<div class="status-lane-copy"><strong>{t("status.unstagedTitle")}</strong><small>{t("status.workingTree")}</small></div>
<span class="status-lane-count">{unstagedCount}</span>
</div>
<div class="status-lane-actions">
{#if selectedUnstagedCount > 1}
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={`Stage ${selectedUnstagedCount} selected files`}>
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={t("status.stageSelected", { count: selectedUnstagedCount })}>
<ArrowRight size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
</button>
{/if}
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
<ArrowRight size={13} aria-hidden="true" /> Stage all
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title={t("status.stageAllHint")}>
<ArrowRight size={13} aria-hidden="true" /> {t("status.stageAll")}
</button>
{#if selectedUnstagedCount > 1}
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={`Discard unstaged changes in ${selectedUnstagedCount} selected files`}>
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedUnstagedCount}
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={t("status.discardUnstagedSelected", { count: selectedUnstagedCount })}>
<RotateCcw size={13} aria-hidden="true" /> {t("status.discard")} {selectedUnstagedCount}
</button>
{/if}
</div>
@@ -479,20 +495,20 @@
{@const file = row.file}
{@const stageTargets = selectedStageTargets(file)}
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "file", file.path, [file])}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={t("status.selectInExplorer", { path: displayPath(file) })}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
</button>
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
<div class="status-file-actions">
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><ArrowRight size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={t("status.stageFile")}><ArrowRight size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title={t("status.showUnstagedDetails")}><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={t("status.discardUnstaged")}><RotateCcw size={14} aria-hidden="true" /></button>
</div>
</article>
{/if}
{/each}
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
{#if unstagedCount === 0}<p class="status-lane-empty">{t("status.emptyUnstaged")}</p>{/if}
</div>
</section>
@@ -500,25 +516,25 @@
<span><ArrowRight size={12} /></span>
</div>
<section class="status-lane staged-lane" aria-label="Staged changes">
<section class="status-lane staged-lane" aria-label={t("status.laneStaged")}>
<header class="status-lane-head">
<div class="status-lane-title">
<div class="status-lane-copy"><strong>Staged</strong><small>Next commit</small></div>
<div class="status-lane-copy"><strong>{t("status.stagedTitle")}</strong><small>{t("status.nextCommit")}</small></div>
<span class="status-lane-count">{stagedCount}</span>
</div>
<div class="status-lane-actions">
{#if selectedStagedCount > 1}
<span class="status-selection-count">{selectedStagedCount} selected</span>
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={`Unstage ${selectedStagedCount} selected files`}>
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={t("status.unstageSelected", { count: selectedStagedCount })}>
<ArrowLeft size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
</button>
{/if}
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
<ArrowLeft size={13} aria-hidden="true" /> Unstage all
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title={t("status.unstageAllHint")}>
<ArrowLeft size={13} aria-hidden="true" /> {t("status.unstageAll")}
</button>
{#if selectedStagedCount > 1}
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null), true)} disabled={isBusy} title={`Discard staged changes in ${selectedStagedCount} selected files`}>
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedStagedCount}
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null), true)} disabled={isBusy} title={t("status.discardStagedSelected", { count: selectedStagedCount })}>
<RotateCcw size={13} aria-hidden="true" /> {t("status.discard")} {selectedStagedCount}
</button>
{/if}
</div>
@@ -536,20 +552,20 @@
{@const file = row.file}
{@const unstageTargets = selectedUnstageTargets(file)}
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "file", file.path, [file])}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={t("status.selectInExplorer", { path: displayPath(file) })}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
</button>
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
<div class="status-file-actions">
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><ArrowLeft size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={t("status.unstageFile")}><ArrowLeft size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title={t("status.showStagedDetails")}><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={t("status.discardStaged")}><RotateCcw size={14} aria-hidden="true" /></button>
</div>
</article>
{/if}
{/each}
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
{#if stagedCount === 0}<p class="status-lane-empty">{t("status.emptyStaged")}</p>{/if}
</div>
</section>
</div>
@@ -568,7 +584,7 @@
</svg>
<img src={iconUrl} alt="" class="status-panel-overlay-icon" />
</div>
<span class="status-panel-overlay-label">{operation || "Working"}</span>
<span class="status-panel-overlay-label">{operation || t("status.working")}</span>
<div class="status-panel-overlay-bar"><span></span></div>
</div>
</div>
@@ -576,17 +592,23 @@
</section>
{#if statusContextTarget}
<div bind:this={statusContextMenuElement} class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
<div bind:this={statusContextMenuElement} class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={t("status.menuActionsFor", { name: statusContextTarget.label })} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
<div class="status-context-label">
<span class="status-context-object-icon" aria-hidden="true">
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else if statusContextTarget.kind === "selection"}<CopyCheck size={16} />{:else}<FileDiff size={16} />{/if}
</span>
<span class="status-context-object-copy">
<span class="status-context-kind">{statusContextTarget.lane === "unstaged" ? "Unstaged" : "Staged"} {statusContextTarget.kind}</span>
<strong title={statusContextTarget.label}>{statusContextName(statusContextTarget.label)}</strong>
<span class="status-context-path" title={statusContextTarget.label}><Folder size={10} aria-hidden="true" />{statusContextParent(statusContextTarget.label)}</span>
<span class="status-context-kind">{statusContextTarget.lane === "unstaged"
? (statusContextTarget.kind === "folder" ? t("status.menuKindUnstagedFolder") : statusContextTarget.kind === "selection" ? t("status.menuKindUnstagedSelection") : t("status.menuKindUnstagedFile"))
: (statusContextTarget.kind === "folder" ? t("status.menuKindStagedFolder") : statusContextTarget.kind === "selection" ? t("status.menuKindStagedSelection") : t("status.menuKindStagedFile"))}</span>
{#if statusContextTarget.kind === "selection"}
<strong>{t("status.menuFileCount", { count: statusContextTarget.files.length })}</strong>
{:else}
<strong title={statusContextTarget.label}>{statusContextName(statusContextTarget.label)}</strong>
<span class="status-context-path" title={statusContextTarget.label}><Folder size={10} aria-hidden="true" />{statusContextParent(statusContextTarget.label)}</span>
{/if}
</span>
<span class="status-context-count" title={`${statusContextTarget.files.length} ${statusContextTarget.files.length === 1 ? "file" : "files"}`}>
<span class="status-context-count" title={statusContextTarget.files.length === 1 ? t("status.menuFileCountOne") : t("status.menuFileCount", { count: statusContextTarget.files.length })}>
{statusContextTarget.files.length}
</span>
</div>
@@ -595,56 +617,58 @@
{#if statusContextTarget.lane === "unstaged"}<ArrowRight size={15} />{:else}<ArrowLeft size={15} />{/if}
</span>
<span class="status-context-action-copy">
<strong>{statusContextTarget.lane === "unstaged" ? "Stage" : "Unstage"} {statusContextTarget.kind}</strong>
<span>{statusContextTarget.lane === "unstaged" ? "Add to the next commit" : "Move back to working changes"}</span>
<strong>{statusContextTarget.lane === "unstaged"
? (statusContextTarget.kind === "folder" ? t("status.menuStageFolder") : statusContextTarget.kind === "selection" ? t("status.menuStageSelection", { count: statusContextTarget.files.length }) : t("status.menuStageFile"))
: (statusContextTarget.kind === "folder" ? t("status.menuUnstageFolder") : statusContextTarget.kind === "selection" ? t("status.menuUnstageSelection", { count: statusContextTarget.files.length }) : t("status.menuUnstageFile"))}</strong>
<span>{statusContextTarget.lane === "unstaged" ? t("status.menuStageHint") : t("status.menuUnstageHint")}</span>
</span>
</button>
<button type="button" role="menuitem" onclick={runStatusContextStashAction} disabled={isBusy}>
<span class="status-context-action-icon" aria-hidden="true"><Archive size={15} /></span>
<span class="status-context-action-copy">
<strong>Stash {statusContextTarget.kind}</strong>
<span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span>
<strong>{statusContextTarget.kind === "folder" ? t("status.menuStashFolder") : statusContextTarget.kind === "selection" ? t("status.menuStashSelection", { count: statusContextTarget.files.length }) : t("status.menuStashFile")}</strong>
<span>{statusContextTarget.files.length === 1 ? t("status.menuStashHintOne") : t("status.menuStashHint", { count: statusContextTarget.files.length })}</span>
</span>
</button>
{#if statusContextCanIgnore || statusContextCanStopTracking}
<div class="menu-separator" role="separator"></div>
{/if}
{#if statusContextCanStopTracking}
<button type="button" role="menuitem" onclick={runStatusContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
<button type="button" role="menuitem" onclick={runStatusContextStopTracking} disabled={isBusy} title={t("status.menuStopTrackingHint")}>
<span class="status-context-action-icon untrack" aria-hidden="true">
{#if statusContextTarget.kind === "folder"}<FolderMinus size={15} />{:else}<FileMinus2 size={15} />{/if}
</span>
<span class="status-context-action-copy">
<strong>Stop tracking {statusContextTarget.kind}</strong>
<span>Keep it on disk and remove it from Git</span>
<strong>{statusContextTarget.kind === "folder" ? t("status.menuStopTrackingFolder") : statusContextTarget.kind === "selection" ? t("status.menuStopTrackingSelection", { count: statusContextTarget.files.length }) : t("status.menuStopTrackingFile")}</strong>
<span>{t("status.menuStopTrackingNote")}</span>
</span>
</button>
{/if}
{#if statusContextCanIgnore}
{#if statusContextTarget.kind === "file"}
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("file")} disabled={isBusy} title={`Add /${statusContextTarget.label.replace(/\\/g, "/")} to .gitignore`}>
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("file")} disabled={isBusy} title={t("status.menuIgnoreFileHint", { path: statusContextTarget.label.replace(/\\/g, "/") })}>
<span class="status-context-action-icon ignore" aria-hidden="true"><FileX size={15} /></span>
<span class="status-context-action-copy">
<strong>Ignore file</strong>
<span>Add only this file to .gitignore</span>
<strong>{t("status.menuIgnoreFile")}</strong>
<span>{t("status.menuIgnoreFileNote")}</span>
</span>
</button>
{/if}
{#if statusContextIgnoreExtension}
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("extension")} disabled={isBusy} title={`Add *.${statusContextIgnoreExtension} to .gitignore`}>
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("extension")} disabled={isBusy} title={t("status.menuIgnoreExtHint", { ext: statusContextIgnoreExtension })}>
<span class="status-context-action-icon ignore" aria-hidden="true"><FileType size={15} /></span>
<span class="status-context-action-copy">
<strong>Ignore all *.{statusContextIgnoreExtension} files</strong>
<span>Match this file type repository-wide</span>
<strong>{t("status.menuIgnoreExt", { ext: statusContextIgnoreExtension })}</strong>
<span>{t("status.menuIgnoreExtNote")}</span>
</span>
</button>
{/if}
{#if statusContextTarget.kind === "folder" && statusContextIgnoreFolder}
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("folder")} disabled={isBusy} title={`Add /${statusContextIgnoreFolder}/ to .gitignore`}>
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("folder")} disabled={isBusy} title={t("status.menuIgnoreFolderHint", { path: statusContextIgnoreFolder })}>
<span class="status-context-action-icon ignore" aria-hidden="true"><FolderX size={15} /></span>
<span class="status-context-action-copy">
<strong>Ignore folder</strong>
<span>Add this folder and its contents to .gitignore</span>
<strong>{t("status.menuIgnoreFolder")}</strong>
<span>{t("status.menuIgnoreFolderNote")}</span>
</span>
</button>
{/if}
+93 -10
View File
@@ -1,22 +1,77 @@
<script lang="ts">
import { Boxes, Plus, RefreshCw, X, ExternalLink, GitCommitHorizontal, LoaderCircle } from "@lucide/svelte";
import type { GitSubmodule } from "../types";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { FolderOpen, Boxes, Plus, RefreshCw, X, ExternalLink, GitCommitHorizontal, LoaderCircle } from "@lucide/svelte";
import type { GitSubmodule, GitTag } from "../types";
interface Props {
repoPath: string;
modules: GitSubmodule[]; isLoading: boolean; isBusy: boolean; error: string;
language: "de" | "en"; recursive: boolean;
onRecursive: (value: boolean) => void;
onRefresh: () => void; onClose: () => void;
onAdd: (url: string, path: string, branch: string) => Promise<boolean>;
onAction: (module: GitSubmodule, action: "update" | "stage" | "sync") => Promise<boolean>;
onAction: (module: GitSubmodule, action: "update" | "stage" | "sync" | "fetch") => Promise<boolean>;
onLoadTags: (module: GitSubmodule) => Promise<GitTag[]>;
onCheckout: (module: GitSubmodule, revision: string, kind: "tag" | "commit") => Promise<boolean>;
onOpen: (module: GitSubmodule) => void;
}
let { modules, isLoading, isBusy, error, language, recursive, onRecursive, onRefresh, onClose, onAdd, onAction, onOpen }: Props = $props();
let { repoPath, modules, isLoading, isBusy, error, language, recursive, onRecursive, onRefresh, onClose, onAdd, onAction, onOpen, onLoadTags, onCheckout }: Props = $props();
const t = (de: string, en: string) => language === "de" ? de : en;
let selectedPath = $state("");
let adding = $state(false);
let url = $state("");
let destination = $state("");
let parentFolder = $state("");
let folderName = $state("");
let folderNameEdited = $state(false);
let browseError = $state("");
let browsing = $state(false);
const suggestedName = $derived((url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "").split(/[\\/:]/).filter(Boolean).pop() ?? "").replace(/\.git$/i, ""));
const effectiveName = $derived(folderNameEdited ? folderName.trim() : suggestedName);
const relativeParent = $derived(parentFolder.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, ""));
const destination = $derived([relativeParent === "." ? "" : relativeParent, effectiveName].filter(Boolean).join("/"));
const validDestination = $derived(Boolean(effectiveName) && !/[\\/:<>"|?*\x00-\x1f]/.test(effectiveName) && ![".", ".."].includes(effectiveName) && !effectiveName.startsWith("-") && !relativeParent.startsWith("/") && !relativeParent.includes(":") && relativeParent.split("/").every(part => part !== ".." && !part.startsWith("-")));
async function chooseSubmoduleFolder() {
browseError = ""; browsing = true;
try {
const chosen = await openDialog({ title: t("Zielordner im Repository auswählen", "Select destination inside repository"), directory: true, multiple: false, defaultPath: repoPath });
if (typeof chosen !== "string") return;
const root = repoPath.replace(/\\/g, "/").replace(/\/+$/, "");
const folder = chosen.replace(/\\/g, "/").replace(/\/+$/, "");
const windows = /^[a-z]:/i.test(root) || root.startsWith("//");
const compareRoot = windows ? root.toLowerCase() : root;
const compareFolder = windows ? folder.toLowerCase() : folder;
if (compareFolder !== compareRoot && !compareFolder.startsWith(compareRoot + "/")) {
browseError = t("Bitte einen Ordner innerhalb des Hauptrepositorys auswählen.", "Choose a folder inside the parent repository.");
return;
}
parentFolder = folder.slice(root.length).replace(/^\//, "");
} catch (error) { browseError = String(error); }
finally { browsing = false; }
}
let branch = $state("");
let revisionKind = $state<"tag" | "commit">("tag");
let revision = $state("");
let tags = $state<GitTag[]>([]);
let tagsLoading = $state(false);
let tagsError = $state("");
let tagRequest = 0;
async function loadTags(module: GitSubmodule) {
const request = ++tagRequest;
tagsLoading = true; tagsError = "";
try { const loaded = await onLoadTags(module); if (request === tagRequest) tags = loaded; }
catch (error) { if (request === tagRequest) tagsError = String(error); }
finally { if (request === tagRequest) tagsLoading = false; }
}
$effect(() => {
const module = selected;
revision = ""; tags = []; tagsError = "";
if (module?.local_commit) void loadTags(module);
else { tagRequest++; tagsLoading = false; }
return () => { tagRequest++; };
});
async function fetchRevisions() {
const module = selected;
if (module && await onAction(module, "fetch")) await loadTags(module);
}
let selected = $derived(modules.find(m => m.path === selectedPath) ?? modules[0]);
let disabled = $derived(isBusy || isLoading);
function statusLabel(m: GitSubmodule) {
@@ -31,7 +86,7 @@
(node.querySelector<HTMLElement>("button:not(:disabled)") ?? node).focus();
const trap = (event: KeyboardEvent) => {
if (event.key !== "Tab") return;
const controls = Array.from(node.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), [tabindex="0"]'));
const controls = Array.from(node.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex="0"]'));
const first = controls[0]; const last = controls[controls.length - 1];
if (!first) { event.preventDefault(); return; }
if (event.shiftKey && (document.activeElement === first || document.activeElement === node)) { event.preventDefault(); last.focus(); }
@@ -42,8 +97,9 @@
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (await onAdd(url.trim(), destination.trim(), branch.trim())) {
adding = false; url = ""; destination = ""; branch = "";
if (!validDestination || browsing) return;
if (await onAdd(url.trim(), destination, branch.trim())) {
adding = false; url = ""; parentFolder = ""; folderName = ""; folderNameEdited = false; branch = ""; browseError = "";
}
}
</script>
@@ -67,12 +123,17 @@
<form class="submodule-add" onsubmit={submit}>
<h3>{t("Submodul hinzufügen", "Add submodule")}</h3>
<label>{t("Repository-URL", "Repository URL")}<input bind:value={url} required disabled={disabled} placeholder="https://github.com/team/repository.git" /></label>
<label for="submodule-parent">{t("Zielordner · relativ zum Repository", "Destination folder · relative to repository")}</label>
<div class="submodule-folder-picker"><input id="submodule-parent" bind:value={parentFolder} disabled={disabled || browsing} placeholder={t(". (Repository-Hauptordner) oder libs", ". (repository root) or libs")} /><button class="btn-secondary" type="button" disabled={disabled || browsing} onclick={chooseSubmoduleFolder}><FolderOpen size={15} />{t("Durchsuchen", "Browse")}</button></div>
{#if browseError}<div class="submodule-error" role="alert">{browseError}</div>{/if}
<div class="submodule-fields">
<label>{t("Pfad im Repository", "Path in repository")}<input bind:value={destination} required disabled={disabled} placeholder="libs/repository" /></label>
<label>{t("Ordnername", "Folder name")}<input value={folderNameEdited ? folderName : suggestedName} oninput={event => { folderName = event.currentTarget.value; folderNameEdited = Boolean(folderName.trim()) && folderName !== suggestedName; }} disabled={disabled} placeholder={t("Wird aus der Repository-URL übernommen", "Taken from the repository URL")} /></label>
<label>{t("Tracking-Branch · optional", "Tracking branch · optional")}<input bind:value={branch} disabled={disabled} placeholder={t("Standard-Branch", "Default branch")} /></label>
</div>
<p>{t("Zielpfad", "Destination")}: <code>{destination || "—"}</code></p>
{#if destination && !validDestination}<p class="submodule-error" role="alert">{t("Bitte einen relativen Zielordner und einen gültigen Ordnernamen eingeben.", "Enter a relative destination folder and a valid folder name.")}</p>{/if}
<p>{t("Die .gitmodules-Datei und der neue Verweis werden zum Commit vorgemerkt.", "The .gitmodules file and new reference will be staged for commit.")}</p>
<div class="submodule-actions"><button class="btn-secondary" type="button" disabled={disabled} onclick={() => adding = false}>{t("Abbrechen", "Cancel")}</button><button class="btn-primary" disabled={disabled || !url.trim() || !destination.trim()}>{t("Submodul hinzufügen", "Add submodule")}</button></div>
<div class="submodule-actions"><button class="btn-secondary" type="button" disabled={disabled} onclick={() => adding = false}>{t("Abbrechen", "Cancel")}</button><button class="btn-primary" disabled={disabled || browsing || !url.trim() || !validDestination}>{t("Submodul hinzufügen", "Add submodule")}</button></div>
</form>
{/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}
</div>
{#if selected.local_commit}
<form class="submodule-revision" onsubmit={event => { event.preventDefault(); if (selected && revision.trim()) void onCheckout(selected, revision.trim(), revisionKind); }}>
<div class="revision-heading"><strong>{t("Commit oder Tag wechseln", "Change commit or tag")}</strong><button class="btn-sm" type="button" disabled={disabled || tagsLoading} onclick={fetchRevisions}><RefreshCw size={13} />{t("Tags & Commits abrufen", "Fetch tags & commits")}</button></div>
<label>{t("Auswahl", "Selection")}<select bind:value={revisionKind} onchange={() => revision = ""} disabled={disabled}><option value="tag">Tag</option><option value="commit">Commit</option></select></label>
{#if revisionKind === "tag"}
<label>Tag<select bind:value={revision} disabled={disabled || tagsLoading || !tags.length}><option value="">{tagsLoading ? t("Tags werden geladen…", "Loading tags…") : tags.length ? t("Tag auswählen", "Select tag") : t("Keine lokalen Tags", "No local tags")}</option>{#each tags as tag (tag.name)}<option value={tag.name}>{tag.name}</option>{/each}</select></label>
{:else}
<label>{t("Commit-Hash", "Commit hash")}<input bind:value={revision} disabled={disabled} placeholder="a81c2f4" spellcheck="false" required pattern={"[a-fA-F0-9]{4,64}"} /></label>
{/if}
{#if tagsError}<p class="submodule-error" role="alert">{tagsError}</p>{/if}
<button class="btn-secondary" disabled={disabled || selected.dirty || selected.conflicted || !revision.trim() || (revisionKind === "tag" && tagsLoading)}>{t("Ausgewählten Stand auschecken", "Check out selected revision")}</button>
<p>{t("Danach den neuen Verweis stagen und im übergeordneten Repository committen.", "Then stage the new reference and commit it in the parent repository.")}</p>
</form>
{/if}
<div class="submodule-actions">
<button class="btn-primary" disabled={disabled || selected.dirty || selected.conflicted} onclick={() => selected && onAction(selected, "update")}>{selected.local_commit ? t("Gespeicherten Stand auschecken", "Check out recorded commit") : t("Initialisieren", "Initialize")}</button>
{#if selected.local_commit && selected.local_commit !== selected.recorded_commit}<button class="btn-secondary" disabled={disabled || selected.conflicted} onclick={() => selected && onAction(selected, "stage")}>{t("Verweis stagen", "Stage reference")}</button>{/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; } }
</style>
+16 -15
View File
@@ -2,6 +2,7 @@
import { Check, ChevronDown, ChevronRight, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import { tick } from "svelte";
import type { GitTag } from "../types";
import { t } from "../i18n.svelte";
interface Props {
tags: GitTag[];
hasRepository: boolean;
@@ -93,21 +94,21 @@
</script>
<svelte:window on:click={closeTagContextMenu} on:keydown={(event) => { if (event.key === "Escape") closeTagContextMenu(); }} on:contextmenu|capture={closeTagContextMenu} />
<section class="panel tags-panel" class:collapsed aria-label="Tags">
<section class="panel tags-panel" class:collapsed aria-label={t("tags.title")}>
<div class="section-head">
<h2 class="sidebar-section-title"><TagIcon size={16} aria-hidden="true" />Tags</h2>
<h2 class="sidebar-section-title"><TagIcon size={16} aria-hidden="true" />{t("tags.title")}</h2>
<div class="branch-head-actions">
<button class="branch-create-toggle" type="button" onclick={openTagCreateForm} disabled={!hasRepository || isBusy} title="Create new tag" aria-label="Create new tag"><Plus size={14} aria-hidden="true" /></button>
<button class="branch-create-toggle" type="button" onclick={openTagCreateForm} disabled={!hasRepository || isBusy} title={t("tags.create")} aria-label={t("tags.create")}><Plus size={14} aria-hidden="true" /></button>
<span class="pill pill-count">{tags.length}</span>
<button class="branch-create-toggle panel-collapse-toggle" type="button" onclick={onToggleCollapsed}
aria-expanded={!collapsed} title={collapsed ? "Expand tags" : "Collapse tags"} aria-label={collapsed ? "Expand tags" : "Collapse tags"}>
aria-expanded={!collapsed} title={collapsed ? t("tags.expand") : t("tags.collapse")} aria-label={collapsed ? t("tags.expand") : t("tags.collapse")}>
{#if collapsed}<ChevronRight size={14} aria-hidden="true" />{:else}<ChevronDown size={14} aria-hidden="true" />{/if}
</button>
</div>
</div>
{#if !collapsed}
<div class="sidebar-tags-list">
{#if !hasRepository}<p class="branch-empty">Open a repository to list tags.</p>
{#if !hasRepository}<p class="branch-empty">{t("tags.openRepo")}</p>
{:else}
{#if tagCreateOpen}
<form class="branch-create-form tag-create-form" onsubmit={submitCreateTag}>
@@ -119,27 +120,27 @@
autocomplete="off"
spellcheck="false"
placeholder="v1.0.0"
aria-label="New tag name"
aria-label={t("tags.nameLabel")}
/>
<input
bind:value={newTagMessage}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="Message (optional)"
aria-label="Tag message"
placeholder={t("tags.messagePlaceholder")}
aria-label={t("tags.messageLabel")}
/>
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title="Create tag">
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title={t("tags.createAction")}>
<Check size={14} aria-hidden="true" />
</button>
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title="Cancel">
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title={t("common.cancel")}>
<X size={14} aria-hidden="true" />
</button>
</form>
{/if}
{#if tags.length === 0}
<div class="branch-empty">No tags.</div>
<div class="branch-empty">{t("tags.empty")}</div>
{:else}
{#each tags as tag (tag.name)}
<article
@@ -167,16 +168,16 @@
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextTag.name}`}
aria-label={t("tags.actionsFor", { name: contextTag.name })}
>
<button type="button" role="menuitem" onclick={pushContextTag} disabled={isBusy}>
<Upload size={14} aria-hidden="true" />
Push to remote
{t("tags.push")}
</button>
<div class="menu-separator" role="separator"></div>
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title="Delete local tag">
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title={t("tags.deleteLocal")}>
<Trash2 size={14} aria-hidden="true" />
Delete
{t("common.delete")}
</button>
</div>
{/if}
+85 -89
View File
@@ -21,6 +21,8 @@
X,
} from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitWorktree } from "../types";
import { t } from "../i18n.svelte";
import ConfirmDialog, { type ConfirmRequest } from "./ConfirmDialog.svelte";
import SelectMenu from "./SelectMenu.svelte";
type CreateMode = "existing" | "new" | "detached";
@@ -99,7 +101,7 @@
let checkedOutBranches = $derived(new Set(worktrees.map((worktree) => worktree.branch).filter((branch): branch is string => Boolean(branch))));
function displayName(worktree: GitWorktree): string {
return worktree.branch || (worktree.detached ? `Detached at ${worktree.short_head || "HEAD"}` : "Bare worktree");
return worktree.branch || (worktree.detached ? t("worktreeDialog.detachedAt", { head: worktree.short_head || "HEAD" }) : t("worktreeDialog.bare"));
}
function pathName(path: string): string {
@@ -117,7 +119,7 @@
async function chooseDestination(current = "") {
const selected = await openDialog({
title: current ? "Choose new worktree location" : "Choose worktree folder",
title: current ? t("worktreeDialog.chooseNewLocation") : t("worktreeDialog.chooseFolder"),
directory: true,
multiple: false,
defaultPath: current || undefined,
@@ -175,14 +177,33 @@
forceRemoval = false;
}
async function confirmRemoval() {
async function confirmRemoval(force: boolean) {
if (!pendingRemoval) return;
if (await onRemove(pendingRemoval, forceRemoval)) {
forceRemoval = force;
if (await onRemove(pendingRemoval, force)) {
pendingRemoval = null;
forceRemoval = false;
}
}
/** Same shape as every other delete confirmation in the app. */
function removalConfirmRequest(worktree: GitWorktree): ConfirmRequest {
return {
eyebrow: t("worktreeDialog.removeEyebrow"),
title: t("confirm.worktreeRemove.title", { name: displayName(worktree) }),
message: t("confirm.worktreeRemove.message"),
items: [worktree.path],
checkbox: worktree.clean
? undefined
: {
label: t("worktreeDialog.removeForce"),
note: t("worktreeDialog.removeForceNote", { count: worktree.changed_files }),
required: true,
},
confirmLabel: t("confirm.worktreeRemove.action"),
};
}
function requestLock(worktree: GitWorktree) {
pendingLock = worktree;
lockReason = "";
@@ -203,28 +224,28 @@
<div class="worktree-dialog-heading unified-dialog-heading">
<span class="worktree-dialog-mark unified-dialog-icon" aria-hidden="true"><HardDrive size={18} /></span>
<div class="unified-dialog-text">
<span class="eyebrow">Parallel workspaces</span>
<p class="dialog-title" id="worktree-dialog-title">Worktrees</p>
<span class="eyebrow">{t("worktreeDialog.eyebrow")}</span>
<p class="dialog-title" id="worktree-dialog-title">{t("worktrees.title")}</p>
</div>
</div>
<div class="dialog-header-actions">
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading} title="Refresh worktrees">
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading} title={t("worktreeDialog.refresh")}>
<RefreshCw class={isLoading ? "spin" : undefined} size={15} aria-hidden="true" />
Refresh
{t("worktreeDialog.refreshShort")}
</button>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label={t("common.close")}>
<X size={16} aria-hidden="true" />
</button>
</div>
</header>
<div class="worktree-summary">
<div><strong>{linkedCount}</strong><span>linked worktrees</span></div>
<div><strong>{worktrees.filter((worktree) => !worktree.clean).length}</strong><span>with changes</span></div>
<div class:attention={prunableCount > 0}><strong>{prunableCount}</strong><span>stale entries</span></div>
<div><strong>{linkedCount}</strong><span>{t("worktreeDialog.linked")}</span></div>
<div><strong>{worktrees.filter((worktree) => !worktree.clean).length}</strong><span>{t("worktreeDialog.withChanges")}</span></div>
<div class:attention={prunableCount > 0}><strong>{prunableCount}</strong><span>{t("worktreeDialog.stale")}</span></div>
<button class="btn-primary" type="button" onclick={() => { createOpen = !createOpen; }} disabled={isBusy}>
<Plus size={15} aria-hidden="true" />
New worktree
{t("worktreeDialog.new")}
</button>
</div>
@@ -237,33 +258,33 @@
<form class="worktree-create-card" onsubmit={submitCreate}>
<header>
<div>
<span class="eyebrow">Create</span>
<h3>Choose what this workspace should track</h3>
<span class="eyebrow">{t("worktreeDialog.createEyebrow")}</span>
<h3>{t("worktreeDialog.createTitle")}</h3>
</div>
<button class="btn-sm dialog-close" type="button" onclick={() => { createOpen = false; }} disabled={isBusy} aria-label="Close create form">
<button class="btn-sm dialog-close" type="button" onclick={() => { createOpen = false; }} disabled={isBusy} aria-label={t("worktreeDialog.closeCreate")}>
<X size={15} aria-hidden="true" />
</button>
</header>
<div class="worktree-mode-tabs" role="tablist" aria-label="Worktree type">
<div class="worktree-mode-tabs" role="tablist" aria-label={t("worktreeDialog.typeLabel")}>
<button class:active={createMode === "existing"} type="button" role="tab" aria-selected={createMode === "existing"} onclick={() => { createMode = "existing"; }}>
<GitBranch size={14} aria-hidden="true" />Existing branch
<GitBranch size={14} aria-hidden="true" />{t("worktreeDialog.existingBranch")}
</button>
<button class:active={createMode === "new"} type="button" role="tab" aria-selected={createMode === "new"} onclick={() => { createMode = "new"; }}>
<Plus size={14} aria-hidden="true" />New branch
<Plus size={14} aria-hidden="true" />{t("worktreeDialog.newBranch")}
</button>
<button class:active={createMode === "detached"} type="button" role="tab" aria-selected={createMode === "detached"} onclick={() => { createMode = "detached"; }}>
<CircleDot size={14} aria-hidden="true" />Detached
<CircleDot size={14} aria-hidden="true" />{t("worktreeDialog.detached")}
</button>
</div>
<div class="worktree-create-fields">
{#if createMode === "existing"}
<label>
<span>Branch</span>
<span>{t("common.branch")}</span>
<SelectMenu
value={selectedBranch}
placeholder="Select a local branch"
placeholder={t("worktreeDialog.selectBranch")}
options={localBranches.map((branch) => ({
value: branch.name,
label: `${branch.name}${!branchAvailable(branch.name) ? " (already checked out)" : ""}`,
@@ -275,26 +296,26 @@
</label>
{:else if createMode === "new"}
<label>
<span>New branch name</span>
<input bind:value={newBranch} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="feature/my-change" />
<span>{t("worktreeDialog.newBranchName")}</span>
<input bind:value={newBranch} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder={t("worktreeDialog.newBranchPlaceholder")} />
</label>
<label>
<span>Start point</span>
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD, branch or commit" />
<span>{t("worktreeDialog.startPoint")}</span>
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder={t("worktreeDialog.startPointPlaceholder")} />
</label>
{:else}
<label>
<span>Commit or ref</span>
<span>{t("worktreeDialog.commitOrRef")}</span>
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD" />
</label>
{/if}
<label class="worktree-path-field">
<span>Folder</span>
<span>{t("worktreeDialog.folder")}</span>
<div>
<input bind:value={destination} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="Choose an empty folder" />
<input bind:value={destination} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder={t("worktreeDialog.folderPlaceholder")} />
<button class="btn-secondary" type="button" onclick={() => chooseDestination()} disabled={isBusy}>
<FolderOpen size={15} aria-hidden="true" />Browse
<FolderOpen size={15} aria-hidden="true" />{t("worktreeDialog.browse")}
</button>
</div>
</label>
@@ -303,7 +324,7 @@
<footer>
<label class="worktree-check">
<input type="checkbox" bind:checked={lockAfterCreate} disabled={isBusy} />
<span><strong>Lock after creation</strong><small>Protects removable or temporary locations from pruning.</small></span>
<span><strong>{t("worktreeDialog.lockAfterCreate")}</strong><small>{t("worktreeDialog.lockAfterCreateNote")}</small></span>
</label>
<button
class="btn-primary"
@@ -311,16 +332,16 @@
disabled={isBusy || !destination.trim() || (createMode === "existing" && (!selectedBranch || !branchAvailable(selectedBranch))) || (createMode === "new" && !newBranch.trim())}
>
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Plus size={15} aria-hidden="true" />{/if}
Create worktree
{t("worktreeDialog.createAction")}
</button>
</footer>
</form>
{/if}
{#if isLoading && worktrees.length === 0}
<div class="worktree-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>Reading worktrees…</span></div>
<div class="worktree-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>{t("worktreeDialog.loading")}</span></div>
{:else if worktrees.length === 0}
<div class="worktree-empty"><HardDrive size={24} aria-hidden="true" /><strong>No worktrees found</strong><span>Create one to work on another branch without switching this workspace.</span></div>
<div class="worktree-empty"><HardDrive size={24} aria-hidden="true" /><strong>{t("worktreeDialog.emptyTitle")}</strong><span>{t("worktreeDialog.emptyNote")}</span></div>
{:else}
<div class="worktree-list">
{#each worktrees as worktree (worktree.path)}
@@ -339,11 +360,11 @@
</div>
</div>
<div class="worktree-badges">
{#if worktree.is_main}<span>Main</span>{/if}
{#if worktree.is_current}<span class="active">Open</span>{/if}
{#if worktree.detached}<span>Detached</span>{/if}
{#if worktree.locked}<span class="locked"><Lock size={10} aria-hidden="true" />Locked</span>{/if}
{#if worktree.prunable || worktree.missing}<span class="danger">Stale</span>{/if}
{#if worktree.is_main}<span>{t("worktreeDialog.main")}</span>{/if}
{#if worktree.is_current}<span class="active">{t("worktreeDialog.open")}</span>{/if}
{#if worktree.detached}<span>{t("worktreeDialog.detached")}</span>{/if}
{#if worktree.locked}<span class="locked"><Lock size={10} aria-hidden="true" />{t("worktreeDialog.locked")}</span>{/if}
{#if worktree.prunable || worktree.missing}<span class="danger">{t("worktreeDialog.staleBadge")}</span>{/if}
</div>
</header>
@@ -360,31 +381,31 @@
<footer>
<button class="btn-secondary" type="button" onclick={() => onOpen(worktree)} disabled={isBusy || worktree.missing || worktree.bare}>
<ExternalLink size={14} aria-hidden="true" />{worktree.is_current ? "Refresh tab" : "Open tab"}
<ExternalLink size={14} aria-hidden="true" />{worktree.is_current ? t("worktreeDialog.refreshTab") : t("worktreeDialog.openTab")}
</button>
<div class="worktree-card-actions">
{#if worktree.prunable}
<button class="btn-sm" type="button" onclick={() => chooseRepairLocation(worktree)} disabled={isBusy} title="Locate and repair worktree">
<Wrench size={14} aria-hidden="true" />Repair
<button class="btn-sm" type="button" onclick={() => chooseRepairLocation(worktree)} disabled={isBusy} title={t("worktreeDialog.repairHint")}>
<Wrench size={14} aria-hidden="true" />{t("worktreeDialog.repair")}
</button>
{/if}
{#if !worktree.is_main && !worktree.missing}
<button class="btn-sm" type="button" onclick={() => chooseMoveDestination(worktree)} disabled={isBusy || worktree.locked || worktree.is_current} title="Move worktree">
<FolderInput size={14} aria-hidden="true" />Move
<button class="btn-sm" type="button" onclick={() => chooseMoveDestination(worktree)} disabled={isBusy || worktree.locked || worktree.is_current} title={t("worktreeDialog.moveHint")}>
<FolderInput size={14} aria-hidden="true" />{t("worktreeDialog.move")}
</button>
{/if}
{#if worktree.locked}
<button class="btn-sm" type="button" onclick={() => onUnlock(worktree)} disabled={isBusy} title="Unlock worktree">
<Unlock size={14} aria-hidden="true" />Unlock
<button class="btn-sm" type="button" onclick={() => onUnlock(worktree)} disabled={isBusy} title={t("worktreeDialog.unlockHint")}>
<Unlock size={14} aria-hidden="true" />{t("worktreeDialog.unlock")}
</button>
{:else if !worktree.is_main}
<button class="btn-sm" type="button" onclick={() => requestLock(worktree)} disabled={isBusy} title="Lock worktree">
<Lock size={14} aria-hidden="true" />Lock
<button class="btn-sm" type="button" onclick={() => requestLock(worktree)} disabled={isBusy} title={t("worktreeDialog.lockHint")}>
<Lock size={14} aria-hidden="true" />{t("worktreeDialog.lock")}
</button>
{/if}
{#if !worktree.is_main}
<button class="btn-sm danger" type="button" onclick={() => requestRemoval(worktree)} disabled={isBusy || worktree.is_current || worktree.locked || worktree.missing} title={worktree.missing ? "Use Prune to remove stale metadata" : "Remove worktree"}>
<Trash2 size={14} aria-hidden="true" />Remove
<button class="btn-sm danger" type="button" onclick={() => requestRemoval(worktree)} disabled={isBusy || worktree.is_current || worktree.locked || worktree.missing} title={worktree.missing ? t("worktreeDialog.removePruneHint") : t("worktreeDialog.removeHint")}>
<Trash2 size={14} aria-hidden="true" />{t("worktreeDialog.remove")}
</button>
{/if}
</div>
@@ -397,7 +418,7 @@
</div>
<footer class="worktree-dialog-footer">
<div><ShieldCheck size={14} aria-hidden="true" /><span>Dirty, active and locked worktrees are protected.</span></div>
<div><ShieldCheck size={14} aria-hidden="true" /><span>{t("worktreeDialog.protectedNote")}</span></div>
<button class="btn-secondary" type="button" onclick={onPrune} disabled={isBusy || prunableCount === 0}>
<Wrench size={14} aria-hidden="true" />Prune {prunableCount || ""}
</button>
@@ -406,37 +427,12 @@
</div>
{#if pendingRemoval}
<div class="dialog-backdrop worktree-nested-backdrop" role="presentation">
<div class="dialog worktree-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="worktree-remove-title">
<header class="dialog-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><HardDrive size={18} /></span>
<div class="unified-dialog-text">
<span class="eyebrow">Remove worktree</span>
<p class="dialog-title" id="worktree-remove-title">Remove {displayName(pendingRemoval)}?</p>
</div>
<button class="dialog-close" type="button" onclick={() => { pendingRemoval = null; }} disabled={isBusy} aria-label="Cancel removal"><X size={18} /></button>
</header>
<div class="worktree-confirm-body">
<span class="discard-warning-icon" aria-hidden="true"><AlertTriangle size={21} /></span>
<div>
<p>This removes the worktree folder and its Git registration. The branch itself is kept.</p>
<code>{pendingRemoval.path}</code>
{#if !pendingRemoval.clean}
<label class="worktree-check danger">
<input type="checkbox" bind:checked={forceRemoval} disabled={isBusy} />
<span><strong>Remove despite local changes</strong><small>{pendingRemoval.changed_files} changed files may be permanently deleted.</small></span>
</label>
{/if}
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={() => { pendingRemoval = null; }} disabled={isBusy}>Cancel</button>
<button class="btn-danger" type="button" onclick={confirmRemoval} disabled={isBusy || (!pendingRemoval.clean && !forceRemoval)}>
<Trash2 size={15} aria-hidden="true" />Remove worktree
</button>
</footer>
</div>
</div>
<ConfirmDialog
request={removalConfirmRequest(pendingRemoval)}
{isBusy}
onConfirm={(result) => { void confirmRemoval(result.checked); }}
onCancel={() => { pendingRemoval = null; forceRemoval = false; }}
/>
{/if}
{#if pendingLock}
@@ -445,20 +441,20 @@
<header class="dialog-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><HardDrive size={18} /></span>
<div class="unified-dialog-text">
<span class="eyebrow">Protect worktree</span>
<p class="dialog-title" id="worktree-lock-title">Lock {displayName(pendingLock)}</p>
<span class="eyebrow">{t("worktreeDialog.lockEyebrow")}</span>
<p class="dialog-title" id="worktree-lock-title">{t("worktreeDialog.lockTitle", { name: displayName(pendingLock) })}</p>
</div>
<button class="dialog-close" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy} aria-label="Cancel locking"><X size={18} /></button>
<button class="dialog-close" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy} aria-label={t("worktreeDialog.cancelLock")}><X size={18} /></button>
</header>
<div class="worktree-lock-body">
<label>
<span>Reason <small>optional</small></span>
<input bind:value={lockReason} disabled={isBusy} autocomplete="off" placeholder="External drive, long-running work…" />
<span>{t("worktreeDialog.reason")} <small>{t("worktreeDialog.optional")}</small></span>
<input bind:value={lockReason} disabled={isBusy} autocomplete="off" placeholder={t("worktreeDialog.reasonPlaceholder")} />
</label>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy}>Cancel</button>
<button class="btn-primary" type="button" onclick={confirmLock} disabled={isBusy}><Lock size={15} aria-hidden="true" />Lock</button>
<button class="btn-secondary" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy}>{t("common.cancel")}</button>
<button class="btn-primary" type="button" onclick={confirmLock} disabled={isBusy}><Lock size={15} aria-hidden="true" />{t("worktreeDialog.lock")}</button>
</footer>
</div>
</div>
+15 -14
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { ChevronDown, ChevronRight, GitBranch, HardDrive, Lock, Plus, RefreshCw } from "@lucide/svelte";
import type { GitWorktree } from "../types";
import { t } from "../i18n.svelte";
interface Props {
worktrees: GitWorktree[];
@@ -19,18 +20,18 @@
const name = (path: string) => path.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || path;
</script>
<section class="panel worktree-panel" class:collapsed aria-label="Worktrees" aria-busy={loading}>
<section class="panel worktree-panel" class:collapsed aria-label={t("worktrees.title")} aria-busy={loading}>
<div class="section-head">
<h2 class="sidebar-section-title"><HardDrive size={16} aria-hidden="true" />Worktrees</h2>
<h2 class="sidebar-section-title"><HardDrive size={16} aria-hidden="true" />{t("worktrees.title")}</h2>
<div class="branch-head-actions">
<button class="branch-create-toggle" type="button" onclick={onManage} disabled={!hasRepository || isBusy}
title="Create or manage worktrees" aria-label="Create or manage worktrees" aria-haspopup="dialog">
title={t("worktrees.manage")} aria-label={t("worktrees.manage")} aria-haspopup="dialog">
<Plus size={14} aria-hidden="true" />
</button>
<span class="pill pill-count">{loading && linkedWorktrees.length === 0 ? "…" : linkedWorktrees.length}</span>
<button class="branch-create-toggle panel-collapse-toggle" type="button" onclick={onToggleCollapsed}
aria-expanded={!collapsed} title={collapsed ? "Expand worktrees" : "Collapse worktrees"}
aria-label={collapsed ? "Expand worktrees" : "Collapse worktrees"}>
aria-expanded={!collapsed} title={collapsed ? t("worktrees.expand") : t("worktrees.collapse")}
aria-label={collapsed ? t("worktrees.expand") : t("worktrees.collapse")}>
{#if collapsed}<ChevronRight size={14} aria-hidden="true" />{:else}<ChevronDown size={14} aria-hidden="true" />{/if}
</button>
</div>
@@ -38,30 +39,30 @@
{#if !collapsed}
<div class="sidebar-worktree-list">
{#if !hasRepository}
<p class="branch-empty">Open a repository to list worktrees.</p>
<p class="branch-empty">{t("worktrees.openRepo")}</p>
{:else if error}
<div class="sidebar-worktree-error" role="status">
<span>{error}</span>
<button class="btn-sm" type="button" onclick={onRefresh} disabled={loading || isBusy}><RefreshCw size={13} aria-hidden="true" />Retry</button>
<button class="btn-sm" type="button" onclick={onRefresh} disabled={loading || isBusy}><RefreshCw size={13} aria-hidden="true" />{t("worktrees.retry")}</button>
</div>
{:else if loading && linkedWorktrees.length === 0}
<p class="branch-empty" role="status">Loading worktrees…</p>
<p class="branch-empty" role="status">{t("worktrees.loading")}</p>
{:else if linkedWorktrees.length === 0}
<p class="branch-empty">No linked worktrees.</p>
<p class="branch-empty">{t("worktrees.empty")}</p>
{:else}
{#each linkedWorktrees as worktree (worktree.path)}
<button class="sidebar-worktree-row" class:current={worktree.is_current} type="button"
onclick={() => onOpen(worktree)} disabled={isBusy || worktree.missing || worktree.bare}
aria-current={worktree.is_current ? "location" : undefined}
title={`${worktree.path}${worktree.missing ? " — missing" : worktree.bare ? " — bare repository" : ""}`}>
title={`${worktree.path}${worktree.missing ? t("worktrees.missingSuffix") : worktree.bare ? t("worktrees.bareSuffix") : ""}`}>
<HardDrive size={15} aria-hidden="true" />
<span class="sidebar-worktree-info">
<strong>{name(worktree.path)}</strong>
<span><GitBranch size={12} aria-hidden="true" />{worktree.branch || (worktree.bare ? "Bare repository" : `Detached · ${worktree.short_head || "HEAD"}`)}</span>
<span><GitBranch size={12} aria-hidden="true" />{worktree.branch || (worktree.bare ? t("worktrees.bare") : t("worktrees.detached", { head: worktree.short_head || "HEAD" }))}</span>
</span>
{#if worktree.locked}<Lock size={12} aria-label="Locked" />{/if}
{#if worktree.missing}<span class="pill">Missing</span>
{:else if worktree.is_current}<span class="pill pill-active">Current</span>{/if}
{#if worktree.locked}<Lock size={12} aria-label={t("worktrees.locked")} />{/if}
{#if worktree.missing}<span class="pill">{t("worktrees.missing")}</span>
{:else if worktree.is_current}<span class="pill pill-active">{t("worktrees.current")}</span>{/if}
</button>
{/each}
{/if}
+8 -4
View File
@@ -737,9 +737,13 @@ export function pullRequestAiGenerate(path: string, remote: string, sourceBranch
export function listSubmodules(path: string, recursive = true): Promise<GitSubmodule[]> {
return invoke("list_submodules", { path, recursive });
}
export function addSubmodule(path: string, url: string, destination: string, branch?: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
return invoke("checkout_submodule_revision", { path, modulePath, revision, kind });
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Central translation helper.
*
* The active language lives in module scope so components can call `t(...)`
* without threading a `language` prop through every layer. Reading `t(...)`
* inside markup registers a dependency on `activeLanguage`, so switching the
* language in settings re-renders every translated string.
*/
import { messages, type MessageEntry, type MessageKey } from "./messages";
import type { AppLanguage } from "./types";
let activeLanguage = $state<AppLanguage>("en");
export function setLanguage(next: AppLanguage) {
activeLanguage = next;
}
export function getLanguage(): AppLanguage {
return activeLanguage;
}
export function isGermanLanguage(): boolean {
return activeLanguage === "de";
}
export type TranslationValues = Record<string, string | number>;
/** Look up `key` in the active language and fill in `{placeholders}`. */
export function t(key: MessageKey, values?: TranslationValues): string {
const entry: MessageEntry | undefined = messages[key];
let text: string = entry ? entry[activeLanguage] ?? entry.en : key;
if (values) {
for (const [name, value] of Object.entries(values)) {
text = text.split(`{${name}}`).join(String(value));
}
}
return text;
}
/** Pick the singular or plural key based on `count` and pass it as `{count}`. */
export function tPlural(one: MessageKey, many: MessageKey, count: number, values?: TranslationValues): string {
return t(count === 1 ? one : many, { count, ...values });
}
+500
View File
@@ -0,0 +1,500 @@
/**
* User-facing strings, English first with the German translation beside it.
* Keys are grouped by the area they appear in. Use `{name}` for placeholders.
*/
export const messages = {
// ── Shared wording ─────────────────────────────────────────────────────────
"common.cancel": { en: "Cancel", de: "Abbrechen" },
"common.close": { en: "Close", de: "Schließen" },
"common.delete": { en: "Delete", de: "Löschen" },
"common.rename": { en: "Rename", de: "Umbenennen" },
"common.checkout": { en: "Checkout", de: "Auschecken" },
"common.local": { en: "Local", de: "Lokal" },
"common.remote": { en: "Remote", de: "Remote" },
"common.confirm": { en: "Confirm", de: "Bestätigen" },
"common.branch": { en: "Branch", de: "Branch" },
"common.commit": { en: "Commit", de: "Commit" },
// ── Confirmation dialog ────────────────────────────────────────────────────
"confirm.eyebrow": { en: "Please confirm", de: "Bitte bestätigen" },
"confirm.more": { en: "+{count} more", de: "+{count} weitere" },
"confirm.moreOne": { en: "+1 more", de: "+1 weiterer" },
// ── Confirmations ──────────────────────────────────────────────────────────
"confirm.branchFolder.title": { en: "Delete {count} branches?", de: "{count} Branches löschen?" },
"confirm.branchFolder.titleOne": { en: "Delete 1 branch?", de: "1 Branch löschen?" },
"confirm.branchFolder.messageLocalOne": { en: "This local branch in the folder “{folder}” will be deleted.", de: "Dieser lokale Branch im Ordner „{folder}“ wird gelöscht." },
"confirm.branchFolder.messageRemoteOne": { en: "This remote branch in the folder “{folder}” will be deleted on the remote.", de: "Dieser Remote-Branch im Ordner „{folder}“ wird auf dem Remote gelöscht." },
"confirm.branchFolder.messageLocal": { en: "These local branches in the folder “{folder}” will be deleted.", de: "Diese lokalen Branches im Ordner „{folder}“ werden gelöscht." },
"confirm.branchFolder.messageRemote": { en: "These remote branches in the folder “{folder}” will be deleted on the remote.", de: "Diese Remote-Branches im Ordner „{folder}“ werden auf dem Remote gelöscht." },
"confirm.branchFolder.noteCurrent": { en: "The current branch stays and is not deleted.", de: "Der aktuelle Branch bleibt erhalten und wird nicht gelöscht." },
"confirm.branchFolder.noteRemote": { en: "This affects everyone working with this remote.", de: "Das betrifft alle, die mit diesem Remote arbeiten." },
"confirm.rebaseAbort.title": { en: "Abort rebase?", de: "Rebase abbrechen?" },
"confirm.rebaseAbort.message": { en: "The rebase stops and the repository returns to the state it had before it started.", de: "Der Rebase wird gestoppt und das Repository kehrt in den Zustand davor zurück." },
"confirm.rebaseAbort.action": { en: "Abort rebase", de: "Rebase abbrechen" },
"confirm.cherryPickAbort.title": { en: "Abort cherry-pick?", de: "Cherry-Pick abbrechen?" },
"confirm.cherryPickAbort.message": { en: "The cherry-pick stops and the repository returns to the state it had before it started.", de: "Der Cherry-Pick wird gestoppt und das Repository kehrt in den Zustand davor zurück." },
"confirm.cherryPickAbort.action": { en: "Abort cherry-pick", de: "Cherry-Pick abbrechen" },
"confirm.mergeAbort.title": { en: "Abort merge?", de: "Merge abbrechen?" },
"confirm.mergeAbort.message": { en: "The merge stops and your files return to the state they had before it started.", de: "Der Merge wird gestoppt und deine Dateien kehren in den Zustand davor zurück." },
"confirm.mergeAbort.action": { en: "Abort merge", de: "Merge abbrechen" },
"confirm.tagDelete.title": { en: "Delete tag {name}?", de: "Tag {name} löschen?" },
"confirm.tagDelete.message": { en: "The tag is removed from your local repository.", de: "Das Tag wird aus deinem lokalen Repository entfernt." },
"confirm.tagDelete.note": { en: "A copy already pushed to a remote is kept.", de: "Eine bereits gepushte Kopie auf einem Remote bleibt bestehen." },
"confirm.stashDrop.title": { en: "Delete stash {name}?", de: "Stash {name} löschen?" },
"confirm.stashDrop.message": { en: "The stashed changes are deleted.", de: "Die gestashten Änderungen werden gelöscht." },
"confirm.stashDrop.note": { en: "This cannot be undone.", de: "Das lässt sich nicht rückgängig machen." },
"confirm.forcePush.title": { en: "Force push?", de: "Force-Push ausführen?" },
"confirm.forcePush.message": { en: "The current branch is pushed with --force-with-lease, overwriting the remote branch with your history.", de: "Der aktuelle Branch wird mit --force-with-lease gepusht und überschreibt den Remote-Branch mit deiner Historie." },
"confirm.forcePush.note": { en: "Intended for a branch whose history you rebased. Commits others pushed in the meantime can be lost.", de: "Gedacht für einen Branch, dessen Historie du umgeschrieben hast. Commits, die andere inzwischen gepusht haben, können verloren gehen." },
"confirm.forcePush.action": { en: "Force push", de: "Force-Push" },
"confirm.revert.title": { en: "Revert commit {hash}?", de: "Commit {hash} rückgängig machen?" },
"confirm.revert.message": { en: "A new commit is created that reverses the changes of this commit. Nothing is removed from the history.", de: "Es wird ein neuer Commit erstellt, der die Änderungen dieses Commits zurücknimmt. Aus der Historie wird nichts entfernt." },
"confirm.revert.action": { en: "Revert", de: "Rückgängig machen" },
"confirm.undoCommit.title": { en: "Undo last commit?", de: "Letzten Commit rückgängig machen?" },
"confirm.undoCommit.message": { en: "The commit is removed, its changes stay staged and ready to commit again.", de: "Der Commit wird entfernt, seine Änderungen bleiben gestaged und können erneut committet werden." },
"confirm.undoCommit.note": { en: "Your working tree files are kept.", de: "Die Dateien im Arbeitsverzeichnis bleiben erhalten." },
"confirm.undoCommit.action": { en: "Undo commit", de: "Commit zurücknehmen" },
"confirm.restoreTree.title": { en: "Restore working tree to {hash}?", de: "Arbeitsverzeichnis auf {hash} zurücksetzen?" },
"confirm.restoreTree.message": { en: "The files from that commit come back as unstaged changes, ready for you to review and commit.", de: "Die Dateien aus diesem Commit kommen als ungestagte Änderungen zurück, bereit zum Prüfen und Committen." },
"confirm.restoreTree.note": { en: "No commit is removed and the branch stays where it is.", de: "Es wird kein Commit entfernt und der Branch bleibt, wo er ist." },
"confirm.restoreTree.action": { en: "Restore", de: "Wiederherstellen" },
"confirm.restoreFile.title": { en: "Restore {path} from {hash}?", de: "{path} aus {hash} wiederherstellen?" },
"confirm.restoreFile.titleFolder": { en: "Restore folder {path} from {hash}?", de: "Ordner {path} aus {hash} wiederherstellen?" },
"confirm.restoreFile.message": { en: "This overwrites the version in your working tree so you can review and commit it.", de: "Das überschreibt die Version in deinem Arbeitsverzeichnis, damit du sie prüfen und committen kannst." },
"confirm.unrelatedHistories.title": { en: "Merge separate histories?", de: "Getrennte Historien zusammenführen?" },
"confirm.unrelatedHistories.message": { en: "The local and the remote repository have separate commit histories.", de: "Das lokale und das entfernte Repository haben getrennte Commit-Historien." },
"confirm.unrelatedHistories.note": { en: "Merging them anyway may produce merge conflicts.", de: "Ein Zusammenführen kann Merge-Konflikte erzeugen." },
"confirm.unrelatedHistories.action": { en: "Merge anyway", de: "Trotzdem zusammenführen" },
"confirm.pushRejected.title": { en: "Pull first?", de: "Zuerst pullen?" },
"confirm.pushRejected.message": { en: "The remote has newer commits, so the push was rejected.", de: "Der Remote hat neuere Commits, deshalb wurde der Push abgelehnt." },
"confirm.pushRejected.note": { en: "Run Pull/Merge now and push again afterwards?", de: "Jetzt Pull/Merge ausführen und danach erneut pushen?" },
"confirm.pushRejected.action": { en: "Pull and push", de: "Pullen und pushen" },
"confirm.lfsPrune.title": { en: "Prune LFS cache?", de: "LFS-Cache bereinigen?" },
"confirm.lfsPrune.message": { en: "Local LFS objects that are no longer needed are removed from the cache.", de: "Nicht mehr benötigte lokale LFS-Objekte werden aus dem Cache entfernt." },
"confirm.lfsPrune.note": { en: "Unpushed and currently used objects are kept.", de: "Nicht gepushte und aktuell verwendete Objekte bleiben erhalten." },
"confirm.lfsPrune.action": { en: "Prune cache", de: "Cache bereinigen" },
"confirm.review.mergeTitle": { en: "Merge request #{number}?", de: "Request #{number} zusammenführen?" },
"confirm.review.mergeMessage": { en: "The request is merged in the connected service.", de: "Der Request wird im verbundenen Dienst zusammengeführt." },
"confirm.review.mergeAction": { en: "Merge", de: "Zusammenführen" },
"confirm.review.closeTitle": { en: "Close request #{number}?", de: "Request #{number} schließen?" },
"confirm.review.closeMessage": { en: "The request is closed without being merged.", de: "Der Request wird geschlossen, ohne zusammengeführt zu werden." },
"confirm.review.closeAction": { en: "Close request", de: "Request schließen" },
"confirm.review.reopenTitle": { en: "Reopen request #{number}?", de: "Request #{number} wieder öffnen?" },
"confirm.review.reopenMessage": { en: "The request is reopened in the connected service.", de: "Der Request wird im verbundenen Dienst wieder geöffnet." },
"confirm.review.reopenAction": { en: "Reopen request", de: "Request wieder öffnen" },
// ── Tags panel ─────────────────────────────────────────────────────────────
"tags.title": { en: "Tags", de: "Tags" },
"tags.create": { en: "Create new tag", de: "Neues Tag erstellen" },
"tags.expand": { en: "Expand tags", de: "Tags ausklappen" },
"tags.collapse": { en: "Collapse tags", de: "Tags einklappen" },
"tags.openRepo": { en: "Open a repository to list tags.", de: "Öffne ein Repository, um Tags zu sehen." },
"tags.nameLabel": { en: "New tag name", de: "Name des neuen Tags" },
"tags.messagePlaceholder": { en: "Message (optional)", de: "Nachricht (optional)" },
"tags.messageLabel": { en: "Tag message", de: "Tag-Nachricht" },
"tags.createAction": { en: "Create tag", de: "Tag erstellen" },
"tags.empty": { en: "No tags.", de: "Keine Tags." },
"tags.actionsFor": { en: "Actions for {name}", de: "Aktionen für {name}" },
"tags.push": { en: "Push to remote", de: "Zum Remote pushen" },
"tags.deleteLocal": { en: "Delete local tag", de: "Lokales Tag löschen" },
// ── Branch panel ───────────────────────────────────────────────────────────
"branches.title": { en: "Branches", de: "Branches" },
"branches.create": { en: "Create new branch", de: "Neuen Branch erstellen" },
"branches.expand": { en: "Expand branches", de: "Branches ausklappen" },
"branches.collapse": { en: "Collapse branches", de: "Branches einklappen" },
"branches.expandPanel": { en: "Expand branches panel", de: "Branch-Bereich ausklappen" },
"branches.collapsePanel": { en: "Collapse branches panel", de: "Branch-Bereich einklappen" },
"branches.openRepo": { en: "Open a repository to list branches.", de: "Öffne ein Repository, um Branches zu sehen." },
"branches.namePlaceholder": { en: "new-branch-name", de: "neuer-branch-name" },
"branches.nameLabel": { en: "New branch name", de: "Name des neuen Branches" },
"branches.createAction": { en: "Create branch", de: "Branch erstellen" },
"branches.filter": { en: "Filter branches", de: "Branches filtern" },
"branches.filterClear": { en: "Clear filter", de: "Filter zurücksetzen" },
"branches.revealCurrent": { en: "Reveal current branch in list", de: "Aktuellen Branch in der Liste zeigen" },
"branches.notPublished": { en: "Not published", de: "Nicht veröffentlicht" },
"branches.upstreamGone": { en: "{upstream} (gone)", de: "{upstream} (fehlt)" },
"branches.emptyLocal": { en: "No local branches.", de: "Keine lokalen Branches." },
"branches.emptyRemote": { en: "No remote branches.", de: "Keine Remote-Branches." },
"branches.noMatch": { en: "No branches match “{query}”.", de: "Keine Branches passen zu „{query}“." },
"branches.folderTitle": { en: "{name} · {count} branches", de: "{name} · {count} Branches" },
"branches.folderTitleOne": { en: "{name} · 1 branch", de: "{name} · 1 Branch" },
"branches.containsCurrent": { en: "Contains current branch", de: "Enthält den aktuellen Branch" },
"branches.actions": { en: "Branch actions", de: "Branch-Aktionen" },
"branches.actionsFor": { en: "Actions for {name}", de: "Aktionen für {name}" },
"branches.folderActionsFor": { en: "Actions for branch folder {name}", de: "Aktionen für Branch-Ordner {name}" },
"branches.tipCurrent": { en: "Current branch (HEAD)", de: "Aktueller Branch (HEAD)" },
"branches.tipTracks": { en: "Tracks {upstream}", de: "Verfolgt {upstream}" },
"branches.tipGone": { en: "Upstream {upstream} is gone", de: "Upstream {upstream} existiert nicht mehr" },
"branches.tipLocalOnly": { en: "Local only not published", de: "Nur lokal nicht veröffentlicht" },
"branches.tipCheckedOut": { en: "Checked out locally as {name}", de: "Lokal ausgecheckt als {name}" },
"branches.tipDoubleClick": { en: "Double-click to checkout", de: "Doppelklick zum Auschecken" },
"branches.labelTracks": { en: "Tracks {upstream}", de: "Verfolgt {upstream}" },
"branches.labelGone": { en: "Upstream gone", de: "Upstream fehlt" },
"branches.labelLocalOnly": { en: "Local only", de: "Nur lokal" },
"branches.labelCheckedOut": { en: "Checked out as {name}", de: "Ausgecheckt als {name}" },
"branches.menuCompare": { en: "Compare with...", de: "Vergleichen mit …" },
"branches.menuMerge": { en: "Merge into current", de: "In aktuellen Branch mergen" },
"branches.menuRebase": { en: "Rebase current onto this", de: "Aktuellen Branch hierauf rebasen" },
"branches.menuWorktree": { en: "Open in new worktree", de: "In neuem Worktree öffnen" },
"branches.menuRenameRemote": { en: "Rename remote...", de: "Remote umbenennen …" },
"branches.menuDeleteRemote": { en: "Delete remote", de: "Remote löschen" },
"branches.cannotDeleteCurrent": { en: "Current branch cannot be deleted", de: "Der aktuelle Branch kann nicht gelöscht werden" },
"branches.deleteRemoteBranch": { en: "Delete remote branch", de: "Remote-Branch löschen" },
"branches.deleteLocalBranch": { en: "Delete local branch", de: "Lokalen Branch löschen" },
"branches.deleteFolder": { en: "Delete {count} branches", de: "{count} Branches löschen" },
"branches.folderKeepsCurrent": { en: "The current branch will be kept", de: "Der aktuelle Branch bleibt erhalten" },
"branches.folderDeleteHint": { en: "Delete all branches in this folder", de: "Alle Branches in diesem Ordner löschen" },
// ── Worktree panel ─────────────────────────────────────────────────────────
"worktrees.title": { en: "Worktrees", de: "Worktrees" },
"worktrees.manage": { en: "Create or manage worktrees", de: "Worktrees erstellen oder verwalten" },
"worktrees.expand": { en: "Expand worktrees", de: "Worktrees ausklappen" },
"worktrees.collapse": { en: "Collapse worktrees", de: "Worktrees einklappen" },
"worktrees.openRepo": { en: "Open a repository to list worktrees.", de: "Öffne ein Repository, um Worktrees zu sehen." },
"worktrees.retry": { en: "Retry", de: "Erneut versuchen" },
"worktrees.loading": { en: "Loading worktrees…", de: "Worktrees werden geladen…" },
"worktrees.empty": { en: "No linked worktrees.", de: "Keine verknüpften Worktrees." },
"worktrees.missingSuffix": { en: " — missing", de: " — fehlt" },
"worktrees.bareSuffix": { en: " — bare repository", de: " — Bare-Repository" },
"worktrees.bare": { en: "Bare repository", de: "Bare-Repository" },
"worktrees.detached": { en: "Detached · {head}", de: "Losgelöst · {head}" },
"worktrees.locked": { en: "Locked", de: "Gesperrt" },
"worktrees.missing": { en: "Missing", de: "Fehlt" },
"worktrees.current": { en: "Current", de: "Aktuell" },
// ── Stash panel ────────────────────────────────────────────────────────────
"stashes.title": { en: "Stashes", de: "Stashes" },
"stashes.create": { en: "Create stash", de: "Stash erstellen" },
"stashes.expand": { en: "Expand stash panel", de: "Stash-Bereich ausklappen" },
"stashes.collapse": { en: "Collapse stash panel", de: "Stash-Bereich einklappen" },
"stashes.noRepo": { en: "No repository loaded.", de: "Kein Repository geladen." },
"stashes.messagePlaceholder": { en: "Optional message", de: "Nachricht (optional)" },
"stashes.messageLabel": { en: "Stash message", de: "Stash-Nachricht" },
"stashes.untracked": { en: "Untracked", de: "Unverfolgte" },
"stashes.saveHint": { en: "Save current working tree changes to a stash", de: "Aktuelle Änderungen im Arbeitsverzeichnis in einem Stash sichern" },
"stashes.save": { en: "Stash", de: "Stashen" },
"stashes.empty": { en: "No stashes saved.", de: "Keine Stashes gespeichert." },
"stashes.on": { en: "on {branch}", de: "auf {branch}" },
"stashes.applyHint": { en: "Apply stash and keep it", de: "Stash anwenden und behalten" },
"stashes.apply": { en: "Apply", de: "Anwenden" },
"stashes.popHint": { en: "Apply stash and remove it if successful", de: "Stash anwenden und bei Erfolg entfernen" },
"stashes.pop": { en: "Pop", de: "Pop" },
"stashes.dropHint": { en: "Delete stash", de: "Stash löschen" },
"stashes.drop": { en: "Drop", de: "Löschen" },
// ── Status panel ───────────────────────────────────────────────────────────
"status.panelLabel": { en: "Working tree status", de: "Status des Arbeitsverzeichnisses" },
"status.eyebrow": { en: "Workspace", de: "Arbeitsbereich" },
"status.title": { en: "Changes", de: "Änderungen" },
"status.viewGroup": { en: "Changes view", de: "Ansicht der Änderungen" },
"status.viewList": { en: "List view", de: "Listenansicht" },
"status.viewListShort": { en: "List", de: "Liste" },
"status.viewTree": { en: "Tree view", de: "Baumansicht" },
"status.viewTreeShort": { en: "Tree", de: "Baum" },
"status.staged": { en: "{count} staged", de: "{count} gestaged" },
"status.unstaged": { en: "{count} unstaged", de: "{count} ungestaged" },
"status.discardAllHint": { en: "Discard all staged and unstaged changes", de: "Alle gestagten und ungestagten Änderungen verwerfen" },
"status.discardAll": { en: "Discard all", de: "Alles verwerfen" },
"status.noRepo": { en: "No repository loaded.", de: "Kein Repository geladen." },
"status.clean": { en: "Working tree is clean.", de: "Das Arbeitsverzeichnis ist sauber." },
"status.noChanges": { en: "No file changes returned.", de: "Keine Dateiänderungen zurückgegeben." },
"status.laneUnstaged": { en: "Unstaged changes", de: "Ungestagte Änderungen" },
"status.laneStaged": { en: "Staged changes", de: "Gestagte Änderungen" },
"status.unstagedTitle": { en: "Unstaged", de: "Ungestaged" },
"status.stagedTitle": { en: "Staged", de: "Gestaged" },
"status.workingTree": { en: "Working tree", de: "Arbeitsverzeichnis" },
"status.nextCommit": { en: "Next commit", de: "Nächster Commit" },
"status.stageSelected": { en: "Stage {count} selected files", de: "{count} ausgewählte Dateien stagen" },
"status.unstageSelected": { en: "Unstage {count} selected files", de: "{count} ausgewählte Dateien entstagen" },
"status.stageAllHint": { en: "Stage all unstaged files", de: "Alle ungestagten Dateien stagen" },
"status.stageAll": { en: "Stage all", de: "Alle stagen" },
"status.unstageAllHint": { en: "Unstage all staged files", de: "Alle gestagten Dateien entstagen" },
"status.unstageAll": { en: "Unstage all", de: "Alle entstagen" },
"status.discardUnstagedSelected": { en: "Discard unstaged changes in {count} selected files", de: "Ungestagte Änderungen in {count} ausgewählten Dateien verwerfen" },
"status.discardStagedSelected": { en: "Discard staged changes in {count} selected files", de: "Gestagte Änderungen in {count} ausgewählten Dateien verwerfen" },
"status.discard": { en: "Discard", de: "Verwerfen" },
"status.selectInExplorer": { en: "Select {path} in Explorer", de: "{path} im Explorer auswählen" },
"status.stageFile": { en: "Stage file", de: "Datei stagen" },
"status.unstageFile": { en: "Unstage file", de: "Datei entstagen" },
"status.showUnstagedDetails": { en: "Show unstaged details", de: "Ungestagte Details anzeigen" },
"status.showStagedDetails": { en: "Show staged details", de: "Gestagte Details anzeigen" },
"status.discardUnstaged": { en: "Discard unstaged changes", de: "Ungestagte Änderungen verwerfen" },
"status.discardStaged": { en: "Discard staged changes", de: "Gestagte Änderungen verwerfen" },
"status.emptyUnstaged": { en: "No unstaged changes.", de: "Keine ungestagten Änderungen." },
"status.emptyStaged": { en: "Stage files to include them in the next commit.", de: "Stage Dateien, damit sie in den nächsten Commit kommen." },
"status.working": { en: "Working", de: "Arbeitet" },
"status.repositoryRoot": { en: "Repository root", de: "Repository-Wurzel" },
"status.menuActionsFor": { en: "Actions for {name}", de: "Aktionen für {name}" },
"status.menuKindUnstagedFile": { en: "Unstaged file", de: "Ungestagte Datei" },
"status.menuKindUnstagedFolder": { en: "Unstaged folder", de: "Ungestagter Ordner" },
"status.menuKindStagedFile": { en: "Staged file", de: "Gestagte Datei" },
"status.menuKindStagedFolder": { en: "Staged folder", de: "Gestagter Ordner" },
"status.menuFileCount": { en: "{count} files", de: "{count} Dateien" },
"status.menuFileCountOne": { en: "1 file", de: "1 Datei" },
"status.menuStageFile": { en: "Stage file", de: "Datei stagen" },
"status.menuStageFolder": { en: "Stage folder", de: "Ordner stagen" },
"status.menuUnstageFile": { en: "Unstage file", de: "Datei entstagen" },
"status.menuUnstageFolder": { en: "Unstage folder", de: "Ordner entstagen" },
"status.menuStageHint": { en: "Add to the next commit", de: "Zum nächsten Commit hinzufügen" },
"status.menuUnstageHint": { en: "Move back to working changes", de: "Zurück zu den Arbeitsänderungen" },
"status.menuStashFile": { en: "Stash file", de: "Datei stashen" },
"status.menuStashFolder": { en: "Stash folder", de: "Ordner stashen" },
"status.menuStashHintOne": { en: "Save this file for later", de: "Diese Datei für später sichern" },
"status.menuStashHint": { en: "Save {count} files for later", de: "{count} Dateien für später sichern" },
"status.menuStopTrackingHint": { en: "Keep the working-tree content and remove it from the Git index", de: "Inhalt im Arbeitsverzeichnis behalten und aus dem Git-Index entfernen" },
"status.menuStopTrackingFile": { en: "Stop tracking file", de: "Datei nicht mehr verfolgen" },
"status.menuStopTrackingFolder": { en: "Stop tracking folder", de: "Ordner nicht mehr verfolgen" },
"status.menuStopTrackingNote": { en: "Keep it on disk and remove it from Git", de: "Auf der Festplatte behalten und aus Git entfernen" },
"status.menuIgnoreFileHint": { en: "Add /{path} to .gitignore", de: "/{path} zu .gitignore hinzufügen" },
"status.menuIgnoreFile": { en: "Ignore file", de: "Datei ignorieren" },
"status.menuIgnoreFileNote": { en: "Add only this file to .gitignore", de: "Nur diese Datei zu .gitignore hinzufügen" },
"status.menuIgnoreExtHint": { en: "Add *.{ext} to .gitignore", de: "*.{ext} zu .gitignore hinzufügen" },
"status.menuIgnoreExt": { en: "Ignore all *.{ext} files", de: "Alle *.{ext}-Dateien ignorieren" },
"status.menuIgnoreExtNote": { en: "Match this file type repository-wide", de: "Diesen Dateityp im ganzen Repository erfassen" },
"status.menuIgnoreFolderHint": { en: "Add /{path}/ to .gitignore", de: "/{path}/ zu .gitignore hinzufügen" },
"status.menuIgnoreFolder": { en: "Ignore folder", de: "Ordner ignorieren" },
"status.menuIgnoreFolderNote": { en: "Add this folder and its contents to .gitignore", de: "Diesen Ordner samt Inhalt zu .gitignore hinzufügen" },
// ── History panel ──────────────────────────────────────────────────────────
"history.panelLabel": { en: "Commit history", de: "Commit-Historie" },
"history.eyebrow": { en: "History", de: "Historie" },
"history.title": { en: "Commits", de: "Commits" },
"history.customizeBranches": { en: "Customize visible branches", de: "Sichtbare Branches anpassen" },
"history.visibleBranches": { en: "{visible} of {total} branches visible. Customize branches.", de: "{visible} von {total} Branches sichtbar. Branches anpassen." },
"history.noRepo": { en: "No repository loaded.", de: "Kein Repository geladen." },
"history.noCommits": { en: "No commits returned.", de: "Keine Commits zurückgegeben." },
"history.noMatchingCommits": { en: "No loaded commits match the selected branches.", de: "Keine geladenen Commits passen zu den gewählten Branches." },
"history.refs": { en: "Commit references", de: "Commit-Referenzen" },
"history.localOnlyBadge": { en: "LOCAL", de: "LOKAL" },
"history.localOnlyHint": { en: "This branch exists only locally and has not been published yet", de: "Dieser Branch existiert nur lokal und wurde noch nicht veröffentlicht" },
"history.refsOnCommit": { en: "References on this commit", de: "Referenzen auf diesem Commit" },
"history.current": { en: "Current", de: "Aktuell" },
"history.localOnly": { en: "Local only", de: "Nur lokal" },
"history.tags": { en: "Tags", de: "Tags" },
"history.other": { en: "Other", de: "Sonstige" },
"history.note": { en: "Note", de: "Notiz" },
"history.gitNote": { en: "Git Note", de: "Git-Notiz" },
"history.clickToOpen": { en: "Click to open", de: "Zum Öffnen klicken" },
"history.noteEmpty": { en: "This Git note is empty.", de: "Diese Git-Notiz ist leer." },
"history.openNote": { en: "Open Git note for {hash}", de: "Git-Notiz zu {hash} öffnen" },
"history.addNote": { en: "Add a Git note to {hash}", de: "Git-Notiz zu {hash} hinzufügen" },
"history.tagTitle": { en: "Tag {name}", de: "Tag {name}" },
"history.showMoreRefs": { en: "Show {count} more references", de: "{count} weitere Referenzen anzeigen" },
"history.showMoreRefsOne": { en: "Show 1 more reference", de: "1 weitere Referenz anzeigen" },
"history.changedFiles": { en: "Changed files", de: "Geänderte Dateien" },
"history.diffBeforeRestore": { en: "Show differences before restoring - {file}", de: "Unterschiede vor dem Wiederherstellen anzeigen {file}" },
"history.commitActions": { en: "Commit actions", de: "Commit-Aktionen" },
"history.actionsFor": { en: "Actions for {hash}", de: "Aktionen für {hash}" },
"history.loadingOlder": { en: "Loading older commits…", de: "Ältere Commits werden geladen…" },
"history.loadOlderFailed": { en: "Older commits could not be loaded.", de: "Ältere Commits konnten nicht geladen werden." },
"history.retry": { en: "Retry", de: "Erneut versuchen" },
"history.loadOlder": { en: "Load older commits", de: "Ältere Commits laden" },
"history.menuBranch": { en: "Branch", de: "Branch" },
"history.menuRestore": { en: "Restore", de: "Wiederherstellen" },
"history.menuCherryPick": { en: "Cherry-pick", de: "Cherry-Pick" },
"history.menuCherryPickHint": { en: "Apply this commit's changes on top of the current branch", de: "Änderungen dieses Commits auf den aktuellen Branch anwenden" },
"history.menuRevert": { en: "Revert", de: "Rückgängig machen" },
"history.menuRevertHint": { en: "Create a new commit that reverses this commit", de: "Neuen Commit erstellen, der diesen Commit zurücknimmt" },
"history.branchDialogLabel": { en: "Select visible branches", de: "Sichtbare Branches wählen" },
"history.graphEyebrow": { en: "Git graph", de: "Git-Graph" },
"history.graphTitle": { en: "Visible branches", de: "Sichtbare Branches" },
"history.closeBranchDialog": { en: "Close branch selection", de: "Branch-Auswahl schließen" },
"history.focus": { en: "Focus", de: "Fokus" },
"history.showAll": { en: "Show all", de: "Alle zeigen" },
"history.hideAll": { en: "Hide all", de: "Alle verbergen" },
"history.branchLocalOnlyTitle": { en: "{name} · Local only — not published yet", de: "{name} · Nur lokal — noch nicht veröffentlicht" },
"history.branchTracksTitle": { en: "{name} · Tracks {upstream}", de: "{name} · Verfolgt {upstream}" },
"history.branchRemoteTitle": { en: "Remote branch {name}", de: "Remote-Branch {name}" },
"history.branchLocalTitle": { en: "Local branch {name}", de: "Lokaler Branch {name}" },
"history.branchesSelected": { en: "{visible} of {total} branches selected", de: "{visible} von {total} Branches ausgewählt" },
// ── Reflog dialog ──────────────────────────────────────────────────────────
"reflog.title": { en: "Reflog", de: "Reflog" },
"reflog.eyebrow": { en: "Recovery history", de: "Wiederherstellungs-Historie" },
"reflog.searchPlaceholder": { en: "Search actions, hashes or authors", de: "Aktionen, Hashes oder Autoren suchen" },
"reflog.searchLabel": { en: "Search reflog", de: "Reflog durchsuchen" },
"reflog.loading": { en: "Loading reflog…", de: "Reflog wird geladen…" },
"reflog.noMatch": { en: "No reflog entries match this search.", de: "Keine Reflog-Einträge passen zu dieser Suche." },
"reflog.listLabel": { en: "Reflog entries", de: "Reflog-Einträge" },
"reflog.author": { en: "Author", de: "Autor" },
"reflog.date": { en: "Date", de: "Datum" },
"reflog.preview": { en: "Preview changes to current HEAD", de: "Unterschiede zum aktuellen HEAD ansehen" },
"reflog.safeRecovery": { en: "Safe recovery", de: "Sichere Wiederherstellung" },
"reflog.safeRecoveryNote": { en: "Create a new branch here. The current branch is not reset or deleted.", de: "Hier einen neuen Branch erstellen. Der aktuelle Branch wird weder zurückgesetzt noch gelöscht." },
"reflog.recoveryBranch": { en: "Recovery branch", de: "Wiederherstellungs-Branch" },
"reflog.createBranch": { en: "Create and checkout recovery branch", de: "Wiederherstellungs-Branch erstellen und auschecken" },
"reflog.selectEntry": { en: "Select a reflog entry to inspect or recover it.", de: "Wähle einen Reflog-Eintrag, um ihn anzusehen oder wiederherzustellen." },
// ── Blame dialog ───────────────────────────────────────────────────────────
"blame.dialogLabel": { en: "File blame", de: "Datei-Blame" },
"blame.eyebrow": { en: "Blame", de: "Blame" },
"blame.loading": { en: "Loading blame…", de: "Blame wird geladen…" },
"blame.empty": { en: "No blame information available for this file.", de: "Für diese Datei gibt es keine Blame-Informationen." },
"blame.searchPlaceholder": { en: "Search blame", de: "Blame durchsuchen" },
"blame.searchClear": { en: "Clear blame search", de: "Blame-Suche zurücksetzen" },
"blame.columnCommit": { en: "Commit", de: "Commit" },
"blame.columnCode": { en: "Code", de: "Code" },
"blame.noMatches": { en: "No matches found.", de: "Keine Treffer gefunden." },
"blame.uncommitted": { en: "Not committed yet", de: "Noch nicht committet" },
// ── Interactive rebase dialog ──────────────────────────────────────────────
"rebase.dialogLabel": { en: "Interactive rebase", de: "Interaktiver Rebase" },
"rebase.eyebrow": { en: "Rewrite local history", de: "Lokale Historie umschreiben" },
"rebase.rebaseOnto": { en: "Rebase", de: "Rebase" },
"rebase.currentBranch": { en: "current branch", de: "aktueller Branch" },
"rebase.onto": { en: "onto", de: "auf" },
"rebase.baseRemote": { en: "Remote - {name}", de: "Remote {name}" },
"rebase.baseLocal": { en: "Local - {name}", de: "Lokal {name}" },
"rebase.selectBase": { en: "Select a base branch", de: "Basis-Branch wählen" },
"rebase.hint": { en: "Oldest commit first. Reorder commits, then choose how each one should be replayed.", de: "Ältester Commit zuerst. Ordne die Commits neu und wähle, wie jeder wiederholt werden soll." },
"rebase.loading": { en: "Loading rebase range…", de: "Rebase-Bereich wird geladen…" },
"rebase.selectBaseHint": { en: "Select the branch or commit that should become the new base.", de: "Wähle den Branch oder Commit, der die neue Basis werden soll." },
"rebase.noCommits": { en: "No linear commits are available above this base.", de: "Über dieser Basis gibt es keine linearen Commits." },
"rebase.planLabel": { en: "Interactive rebase plan", de: "Plan für den interaktiven Rebase" },
"rebase.moveUp": { en: "Move up", de: "Nach oben" },
"rebase.moveDown": { en: "Move down", de: "Nach unten" },
"rebase.actionFor": { en: "Action for {hash}", de: "Aktion für {hash}" },
"rebase.newMessageFor": { en: "New message for {hash}", de: "Neue Nachricht für {hash}" },
"rebase.invalidSquash": { en: "Squash and fixup need an earlier commit that is not dropped.", de: "Squash und Fixup brauchen einen früheren Commit, der nicht verworfen wird." },
"rebase.invalidReword": { en: "Reword messages cannot be empty.", de: "Neue Commit-Nachrichten dürfen nicht leer sein." },
"rebase.keptCount": { en: "{kept} of {total} commits kept", de: "{kept} von {total} Commits behalten" },
"rebase.start": { en: "Start rebase", de: "Rebase starten" },
// ── Worktree dialog ────────────────────────────────────────────────────────
"worktreeDialog.eyebrow": { en: "Parallel workspaces", de: "Parallele Arbeitsbereiche" },
"worktreeDialog.refresh": { en: "Refresh worktrees", de: "Worktrees neu laden" },
"worktreeDialog.linked": { en: "linked worktrees", de: "verknüpfte Worktrees" },
"worktreeDialog.withChanges": { en: "with changes", de: "mit Änderungen" },
"worktreeDialog.stale": { en: "stale entries", de: "veraltete Einträge" },
"worktreeDialog.new": { en: "New worktree", de: "Neuer Worktree" },
"worktreeDialog.createEyebrow": { en: "Create", de: "Erstellen" },
"worktreeDialog.createTitle": { en: "Choose what this workspace should track", de: "Wähle, was dieser Arbeitsbereich verfolgen soll" },
"worktreeDialog.closeCreate": { en: "Close create form", de: "Erstellen-Formular schließen" },
"worktreeDialog.typeLabel": { en: "Worktree type", de: "Worktree-Typ" },
"worktreeDialog.existingBranch": { en: "Existing branch", de: "Vorhandener Branch" },
"worktreeDialog.newBranch": { en: "New branch", de: "Neuer Branch" },
"worktreeDialog.detached": { en: "Detached", de: "Losgelöst" },
"worktreeDialog.selectBranch": { en: "Select a local branch", de: "Lokalen Branch wählen" },
"worktreeDialog.newBranchName": { en: "New branch name", de: "Name des neuen Branches" },
"worktreeDialog.newBranchPlaceholder": { en: "feature/my-change", de: "feature/meine-aenderung" },
"worktreeDialog.startPoint": { en: "Start point", de: "Startpunkt" },
"worktreeDialog.startPointPlaceholder": { en: "HEAD, branch or commit", de: "HEAD, Branch oder Commit" },
"worktreeDialog.commitOrRef": { en: "Commit or ref", de: "Commit oder Ref" },
"worktreeDialog.folder": { en: "Folder", de: "Ordner" },
"worktreeDialog.folderPlaceholder": { en: "Choose an empty folder", de: "Leeren Ordner wählen" },
"worktreeDialog.browse": { en: "Browse", de: "Durchsuchen" },
"worktreeDialog.lockAfterCreate": { en: "Lock after creation", de: "Nach dem Erstellen sperren" },
"worktreeDialog.lockAfterCreateNote": { en: "Protects removable or temporary locations from pruning.", de: "Schützt Wechseldatenträger oder temporäre Orte vor dem Aufräumen." },
"worktreeDialog.createAction": { en: "Create worktree", de: "Worktree erstellen" },
"worktreeDialog.loading": { en: "Reading worktrees…", de: "Worktrees werden gelesen…" },
"worktreeDialog.emptyTitle": { en: "No worktrees found", de: "Keine Worktrees gefunden" },
"worktreeDialog.emptyNote": { en: "Create one to work on another branch without switching this workspace.", de: "Erstelle einen, um an einem anderen Branch zu arbeiten, ohne diesen Arbeitsbereich zu wechseln." },
"worktreeDialog.main": { en: "Main", de: "Haupt" },
"worktreeDialog.open": { en: "Open", de: "Offen" },
"worktreeDialog.locked": { en: "Locked", de: "Gesperrt" },
"worktreeDialog.staleBadge": { en: "Stale", de: "Veraltet" },
"worktreeDialog.refreshTab": { en: "Refresh tab", de: "Tab aktualisieren" },
"worktreeDialog.openTab": { en: "Open tab", de: "Tab öffnen" },
"worktreeDialog.repairHint": { en: "Locate and repair worktree", de: "Worktree finden und reparieren" },
"worktreeDialog.repair": { en: "Repair", de: "Reparieren" },
"worktreeDialog.moveHint": { en: "Move worktree", de: "Worktree verschieben" },
"worktreeDialog.move": { en: "Move", de: "Verschieben" },
"worktreeDialog.unlockHint": { en: "Unlock worktree", de: "Worktree entsperren" },
"worktreeDialog.unlock": { en: "Unlock", de: "Entsperren" },
"worktreeDialog.lockHint": { en: "Lock worktree", de: "Worktree sperren" },
"worktreeDialog.lock": { en: "Lock", de: "Sperren" },
"worktreeDialog.removeHint": { en: "Remove worktree", de: "Worktree entfernen" },
"worktreeDialog.removePruneHint": { en: "Use Prune to remove stale metadata", de: "Nutze Prune, um veraltete Metadaten zu entfernen" },
"worktreeDialog.remove": { en: "Remove", de: "Entfernen" },
"worktreeDialog.protectedNote": { en: "Dirty, active and locked worktrees are protected.", de: "Worktrees mit Änderungen, aktive und gesperrte sind geschützt." },
"worktreeDialog.removeEyebrow": { en: "Remove worktree", de: "Worktree entfernen" },
"worktreeDialog.removeTitle": { en: "Remove {name}?", de: "{name} entfernen?" },
"worktreeDialog.cancelRemoval": { en: "Cancel removal", de: "Entfernen abbrechen" },
"worktreeDialog.removeBody": { en: "This removes the worktree folder and its Git registration. The branch itself is kept.", de: "Das entfernt den Worktree-Ordner und seine Git-Registrierung. Der Branch selbst bleibt erhalten." },
"worktreeDialog.removeForce": { en: "Remove despite local changes", de: "Trotz lokaler Änderungen entfernen" },
"worktreeDialog.removeForceNote": { en: "{count} changed files may be permanently deleted.", de: "{count} geänderte Dateien können dauerhaft gelöscht werden." },
"worktreeDialog.lockEyebrow": { en: "Protect worktree", de: "Worktree schützen" },
"worktreeDialog.lockTitle": { en: "Lock {name}", de: "{name} sperren" },
"worktreeDialog.cancelLock": { en: "Cancel locking", de: "Sperren abbrechen" },
"worktreeDialog.reason": { en: "Reason", de: "Grund" },
"worktreeDialog.optional": { en: "optional", de: "optional" },
"worktreeDialog.reasonPlaceholder": { en: "External drive, long-running work…", de: "Externe Festplatte, langlaufende Arbeit…" },
"worktreeDialog.detachedAt": { en: "Detached at {head}", de: "Losgelöst bei {head}" },
"worktreeDialog.bare": { en: "Bare worktree", de: "Bare-Worktree" },
"worktreeDialog.chooseNewLocation": { en: "Choose new worktree location", de: "Neuen Ort für den Worktree wählen" },
"worktreeDialog.chooseFolder": { en: "Choose worktree folder", de: "Worktree-Ordner wählen" },
// ── AI settings ────────────────────────────────────────────────────────────
"ai.providerLabel": { en: "AI provider", de: "KI-Anbieter" },
"ai.custom": { en: "Custom endpoint", de: "Eigener Endpunkt" },
"ai.model": { en: "Model", de: "Modell" },
"ai.apiKey": { en: "API key", de: "API-Schlüssel" },
"ai.apiKeyOptional": { en: "API key (optional)", de: "API-Schlüssel (optional)" },
"ai.endpointUrl": { en: "Endpoint URL", de: "Endpunkt-URL" },
"ai.optional": { en: "Optional", de: "Optional" },
"ai.customHint": { en: "For local OpenAI-compatible servers like Ollama or LM Studio. The base URL should end in /v1.", de: "Für lokale, OpenAI-kompatible Server wie Ollama oder LM Studio. Die Basis-URL sollte auf /v1 enden." },
"ai.keysNotLoaded": { en: "API keys could not be loaded. Existing credentials have been preserved.", de: "Die API-Schlüssel konnten nicht geladen werden. Vorhandene Zugangsdaten bleiben erhalten." },
"ai.waitForSettings": { en: "Please wait for AI settings to load.", de: "Bitte warte, bis die KI-Einstellungen geladen sind." },
"branches.listLabel": { en: "Branch list", de: "Branch-Liste" },
"worktreeDialog.refreshShort": { en: "Refresh", de: "Aktualisieren" },
"history.hoverBranches": { en: "Branches: {list}", de: "Branches: {list}" },
"history.hoverContaining": { en: "Branches containing this commit: {list}", de: "Branches, die diesen Commit enthalten: {list}" },
// ── Branch delete confirmation ─────────────────────────────────────────────
"confirm.branchDelete.eyebrow": { en: "Delete branch", de: "Branch löschen" },
"confirm.branchDelete.eyebrowRemote": { en: "Remote branch", de: "Remote-Branch" },
"confirm.branchDelete.eyebrowForce": { en: "Force delete", de: "Löschen erzwingen" },
"confirm.branchDelete.title": { en: "Delete branch?", de: "Branch löschen?" },
"confirm.branchDelete.titleRemote": { en: "Delete remote branch?", de: "Remote-Branch löschen?" },
"confirm.branchDelete.titleForce": { en: "Force delete branch?", de: "Löschen des Branches erzwingen?" },
"confirm.branchDelete.message": { en: "This branch will be removed from your local repository.", de: "Dieser Branch wird aus deinem lokalen Repository entfernt." },
"confirm.branchDelete.messageRemote": { en: "This branch will be removed from the shared remote repository.", de: "Dieser Branch wird aus dem gemeinsamen Remote-Repository entfernt." },
"confirm.branchDelete.messageForce": { en: "This branch is not fully merged. Some commits may only exist here.", de: "Dieser Branch ist nicht vollständig gemergt. Manche Commits gibt es vielleicht nur hier." },
"confirm.branchDelete.note": { en: "Git will stop the deletion if the branch contains unmerged commits.", de: "Git bricht das Löschen ab, wenn der Branch nicht gemergte Commits enthält." },
"confirm.branchDelete.noteRemote": { en: "This affects everyone using {remote}. Your local branch is kept.", de: "Das betrifft alle, die {remote} nutzen. Dein lokaler Branch bleibt erhalten." },
"confirm.branchDelete.noteForce": { en: "Force deletion can make unmerged commits difficult to recover.", de: "Erzwungenes Löschen kann nicht gemergte Commits schwer wiederherstellbar machen." },
"confirm.branchDelete.action": { en: "Delete", de: "Löschen" },
"confirm.branchDelete.actionRemote": { en: "Delete from remote", de: "Auf dem Remote löschen" },
"confirm.branchDelete.actionForce": { en: "Force delete", de: "Löschen erzwingen" },
// ── Discard confirmation ───────────────────────────────────────────────────
"confirm.discard.eyebrow": { en: "Confirm discard", de: "Verwerfen bestätigen" },
"confirm.discard.titleHunk": { en: "Discard hunk?", de: "Block verwerfen?" },
"confirm.discard.titleLines": { en: "Discard selected lines?", de: "Ausgewählte Zeilen verwerfen?" },
"confirm.discard.titleFiles": { en: "Discard changes in {count} files?", de: "Änderungen in {count} Dateien verwerfen?" },
"confirm.discard.titleFile": { en: "Discard file changes?", de: "Dateiänderungen verwerfen?" },
"confirm.discard.messageHunk": { en: "This resets the {source} for the selected hunk below.", de: "Das setzt {source} für den unten gewählten Block zurück." },
"confirm.discard.messageLines": { en: "This resets the {source} for the selected lines below.", de: "Das setzt {source} für die unten gewählten Zeilen zurück." },
"confirm.discard.messageFiles": { en: "This resets the {source} for the {count} files below.", de: "Das setzt {source} für die {count} Dateien unten zurück." },
"confirm.discard.messageFile": { en: "This resets the {source} for the file below.", de: "Das setzt {source} für die Datei unten zurück." },
"confirm.discard.sourceBoth": { en: "staged and unstaged changes", de: "gestagten und ungestagten Änderungen" },
"confirm.discard.sourceStaged": { en: "staged changes", de: "gestagten Änderungen" },
"confirm.discard.sourceUnstaged": { en: "unstaged changes", de: "ungestagten Änderungen" },
"confirm.discard.note": { en: "This cannot be undone. If a file only exists in your working tree, it can be deleted entirely.", de: "Das lässt sich nicht rückgängig machen. Existiert eine Datei nur im Arbeitsverzeichnis, kann sie ganz gelöscht werden." },
"confirm.discard.action": { en: "Discard", de: "Verwerfen" },
// ── Worktree removal confirmation ──────────────────────────────────────────
"confirm.worktreeRemove.title": { en: "Remove {name}?", de: "{name} entfernen?" },
"confirm.worktreeRemove.message": { en: "This removes the worktree folder and its Git registration. The branch itself is kept.", de: "Das entfernt den Worktree-Ordner und seine Git-Registrierung. Der Branch selbst bleibt erhalten." },
"confirm.worktreeRemove.action": { en: "Remove worktree", de: "Worktree entfernen" },
// ── Stash from the changes context menu ────────────────────────────────────
"confirm.stashFiles.eyebrow": { en: "Save for later", de: "Für später sichern" },
"confirm.stashFiles.titleOne": { en: "Stash 1 file?", de: "1 Datei stashen?" },
"confirm.stashFiles.title": { en: "Stash {count} files?", de: "{count} Dateien stashen?" },
"confirm.stashFiles.message": { en: "These changes are saved to a stash and removed from your working tree.", de: "Diese Änderungen werden in einem Stash gesichert und aus dem Arbeitsverzeichnis entfernt." },
"confirm.stashFiles.inputLabel": { en: "Message (optional)", de: "Nachricht (optional)" },
"confirm.stashFiles.untracked": { en: "Include untracked files", de: "Unverfolgte Dateien einbeziehen" },
"confirm.stashFiles.untrackedNote": { en: "Files Git does not track yet are stashed as well.", de: "Dateien, die Git noch nicht verfolgt, werden mitgesichert." },
"status.menuKindUnstagedSelection": { en: "Unstaged selection", de: "Ungestagte Auswahl" },
"status.menuKindStagedSelection": { en: "Staged selection", de: "Gestagte Auswahl" },
"status.menuStageSelection": { en: "Stage {count} files", de: "{count} Dateien stagen" },
"status.menuUnstageSelection": { en: "Unstage {count} files", de: "{count} Dateien entstagen" },
"status.menuStashSelection": { en: "Stash {count} files", de: "{count} Dateien stashen" },
"status.menuStopTrackingSelection": { en: "Stop tracking {count} files", de: "{count} Dateien nicht mehr verfolgen" },
} as const;
export type MessageKey = keyof typeof messages;
export type MessageEntry = { en: string; de: string };