Compare commits

...
6 Commits
Author SHA1 Message Date
Christoph Brandau f43fe00873 feat(git): rename remote branches atomically via push
Adds a new command to rename remote branches and update tracking refs.
Renaming remote refs is performed atomically using a single push.
The push creates the new remote ref and deletes the old if it succeeds.
The frontend now invokes remote-rename when needed and shows labels.

- Atomic remote rename via push with create/ref and delete
- Frontend supports remote branch renames from the branch panel
- Compare UI now shows labels for remote refs in results
2026-08-13 18:32:16 +02:00
Christoph Brandau ca14fac90c feat(ui): refine history graph visuals and layout
Refines history panel visuals and layout, including min width handling.
Removes the old branch visibility select in favor of a dialog button.
It also adds a graph connector to show primary branches.
Adds branch chips, icons, and hover effects to commits for clarity.

- History panel visuals and min width updated to improve layout.
- Removed branch visibility select; added dialog to customize branches.
- Branch chips and graph connectors enhanced with icons and hover effects.
2026-08-13 18:01:10 +02:00
Christoph Brandau 608b3131d2 feat(history-panel): add branch visibility modes and richer refs
Adds branch visibility modes to the history panel and remote branches.
A data model supports commits and refs including local and remote.
UI tweaks add compact ref chips and a new details panel.

- Implement focus/local/all/custom modes for branch visibility
- Introduce CommitBranchDecoration and CommitRefSummary types
- Wire remote branches and ahead/behind data to UI
2026-08-13 15:02:42 +02:00
Christoph Brandau a823aabbb9 feat(external-tools): force tools to open in new windows where needed
Adds a helper that forces selected tools to launch in a new window
instead of reusing the current one. This is applied to code editors,
diff/merge, and terminal launches, aligning behavior across platforms.
Presets and defaults are updated to pass new-window or equivalent flags,
and tests verify the new behavior for common tools.

- Update code editors to always use a new window when opened
- Normalize launch flags for Windows terminals and diff tools
- Add tests covering new-window behavior for common tools
2026-08-13 14:33:05 +02:00
Christoph Brandau 15d1f2bfd6 feat(external-tools): add cross-platform external tool discovery
Adds a new external tools subsystem to detect and launch
diff and editor tools across Windows, macOS, and Linux.
It exposes data models for tools, commands, and results to the UI
and serializes them for consumption by the app.

- Implement cross-platform discovery of editors and diff tools
- Expose serialized results to the UI for user selection
- Centralize per-OS known tool lists and overrides
2026-08-13 14:08:02 +02:00
Christoph Brandau 3eb554fee7 feat(ui): add command palette and commit selection sync
Introduce a global command palette for quick access to common repository
actions, branches, files, and commits. Wire it into the main shell so it
can open settings, help, and other dialogs while keeping keyboard access
consistent.

