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
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -3129,6 +3129,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, ¬e)
|
||||||
|
})
|
||||||
|
.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 +6147,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!(
|
||||||
|
|||||||
+26
-12
@@ -1,28 +1,33 @@
|
|||||||
#![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,
|
||||||
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
|
repair_worktree, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
||||||
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
|
restore_files, restore_reflog_entry, restore_to_commit, revert_commit,
|
||||||
set_branch_upstream, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop,
|
run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream,
|
||||||
|
set_commit_note, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop,
|
||||||
stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote,
|
stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote,
|
||||||
};
|
};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
@@ -123,6 +128,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,
|
||||||
@@ -174,6 +183,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,
|
||||||
|
|||||||
+352
-6
@@ -18,6 +18,7 @@
|
|||||||
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 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";
|
||||||
@@ -56,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,
|
||||||
@@ -86,18 +90,24 @@
|
|||||||
pruneWorktrees,
|
pruneWorktrees,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
|
pushCommitNotes,
|
||||||
pushTag,
|
pushTag,
|
||||||
removeRemote,
|
removeRemote,
|
||||||
removeWorktree,
|
removeWorktree,
|
||||||
repairWorktree,
|
repairWorktree,
|
||||||
revertCommit,
|
revertCommit,
|
||||||
setBranchUpstream,
|
setBranchUpstream,
|
||||||
|
setCommitNote,
|
||||||
updateRemote,
|
updateRemote,
|
||||||
renameBranch,
|
renameBranch,
|
||||||
rebaseAbort,
|
rebaseAbort,
|
||||||
rebaseBranch,
|
rebaseBranch,
|
||||||
rebaseContinue,
|
rebaseContinue,
|
||||||
getRemoteUrl,
|
getRemoteUrl,
|
||||||
|
detectExternalTools,
|
||||||
|
launchExternalDiff,
|
||||||
|
launchExternalMerge,
|
||||||
|
launchExternalTool,
|
||||||
credLoad,
|
credLoad,
|
||||||
credSave,
|
credSave,
|
||||||
credDelete,
|
credDelete,
|
||||||
@@ -131,8 +141,11 @@
|
|||||||
AnalyticsSettings,
|
AnalyticsSettings,
|
||||||
CommitAiPhase,
|
CommitAiPhase,
|
||||||
ConflictFile,
|
ConflictFile,
|
||||||
|
DetectedExternalTool,
|
||||||
ExplorerNode,
|
ExplorerNode,
|
||||||
ExplorerNodeKind,
|
ExplorerNodeKind,
|
||||||
|
ExternalDiffScope,
|
||||||
|
ExternalToolsSettings,
|
||||||
GitBlameLine,
|
GitBlameLine,
|
||||||
GitBranch as GitBranchInfo,
|
GitBranch as GitBranchInfo,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
@@ -157,6 +170,12 @@
|
|||||||
RepositoryBundle,
|
RepositoryBundle,
|
||||||
StoredCredential,
|
StoredCredential,
|
||||||
} from "./lib/types";
|
} from "./lib/types";
|
||||||
|
import {
|
||||||
|
defaultExternalToolsSettings,
|
||||||
|
externalToolDisplayName,
|
||||||
|
normaliseExternalToolsSettings,
|
||||||
|
resolveDetectedExternalToolPrograms,
|
||||||
|
} from "./lib/externalTools";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
orgKeyFromUrl,
|
orgKeyFromUrl,
|
||||||
@@ -223,6 +242,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";
|
||||||
@@ -289,6 +309,15 @@
|
|||||||
let expandedExplorerPaths = new Set<string>();
|
let expandedExplorerPaths = new Set<string>();
|
||||||
let expandedCommitHashes = new Set<string>();
|
let expandedCommitHashes = new Set<string>();
|
||||||
let selectedCommitHash = "";
|
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 = "";
|
||||||
@@ -319,6 +348,11 @@
|
|||||||
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 = "";
|
||||||
@@ -481,6 +515,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);
|
||||||
@@ -491,6 +530,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);
|
||||||
@@ -769,7 +809,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 {
|
||||||
@@ -941,16 +981,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();
|
||||||
@@ -1794,6 +1837,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 = "";
|
||||||
@@ -2854,6 +2905,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 () => {
|
||||||
@@ -3752,6 +3940,104 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async function openFileFromCommandPalette(file: GitRepositoryFile) {
|
||||||
if (!activeRepoPath) return;
|
if (!activeRepoPath) return;
|
||||||
selectedExplorerPath = file.path;
|
selectedExplorerPath = file.path;
|
||||||
@@ -3932,21 +4218,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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4080,6 +4385,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}
|
||||||
@@ -4088,7 +4396,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}
|
||||||
@@ -4478,11 +4788,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}
|
||||||
@@ -4540,7 +4855,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}
|
||||||
/>
|
/>
|
||||||
@@ -4629,6 +4944,7 @@
|
|||||||
onCreateBranchFromCommit={openNewBranchDialog}
|
onCreateBranchFromCommit={openNewBranchDialog}
|
||||||
onCherryPickCommit={cherryPickFromCommit}
|
onCherryPickCommit={cherryPickFromCommit}
|
||||||
onRevertCommit={revertHistoryCommit}
|
onRevertCommit={revertHistoryCommit}
|
||||||
|
onOpenCommitNote={openCommitNoteDialog}
|
||||||
onSelectCommit={(commit) => { selectedCommitHash = commit.hash; }}
|
onSelectCommit={(commit) => { selectedCommitHash = commit.hash; }}
|
||||||
onToggleCommitFiles={(hash) => {
|
onToggleCommitFiles={(hash) => {
|
||||||
const next = new Set(expandedCommitHashes);
|
const next = new Set(expandedCommitHashes);
|
||||||
@@ -4709,6 +5025,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; }}
|
||||||
/>
|
/>
|
||||||
@@ -4751,6 +5072,9 @@
|
|||||||
onClose={closeLinePatch}
|
onClose={closeLinePatch}
|
||||||
onRefresh={refreshLinePatch}
|
onRefresh={refreshLinePatch}
|
||||||
onApply={applyLinePatch}
|
onApply={applyLinePatch}
|
||||||
|
language={appLanguage}
|
||||||
|
diffName={diffToolName}
|
||||||
|
onExternalDiff={openCurrentLinePatchExternally}
|
||||||
/>
|
/>
|
||||||
{/await}
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -4849,6 +5173,25 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#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 branch from the branch context menu -->
|
<!-- Rename a local branch from the branch context menu -->
|
||||||
{#if renameBranchTarget}
|
{#if renameBranchTarget}
|
||||||
<RenameBranchDialog
|
<RenameBranchDialog
|
||||||
@@ -5007,10 +5350,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}
|
||||||
|
|||||||
+30
@@ -2620,6 +2620,7 @@
|
|||||||
background: rgba(65,209,255,0.08);
|
background: rgba(65,209,255,0.08);
|
||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
}
|
}
|
||||||
|
.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 {
|
||||||
@@ -3188,6 +3189,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));
|
||||||
@@ -3503,6 +3507,12 @@
|
|||||||
.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; }
|
||||||
|
|
||||||
@@ -3511,6 +3521,11 @@
|
|||||||
|
|
||||||
.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); }
|
||||||
|
|
||||||
@@ -6589,6 +6604,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; }
|
||||||
|
|||||||
@@ -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 = () => {};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
|
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, X } from "@lucide/svelte";
|
||||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||||
|
|
||||||
interface GraphSegment {
|
interface GraphSegment {
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
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;
|
onSelectCommit: (commit: GitCommit) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@
|
|||||||
onCreateBranchFromCommit = () => {},
|
onCreateBranchFromCommit = () => {},
|
||||||
onCherryPickCommit = () => {},
|
onCherryPickCommit = () => {},
|
||||||
onRevertCommit = () => {},
|
onRevertCommit = () => {},
|
||||||
|
onOpenCommitNote = () => {},
|
||||||
onSelectCommit = () => {},
|
onSelectCommit = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -445,6 +447,20 @@
|
|||||||
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();
|
||||||
@@ -720,6 +736,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"
|
||||||
@@ -767,6 +793,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
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
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: ["--wait", "--diff", "{left}", "{right}"] });
|
||||||
|
const codeMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--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, ["--reuse-window", "{file}"])),
|
||||||
|
editor({ id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed" }),
|
||||||
|
editor({ id: "sublime-text", label: "Sublime Text", program: externalToolsPlatform === "windows" ? "subl.exe" : "subl" }),
|
||||||
|
...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" }),
|
||||||
|
{ ...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" }),
|
||||||
|
editor({ id: "gedit", label: "GNOME Text Editor", program: "gnome-text-editor" }),
|
||||||
|
editor({ id: "geany", label: "Geany", program: "geany" }),
|
||||||
|
editor({ id: "helix", label: "Helix", program: "hx" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const dedicatedDiff: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||||
|
? [
|
||||||
|
{ id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/readonly", "{left}", "{right}"] },
|
||||||
|
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/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: ["{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: ["{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: ["{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||||
|
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/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: ["{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: ["{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: ["-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: "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: ["-a", "Terminal", "{repo}"], builtIn: true },
|
||||||
|
{ id: "iterm2", label: "iTerm2", program: "open", args: ["-a", "iTerm", "{repo}"] },
|
||||||
|
{ id: "warp", label: "Warp", program: "open", args: ["-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: ["{repo}"], builtIn: true },
|
||||||
|
{ id: "total-commander", label: "Total Commander", program: "TOTALCMD64.EXE", args: ["/O", "/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: ["{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: ["--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(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 args = 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;
|
||||||
|
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(candidate.editor, defaults.editor),
|
||||||
|
diff: normaliseSetting(candidate.diff, defaults.diff),
|
||||||
|
merge: normaliseSetting(candidate.merge, defaults.merge),
|
||||||
|
terminal: normaliseSetting(candidate.terminal, defaults.terminal),
|
||||||
|
fileManager: normaliseSetting(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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
@@ -370,6 +389,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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user