Also add commit selection state to the history view so the active commit
is highlighted and brought into view when chosen from either the palette
or the history panel.
2026-08-13 07:53:19 +02:00
20 changed files with 5306 additions and 434 deletions
File diff suppressed because it is too large Load Diff
+475
View File
@@ -675,6 +675,99 @@ pub fn delete_remote_branch(
result result
} }
#[tauri::command]
pub async fn rename_remote_branch(
path: String,
remote: String,
old_branch: String,
new_branch: String,
) -> Result<GitStatus, String> {
run_git_task("Could not rename remote branch", move || {
rename_remote_branch_core(path, remote, old_branch, new_branch)
})
.await
}
fn rename_remote_branch_core(
path: String,
remote: String,
old_branch: String,
new_branch: String,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?;
let old_branch = validate_branch_ref_name(old_branch.trim())?;
let new_branch = validate_branch_ref_name(new_branch.trim())?;
if old_branch == new_branch {
return Err("The new remote branch name is unchanged.".to_string());
}
let old_tracking_ref = format!("refs/remotes/{remote}/{old_branch}");
let new_tracking_ref = format!("refs/remotes/{remote}/{new_branch}");
if !ref_exists(&repo, &old_tracking_ref)? {
return Err(format!(
"Remote branch '{remote}/{old_branch}' was not found."
));
}
if ref_exists(&repo, &new_tracking_ref)? {
return Err(format!(
"Remote branch '{remote}/{new_branch}' already exists."
));
}
let old_hash = run_git(&repo, ["rev-parse", "--verify", old_tracking_ref.as_str()])?;
let old_hash = String::from_utf8_lossy(&old_hash).trim().to_string();
let old_remote_ref = format!("refs/heads/{old_branch}");
let new_remote_ref = format!("refs/heads/{new_branch}");
let source_lease = format!("--force-with-lease={old_remote_ref}:{old_hash}");
// An empty expected value means the destination must not exist on the remote.
let destination_lease = format!("--force-with-lease={new_remote_ref}:");
let create_refspec = format!("{old_tracking_ref}:{new_remote_ref}");
let delete_refspec = format!(":{old_remote_ref}");
// Git has no standalone remote-rename command. Create the new ref and delete
// the old one in a single atomic push so a rejected update leaves both untouched.
run_git(
&repo,
[
"push",
"--atomic",
source_lease.as_str(),
destination_lease.as_str(),
remote.as_str(),
create_refspec.as_str(),
delete_refspec.as_str(),
],
)?;
// Git normally updates remote-tracking refs after a successful push. Keep the
// local view consistent as a fallback for unusual remote/refspec setups.
if !ref_exists(&repo, &new_tracking_ref)? {
if let Err(error) = run_git(
&repo,
["update-ref", new_tracking_ref.as_str(), old_hash.as_str()],
) {
log::warn!(target: "gitty::remote", "remote rename succeeded, but the new tracking ref could not be updated: {error}");
}
}
if ref_exists(&repo, &old_tracking_ref)? {
if let Err(error) = run_git(
&repo,
[
"update-ref",
"-d",
old_tracking_ref.as_str(),
old_hash.as_str(),
],
) {
log::warn!(target: "gitty::remote", "remote rename succeeded, but the old tracking ref could not be removed: {error}");
}
}
status_for_repo(&repo)
}
#[tauri::command] #[tauri::command]
pub async fn list_stashes(path: String) -> Result<Vec<GitStash>, String> { pub async fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
run_git_task("Could not load stashes", move || { run_git_task("Could not load stashes", move || {
@@ -3129,6 +3222,230 @@ pub async fn list_commits(
.await .await
} }
const COMMIT_NOTES_REF: &str = "refs/notes/commits";
const COMMIT_NOTES_SYNC_REF: &str = "refs/gitlite/notes-sync";
const MAX_COMMIT_NOTE_BYTES: usize = 256 * 1024;
#[tauri::command]
pub async fn get_commit_note(path: String, commit: String) -> Result<Option<String>, String> {
run_git_task("Could not load commit note", move || {
let repo = resolve_repo(&path)?;
commit_note_for_repo(&repo, &commit)
})
.await
}
#[tauri::command]
pub async fn set_commit_note(path: String, commit: String, note: String) -> Result<(), String> {
run_git_task("Could not save commit note", move || {
let repo = resolve_repo(&path)?;
set_commit_note_for_repo(&repo, &commit, &note)
})
.await
}
#[tauri::command]
pub async fn delete_commit_note(path: String, commit: String) -> Result<(), String> {
run_git_task("Could not delete commit note", move || {
let repo = resolve_repo(&path)?;
delete_commit_note_for_repo(&repo, &commit)
})
.await
}
#[tauri::command]
pub async fn fetch_commit_notes(
path: String,
remote: String,
username: Option<String>,
password: Option<String>,
) -> Result<(), String> {
run_git_task("Could not fetch commit notes", move || {
let repo = resolve_repo(&path)?;
fetch_commit_notes_for_repo(&repo, &remote, username.as_deref(), password.as_deref())
})
.await
}
#[tauri::command]
pub async fn push_commit_notes(
path: String,
remote: String,
username: Option<String>,
password: Option<String>,
) -> Result<(), String> {
run_git_task("Could not push commit notes", move || {
let repo = resolve_repo(&path)?;
push_commit_notes_for_repo(&repo, &remote, username.as_deref(), password.as_deref())
})
.await
}
fn commit_note_for_repo(repo: &Path, commit: &str) -> Result<Option<String>, String> {
let commit = verify_commit(repo, commit)?;
let output = git_command()
.arg("-C")
.arg(repo)
.args(["notes", "--ref", COMMIT_NOTES_REF, "list", commit.as_str()])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.code() == Some(1) {
return Ok(None);
}
if !output.status.success() {
return Err(format!(
"Could not inspect commit note: {}",
command_output_details(&output)
));
}
let note_object = String::from_utf8_lossy(&output.stdout).trim().to_string();
if note_object.is_empty() {
return Ok(None);
}
let note = run_git(repo, ["cat-file", "blob", note_object.as_str()])?;
let mut note =
String::from_utf8(note).map_err(|_| "Commit note is not valid UTF-8 text.".to_string())?;
if note.ends_with('\n') {
note.pop();
if note.ends_with('\r') {
note.pop();
}
}
Ok(Some(note))
}
fn set_commit_note_for_repo(repo: &Path, commit: &str, note: &str) -> Result<(), String> {
let commit = verify_commit(repo, commit)?;
if note.trim().is_empty() {
return Err("Commit note must not be empty. Use Delete to remove it.".to_string());
}
if note.len() > MAX_COMMIT_NOTE_BYTES {
return Err(format!(
"Commit note is too large (maximum {} KiB).",
MAX_COMMIT_NOTE_BYTES / 1024
));
}
run_git_with_stdin(
repo,
[
"notes",
"--ref",
COMMIT_NOTES_REF,
"add",
"-f",
"-F",
"-",
"--",
commit.as_str(),
],
note.as_bytes(),
)?;
Ok(())
}
fn delete_commit_note_for_repo(repo: &Path, commit: &str) -> Result<(), String> {
let commit = verify_commit(repo, commit)?;
run_git(
repo,
[
"notes",
"--ref",
COMMIT_NOTES_REF,
"remove",
"--ignore-missing",
"--",
commit.as_str(),
],
)?;
Ok(())
}
fn fetch_commit_notes_for_repo(
repo: &Path,
remote: &str,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
let remote = validate_remote_name(repo, remote, true)?;
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
let refspec = format!("+{COMMIT_NOTES_REF}:{COMMIT_NOTES_SYNC_REF}");
let fetch_args = ["fetch", remote.as_str(), refspec.as_str()];
let fetched = match (username, password) {
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
run_git_authenticated(repo, fetch_args, user, pass)
}
_ => run_git(repo, fetch_args),
};
if let Err(error) = fetched {
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
return Err(
if error.to_lowercase().contains("couldn't find remote ref") {
format!("Remote '{remote}' does not contain commit notes yet.")
} else {
error
},
);
}
let merge_result = (|| -> Result<(), String> {
if ref_exists(repo, COMMIT_NOTES_REF)? {
run_git(
repo,
[
"notes",
"--ref",
COMMIT_NOTES_REF,
"merge",
"-s",
"cat_sort_uniq",
COMMIT_NOTES_SYNC_REF,
],
)?;
} else {
let remote_notes_hash = run_git(repo, ["rev-parse", COMMIT_NOTES_SYNC_REF])?;
let remote_notes_hash = String::from_utf8_lossy(&remote_notes_hash)
.trim()
.to_string();
run_git(
repo,
["update-ref", COMMIT_NOTES_REF, remote_notes_hash.as_str()],
)?;
}
Ok(())
})();
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
merge_result
}
fn push_commit_notes_for_repo(
repo: &Path,
remote: &str,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
let remote = validate_remote_name(repo, remote, true)?;
if !ref_exists(repo, COMMIT_NOTES_REF)? {
return Err("There are no local commit notes to push.".to_string());
}
let refspec = format!("{COMMIT_NOTES_REF}:{COMMIT_NOTES_REF}");
let push_args = ["push", remote.as_str(), refspec.as_str()];
match (username, password) {
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
run_git_authenticated(repo, push_args, user, pass)?;
}
_ => {
run_git(repo, push_args)?;
}
}
Ok(())
}
fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> { fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
let bounded_limit = limit.unwrap_or(100).clamp(1, 500); let bounded_limit = limit.unwrap_or(100).clamp(1, 500);
commit_page_for_repo(repo, Some(bounded_limit), None) commit_page_for_repo(repo, Some(bounded_limit), None)
@@ -5923,6 +6240,91 @@ mod tests {
run_git_test(repo, ["commit", "-q", "-m", "init"]); run_git_test(repo, ["commit", "-q", "-m", "init"]);
} }
#[test]
fn commit_notes_can_be_created_updated_and_deleted_without_changing_commit() {
let repo = init_temp_repo("commit_notes_crud");
commit_initial_file(&repo.path);
let commit_before = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
assert_eq!(
commit_note_for_repo(&repo.path, &commit_before).expect("note lookup should work"),
None
);
set_commit_note_for_repo(
&repo.path,
&commit_before,
"Review: sieht gut aus\nBuild: 42",
)
.expect("note should be created");
assert_eq!(
commit_note_for_repo(&repo.path, &commit_before).expect("note should load"),
Some("Review: sieht gut aus\nBuild: 42".to_string())
);
set_commit_note_for_repo(&repo.path, &commit_before, "Freigabe erteilt")
.expect("note should be replaced");
assert_eq!(
commit_note_for_repo(&repo.path, &commit_before).expect("updated note should load"),
Some("Freigabe erteilt".to_string())
);
delete_commit_note_for_repo(&repo.path, &commit_before).expect("note should be deleted");
assert_eq!(
commit_note_for_repo(&repo.path, &commit_before)
.expect("deleted note lookup should work"),
None
);
assert_eq!(
git_output_test(&repo.path, ["rev-parse", "HEAD"]),
commit_before
);
}
#[test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
)]
fn commit_notes_can_be_pushed_and_fetched_through_the_notes_ref() {
let source = init_temp_repo("commit_notes_source");
let target = init_temp_repo("commit_notes_target");
let remote = init_bare_temp_repo("commit_notes_remote");
commit_initial_file(&source.path);
let commit = git_output_test(&source.path, ["rev-parse", "HEAD"]);
let remote_url = format!(
"file:///{}",
remote.path.to_string_lossy().replace('\\', "/")
);
run_git_test(
&source.path,
["remote", "add", "origin", remote_url.as_str()],
);
run_git_test(
&source.path,
["push", "-q", "origin", "HEAD:refs/heads/main"],
);
set_commit_note_for_repo(&source.path, &commit, "Shared review note")
.expect("source note should be created");
push_commit_notes_for_repo(&source.path, "origin", None, None)
.expect("notes should be pushed");
run_git_test(
&target.path,
["remote", "add", "origin", remote_url.as_str()],
);
run_git_test(&target.path, ["fetch", "-q", "origin", "main"]);
run_git_test(&target.path, ["checkout", "-q", "FETCH_HEAD"]);
fetch_commit_notes_for_repo(&target.path, "origin", None, None)
.expect("notes should be fetched");
assert_eq!(
commit_note_for_repo(&target.path, &commit).expect("fetched note should load"),
Some("Shared review note".to_string())
);
}
#[test] #[test]
fn clone_directory_name_is_inferred_from_common_remote_urls() { fn clone_directory_name_is_inferred_from_common_remote_urls() {
assert_eq!( assert_eq!(
@@ -6377,6 +6779,31 @@ mod tests {
assert!(comparison.patch.contains("second line")); assert!(comparison.patch.contains("second line"));
} }
#[test]
fn compare_commits_accepts_branch_refs_for_a_full_repository_diff() {
let repo = init_temp_repo("compare_branches");
commit_initial_file(&repo.path);
run_git_test(&repo.path, ["branch", "base"]);
fs::write(repo.path.join("branch-only.txt"), "only on feature\n")
.expect("branch file should be written");
run_git_test(&repo.path, ["add", "branch-only.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "feature change"]);
run_git_test(&repo.path, ["branch", "feature/complete-compare"]);
let comparison = compare_commits(
repo.path.to_string_lossy().to_string(),
"refs/heads/base".to_string(),
"refs/heads/feature/complete-compare".to_string(),
)
.unwrap();
assert!(comparison.files.iter().any(|file| {
file.path == "branch-only.txt" && file.status == FileStatusKind::Added
}));
assert!(comparison.patch.contains("only on feature"));
}
#[test] #[test]
fn compare_commits_includes_full_file_context() { fn compare_commits_includes_full_file_context() {
let repo = init_temp_repo("compare_full_context"); let repo = init_temp_repo("compare_full_context");
@@ -6986,6 +7413,54 @@ mod tests {
); );
} }
#[test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
)]
fn rename_remote_branch_moves_the_remote_ref_atomically() {
let repo = init_temp_repo("rename_remote_branch");
let remote = init_bare_temp_repo("rename_remote_branch_remote");
commit_initial_file(&repo.path);
let commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
let remote_url = format!(
"file:///{}",
remote.path.to_string_lossy().replace('\\', "/")
);
run_git_test(&repo.path, ["remote", "add", "origin", remote_url.as_str()]);
run_git_test(
&repo.path,
["push", "-q", "origin", "HEAD:refs/heads/feature/old-name"],
);
run_git_test(&repo.path, ["fetch", "-q", "origin"]);
rename_remote_branch_core(
repo.path.to_string_lossy().to_string(),
"origin".to_string(),
"feature/old-name".to_string(),
"feature/new-name".to_string(),
)
.unwrap();
assert!(
!ref_exists(&remote.path, "refs/heads/feature/old-name").unwrap(),
"old remote branch should be gone"
);
assert_eq!(
git_output_test(&remote.path, ["rev-parse", "refs/heads/feature/new-name"]),
commit
);
assert!(
!ref_exists(&repo.path, "refs/remotes/origin/feature/old-name").unwrap(),
"old remote-tracking branch should be gone"
);
assert!(
ref_exists(&repo.path, "refs/remotes/origin/feature/new-name").unwrap(),
"new remote-tracking branch should exist"
);
}
#[test] #[test]
fn delete_branch_removes_local_branch_but_rejects_current_branch() { fn delete_branch_removes_local_branch_but_rejects_current_branch() {
let repo = init_temp_repo("delete_branch"); let repo = init_temp_repo("delete_branch");
+27 -11
View File
@@ -1,29 +1,35 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod badge; mod badge;
mod external_tools;
mod git; mod git;
mod telemetry; mod telemetry;
use badge::set_sync_badge; use badge::set_sync_badge;
use external_tools::{
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
};
use git::{ use git::{
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch, SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate, cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status, commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
cred_delete, cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag, cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes, get_commit_note,
get_status, init_repository, last_commit_message, list_branches, list_commits, get_file_blame, get_file_patch, get_remote_url, get_status, init_repository,
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes, last_commit_message, list_branches, list_commits, list_file_history,
list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_tag, merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
rename_branch, repair_worktree, resolve_conflict, resolve_conflict_side, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
rename_remote_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit, restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
revert_commit, run_sequence_editor_if_requested, search_code_introductions, revert_commit, run_sequence_editor_if_requested, search_code_introductions,
set_branch_upstream, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop, set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply,
stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
update_remote,
}; };
use tauri::Manager; use tauri::Manager;
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled}; use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
@@ -123,6 +129,10 @@ async fn main() {
clone_repository, clone_repository,
open_repo_in_explorer, open_repo_in_explorer,
open_repository_file, open_repository_file,
detect_external_tools,
launch_external_tool,
launch_external_diff,
launch_external_merge,
get_status, get_status,
list_branches, list_branches,
list_remotes, list_remotes,
@@ -135,6 +145,7 @@ async fn main() {
checkout_branch, checkout_branch,
create_branch, create_branch,
rename_branch, rename_branch,
rename_remote_branch,
delete_branch, delete_branch,
list_worktrees, list_worktrees,
add_worktree, add_worktree,
@@ -174,6 +185,11 @@ async fn main() {
push, push,
fetch, fetch,
list_commits, list_commits,
get_commit_note,
set_commit_note,
delete_commit_note,
fetch_commit_notes,
push_commit_notes,
restore_to_commit, restore_to_commit,
restore_file_from_commit, restore_file_from_commit,
merge_branch, merge_branch,
+513 -31
View File
@@ -17,6 +17,8 @@
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte"; import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte";
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte"; import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
import CommandPalette from "./lib/components/CommandPalette.svelte";
import CommitNoteDialog from "./lib/components/CommitNoteDialog.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte"; import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte"; import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
import CredentialDialog from "./lib/components/CredentialDialog.svelte"; import CredentialDialog from "./lib/components/CredentialDialog.svelte";
@@ -55,12 +57,15 @@
createBranch, createBranch,
createTag, createTag,
deleteBranch, deleteBranch,
deleteCommitNote,
deleteTag, deleteTag,
deleteRemoteBranch, deleteRemoteBranch,
initRepository, initRepository,
diffFileAgainstWorkingTree, diffFileAgainstWorkingTree,
compareFileToParent, compareFileToParent,
fetchCommitNotes,
fetchRemote, fetchRemote,
getCommitNote,
getFileBlame, getFileBlame,
getStatus, getStatus,
lastCommitMessage, lastCommitMessage,
@@ -85,18 +90,25 @@
pruneWorktrees, pruneWorktrees,
pull, pull,
push, push,
pushCommitNotes,
pushTag, pushTag,
removeRemote, removeRemote,
removeWorktree, removeWorktree,
repairWorktree, repairWorktree,
revertCommit, revertCommit,
setBranchUpstream, setBranchUpstream,
setCommitNote,
updateRemote, updateRemote,
renameBranch, renameBranch,
renameRemoteBranch,
rebaseAbort, rebaseAbort,
rebaseBranch, rebaseBranch,
rebaseContinue, rebaseContinue,
getRemoteUrl, getRemoteUrl,
detectExternalTools,
launchExternalDiff,
launchExternalMerge,
launchExternalTool,
credLoad, credLoad,
credSave, credSave,
credDelete, credDelete,
@@ -130,8 +142,11 @@
AnalyticsSettings, AnalyticsSettings,
CommitAiPhase, CommitAiPhase,
ConflictFile, ConflictFile,
DetectedExternalTool,
ExplorerNode, ExplorerNode,
ExplorerNodeKind, ExplorerNodeKind,
ExternalDiffScope,
ExternalToolsSettings,
GitBlameLine, GitBlameLine,
GitBranch as GitBranchInfo, GitBranch as GitBranchInfo,
GitCommit, GitCommit,
@@ -156,6 +171,12 @@
RepositoryBundle, RepositoryBundle,
StoredCredential, StoredCredential,
} from "./lib/types"; } from "./lib/types";
import {
defaultExternalToolsSettings,
externalToolDisplayName,
normaliseExternalToolsSettings,
resolveDetectedExternalToolPrograms,
} from "./lib/externalTools";
import { import {
orgKeyFromUrl, orgKeyFromUrl,
@@ -222,6 +243,7 @@
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1"; const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
const APP_THEME_KEY = "gitlite.theme.v1"; const APP_THEME_KEY = "gitlite.theme.v1";
const APP_LANGUAGE_KEY = "gitlite.language.v1"; const APP_LANGUAGE_KEY = "gitlite.language.v1";
const EXTERNAL_TOOLS_SETTINGS_KEY = "gitlite.externalTools.v1";
const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1"; const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1"; const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1"; const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
@@ -245,7 +267,7 @@
const LEFT_STASH_PANEL_MAX_HEIGHT = 420; const LEFT_STASH_PANEL_MAX_HEIGHT = 420;
const LEFT_EXPLORER_PANEL_MIN_HEIGHT = 220; const LEFT_EXPLORER_PANEL_MIN_HEIGHT = 220;
const HISTORY_ASIDE_DEFAULT_WIDTH = 620; const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
const HISTORY_ASIDE_MIN_WIDTH = 560; const HISTORY_ASIDE_MIN_WIDTH = 420;
const HISTORY_ASIDE_MAX_WIDTH = 920; const HISTORY_ASIDE_MAX_WIDTH = 920;
const ERROR_AUTO_HIDE_MS = 6000; const ERROR_AUTO_HIDE_MS = 6000;
const COMMIT_HISTORY_PAGE_SIZE = 50; const COMMIT_HISTORY_PAGE_SIZE = 50;
@@ -287,6 +309,16 @@
let selectedExplorerKind: ExplorerNodeKind = "file"; let selectedExplorerKind: ExplorerNodeKind = "file";
let expandedExplorerPaths = new Set<string>(); let expandedExplorerPaths = new Set<string>();
let expandedCommitHashes = new Set<string>(); let expandedCommitHashes = new Set<string>();
let selectedCommitHash = "";
let commitNoteTarget: GitCommit | null = null;
let commitNoteRepoPath = "";
let commitNoteText = "";
let commitNoteRemotes: GitRemote[] = [];
let commitNotePreferredRemote = "";
let commitNoteLoading = false;
let commitNoteBusy = false;
let commitNoteError = "";
let commitNoteStatus = "";
let fileHistory: GitCommit[] = []; let fileHistory: GitCommit[] = [];
let fileHistoryLoading = false; let fileHistoryLoading = false;
let fileHistoryError = ""; let fileHistoryError = "";
@@ -312,16 +344,24 @@
let aiSettingsOpen = false; let aiSettingsOpen = false;
let appSettingsOpen = false; let appSettingsOpen = false;
let helpOpen = false; let helpOpen = false;
let commandPaletteOpen = false;
let analyticsNoticeOpen = false; let analyticsNoticeOpen = false;
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings(); let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
let appTheme: AppTheme = loadThemePreference(); let appTheme: AppTheme = loadThemePreference();
let appLanguage: AppLanguage = loadLanguagePreference(); let appLanguage: AppLanguage = loadLanguagePreference();
let externalToolsSettings: ExternalToolsSettings = loadExternalToolsSettings();
let externalToolsConfigured = hasStoredExternalToolsSettings();
let detectedExternalTools: DetectedExternalTool[] = [];
let externalToolsDetectionPending = true;
let externalToolsDetectionUnavailable = false;
let localModelOptions: LocalModelOption[] = []; let localModelOptions: LocalModelOption[] = [];
let errorMessage = ""; let errorMessage = "";
let operation = ""; let operation = "";
let compareFrom = ""; let compareFrom = "";
let compareTo = ""; let compareTo = "";
let comparison: GitCommitComparison | null = null; let comparison: GitCommitComparison | null = null;
let comparisonFromLabel = "";
let comparisonToLabel = "";
let newBranchCommit: GitCommit | null = null; let newBranchCommit: GitCommit | null = null;
let renameBranchTarget: GitBranchInfo | null = null; let renameBranchTarget: GitBranchInfo | null = null;
let deleteBranchTarget: GitBranchInfo | null = null; let deleteBranchTarget: GitBranchInfo | null = null;
@@ -478,6 +518,11 @@
.filter((repo) => repoMatchesSearch(repo, repoSearchTerm)); .filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
$: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed); $: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed);
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed; $: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
$: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools);
$: diffToolName = externalToolDisplayName("diff", externalToolsSettings.diff, detectedExternalTools);
$: mergeToolName = externalToolDisplayName("merge", externalToolsSettings.merge, detectedExternalTools);
$: terminalToolName = externalToolDisplayName("terminal", externalToolsSettings.terminal, detectedExternalTools);
$: fileManagerToolName = externalToolDisplayName("fileManager", externalToolsSettings.fileManager, detectedExternalTools);
$: applyThemePreference(appTheme); $: applyThemePreference(appTheme);
$: applyLanguagePreference(appLanguage); $: applyLanguagePreference(appLanguage);
@@ -488,6 +533,7 @@
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)"); themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
themeMediaQuery.addEventListener("change", handleSystemThemeChange); themeMediaQuery.addEventListener("change", handleSystemThemeChange);
void runStartupSequence(); void runStartupSequence();
void refreshDetectedExternalTools();
void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; }); void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; });
window.addEventListener("beforeunload", handleAppShutdown); window.addEventListener("beforeunload", handleAppShutdown);
window.addEventListener("pagehide", handleAppShutdown); window.addEventListener("pagehide", handleAppShutdown);
@@ -766,7 +812,7 @@
} }
async function autoRefreshTick() { async function autoRefreshTick() {
if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || globalSearchOpen || helpOpen) return; if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return;
const path = activeRepoPath; const path = activeRepoPath;
autoRefreshInFlight = true; autoRefreshInFlight = true;
try { try {
@@ -938,16 +984,19 @@
if (appTheme === "system") applyThemePreference(appTheme); if (appTheme === "system") applyThemePreference(appTheme);
} }
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextLanguage: AppLanguage, nextAutoRefresh: boolean) { function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextLanguage: AppLanguage, nextAutoRefresh: boolean, nextExternalTools: ExternalToolsSettings) {
const autoRefreshWasEnabled = autoRefreshEnabled; const autoRefreshWasEnabled = autoRefreshEnabled;
analyticsSettings = next; analyticsSettings = next;
appTheme = nextTheme; appTheme = nextTheme;
appLanguage = nextLanguage; appLanguage = nextLanguage;
autoRefreshEnabled = nextAutoRefresh; autoRefreshEnabled = nextAutoRefresh;
externalToolsSettings = nextExternalTools;
persistAnalyticsSettings(next); persistAnalyticsSettings(next);
persistThemePreference(nextTheme); persistThemePreference(nextTheme);
persistLanguagePreference(nextLanguage); persistLanguagePreference(nextLanguage);
persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh); persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh);
persistExternalToolsSettings(nextExternalTools);
externalToolsConfigured = true;
setTelemetryEnabled(next.enabled); setTelemetryEnabled(next.enabled);
appSettingsOpen = false; appSettingsOpen = false;
if (nextAutoRefresh && !autoRefreshWasEnabled) void autoRefreshTick(); if (nextAutoRefresh && !autoRefreshWasEnabled) void autoRefreshTick();
@@ -1791,6 +1840,14 @@
selectedExplorerKind = "file"; selectedExplorerKind = "file";
expandedExplorerPaths = new Set(); expandedExplorerPaths = new Set();
expandedCommitHashes = new Set(); expandedCommitHashes = new Set();
commitNoteTarget = null;
commitNoteRepoPath = "";
commitNoteText = "";
commitNoteRemotes = [];
commitNoteLoading = false;
commitNoteBusy = false;
commitNoteError = "";
commitNoteStatus = "";
fileHistory = []; fileHistory = [];
fileHistoryLoading = false; fileHistoryLoading = false;
fileHistoryError = ""; fileHistoryError = "";
@@ -1798,6 +1855,8 @@
compareFrom = ""; compareFrom = "";
compareTo = ""; compareTo = "";
comparison = null; comparison = null;
comparisonFromLabel = "";
comparisonToLabel = "";
compareSelectOpen = false; compareSelectOpen = false;
compareDialogOpen = false; compareDialogOpen = false;
interactiveRebaseOpen = false; interactiveRebaseOpen = false;
@@ -1927,15 +1986,12 @@
commitHistoryLoadingMore = false; commitHistoryLoadingMore = false;
commitHistoryLoadError = ""; commitHistoryLoadError = "";
lastFileHistoryHeadHash = commits[0]?.hash ?? ""; lastFileHistoryHeadHash = commits[0]?.hash ?? "";
const hashes = new Set(commits.map((c) => c.hash)); const targets = new Set([
if (compareFrom && !hashes.has(compareFrom)) compareFrom = ""; ...commits.map((commit) => commit.hash),
if (compareTo && !hashes.has(compareTo)) compareTo = ""; ...branches.map(compareRefForBranch),
if (comparison && comparison.to_hash.length > 0 && (!hashes.has(comparison.from_hash) || !hashes.has(comparison.to_hash))) { ]);
comparison = null; if (compareFrom && !targets.has(compareFrom)) compareFrom = "";
compareDialogOpen = false; if (compareTo && !targets.has(compareTo)) compareTo = "";
selectedDiffPath = "";
pendingRestoreFile = null;
}
} }
async function loadMoreCommitHistory() { async function loadMoreCommitHistory() {
@@ -2428,22 +2484,47 @@
}); });
} }
function renameLocalBranch(branch: GitBranchInfo) { function openRenameBranchDialog(branch: GitBranchInfo) {
if (!activeRepoPath || branch.remote) return; if (!activeRepoPath) return;
if (branch.remote && branch.name.indexOf("/") < 1) {
errorMessage = "Could not determine remote name.";
return;
}
renameBranchTarget = branch; renameBranchTarget = branch;
trackEvent("branch_rename_dialog_opened"); trackEvent("branch_rename_dialog_opened", { remote: branch.remote ? 1 : 0 });
} }
async function submitRenameBranch(branchName: string) { async function submitRenameBranch(branchName: string) {
const branch = renameBranchTarget; const branch = renameBranchTarget;
const name = branchName.trim(); const name = branchName.trim();
if (!activeRepoPath || !branch || branch.remote || !name || name === branch.name) return; if (!activeRepoPath || !branch || !name) return;
if (branch.remote) {
const slash = branch.name.indexOf("/");
if (slash < 1) {
errorMessage = "Could not determine remote name.";
return;
}
const remote = branch.name.slice(0, slash);
const oldRemoteBranch = branch.name.slice(slash + 1);
if (name === oldRemoteBranch) return;
await runOperation(`Renaming ${branch.name} on remote`, async () => {
applyStatus(await renameRemoteBranch(activeRepoPath, remote, oldRemoteBranch, name));
renameBranchTarget = null;
await refreshRefsAndCommitGraph(activeRepoPath);
trackEvent("branch_renamed", { remote: 1 });
});
return;
}
if (name === branch.name) return;
await runOperation(`Renaming ${branch.name}`, async () => { await runOperation(`Renaming ${branch.name}`, async () => {
applyStatus(await renameBranch(activeRepoPath, branch.name, name)); applyStatus(await renameBranch(activeRepoPath, branch.name, name));
renameBranchTarget = null; renameBranchTarget = null;
await refreshRepositoryViews(activeRepoPath); await refreshRepositoryViews(activeRepoPath);
trackEvent("branch_renamed"); trackEvent("branch_renamed", { remote: 0 });
}); });
} }
@@ -2787,6 +2868,8 @@
await runOperation("Previewing reflog entry", async () => { await runOperation("Previewing reflog entry", async () => {
const result = await compareCommits(activeRepoPath, entry.hash, "HEAD"); const result = await compareCommits(activeRepoPath, entry.hash, "HEAD");
comparison = result; comparison = result;
comparisonFromLabel = entry.selector;
comparisonToLabel = "HEAD";
selectedDiffPath = result.files[0]?.path ?? ""; selectedDiffPath = result.files[0]?.path ?? "";
diffHighlightQuery = ""; diffHighlightQuery = "";
pendingRestoreFile = null; pendingRestoreFile = null;
@@ -2851,6 +2934,143 @@
}); });
} }
function closeCommitNoteDialog() {
if (commitNoteBusy) return;
commitNoteTarget = null;
commitNoteRepoPath = "";
commitNoteText = "";
commitNoteRemotes = [];
commitNotePreferredRemote = "";
commitNoteLoading = false;
commitNoteError = "";
commitNoteStatus = "";
}
async function openCommitNoteDialog(commit: GitCommit) {
if (!activeRepoPath || commitNoteBusy) return;
const repo = activeRepoPath;
selectedCommitHash = commit.hash;
commitNoteTarget = commit;
commitNoteRepoPath = repo;
commitNoteText = "";
commitNoteRemotes = [];
commitNotePreferredRemote = selectedRemote;
commitNoteLoading = true;
commitNoteError = "";
commitNoteStatus = "";
const [noteResult, remotesResult] = await Promise.allSettled([
getCommitNote(repo, commit.hash),
listRemotes(repo),
]);
if (commitNoteRepoPath !== repo || commitNoteTarget?.hash !== commit.hash) return;
if (noteResult.status === "fulfilled") {
commitNoteText = noteResult.value ?? "";
} else {
commitNoteError = errorToMessage(noteResult.reason);
}
if (remotesResult.status === "fulfilled") {
commitNoteRemotes = remotesResult.value;
commitNotePreferredRemote = remotesResult.value.some((remote) => remote.name === selectedRemote)
? selectedRemote
: (remotesResult.value[0]?.name ?? "");
} else if (!commitNoteError) {
commitNoteError = errorToMessage(remotesResult.reason);
}
commitNoteLoading = false;
}
async function saveActiveCommitNote(note: string) {
const commit = commitNoteTarget;
const repo = commitNoteRepoPath;
if (!commit || !repo || commitNoteBusy || !note.trim()) return;
commitNoteBusy = true;
commitNoteError = "";
commitNoteStatus = "";
try {
await setCommitNote(repo, commit.hash, note);
commitNoteText = note;
commitNoteStatus = appLanguage === "de"
? "Notiz gespeichert. Der Commit-Hash ist unverändert."
: "Note saved. The commit hash is unchanged.";
trackEvent("commit_note_saved");
} catch (error) {
commitNoteError = errorToMessage(error);
} finally {
commitNoteBusy = false;
}
}
async function deleteActiveCommitNote() {
const commit = commitNoteTarget;
const repo = commitNoteRepoPath;
if (!commit || !repo || commitNoteBusy) return;
commitNoteBusy = true;
commitNoteError = "";
commitNoteStatus = "";
try {
await deleteCommitNote(repo, commit.hash);
commitNoteText = "";
commitNoteStatus = appLanguage === "de" ? "Notiz gelöscht." : "Note deleted.";
trackEvent("commit_note_deleted");
} catch (error) {
commitNoteError = errorToMessage(error);
} finally {
commitNoteBusy = false;
}
}
async function storedCredentialForNoteRemote(remote: string, direction: "fetch" | "push") {
const config = commitNoteRemotes.find((item) => item.name === remote);
const key = orgKeyFromUrl(direction === "push" ? (config?.push_url ?? "") : (config?.fetch_url ?? ""));
const stored = await loadStoredCredential(key);
if (stored && isCredentialExpired(stored)) {
if (key) await credDelete(key).catch(() => {});
return null;
}
return stored;
}
function commitNoteRemoteError(error: unknown): string {
const raw = errorToMessage(error);
if (!isAuthError(raw)) return stripAuthPrefix(raw);
const detail = summarizeGitError(stripAuthPrefix(raw));
return appLanguage === "de"
? `${detail || "Anmeldung fehlgeschlagen."} Bitte zuerst über Pull oder Push bei diesem Remote anmelden.`
: `${detail || "Sign-in failed."} Sign in to this remote using Pull or Push first.`;
}
async function syncActiveCommitNotes(remote: string, direction: "fetch" | "push") {
const commit = commitNoteTarget;
const repo = commitNoteRepoPath;
if (!commit || !repo || !remote || commitNoteBusy) return;
commitNoteBusy = true;
commitNoteError = "";
commitNoteStatus = "";
try {
const credential = await storedCredentialForNoteRemote(remote, direction);
if (direction === "fetch") {
await fetchCommitNotes(repo, remote, credential?.username, credential?.password);
commitNoteText = (await getCommitNote(repo, commit.hash)) ?? "";
commitNoteStatus = appLanguage === "de"
? `Notizen von ${remote} geladen und zusammengeführt.`
: `Notes fetched from ${remote} and merged.`;
trackEvent("commit_notes_fetched");
} else {
await pushCommitNotes(repo, remote, credential?.username, credential?.password);
commitNoteStatus = appLanguage === "de"
? `Notizen zu ${remote} gesendet.`
: `Notes pushed to ${remote}.`;
trackEvent("commit_notes_pushed");
}
} catch (error) {
commitNoteError = commitNoteRemoteError(error);
} finally {
commitNoteBusy = false;
}
}
async function cherryPickFromCommit(commit: GitCommit) { async function cherryPickFromCommit(commit: GitCommit) {
if (!activeRepoPath || rebaseInProgress || cherryPickInProgress) return; if (!activeRepoPath || rebaseInProgress || cherryPickInProgress) return;
await runOperation(`Cherry-picking ${commit.short_hash}`, async () => { await runOperation(`Cherry-picking ${commit.short_hash}`, async () => {
@@ -3625,6 +3845,8 @@
diffFile.path === file.path || diffFile.old_path === file.old_path || diffFile.old_path === file.path, diffFile.path === file.path || diffFile.old_path === file.old_path || diffFile.old_path === file.path,
); );
comparison = result; comparison = result;
comparisonFromLabel = "";
comparisonToLabel = "";
selectedDiffPath = matchingFile?.path ?? result.files[0]?.path ?? file.path; selectedDiffPath = matchingFile?.path ?? result.files[0]?.path ?? file.path;
pendingRestoreFile = { commit: target, file }; pendingRestoreFile = { commit: target, file };
compareDialogOpen = true; compareDialogOpen = true;
@@ -3749,6 +3971,122 @@
} }
} }
async function openExternalFileDiff(filePath: string, scope: ExternalDiffScope, source: "status" | "explorer" | "diff-dialog") {
if (!activeRepoPath || !filePath || isBusy) return;
await runOperation(`Opening ${filePath} in ${diffToolName}`, async () => {
await launchExternalDiff(activeRepoPath, filePath, externalToolsSettings.diff, scope);
trackEvent("external_tool_opened", { kind: "diff", scope: source });
});
}
async function openPreferredFileDiff(file: GitFileStatus, staged: boolean) {
if (externalToolsSettings.diffOpenMode === "external") {
await openExternalFileDiff(file.path, staged ? "staged" : "unstaged", "status");
return;
}
await openLinePatch(file, staged);
}
async function openCurrentLinePatchExternally() {
if (!linePatchFile) return;
await openExternalFileDiff(linePatchFile.path, linePatchStaged ? "staged" : "unstaged", "diff-dialog");
}
function loadExternalToolsSettings(): ExternalToolsSettings {
try {
return normaliseExternalToolsSettings(JSON.parse(localStorage.getItem(EXTERNAL_TOOLS_SETTINGS_KEY) ?? "null"));
} catch {
return defaultExternalToolsSettings();
}
}
function hasStoredExternalToolsSettings(): boolean {
try {
return localStorage.getItem(EXTERNAL_TOOLS_SETTINGS_KEY) != null;
} catch {
return false;
}
}
function persistExternalToolsSettings(next: ExternalToolsSettings) {
try {
localStorage.setItem(EXTERNAL_TOOLS_SETTINGS_KEY, JSON.stringify(next));
} catch {
// Local storage is best-effort only; built-in defaults remain usable.
}
}
async function refreshDetectedExternalTools(applyDetectedDefaults = true) {
externalToolsDetectionPending = true;
externalToolsDetectionUnavailable = false;
try {
detectedExternalTools = await detectExternalTools();
if (applyDetectedDefaults) {
externalToolsSettings = externalToolsConfigured
? resolveDetectedExternalToolPrograms(externalToolsSettings, detectedExternalTools)
: defaultExternalToolsSettings(detectedExternalTools);
}
} catch {
detectedExternalTools = [];
externalToolsDetectionUnavailable = true;
} finally {
externalToolsDetectionPending = false;
}
}
async function openActiveRepoInEditor() {
if (!activeRepoPath || isBusy) return;
try {
await launchExternalTool(activeRepoPath, externalToolsSettings.editor);
trackEvent("external_tool_opened", { kind: "editor", scope: "repository" });
} catch (error) { errorMessage = errorToMessage(error); }
}
async function openActiveRepoTerminal() {
if (!activeRepoPath || isBusy) return;
try { await launchExternalTool(activeRepoPath, externalToolsSettings.terminal); trackEvent("external_tool_opened", { kind: "terminal" }); }
catch (error) { errorMessage = errorToMessage(error); }
}
async function openActiveRepoFileManager() {
if (!activeRepoPath || isBusy) return;
try { await launchExternalTool(activeRepoPath, externalToolsSettings.fileManager); trackEvent("external_tool_opened", { kind: "file_manager" }); }
catch { await openActiveRepoInExplorer(); }
}
async function openExplorerFileInEditor(node: ExplorerNode) {
if (!activeRepoPath || node.kind !== "file" || isBusy) return;
try {
await launchExternalTool(activeRepoPath, externalToolsSettings.editor, node.path);
trackEvent("external_tool_opened", { kind: "editor", scope: "file" });
} catch (error) {
errorMessage = errorToMessage(error);
}
}
async function compareExplorerFileExternally(node: ExplorerNode) {
if (!activeRepoPath || node.kind !== "file" || !node.tracked || isBusy) return;
await openExternalFileDiff(node.path, "head", "explorer");
}
async function openFileFromCommandPalette(file: GitRepositoryFile) {
if (!activeRepoPath) return;
selectedExplorerPath = file.path;
selectedExplorerKind = "file";
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
try {
await openRepositoryFile(activeRepoPath, file.path);
trackEvent("explorer_file_opened", { source: "command_palette", tracked: file.tracked ? 1 : 0 });
} catch (error) {
errorMessage = errorToMessage(error);
}
}
function selectCommitFromCommandPalette(target: GitCommit) {
selectedCommitHash = target.hash;
trackEvent("commit_selected", { source: "command_palette" });
}
async function restoreSelectedFileFromCommit(target: GitCommit) { async function restoreSelectedFileFromCommit(target: GitCommit) {
if (!activeRepoPath || !selectedExplorerPath) return; if (!activeRepoPath || !selectedExplorerPath) return;
const kind = selectedExplorerKind === "folder" ? "folder" : "file"; const kind = selectedExplorerKind === "folder" ? "folder" : "file";
@@ -3765,12 +4103,36 @@
// ── Compare ──────────────────────────────────────────────────────────────── // ── Compare ────────────────────────────────────────────────────────────────
function compareRefForBranch(branch: GitBranchInfo): string {
return branch.remote ? `refs/remotes/${branch.name}` : `refs/heads/${branch.name}`;
}
function compareLabelForTarget(target: string): string {
const branch = branches.find((candidate) => compareRefForBranch(candidate) === target);
if (branch) return branch.name;
return commits.find((commit) => commit.hash === target)?.short_hash ?? "";
}
function openCompareSelect() { function openCompareSelect() {
if (!hasRepository) return; if (!hasRepository) return;
if (!compareFrom) {
const current = branches.find((branch) => branch.current);
if (current) compareFrom = compareRefForBranch(current);
}
compareSelectOpen = true; compareSelectOpen = true;
trackEvent("compare_opened"); trackEvent("compare_opened");
} }
function compareBranchWithCurrent(branch: GitBranchInfo) {
if (!hasRepository) return;
const selected = compareRefForBranch(branch);
const current = branches.find((candidate) => candidate.current);
compareFrom = current ? compareRefForBranch(current) : "";
compareTo = selected === compareFrom ? "" : selected;
compareSelectOpen = true;
trackEvent("compare_opened", { source: "branch_context", remote: branch.remote ? 1 : 0 });
}
function openGlobalSearchDialog() { function openGlobalSearchDialog() {
globalSearchOpen = true; globalSearchOpen = true;
trackEvent("global_search_opened"); trackEvent("global_search_opened");
@@ -3781,11 +4143,21 @@
trackEvent("help_opened"); trackEvent("help_opened");
} }
async function compareSelectedCommits() { function openAppSettings() {
appSettingsOpen = true;
}
function openAiSettings() {
aiSettingsOpen = true;
}
async function compareSelectedTargets() {
if (!canCompare) return; if (!canCompare) return;
await runOperation("Comparing commits", async () => { await runOperation("Comparing revisions", async () => {
const result = await compareCommits(activeRepoPath, compareFrom, compareTo); const result = await compareCommits(activeRepoPath, compareFrom, compareTo);
comparison = result; comparison = result;
comparisonFromLabel = compareLabelForTarget(compareFrom);
comparisonToLabel = compareLabelForTarget(compareTo);
selectedDiffPath = result.files[0]?.path ?? ""; selectedDiffPath = result.files[0]?.path ?? "";
diffHighlightQuery = ""; diffHighlightQuery = "";
pendingRestoreFile = null; pendingRestoreFile = null;
@@ -3802,6 +4174,8 @@
await runOperation(`Diffing ${selectedExplorerPath}`, async () => { await runOperation(`Diffing ${selectedExplorerPath}`, async () => {
const result = await diffFileAgainstWorkingTree(activeRepoPath, historyCommit.hash, selectedExplorerPath); const result = await diffFileAgainstWorkingTree(activeRepoPath, historyCommit.hash, selectedExplorerPath);
comparison = result; comparison = result;
comparisonFromLabel = "";
comparisonToLabel = "";
selectedDiffPath = result.files[0]?.path ?? selectedExplorerPath; selectedDiffPath = result.files[0]?.path ?? selectedExplorerPath;
diffHighlightQuery = ""; diffHighlightQuery = "";
pendingRestoreFile = null; pendingRestoreFile = null;
@@ -3818,6 +4192,8 @@
await runOperation(`Diffing ${hit.file}`, async () => { await runOperation(`Diffing ${hit.file}`, async () => {
const result = await diffFileAgainstWorkingTree(activeRepoPath, hit.commit_hash, hit.file); const result = await diffFileAgainstWorkingTree(activeRepoPath, hit.commit_hash, hit.file);
comparison = result; comparison = result;
comparisonFromLabel = "";
comparisonToLabel = "";
selectedDiffPath = result.files[0]?.path ?? hit.file; selectedDiffPath = result.files[0]?.path ?? hit.file;
diffHighlightQuery = lastSearchQuery; diffHighlightQuery = lastSearchQuery;
pendingRestoreFile = null; pendingRestoreFile = null;
@@ -3903,21 +4279,40 @@
async function openResolveDialog() { async function openResolveDialog() {
if (!hasConflicts || isBusy) return; if (!hasConflicts || isBusy) return;
const first = conflictedFiles[0].path; const first = conflictedFiles[0].path;
await runOperation("Loading conflicts", async () => { const openExternally = externalToolsSettings.mergeOpenMode === "external";
await runOperation(openExternally ? `Opening ${first} in ${mergeToolName}` : "Loading conflicts", async () => {
preparedResolutions = {}; preparedResolutions = {};
resolveDialogOpen = true; resolveDialogOpen = true;
await loadConflict(first); await loadConflict(first);
trackEvent("resolve_dialog_opened", { trackEvent("resolve_dialog_opened", {
conflicts: conflictedFiles.length, conflicts: conflictedFiles.length,
}); });
if (openExternally) await launchMergeToolForConflict(first);
}); });
} }
async function selectConflictFile(path: string) { async function selectConflictFile(path: string) {
if (path === conflictTarget || isBusy) return; if (path === conflictTarget || isBusy) return;
await runOperation(`Loading ${path}`, async () => { const openExternally = externalToolsSettings.mergeOpenMode === "external";
await runOperation(openExternally ? `Opening ${path} in ${mergeToolName}` : `Loading ${path}`, async () => {
await loadConflict(path); await loadConflict(path);
trackEvent("conflict_file_selected"); trackEvent("conflict_file_selected");
if (openExternally) await launchMergeToolForConflict(path);
});
}
async function launchMergeToolForConflict(path: string) {
if (!activeRepoPath || !path) return;
await launchExternalMerge(activeRepoPath, path, externalToolsSettings.merge);
await loadConflict(path);
await refreshExplorerFiles(activeRepoPath);
trackEvent("external_tool_opened", { kind: "merge", scope: "conflict" });
}
async function openConflictInExternalMerge(path: string) {
if (!activeRepoPath || !path || isBusy) return;
await runOperation(`Opening ${path} in ${mergeToolName}`, async () => {
await launchMergeToolForConflict(path);
}); });
} }
@@ -3970,11 +4365,20 @@
// ── Event handlers ───────────────────────────────────────────────────────── // ── Event handlers ─────────────────────────────────────────────────────────
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
if (!event.repeat) commandPaletteOpen = !commandPaletteOpen;
return;
}
if ((event.ctrlKey || event.metaKey) && event.key === "/") { if ((event.ctrlKey || event.metaKey) && event.key === "/") {
event.preventDefault(); event.preventDefault();
openHelp(); openHelp();
return; return;
} }
if (event.key === "Escape" && commandPaletteOpen) {
commandPaletteOpen = false;
return;
}
if (event.key === "Escape" && helpOpen) { if (event.key === "Escape" && helpOpen) {
helpOpen = false; helpOpen = false;
return; return;
@@ -4012,7 +4416,7 @@
<main class="shell"> <main class="shell">
<TitleBar <TitleBar
onOpenSettings={() => { appSettingsOpen = true; }} onOpenSettings={openAppSettings}
onOpenHelp={openHelp} onOpenHelp={openHelp}
language={appLanguage} language={appLanguage}
/> />
@@ -4042,6 +4446,9 @@
ahead={status?.ahead ?? 0} ahead={status?.ahead ?? 0}
behind={status?.behind ?? 0} behind={status?.behind ?? 0}
language={appLanguage} language={appLanguage}
editorName={editorToolName}
terminalName={terminalToolName}
fileManagerName={fileManagerToolName}
onFetch={fetchRepo} onFetch={fetchRepo}
onPull={pullRepo} onPull={pullRepo}
onPush={pushRepo} onPush={pushRepo}
@@ -4050,7 +4457,9 @@
onCompare={openCompareSelect} onCompare={openCompareSelect}
onInteractiveRebase={openInteractiveRebase} onInteractiveRebase={openInteractiveRebase}
onReflog={openReflog} onReflog={openReflog}
onOpenInExplorer={openActiveRepoInExplorer} onOpenInEditor={openActiveRepoInEditor}
onOpenTerminal={openActiveRepoTerminal}
onOpenInExplorer={openActiveRepoFileManager}
onFetchPrune={fetchPruneRepo} onFetchPrune={fetchPruneRepo}
onForcePush={forcePushRepo} onForcePush={forcePushRepo}
onSyncOptions={openSyncOptions} onSyncOptions={openSyncOptions}
@@ -4342,7 +4751,7 @@
<section <section
class="workspace" class="workspace"
aria-label="Git workspace" aria-label="Git workspace"
style="--left-sidebar-width: {leftSidebarWidth}px; --history-aside-width: {historyAsideWidth}px;" style="--left-sidebar-width: {leftSidebarWidth}px; --history-aside-min-width: {HISTORY_ASIDE_MIN_WIDTH}px; --history-aside-width: {historyAsideWidth}px;"
> >
<!-- Left sidebar: branches + explorer --> <!-- Left sidebar: branches + explorer -->
@@ -4363,10 +4772,11 @@
{hasRepository} {hasRepository}
{isBusy} {isBusy}
onCheckout={checkout} onCheckout={checkout}
onCompareBranch={compareBranchWithCurrent}
onMerge={merge} onMerge={merge}
onRebase={rebaseOnto} onRebase={rebaseOnto}
onCreateBranch={createNewBranch} onCreateBranch={createNewBranch}
onRenameBranch={renameLocalBranch} onRenameBranch={openRenameBranchDialog}
onDeleteBranch={deleteLocalBranch} onDeleteBranch={deleteLocalBranch}
onDeleteRemoteBranch={deleteTrackedRemoteBranch} onDeleteRemoteBranch={deleteTrackedRemoteBranch}
onCreateTag={createNewTag} onCreateTag={createNewTag}
@@ -4440,11 +4850,16 @@
{selectedExplorerKind} {selectedExplorerKind}
{hasRepository} {hasRepository}
{isBusy} {isBusy}
language={appLanguage}
editorName={editorToolName}
diffName={diffToolName}
onToggleFolder={toggleExplorerFolder} onToggleFolder={toggleExplorerFolder}
onExpandAllFolders={expandAllExplorerFolders} onExpandAllFolders={expandAllExplorerFolders}
onCollapseAllFolders={collapseAllExplorerFolders} onCollapseAllFolders={collapseAllExplorerFolders}
onSelectNode={selectExplorerNode} onSelectNode={selectExplorerNode}
onOpenFile={openFileFromExplorer} onOpenFile={openFileFromExplorer}
onOpenInEditor={openExplorerFileInEditor}
onExternalDiff={compareExplorerFileExternally}
onFileHistory={openFileHistoryDialog} onFileHistory={openFileHistoryDialog}
onBlame={openBlame} onBlame={openBlame}
collapsed={explorerPanelCollapsed} collapsed={explorerPanelCollapsed}
@@ -4502,7 +4917,7 @@
onUnstage={unstageFile} onUnstage={unstageFile}
onDiscard={discardFiles} onDiscard={discardFiles}
onDiscardMany={discardChanges} onDiscardMany={discardChanges}
onPatch={openLinePatch} onPatch={openPreferredFileDiff}
onStageAll={stageAllFiles} onStageAll={stageAllFiles}
onUnstageAll={unstageAllFiles} onUnstageAll={unstageAllFiles}
/> />
@@ -4544,7 +4959,7 @@
onGenerateCommitMessage={generateCommitMessageWithAi} onGenerateCommitMessage={generateCommitMessageWithAi}
onReviewStaged={reviewStagedWithAi} onReviewStaged={reviewStagedWithAi}
onSplitStaged={splitStagedWithAi} onSplitStaged={splitStagedWithAi}
onOpenAiSettings={() => { aiSettingsOpen = true; }} onOpenAiSettings={openAiSettings}
onToggleAmend={toggleAmendMode} onToggleAmend={toggleAmendMode}
onUndoLastCommit={undoLastCommitChange} onUndoLastCommit={undoLastCommitChange}
/> />
@@ -4574,9 +4989,13 @@
<aside class="history-aside" aria-label="Commit history"> <aside class="history-aside" aria-label="Commit history">
<HistoryPanel <HistoryPanel
{commits} {commits}
{selectedCommitHash}
{localBranchNames} {localBranchNames}
remoteBranchNames={remoteBranches.map((branch) => branch.name)}
activeBranch={status?.current_branch ?? ""} activeBranch={status?.current_branch ?? ""}
activeUpstream={status?.upstream ?? ""} activeUpstream={status?.upstream ?? ""}
activeAhead={status?.ahead ?? 0}
activeBehind={status?.behind ?? 0}
repositoryKey={activeRepoPath} repositoryKey={activeRepoPath}
{hasRepository} {hasRepository}
{isBusy} {isBusy}
@@ -4590,6 +5009,8 @@
onCreateBranchFromCommit={openNewBranchDialog} onCreateBranchFromCommit={openNewBranchDialog}
onCherryPickCommit={cherryPickFromCommit} onCherryPickCommit={cherryPickFromCommit}
onRevertCommit={revertHistoryCommit} onRevertCommit={revertHistoryCommit}
onOpenCommitNote={openCommitNoteDialog}
onSelectCommit={(commit) => { selectedCommitHash = commit.hash; }}
onToggleCommitFiles={(hash) => { onToggleCommitFiles={(hash) => {
const next = new Set(expandedCommitHashes); const next = new Set(expandedCommitHashes);
if (next.has(hash)) next.delete(hash); else next.add(hash); if (next.has(hash)) next.delete(hash); else next.add(hash);
@@ -4615,6 +5036,34 @@
</div> </div>
</main> </main>
{#if commandPaletteOpen}
<CommandPalette
language={appLanguage}
{hasRepository}
{isBusy}
{branches}
files={repoFiles}
{commits}
onClose={() => { commandPaletteOpen = false; }}
onCheckoutBranch={checkout}
onOpenFile={openFileFromCommandPalette}
onSelectCommit={selectCommitFromCommandPalette}
onFetch={fetchRepo}
onPull={pullRepo}
onPush={pushRepo}
onRefresh={refreshRepo}
onOpenSearch={openGlobalSearchDialog}
onOpenCompare={openCompareSelect}
onOpenReflog={openReflog}
onOpenInteractiveRebase={openInteractiveRebase}
onOpenWorktrees={openWorktreeDialog}
onOpenSyncSettings={openSyncOptions}
onOpenSettings={openAppSettings}
onOpenAiSettings={openAiSettings}
onOpenHelp={openHelp}
/>
{/if}
{#if updateToastOpen} {#if updateToastOpen}
<UpdateToast <UpdateToast
state={updateToastState} state={updateToastState}
@@ -4641,6 +5090,11 @@
theme={appTheme} theme={appTheme}
language={appLanguage} language={appLanguage}
autoRefresh={autoRefreshEnabled} autoRefresh={autoRefreshEnabled}
externalTools={externalToolsSettings}
detectedTools={detectedExternalTools}
detectionPending={externalToolsDetectionPending}
detectionUnavailable={externalToolsDetectionUnavailable}
onRefreshDetectedTools={() => refreshDetectedExternalTools(false)}
onSave={saveAppSettings} onSave={saveAppSettings}
onClose={() => { appSettingsOpen = false; }} onClose={() => { appSettingsOpen = false; }}
/> />
@@ -4683,6 +5137,9 @@
onClose={closeLinePatch} onClose={closeLinePatch}
onRefresh={refreshLinePatch} onRefresh={refreshLinePatch}
onApply={applyLinePatch} onApply={applyLinePatch}
language={appLanguage}
diffName={diffToolName}
onExternalDiff={openCurrentLinePatchExternally}
/> />
{/await} {/await}
{/if} {/if}
@@ -4781,7 +5238,26 @@
/> />
{/if} {/if}
<!-- Rename a local branch from the branch context menu --> {#if commitNoteTarget}
<CommitNoteDialog
commit={commitNoteTarget}
note={commitNoteText}
remotes={commitNoteRemotes}
preferredRemote={commitNotePreferredRemote}
language={appLanguage}
isLoading={commitNoteLoading}
isBusy={commitNoteBusy}
error={commitNoteError}
status={commitNoteStatus}
onSave={saveActiveCommitNote}
onDelete={deleteActiveCommitNote}
onFetch={(remote) => syncActiveCommitNotes(remote, "fetch")}
onPush={(remote) => syncActiveCommitNotes(remote, "push")}
onClose={closeCommitNoteDialog}
/>
{/if}
<!-- Rename a local or remote branch from the branch context menu -->
{#if renameBranchTarget} {#if renameBranchTarget}
<RenameBranchDialog <RenameBranchDialog
branch={renameBranchTarget} branch={renameBranchTarget}
@@ -4814,7 +5290,7 @@
{/await} {/await}
{/if} {/if}
<!-- Compare: pick the two commits to diff --> <!-- Interactive rebase -->
{#if interactiveRebaseOpen} {#if interactiveRebaseOpen}
{#await import("./lib/components/InteractiveRebaseDialog.svelte") then module} {#await import("./lib/components/InteractiveRebaseDialog.svelte") then module}
<module.default <module.default
@@ -4847,10 +5323,11 @@
/> />
{/if} {/if}
<!-- Compare: pick the two commits to diff --> <!-- Compare: pick two branches or commits to diff -->
{#if compareSelectOpen} {#if compareSelectOpen}
<CompareSelectDialog <CompareSelectDialog
{commits} {commits}
{branches}
{compareFrom} {compareFrom}
{compareTo} {compareTo}
{canCompare} {canCompare}
@@ -4858,7 +5335,7 @@
{operation} {operation}
onCompareFromChange={(val) => { compareFrom = val; }} onCompareFromChange={(val) => { compareFrom = val; }}
onCompareToChange={(val) => { compareTo = val; }} onCompareToChange={(val) => { compareTo = val; }}
onCompare={compareSelectedCommits} onCompare={compareSelectedTargets}
onClose={() => { compareSelectOpen = false; }} onClose={() => { compareSelectOpen = false; }}
/> />
{/if} {/if}
@@ -4870,6 +5347,8 @@
{comparison} {comparison}
{selectedDiffPath} {selectedDiffPath}
{isBusy} {isBusy}
fromLabel={comparisonFromLabel}
toLabel={comparisonToLabel}
highlightQuery={diffHighlightQuery} highlightQuery={diffHighlightQuery}
restoreLabel={pendingRestoreFile ? "Restore file" : ""} restoreLabel={pendingRestoreFile ? "Restore file" : ""}
onClose={closeCompareDialog} onClose={closeCompareDialog}
@@ -4939,10 +5418,13 @@
{preparedResolutions} {preparedResolutions}
{isBusy} {isBusy}
{operation} {operation}
language={appLanguage}
mergeName={mergeToolName}
onClose={() => { resolveDialogOpen = false; }} onClose={() => { resolveDialogOpen = false; }}
onSelectFile={selectConflictFile} onSelectFile={selectConflictFile}
onMarkResolved={handleMarkResolved} onMarkResolved={handleMarkResolved}
onApply={applyPreparedResolutions} onApply={applyPreparedResolutions}
onExternalMerge={openConflictInExternalMerge}
/> />
{/await} {/await}
{/if} {/if}
+422 -133
View File
@@ -1506,7 +1506,7 @@
.workspace { .workspace {
display: grid; display: grid;
grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 8px minmax(0, 1fr) 8px minmax(560px, var(--history-aside-width, 620px)); grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 8px minmax(0, 1fr) 8px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 620px));
flex: 1 1 0; flex: 1 1 0;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -2408,6 +2408,7 @@
.commit-row { display: grid; min-width: 0; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); transition: border-color 120ms; } .commit-row { display: grid; min-width: 0; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); transition: border-color 120ms; }
.commit-row + .commit-row { margin-top: 5px; } .commit-row + .commit-row { margin-top: 5px; }
.commit-row:hover { border-color: var(--color-border); } .commit-row:hover { border-color: var(--color-border); }
.commit-row.selected { border-color: color-mix(in srgb, var(--color-primary) 58%, var(--color-border)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent) inset; }
.commit-row.compact { padding: 8px; } .commit-row.compact { padding: 8px; }
.commit-line { display: flex; align-items: flex-start; min-width: 0; gap: 8px; } .commit-line { display: flex; align-items: flex-start; min-width: 0; gap: 8px; }
@@ -2491,38 +2492,6 @@
font-size: 10.5px; font-size: 10.5px;
font-weight: 700; font-weight: 700;
} }
.commit-local-branches {
display: inline-flex;
flex: 0 1 auto;
flex-wrap: wrap;
gap: 3px;
min-width: 0;
max-width: min(100%, 260px);
}
.commit-branch-chip {
display: inline-flex;
align-items: center;
gap: 3px;
min-width: 0;
max-width: 100%;
height: 17px;
padding: 0 6px 0 5px;
overflow: hidden;
border: 1px solid rgba(91,209,138,0.22);
border-radius: 999px;
color: #a8eeba;
background: rgba(34,68,48,0.42);
font-family: var(--font-mono);
font-size: 9.5px;
font-weight: 750;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-branch-chip svg {
flex: 0 0 auto;
color: #76d995;
}
.commit-author { .commit-author {
flex: 1 1 80px; flex: 1 1 80px;
overflow: hidden; overflow: hidden;
@@ -2530,30 +2499,220 @@
white-space: nowrap; white-space: nowrap;
} }
.ref-list { display: flex; flex-wrap: wrap; gap: 3px; } .commit-ref-area {
.ref-list .ref-chip { position: relative;
max-width: 100%; z-index: 3;
display: grid;
gap: 5px;
min-width: 0;
}
.commit-ref-strip {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
min-height: 20px;
}
.compact-ref-chip {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: min(58%, 230px);
height: 19px;
padding: 0 6px 0 5px;
overflow: hidden; overflow: hidden;
padding: 1px 6px; border: 1px solid rgba(91,209,138,0.2);
border-radius: 999px; border-radius: 5px;
color: var(--color-accent); color: #a8eeba;
background: rgba(106,154,255,0.09); background: rgba(34,68,48,0.3);
border: 1px solid rgba(106,154,255,0.16); font-family: var(--font-mono);
font-size: 9.5px; font-size: 9.5px;
font-weight: 750;
line-height: 1;
white-space: nowrap;
}
.compact-ref-chip.branch {
flex: 0 1 auto;
max-width: 22px;
height: 20px;
margin-left: -10px;
padding: 0 5px;
border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 34%, transparent);
border-left-width: 2px;
border-radius: 0 5px 5px 0 !important;
color: #dce9ff;
background:
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #69a7ff) 14%, rgba(18,24,36,.96)), rgba(18,24,36,.82));
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--ref-lane-color, #69a7ff) 72%, transparent);
transition:
max-width 190ms cubic-bezier(.2,.75,.25,1),
padding-right 190ms cubic-bezier(.2,.75,.25,1),
border-color 140ms ease,
background 140ms ease,
box-shadow 140ms ease;
}
.compact-ref-branch-icon {
flex: 0 0 auto;
color: var(--ref-lane-color, #69a7ff);
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 28%, transparent));
transition: transform 190ms cubic-bezier(.2,.75,.25,1);
}
.compact-ref-chip > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.commit-ref-detail-item > i {
flex: 0 0 auto;
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--ref-lane-color, #69a7ff);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 18%, transparent);
}
.compact-ref-chip.current {
border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 62%, transparent);
color: #eaf2ff;
background:
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #69a7ff) 25%, rgba(18,24,36,.96)), rgba(18,24,36,.9));
}
.compact-ref-chip.remote {
border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 38%, transparent);
color: #bcd2ff;
background:
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #69a7ff) 14%, rgba(20,27,42,.94)), rgba(20,27,42,.84));
}
.compact-ref-chip.branch > span,
.compact-ref-chip.branch > small {
opacity: 0;
transition: opacity 80ms ease;
}
.graph-row:hover .compact-ref-chip.branch,
.graph-row.selected .compact-ref-chip.branch,
.graph-row:focus-within .compact-ref-chip.branch {
max-width: min(62%, 260px);
padding-right: 8px;
border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 64%, transparent);
background:
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #69a7ff) 24%, rgba(18,24,36,.98)), rgba(18,24,36,.9));
box-shadow:
inset 2px 0 0 var(--ref-lane-color, #69a7ff),
0 3px 12px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 12%, rgba(0,0,0,.24));
}
.graph-row:hover .compact-ref-branch-icon,
.graph-row.selected .compact-ref-branch-icon,
.graph-row:focus-within .compact-ref-branch-icon {
transform: translateX(1px);
}
.graph-row:hover .compact-ref-chip.branch > span,
.graph-row:hover .compact-ref-chip.branch > small,
.graph-row.selected .compact-ref-chip.branch > span,
.graph-row.selected .compact-ref-chip.branch > small,
.graph-row:focus-within .compact-ref-chip.branch > span,
.graph-row:focus-within .compact-ref-chip.branch > small {
opacity: 1;
transition-delay: 55ms;
transition-duration: 120ms;
}
.compact-ref-chip.tag {
flex: 0 1 auto;
max-width: min(32%, 150px);
padding-inline: 4px;
border-color: transparent;
color: #dbc078;
background: transparent;
}
.compact-ref-chip.tag svg { flex: 0 0 auto; }
.compact-ref-chip small {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 2px;
padding-left: 4px;
border-left: 1px solid rgba(255,255,255,.12);
color: #f0bd6b;
font-family: var(--font-sans);
font-size: 8.5px;
font-weight: 850;
}
.compact-ref-chip small.up-to-date { color: #7de39b; }
.compact-ref-overflow {
flex: 0 0 auto;
min-width: 29px;
min-height: 19px;
height: 19px;
padding: 0 5px;
border-color: transparent;
border-radius: 4px;
color: var(--color-ink-dim);
background: rgba(255,255,255,0.025);
font-family: var(--font-mono);
font-size: 9px;
font-weight: 800;
}
.compact-ref-overflow:hover:not(:disabled),
.compact-ref-overflow[aria-expanded="true"] {
border-color: rgba(101,162,255,.34);
color: var(--color-ink);
background: rgba(101,162,255,.1);
}
.commit-ref-details {
display: grid;
gap: 7px;
padding: 8px;
border: 1px solid rgba(94,110,156,.2);
border-radius: 7px;
background: rgba(10,14,21,.68);
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--ref-lane-color, #69a7ff) 65%, transparent);
}
.commit-ref-details > strong {
color: var(--color-ink-muted);
font-size: 10px;
font-weight: 800;
}
.commit-ref-details section {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
align-items: start;
gap: 7px;
}
.commit-ref-details section > span {
padding-top: 3px;
color: var(--color-ink-faint);
font-size: 8px;
font-weight: 850;
letter-spacing: .08em;
text-transform: uppercase;
}
.commit-ref-details section > div { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; }
.commit-ref-detail-item {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
min-height: 20px;
padding: 2px 6px;
overflow: hidden;
border: 1px solid rgba(94,110,156,.18);
border-radius: 5px;
color: var(--color-ink-dim);
background: rgba(255,255,255,.025);
font-family: var(--font-mono);
font-size: 9px;
font-weight: 700; font-weight: 700;
line-height: 1.25;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.ref-list .ref-chip.head { .commit-ref-detail-item.local { color: #a8eeba; border-color: rgba(91,209,138,.18); }
color: #061021; .commit-ref-detail-item.remote { color: #bcd2ff; border-color: rgba(122,172,255,.2); }
border-color: rgba(65,209,255,0.48); .commit-ref-detail-item.tag { color: #dbc078; border-color: rgba(224,180,92,.2); }
background: linear-gradient(135deg, #41d1ff, #7c6cff); .commit-ref-detail-item small {
box-shadow: 0 0 14px rgba(65,209,255,0.2); color: var(--color-ink-faint);
font-family: var(--font-sans);
font-size: 8px;
font-weight: 800;
} }
.ref-list .ref-chip.branch { color: #7ddf9c; background: rgba(78,202,118,0.08); border-color: rgba(78,202,118,0.18); }
.ref-list .ref-chip.remote { color: #aeb6ff; background: rgba(124,108,255,0.08); border-color: rgba(124,108,255,0.18); }
.ref-list .ref-chip.tag { color: #dbc078; background: rgba(224,180,92,0.08); border-color: rgba(224,180,92,0.2); }
.commit-files { .commit-files {
display: grid; display: grid;
@@ -2619,6 +2778,16 @@
background: rgba(65,209,255,0.08); background: rgba(65,209,255,0.08);
color: var(--color-ink); color: var(--color-ink);
} }
.branch-filter-group-label {
padding: 9px 9px 3px;
color: var(--color-ink-faint);
font-size: 9px;
font-weight: 850;
letter-spacing: .1em;
text-transform: uppercase;
}
.commit-note-button:not(:disabled) { color: color-mix(in srgb, var(--color-accent) 72%, var(--color-ink-dim)); }
/* --- Git graph --- */ /* --- Git graph --- */
.graph-list { .graph-list {
@@ -2692,6 +2861,10 @@
stroke-dasharray: 4 4; stroke-dasharray: 4 4;
filter: drop-shadow(0 0 3px rgba(122,172,255,0.2)); filter: drop-shadow(0 0 3px rgba(122,172,255,0.2));
} }
.graph-svg path.graph-ref-connector {
opacity: 0.35;
stroke-linecap: round;
}
.graph-dot { .graph-dot {
position: absolute; position: absolute;
z-index: 2; z-index: 2;
@@ -2725,64 +2898,13 @@
border-color: var(--dot-color, #5a8cf8); border-color: var(--dot-color, #5a8cf8);
box-shadow: 0 0 0 1px rgba(255,255,255,0.1); box-shadow: 0 0 0 1px rgba(255,255,255,0.1);
} }
.graph-hover-branches {
position: absolute;
z-index: 5;
top: 50%;
display: flex;
flex-wrap: wrap;
gap: 3px;
max-width: 190px;
opacity: 0;
pointer-events: none;
transform: translateY(-50%) translateX(-4px);
transition: opacity 120ms ease, transform 120ms ease;
}
.graph-gutter:hover .graph-hover-branches {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
.graph-hover-branches span {
display: inline-flex;
align-items: center;
gap: 3px;
max-width: 180px;
height: 18px;
padding: 0 6px 0 5px;
overflow: hidden;
border: 1px solid rgba(91,209,138,0.28);
border-radius: 999px;
color: #b2f0c2;
background: rgba(14,36,27,0.94);
box-shadow: 0 8px 22px rgba(0,0,0,0.24);
font-family: var(--font-mono);
font-size: 9.5px;
font-weight: 750;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.graph-hover-branches span.remote,
.branch-filter-option.remote { .branch-filter-option.remote {
border-color: rgba(122,172,255,0.32); border-color: rgba(122,172,255,0.32);
color: #bcd2ff; color: #bcd2ff;
background: rgba(31,43,72,0.88); background: rgba(31,43,72,0.88);
} }
.graph-hover-branches span.ahead {
border-color: rgba(224,160,64,0.42);
color: #ffd99a;
background: rgba(58,42,20,0.94);
}
.graph-hover-branches span.behind {
border-color: rgba(122,172,255,0.46);
color: #c7dbff;
background: rgba(25,39,70,0.94);
}
.graph-hover-branches svg {
flex: 0 0 auto;
color: #76d995;
}
.commit-body { .commit-body {
position: relative;
display: grid; display: grid;
gap: 5px; gap: 5px;
min-width: 0; min-width: 0;
@@ -2790,6 +2912,39 @@
background: rgba(18,24,36,0.5); background: rgba(18,24,36,0.5);
transition: background 120ms ease; transition: background 120ms ease;
} }
.commit-body.has-branch-ref::before {
content: "";
position: absolute;
z-index: 2;
top: 24px;
bottom: calc(50% + 16px);
left: -16px;
width: 16px;
min-height: 14px;
border-top: 1.5px solid var(--ref-lane-color, #69a7ff);
border-left: 1.5px solid var(--ref-lane-color, #69a7ff);
border-top-left-radius: 14px;
opacity: .35;
pointer-events: none;
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 12%, transparent));
transition: opacity 140ms ease, filter 140ms ease;
}
.commit-body.has-branch-ref::after {
content: "";
position: absolute;
z-index: 2;
top: calc(50% - 16px);
left: -32px;
width: 16px;
height: 16px;
border-right: 1.5px solid var(--ref-lane-color, #69a7ff);
border-bottom: 1.5px solid var(--ref-lane-color, #69a7ff);
border-bottom-right-radius: 14px;
opacity: .35;
pointer-events: none;
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 12%, transparent));
transition: opacity 140ms ease, filter 140ms ease;
}
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(226,232,240,0.075); } .graph-row + .graph-row .commit-body { border-top: 1px solid rgba(226,232,240,0.075); }
.graph-row.graph-ahead-row .commit-body { .graph-row.graph-ahead-row .commit-body {
box-shadow: inset 3px 0 0 rgba(224,160,64,0.72); box-shadow: inset 3px 0 0 rgba(224,160,64,0.72);
@@ -2799,6 +2954,18 @@
} }
.graph-row:hover .commit-body { background: rgba(30,39,57,0.72); } .graph-row:hover .commit-body { background: rgba(30,39,57,0.72); }
.graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; } .graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; }
.graph-row:hover .graph-svg path.graph-ref-connector,
.graph-row.selected .graph-svg path.graph-ref-connector,
.graph-row:focus-within .graph-svg path.graph-ref-connector { opacity: .95; stroke-width: 1.8; }
.graph-row:hover .commit-body.has-branch-ref::before,
.graph-row:hover .commit-body.has-branch-ref::after,
.graph-row.selected .commit-body.has-branch-ref::before,
.graph-row.selected .commit-body.has-branch-ref::after,
.graph-row:focus-within .commit-body.has-branch-ref::before,
.graph-row:focus-within .commit-body.has-branch-ref::after {
opacity: .95;
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 24%, transparent));
}
.graph-row:hover .graph-svg path.hidden-branch { opacity: 0.12; stroke-width: 2.2; } .graph-row:hover .graph-svg path.hidden-branch { opacity: 0.12; stroke-width: 2.2; }
.graph-row:hover .graph-dot { transform: translate(-50%, -50%) scale(1.12); } .graph-row:hover .graph-dot { transform: translate(-50%, -50%) scale(1.12); }
.graph-row:hover .graph-dot.hidden-branch { transform: translate(-50%, -50%) scale(1); } .graph-row:hover .graph-dot.hidden-branch { transform: translate(-50%, -50%) scale(1); }
@@ -2811,6 +2978,28 @@
rgba(20,27,40,0.62); rgba(20,27,40,0.62);
} }
@media (prefers-reduced-motion: reduce) {
.compact-ref-chip.branch,
.compact-ref-chip.branch > span,
.compact-ref-chip.branch > small,
.compact-ref-branch-icon,
.commit-body.has-branch-ref::before,
.commit-body.has-branch-ref::after {
transition: none;
}
}
@media (hover: none) {
.compact-ref-chip.branch {
max-width: min(62%, 260px);
padding-right: 8px;
}
.compact-ref-chip.branch > span,
.compact-ref-chip.branch > small {
opacity: 1;
}
}
/* --- Compare panel --- */ /* --- Compare panel --- */
.compare-panel { .compare-panel {
@@ -2834,6 +3023,16 @@
.compare-field { display: grid; gap: 4px; min-width: 0; } .compare-field { display: grid; gap: 4px; min-width: 0; }
.compare-field span { color: var(--color-ink-faint); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; } .compare-field span { color: var(--color-ink-faint); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; }
.compare-arrow { margin-bottom: 6px; color: var(--color-ink-faint); } .compare-arrow { margin-bottom: 6px; color: var(--color-ink-faint); }
.compare-target-help {
margin: 12px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle));
border-radius: 8px;
color: var(--color-ink-dim);
background: color-mix(in srgb, var(--color-accent) 5%, var(--color-surface-raised));
font-size: 11.5px;
line-height: 1.5;
}
.compare-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; padding: 10px 12px; } .compare-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; padding: 10px 12px; }
.compare-range { display: flex; align-items: center; gap: 7px; color: var(--color-ink-muted); } .compare-range { display: flex; align-items: center; gap: 7px; color: var(--color-ink-muted); }
@@ -3187,6 +3386,9 @@
max-height: calc(100vh - 32px); max-height: calc(100vh - 32px);
overflow: auto; overflow: auto;
} }
.app-settings-dialog {
width: min(880px, calc(100vw - 32px));
}
.clone-repository-dialog { .clone-repository-dialog {
display: block; display: block;
width: min(620px, calc(100vw - 32px)); width: min(620px, calc(100vw - 32px));
@@ -3493,6 +3695,34 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.new-branch-field > div { display: flex; min-width: 0; }
.new-branch-field > div > input { width: 100%; min-width: 0; }
.remote-branch-name-field > strong {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
height: 34px;
padding: 0 0 0 11px;
border: 1px solid var(--color-border-input);
border-right: 0;
border-radius: var(--ui-radius-sm) 0 0 var(--ui-radius-sm);
color: var(--color-ink-faint);
background: var(--color-surface-dim);
font-family: var(--font-mono);
font-size: 12px;
}
.remote-branch-name-field > input { border-radius: 0 var(--ui-radius-sm) var(--ui-radius-sm) 0; }
.rename-remote-note {
margin: -2px 0 0;
padding: 10px 12px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
color: var(--color-ink-dim);
background: var(--color-surface-dim);
font-size: 11.5px;
line-height: 1.5;
}
.rename-remote-note strong { color: var(--color-ink); font-family: var(--font-mono); font-weight: 700; }
.new-branch-actions { .new-branch-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -3502,14 +3732,26 @@
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); } .dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.dialog-header > div:first-child { min-width: 0; } .dialog-header > div:first-child { min-width: 0; }
.dialog-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex: 0 0 auto; min-width: 0; } .dialog-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex: 0 0 auto; min-width: 0; }
.tool-surface-choice { display: inline-flex; align-items: center; gap: 3px; min-width: 0; padding: 3px; border: 1px solid var(--color-border-subtle); border-radius: 7px; background: var(--color-surface-dim); }
.tool-surface-choice > span { padding: 0 6px 0 4px; color: var(--color-ink-faint); font-size: 9px; font-weight: 800; letter-spacing: .035em; text-transform: uppercase; white-space: nowrap; }
.tool-surface-choice > button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-width: 0; min-height: 26px; max-width: 190px; padding: 0 8px; border: 1px solid transparent; border-radius: 5px; color: var(--color-ink-dim); background: transparent; font-size: 10px; font-weight: 750; }
.tool-surface-choice > button > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tool-surface-choice > button:hover:not(:disabled) { color: var(--color-ink); background: var(--color-surface-hover); }
.tool-surface-choice > button.active { border-color: color-mix(in srgb, var(--color-accent) 32%, var(--color-border)); color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); }
.compare-restore { max-width: 170px; min-width: 0; } .compare-restore { max-width: 170px; min-width: 0; }
.compare-restore span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .compare-restore span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dialog-range { display: flex; align-items: center; gap: 8px; margin: 2px 0 0; color: var(--color-accent); font-size: 15px; } .dialog-range { display: flex; align-items: center; gap: 8px; max-width: min(68vw, 780px); margin: 2px 0 0; color: var(--color-accent); font-size: 15px; }
.dialog-range .hash { min-width: 0; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dialog-title { margin: 2px 0 0; color: var(--color-ink); font-size: 15px; font-weight: 600; } .dialog-title { margin: 2px 0 0; color: var(--color-ink); font-size: 15px; font-weight: 600; }
.dialog-close { min-height: 32px; min-width: 32px; padding: 0; justify-content: center; } .dialog-close { min-height: 32px; min-width: 32px; padding: 0; justify-content: center; }
@media (max-width: 760px) {
.tool-surface-choice > span { display: none; }
.tool-surface-choice > button { max-width: 120px; padding-inline: 7px; }
}
.dialog-body { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 0; } .dialog-body { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 0; }
.compare-dialog .dialog-body { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); } .compare-dialog .dialog-body { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); }
@@ -3649,6 +3891,11 @@
} }
.split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); } .split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); }
.split-col-hash { .split-col-hash {
min-width: 0;
max-width: min(42%, 260px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 1px 6px; padding: 1px 6px;
border-radius: 5px; border-radius: 5px;
background: rgba(90,140,248,0.12); background: rgba(90,140,248,0.12);
@@ -5519,7 +5766,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.repo-toolbar-divider { height: 34px; margin-inline: 6px; } .repo-toolbar-divider { height: 34px; margin-inline: 6px; }
.workspace { .workspace {
grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 7px minmax(360px, 1fr) 7px minmax(560px, var(--history-aside-width, 620px)); grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 7px minmax(360px, 1fr) 7px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 620px));
flex: 1 1 0; flex: 1 1 0;
padding: 0; padding: 0;
background: var(--color-border-subtle); background: var(--color-border-subtle);
@@ -5640,7 +5887,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
contain-intrinsic-block-size: 108px; contain-intrinsic-block-size: 108px;
} }
.commit-avatar { border-radius: 50%; } .commit-avatar { border-radius: 50%; }
.commit-kind, .commit-branch-chip, .ref-chip { border-radius: 4px !important; } .commit-kind, .compact-ref-chip { border-radius: 4px !important; }
.workspace-statusbar { .workspace-statusbar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -5886,46 +6133,73 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
color: #315fd6; color: #315fd6;
} }
:root[data-theme="light"] .commit-branch-chip, :root[data-theme="light"] .compact-ref-chip.branch {
:root[data-theme="light"] .graph-hover-branches span { border-color: color-mix(in srgb, var(--ref-lane-color, #315fd6) 48%, rgba(49,95,214,.16));
border-color: rgba(31,128,76,0.22); color: #18345f;
color: #146b3b; background:
background: rgba(224,246,233,0.92); linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #315fd6) 14%, #f7faff), #f7faff);
box-shadow: 0 8px 22px rgba(28,44,74,0.12); }
:root[data-theme="light"] .compact-ref-chip.current {
border-color: color-mix(in srgb, var(--ref-lane-color, #315fd6) 58%, rgba(49,95,214,.2));
color: #18345f;
background:
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #315fd6) 20%, #f7faff), #f7faff);
} }
:root[data-theme="light"] .graph-hover-branches span.remote,
:root[data-theme="light"] .branch-filter-option.remote { :root[data-theme="light"] .branch-filter-option.remote {
border-color: rgba(49,95,214,0.22); border-color: rgba(49,95,214,0.22);
color: #315fd6; color: #315fd6;
background: rgba(231,237,255,0.92); background: rgba(231,237,255,0.92);
} }
:root[data-theme="light"] .graph-hover-branches span.ahead { :root[data-theme="light"] .compact-ref-chip.remote {
border-color: rgba(150,98,15,0.28); border-color: color-mix(in srgb, var(--ref-lane-color, #315fd6) 42%, rgba(49,95,214,.16));
color: #96620f;
background: rgba(255,244,224,0.95);
}
:root[data-theme="light"] .graph-hover-branches span.behind {
border-color: rgba(49,95,214,0.26);
color: #315fd6; color: #315fd6;
background: rgba(231,237,255,0.95); background:
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #315fd6) 12%, #f7faff), #f7faff);
} }
:root[data-theme="light"] .ref-list .ref-chip.head { :root[data-theme="light"] .graph-row:hover .compact-ref-chip.branch,
color: #ffffff; :root[data-theme="light"] .graph-row.selected .compact-ref-chip.branch,
background: linear-gradient(135deg, #0f8fb5, #315fd6); :root[data-theme="light"] .graph-row:focus-within .compact-ref-chip.branch {
border-color: color-mix(in srgb, var(--ref-lane-color, #315fd6) 62%, rgba(49,95,214,.2));
background:
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #315fd6) 22%, #f7faff), #ffffff);
box-shadow:
inset 2px 0 0 var(--ref-lane-color, #315fd6),
0 3px 12px color-mix(in srgb, var(--ref-lane-color, #315fd6) 10%, rgba(34,49,78,.16));
} }
:root[data-theme="light"] .ref-list .ref-chip.branch { :root[data-theme="light"] .compact-ref-chip.tag,
color: #16723b; :root[data-theme="light"] .commit-ref-detail-item.tag {
background: rgba(78,202,118,0.12); color: #8b5d0e;
} }
:root[data-theme="light"] .ref-list .ref-chip.remote { :root[data-theme="light"] .compact-ref-chip.tag {
color: #315fd6; background: transparent;
background: rgba(49,95,214,0.09); }
:root[data-theme="light"] .compact-ref-overflow {
color: #60708a;
border-color: transparent;
background: rgba(49,95,214,.035);
}
:root[data-theme="light"] .graph-branch-dialog-button {
color: #475873;
border-color: rgba(49,95,214,.16);
background: rgba(244,247,252,.9);
}
:root[data-theme="light"] .commit-ref-details {
border-color: rgba(49,95,214,.16);
background: rgba(247,249,253,.94);
}
:root[data-theme="light"] .commit-ref-detail-item {
color: #475873;
background: #ffffff;
} }
:root[data-theme="light"] .commit-files, :root[data-theme="light"] .commit-files,
@@ -6133,16 +6407,16 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
/* --- Responsive breakpoints --- */ /* --- Responsive breakpoints --- */
@media (min-width: 1800px) { @media (min-width: 1800px) {
.workspace { grid-template-columns: minmax(220px, var(--left-sidebar-width, 320px)) 8px minmax(0, 1fr) 8px minmax(560px, var(--history-aside-width, 680px)); } .workspace { grid-template-columns: minmax(220px, var(--left-sidebar-width, 320px)) 8px minmax(0, 1fr) 8px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 680px)); }
} }
@media (max-width: 1400px) { @media (max-width: 1400px) {
.workspace { grid-template-columns: minmax(200px, var(--left-sidebar-width, 265px)) 8px minmax(0, 1fr) 8px minmax(540px, var(--history-aside-width, 580px)); } .workspace { grid-template-columns: minmax(200px, var(--left-sidebar-width, 265px)) 8px minmax(0, 1fr) 8px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 580px)); }
} }
/* Stack CommitPanel below StatusPanel; history panels stay side by side */ /* Stack CommitPanel below StatusPanel; history panels stay side by side */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.workspace { grid-template-columns: minmax(185px, var(--left-sidebar-width, 220px)) 8px minmax(0, 1fr) 8px minmax(500px, var(--history-aside-width, 560px)); } .workspace { grid-template-columns: minmax(185px, var(--left-sidebar-width, 220px)) 8px minmax(0, 1fr) 8px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 560px)); }
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); } .top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); }
} }
@@ -6588,6 +6862,21 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
padding: 0; padding: 0;
border-radius: 5px; border-radius: 5px;
} }
.explorer-head-actions .explorer-tool-action:not(:disabled) {
color: var(--color-accent);
border-color: color-mix(in srgb, var(--color-accent) 24%, var(--color-border));
background: color-mix(in srgb, var(--color-accent) 7%, var(--color-surface-raised));
}
.explorer-head-actions .explorer-tool-action:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--color-accent) 42%, var(--color-border));
background: color-mix(in srgb, var(--color-accent) 13%, var(--color-surface-raised));
}
.explorer-action-divider {
inline-size: 1px;
block-size: 14px;
margin-inline: 1px;
background: var(--color-border);
}
.ai-review-suggestion { display: grid; gap: 3px; margin-top: 9px; padding: 8px 9px; border-radius: 5px; background: var(--color-surface-dim); } .ai-review-suggestion { display: grid; gap: 3px; margin-top: 9px; padding: 8px 9px; border-radius: 5px; background: var(--color-surface-dim); }
.ai-review-suggestion strong { color: var(--color-ink-dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; } .ai-review-suggestion strong { color: var(--color-ink-dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; }
.ai-review-suggestion span { color: var(--color-ink-muted); font-size: 11.5px; line-height: 1.45; } .ai-review-suggestion span { color: var(--color-ink-muted); font-size: 11.5px; line-height: 1.45; }
+20 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { import {
ChevronDown, ChevronDown,
Code2,
CloudDownload, CloudDownload,
Download, Download,
FolderOpen, FolderOpen,
@@ -12,6 +13,7 @@
Search, Search,
Upload, Upload,
Settings2, Settings2,
Terminal,
} from "@lucide/svelte"; } from "@lucide/svelte";
export let hasRepository: boolean = false; export let hasRepository: boolean = false;
@@ -20,6 +22,9 @@
export let ahead: number = 0; export let ahead: number = 0;
export let behind: number = 0; export let behind: number = 0;
export let language: "en" | "de" = "en"; export let language: "en" | "de" = "en";
export let editorName: string = "Editor";
export let terminalName: string = "Terminal";
export let fileManagerName: string = "Explorer";
export let onFetch: () => void = () => {}; export let onFetch: () => void = () => {};
export let onPull: () => void = () => {}; export let onPull: () => void = () => {};
export let onPush: () => void = () => {}; export let onPush: () => void = () => {};
@@ -29,6 +34,8 @@
export let onInteractiveRebase: () => void = () => {}; export let onInteractiveRebase: () => void = () => {};
export let onReflog: () => void = () => {}; export let onReflog: () => void = () => {};
export let onOpenInExplorer: () => void = () => {}; export let onOpenInExplorer: () => void = () => {};
export let onOpenInEditor: () => void = () => {};
export let onOpenTerminal: () => void = () => {};
export let onFetchPrune: () => void = () => {}; export let onFetchPrune: () => void = () => {};
export let onForcePush: () => void = () => {}; export let onForcePush: () => void = () => {};
export let onSyncOptions: () => void = () => {}; export let onSyncOptions: () => void = () => {};
@@ -144,8 +151,8 @@
class="repo-action" class="repo-action"
onclick={onCompare} onclick={onCompare}
disabled={!hasRepository || isBusy} disabled={!hasRepository || isBusy}
title={isGerman ? "Commits vergleichen" : "Compare commits"} title={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
aria-label={isGerman ? "Commits vergleichen" : "Compare commits"} aria-label={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
> >
<GitCompare size={15} aria-hidden="true" /> <GitCompare size={15} aria-hidden="true" />
<span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span> <span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span>
@@ -192,15 +199,23 @@
<div class="repo-toolbar-divider utility" aria-hidden="true"></div> <div class="repo-toolbar-divider utility" aria-hidden="true"></div>
<div class="repo-action-group repo-utility-actions"> <div class="repo-action-group repo-utility-actions">
<button class="repo-action" onclick={onOpenInEditor} disabled={!hasRepository || isBusy} title={isGerman ? `Repository in ${editorName} öffnen` : `Open repository in ${editorName}`}>
<Code2 size={15} aria-hidden="true" />
<span class="repo-action-label utility-label">{editorName}</span>
</button>
<button class="repo-action" onclick={onOpenTerminal} disabled={!hasRepository || isBusy} title={isGerman ? `${terminalName} im Repository öffnen` : `Open ${terminalName} in repository`}>
<Terminal size={15} aria-hidden="true" />
<span class="repo-action-label utility-label">{terminalName}</span>
</button>
<button <button
class="repo-action" class="repo-action"
onclick={onOpenInExplorer} onclick={onOpenInExplorer}
disabled={!hasRepository || isBusy} disabled={!hasRepository || isBusy}
title={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"} title={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
aria-label={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"} aria-label={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
> >
<FolderOpen size={15} aria-hidden="true" /> <FolderOpen size={15} aria-hidden="true" />
<span class="repo-action-label utility-label">Explorer</span> <span class="repo-action-label utility-label">{fileManagerName}</span>
</button> </button>
<button <button
+529 -97
View File
@@ -1,22 +1,83 @@
<script lang="ts"> <script lang="ts">
import { Check, Languages, RefreshCw, Settings, X } from "@lucide/svelte"; import { open } from "@tauri-apps/plugin-dialog";
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types"; import {
Check,
CheckCircle2,
ChevronDown,
ChevronRight,
CircleDashed,
Code2,
FolderOpen,
GitCompare,
GitMerge,
Languages,
Palette,
RefreshCw,
RotateCw,
Settings2,
ShieldCheck,
SlidersHorizontal,
Terminal,
Wrench,
X,
} from "@lucide/svelte";
import {
applyExternalToolPreset,
defaultExternalToolsSettings,
externalToolPresets,
isExternalToolPresetAvailable,
type ExternalToolKind,
type ExternalToolPreset,
} from "../externalTools";
import type {
AnalyticsSettings,
AppLanguage,
AppTheme,
DetectedExternalTool,
ExternalToolsSettings,
ToolOpenMode,
} from "../types";
type SettingsPage = "general" | "tools";
interface Props { interface Props {
analytics: AnalyticsSettings; analytics: AnalyticsSettings;
theme: AppTheme; theme: AppTheme;
language: AppLanguage; language: AppLanguage;
autoRefresh: boolean; autoRefresh: boolean;
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean) => void; externalTools: ExternalToolsSettings;
detectedTools: DetectedExternalTool[];
detectionPending: boolean;
detectionUnavailable: boolean;
onRefreshDetectedTools: () => void | Promise<void>;
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean, externalTools: ExternalToolsSettings) => void;
onClose: () => void; onClose: () => void;
} }
let { analytics, theme = "system", language = "en", autoRefresh = true, onSave = () => {}, onClose = () => {} }: Props = $props(); let {
analytics,
theme = "system",
language = "en",
autoRefresh = true,
externalTools,
detectedTools = [],
detectionPending = false,
detectionUnavailable = false,
onRefreshDetectedTools = () => {},
onSave = () => {},
onClose = () => {},
}: Props = $props();
const toolKinds: ExternalToolKind[] = ["editor", "diff", "merge", "terminal", "fileManager"];
let activePage = $state<SettingsPage>("tools");
let activeToolKind = $state<ExternalToolKind>("editor");
let advancedOpen = $state(false);
let analyticsEnabled = $state(true); let analyticsEnabled = $state(true);
let selectedTheme = $state<AppTheme>("system"); let selectedTheme = $state<AppTheme>("system");
let selectedLanguage = $state<AppLanguage>("en"); let selectedLanguage = $state<AppLanguage>("en");
let autoRefreshEnabled = $state(true); let autoRefreshEnabled = $state(true);
let tools = $state<ExternalToolsSettings>(defaultExternalToolsSettings());
const isGerman = $derived(selectedLanguage === "de"); const isGerman = $derived(selectedLanguage === "de");
$effect(() => { $effect(() => {
@@ -24,6 +85,7 @@
selectedTheme = theme; selectedTheme = theme;
selectedLanguage = language; selectedLanguage = language;
autoRefreshEnabled = autoRefresh; autoRefreshEnabled = autoRefresh;
tools = structuredClone(externalTools);
}); });
function save() { function save() {
@@ -31,112 +93,482 @@
...analytics, ...analytics,
enabled: analyticsEnabled, enabled: analyticsEnabled,
noticeSeen: true, noticeSeen: true,
}, selectedTheme, selectedLanguage, autoRefreshEnabled); }, selectedTheme, selectedLanguage, autoRefreshEnabled, $state.snapshot(tools));
}
function toolLabel(kind: ExternalToolKind): string {
const labels = {
editor: "Editor",
diff: isGerman ? "Diff-Tool" : "Diff tool",
merge: isGerman ? "Merge-Tool" : "Merge tool",
terminal: "Terminal",
fileManager: isGerman ? "Dateimanager" : "File manager",
};
return labels[kind];
}
function toolDescription(kind: ExternalToolKind): string {
const descriptions = isGerman
? {
editor: "Öffnet Repositories und einzelne Dateien zum Bearbeiten.",
diff: "Vergleicht eine Arbeitsdatei mit ihrer Version aus HEAD.",
merge: "Übergibt Base, Current, Incoming und Ergebnis an einen 3-Wege-Merger.",
terminal: "Startet eine Shell direkt im Repository-Verzeichnis.",
fileManager: "Öffnet das Repository im bevorzugten Dateimanager.",
}
: {
editor: "Opens repositories and individual files for editing.",
diff: "Compares a working file with its version from HEAD.",
merge: "Passes base, current, incoming, and result to a three-way merger.",
terminal: "Starts a shell directly in the repository directory.",
fileManager: "Opens the repository in your preferred file manager.",
};
return descriptions[kind];
}
function toolUsage(kind: ExternalToolKind): string {
const usage = isGerman
? {
editor: "Oben in der Repository-Leiste oder über das Code-Symbol im Datei-Explorer.",
diff: "Datei im Explorer markieren und das Vergleichs-Symbol anklicken alternativ Rechtsklick auf die Datei.",
merge: "Bei einem Konflikt „Konflikte lösen“ öffnen und anschließend dieses Merge-Tool starten.",
terminal: "Oben in der Repository-Leiste über den Terminal-Button.",
fileManager: "Oben in der Repository-Leiste über den Ordner-Button.",
}
: {
editor: "Use the repository toolbar or the code button in the file explorer.",
diff: "Select a file in Explorer and click the compare button, or right-click the file.",
merge: "Open Resolve conflicts and start this merge tool from the conflict view.",
terminal: "Use the terminal button in the repository toolbar.",
fileManager: "Use the folder button in the repository toolbar.",
};
return usage[kind];
}
function presetAvailable(kind: ExternalToolKind, preset: ExternalToolPreset): boolean {
return isExternalToolPresetAvailable(kind, preset, detectedTools);
}
function availablePresets(kind: ExternalToolKind): ExternalToolPreset[] {
return externalToolPresets[kind].filter((preset) => presetAvailable(kind, preset));
}
function otherPresets(kind: ExternalToolKind): ExternalToolPreset[] {
return externalToolPresets[kind].filter((preset) => !presetAvailable(kind, preset));
}
function selectedPreset(kind: ExternalToolKind): ExternalToolPreset | undefined {
return externalToolPresets[kind].find((preset) => preset.id === tools[kind].preset);
}
function selectedToolName(kind: ExternalToolKind): string {
return tools[kind].preset === "custom"
? tools[kind].program.split(/[\\/]/).pop() || (isGerman ? "Eigenes Programm" : "Custom application")
: selectedPreset(kind)?.label ?? tools[kind].program;
}
function openMode(kind: "diff" | "merge"): ToolOpenMode {
return kind === "diff" ? tools.diffOpenMode : tools.mergeOpenMode;
}
function setOpenMode(kind: "diff" | "merge", mode: ToolOpenMode) {
if (kind === "diff") tools.diffOpenMode = mode;
else tools.mergeOpenMode = mode;
}
function selectionAvailable(kind: ExternalToolKind): boolean {
if (tools[kind].preset === "custom") return tools[kind].program.trim().length > 0;
const preset = selectedPreset(kind);
return preset ? presetAvailable(kind, preset) : false;
}
function selectionStatus(kind: ExternalToolKind): string {
if (tools[kind].preset === "custom") {
return tools[kind].program.trim()
? (isGerman ? "Manuell konfiguriert" : "Manually configured")
: (isGerman ? "Programmpfad fehlt" : "Application path missing");
}
return selectionAvailable(kind)
? (isGerman ? "Installiert und verfügbar" : "Installed and available")
: (isGerman ? "Nicht automatisch erkannt" : "Not automatically detected");
}
function changePreset(kind: ExternalToolKind, id: string) {
if (id === "custom") {
tools[kind] = { ...tools[kind], preset: "custom" };
advancedOpen = true;
return;
}
tools[kind] = applyExternalToolPreset(kind, id, detectedTools);
}
function selectToolKind(kind: ExternalToolKind) {
activeToolKind = kind;
advancedOpen = tools[kind].preset === "custom";
}
function updateProgram(kind: ExternalToolKind, program: string) {
tools[kind] = { ...tools[kind], preset: "custom", program };
}
function updateArgs(kind: ExternalToolKind, value: string) {
tools[kind] = {
...tools[kind],
preset: "custom",
args: value.split("\n").map((arg) => arg.trim()).filter(Boolean),
};
}
async function browseProgram(kind: ExternalToolKind) {
const selected = await open({
title: isGerman ? `${toolLabel(kind)} auswählen` : `Choose ${toolLabel(kind)}`,
multiple: false,
directory: false,
});
if (typeof selected === "string") {
updateProgram(kind, selected);
advancedOpen = true;
}
} }
</script> </script>
<div class="dialog-backdrop" role="presentation"> <div class="dialog-backdrop" role="presentation">
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1"> <div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
<header class="dialog-header"> <header class="app-settings-head">
<div> <div class="app-settings-title">
<span class="eyebrow">Gitty</span> <span class="app-settings-mark"><Settings2 size={18} aria-hidden="true" /></span>
<h2 class="dialog-title">{isGerman ? "Einstellungen" : "Settings"}</h2> <div>
<h2>{isGerman ? "Einstellungen" : "Settings"}</h2>
<p>{isGerman ? "Gitty an deinen Workflow anpassen" : "Make Gitty fit your workflow"}</p>
</div>
</div> </div>
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"}> <button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Einstellungen schließen" : "Close settings"}>
<X size={18} aria-hidden="true" /> <X size={18} aria-hidden="true" />
</button> </button>
</header> </header>
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}> <form class="app-settings-shell" onsubmit={(event) => { event.preventDefault(); save(); }}>
<section class="settings-section"> <div class="app-settings-body">
<header> <nav class="settings-nav" aria-label={isGerman ? "Einstellungsbereiche" : "Settings sections"}>
<Settings size={16} aria-hidden="true" /> <button type="button" class:active={activePage === "general"} onclick={() => { activePage = "general"; }}>
<div> <SlidersHorizontal size={16} aria-hidden="true" />
<span class="eyebrow">{isGerman ? "Darstellung" : "Appearance"}</span> <span>
<h3>{isGerman ? "Farbschema" : "Theme"}</h3> <strong>{isGerman ? "Allgemein" : "General"}</strong>
</div> <small>{isGerman ? "Darstellung & Verhalten" : "Appearance & behavior"}</small>
</header> </span>
</button>
<button type="button" class:active={activePage === "tools"} onclick={() => { activePage = "tools"; }}>
<Wrench size={16} aria-hidden="true" />
<span>
<strong>{isGerman ? "Externe Tools" : "External tools"}</strong>
<small>{isGerman ? "Editor, Diff & Terminal" : "Editor, diff & terminal"}</small>
</span>
{#if !detectionUnavailable}<em>{detectedTools.length}</em>{/if}
</button>
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}> <div class="settings-nav-note">
<label class:active={selectedTheme === "system"}> <ShieldCheck size={15} aria-hidden="true" />
<input type="radio" bind:group={selectedTheme} value="system" /> <p>{isGerman ? "Tool-Argumente werden direkt und ohne zusätzliche Shell übergeben." : "Tool arguments are passed directly without an extra shell."}</p>
<span>System</span> </div>
</label> </nav>
<label class:active={selectedTheme === "light"}>
<input type="radio" bind:group={selectedTheme} value="light" /> <div class="settings-content">
<span>{isGerman ? "Hell" : "Light"}</span> {#if activePage === "general"}
</label> <div class="settings-page-head">
<label class:active={selectedTheme === "dark"}> <div>
<input type="radio" bind:group={selectedTheme} value="dark" /> <h3>{isGerman ? "Allgemein" : "General"}</h3>
<span>{isGerman ? "Dunkel" : "Dark"}</span> <p>{isGerman ? "Darstellung, Sprache und Hintergrundverhalten." : "Appearance, language, and background behavior."}</p>
</label> </div>
</div>
<div class="general-settings-grid">
<section class="general-setting-panel">
<header><Palette size={16} /><div><h4>{isGerman ? "Farbschema" : "Theme"}</h4><p>{isGerman ? "Passend zu deiner Umgebung." : "Match your environment."}</p></div></header>
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}>
<label class:active={selectedTheme === "system"}><input type="radio" bind:group={selectedTheme} value="system" /><span>System</span></label>
<label class:active={selectedTheme === "light"}><input type="radio" bind:group={selectedTheme} value="light" /><span>{isGerman ? "Hell" : "Light"}</span></label>
<label class:active={selectedTheme === "dark"}><input type="radio" bind:group={selectedTheme} value="dark" /><span>{isGerman ? "Dunkel" : "Dark"}</span></label>
</div>
</section>
<section class="general-setting-panel">
<header><Languages size={16} /><div><h4>{isGerman ? "Sprache" : "Language"}</h4><p>{isGerman ? "Sprache der Oberfläche." : "Language used by the interface."}</p></div></header>
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
<label class:active={selectedLanguage === "en"}><input type="radio" bind:group={selectedLanguage} value="en" /><span>EN · English</span></label>
<label class:active={selectedLanguage === "de"}><input type="radio" bind:group={selectedLanguage} value="de" /><span>DE · Deutsch</span></label>
</div>
</section>
<section class="general-setting-panel general-setting-wide">
<header><RefreshCw size={16} /><div><h4>{isGerman ? "Repository-Aktualisierung" : "Repository refresh"}</h4><p>{isGerman ? "Arbeitsbereich und Remotes aktuell halten." : "Keep the working tree and remotes current."}</p></div></header>
<label class="settings-switch-row">
<span><strong>{isGerman ? "Automatisch aktualisieren" : "Refresh automatically"}</strong><small>{isGerman ? "Branch-Status und Änderungen regelmäßig im Hintergrund prüfen." : "Periodically check branch state and working-tree changes."}</small></span>
<input type="checkbox" bind:checked={autoRefreshEnabled} />
</label>
</section>
<section class="general-setting-panel general-setting-wide">
<header><ShieldCheck size={16} /><div><h4>{isGerman ? "Datenschutz" : "Privacy"}</h4><p>{isGerman ? "Anonyme Produkt- und Fehlerdiagnose." : "Anonymous product and error diagnostics."}</p></div></header>
<label class="settings-switch-row">
<span><strong>{isGerman ? "Anonyme Analytics erlauben" : "Allow anonymous analytics"}</strong><small>{isGerman ? "Keine Pfade, Remotes, Branches, Diffs, Zugangsdaten oder Quelltexte." : "No paths, remotes, branches, diffs, credentials, or source code."}</small></span>
<input type="checkbox" bind:checked={analyticsEnabled} />
</label>
</section>
</div>
{:else}
<div class="settings-page-head tools-page-head">
<div>
<h3>{isGerman ? "Externe Tools" : "External tools"}</h3>
<p>
{detectionUnavailable
? (isGerman ? "Automatische Erkennung ist in dieser Umgebung nicht verfügbar." : "Automatic detection is unavailable in this environment.")
: (isGerman ? `${detectedTools.length} installierte Programme erkannt.` : `${detectedTools.length} installed applications detected.`)}
</p>
</div>
<button class="tool-rescan-button" type="button" onclick={onRefreshDetectedTools} disabled={detectionPending}>
<RotateCw class={detectionPending ? "spin" : ""} size={14} aria-hidden="true" />
{detectionPending ? (isGerman ? "Erkennung läuft…" : "Detecting…") : (isGerman ? "Neu erkennen" : "Detect again")}
</button>
</div>
<div class="tool-kind-tabs" role="tablist" aria-label={isGerman ? "Tool-Kategorie" : "Tool category"}>
{#each toolKinds as kind}
<button type="button" role="tab" aria-selected={activeToolKind === kind} class:active={activeToolKind === kind} onclick={() => selectToolKind(kind)}>
{#if kind === "editor"}<Code2 size={16} />
{:else if kind === "diff"}<GitCompare size={16} />
{:else if kind === "merge"}<GitMerge size={16} />
{:else if kind === "terminal"}<Terminal size={16} />
{:else}<FolderOpen size={16} />{/if}
<span>{toolLabel(kind)}</span>
<small class:available={selectionAvailable(kind)}></small>
</button>
{/each}
</div>
<section class="tool-config-panel" aria-label={`${toolLabel(activeToolKind)} ${isGerman ? "konfigurieren" : "configuration"}`}>
<div class="tool-config-summary">
<span class="tool-config-icon">
{#if activeToolKind === "editor"}<Code2 size={22} />
{:else if activeToolKind === "diff"}<GitCompare size={22} />
{:else if activeToolKind === "merge"}<GitMerge size={22} />
{:else if activeToolKind === "terminal"}<Terminal size={22} />
{:else}<FolderOpen size={22} />{/if}
</span>
<div>
<h4>{toolLabel(activeToolKind)}</h4>
<p>{toolDescription(activeToolKind)}</p>
</div>
<span class="tool-status" class:available={selectionAvailable(activeToolKind)}>
{#if selectionAvailable(activeToolKind)}<CheckCircle2 size={13} />{:else}<CircleDashed size={13} />{/if}
{selectionStatus(activeToolKind)}
</span>
</div>
{#if activeToolKind === "diff" || activeToolKind === "merge"}
{@const openModeKind = activeToolKind as "diff" | "merge"}
<div class="tool-route" aria-label={isGerman ? "Aktuelle Standardansicht" : "Current default view"}>
<span>{isGerman ? "Standard" : "Default"}</span><ChevronRight size={15} aria-hidden="true" />
<strong>{openMode(openModeKind) === "gitty" ? (isGerman ? "Gitty · integriert" : "Gitty · built in") : selectedToolName(activeToolKind)}</strong>
</div>
<fieldset class="tool-open-mode">
<legend>{isGerman ? "Beim Öffnen verwenden" : "Use when opening"}</legend>
<div>
<button type="button" class:active={openMode(openModeKind) === "gitty"} aria-pressed={openMode(openModeKind) === "gitty"} onclick={() => setOpenMode(openModeKind, "gitty")}>
<span>Gitty</span><small>{isGerman ? "Integrierte Ansicht" : "Built-in view"}</small>
</button>
<button type="button" class:active={openMode(openModeKind) === "external"} aria-pressed={openMode(openModeKind) === "external"} onclick={() => setOpenMode(openModeKind, "external")}>
<span>{selectedToolName(activeToolKind)}</span><small>{isGerman ? "Externes Programm" : "External application"}</small>
</button>
</div>
</fieldset>
{:else}
<div class="tool-route" aria-label={isGerman ? "Aktuelle Standardzuordnung" : "Current default mapping"}>
<span>Gitty</span><ChevronRight size={15} aria-hidden="true" /><strong>{selectedToolName(activeToolKind)}</strong>
</div>
{/if}
<label class="tool-default-field">
<span>{isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}</span>
<select value={tools[activeToolKind].preset} onchange={(event) => changePreset(activeToolKind, event.currentTarget.value)} aria-label={isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}>
{#if availablePresets(activeToolKind).length > 0}
<optgroup label={isGerman ? "Installiert" : "Installed"}>
{#each availablePresets(activeToolKind) as preset}<option value={preset.id}> {preset.label}</option>{/each}
</optgroup>
{/if}
<optgroup label={isGerman ? "Weitere unterstützte Programme" : "Other supported applications"}>
{#each otherPresets(activeToolKind) as preset}<option value={preset.id}>{preset.label}</option>{/each}
</optgroup>
<option value="custom">{isGerman ? "Eigenes Programm auswählen…" : "Choose a custom application…"}</option>
</select>
<small>{isGerman ? "Diese Auswahl wird gespeichert und für alle passenden Aktionen verwendet." : "This selection is saved and used for every matching action."}</small>
</label>
<div class="tool-usage-callout">
<span>{isGerman ? "So öffnest du es" : "How to open it"}</span>
<p>{toolUsage(activeToolKind)}</p>
</div>
<button class="tool-advanced-toggle" type="button" aria-expanded={advancedOpen} onclick={() => { advancedOpen = !advancedOpen; }}>
<span>{isGerman ? "Programmpfad und Argumente" : "Application path and arguments"}</span>
{#if advancedOpen}<ChevronDown size={15} />{:else}<ChevronRight size={15} />{/if}
</button>
{#if advancedOpen}
<div class="tool-advanced-panel">
<label>
<span>{isGerman ? "Programmpfad" : "Application path"}</span>
<div class="tool-program-row">
<input value={tools[activeToolKind].program} oninput={(event) => updateProgram(activeToolKind, event.currentTarget.value)} spellcheck="false" />
<button type="button" onclick={() => browseProgram(activeToolKind)} title={isGerman ? "Programm auswählen" : "Choose application"} aria-label={isGerman ? "Programm auswählen" : "Choose application"}><FolderOpen size={15} /></button>
</div>
</label>
<label>
<span>{isGerman ? "Argumente · eine Zeile pro Argument" : "Arguments · one per line"}</span>
<textarea value={tools[activeToolKind].args.join("\n")} oninput={(event) => updateArgs(activeToolKind, event.currentTarget.value)} spellcheck="false"></textarea>
</label>
<p class="tool-placeholders">
<span>{isGerman ? "Verfügbare Platzhalter" : "Available placeholders"}</span>
<code>{"{repo}"}</code><code>{"{file}"}</code><code>{"{parent}"}</code><code>{"{left}"}</code><code>{"{right}"}</code><code>{"{base}"}</code><code>{"{ours}"}</code><code>{"{theirs}"}</code><code>{"{result}"}</code>
</p>
</div>
{/if}
</section>
{/if}
</div> </div>
</section>
<section class="settings-section">
<header>
<RefreshCw size={16} aria-hidden="true" />
<div>
<span class="eyebrow">Repository</span>
<h3>{isGerman ? "Automatische Aktualisierung" : "Auto refresh"}</h3>
</div>
</header>
<label class="settings-toggle-row">
<input type="checkbox" bind:checked={autoRefreshEnabled} />
<span>
<strong>{isGerman ? "Repositories automatisch aktualisieren" : "Refresh repositories automatically"}</strong>
<small>{isGerman ? "Aktualisiert Arbeitsbereich, Branch-Status und Remotes regelmäßig im Hintergrund." : "Periodically refreshes the working tree, branch status, and remotes in the background."}</small>
</span>
</label>
</section>
<section class="settings-section">
<header>
<Languages size={16} aria-hidden="true" />
<div>
<span class="eyebrow">{isGerman ? "Sprache" : "Language"}</span>
<h3>{isGerman ? "App-Sprache" : "App language"}</h3>
</div>
</header>
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
<label class:active={selectedLanguage === "en"}>
<input type="radio" bind:group={selectedLanguage} value="en" />
<span>EN · English</span>
</label>
<label class:active={selectedLanguage === "de"}>
<input type="radio" bind:group={selectedLanguage} value="de" />
<span>DE · Deutsch</span>
</label>
</div>
</section>
<section class="settings-section">
<header>
<Settings size={16} aria-hidden="true" />
<div>
<span class="eyebrow">Analytics</span>
<h3>{isGerman ? "Anonyme Nutzungsanalyse" : "Anonymous usage analytics"}</h3>
</div>
</header>
<label class="settings-toggle-row">
<input type="checkbox" bind:checked={analyticsEnabled} />
<span>
<strong>{isGerman ? "Anonyme Analytics und Fehlerberichte erlauben" : "Allow anonymous analytics and error reports"}</strong>
<small>{isGerman ? "Es werden keine Repository-Pfade, Remotes, Branches, Commit-Nachrichten, Dateinamen, Diffs, Zugangsdaten oder Code übertragen." : "No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent."}</small>
</span>
</label>
</section>
<div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="submit">
<Check size={16} aria-hidden="true" />
{isGerman ? "Speichern" : "Save"}
</button>
</div> </div>
<footer class="app-settings-footer">
<span>{isGerman ? "Änderungen werden erst beim Speichern übernommen." : "Changes are applied only after saving."}</span>
<div>
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="submit"><Check size={16} aria-hidden="true" />{isGerman ? "Änderungen speichern" : "Save changes"}</button>
</div>
</footer>
</form> </form>
</div> </div>
</div> </div>
<style>
.app-settings-dialog { display: grid; grid-template-rows: auto minmax(0, 1fr); width: min(920px, calc(100vw - 32px)); height: min(720px, calc(100vh - 32px)); overflow: hidden; }
.app-settings-head { display: flex; align-items: center; justify-content: space-between; min-height: 70px; padding: 14px 18px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.app-settings-title { display: flex; align-items: center; gap: 12px; min-width: 0; }
.app-settings-mark { display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, transparent); }
.app-settings-title h2, .settings-page-head h3, .tool-config-summary h4, .general-setting-panel h4 { margin: 0; color: var(--color-ink); }
.app-settings-title h2 { font-size: 18px; line-height: 1.2; }
.app-settings-title p, .settings-page-head p, .tool-config-summary p, .general-setting-panel p { margin: 0; color: var(--color-ink-dim); }
.app-settings-title p { margin-top: 3px; font-size: 11px; }
.app-settings-shell { display: grid; min-height: 0; grid-template-rows: minmax(0, 1fr) auto; }
.app-settings-body { display: grid; min-height: 0; grid-template-columns: 205px minmax(0, 1fr); }
.settings-nav { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 14px 12px; border-right: 1px solid var(--color-border-subtle); background: color-mix(in srgb, var(--app-dialog-chrome) 72%, var(--app-dialog-bg)); }
.settings-nav > button { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; min-height: 50px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; color: var(--color-ink-dim); background: transparent; text-align: left; }
.settings-nav > button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
.settings-nav > button.active { border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); }
.settings-nav button > :global(svg) { color: var(--color-ink-muted); }
.settings-nav button.active > :global(svg) { color: var(--color-accent); }
.settings-nav button span { display: grid; min-width: 0; gap: 2px; }
.settings-nav button strong { font-size: 12px; }
.settings-nav button small { overflow: hidden; color: var(--color-ink-faint); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; }
.settings-nav button em { display: grid; place-items: center; min-width: 21px; height: 20px; padding-inline: 5px; border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 12%, transparent); font-size: 9px; font-style: normal; font-weight: 800; }
.settings-nav-note { display: flex; align-items: flex-start; gap: 8px; margin-top: auto; padding: 10px; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-faint); }
.settings-nav-note :global(svg) { flex: 0 0 auto; margin-top: 1px; color: var(--color-success); }
.settings-nav-note p { margin: 0; font-size: 9.5px; line-height: 1.45; }
.settings-content { min-width: 0; overflow: auto; padding: 18px 20px 22px; }
.settings-page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.settings-page-head h3 { font-size: 18px; }
.settings-page-head p { margin-top: 4px; font-size: 11px; line-height: 1.45; }
.tool-rescan-button { display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; min-height: 30px; padding: 0 10px; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink-muted); background: var(--color-surface-raised); font-size: 10px; font-weight: 750; }
.tool-rescan-button:hover:not(:disabled) { color: var(--color-ink); border-color: var(--color-border-input); background: var(--color-surface-hover); }
.tool-kind-tabs { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; margin-bottom: 14px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--app-settings-row-bg); }
.tool-kind-tabs button { position: relative; display: flex; align-items: center; justify-content: center; gap: 7px; min-width: 0; height: 38px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; font-size: 10.5px; font-weight: 750; }
.tool-kind-tabs button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
.tool-kind-tabs button.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-raised); box-shadow: 0 2px 8px rgba(0,0,0,.12); }
.tool-kind-tabs button.active :global(svg) { color: var(--color-accent); }
.tool-kind-tabs button small { position: absolute; top: 5px; right: 6px; width: 5px; height: 5px; border-radius: 50%; background: var(--color-ink-faint); }
.tool-kind-tabs button small.available { background: var(--color-success); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-success) 15%, transparent); }
.tool-config-panel { display: grid; gap: 14px; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 12px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
.tool-config-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; }
.tool-config-icon { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid color-mix(in srgb, var(--color-accent) 25%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 8%, transparent); }
.tool-config-summary h4 { font-size: 14px; }
.tool-config-summary p { margin-top: 3px; font-size: 10.5px; line-height: 1.4; }
.tool-status { display: inline-flex; align-items: center; gap: 5px; padding: 5px 7px; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-ink-faint); font-size: 9px; font-weight: 750; }
.tool-status.available { border-color: color-mix(in srgb, var(--color-success) 24%, var(--color-border)); color: var(--color-success); background: color-mix(in srgb, var(--color-success) 6%, transparent); }
.tool-route { display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 7px 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; color: var(--color-ink-faint); background: var(--color-surface-raised); font-size: 10.5px; }
.tool-route strong { color: var(--color-ink); }
.tool-open-mode { display: grid; gap: 6px; min-width: 0; margin: 0; padding: 0; border: 0; }
.tool-open-mode legend { margin-bottom: 6px; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
.tool-open-mode > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--color-surface-raised); }
.tool-open-mode button { display: grid; justify-items: start; gap: 2px; min-width: 0; min-height: 46px; padding: 7px 10px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; text-align: left; }
.tool-open-mode button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
.tool-open-mode button.active { border-color: color-mix(in srgb, var(--color-accent) 38%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-hover)); box-shadow: inset 2px 0 0 var(--color-accent); }
.tool-open-mode button span { max-width: 100%; overflow: hidden; font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
.tool-open-mode button small { color: var(--color-ink-faint); font-size: 9px; font-weight: 550; }
.tool-default-field, .tool-advanced-panel label { display: grid; gap: 6px; min-width: 0; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
.tool-default-field select, .tool-advanced-panel input, .tool-advanced-panel textarea { width: 100%; min-width: 0; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink); background: var(--color-surface-raised); font: inherit; }
.tool-default-field select { height: 38px; padding: 0 11px; font-size: 12px; font-weight: 700; }
.tool-default-field small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; }
.tool-usage-callout { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 10px; padding: 10px 11px; border-left: 2px solid var(--color-accent); border-radius: 0 7px 7px 0; background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
.tool-usage-callout span { color: var(--color-accent); font-size: 9.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
.tool-usage-callout p { margin: 0; color: var(--color-ink-muted); font-size: 10.5px; line-height: 1.45; }
.tool-advanced-toggle { display: flex; align-items: center; justify-content: space-between; min-height: 32px; padding: 0; border: 0; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-dim); background: transparent; font-size: 10.5px; font-weight: 750; }
.tool-advanced-toggle:hover { color: var(--color-ink); }
.tool-advanced-panel { display: grid; gap: 11px; padding-top: 2px; }
.tool-program-row { display: grid; grid-template-columns: minmax(0, 1fr) 34px; gap: 6px; }
.tool-advanced-panel input { height: 34px; padding: 0 9px; font-family: var(--font-mono); font-size: 10.5px; }
.tool-advanced-panel textarea { min-height: 80px; padding: 8px 9px; resize: vertical; font-family: var(--font-mono); font-size: 10.5px; line-height: 1.45; }
.tool-program-row button { display: grid; place-items: center; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink-muted); background: var(--color-surface-raised); }
.tool-program-row button:hover { color: var(--color-ink); border-color: var(--color-border-input); background: var(--color-surface-hover); }
.tool-placeholders { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin: 0; color: var(--color-ink-faint); font-size: 9px; }
.tool-placeholders span { margin-right: 3px; }
.tool-placeholders code { padding: 2px 4px; border-radius: 4px; color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 9%, transparent); }
.general-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.general-setting-panel { display: grid; align-content: start; gap: 14px; min-width: 0; padding: 14px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--app-settings-row-bg); }
.general-setting-panel.general-setting-wide { grid-column: 1 / -1; }
.general-setting-panel > header { display: flex; align-items: flex-start; gap: 9px; }
.general-setting-panel > header > :global(svg) { flex: 0 0 auto; margin-top: 1px; color: var(--color-accent); }
.general-setting-panel h4 { font-size: 12.5px; }
.general-setting-panel p { margin-top: 3px; font-size: 9.5px; }
.settings-segmented { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 3px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
.settings-segmented.settings-language { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.settings-segmented label { display: flex; align-items: center; justify-content: center; min-height: 31px; border: 1px solid transparent; border-radius: 6px; color: var(--color-ink-dim); font-size: 10.5px; font-weight: 750; }
.settings-segmented label.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-hover); }
.settings-segmented input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.settings-switch-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; }
.settings-switch-row span { display: grid; gap: 3px; }
.settings-switch-row strong { color: var(--color-ink); font-size: 11px; }
.settings-switch-row small { color: var(--color-ink-dim); font-size: 9.5px; line-height: 1.4; }
.settings-switch-row input { width: 32px; height: 18px; accent-color: var(--color-accent); }
.app-settings-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 62px; padding: 11px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.app-settings-footer > span { color: var(--color-ink-faint); font-size: 9.5px; }
.app-settings-footer > div { display: flex; gap: 8px; }
.app-settings-footer button { min-height: 32px; }
@media (max-width: 760px) {
.app-settings-dialog { height: min(760px, calc(100vh - 20px)); width: min(660px, calc(100vw - 20px)); }
.app-settings-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
.settings-nav { flex-direction: row; padding: 8px 10px; border-right: 0; border-bottom: 1px solid var(--color-border-subtle); }
.settings-nav > button { width: auto; min-width: 0; flex: 1 1 0; min-height: 42px; }
.settings-nav-note { display: none; }
.settings-content { padding: 14px; }
.tool-kind-tabs { grid-template-columns: repeat(5, minmax(42px, 1fr)); overflow-x: auto; }
.tool-kind-tabs button { height: 40px; }
.tool-kind-tabs button span { display: none; }
.tool-config-summary { grid-template-columns: auto minmax(0, 1fr); }
.tool-status { grid-column: 1 / -1; justify-self: start; }
.app-settings-footer > span { display: none; }
.app-settings-footer { justify-content: flex-end; }
}
@media (max-width: 520px) {
.app-settings-head { min-height: 58px; padding: 10px 12px; }
.app-settings-mark { width: 34px; height: 34px; }
.settings-nav button small, .settings-nav button em { display: none; }
.settings-nav > button { grid-template-columns: auto minmax(0, 1fr); }
.settings-page-head { align-items: stretch; flex-direction: column; }
.tool-rescan-button { align-self: flex-start; }
.general-settings-grid { grid-template-columns: 1fr; }
.general-setting-panel.general-setting-wide { grid-column: auto; }
.tool-config-panel { padding: 13px; }
.tool-usage-callout { grid-template-columns: 1fr; gap: 4px; }
}
</style>
+17 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte"; import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitTag } from "../types"; import type { GitBranch as GitBranchInfo, GitTag } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode; type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -49,6 +49,7 @@
hasRepository: boolean; hasRepository: boolean;
isBusy: boolean; isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void; onCheckout: (branch: GitBranchInfo) => void;
onCompareBranch: (branch: GitBranchInfo) => void;
onMerge: (branch: GitBranchInfo) => void; onMerge: (branch: GitBranchInfo) => void;
onRebase: (branch: GitBranchInfo) => void; onRebase: (branch: GitBranchInfo) => void;
onCreateBranch: (branchName: string) => void | Promise<void>; onCreateBranch: (branchName: string) => void | Promise<void>;
@@ -72,6 +73,7 @@
hasRepository = false, hasRepository = false,
isBusy = false, isBusy = false,
onCheckout = () => {}, onCheckout = () => {},
onCompareBranch = () => {},
onMerge = () => {}, onMerge = () => {},
onRebase = () => {}, onRebase = () => {},
onCreateBranch = () => {}, onCreateBranch = () => {},
@@ -250,7 +252,7 @@
const rawX = rect ? event.clientX - rect.left : event.offsetX; const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY; const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192); const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 190); const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 226);
contextBranch = branch; contextBranch = branch;
contextMenuX = Math.max(8, Math.min(rawX, maxX)); contextMenuX = Math.max(8, Math.min(rawX, maxX));
@@ -268,6 +270,13 @@
await onRenameBranch(branch); await onRenameBranch(branch);
} }
function compareContextBranch() {
const branch = contextBranch;
if (!branch || isBusy) return;
closeBranchContextMenu();
onCompareBranch(branch);
}
async function deleteContextBranch() { async function deleteContextBranch() {
const branch = contextBranch; const branch = contextBranch;
if (!branch || branch.current || isBusy) return; if (!branch || branch.current || isBusy) return;
@@ -677,6 +686,10 @@
<GitBranch size={14} aria-hidden="true" /> <GitBranch size={14} aria-hidden="true" />
Checkout Checkout
</button> </button>
<button type="button" role="menuitem" onclick={compareContextBranch} disabled={isBusy}>
<GitCompare size={14} aria-hidden="true" />
Compare with...
</button>
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}> <button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
<GitMerge size={14} aria-hidden="true" /> <GitMerge size={14} aria-hidden="true" />
Merge into current Merge into current
@@ -690,9 +703,9 @@
Open in new worktree Open in new worktree
</button> </button>
<div class="menu-separator" role="separator"></div> <div class="menu-separator" role="separator"></div>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}> <button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
<Pencil size={14} aria-hidden="true" /> <Pencil size={14} aria-hidden="true" />
Rename {contextBranch.remote ? "Rename remote..." : "Rename"}
</button> </button>
<button <button
class="danger" class="danger"
+159
View File
@@ -0,0 +1,159 @@
<script lang="ts">
import { onMount } from "svelte";
import {
ArrowDownToLine, ArrowUpFromLine, Boxes, CircleHelp, FileCode, GitBranch,
GitCompare, History, RefreshCw, Search, Settings, SlidersHorizontal, Sparkles,
} from "@lucide/svelte";
import type { AppLanguage, GitBranch as GitBranchInfo, GitCommit, GitRepositoryFile } from "../types";
type ItemKind = "fetch" | "pull" | "push" | "refresh" | "search" | "compare" | "reflog" | "rebase" | "worktrees" | "sync" | "settings" | "ai-settings" | "help" | "branch" | "file" | "commit";
interface Item { id: string; group: string; kind: ItemKind; title: string; subtitle: string; search: string; disabled?: boolean; run: () => void | Promise<void>; }
interface Props {
language: AppLanguage; hasRepository: boolean; isBusy: boolean;
branches: GitBranchInfo[]; files: GitRepositoryFile[]; commits: GitCommit[];
onClose: () => void;
onCheckoutBranch: (branch: GitBranchInfo) => void | Promise<void>;
onOpenFile: (file: GitRepositoryFile) => void | Promise<void>;
onSelectCommit: (commit: GitCommit) => void | Promise<void>;
onFetch: () => void | Promise<void>; onPull: () => void | Promise<void>; onPush: () => void | Promise<void>;
onRefresh: () => void | Promise<void>; onOpenSearch: () => void; onOpenCompare: () => void;
onOpenReflog: () => void | Promise<void>; onOpenInteractiveRebase: () => void;
onOpenWorktrees: () => void | Promise<void>; onOpenSyncSettings: () => void | Promise<void>;
onOpenSettings: () => void; onOpenAiSettings: () => void; onOpenHelp: () => void;
}
let {
language = "en", hasRepository = false, isBusy = false, branches = [], files = [], commits = [], onClose = () => {},
onCheckoutBranch = () => {}, onOpenFile = () => {}, onSelectCommit = () => {}, onFetch = () => {}, onPull = () => {},
onPush = () => {}, onRefresh = () => {}, onOpenSearch = () => {}, onOpenCompare = () => {}, onOpenReflog = () => {},
onOpenInteractiveRebase = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {},
onOpenAiSettings = () => {}, onOpenHelp = () => {},
}: Props = $props();
let query = $state("");
let activeIndex = $state(0);
let inputElement = $state<HTMLInputElement | null>(null);
let listElement = $state<HTMLElement | null>(null);
const isGerman = $derived(language === "de");
const repositoryActionDisabled = $derived(!hasRepository || isBusy);
function action(id: string, kind: ItemKind, title: string, subtitle: string, run: () => void | Promise<void>, requiresRepository = true): Item {
return { id, group: isGerman ? "Aktionen" : "Actions", kind, title, subtitle, search: `${title} ${subtitle}`.toLowerCase(), disabled: requiresRepository ? repositoryActionDisabled : false, run };
}
const actionItems = $derived([
action("fetch", "fetch", "Fetch", isGerman ? "Remote-Änderungen abrufen" : "Download remote changes", onFetch),
action("pull", "pull", "Pull", isGerman ? "Änderungen abrufen und integrieren" : "Download and integrate changes", onPull),
action("push", "push", "Push", isGerman ? "Lokale Commits veröffentlichen" : "Publish local commits", onPush),
action("refresh", "refresh", isGerman ? "Repository aktualisieren" : "Refresh repository", isGerman ? "Status und Historie neu laden" : "Reload status and history", onRefresh),
action("search", "search", isGerman ? "Globale Codesuche" : "Global code search", isGerman ? "Code und Dateihistorie durchsuchen" : "Search code and file history", onOpenSearch),
action("compare", "compare", isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits", isGerman ? "Zwei vollständige Revisionen vergleichen" : "Diff two complete revisions", onOpenCompare),
action("reflog", "reflog", "Reflog", isGerman ? "Verlorene Commits finden und wiederherstellen" : "Find and recover lost commits", onOpenReflog),
action("rebase", "rebase", "Interactive Rebase", isGerman ? "Commit-Historie bearbeiten" : "Edit commit history", onOpenInteractiveRebase),
action("worktrees", "worktrees", "Worktrees", isGerman ? "Arbeitsverzeichnisse verwalten" : "Manage linked working trees", onOpenWorktrees),
action("sync", "sync", isGerman ? "Synchronisierung konfigurieren" : "Configure synchronization", isGerman ? "Remote, Upstream und Pull-Strategie" : "Remote, upstream and pull strategy", onOpenSyncSettings),
action("settings", "settings", isGerman ? "Einstellungen" : "Settings", isGerman ? "Darstellung, Sprache und Verhalten" : "Appearance, language and behavior", onOpenSettings, false),
action("ai-settings", "ai-settings", "AI Settings", isGerman ? "Provider und Modell konfigurieren" : "Configure provider and model", onOpenAiSettings, false),
action("help", "help", isGerman ? "Hilfe öffnen" : "Open help", isGerman ? "Git-Dokumentation und Tastenkürzel" : "Git documentation and keyboard shortcuts", onOpenHelp, false),
]);
const dynamicItems = $derived.by(() => {
if (!hasRepository) return [];
const branchItems: Item[] = branches.map((branch) => ({
id: `branch:${branch.remote ? "remote" : "local"}:${branch.name}`, group: "Branches", kind: "branch", title: branch.name,
subtitle: branch.current ? (isGerman ? "Aktueller Branch" : "Current branch") : branch.remote ? (isGerman ? "Remote-Branch auschecken" : "Check out remote branch") : (isGerman ? "Branch auschecken" : "Check out branch"),
search: `${branch.name} branch ${branch.remote ? "remote" : "local"}`.toLowerCase(), disabled: isBusy || branch.current, run: () => onCheckoutBranch(branch),
}));
const fileItems: Item[] = files.map((file) => ({
id: `file:${file.path}`, group: isGerman ? "Dateien" : "Files", kind: "file", title: file.path.split(/[\\/]/).pop() ?? file.path,
subtitle: file.path, search: `${file.path} file datei`.toLowerCase(), disabled: isBusy, run: () => onOpenFile(file),
}));
const commitItems: Item[] = commits.map((commit) => ({
id: `commit:${commit.hash}`, group: isGerman ? "Geladene Commits" : "Loaded commits", kind: "commit", title: commit.summary || (isGerman ? "Ohne Commit-Nachricht" : "No commit message"),
subtitle: `${commit.short_hash} · ${commit.author_name}`, search: `${commit.hash} ${commit.short_hash} ${commit.summary} ${commit.author_name} ${commit.author_email} ${commit.refs.join(" ")}`.toLowerCase(), run: () => onSelectCommit(commit),
}));
return [...branchItems, ...fileItems, ...commitItems];
});
const visibleItems = $derived.by(() => {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
const allItems = [...actionItems, ...dynamicItems];
if (terms.length === 0) return allItems.slice(0, 35);
return allItems.filter((item) => terms.every((term) => item.search.includes(term))).slice(0, 80);
});
$effect(() => { query; activeIndex = 0; });
$effect(() => { if (activeIndex >= visibleItems.length) activeIndex = Math.max(0, visibleItems.length - 1); });
$effect(() => { activeIndex; queueMicrotask(() => listElement?.querySelector<HTMLElement>("[data-active='true']")?.scrollIntoView({ block: "nearest" })); });
onMount(() => inputElement?.focus());
function execute(item: Item | undefined) { if (!item || item.disabled) return; onClose(); queueMicrotask(() => { void item.run(); }); }
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); return; }
if (event.key === "ArrowDown") { event.preventDefault(); activeIndex = Math.min(activeIndex + 1, visibleItems.length - 1); return; }
if (event.key === "ArrowUp") { event.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); return; }
if (event.key === "Enter") { event.preventDefault(); execute(visibleItems[activeIndex]); }
}
</script>
<div class="command-palette-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) onClose(); }}>
<div class="command-palette" role="dialog" aria-modal="true" aria-label={isGerman ? "Befehlspalette" : "Command palette"}>
<div class="command-palette-search">
<Search size={19} aria-hidden="true" />
<input bind:this={inputElement} bind:value={query} onkeydown={handleKeydown} placeholder={isGerman ? "Aktion, Branch, Datei oder Commit suchen…" : "Search actions, branches, files, or commits…"} aria-label={isGerman ? "Befehl suchen" : "Search commands"} autocomplete="off" spellcheck="false" />
<kbd>ESC</kbd>
</div>
<div class="command-palette-results" bind:this={listElement} role="listbox" aria-label={isGerman ? "Ergebnisse" : "Results"}>
{#if visibleItems.length === 0}
<div class="command-palette-empty"><Search size={24} aria-hidden="true" /><strong>{isGerman ? "Keine Treffer" : "No results"}</strong><span>{isGerman ? "Versuche einen anderen Suchbegriff." : "Try a different search term."}</span></div>
{:else}
{#each visibleItems as item, index (item.id)}
{#if index === 0 || visibleItems[index - 1].group !== item.group}<div class="command-palette-group">{item.group}</div>{/if}
<button class="command-palette-item" class:active={index === activeIndex} type="button" role="option" aria-selected={index === activeIndex} data-active={index === activeIndex} disabled={item.disabled} onmouseenter={() => { activeIndex = index; }} onclick={() => execute(item)}>
<span class={`command-palette-icon ${item.kind}`}>
{#if item.kind === "fetch" || item.kind === "pull"}<ArrowDownToLine size={16} />
{:else if item.kind === "push"}<ArrowUpFromLine size={16} />
{:else if item.kind === "refresh"}<RefreshCw size={16} />
{:else if item.kind === "search"}<Search size={16} />
{:else if item.kind === "compare"}<GitCompare size={16} />
{:else if item.kind === "reflog" || item.kind === "commit"}<History size={16} />
{:else if item.kind === "rebase" || item.kind === "branch"}<GitBranch size={16} />
{:else if item.kind === "worktrees"}<Boxes size={16} />
{:else if item.kind === "sync"}<SlidersHorizontal size={16} />
{:else if item.kind === "settings"}<Settings size={16} />
{:else if item.kind === "ai-settings"}<Sparkles size={16} />
{:else if item.kind === "help"}<CircleHelp size={16} />
{:else}<FileCode size={16} />{/if}
</span>
<span class="command-palette-copy"><strong>{item.title}</strong><small>{item.subtitle}</small></span>
{#if item.kind === "branch" && item.disabled && !isBusy}<span class="command-palette-current">{isGerman ? "AKTUELL" : "CURRENT"}</span>{/if}
</button>
{/each}
{/if}
</div>
<footer class="command-palette-footer"><span><kbd></kbd><kbd></kbd>{isGerman ? "Navigieren" : "Navigate"}</span><span><kbd></kbd>{isGerman ? "Öffnen" : "Open"}</span>{#if commits.length > 0}<span class="command-palette-hint">{isGerman ? `${commits.length} geladene Commits` : `${commits.length} loaded commits`}</span>{/if}</footer>
</div>
</div>
<style>
.command-palette-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: start center; padding: min(14vh, 120px) 20px 20px; background: color-mix(in srgb, var(--app-dialog-backdrop) 76%, transparent); backdrop-filter: blur(7px) saturate(.82); }
.command-palette { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(720px, 100%); max-height: min(650px, 76vh); overflow: hidden; border: 1px solid color-mix(in srgb, var(--color-primary) 22%, var(--color-border)); border-radius: 14px; background: var(--app-dialog-bg); box-shadow: 0 28px 90px rgba(0,0,0,.42); }
.command-palette-search { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 15px 17px; border-bottom: 1px solid var(--color-border); color: var(--color-primary); }
.command-palette-search input { min-width: 0; border: 0; outline: 0; color: var(--color-ink); background: transparent; font: inherit; font-size: 15px; }
.command-palette-search input::placeholder { color: var(--color-ink-dim); }
kbd { display: inline-grid; place-items: center; min-width: 23px; height: 21px; padding: 0 5px; border: 1px solid var(--color-border); border-radius: 5px; color: var(--color-ink-dim); background: var(--color-surface-raised); font-family: var(--font-mono); font-size: 9px; font-weight: 700; }
.command-palette-results { min-height: 120px; overflow-y: auto; padding: 7px; }
.command-palette-group { padding: 10px 9px 5px; color: var(--color-ink-dim); font-size: 10px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
.command-palette-item { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; width: 100%; gap: 10px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; text-align: left; color: var(--color-ink); background: transparent; cursor: pointer; }
.command-palette-item.active:not(:disabled) { border-color: color-mix(in srgb, var(--color-primary) 24%, transparent); background: color-mix(in srgb, var(--color-primary) 11%, var(--color-surface-raised)); }
.command-palette-item:disabled { cursor: default; opacity: .48; }
.command-palette-icon { display: grid; place-items: center; width: 31px; height: 31px; border: 1px solid var(--color-border-subtle); border-radius: 8px; color: var(--color-ink-muted); background: var(--color-surface-raised); }
.command-palette-icon.branch { color: #65c98b; } .command-palette-icon.file { color: #69a7ff; } .command-palette-icon.commit { color: #ba82ff; }
.command-palette-copy { display: grid; min-width: 0; gap: 2px; } .command-palette-copy strong, .command-palette-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.command-palette-copy strong { font-size: 12.5px; font-weight: 700; } .command-palette-copy small { color: var(--color-ink-dim); font-size: 10.5px; }
.command-palette-current { color: var(--color-ink-dim); font-family: var(--font-mono); font-size: 9px; font-weight: 700; }
.command-palette-empty { display: grid; place-items: center; gap: 5px; padding: 54px 20px; color: var(--color-ink-dim); } .command-palette-empty strong { margin-top: 5px; color: var(--color-ink); font-size: 13px; } .command-palette-empty span { font-size: 11px; }
.command-palette-footer { display: flex; align-items: center; gap: 16px; min-height: 38px; padding: 7px 12px; border-top: 1px solid var(--color-border); color: var(--color-ink-dim); font-size: 10px; }
.command-palette-footer span { display: flex; align-items: center; gap: 5px; } .command-palette-footer span kbd + kbd { margin-left: -3px; } .command-palette-hint { margin-left: auto; }
@media (max-width: 640px) { .command-palette-backdrop { padding: 60px 10px 10px; } .command-palette { max-height: calc(100vh - 80px); } .command-palette-hint { display: none !important; } }
</style>
+303
View File
@@ -0,0 +1,303 @@
<script lang="ts">
import {
Download,
GitCommitHorizontal,
Info,
LoaderCircle,
Save,
StickyNote,
Trash2,
Upload,
X,
} from "@lucide/svelte";
import type { AppLanguage, GitCommit, GitRemote } from "../types";
interface Props {
commit: GitCommit;
note: string;
remotes: GitRemote[];
preferredRemote: string;
language: AppLanguage;
isLoading: boolean;
isBusy: boolean;
error: string;
status: string;
onSave: (note: string) => void | Promise<void>;
onDelete: () => void | Promise<void>;
onFetch: (remote: string) => void | Promise<void>;
onPush: (remote: string) => void | Promise<void>;
onClose: () => void;
}
let {
commit,
note = "",
remotes = [],
preferredRemote = "",
language = "en",
isLoading = false,
isBusy = false,
error = "",
status = "",
onSave = () => {},
onDelete = () => {},
onFetch = () => {},
onPush = () => {},
onClose = () => {},
}: Props = $props();
let draft = $state("");
let selectedRemote = $state("");
let deleteConfirmOpen = $state(false);
let lastLoadedKey = $state("");
let hasChanges = $derived(draft !== note);
let canSave = $derived(!isLoading && !isBusy && draft.trim().length > 0 && hasChanges);
const text = $derived(language === "de" ? {
eyebrow: "Interne Git-Notiz",
title: "Commit-Notiz",
commit: "Commit",
explanation: "Die Notiz wird separat unter refs/notes/commits gespeichert. Hash und Commit-Historie bleiben unverändert.",
label: "Notiz",
placeholder: "Zum Beispiel Review-Hinweise, Ticket-Kontext, Build-ID oder Freigabestatus …",
loading: "Notiz wird geladen …",
syncTitle: "Mit Remote synchronisieren",
syncHelp: "Git Notes reisen nicht automatisch mit Branches. Lade sie gezielt vom Remote oder sende deine lokalen Notizen dorthin.",
noRemotes: "Für dieses Repository ist kein Remote eingerichtet.",
remote: "Remote",
fetch: "Vom Remote laden",
push: "Zum Remote senden",
delete: "Notiz löschen",
deleteQuestion: "Diese Notiz wirklich löschen?",
deleteConfirm: "Ja, löschen",
cancel: "Abbrechen",
close: "Schließen",
save: "Notiz speichern",
characters: "Zeichen",
} : {
eyebrow: "Internal Git note",
title: "Commit note",
commit: "Commit",
explanation: "The note is stored separately under refs/notes/commits. The commit hash and history stay unchanged.",
label: "Note",
placeholder: "For example review findings, ticket context, a build ID, or approval status…",
loading: "Loading note…",
syncTitle: "Sync with a remote",
syncHelp: "Git Notes do not travel with branches automatically. Fetch them explicitly from a remote or push your local notes there.",
noRemotes: "No remote is configured for this repository.",
remote: "Remote",
fetch: "Fetch from remote",
push: "Push to remote",
delete: "Delete note",
deleteQuestion: "Delete this note?",
deleteConfirm: "Yes, delete",
cancel: "Cancel",
close: "Close",
save: "Save note",
characters: "characters",
});
$effect(() => {
const loadedKey = `${commit.hash}:${note}`;
if (loadedKey === lastLoadedKey) return;
draft = note;
lastLoadedKey = loadedKey;
deleteConfirmOpen = false;
});
$effect(() => {
if (selectedRemote && remotes.some((remote) => remote.name === selectedRemote)) return;
selectedRemote = remotes.some((remote) => remote.name === preferredRemote)
? preferredRemote
: (remotes[0]?.name ?? "");
});
function submit(event: SubmitEvent) {
event.preventDefault();
if (!canSave) return;
void onSave(draft);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && !isBusy) onClose();
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
if (canSave) void onSave(draft);
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="dialog-backdrop app-chrome-backdrop commit-note-backdrop" role="presentation">
<div class="commit-note-dialog" role="dialog" aria-modal="true" aria-labelledby="commit-note-title" tabindex="-1">
<header class="commit-note-head">
<div class="commit-note-heading">
<span class="commit-note-icon"><StickyNote size={20} aria-hidden="true" /></span>
<div>
<span class="eyebrow">{text.eyebrow}</span>
<h2 id="commit-note-title">{text.title}</h2>
</div>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={text.close} aria-label={text.close}>
<X size={18} aria-hidden="true" />
</button>
</header>
<div class="commit-note-body">
<div class="commit-note-target">
<span class="commit-target-icon"><GitCommitHorizontal size={18} aria-hidden="true" /></span>
<div>
<span>{text.commit} <code>{commit.short_hash}</code></span>
<strong title={commit.summary}>{commit.summary}</strong>
<small>{commit.author_name} · {new Date(commit.date).toLocaleString()}</small>
</div>
</div>
<div class="commit-note-info">
<Info size={16} aria-hidden="true" />
<span>{text.explanation}</span>
</div>
{#if error}
<div class="commit-note-message error" role="alert">{error}</div>
{:else if status}
<div class="commit-note-message success" role="status">{status}</div>
{/if}
<form class="commit-note-form" onsubmit={submit}>
<label for="commit-note-editor">
<span>{text.label}</span>
<small>{draft.length.toLocaleString()} {text.characters}</small>
</label>
<div class="commit-note-editor-wrap">
{#if isLoading}
<div class="commit-note-loading"><LoaderCircle class="spin" size={18} aria-hidden="true" />{text.loading}</div>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<textarea
id="commit-note-editor"
bind:value={draft}
placeholder={text.placeholder}
disabled={isLoading || isBusy}
maxlength={262144}
spellcheck="true"
autofocus
></textarea>
</div>
</form>
<section class="commit-note-sync" aria-labelledby="commit-note-sync-title">
<div class="commit-note-sync-copy">
<strong id="commit-note-sync-title">{text.syncTitle}</strong>
<span>{text.syncHelp}</span>
</div>
{#if remotes.length > 0}
<div class="commit-note-sync-controls">
<label>
<span>{text.remote}</span>
<select bind:value={selectedRemote} disabled={isBusy}>
{#each remotes as remote (remote.name)}
<option value={remote.name}>{remote.name}</option>
{/each}
</select>
</label>
<button type="button" onclick={() => onFetch(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Download size={15} aria-hidden="true" />{/if}
{text.fetch}
</button>
<button type="button" onclick={() => onPush(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Upload size={15} aria-hidden="true" />{/if}
{text.push}
</button>
</div>
{:else}
<span class="commit-note-no-remotes">{text.noRemotes}</span>
{/if}
</section>
</div>
<footer class="commit-note-footer">
<div class="commit-note-delete">
{#if deleteConfirmOpen}
<span>{text.deleteQuestion}</span>
<button class="btn-danger" type="button" onclick={() => onDelete()} disabled={isBusy}>{text.deleteConfirm}</button>
<button type="button" onclick={() => { deleteConfirmOpen = false; }} disabled={isBusy}>{text.cancel}</button>
{:else}
<button type="button" class="commit-note-delete-trigger" onclick={() => { deleteConfirmOpen = true; }} disabled={isBusy || isLoading || !note}>
<Trash2 size={15} aria-hidden="true" />{text.delete}
</button>
{/if}
</div>
<div class="commit-note-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{text.close}</button>
<button class="btn-primary" type="button" onclick={() => onSave(draft)} disabled={!canSave}>
{#if isBusy}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Save size={16} aria-hidden="true" />{/if}
{text.save}
</button>
</div>
</footer>
</div>
</div>
<style>
.commit-note-backdrop { z-index: 72; }
.commit-note-dialog {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(720px, calc(100vw - 32px));
max-height: min(780px, calc(100vh - 74px));
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 14px;
background: var(--app-dialog-bg);
box-shadow: var(--app-dialog-shadow);
}
.commit-note-head,
.commit-note-footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; background: var(--app-dialog-chrome); }
.commit-note-head { padding: 16px 18px; border-bottom: 1px solid var(--color-border-subtle); }
.commit-note-heading { display: flex; align-items: center; gap: 12px; min-width: 0; }
.commit-note-heading h2 { margin: 2px 0 0; color: var(--color-ink); font-size: 18px; line-height: 1.2; }
.commit-note-icon,
.commit-target-icon { display: grid; flex: 0 0 auto; place-items: center; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
.commit-note-icon { width: 40px; height: 40px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 11px; }
.commit-note-body { display: grid; align-content: start; gap: 13px; min-height: 0; padding: 16px 18px; overflow: auto; }
.commit-note-target { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 11px; padding: 11px 12px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--color-surface-raised); }
.commit-target-icon { width: 34px; height: 34px; border-radius: 8px; }
.commit-note-target > div { display: grid; gap: 3px; min-width: 0; }
.commit-note-target span,
.commit-note-target small { color: var(--color-ink-faint); font-size: 10.5px; }
.commit-note-target code { margin-left: 4px; color: var(--color-accent); font: 700 10.5px var(--font-mono); }
.commit-note-target strong { overflow: hidden; color: var(--color-ink); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.commit-note-info { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 8px; padding: 10px 11px; border: 1px solid color-mix(in srgb, var(--color-accent) 22%, var(--color-border-subtle)); border-radius: 9px; color: var(--color-ink-muted); background: color-mix(in srgb, var(--color-accent) 6%, transparent); font-size: 11px; line-height: 1.45; }
.commit-note-info :global(svg) { margin-top: 1px; color: var(--color-accent); }
.commit-note-message { padding: 9px 11px; border: 1px solid; border-radius: 8px; font-size: 11px; font-weight: 700; }
.commit-note-message.error { border-color: rgba(232, 96, 96, .32); color: #ef8888; background: rgba(232, 96, 96, .08); }
.commit-note-message.success { border-color: color-mix(in srgb, #5bd18a 34%, var(--color-border)); color: #70dc99; background: color-mix(in srgb, #5bd18a 8%, transparent); }
.commit-note-form { display: grid; gap: 6px; }
.commit-note-form > label { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--color-ink-muted); font-size: 11px; font-weight: 800; }
.commit-note-form > label small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 600; }
.commit-note-editor-wrap { position: relative; }
.commit-note-editor-wrap textarea { min-height: 164px; max-height: 320px; resize: vertical; font-size: 12.5px; line-height: 1.55; }
.commit-note-loading { position: absolute; inset: 0; z-index: 1; display: flex; align-items: center; justify-content: center; gap: 8px; border-radius: 8px; color: var(--color-ink-muted); background: color-mix(in srgb, var(--app-input-bg) 92%, transparent); font-size: 11px; font-weight: 700; }
.commit-note-sync { display: grid; gap: 11px; padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--color-surface-raised); }
.commit-note-sync-copy { display: grid; gap: 4px; }
.commit-note-sync-copy strong { color: var(--color-ink); font-size: 12px; }
.commit-note-sync-copy span,
.commit-note-no-remotes { color: var(--color-ink-faint); font-size: 10.5px; line-height: 1.45; }
.commit-note-sync-controls { display: grid; grid-template-columns: minmax(130px, 1fr) auto auto; align-items: end; gap: 8px; }
.commit-note-sync-controls label { display: grid; gap: 5px; color: var(--color-ink-muted); font-size: 10px; font-weight: 800; }
.commit-note-sync-controls button { min-height: 34px; font-size: 10.5px; font-weight: 750; }
.commit-note-footer { min-height: 66px; padding: 12px 18px; border-top: 1px solid var(--color-border-subtle); }
.commit-note-delete,
.commit-note-actions { display: flex; align-items: center; gap: 8px; }
.commit-note-delete > span { color: var(--color-ink-muted); font-size: 10.5px; font-weight: 700; }
.commit-note-delete-trigger { border-color: transparent; color: #e87a7a; background: transparent; }
.commit-note-delete-trigger:hover:not(:disabled) { border-color: rgba(232, 96, 96, .24); color: #ff9a9a; background: rgba(232, 96, 96, .08); }
@media (max-width: 660px) {
.commit-note-sync-controls { grid-template-columns: 1fr; }
.commit-note-footer { align-items: stretch; flex-direction: column; }
.commit-note-delete,
.commit-note-actions { justify-content: flex-end; }
}
</style>
+9 -5
View File
@@ -20,6 +20,8 @@
comparison: GitCommitComparison; comparison: GitCommitComparison;
selectedDiffPath: string; selectedDiffPath: string;
isBusy: boolean; isBusy: boolean;
fromLabel?: string;
toLabel?: string;
restoreLabel?: string; restoreLabel?: string;
/** When opened from a search hit, the term to highlight on matching lines. */ /** When opened from a search hit, the term to highlight on matching lines. */
highlightQuery?: string; highlightQuery?: string;
@@ -32,6 +34,8 @@
comparison, comparison,
selectedDiffPath = "", selectedDiffPath = "",
isBusy = false, isBusy = false,
fromLabel = "",
toLabel = "",
restoreLabel = "", restoreLabel = "",
highlightQuery = "", highlightQuery = "",
onClose = () => {}, onClose = () => {},
@@ -219,15 +223,15 @@
class="dialog-backdrop compare-dialog-backdrop" class="dialog-backdrop compare-dialog-backdrop"
role="presentation" role="presentation"
> >
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1"> <div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Branch or commit comparison" tabindex="-1">
<header class="dialog-header"> <header class="dialog-header">
<div> <div>
<span class="eyebrow">Compare</span> <span class="eyebrow">Compare</span>
<h2 class="dialog-range"> <h2 class="dialog-range">
<span class="hash">{comparison.from_short}</span> <span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
<ArrowRight size={14} aria-hidden="true" /> <ArrowRight size={14} aria-hidden="true" />
<span class="hash">{comparison.to_short}</span> <span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
</h2> </h2>
</div> </div>
<div class="dialog-header-actions"> <div class="dialog-header-actions">
@@ -289,11 +293,11 @@
<div class="split-col-headers"> <div class="split-col-headers">
<div class="split-col-label"> <div class="split-col-label">
<span>Before</span> <span>Before</span>
<span class="split-col-hash">{comparison.from_short}</span> <span class="split-col-hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
</div> </div>
<div class="split-col-label"> <div class="split-col-label">
<span>After</span> <span>After</span>
<span class="split-col-hash">{comparison.to_short}</span> <span class="split-col-hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
</div> </div>
</div> </div>
+66 -18
View File
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte"; import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
import type { GitCommit } from "../types"; import type { GitBranch, GitCommit } from "../types";
interface Props { interface Props {
commits: GitCommit[]; commits: GitCommit[];
branches: GitBranch[];
compareFrom: string; compareFrom: string;
compareTo: string; compareTo: string;
canCompare: boolean; canCompare: boolean;
@@ -17,6 +18,7 @@
let { let {
commits = [], commits = [],
branches = [],
compareFrom = "", compareFrom = "",
compareTo = "", compareTo = "",
canCompare = false, canCompare = false,
@@ -32,6 +34,14 @@
return `${item.short_hash} - ${item.summary}`; return `${item.short_hash} - ${item.summary}`;
} }
function branchValue(branch: GitBranch): string {
return branch.remote ? `refs/remotes/${branch.name}` : `refs/heads/${branch.name}`;
}
let localBranches = $derived(branches.filter((branch) => !branch.remote));
let remoteBranches = $derived(branches.filter((branch) => branch.remote));
let targetCount = $derived(branches.length + commits.length);
function handleSubmit(event: SubmitEvent) { function handleSubmit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
onCompare(); onCompare();
@@ -42,53 +52,89 @@
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" role="presentation"
> >
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1"> <div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select branches or commits to compare" tabindex="-1">
<header class="dialog-header"> <header class="dialog-header">
<div> <div>
<span class="eyebrow">Compare</span> <span class="eyebrow">Compare</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Select commits</h2> <h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Compare branches or commits</h2>
</div> </div>
<button class="dialog-close" type="button" onclick={onClose} title="Close"> <button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" /> <X size={18} aria-hidden="true" />
</button> </button>
</header> </header>
{#if commits.length < 2} {#if targetCount < 2}
<div class="blank-state">At least two commits are needed to compare.</div> <div class="blank-state">At least two branches or commits are needed to compare.</div>
{:else} {:else}
<form class="compare-form" onsubmit={handleSubmit}> <form class="compare-form" onsubmit={handleSubmit}>
<label class="compare-field"> <label class="compare-field">
<span>From (older)</span> <span>Base</span>
<select <select
value={compareFrom} value={compareFrom}
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)} onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
disabled={isBusy} disabled={isBusy}
> >
<option value="" disabled>Select a commit</option> <option value="" disabled>Select a branch or commit</option>
{#each commits as item (item.hash)} {#if localBranches.length > 0}
<option value={item.hash}>{commitOptionLabel(item)}</option> <optgroup label="Local branches">
{/each} {#each localBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
{/each}
</optgroup>
{/if}
{#if remoteBranches.length > 0}
<optgroup label="Remote branches">
{#each remoteBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}</option>
{/each}
</optgroup>
{/if}
{#if commits.length > 0}
<optgroup label="Recent commits">
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</optgroup>
{/if}
</select> </select>
</label> </label>
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" /> <ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
<label class="compare-field"> <label class="compare-field">
<span>To (newer)</span> <span>Compare with</span>
<select <select
value={compareTo} value={compareTo}
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)} onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
disabled={isBusy} disabled={isBusy}
> >
<option value="" disabled>Select a commit</option> <option value="" disabled>Select a branch or commit</option>
{#each commits as item (item.hash)} {#if localBranches.length > 0}
<option value={item.hash}>{commitOptionLabel(item)}</option> <optgroup label="Local branches">
{/each} {#each localBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
{/each}
</optgroup>
{/if}
{#if remoteBranches.length > 0}
<optgroup label="Remote branches">
{#each remoteBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}</option>
{/each}
</optgroup>
{/if}
{#if commits.length > 0}
<optgroup label="Recent commits">
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</optgroup>
{/if}
</select> </select>
</label> </label>
<button class="btn-primary" type="submit" disabled={!canCompare}> <button class="btn-primary" type="submit" disabled={!canCompare}>
{#if operation === "Comparing commits"} {#if operation === "Comparing revisions"}
<LoaderCircle class="spin" size={16} aria-hidden="true" /> <LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else} {:else}
<GitCompare size={16} aria-hidden="true" /> <GitCompare size={16} aria-hidden="true" />
@@ -98,9 +144,11 @@
</form> </form>
{#if compareFrom && compareTo && compareFrom === compareTo} {#if compareFrom && compareTo && compareFrom === compareTo}
<div class="blank-state">Select two different commits to compare.</div> <div class="blank-state">Select two different branches or commits to compare.</div>
{:else} {:else}
<div class="blank-state">Pick two commits and run a comparison.</div> <div class="compare-target-help">
The two branch tips are compared across the entire repository. Uncommitted working-tree changes are not included.
</div>
{/if} {/if}
{/if} {/if}
</div> </div>
+69 -1
View File
@@ -12,6 +12,7 @@
FileImage, FileImage,
FileJson, FileJson,
FileSearch, FileSearch,
GitCompare,
FileSpreadsheet, FileSpreadsheet,
FileText, FileText,
FileType, FileType,
@@ -33,11 +34,16 @@
selectedExplorerKind: ExplorerNodeKind; selectedExplorerKind: ExplorerNodeKind;
hasRepository: boolean; hasRepository: boolean;
isBusy: boolean; isBusy: boolean;
language?: "en" | "de";
editorName?: string;
diffName?: string;
onToggleFolder: (node: ExplorerNode) => void; onToggleFolder: (node: ExplorerNode) => void;
onExpandAllFolders: () => void; onExpandAllFolders: () => void;
onCollapseAllFolders: () => void; onCollapseAllFolders: () => void;
onSelectNode: (node: ExplorerNode) => void; onSelectNode: (node: ExplorerNode) => void;
onOpenFile: (node: ExplorerNode) => void; onOpenFile: (node: ExplorerNode) => void;
onOpenInEditor: (node: ExplorerNode) => void;
onExternalDiff: (node: ExplorerNode) => void;
onFileHistory: (node: ExplorerNode) => void; onFileHistory: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void; onBlame: (node: ExplorerNode) => void;
collapsed?: boolean; collapsed?: boolean;
@@ -51,11 +57,16 @@
selectedExplorerKind = "file", selectedExplorerKind = "file",
hasRepository = false, hasRepository = false,
isBusy = false, isBusy = false,
language = "en",
editorName = "Editor",
diffName = "diff tool",
onToggleFolder = () => {}, onToggleFolder = () => {},
onExpandAllFolders = () => {}, onExpandAllFolders = () => {},
onCollapseAllFolders = () => {}, onCollapseAllFolders = () => {},
onSelectNode = () => {}, onSelectNode = () => {},
onOpenFile = () => {}, onOpenFile = () => {},
onOpenInEditor = () => {},
onExternalDiff = () => {},
onFileHistory = () => {}, onFileHistory = () => {},
onBlame = () => {}, onBlame = () => {},
collapsed = false, collapsed = false,
@@ -65,6 +76,7 @@
let contextNode = $state<ExplorerNode | null>(null); let contextNode = $state<ExplorerNode | null>(null);
let contextMenuX = $state(0); let contextMenuX = $state(0);
let contextMenuY = $state(0); let contextMenuY = $state(0);
const isGerman = $derived(language === "de");
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null { function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
if (!next) return current; if (!next) return current;
@@ -178,7 +190,7 @@
contextNode = node; contextNode = node;
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192)); contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 132)); contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 220));
} }
function closeFileContextMenu() { function closeFileContextMenu() {
@@ -192,6 +204,20 @@
onOpenFile(node); onOpenFile(node);
} }
function openContextFileInEditor() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onOpenInEditor(node);
}
function openContextExternalDiff() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onExternalDiff(node);
}
function openContextBlame() { function openContextBlame() {
const node = contextNode; const node = contextNode;
if (!node || node.kind !== "file") return; if (!node || node.kind !== "file") return;
@@ -213,6 +239,11 @@
let explorerTree = $derived(buildExplorerTree(repoFiles)); let explorerTree = $derived(buildExplorerTree(repoFiles));
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths)); let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder")); let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
let selectedFileNode = $derived(
selectedExplorerKind === "file"
? visibleNodes.find((node) => node.kind === "file" && node.path === selectedExplorerPath) ?? null
: null,
);
</script> </script>
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} /> <svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
@@ -224,6 +255,35 @@
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2> <h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2>
</div> </div>
<div class="explorer-head-actions"> <div class="explorer-head-actions">
<button
class="explorer-bulk-button explorer-tool-action"
type="button"
onclick={() => selectedFileNode && onOpenInEditor(selectedFileNode)}
disabled={isBusy || !selectedFileNode || selectedFileNode.status === "deleted"}
title={isGerman
? `Ausgewählte Datei in ${editorName} öffnen`
: `Open selected file in ${editorName}`}
aria-label={isGerman
? `Ausgewählte Datei in ${editorName} öffnen`
: `Open selected file in ${editorName}`}
>
<FileCode size={14} aria-hidden="true" />
</button>
<button
class="explorer-bulk-button explorer-tool-action"
type="button"
onclick={() => selectedFileNode && onExternalDiff(selectedFileNode)}
disabled={isBusy || !selectedFileNode || !selectedFileNode.tracked || selectedFileNode.status === "deleted"}
title={isGerman
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
: `Compare selected file with HEAD in ${diffName}`}
aria-label={isGerman
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
: `Compare selected file with HEAD in ${diffName}`}
>
<GitCompare size={14} aria-hidden="true" />
</button>
<span class="explorer-action-divider" aria-hidden="true"></span>
<button <button
class="explorer-bulk-button" class="explorer-bulk-button"
type="button" type="button"
@@ -366,6 +426,14 @@
tabindex="-1" tabindex="-1"
aria-label={`Actions for ${contextNode.path}`} aria-label={`Actions for ${contextNode.path}`}
> >
<button type="button" role="menuitem" onclick={openContextFileInEditor} disabled={contextNode.status === "deleted"}>
<FileCode size={14} aria-hidden="true" />
{isGerman ? `In ${editorName} öffnen` : `Open in ${editorName}`}
</button>
<button type="button" role="menuitem" onclick={openContextExternalDiff} disabled={!contextNode.tracked || contextNode.status === "deleted"}>
<GitCompare size={14} aria-hidden="true" />
{isGerman ? `Mit HEAD in ${diffName} vergleichen` : `Compare with HEAD in ${diffName}`}
</button>
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
+435 -110
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, X } from "@lucide/svelte"; import { Check, Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, Tag, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types"; import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment { interface GraphSegment {
@@ -22,11 +22,33 @@
graphCommit: GitCommit; graphCommit: GitCommit;
} }
type BranchVisibilityMode = "focus" | "local" | "all" | "custom";
type CommitRefKind = "local" | "remote" | "head";
interface CommitBranchDecoration {
label: string;
kind: CommitRefKind;
current: boolean;
trackedRemote: string;
representedBranches: string[];
}
interface CommitRefSummary {
branches: CommitBranchDecoration[];
tags: string[];
other: string[];
primaryBranch: CommitBranchDecoration | null;
primaryTag: string;
overflowCount: number;
}
const GRAPH_COLORS = [ const GRAPH_COLORS = [
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff", "#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55", "#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
]; ];
const GRAPH_LANE = 18; const GRAPH_LANE = 18;
const GRAPH_REF_ARM = 16;
const GRAPH_VISIBILITY_STORAGE_PREFIX = "gitlite.graphVisibility.v1:";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, { const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium", dateStyle: "medium",
timeStyle: "short", timeStyle: "short",
@@ -35,8 +57,11 @@
interface Props { interface Props {
commits: GitCommit[]; commits: GitCommit[];
localBranchNames: string[]; localBranchNames: string[];
remoteBranchNames: string[];
activeBranch: string; activeBranch: string;
activeUpstream: string; activeUpstream: string;
activeAhead: number;
activeBehind: number;
repositoryKey: string; repositoryKey: string;
hasRepository: boolean; hasRepository: boolean;
isBusy: boolean; isBusy: boolean;
@@ -44,6 +69,7 @@
isLoadingMore: boolean; isLoadingMore: boolean;
loadMoreError: string; loadMoreError: string;
expandedCommitHashes: Set<string>; expandedCommitHashes: Set<string>;
selectedCommitHash: string;
onLoadMore: () => void | Promise<void>; onLoadMore: () => void | Promise<void>;
onRestoreCommit: (commit: GitCommit) => void; onRestoreCommit: (commit: GitCommit) => void;
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void; onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
@@ -51,13 +77,18 @@
onCreateBranchFromCommit: (commit: GitCommit) => void; onCreateBranchFromCommit: (commit: GitCommit) => void;
onCherryPickCommit: (commit: GitCommit) => void; onCherryPickCommit: (commit: GitCommit) => void;
onRevertCommit: (commit: GitCommit) => void; onRevertCommit: (commit: GitCommit) => void;
onOpenCommitNote: (commit: GitCommit) => void;
onSelectCommit: (commit: GitCommit) => void;
} }
let { let {
commits = [], commits = [],
localBranchNames = [], localBranchNames = [],
remoteBranchNames = [],
activeBranch = "", activeBranch = "",
activeUpstream = "", activeUpstream = "",
activeAhead = 0,
activeBehind = 0,
repositoryKey = "", repositoryKey = "",
hasRepository = false, hasRepository = false,
isBusy = false, isBusy = false,
@@ -65,6 +96,7 @@
isLoadingMore = false, isLoadingMore = false,
loadMoreError = "", loadMoreError = "",
expandedCommitHashes = new Set(), expandedCommitHashes = new Set(),
selectedCommitHash = "",
onLoadMore = () => {}, onLoadMore = () => {},
onRestoreCommit = () => {}, onRestoreCommit = () => {},
onPreviewCommitFile = () => {}, onPreviewCommitFile = () => {},
@@ -72,12 +104,15 @@
onCreateBranchFromCommit = () => {}, onCreateBranchFromCommit = () => {},
onCherryPickCommit = () => {}, onCherryPickCommit = () => {},
onRevertCommit = () => {}, onRevertCommit = () => {},
onOpenCommitNote = () => {},
onSelectCommit = () => {},
}: Props = $props(); }: Props = $props();
let hiddenGraphBranches = $state<Set<string>>(new Set()); let branchVisibilityMode = $state<BranchVisibilityMode>("focus");
let customVisibleBranches = $state<Set<string>>(new Set());
let loadedVisibilityRepository = $state("");
let branchDialogOpen = $state(false); let branchDialogOpen = $state(false);
let userAdjustedBranchFilter = $state(false); let expandedRefsCommitHash = $state("");
let lastDefaultFilterKey = $state("");
let panelElement = $state<HTMLElement | null>(null); let panelElement = $state<HTMLElement | null>(null);
let contextCommit = $state<GitCommit | null>(null); let contextCommit = $state<GitCommit | null>(null);
let contextMenuX = $state(0); let contextMenuX = $state(0);
@@ -226,39 +261,40 @@
} }
let localBranchNameSet = $derived(new Set(localBranchNames)); let localBranchNameSet = $derived(new Set(localBranchNames));
let remoteBranchNameSet = $derived(new Set(remoteBranchNames));
function uniqueStrings(values: string[]): string[] { function uniqueStrings(values: string[]): string[] {
return [...new Set(values.filter(Boolean))]; return [...new Set(values.filter(Boolean))];
} }
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, activeUpstream].filter(Boolean))); let graphBranchNames = $derived(uniqueStrings([...localBranchNames, ...remoteBranchNames, activeUpstream].filter(Boolean)));
let graphBranchNameSet = $derived(new Set(graphBranchNames)); let graphBranchNameSet = $derived(new Set(graphBranchNames));
function focusBranchNames(): string[] {
const focused = uniqueStrings([activeBranch, activeUpstream].filter((branch) => graphBranchNameSet.has(branch)));
if (focused.length > 0) return focused;
return localBranchNames[0] ? [localBranchNames[0]] : graphBranchNames.slice(0, 1);
}
function branchNamesForMode(): string[] {
if (branchVisibilityMode === "focus") return focusBranchNames();
if (branchVisibilityMode === "local") return localBranchNames;
if (branchVisibilityMode === "all") return graphBranchNames;
return graphBranchNames.filter((branch) => customVisibleBranches.has(branch));
}
let visibleGraphBranchNames = $derived(branchNamesForMode());
let visibleGraphBranchNameSet = $derived(new Set(visibleGraphBranchNames));
function branchIsVisible(branch: string): boolean { function branchIsVisible(branch: string): boolean {
if (branch === activeUpstream && activeUpstream) { return visibleGraphBranchNameSet.has(branch);
return !activeBranch || !hiddenGraphBranches.has(activeBranch);
}
return !hiddenGraphBranches.has(branch);
}
function visibleBranchLabels(labels: string[]): string[] {
return labels.filter(branchIsVisible);
}
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
const directBranches = graphBranchRefs(commit);
if (directBranches.length > 0) return directBranches;
const containingBranches = row?.branchLabels ?? [];
if (containingBranches.length <= 3) return containingBranches;
return [...containingBranches.slice(0, 3), `+${containingBranches.length - 3} more`];
} }
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string { function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
const directBranches = graphBranchRefs(commit); const directBranches = graphBranchRefs(commit).filter(branchIsVisible);
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`; if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
const containingBranches = row?.branchLabels ?? []; const containingBranches = (row?.branchLabels ?? []).filter(branchIsVisible);
if (containingBranches.length === 0) return commit.short_hash; if (containingBranches.length === 0) return commit.short_hash;
return `Branches containing this commit: ${containingBranches.join(", ")}`; return `Branches containing this commit: ${containingBranches.join(", ")}`;
} }
@@ -278,7 +314,7 @@
function branchesAreVisible(branches: string[]): boolean { function branchesAreVisible(branches: string[]): boolean {
if (graphBranchNames.length === 0) return true; if (graphBranchNames.length === 0) return true;
return branches.some(branchIsVisible); return branches.some((branch) => visibleGraphBranchNameSet.has(branch));
} }
function rowGraphIsVisible(row: GraphRow | undefined): boolean { function rowGraphIsVisible(row: GraphRow | undefined): boolean {
@@ -355,20 +391,25 @@
} }
function toggleGraphBranch(branch: string) { function toggleGraphBranch(branch: string) {
const next = new Set(hiddenGraphBranches); const next = branchVisibilityMode === "custom"
? new Set(customVisibleBranches)
: new Set(visibleGraphBranchNames);
if (next.has(branch)) next.delete(branch); else next.add(branch); if (next.has(branch)) next.delete(branch); else next.add(branch);
hiddenGraphBranches = next; customVisibleBranches = next;
userAdjustedBranchFilter = true; branchVisibilityMode = "custom";
} }
function showAllGraphBranches() { function showAllGraphBranches() {
hiddenGraphBranches = new Set(); branchVisibilityMode = "all";
userAdjustedBranchFilter = true;
} }
function hideAllGraphBranches() { function hideAllGraphBranches() {
hiddenGraphBranches = new Set(localBranchNames); customVisibleBranches = new Set();
userAdjustedBranchFilter = true; branchVisibilityMode = "custom";
}
function showFocusGraphBranches() {
branchVisibilityMode = "focus";
} }
function openBranchDialog() { function openBranchDialog() {
@@ -385,6 +426,10 @@
} }
} }
function toggleCommitRefs(commit: GitCommit) {
expandedRefsCommitHash = expandedRefsCommitHash === commit.hash ? "" : commit.hash;
}
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) { function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -441,9 +486,24 @@
await onRevertCommit(commit); await onRevertCommit(commit);
} }
async function openContextCommitNote() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
onSelectCommit(commit);
await onOpenCommitNote(commit);
}
async function openCommitNote(commit: GitCommit) {
if (isBusy) return;
onSelectCommit(commit);
await onOpenCommitNote(commit);
}
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
closeCommitContextMenu(); closeCommitContextMenu();
expandedRefsCommitHash = "";
handleBranchDialogKeydown(event); handleBranchDialogKeydown(event);
} }
@@ -471,22 +531,138 @@
return labels; return labels;
} }
function visibleRefs(commit: GitCommit): string[] {
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
}
function refClass(ref: string): string {
if (ref.startsWith("HEAD")) return "head";
if (ref.startsWith("tag:")) return "tag";
if (localBranchNameSet.has(refLabel(ref))) return "branch";
if (ref.includes("/")) return "remote";
return "branch";
}
function refLabel(ref: string): string { function refLabel(ref: string): string {
return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, ""); return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, "");
} }
function isSymbolicRemoteHead(ref: string): boolean {
if (ref.startsWith("HEAD ->") || ref.startsWith("tag:")) return false;
const label = refLabel(ref);
return !localBranchNameSet.has(label) && /(?:^|\/)HEAD(?:\s*->|$)/.test(ref);
}
function branchNameWithoutRemote(branch: string): string {
const slash = branch.indexOf("/");
return slash === -1 ? branch : branch.slice(slash + 1);
}
function remoteName(branch: string): string {
return branch.split("/", 1)[0] ?? branch;
}
function matchingRemoteBranch(local: string, remoteBranches: string[]): string {
if (local === activeBranch && activeUpstream && remoteBranches.includes(activeUpstream)) {
return activeUpstream;
}
return remoteBranches.find((remote) => branchNameWithoutRemote(remote) === local) ?? "";
}
function compareBranchDecorations(left: CommitBranchDecoration, right: CommitBranchDecoration): number {
const priority = (item: CommitBranchDecoration) => {
if (item.label === activeBranch) return 0;
if (item.kind === "head") return 1;
if (item.kind === "local") return 2;
if (item.label === activeUpstream) return 3;
return 4;
};
return priority(left) - priority(right) || left.label.localeCompare(right.label, undefined, { numeric: true });
}
function commitRefSummary(commit: GitCommit): CommitRefSummary {
const local = new Set<string>();
const remote = new Set<string>();
const tags = new Set<string>();
const other = new Set<string>();
let detachedHead = false;
for (const ref of commit.refs) {
if (isSymbolicRemoteHead(ref)) continue;
const label = refLabel(ref);
if (!label) continue;
if (ref.startsWith("tag:")) {
tags.add(label);
} else if (localBranchNameSet.has(label)) {
local.add(label);
} else if (remoteBranchNameSet.has(label) || label === activeUpstream) {
remote.add(label);
} else if (label === "HEAD") {
detachedHead = true;
} else {
other.add(label);
}
}
const remainingRemote = [...remote];
const branches: CommitBranchDecoration[] = [...local].map((label) => {
const trackedRemote = matchingRemoteBranch(label, remainingRemote);
if (trackedRemote) remainingRemote.splice(remainingRemote.indexOf(trackedRemote), 1);
return {
label,
kind: "local",
current: label === activeBranch,
trackedRemote,
representedBranches: trackedRemote ? [label, trackedRemote] : [label],
};
});
if (detachedHead) {
branches.push({
label: "HEAD",
kind: "head",
current: true,
trackedRemote: "",
representedBranches: [],
});
}
branches.push(...remainingRemote.map((label) => ({
label,
kind: "remote" as const,
current: false,
trackedRemote: "",
representedBranches: [label],
})));
const sortedBranches = branches.sort(compareBranchDecorations);
const primaryBranch = sortedBranches.find(
(branch) => branch.kind === "head" || branch.representedBranches.some(branchIsVisible),
) ?? null;
const sortedTags = [...tags].sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
const sortedOther = [...other].sort((left, right) => left.localeCompare(right));
const primaryTag = sortedTags[0] ?? "";
return {
branches: sortedBranches,
tags: sortedTags,
other: sortedOther,
primaryBranch,
primaryTag,
overflowCount:
Math.max(0, sortedBranches.length - (primaryBranch ? 1 : 0))
+ Math.max(0, sortedTags.length - (primaryTag ? 1 : 0))
+ sortedOther.length,
};
}
function branchStatusLabel(branch: CommitBranchDecoration): string {
if (branch.trackedRemote) return `✓ ${remoteName(branch.trackedRemote)}`;
if (branch.label === activeBranch) {
const parts = [];
if (activeAhead > 0) parts.push(`↑${activeAhead}`);
if (activeBehind > 0) parts.push(`↓${activeBehind}`);
return parts.join(" ");
}
if (branch.label === activeUpstream && activeBehind > 0) return `↓${activeBehind}`;
return "";
}
function branchDecorationTitle(branch: CommitBranchDecoration): string {
if (branch.trackedRemote) return `${branch.label} · up to date with ${branch.trackedRemote}`;
const status = branchStatusLabel(branch);
if (status) return `${branch.label} · ${status}`;
return branch.kind === "remote" ? `Remote branch ${branch.label}` : `Local branch ${branch.label}`;
}
function formatCommitDate(value: string): string { function formatCommitDate(value: string): string {
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return value; if (Number.isNaN(date.getTime())) return value;
@@ -494,43 +670,76 @@
} }
$effect(() => { $effect(() => {
const available = new Set(localBranchNames); const currentRepository = repositoryKey;
const nextHidden = new Set([...hiddenGraphBranches].filter((branch) => available.has(branch))); if (loadedVisibilityRepository === currentRepository) return;
if (nextHidden.size !== hiddenGraphBranches.size) { loadedVisibilityRepository = currentRepository;
hiddenGraphBranches = nextHidden; expandedRefsCommitHash = "";
if (!currentRepository) {
branchVisibilityMode = "focus";
customVisibleBranches = new Set();
return;
}
try {
const stored = localStorage.getItem(`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`);
if (!stored) {
branchVisibilityMode = "focus";
customVisibleBranches = new Set();
return;
}
const parsed = JSON.parse(stored) as { mode?: unknown; branches?: unknown };
const mode = parsed.mode;
branchVisibilityMode = mode === "focus" || mode === "local" || mode === "all" || mode === "custom"
? mode
: "focus";
customVisibleBranches = new Set(
Array.isArray(parsed.branches)
? parsed.branches.filter((branch): branch is string => typeof branch === "string")
: [],
);
} catch {
branchVisibilityMode = "focus";
customVisibleBranches = new Set();
} }
}); });
$effect(() => { $effect(() => {
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch) const currentRepository = repositoryKey;
? activeBranch const mode = branchVisibilityMode;
: (graphBranchNames[0] ?? ""); const branches = [...customVisibleBranches];
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`; if (!currentRepository || loadedVisibilityRepository !== currentRepository) return;
try {
if (!defaultBranch) { localStorage.setItem(
if (lastDefaultFilterKey !== defaultFilterKey) { `${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`,
hiddenGraphBranches = new Set(); JSON.stringify({ mode, branches }),
userAdjustedBranchFilter = false; );
lastDefaultFilterKey = defaultFilterKey; } catch {
} // The graph still works if storage is unavailable.
return;
} }
});
if (lastDefaultFilterKey !== defaultFilterKey) { $effect(() => {
userAdjustedBranchFilter = false; if (graphBranchNames.length === 0 || customVisibleBranches.size === 0) return;
lastDefaultFilterKey = defaultFilterKey; const available = new Set(graphBranchNames);
} const next = new Set([...customVisibleBranches].filter((branch) => available.has(branch)));
if (next.size !== customVisibleBranches.size) customVisibleBranches = next;
});
if (!userAdjustedBranchFilter) { $effect(() => {
hiddenGraphBranches = new Set(localBranchNames.filter((branch) => branch !== defaultBranch)); if (!selectedCommitHash) return;
} queueMicrotask(() => {
panelElement
?.querySelector<HTMLElement>(`[data-commit-hash="${CSS.escape(selectedCommitHash)}"]`)
?.scrollIntoView({ block: "center", behavior: "smooth" });
});
}); });
let branchMembership = $derived(branchMembershipByHash(commits)); let branchMembership = $derived(branchMembershipByHash(commits));
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership)); let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit)); let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit)); let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit));
let visibleBranchCount = $derived(localBranchNames.filter(branchIsVisible).length); let visibleBranchCount = $derived(visibleGraphBranchNames.length);
let graph = $derived(computeGraph(graphCommits, branchMembership)); let graph = $derived(computeGraph(graphCommits, branchMembership));
let graphRows = $derived(graph.rows); let graphRows = $derived(graph.rows);
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42)); let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
@@ -544,17 +753,18 @@
<span class="eyebrow">History</span> <span class="eyebrow">History</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2> <h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
</div> </div>
{#if localBranchNames.length > 0} {#if graphBranchNames.length > 0}
<div class="section-head-actions"> <div class="section-head-actions">
<button <button
class="graph-branch-dialog-button" class="graph-branch-dialog-button"
type="button" type="button"
onclick={openBranchDialog} onclick={openBranchDialog}
title="Select branches shown in the graph" title="Customize visible branches"
aria-label={`${visibleBranchCount} of ${graphBranchNames.length} branches visible. Customize branches.`}
> >
<GitBranch size={13} aria-hidden="true" /> <GitBranch size={13} aria-hidden="true" />
Branches Branches
<span>{visibleBranchCount}/{localBranchNames.length}</span> <span>{visibleBranchCount}/{graphBranchNames.length}</span>
</button> </button>
</div> </div>
{/if} {/if}
@@ -572,11 +782,12 @@
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)} {#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
{@const item = entry.commit} {@const item = entry.commit}
{@const row = graphRows[rowIndex]} {@const row = graphRows[rowIndex]}
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)} {@const refSummary = commitRefSummary(item)}
{@const otherRefs = visibleRefs(item)}
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])} {@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
<article <article
class="commit-row graph-row" class="commit-row graph-row"
class:selected={selectedCommitHash === item.hash}
data-commit-hash={item.hash}
class:graph-ahead-row={rowSyncClass === "ahead"} class:graph-ahead-row={rowSyncClass === "ahead"}
class:graph-behind-row={rowSyncClass === "behind"} class:graph-behind-row={rowSyncClass === "behind"}
class:merge-row={item.parents.length > 1} class:merge-row={item.parents.length > 1}
@@ -608,6 +819,15 @@
vector-effect="non-scaling-stroke" vector-effect="non-scaling-stroke"
/> />
{/each} {/each}
{#if refSummary.primaryBranch}
<path
class="graph-ref-connector"
d={`M ${graphColX(row.dotCol)} 50 L ${graphWidth - GRAPH_REF_ARM * 2} 50`}
stroke={row.dotColor}
stroke-width="1.5"
vector-effect="non-scaling-stroke"
/>
{/if}
</svg> </svg>
<span <span
class="graph-dot" class="graph-dot"
@@ -619,25 +839,106 @@
title={commitHoverTitle(item, row)} title={commitHoverTitle(item, row)}
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`} style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
></span> ></span>
{#if hoverBranchRefs.length > 0}
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
{#each hoverBranchRefs as branch}
<span
class:remote={branch === activeUpstream}
class:ahead={activeBranch === branch && rowSyncClass === "ahead"}
class:behind={activeUpstream === branch && rowSyncClass === "behind"}
title={branch}
>
<GitBranch size={10} aria-hidden="true" />
{branch}
</span>
{/each}
</div>
{/if}
{/if} {/if}
</div> </div>
<div class="commit-body"> <div
class="commit-body"
class:has-branch-ref={Boolean(refSummary.primaryBranch)}
style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`}
>
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
<div class="commit-ref-area">
<div class="commit-ref-strip" aria-label="Commit references">
{#if refSummary.primaryBranch}
<span
class="compact-ref-chip branch"
class:current={refSummary.primaryBranch.current}
class:remote={refSummary.primaryBranch.kind === "remote"}
title={branchDecorationTitle(refSummary.primaryBranch)}
>
<GitBranch class="compact-ref-branch-icon" size={10} aria-hidden="true" />
<span>{refSummary.primaryBranch.label}</span>
{#if branchStatusLabel(refSummary.primaryBranch)}
<small class:up-to-date={Boolean(refSummary.primaryBranch.trackedRemote)}>
{#if refSummary.primaryBranch.trackedRemote}<Check size={9} aria-hidden="true" />{/if}
{branchStatusLabel(refSummary.primaryBranch).replace(/^✓\s*/, "")}
</small>
{/if}
</span>
{/if}
{#if refSummary.primaryTag}
<span class="compact-ref-chip tag" title={`Tag ${refSummary.primaryTag}`}>
<Tag size={10} aria-hidden="true" />
<span>{refSummary.primaryTag}</span>
</span>
{/if}
{#if refSummary.overflowCount > 0}
<button
class="compact-ref-overflow"
type="button"
onclick={() => toggleCommitRefs(item)}
aria-expanded={expandedRefsCommitHash === item.hash}
aria-controls={`commit-refs-${item.hash}`}
title={`Show ${refSummary.overflowCount} more ${refSummary.overflowCount === 1 ? "reference" : "references"}`}
>
+{refSummary.overflowCount}
</button>
{/if}
</div>
{#if refSummary.overflowCount > 0 && expandedRefsCommitHash === item.hash}
<div class="commit-ref-details" id={`commit-refs-${item.hash}`}>
<strong>References on this commit</strong>
{#if refSummary.branches.some((branch) => branch.kind !== "remote")}
<section>
<span>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.trackedRemote}<small>{branch.trackedRemote}</small>{/if}
</span>
{/each}
</div>
</section>
{/if}
{#if refSummary.branches.some((branch) => branch.kind === "remote")}
<section>
<span>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>
{/each}
</div>
</section>
{/if}
{#if refSummary.tags.length > 0}
<section>
<span>Tags</span>
<div>
{#each refSummary.tags as tag}
<span class="commit-ref-detail-item tag"><Tag size={10} aria-hidden="true" />{tag}</span>
{/each}
</div>
</section>
{/if}
{#if refSummary.other.length > 0}
<section>
<span>Other</span>
<div>
{#each refSummary.other as ref}
<span class="commit-ref-detail-item">{ref}</span>
{/each}
</div>
</section>
{/if}
</div>
{/if}
</div>
{/if}
<div class="commit-card-head"> <div class="commit-card-head">
<span class="commit-avatar"> <span class="commit-avatar">
{authorInitials(item.author_name)} {authorInitials(item.author_name)}
@@ -659,14 +960,6 @@
</div> </div>
</div> </div>
{#if otherRefs.length > 0}
<div class="ref-list" aria-label="Commit refs">
{#each otherRefs as ref}
<span class={`ref-chip ${refClass(ref)}`}>{refLabel(ref)}</span>
{/each}
</div>
{/if}
{#if item.files.length > 0} {#if item.files.length > 0}
<div class="commit-files"> <div class="commit-files">
<button <button
@@ -705,6 +998,16 @@
<div class="commit-actions"> <div class="commit-actions">
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time> <time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons"> <div class="commit-action-buttons">
<button
class="commit-menu-button commit-note-button"
type="button"
onclick={() => openCommitNote(item)}
disabled={isBusy}
title={`Open internal note for ${item.short_hash}`}
aria-label={`Open internal note for ${item.short_hash}`}
>
<StickyNote size={14} aria-hidden="true" />
</button>
<button <button
class="commit-menu-button" class="commit-menu-button"
type="button" type="button"
@@ -752,6 +1055,10 @@
<GitBranch size={14} aria-hidden="true" /> <GitBranch size={14} aria-hidden="true" />
Branch Branch
</button> </button>
<button type="button" role="menuitem" onclick={openContextCommitNote} disabled={isBusy}>
<StickyNote size={14} aria-hidden="true" />
Note
</button>
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}> <button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
<RotateCcw size={14} aria-hidden="true" /> <RotateCcw size={14} aria-hidden="true" />
Restore Restore
@@ -792,25 +1099,43 @@
</header> </header>
<div class="branch-filter-summary"> <div class="branch-filter-summary">
<span>{visibleBranchCount} of {localBranchNames.length} branches selected</span> <span>{visibleBranchCount} of {graphBranchNames.length} branches selected</span>
<div class="branch-filter-actions"> <div class="branch-filter-actions">
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === localBranchNames.length}>Show all</button> <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={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
</div> </div>
</div> </div>
<div class="branch-filter-dialog-list"> <div class="branch-filter-dialog-list">
{#each localBranchNames as branch} {#if localBranchNames.length > 0}
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}> <span class="branch-filter-group-label">Local</span>
<input {#each localBranchNames as branch}
type="checkbox" <label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
checked={branchIsVisible(branch)} <input
onchange={() => toggleGraphBranch(branch)} type="checkbox"
/> checked={branchIsVisible(branch)}
<GitBranch size={14} aria-hidden="true" /> onchange={() => toggleGraphBranch(branch)}
<span>{branch}</span> />
</label> <GitBranch size={14} aria-hidden="true" />
{/each} <span>{branch}</span>
</label>
{/each}
{/if}
{#if remoteBranchNames.length > 0}
<span class="branch-filter-group-label">Remote</span>
{#each remoteBranchNames as branch}
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option remote" title={branch}>
<input
type="checkbox"
checked={branchIsVisible(branch)}
onchange={() => toggleGraphBranch(branch)}
/>
<GitBranch size={14} aria-hidden="true" />
<span>{branch}</span>
</label>
{/each}
{/if}
</div> </div>
</div> </div>
</div> </div>
+18 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Check, LoaderCircle, MousePointer2, X } from "@lucide/svelte"; import { Check, ExternalLink, FileDiff, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
import type { GitFileStatus, PatchApplyAction } from "../types"; import type { GitFileStatus, PatchApplyAction } from "../types";
type PatchLineKind = "context" | "add" | "delete" | "meta"; type PatchLineKind = "context" | "add" | "delete" | "meta";
@@ -34,9 +34,12 @@
isBusy: boolean; isBusy: boolean;
isLoading: boolean; isLoading: boolean;
error: string; error: string;
language?: "en" | "de";
diffName?: string;
onClose: () => void; onClose: () => void;
onRefresh: () => void | Promise<void>; onRefresh: () => void | Promise<void>;
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>; onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
onExternalDiff: () => void | Promise<void>;
} }
let { let {
@@ -46,11 +49,16 @@
isBusy = false, isBusy = false,
isLoading = false, isLoading = false,
error = "", error = "",
language = "en",
diffName = "diff tool",
onClose = () => {}, onClose = () => {},
onRefresh = () => {}, onRefresh = () => {},
onApply = () => {}, onApply = () => {},
onExternalDiff = () => {},
}: Props = $props(); }: Props = $props();
const isGerman = $derived(language === "de");
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false }); let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
let patchScroll = $state<HTMLDivElement | null>(null); let patchScroll = $state<HTMLDivElement | null>(null);
let selectedLineIds = $state<Set<string>>(new Set()); let selectedLineIds = $state<Set<string>>(new Set());
@@ -270,6 +278,15 @@
<p class="dialog-title" title={displayPath}>{displayPath}</p> <p class="dialog-title" title={displayPath}>{displayPath}</p>
</div> </div>
<div class="dialog-header-actions"> <div class="dialog-header-actions">
<div class="tool-surface-choice" aria-label={isGerman ? "Diff öffnen mit" : "Open diff with"}>
<span>{isGerman ? "Öffnen mit" : "Open with"}</span>
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty anzeigen" : "Show in Gitty"}>
<FileDiff size={13} aria-hidden="true" />Gitty
</button>
<button type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={isGerman ? `In ${diffName} öffnen` : `Open in ${diffName}`}>
<ExternalLink size={13} aria-hidden="true" /><span>{diffName}</span>
</button>
</div>
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button> <button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</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="Close">
<X size={16} aria-hidden="true" /> <X size={16} aria-hidden="true" />
+39 -14
View File
@@ -18,14 +18,29 @@
let name = $state(""); let name = $state("");
function remoteName(branch: GitBranchInfo): string {
if (!branch.remote) return "";
const slash = branch.name.indexOf("/");
return slash > 0 ? branch.name.slice(0, slash) : branch.name;
}
function editableName(branch: GitBranchInfo): string {
if (!branch.remote) return branch.name;
const slash = branch.name.indexOf("/");
return slash >= 0 ? branch.name.slice(slash + 1) : branch.name;
}
let originalName = $derived(editableName(branch));
let remote = $derived(remoteName(branch));
$effect(() => { $effect(() => {
name = branch.name; name = originalName;
}); });
function submit(event: SubmitEvent) { function submit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
const value = name.trim(); const value = name.trim();
if (!value || value === branch.name) return; if (!value || value === originalName) return;
onRename(value); onRename(value);
} }
</script> </script>
@@ -34,10 +49,10 @@
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" role="presentation"
> >
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1"> <div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label={branch.remote ? "Rename remote branch" : "Rename branch"} tabindex="-1">
<header class="dialog-header"> <header class="dialog-header">
<div> <div>
<span class="eyebrow">Rename branch</span> <span class="eyebrow">{branch.remote ? "Rename remote branch" : "Rename branch"}</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2> <h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
</div> </div>
<button class="dialog-close" type="button" onclick={onClose} title="Close"> <button class="dialog-close" type="button" onclick={onClose} title="Close">
@@ -48,27 +63,37 @@
<form class="rename-branch-form" onsubmit={submit}> <form class="rename-branch-form" onsubmit={submit}>
<label class="new-branch-field"> <label class="new-branch-field">
<span>Branch name</span> <span>Branch name</span>
<!-- svelte-ignore a11y_autofocus --> <div class:remote-branch-name-field={branch.remote}>
<input {#if branch.remote}<strong>{remote}/</strong>{/if}
bind:value={name} <!-- svelte-ignore a11y_autofocus -->
autocomplete="off" <input
spellcheck="false" bind:value={name}
disabled={isBusy} autocomplete="off"
autofocus spellcheck="false"
/> disabled={isBusy}
autofocus
/>
</div>
</label> </label>
{#if branch.remote}
<p class="rename-remote-note">
Gitty creates <strong>{remote}/{name.trim() || "new-name"}</strong> and removes
<strong>{branch.name}</strong> in one atomic push. The operation stops if the remote changed in the meantime.
</p>
{/if}
<div class="new-branch-actions"> <div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}> <button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
Cancel Cancel
</button> </button>
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === branch.name}> <button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === originalName}>
{#if isBusy} {#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" /> <LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else} {:else}
<GitBranch size={16} aria-hidden="true" /> <GitBranch size={16} aria-hidden="true" />
{/if} {/if}
Rename {branch.remote ? "Rename on remote" : "Rename"}
</button> </button>
</div> </div>
</form> </form>
+24 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { AlertCircle, Check, LoaderCircle, X } from "@lucide/svelte"; import { AlertCircle, Check, ExternalLink, GitMerge, LoaderCircle, X } from "@lucide/svelte";
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types"; import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>; type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
@@ -24,6 +24,9 @@
onSelectFile: (path: string) => void; onSelectFile: (path: string) => void;
onMarkResolved: (path: string, resolution: PreparedResolution) => void; onMarkResolved: (path: string, resolution: PreparedResolution) => void;
onApply: () => void; onApply: () => void;
onExternalMerge: (path: string) => void | Promise<void>;
language?: "en" | "de";
mergeName?: string;
} }
let { let {
@@ -37,7 +40,11 @@
onSelectFile = () => {}, onSelectFile = () => {},
onMarkResolved = () => {}, onMarkResolved = () => {},
onApply = () => {}, onApply = () => {},
onExternalMerge = () => {},
language = "en",
mergeName = "merge tool",
}: Props = $props(); }: Props = $props();
const isGerman = $derived(language === "de");
let conflictParts = $derived<ConflictPart[]>( let conflictParts = $derived<ConflictPart[]>(
conflict && !conflict.binary ? parseConflicts(conflict.content) : [], conflict && !conflict.binary ? parseConflicts(conflict.content) : [],
@@ -268,9 +275,22 @@
<span class="eyebrow">Resolve</span> <span class="eyebrow">Resolve</span>
<h2 class="dialog-title">Conflicts</h2> <h2 class="dialog-title">Conflicts</h2>
</div> </div>
<button class="dialog-close" type="button" onclick={onClose} title="Close"> <div class="dialog-header-actions">
<X size={18} aria-hidden="true" /> {#if conflictTarget}
</button> <div class="tool-surface-choice" aria-label={isGerman ? "Konflikt bearbeiten mit" : "Edit conflict with"}>
<span>{isGerman ? "Bearbeiten mit" : "Edit with"}</span>
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty bearbeiten" : "Edit in Gitty"}>
<GitMerge size={13} aria-hidden="true" />Gitty
</button>
<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={isGerman ? `In ${mergeName} öffnen` : `Open in ${mergeName}`}>
<ExternalLink size={13} aria-hidden="true" /><span>{mergeName}</span>
</button>
</div>
{/if}
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Konflikte schließen" : "Close conflicts"}>
<X size={18} aria-hidden="true" />
</button>
</div>
</header> </header>
{#if conflictedFiles.length === 0} {#if conflictedFiles.length === 0}
+288
View File
@@ -0,0 +1,288 @@
import type {
DetectedExternalTool,
ExternalToolKind,
ExternalToolSetting,
ExternalToolsSettings,
ToolOpenMode,
} from "./types";
export type { ExternalToolKind } from "./types";
export interface ExternalToolPreset {
id: string;
label: string;
program: string;
args: string[];
builtIn?: boolean;
}
type ToolIdentity = Pick<ExternalToolPreset, "id" | "label" | "program">;
const userAgent = navigator.userAgent;
export const externalToolsPlatform = userAgent.includes("Windows")
? "windows"
: userAgent.includes("Mac")
? "macos"
: "linux";
const codeFamily: ToolIdentity[] = externalToolsPlatform === "windows"
? [
{ id: "vscode", label: "Visual Studio Code", program: "Code.exe" },
{ id: "vscode-insiders", label: "Visual Studio Code Insiders", program: "Code - Insiders.exe" },
{ id: "cursor", label: "Cursor", program: "Cursor.exe" },
{ id: "windsurf", label: "Windsurf", program: "Windsurf.exe" },
{ id: "vscodium", label: "VSCodium", program: "VSCodium.exe" },
]
: [
{ id: "vscode", label: "Visual Studio Code", program: "code" },
{ id: "vscode-insiders", label: "Visual Studio Code Insiders", program: "code-insiders" },
{ id: "cursor", label: "Cursor", program: "cursor" },
{ id: "windsurf", label: "Windsurf", program: "windsurf" },
{ id: "vscodium", label: "VSCodium", program: "codium" },
];
const jetBrains: ToolIdentity[] = [
{ id: "intellij-idea", label: "IntelliJ IDEA", program: externalToolsPlatform === "windows" ? "idea64.exe" : "idea" },
{ id: "webstorm", label: "WebStorm", program: externalToolsPlatform === "windows" ? "webstorm64.exe" : "webstorm" },
{ id: "pycharm", label: "PyCharm", program: externalToolsPlatform === "windows" ? "pycharm64.exe" : "pycharm" },
{ id: "phpstorm", label: "PhpStorm", program: externalToolsPlatform === "windows" ? "phpstorm64.exe" : "phpstorm" },
{ id: "rider", label: "JetBrains Rider", program: externalToolsPlatform === "windows" ? "rider64.exe" : "rider" },
{ id: "clion", label: "CLion", program: externalToolsPlatform === "windows" ? "clion64.exe" : "clion" },
{ id: "rustrover", label: "RustRover", program: externalToolsPlatform === "windows" ? "rustrover64.exe" : "rustrover" },
{ id: "goland", label: "GoLand", program: externalToolsPlatform === "windows" ? "goland64.exe" : "goland" },
];
const editor = (tool: ToolIdentity, args = ["{file}"]): ExternalToolPreset => ({ ...tool, args });
const codeDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--new-window", "--wait", "--diff", "{left}", "{right}"] });
const codeMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--new-window", "--wait", "--merge", "{ours}", "{theirs}", "{base}", "{result}"] });
const jetBrainsDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["diff", "{left}", "{right}"] });
const jetBrainsMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["merge", "{ours}", "{theirs}", "{base}", "{result}"] });
const commonEditors: ExternalToolPreset[] = [
...codeFamily.map((item) => editor(item, ["--new-window", "{file}"])),
editor({ id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed" }, ["--new", "{file}"]),
editor({ id: "sublime-text", label: "Sublime Text", program: externalToolsPlatform === "windows" ? "subl.exe" : "subl" }, ["--new-window", "{file}"]),
...jetBrains.map((item) => editor(item)),
editor({ id: "neovim", label: "Neovim", program: externalToolsPlatform === "windows" ? "nvim.exe" : "nvim" }),
editor({ id: "vim", label: "Vim", program: externalToolsPlatform === "windows" ? "gvim.exe" : "gvim" }),
editor({ id: "emacs", label: "Emacs", program: externalToolsPlatform === "windows" ? "runemacs.exe" : "emacs" }),
];
const platformEditors: ExternalToolPreset[] = externalToolsPlatform === "windows"
? [
editor({ id: "notepad-plus-plus", label: "Notepad++", program: "notepad++.exe" }, ["-multiInst", "{file}"]),
{ ...editor({ id: "notepad", label: "Windows Notepad", program: "notepad.exe" }), builtIn: true },
]
: externalToolsPlatform === "macos"
? [
editor({ id: "nova", label: "Nova", program: "nova" }),
editor({ id: "textmate", label: "TextMate", program: "mate" }),
editor({ id: "bbedit", label: "BBEdit", program: "bbedit" }),
editor({ id: "xcode", label: "Xcode", program: "xed" }),
]
: [
editor({ id: "lapce", label: "Lapce", program: "lapce" }),
editor({ id: "kate", label: "Kate", program: "kate" }, ["--new", "{file}"]),
editor({ id: "gedit", label: "GNOME Text Editor", program: "gnome-text-editor" }),
editor({ id: "geany", label: "Geany", program: "geany" }, ["--new-instance", "{file}"]),
editor({ id: "helix", label: "Helix", program: "hx" }),
];
const dedicatedDiff: ExternalToolPreset[] = externalToolsPlatform === "windows"
? [
{ id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/solo", "/readonly", "{left}", "{right}"] },
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/s-", "/u", "/e", "/wl", "/wr", "{left}", "{right}"] },
{ id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "{left}", "{right}"] },
{ id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{left}", "{right}"] },
{ id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{left}", "{right}"] },
{ id: "araxis-merge", label: "Araxis Merge", program: "Compare.exe", args: ["/wait", "{left}", "{right}"] },
{ id: "tortoisegitmerge", label: "TortoiseGitMerge", program: "TortoiseGitMerge.exe", args: ["/base:{left}", "/mine:{right}"] },
]
: externalToolsPlatform === "macos"
? [
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{left}", "{right}"] },
{ id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--wait", "{left}", "{right}"] },
{ id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{left}", "{right}"] },
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] },
{ id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{left}", "{right}"] },
]
: [
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{left}", "{right}"] },
{ id: "meld", label: "Meld", program: "meld", args: ["--wait", "{left}", "{right}"] },
{ id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{left}", "{right}"] },
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] },
{ id: "kompare", label: "Kompare", program: "kompare", args: ["{left}", "{right}"] },
];
const dedicatedMerge: ExternalToolPreset[] = externalToolsPlatform === "windows"
? [
{ id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/s-", "/u", "/e", "{ours}", "{theirs}", "{base}", "/o", "{result}"] },
{ id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] },
{ id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] },
{ id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
{ id: "araxis-merge", label: "Araxis Merge", program: "Compare.exe", args: ["/wait", "{ours}", "{base}", "{theirs}", "{result}"] },
{ id: "tortoisegitmerge", label: "TortoiseGitMerge", program: "TortoiseGitMerge.exe", args: ["/base:{base}", "/theirs:{theirs}", "/mine:{ours}", "/merged:{result}"] },
]
: externalToolsPlatform === "macos"
? [
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
{ id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--merge", "--output", "{result}", "{base}", "{ours}", "{theirs}"] },
{ id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{ours}", "{base}", "{theirs}", "{result}"] },
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
{ id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{ours}", "{theirs}", "-ancestor", "{base}", "-merge", "{result}"] },
]
: [
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
{ id: "meld", label: "Meld", program: "meld", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] },
{ id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] },
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
];
const terminalPresets: ExternalToolPreset[] = externalToolsPlatform === "windows"
? [
{ id: "windows-terminal", label: "Windows Terminal", program: "wt.exe", args: ["-w", "new", "-d", "{repo}"] },
{ id: "powershell", label: "PowerShell 7", program: "pwsh.exe", args: ["-NoExit", "-WorkingDirectory", "{repo}"] },
{ id: "windows-powershell", label: "Windows PowerShell", program: "powershell.exe", args: ["-NoExit"], builtIn: true },
{ id: "git-bash", label: "Git Bash", program: "git-bash.exe", args: ["--login", "-i"] },
{ id: "cmd", label: "Command Prompt", program: "cmd.exe", args: ["/K"], builtIn: true },
{ id: "wezterm", label: "WezTerm", program: "wezterm-gui.exe", args: ["start", "--cwd", "{repo}"] },
{ id: "alacritty", label: "Alacritty", program: "alacritty.exe", args: ["--working-directory", "{repo}"] },
{ id: "kitty-terminal", label: "kitty", program: "kitty.exe", args: ["--directory", "{repo}"] },
]
: externalToolsPlatform === "macos"
? [
{ id: "terminal", label: "Terminal", program: "open", args: ["-n", "-a", "Terminal", "{repo}"], builtIn: true },
{ id: "iterm2", label: "iTerm2", program: "open", args: ["-n", "-a", "iTerm", "{repo}"] },
{ id: "warp", label: "Warp", program: "open", args: ["-n", "-a", "Warp", "{repo}"] },
{ id: "wezterm", label: "WezTerm", program: "wezterm", args: ["start", "--cwd", "{repo}"] },
{ id: "alacritty", label: "Alacritty", program: "alacritty", args: ["--working-directory", "{repo}"] },
]
: [
{ id: "x-terminal", label: "System terminal", program: "x-terminal-emulator", args: ["--working-directory={repo}"] },
{ id: "gnome-terminal", label: "GNOME Terminal", program: "gnome-terminal", args: ["--working-directory={repo}"] },
{ id: "konsole", label: "Konsole", program: "konsole", args: ["--workdir", "{repo}"] },
{ id: "kitty-terminal", label: "kitty", program: "kitty", args: ["--directory", "{repo}"] },
{ id: "wezterm", label: "WezTerm", program: "wezterm", args: ["start", "--cwd", "{repo}"] },
{ id: "alacritty", label: "Alacritty", program: "alacritty", args: ["--working-directory", "{repo}"] },
{ id: "xfce-terminal", label: "Xfce Terminal", program: "xfce4-terminal", args: ["--working-directory={repo}"] },
{ id: "tilix", label: "Tilix", program: "tilix", args: ["--working-directory={repo}"] },
];
const fileManagerPresets: ExternalToolPreset[] = externalToolsPlatform === "windows"
? [
{ id: "explorer", label: "Windows Explorer", program: "explorer.exe", args: ["/n,", "{repo}"], builtIn: true },
{ id: "total-commander", label: "Total Commander", program: "TOTALCMD64.EXE", args: ["/N", "/T", "{repo}"] },
{ id: "directory-opus", label: "Directory Opus", program: "dopus.exe", args: ["{repo}"] },
{ id: "double-commander", label: "Double Commander", program: "doublecmd.exe", args: ["{repo}"] },
{ id: "freecommander", label: "FreeCommander XE", program: "FreeCommander.exe", args: ["/L={repo}"] },
{ id: "xyplorer", label: "XYplorer", program: "XYplorer.exe", args: ["{repo}"] },
]
: externalToolsPlatform === "macos"
? [
{ id: "finder", label: "Finder", program: "open", args: ["-n", "{repo}"], builtIn: true },
{ id: "forklift", label: "ForkLift", program: "forklift", args: ["{repo}"] },
{ id: "path-finder", label: "Path Finder", program: "Path Finder", args: ["{repo}"] },
]
: [
{ id: "system-file-manager", label: "System file manager", program: "xdg-open", args: ["{repo}"], builtIn: true },
{ id: "nautilus", label: "GNOME Files", program: "nautilus", args: ["{repo}"] },
{ id: "dolphin", label: "Dolphin", program: "dolphin", args: ["{repo}"] },
{ id: "thunar", label: "Thunar", program: "thunar", args: ["{repo}"] },
{ id: "nemo", label: "Nemo", program: "nemo", args: ["{repo}"] },
{ id: "pcmanfm", label: "PCManFM", program: "pcmanfm", args: ["{repo}"] },
{ id: "double-commander", label: "Double Commander", program: "doublecmd", args: ["{repo}"] },
];
export const externalToolPresets: Record<ExternalToolKind, ExternalToolPreset[]> = {
editor: [...commonEditors, ...platformEditors],
diff: [...dedicatedDiff, ...codeFamily.map(codeDiff), { id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed", args: ["--new", "--diff", "{left}", "{right}"] }, ...jetBrains.map(jetBrainsDiff)],
merge: [...dedicatedMerge, ...codeFamily.map(codeMerge), ...jetBrains.map(jetBrainsMerge)],
terminal: terminalPresets,
fileManager: fileManagerPresets,
};
function detectedPreset(kind: ExternalToolKind, id: string, detectedTools: DetectedExternalTool[]): DetectedExternalTool | undefined {
return detectedTools.find((tool) => tool.id === id && tool.kinds.includes(kind));
}
export function isExternalToolPresetAvailable(kind: ExternalToolKind, preset: ExternalToolPreset, detectedTools: DetectedExternalTool[]): boolean {
return preset.builtIn === true || detectedPreset(kind, preset.id, detectedTools) != null;
}
export function applyExternalToolPreset(kind: ExternalToolKind, id: string, detectedTools: DetectedExternalTool[] = []): ExternalToolSetting {
const value = externalToolPresets[kind].find((item) => item.id === id) ?? externalToolPresets[kind][0];
const detected = detectedPreset(kind, value.id, detectedTools);
return { preset: value.id, program: detected?.program ?? value.program, args: [...value.args] };
}
function preferredPreset(kind: ExternalToolKind, detectedTools: DetectedExternalTool[]): ExternalToolPreset {
return externalToolPresets[kind].find((item) => isExternalToolPresetAvailable(kind, item, detectedTools))
?? externalToolPresets[kind][0];
}
export function defaultExternalToolsSettings(detectedTools: DetectedExternalTool[] = []): ExternalToolsSettings {
return {
editor: applyExternalToolPreset("editor", preferredPreset("editor", detectedTools).id, detectedTools),
diff: applyExternalToolPreset("diff", preferredPreset("diff", detectedTools).id, detectedTools),
merge: applyExternalToolPreset("merge", preferredPreset("merge", detectedTools).id, detectedTools),
terminal: applyExternalToolPreset("terminal", preferredPreset("terminal", detectedTools).id, detectedTools),
fileManager: applyExternalToolPreset("fileManager", preferredPreset("fileManager", detectedTools).id, detectedTools),
diffOpenMode: "gitty",
mergeOpenMode: "gitty",
};
}
function normaliseSetting(kind: ExternalToolKind, value: unknown, fallback: ExternalToolSetting): ExternalToolSetting {
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback;
const candidate = value as Partial<ExternalToolSetting>;
const program = typeof candidate.program === "string" && candidate.program.trim() ? candidate.program : fallback.program;
const storedArgs = Array.isArray(candidate.args)
? candidate.args.filter((item): item is string => typeof item === "string").slice(0, 64)
: fallback.args;
const preset = typeof candidate.preset === "string" && candidate.preset ? candidate.preset : fallback.preset;
// Preset arguments are application-owned and can be upgraded safely. Editing either
// the executable or arguments in Settings changes the preset to "custom", which keeps
// genuine user commands untouched.
const currentPreset = preset === "custom"
? undefined
: externalToolPresets[kind].find((item) => item.id === preset);
const args = currentPreset ? [...currentPreset.args] : storedArgs;
return { preset, program, args };
}
export function normaliseExternalToolsSettings(value: unknown): ExternalToolsSettings {
const defaults = defaultExternalToolsSettings();
if (!value || typeof value !== "object" || Array.isArray(value)) return defaults;
const candidate = value as Partial<ExternalToolsSettings>;
return {
editor: normaliseSetting("editor", candidate.editor, defaults.editor),
diff: normaliseSetting("diff", candidate.diff, defaults.diff),
merge: normaliseSetting("merge", candidate.merge, defaults.merge),
terminal: normaliseSetting("terminal", candidate.terminal, defaults.terminal),
fileManager: normaliseSetting("fileManager", candidate.fileManager, defaults.fileManager),
diffOpenMode: normaliseOpenMode(candidate.diffOpenMode, defaults.diffOpenMode),
mergeOpenMode: normaliseOpenMode(candidate.mergeOpenMode, defaults.mergeOpenMode),
};
}
function normaliseOpenMode(value: unknown, fallback: ToolOpenMode): ToolOpenMode {
return value === "gitty" || value === "external" ? value : fallback;
}
export function resolveDetectedExternalToolPrograms(settings: ExternalToolsSettings, detectedTools: DetectedExternalTool[]): ExternalToolsSettings {
const resolved = structuredClone(settings);
for (const kind of ["editor", "diff", "merge", "terminal", "fileManager"] as ExternalToolKind[]) {
if (resolved[kind].preset === "custom") continue;
const detected = detectedPreset(kind, resolved[kind].preset, detectedTools);
if (detected) resolved[kind].program = detected.program;
}
return resolved;
}
export function externalToolDisplayName(kind: ExternalToolKind, setting: ExternalToolSetting, detectedTools: DetectedExternalTool[] = []): string {
if (setting.preset === "custom") return setting.program.split(/[\\/]/).pop() || setting.program;
return detectedPreset(kind, setting.preset, detectedTools)?.label
?? externalToolPresets[kind].find((item) => item.id === setting.preset)?.label
?? setting.program;
}
+58
View File
@@ -7,6 +7,9 @@ import type {
CommitAiProvider, CommitAiProvider,
CommitAiStatus, CommitAiStatus,
ConflictFile, ConflictFile,
DetectedExternalTool,
ExternalToolCommand,
ExternalDiffScope,
GitBlameResult, GitBlameResult,
GitBranch, GitBranch,
GitCommit, GitCommit,
@@ -45,6 +48,22 @@ export function openRepositoryFile(path: string, file: string): Promise<void> {
return invoke<void>("open_repository_file", { path, file }); return invoke<void>("open_repository_file", { path, file });
} }
export function detectExternalTools(): Promise<DetectedExternalTool[]> {
return invoke<DetectedExternalTool[]>("detect_external_tools");
}
export function launchExternalTool(path: string, command: ExternalToolCommand, file?: string): Promise<void> {
return invoke<void>("launch_external_tool", { path, file: file ?? null, command });
}
export function launchExternalDiff(path: string, file: string, command: ExternalToolCommand, scope: ExternalDiffScope = "head"): Promise<void> {
return invoke<void>("launch_external_diff", { path, file, command, scope });
}
export function launchExternalMerge(path: string, file: string, command: ExternalToolCommand): Promise<void> {
return invoke<void>("launch_external_merge", { path, file, command });
}
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> { export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit }); return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
} }
@@ -114,6 +133,15 @@ export function renameBranch(
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch }); return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
} }
export function renameRemoteBranch(
path: string,
remote: string,
oldBranch: string,
newBranch: string,
): Promise<GitStatus> {
return invoke<GitStatus>("rename_remote_branch", { path, remote, oldBranch, newBranch });
}
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> { export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
return invoke<GitStatus>("delete_branch", { path, branch, force }); return invoke<GitStatus>("delete_branch", { path, branch, force });
} }
@@ -370,6 +398,36 @@ export function listCommits(path: string, limit = 100, skip = 0): Promise<GitCom
return invoke<GitCommit[]>("list_commits", { path, limit, skip }); return invoke<GitCommit[]>("list_commits", { path, limit, skip });
} }
export function getCommitNote(path: string, commit: string): Promise<string | null> {
return invoke<string | null>("get_commit_note", { path, commit });
}
export function setCommitNote(path: string, commit: string, note: string): Promise<void> {
return invoke<void>("set_commit_note", { path, commit, note });
}
export function deleteCommitNote(path: string, commit: string): Promise<void> {
return invoke<void>("delete_commit_note", { path, commit });
}
export function fetchCommitNotes(path: string, remote: string, username?: string, password?: string): Promise<void> {
return invoke<void>("fetch_commit_notes", {
path,
remote,
username: username ?? null,
password: password ?? null,
});
}
export function pushCommitNotes(path: string, remote: string, username?: string, password?: string): Promise<void> {
return invoke<void>("push_commit_notes", {
path,
remote,
username: username ?? null,
password: password ?? null,
});
}
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> { export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {
return invoke<GitStatus>("restore_to_commit", { path, commit }); return invoke<GitStatus>("restore_to_commit", { path, commit });
} }
+30
View File
@@ -69,6 +69,36 @@ export interface AnalyticsSettings {
noticeSeen: boolean; noticeSeen: boolean;
} }
export interface ExternalToolCommand {
program: string;
args: string[];
}
export interface ExternalToolSetting extends ExternalToolCommand {
preset: string;
}
export type ExternalToolKind = "editor" | "diff" | "merge" | "terminal" | "fileManager";
export type ToolOpenMode = "gitty" | "external";
export type ExternalDiffScope = "head" | "staged" | "unstaged";
export interface ExternalToolsSettings {
editor: ExternalToolSetting;
diff: ExternalToolSetting;
merge: ExternalToolSetting;
terminal: ExternalToolSetting;
fileManager: ExternalToolSetting;
diffOpenMode: ToolOpenMode;
mergeOpenMode: ToolOpenMode;
}
export interface DetectedExternalTool {
id: string;
label: string;
program: string;
kinds: ExternalToolKind[];
}
export interface GitStatus { export interface GitStatus {
repo_path: string; repo_path: string;
current_branch: string | null; current_branch: string | null;