Open code test #26

Merged
Christoph merged 9 commits from openCodeTest into main 2026-08-13 18:48:12 +00:00
15 changed files with 3721 additions and 125 deletions
Showing only changes of commit 15d1f2bfd6 - Show all commits
File diff suppressed because it is too large Load Diff
+309
View File
@@ -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, &note)
})
.await
}
#[tauri::command]
pub async fn delete_commit_note(path: String, commit: String) -> Result<(), String> {
run_git_task("Could not delete commit note", move || {
let repo = resolve_repo(&path)?;
delete_commit_note_for_repo(&repo, &commit)
})
.await
}
#[tauri::command]
pub async fn fetch_commit_notes(
path: String,
remote: String,
username: Option<String>,
password: Option<String>,
) -> Result<(), String> {
run_git_task("Could not fetch commit notes", move || {
let repo = resolve_repo(&path)?;
fetch_commit_notes_for_repo(&repo, &remote, username.as_deref(), password.as_deref())
})
.await
}
#[tauri::command]
pub async fn push_commit_notes(
path: String,
remote: String,
username: Option<String>,
password: Option<String>,
) -> Result<(), String> {
run_git_task("Could not push commit notes", move || {
let repo = resolve_repo(&path)?;
push_commit_notes_for_repo(&repo, &remote, username.as_deref(), password.as_deref())
})
.await
}
fn commit_note_for_repo(repo: &Path, commit: &str) -> Result<Option<String>, String> {
let commit = verify_commit(repo, commit)?;
let output = git_command()
.arg("-C")
.arg(repo)
.args(["notes", "--ref", COMMIT_NOTES_REF, "list", commit.as_str()])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.code() == Some(1) {
return Ok(None);
}
if !output.status.success() {
return Err(format!(
"Could not inspect commit note: {}",
command_output_details(&output)
));
}
let note_object = String::from_utf8_lossy(&output.stdout).trim().to_string();
if note_object.is_empty() {
return Ok(None);
}
let note = run_git(repo, ["cat-file", "blob", note_object.as_str()])?;
let mut note =
String::from_utf8(note).map_err(|_| "Commit note is not valid UTF-8 text.".to_string())?;
if note.ends_with('\n') {
note.pop();
if note.ends_with('\r') {
note.pop();
}
}
Ok(Some(note))
}
fn set_commit_note_for_repo(repo: &Path, commit: &str, note: &str) -> Result<(), String> {
let commit = verify_commit(repo, commit)?;
if note.trim().is_empty() {
return Err("Commit note must not be empty. Use Delete to remove it.".to_string());
}
if note.len() > MAX_COMMIT_NOTE_BYTES {
return Err(format!(
"Commit note is too large (maximum {} KiB).",
MAX_COMMIT_NOTE_BYTES / 1024
));
}
run_git_with_stdin(
repo,
[
"notes",
"--ref",
COMMIT_NOTES_REF,
"add",
"-f",
"-F",
"-",
"--",
commit.as_str(),
],
note.as_bytes(),
)?;
Ok(())
}
fn delete_commit_note_for_repo(repo: &Path, commit: &str) -> Result<(), String> {
let commit = verify_commit(repo, commit)?;
run_git(
repo,
[
"notes",
"--ref",
COMMIT_NOTES_REF,
"remove",
"--ignore-missing",
"--",
commit.as_str(),
],
)?;
Ok(())
}
fn fetch_commit_notes_for_repo(
repo: &Path,
remote: &str,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
let remote = validate_remote_name(repo, remote, true)?;
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
let refspec = format!("+{COMMIT_NOTES_REF}:{COMMIT_NOTES_SYNC_REF}");
let fetch_args = ["fetch", remote.as_str(), refspec.as_str()];
let fetched = match (username, password) {
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
run_git_authenticated(repo, fetch_args, user, pass)
}
_ => run_git(repo, fetch_args),
};
if let Err(error) = fetched {
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
return Err(
if error.to_lowercase().contains("couldn't find remote ref") {
format!("Remote '{remote}' does not contain commit notes yet.")
} else {
error
},
);
}
let merge_result = (|| -> Result<(), String> {
if ref_exists(repo, COMMIT_NOTES_REF)? {
run_git(
repo,
[
"notes",
"--ref",
COMMIT_NOTES_REF,
"merge",
"-s",
"cat_sort_uniq",
COMMIT_NOTES_SYNC_REF,
],
)?;
} else {
let remote_notes_hash = run_git(repo, ["rev-parse", COMMIT_NOTES_SYNC_REF])?;
let remote_notes_hash = String::from_utf8_lossy(&remote_notes_hash)
.trim()
.to_string();
run_git(
repo,
["update-ref", COMMIT_NOTES_REF, remote_notes_hash.as_str()],
)?;
}
Ok(())
})();
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
merge_result
}
fn push_commit_notes_for_repo(
repo: &Path,
remote: &str,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
let remote = validate_remote_name(repo, remote, true)?;
if !ref_exists(repo, COMMIT_NOTES_REF)? {
return Err("There are no local commit notes to push.".to_string());
}
let refspec = format!("{COMMIT_NOTES_REF}:{COMMIT_NOTES_REF}");
let push_args = ["push", remote.as_str(), refspec.as_str()];
match (username, password) {
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
run_git_authenticated(repo, push_args, user, pass)?;
}
_ => {
run_git(repo, push_args)?;
}
}
Ok(())
}
fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> { fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
let bounded_limit = limit.unwrap_or(100).clamp(1, 500); let bounded_limit = limit.unwrap_or(100).clamp(1, 500);
commit_page_for_repo(repo, Some(bounded_limit), None) commit_page_for_repo(repo, Some(bounded_limit), None)
@@ -5923,6 +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
View File
@@ -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
View File
@@ -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
View File
@@ -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; }
+18 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { import {
ChevronDown, ChevronDown,
Code2,
CloudDownload, CloudDownload,
Download, Download,
FolderOpen, FolderOpen,
@@ -12,6 +13,7 @@
Search, Search,
Upload, Upload,
Settings2, Settings2,
Terminal,
} from "@lucide/svelte"; } from "@lucide/svelte";
export let hasRepository: boolean = false; export let hasRepository: boolean = false;
@@ -20,6 +22,9 @@
export let ahead: number = 0; export let ahead: number = 0;
export let behind: number = 0; export let behind: number = 0;
export let language: "en" | "de" = "en"; export let language: "en" | "de" = "en";
export let editorName: string = "Editor";
export let terminalName: string = "Terminal";
export let fileManagerName: string = "Explorer";
export let onFetch: () => void = () => {}; export let onFetch: () => void = () => {};
export let onPull: () => void = () => {}; export let onPull: () => void = () => {};
export let onPush: () => void = () => {}; export let onPush: () => void = () => {};
@@ -29,6 +34,8 @@
export let onInteractiveRebase: () => void = () => {}; export let onInteractiveRebase: () => void = () => {};
export let onReflog: () => void = () => {}; export let onReflog: () => void = () => {};
export let onOpenInExplorer: () => void = () => {}; export let onOpenInExplorer: () => void = () => {};
export let onOpenInEditor: () => void = () => {};
export let onOpenTerminal: () => void = () => {};
export let onFetchPrune: () => void = () => {}; export let onFetchPrune: () => void = () => {};
export let onForcePush: () => void = () => {}; export let onForcePush: () => void = () => {};
export let onSyncOptions: () => void = () => {}; export let onSyncOptions: () => void = () => {};
@@ -192,15 +199,23 @@
<div class="repo-toolbar-divider utility" aria-hidden="true"></div> <div class="repo-toolbar-divider utility" aria-hidden="true"></div>
<div class="repo-action-group repo-utility-actions"> <div class="repo-action-group repo-utility-actions">
<button class="repo-action" onclick={onOpenInEditor} disabled={!hasRepository || isBusy} title={isGerman ? `Repository in ${editorName} öffnen` : `Open repository in ${editorName}`}>
<Code2 size={15} aria-hidden="true" />
<span class="repo-action-label utility-label">{editorName}</span>
</button>
<button class="repo-action" onclick={onOpenTerminal} disabled={!hasRepository || isBusy} title={isGerman ? `${terminalName} im Repository öffnen` : `Open ${terminalName} in repository`}>
<Terminal size={15} aria-hidden="true" />
<span class="repo-action-label utility-label">{terminalName}</span>
</button>
<button <button
class="repo-action" class="repo-action"
onclick={onOpenInExplorer} onclick={onOpenInExplorer}
disabled={!hasRepository || isBusy} disabled={!hasRepository || isBusy}
title={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"} title={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
aria-label={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"} aria-label={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
> >
<FolderOpen size={15} aria-hidden="true" /> <FolderOpen size={15} aria-hidden="true" />
<span class="repo-action-label utility-label">Explorer</span> <span class="repo-action-label utility-label">{fileManagerName}</span>
</button> </button>
<button <button
+529 -97
View File
@@ -1,22 +1,83 @@
<script lang="ts"> <script lang="ts">
import { Check, Languages, RefreshCw, Settings, X } from "@lucide/svelte"; import { open } from "@tauri-apps/plugin-dialog";
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types"; import {
Check,
CheckCircle2,
ChevronDown,
ChevronRight,
CircleDashed,
Code2,
FolderOpen,
GitCompare,
GitMerge,
Languages,
Palette,
RefreshCw,
RotateCw,
Settings2,
ShieldCheck,
SlidersHorizontal,
Terminal,
Wrench,
X,
} from "@lucide/svelte";
import {
applyExternalToolPreset,
defaultExternalToolsSettings,
externalToolPresets,
isExternalToolPresetAvailable,
type ExternalToolKind,
type ExternalToolPreset,
} from "../externalTools";
import type {
AnalyticsSettings,
AppLanguage,
AppTheme,
DetectedExternalTool,
ExternalToolsSettings,
ToolOpenMode,
} from "../types";
type SettingsPage = "general" | "tools";
interface Props { interface Props {
analytics: AnalyticsSettings; analytics: AnalyticsSettings;
theme: AppTheme; theme: AppTheme;
language: AppLanguage; language: AppLanguage;
autoRefresh: boolean; autoRefresh: boolean;
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean) => void; externalTools: ExternalToolsSettings;
detectedTools: DetectedExternalTool[];
detectionPending: boolean;
detectionUnavailable: boolean;
onRefreshDetectedTools: () => void | Promise<void>;
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean, externalTools: ExternalToolsSettings) => void;
onClose: () => void; onClose: () => void;
} }
let { analytics, theme = "system", language = "en", autoRefresh = true, onSave = () => {}, onClose = () => {} }: Props = $props(); let {
analytics,
theme = "system",
language = "en",
autoRefresh = true,
externalTools,
detectedTools = [],
detectionPending = false,
detectionUnavailable = false,
onRefreshDetectedTools = () => {},
onSave = () => {},
onClose = () => {},
}: Props = $props();
const toolKinds: ExternalToolKind[] = ["editor", "diff", "merge", "terminal", "fileManager"];
let activePage = $state<SettingsPage>("tools");
let activeToolKind = $state<ExternalToolKind>("editor");
let advancedOpen = $state(false);
let analyticsEnabled = $state(true); let analyticsEnabled = $state(true);
let selectedTheme = $state<AppTheme>("system"); let selectedTheme = $state<AppTheme>("system");
let selectedLanguage = $state<AppLanguage>("en"); let selectedLanguage = $state<AppLanguage>("en");
let autoRefreshEnabled = $state(true); let autoRefreshEnabled = $state(true);
let tools = $state<ExternalToolsSettings>(defaultExternalToolsSettings());
const isGerman = $derived(selectedLanguage === "de"); const isGerman = $derived(selectedLanguage === "de");
$effect(() => { $effect(() => {
@@ -24,6 +85,7 @@
selectedTheme = theme; selectedTheme = theme;
selectedLanguage = language; selectedLanguage = language;
autoRefreshEnabled = autoRefresh; autoRefreshEnabled = autoRefresh;
tools = structuredClone(externalTools);
}); });
function save() { function save() {
@@ -31,112 +93,482 @@
...analytics, ...analytics,
enabled: analyticsEnabled, enabled: analyticsEnabled,
noticeSeen: true, noticeSeen: true,
}, selectedTheme, selectedLanguage, autoRefreshEnabled); }, selectedTheme, selectedLanguage, autoRefreshEnabled, $state.snapshot(tools));
}
function toolLabel(kind: ExternalToolKind): string {
const labels = {
editor: "Editor",
diff: isGerman ? "Diff-Tool" : "Diff tool",
merge: isGerman ? "Merge-Tool" : "Merge tool",
terminal: "Terminal",
fileManager: isGerman ? "Dateimanager" : "File manager",
};
return labels[kind];
}
function toolDescription(kind: ExternalToolKind): string {
const descriptions = isGerman
? {
editor: "Öffnet Repositories und einzelne Dateien zum Bearbeiten.",
diff: "Vergleicht eine Arbeitsdatei mit ihrer Version aus HEAD.",
merge: "Übergibt Base, Current, Incoming und Ergebnis an einen 3-Wege-Merger.",
terminal: "Startet eine Shell direkt im Repository-Verzeichnis.",
fileManager: "Öffnet das Repository im bevorzugten Dateimanager.",
}
: {
editor: "Opens repositories and individual files for editing.",
diff: "Compares a working file with its version from HEAD.",
merge: "Passes base, current, incoming, and result to a three-way merger.",
terminal: "Starts a shell directly in the repository directory.",
fileManager: "Opens the repository in your preferred file manager.",
};
return descriptions[kind];
}
function toolUsage(kind: ExternalToolKind): string {
const usage = isGerman
? {
editor: "Oben in der Repository-Leiste oder über das Code-Symbol im Datei-Explorer.",
diff: "Datei im Explorer markieren und das Vergleichs-Symbol anklicken alternativ Rechtsklick auf die Datei.",
merge: "Bei einem Konflikt „Konflikte lösen“ öffnen und anschließend dieses Merge-Tool starten.",
terminal: "Oben in der Repository-Leiste über den Terminal-Button.",
fileManager: "Oben in der Repository-Leiste über den Ordner-Button.",
}
: {
editor: "Use the repository toolbar or the code button in the file explorer.",
diff: "Select a file in Explorer and click the compare button, or right-click the file.",
merge: "Open Resolve conflicts and start this merge tool from the conflict view.",
terminal: "Use the terminal button in the repository toolbar.",
fileManager: "Use the folder button in the repository toolbar.",
};
return usage[kind];
}
function presetAvailable(kind: ExternalToolKind, preset: ExternalToolPreset): boolean {
return isExternalToolPresetAvailable(kind, preset, detectedTools);
}
function availablePresets(kind: ExternalToolKind): ExternalToolPreset[] {
return externalToolPresets[kind].filter((preset) => presetAvailable(kind, preset));
}
function otherPresets(kind: ExternalToolKind): ExternalToolPreset[] {
return externalToolPresets[kind].filter((preset) => !presetAvailable(kind, preset));
}
function selectedPreset(kind: ExternalToolKind): ExternalToolPreset | undefined {
return externalToolPresets[kind].find((preset) => preset.id === tools[kind].preset);
}
function selectedToolName(kind: ExternalToolKind): string {
return tools[kind].preset === "custom"
? tools[kind].program.split(/[\\/]/).pop() || (isGerman ? "Eigenes Programm" : "Custom application")
: selectedPreset(kind)?.label ?? tools[kind].program;
}
function openMode(kind: "diff" | "merge"): ToolOpenMode {
return kind === "diff" ? tools.diffOpenMode : tools.mergeOpenMode;
}
function setOpenMode(kind: "diff" | "merge", mode: ToolOpenMode) {
if (kind === "diff") tools.diffOpenMode = mode;
else tools.mergeOpenMode = mode;
}
function selectionAvailable(kind: ExternalToolKind): boolean {
if (tools[kind].preset === "custom") return tools[kind].program.trim().length > 0;
const preset = selectedPreset(kind);
return preset ? presetAvailable(kind, preset) : false;
}
function selectionStatus(kind: ExternalToolKind): string {
if (tools[kind].preset === "custom") {
return tools[kind].program.trim()
? (isGerman ? "Manuell konfiguriert" : "Manually configured")
: (isGerman ? "Programmpfad fehlt" : "Application path missing");
}
return selectionAvailable(kind)
? (isGerman ? "Installiert und verfügbar" : "Installed and available")
: (isGerman ? "Nicht automatisch erkannt" : "Not automatically detected");
}
function changePreset(kind: ExternalToolKind, id: string) {
if (id === "custom") {
tools[kind] = { ...tools[kind], preset: "custom" };
advancedOpen = true;
return;
}
tools[kind] = applyExternalToolPreset(kind, id, detectedTools);
}
function selectToolKind(kind: ExternalToolKind) {
activeToolKind = kind;
advancedOpen = tools[kind].preset === "custom";
}
function updateProgram(kind: ExternalToolKind, program: string) {
tools[kind] = { ...tools[kind], preset: "custom", program };
}
function updateArgs(kind: ExternalToolKind, value: string) {
tools[kind] = {
...tools[kind],
preset: "custom",
args: value.split("\n").map((arg) => arg.trim()).filter(Boolean),
};
}
async function browseProgram(kind: ExternalToolKind) {
const selected = await open({
title: isGerman ? `${toolLabel(kind)} auswählen` : `Choose ${toolLabel(kind)}`,
multiple: false,
directory: false,
});
if (typeof selected === "string") {
updateProgram(kind, selected);
advancedOpen = true;
}
} }
</script> </script>
<div class="dialog-backdrop" role="presentation"> <div class="dialog-backdrop" role="presentation">
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1"> <div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
<header class="dialog-header"> <header class="app-settings-head">
<div> <div class="app-settings-title">
<span class="eyebrow">Gitty</span> <span class="app-settings-mark"><Settings2 size={18} aria-hidden="true" /></span>
<h2 class="dialog-title">{isGerman ? "Einstellungen" : "Settings"}</h2> <div>
<h2>{isGerman ? "Einstellungen" : "Settings"}</h2>
<p>{isGerman ? "Gitty an deinen Workflow anpassen" : "Make Gitty fit your workflow"}</p>
</div>
</div> </div>
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"}> <button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Einstellungen schließen" : "Close settings"}>
<X size={18} aria-hidden="true" /> <X size={18} aria-hidden="true" />
</button> </button>
</header> </header>
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}> <form class="app-settings-shell" onsubmit={(event) => { event.preventDefault(); save(); }}>
<section class="settings-section"> <div class="app-settings-body">
<header> <nav class="settings-nav" aria-label={isGerman ? "Einstellungsbereiche" : "Settings sections"}>
<Settings size={16} aria-hidden="true" /> <button type="button" class:active={activePage === "general"} onclick={() => { activePage = "general"; }}>
<div> <SlidersHorizontal size={16} aria-hidden="true" />
<span class="eyebrow">{isGerman ? "Darstellung" : "Appearance"}</span> <span>
<h3>{isGerman ? "Farbschema" : "Theme"}</h3> <strong>{isGerman ? "Allgemein" : "General"}</strong>
</div> <small>{isGerman ? "Darstellung & Verhalten" : "Appearance & behavior"}</small>
</header> </span>
</button>
<button type="button" class:active={activePage === "tools"} onclick={() => { activePage = "tools"; }}>
<Wrench size={16} aria-hidden="true" />
<span>
<strong>{isGerman ? "Externe Tools" : "External tools"}</strong>
<small>{isGerman ? "Editor, Diff & Terminal" : "Editor, diff & terminal"}</small>
</span>
{#if !detectionUnavailable}<em>{detectedTools.length}</em>{/if}
</button>
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}> <div class="settings-nav-note">
<label class:active={selectedTheme === "system"}> <ShieldCheck size={15} aria-hidden="true" />
<input type="radio" bind:group={selectedTheme} value="system" /> <p>{isGerman ? "Tool-Argumente werden direkt und ohne zusätzliche Shell übergeben." : "Tool arguments are passed directly without an extra shell."}</p>
<span>System</span> </div>
</label> </nav>
<label class:active={selectedTheme === "light"}>
<input type="radio" bind:group={selectedTheme} value="light" /> <div class="settings-content">
<span>{isGerman ? "Hell" : "Light"}</span> {#if activePage === "general"}
</label> <div class="settings-page-head">
<label class:active={selectedTheme === "dark"}> <div>
<input type="radio" bind:group={selectedTheme} value="dark" /> <h3>{isGerman ? "Allgemein" : "General"}</h3>
<span>{isGerman ? "Dunkel" : "Dark"}</span> <p>{isGerman ? "Darstellung, Sprache und Hintergrundverhalten." : "Appearance, language, and background behavior."}</p>
</label> </div>
</div>
<div class="general-settings-grid">
<section class="general-setting-panel">
<header><Palette size={16} /><div><h4>{isGerman ? "Farbschema" : "Theme"}</h4><p>{isGerman ? "Passend zu deiner Umgebung." : "Match your environment."}</p></div></header>
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}>
<label class:active={selectedTheme === "system"}><input type="radio" bind:group={selectedTheme} value="system" /><span>System</span></label>
<label class:active={selectedTheme === "light"}><input type="radio" bind:group={selectedTheme} value="light" /><span>{isGerman ? "Hell" : "Light"}</span></label>
<label class:active={selectedTheme === "dark"}><input type="radio" bind:group={selectedTheme} value="dark" /><span>{isGerman ? "Dunkel" : "Dark"}</span></label>
</div>
</section>
<section class="general-setting-panel">
<header><Languages size={16} /><div><h4>{isGerman ? "Sprache" : "Language"}</h4><p>{isGerman ? "Sprache der Oberfläche." : "Language used by the interface."}</p></div></header>
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
<label class:active={selectedLanguage === "en"}><input type="radio" bind:group={selectedLanguage} value="en" /><span>EN · English</span></label>
<label class:active={selectedLanguage === "de"}><input type="radio" bind:group={selectedLanguage} value="de" /><span>DE · Deutsch</span></label>
</div>
</section>
<section class="general-setting-panel general-setting-wide">
<header><RefreshCw size={16} /><div><h4>{isGerman ? "Repository-Aktualisierung" : "Repository refresh"}</h4><p>{isGerman ? "Arbeitsbereich und Remotes aktuell halten." : "Keep the working tree and remotes current."}</p></div></header>
<label class="settings-switch-row">
<span><strong>{isGerman ? "Automatisch aktualisieren" : "Refresh automatically"}</strong><small>{isGerman ? "Branch-Status und Änderungen regelmäßig im Hintergrund prüfen." : "Periodically check branch state and working-tree changes."}</small></span>
<input type="checkbox" bind:checked={autoRefreshEnabled} />
</label>
</section>
<section class="general-setting-panel general-setting-wide">
<header><ShieldCheck size={16} /><div><h4>{isGerman ? "Datenschutz" : "Privacy"}</h4><p>{isGerman ? "Anonyme Produkt- und Fehlerdiagnose." : "Anonymous product and error diagnostics."}</p></div></header>
<label class="settings-switch-row">
<span><strong>{isGerman ? "Anonyme Analytics erlauben" : "Allow anonymous analytics"}</strong><small>{isGerman ? "Keine Pfade, Remotes, Branches, Diffs, Zugangsdaten oder Quelltexte." : "No paths, remotes, branches, diffs, credentials, or source code."}</small></span>
<input type="checkbox" bind:checked={analyticsEnabled} />
</label>
</section>
</div>
{:else}
<div class="settings-page-head tools-page-head">
<div>
<h3>{isGerman ? "Externe Tools" : "External tools"}</h3>
<p>
{detectionUnavailable
? (isGerman ? "Automatische Erkennung ist in dieser Umgebung nicht verfügbar." : "Automatic detection is unavailable in this environment.")
: (isGerman ? `${detectedTools.length} installierte Programme erkannt.` : `${detectedTools.length} installed applications detected.`)}
</p>
</div>
<button class="tool-rescan-button" type="button" onclick={onRefreshDetectedTools} disabled={detectionPending}>
<RotateCw class={detectionPending ? "spin" : ""} size={14} aria-hidden="true" />
{detectionPending ? (isGerman ? "Erkennung läuft…" : "Detecting…") : (isGerman ? "Neu erkennen" : "Detect again")}
</button>
</div>
<div class="tool-kind-tabs" role="tablist" aria-label={isGerman ? "Tool-Kategorie" : "Tool category"}>
{#each toolKinds as kind}
<button type="button" role="tab" aria-selected={activeToolKind === kind} class:active={activeToolKind === kind} onclick={() => selectToolKind(kind)}>
{#if kind === "editor"}<Code2 size={16} />
{:else if kind === "diff"}<GitCompare size={16} />
{:else if kind === "merge"}<GitMerge size={16} />
{:else if kind === "terminal"}<Terminal size={16} />
{:else}<FolderOpen size={16} />{/if}
<span>{toolLabel(kind)}</span>
<small class:available={selectionAvailable(kind)}></small>
</button>
{/each}
</div>
<section class="tool-config-panel" aria-label={`${toolLabel(activeToolKind)} ${isGerman ? "konfigurieren" : "configuration"}`}>
<div class="tool-config-summary">
<span class="tool-config-icon">
{#if activeToolKind === "editor"}<Code2 size={22} />
{:else if activeToolKind === "diff"}<GitCompare size={22} />
{:else if activeToolKind === "merge"}<GitMerge size={22} />
{:else if activeToolKind === "terminal"}<Terminal size={22} />
{:else}<FolderOpen size={22} />{/if}
</span>
<div>
<h4>{toolLabel(activeToolKind)}</h4>
<p>{toolDescription(activeToolKind)}</p>
</div>
<span class="tool-status" class:available={selectionAvailable(activeToolKind)}>
{#if selectionAvailable(activeToolKind)}<CheckCircle2 size={13} />{:else}<CircleDashed size={13} />{/if}
{selectionStatus(activeToolKind)}
</span>
</div>
{#if activeToolKind === "diff" || activeToolKind === "merge"}
{@const openModeKind = activeToolKind as "diff" | "merge"}
<div class="tool-route" aria-label={isGerman ? "Aktuelle Standardansicht" : "Current default view"}>
<span>{isGerman ? "Standard" : "Default"}</span><ChevronRight size={15} aria-hidden="true" />
<strong>{openMode(openModeKind) === "gitty" ? (isGerman ? "Gitty · integriert" : "Gitty · built in") : selectedToolName(activeToolKind)}</strong>
</div>
<fieldset class="tool-open-mode">
<legend>{isGerman ? "Beim Öffnen verwenden" : "Use when opening"}</legend>
<div>
<button type="button" class:active={openMode(openModeKind) === "gitty"} aria-pressed={openMode(openModeKind) === "gitty"} onclick={() => setOpenMode(openModeKind, "gitty")}>
<span>Gitty</span><small>{isGerman ? "Integrierte Ansicht" : "Built-in view"}</small>
</button>
<button type="button" class:active={openMode(openModeKind) === "external"} aria-pressed={openMode(openModeKind) === "external"} onclick={() => setOpenMode(openModeKind, "external")}>
<span>{selectedToolName(activeToolKind)}</span><small>{isGerman ? "Externes Programm" : "External application"}</small>
</button>
</div>
</fieldset>
{:else}
<div class="tool-route" aria-label={isGerman ? "Aktuelle Standardzuordnung" : "Current default mapping"}>
<span>Gitty</span><ChevronRight size={15} aria-hidden="true" /><strong>{selectedToolName(activeToolKind)}</strong>
</div>
{/if}
<label class="tool-default-field">
<span>{isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}</span>
<select value={tools[activeToolKind].preset} onchange={(event) => changePreset(activeToolKind, event.currentTarget.value)} aria-label={isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}>
{#if availablePresets(activeToolKind).length > 0}
<optgroup label={isGerman ? "Installiert" : "Installed"}>
{#each availablePresets(activeToolKind) as preset}<option value={preset.id}> {preset.label}</option>{/each}
</optgroup>
{/if}
<optgroup label={isGerman ? "Weitere unterstützte Programme" : "Other supported applications"}>
{#each otherPresets(activeToolKind) as preset}<option value={preset.id}>{preset.label}</option>{/each}
</optgroup>
<option value="custom">{isGerman ? "Eigenes Programm auswählen…" : "Choose a custom application…"}</option>
</select>
<small>{isGerman ? "Diese Auswahl wird gespeichert und für alle passenden Aktionen verwendet." : "This selection is saved and used for every matching action."}</small>
</label>
<div class="tool-usage-callout">
<span>{isGerman ? "So öffnest du es" : "How to open it"}</span>
<p>{toolUsage(activeToolKind)}</p>
</div>
<button class="tool-advanced-toggle" type="button" aria-expanded={advancedOpen} onclick={() => { advancedOpen = !advancedOpen; }}>
<span>{isGerman ? "Programmpfad und Argumente" : "Application path and arguments"}</span>
{#if advancedOpen}<ChevronDown size={15} />{:else}<ChevronRight size={15} />{/if}
</button>
{#if advancedOpen}
<div class="tool-advanced-panel">
<label>
<span>{isGerman ? "Programmpfad" : "Application path"}</span>
<div class="tool-program-row">
<input value={tools[activeToolKind].program} oninput={(event) => updateProgram(activeToolKind, event.currentTarget.value)} spellcheck="false" />
<button type="button" onclick={() => browseProgram(activeToolKind)} title={isGerman ? "Programm auswählen" : "Choose application"} aria-label={isGerman ? "Programm auswählen" : "Choose application"}><FolderOpen size={15} /></button>
</div>
</label>
<label>
<span>{isGerman ? "Argumente · eine Zeile pro Argument" : "Arguments · one per line"}</span>
<textarea value={tools[activeToolKind].args.join("\n")} oninput={(event) => updateArgs(activeToolKind, event.currentTarget.value)} spellcheck="false"></textarea>
</label>
<p class="tool-placeholders">
<span>{isGerman ? "Verfügbare Platzhalter" : "Available placeholders"}</span>
<code>{"{repo}"}</code><code>{"{file}"}</code><code>{"{parent}"}</code><code>{"{left}"}</code><code>{"{right}"}</code><code>{"{base}"}</code><code>{"{ours}"}</code><code>{"{theirs}"}</code><code>{"{result}"}</code>
</p>
</div>
{/if}
</section>
{/if}
</div> </div>
</section>
<section class="settings-section">
<header>
<RefreshCw size={16} aria-hidden="true" />
<div>
<span class="eyebrow">Repository</span>
<h3>{isGerman ? "Automatische Aktualisierung" : "Auto refresh"}</h3>
</div>
</header>
<label class="settings-toggle-row">
<input type="checkbox" bind:checked={autoRefreshEnabled} />
<span>
<strong>{isGerman ? "Repositories automatisch aktualisieren" : "Refresh repositories automatically"}</strong>
<small>{isGerman ? "Aktualisiert Arbeitsbereich, Branch-Status und Remotes regelmäßig im Hintergrund." : "Periodically refreshes the working tree, branch status, and remotes in the background."}</small>
</span>
</label>
</section>
<section class="settings-section">
<header>
<Languages size={16} aria-hidden="true" />
<div>
<span class="eyebrow">{isGerman ? "Sprache" : "Language"}</span>
<h3>{isGerman ? "App-Sprache" : "App language"}</h3>
</div>
</header>
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
<label class:active={selectedLanguage === "en"}>
<input type="radio" bind:group={selectedLanguage} value="en" />
<span>EN · English</span>
</label>
<label class:active={selectedLanguage === "de"}>
<input type="radio" bind:group={selectedLanguage} value="de" />
<span>DE · Deutsch</span>
</label>
</div>
</section>
<section class="settings-section">
<header>
<Settings size={16} aria-hidden="true" />
<div>
<span class="eyebrow">Analytics</span>
<h3>{isGerman ? "Anonyme Nutzungsanalyse" : "Anonymous usage analytics"}</h3>
</div>
</header>
<label class="settings-toggle-row">
<input type="checkbox" bind:checked={analyticsEnabled} />
<span>
<strong>{isGerman ? "Anonyme Analytics und Fehlerberichte erlauben" : "Allow anonymous analytics and error reports"}</strong>
<small>{isGerman ? "Es werden keine Repository-Pfade, Remotes, Branches, Commit-Nachrichten, Dateinamen, Diffs, Zugangsdaten oder Code übertragen." : "No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent."}</small>
</span>
</label>
</section>
<div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="submit">
<Check size={16} aria-hidden="true" />
{isGerman ? "Speichern" : "Save"}
</button>
</div> </div>
<footer class="app-settings-footer">
<span>{isGerman ? "Änderungen werden erst beim Speichern übernommen." : "Changes are applied only after saving."}</span>
<div>
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="submit"><Check size={16} aria-hidden="true" />{isGerman ? "Änderungen speichern" : "Save changes"}</button>
</div>
</footer>
</form> </form>
</div> </div>
</div> </div>
<style>
.app-settings-dialog { display: grid; grid-template-rows: auto minmax(0, 1fr); width: min(920px, calc(100vw - 32px)); height: min(720px, calc(100vh - 32px)); overflow: hidden; }
.app-settings-head { display: flex; align-items: center; justify-content: space-between; min-height: 70px; padding: 14px 18px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.app-settings-title { display: flex; align-items: center; gap: 12px; min-width: 0; }
.app-settings-mark { display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, transparent); }
.app-settings-title h2, .settings-page-head h3, .tool-config-summary h4, .general-setting-panel h4 { margin: 0; color: var(--color-ink); }
.app-settings-title h2 { font-size: 18px; line-height: 1.2; }
.app-settings-title p, .settings-page-head p, .tool-config-summary p, .general-setting-panel p { margin: 0; color: var(--color-ink-dim); }
.app-settings-title p { margin-top: 3px; font-size: 11px; }
.app-settings-shell { display: grid; min-height: 0; grid-template-rows: minmax(0, 1fr) auto; }
.app-settings-body { display: grid; min-height: 0; grid-template-columns: 205px minmax(0, 1fr); }
.settings-nav { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 14px 12px; border-right: 1px solid var(--color-border-subtle); background: color-mix(in srgb, var(--app-dialog-chrome) 72%, var(--app-dialog-bg)); }
.settings-nav > button { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; min-height: 50px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; color: var(--color-ink-dim); background: transparent; text-align: left; }
.settings-nav > button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
.settings-nav > button.active { border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); }
.settings-nav button > :global(svg) { color: var(--color-ink-muted); }
.settings-nav button.active > :global(svg) { color: var(--color-accent); }
.settings-nav button span { display: grid; min-width: 0; gap: 2px; }
.settings-nav button strong { font-size: 12px; }
.settings-nav button small { overflow: hidden; color: var(--color-ink-faint); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; }
.settings-nav button em { display: grid; place-items: center; min-width: 21px; height: 20px; padding-inline: 5px; border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 12%, transparent); font-size: 9px; font-style: normal; font-weight: 800; }
.settings-nav-note { display: flex; align-items: flex-start; gap: 8px; margin-top: auto; padding: 10px; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-faint); }
.settings-nav-note :global(svg) { flex: 0 0 auto; margin-top: 1px; color: var(--color-success); }
.settings-nav-note p { margin: 0; font-size: 9.5px; line-height: 1.45; }
.settings-content { min-width: 0; overflow: auto; padding: 18px 20px 22px; }
.settings-page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.settings-page-head h3 { font-size: 18px; }
.settings-page-head p { margin-top: 4px; font-size: 11px; line-height: 1.45; }
.tool-rescan-button { display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; min-height: 30px; padding: 0 10px; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink-muted); background: var(--color-surface-raised); font-size: 10px; font-weight: 750; }
.tool-rescan-button:hover:not(:disabled) { color: var(--color-ink); border-color: var(--color-border-input); background: var(--color-surface-hover); }
.tool-kind-tabs { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; margin-bottom: 14px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--app-settings-row-bg); }
.tool-kind-tabs button { position: relative; display: flex; align-items: center; justify-content: center; gap: 7px; min-width: 0; height: 38px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; font-size: 10.5px; font-weight: 750; }
.tool-kind-tabs button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
.tool-kind-tabs button.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-raised); box-shadow: 0 2px 8px rgba(0,0,0,.12); }
.tool-kind-tabs button.active :global(svg) { color: var(--color-accent); }
.tool-kind-tabs button small { position: absolute; top: 5px; right: 6px; width: 5px; height: 5px; border-radius: 50%; background: var(--color-ink-faint); }
.tool-kind-tabs button small.available { background: var(--color-success); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-success) 15%, transparent); }
.tool-config-panel { display: grid; gap: 14px; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 12px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
.tool-config-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; }
.tool-config-icon { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid color-mix(in srgb, var(--color-accent) 25%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 8%, transparent); }
.tool-config-summary h4 { font-size: 14px; }
.tool-config-summary p { margin-top: 3px; font-size: 10.5px; line-height: 1.4; }
.tool-status { display: inline-flex; align-items: center; gap: 5px; padding: 5px 7px; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-ink-faint); font-size: 9px; font-weight: 750; }
.tool-status.available { border-color: color-mix(in srgb, var(--color-success) 24%, var(--color-border)); color: var(--color-success); background: color-mix(in srgb, var(--color-success) 6%, transparent); }
.tool-route { display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 7px 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; color: var(--color-ink-faint); background: var(--color-surface-raised); font-size: 10.5px; }
.tool-route strong { color: var(--color-ink); }
.tool-open-mode { display: grid; gap: 6px; min-width: 0; margin: 0; padding: 0; border: 0; }
.tool-open-mode legend { margin-bottom: 6px; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
.tool-open-mode > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--color-surface-raised); }
.tool-open-mode button { display: grid; justify-items: start; gap: 2px; min-width: 0; min-height: 46px; padding: 7px 10px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; text-align: left; }
.tool-open-mode button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
.tool-open-mode button.active { border-color: color-mix(in srgb, var(--color-accent) 38%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-hover)); box-shadow: inset 2px 0 0 var(--color-accent); }
.tool-open-mode button span { max-width: 100%; overflow: hidden; font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
.tool-open-mode button small { color: var(--color-ink-faint); font-size: 9px; font-weight: 550; }
.tool-default-field, .tool-advanced-panel label { display: grid; gap: 6px; min-width: 0; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
.tool-default-field select, .tool-advanced-panel input, .tool-advanced-panel textarea { width: 100%; min-width: 0; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink); background: var(--color-surface-raised); font: inherit; }
.tool-default-field select { height: 38px; padding: 0 11px; font-size: 12px; font-weight: 700; }
.tool-default-field small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; }
.tool-usage-callout { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 10px; padding: 10px 11px; border-left: 2px solid var(--color-accent); border-radius: 0 7px 7px 0; background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
.tool-usage-callout span { color: var(--color-accent); font-size: 9.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
.tool-usage-callout p { margin: 0; color: var(--color-ink-muted); font-size: 10.5px; line-height: 1.45; }
.tool-advanced-toggle { display: flex; align-items: center; justify-content: space-between; min-height: 32px; padding: 0; border: 0; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-dim); background: transparent; font-size: 10.5px; font-weight: 750; }
.tool-advanced-toggle:hover { color: var(--color-ink); }
.tool-advanced-panel { display: grid; gap: 11px; padding-top: 2px; }
.tool-program-row { display: grid; grid-template-columns: minmax(0, 1fr) 34px; gap: 6px; }
.tool-advanced-panel input { height: 34px; padding: 0 9px; font-family: var(--font-mono); font-size: 10.5px; }
.tool-advanced-panel textarea { min-height: 80px; padding: 8px 9px; resize: vertical; font-family: var(--font-mono); font-size: 10.5px; line-height: 1.45; }
.tool-program-row button { display: grid; place-items: center; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink-muted); background: var(--color-surface-raised); }
.tool-program-row button:hover { color: var(--color-ink); border-color: var(--color-border-input); background: var(--color-surface-hover); }
.tool-placeholders { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin: 0; color: var(--color-ink-faint); font-size: 9px; }
.tool-placeholders span { margin-right: 3px; }
.tool-placeholders code { padding: 2px 4px; border-radius: 4px; color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 9%, transparent); }
.general-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.general-setting-panel { display: grid; align-content: start; gap: 14px; min-width: 0; padding: 14px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--app-settings-row-bg); }
.general-setting-panel.general-setting-wide { grid-column: 1 / -1; }
.general-setting-panel > header { display: flex; align-items: flex-start; gap: 9px; }
.general-setting-panel > header > :global(svg) { flex: 0 0 auto; margin-top: 1px; color: var(--color-accent); }
.general-setting-panel h4 { font-size: 12.5px; }
.general-setting-panel p { margin-top: 3px; font-size: 9.5px; }
.settings-segmented { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 3px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
.settings-segmented.settings-language { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.settings-segmented label { display: flex; align-items: center; justify-content: center; min-height: 31px; border: 1px solid transparent; border-radius: 6px; color: var(--color-ink-dim); font-size: 10.5px; font-weight: 750; }
.settings-segmented label.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-hover); }
.settings-segmented input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.settings-switch-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; }
.settings-switch-row span { display: grid; gap: 3px; }
.settings-switch-row strong { color: var(--color-ink); font-size: 11px; }
.settings-switch-row small { color: var(--color-ink-dim); font-size: 9.5px; line-height: 1.4; }
.settings-switch-row input { width: 32px; height: 18px; accent-color: var(--color-accent); }
.app-settings-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 62px; padding: 11px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.app-settings-footer > span { color: var(--color-ink-faint); font-size: 9.5px; }
.app-settings-footer > div { display: flex; gap: 8px; }
.app-settings-footer button { min-height: 32px; }
@media (max-width: 760px) {
.app-settings-dialog { height: min(760px, calc(100vh - 20px)); width: min(660px, calc(100vw - 20px)); }
.app-settings-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
.settings-nav { flex-direction: row; padding: 8px 10px; border-right: 0; border-bottom: 1px solid var(--color-border-subtle); }
.settings-nav > button { width: auto; min-width: 0; flex: 1 1 0; min-height: 42px; }
.settings-nav-note { display: none; }
.settings-content { padding: 14px; }
.tool-kind-tabs { grid-template-columns: repeat(5, minmax(42px, 1fr)); overflow-x: auto; }
.tool-kind-tabs button { height: 40px; }
.tool-kind-tabs button span { display: none; }
.tool-config-summary { grid-template-columns: auto minmax(0, 1fr); }
.tool-status { grid-column: 1 / -1; justify-self: start; }
.app-settings-footer > span { display: none; }
.app-settings-footer { justify-content: flex-end; }
}
@media (max-width: 520px) {
.app-settings-head { min-height: 58px; padding: 10px 12px; }
.app-settings-mark { width: 34px; height: 34px; }
.settings-nav button small, .settings-nav button em { display: none; }
.settings-nav > button { grid-template-columns: auto minmax(0, 1fr); }
.settings-page-head { align-items: stretch; flex-direction: column; }
.tool-rescan-button { align-self: flex-start; }
.general-settings-grid { grid-template-columns: 1fr; }
.general-setting-panel.general-setting-wide { grid-column: auto; }
.tool-config-panel { padding: 13px; }
.tool-usage-callout { grid-template-columns: 1fr; gap: 4px; }
}
</style>
+303
View File
@@ -0,0 +1,303 @@
<script lang="ts">
import {
Download,
GitCommitHorizontal,
Info,
LoaderCircle,
Save,
StickyNote,
Trash2,
Upload,
X,
} from "@lucide/svelte";
import type { AppLanguage, GitCommit, GitRemote } from "../types";
interface Props {
commit: GitCommit;
note: string;
remotes: GitRemote[];
preferredRemote: string;
language: AppLanguage;
isLoading: boolean;
isBusy: boolean;
error: string;
status: string;
onSave: (note: string) => void | Promise<void>;
onDelete: () => void | Promise<void>;
onFetch: (remote: string) => void | Promise<void>;
onPush: (remote: string) => void | Promise<void>;
onClose: () => void;
}
let {
commit,
note = "",
remotes = [],
preferredRemote = "",
language = "en",
isLoading = false,
isBusy = false,
error = "",
status = "",
onSave = () => {},
onDelete = () => {},
onFetch = () => {},
onPush = () => {},
onClose = () => {},
}: Props = $props();
let draft = $state("");
let selectedRemote = $state("");
let deleteConfirmOpen = $state(false);
let lastLoadedKey = $state("");
let hasChanges = $derived(draft !== note);
let canSave = $derived(!isLoading && !isBusy && draft.trim().length > 0 && hasChanges);
const text = $derived(language === "de" ? {
eyebrow: "Interne Git-Notiz",
title: "Commit-Notiz",
commit: "Commit",
explanation: "Die Notiz wird separat unter refs/notes/commits gespeichert. Hash und Commit-Historie bleiben unverändert.",
label: "Notiz",
placeholder: "Zum Beispiel Review-Hinweise, Ticket-Kontext, Build-ID oder Freigabestatus …",
loading: "Notiz wird geladen …",
syncTitle: "Mit Remote synchronisieren",
syncHelp: "Git Notes reisen nicht automatisch mit Branches. Lade sie gezielt vom Remote oder sende deine lokalen Notizen dorthin.",
noRemotes: "Für dieses Repository ist kein Remote eingerichtet.",
remote: "Remote",
fetch: "Vom Remote laden",
push: "Zum Remote senden",
delete: "Notiz löschen",
deleteQuestion: "Diese Notiz wirklich löschen?",
deleteConfirm: "Ja, löschen",
cancel: "Abbrechen",
close: "Schließen",
save: "Notiz speichern",
characters: "Zeichen",
} : {
eyebrow: "Internal Git note",
title: "Commit note",
commit: "Commit",
explanation: "The note is stored separately under refs/notes/commits. The commit hash and history stay unchanged.",
label: "Note",
placeholder: "For example review findings, ticket context, a build ID, or approval status…",
loading: "Loading note…",
syncTitle: "Sync with a remote",
syncHelp: "Git Notes do not travel with branches automatically. Fetch them explicitly from a remote or push your local notes there.",
noRemotes: "No remote is configured for this repository.",
remote: "Remote",
fetch: "Fetch from remote",
push: "Push to remote",
delete: "Delete note",
deleteQuestion: "Delete this note?",
deleteConfirm: "Yes, delete",
cancel: "Cancel",
close: "Close",
save: "Save note",
characters: "characters",
});
$effect(() => {
const loadedKey = `${commit.hash}:${note}`;
if (loadedKey === lastLoadedKey) return;
draft = note;
lastLoadedKey = loadedKey;
deleteConfirmOpen = false;
});
$effect(() => {
if (selectedRemote && remotes.some((remote) => remote.name === selectedRemote)) return;
selectedRemote = remotes.some((remote) => remote.name === preferredRemote)
? preferredRemote
: (remotes[0]?.name ?? "");
});
function submit(event: SubmitEvent) {
event.preventDefault();
if (!canSave) return;
void onSave(draft);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && !isBusy) onClose();
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
if (canSave) void onSave(draft);
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="dialog-backdrop app-chrome-backdrop commit-note-backdrop" role="presentation">
<div class="commit-note-dialog" role="dialog" aria-modal="true" aria-labelledby="commit-note-title" tabindex="-1">
<header class="commit-note-head">
<div class="commit-note-heading">
<span class="commit-note-icon"><StickyNote size={20} aria-hidden="true" /></span>
<div>
<span class="eyebrow">{text.eyebrow}</span>
<h2 id="commit-note-title">{text.title}</h2>
</div>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={text.close} aria-label={text.close}>
<X size={18} aria-hidden="true" />
</button>
</header>
<div class="commit-note-body">
<div class="commit-note-target">
<span class="commit-target-icon"><GitCommitHorizontal size={18} aria-hidden="true" /></span>
<div>
<span>{text.commit} <code>{commit.short_hash}</code></span>
<strong title={commit.summary}>{commit.summary}</strong>
<small>{commit.author_name} · {new Date(commit.date).toLocaleString()}</small>
</div>
</div>
<div class="commit-note-info">
<Info size={16} aria-hidden="true" />
<span>{text.explanation}</span>
</div>
{#if error}
<div class="commit-note-message error" role="alert">{error}</div>
{:else if status}
<div class="commit-note-message success" role="status">{status}</div>
{/if}
<form class="commit-note-form" onsubmit={submit}>
<label for="commit-note-editor">
<span>{text.label}</span>
<small>{draft.length.toLocaleString()} {text.characters}</small>
</label>
<div class="commit-note-editor-wrap">
{#if isLoading}
<div class="commit-note-loading"><LoaderCircle class="spin" size={18} aria-hidden="true" />{text.loading}</div>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<textarea
id="commit-note-editor"
bind:value={draft}
placeholder={text.placeholder}
disabled={isLoading || isBusy}
maxlength={262144}
spellcheck="true"
autofocus
></textarea>
</div>
</form>
<section class="commit-note-sync" aria-labelledby="commit-note-sync-title">
<div class="commit-note-sync-copy">
<strong id="commit-note-sync-title">{text.syncTitle}</strong>
<span>{text.syncHelp}</span>
</div>
{#if remotes.length > 0}
<div class="commit-note-sync-controls">
<label>
<span>{text.remote}</span>
<select bind:value={selectedRemote} disabled={isBusy}>
{#each remotes as remote (remote.name)}
<option value={remote.name}>{remote.name}</option>
{/each}
</select>
</label>
<button type="button" onclick={() => onFetch(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Download size={15} aria-hidden="true" />{/if}
{text.fetch}
</button>
<button type="button" onclick={() => onPush(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Upload size={15} aria-hidden="true" />{/if}
{text.push}
</button>
</div>
{:else}
<span class="commit-note-no-remotes">{text.noRemotes}</span>
{/if}
</section>
</div>
<footer class="commit-note-footer">
<div class="commit-note-delete">
{#if deleteConfirmOpen}
<span>{text.deleteQuestion}</span>
<button class="btn-danger" type="button" onclick={() => onDelete()} disabled={isBusy}>{text.deleteConfirm}</button>
<button type="button" onclick={() => { deleteConfirmOpen = false; }} disabled={isBusy}>{text.cancel}</button>
{:else}
<button type="button" class="commit-note-delete-trigger" onclick={() => { deleteConfirmOpen = true; }} disabled={isBusy || isLoading || !note}>
<Trash2 size={15} aria-hidden="true" />{text.delete}
</button>
{/if}
</div>
<div class="commit-note-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{text.close}</button>
<button class="btn-primary" type="button" onclick={() => onSave(draft)} disabled={!canSave}>
{#if isBusy}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Save size={16} aria-hidden="true" />{/if}
{text.save}
</button>
</div>
</footer>
</div>
</div>
<style>
.commit-note-backdrop { z-index: 72; }
.commit-note-dialog {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(720px, calc(100vw - 32px));
max-height: min(780px, calc(100vh - 74px));
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 14px;
background: var(--app-dialog-bg);
box-shadow: var(--app-dialog-shadow);
}
.commit-note-head,
.commit-note-footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; background: var(--app-dialog-chrome); }
.commit-note-head { padding: 16px 18px; border-bottom: 1px solid var(--color-border-subtle); }
.commit-note-heading { display: flex; align-items: center; gap: 12px; min-width: 0; }
.commit-note-heading h2 { margin: 2px 0 0; color: var(--color-ink); font-size: 18px; line-height: 1.2; }
.commit-note-icon,
.commit-target-icon { display: grid; flex: 0 0 auto; place-items: center; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
.commit-note-icon { width: 40px; height: 40px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 11px; }
.commit-note-body { display: grid; align-content: start; gap: 13px; min-height: 0; padding: 16px 18px; overflow: auto; }
.commit-note-target { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 11px; padding: 11px 12px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--color-surface-raised); }
.commit-target-icon { width: 34px; height: 34px; border-radius: 8px; }
.commit-note-target > div { display: grid; gap: 3px; min-width: 0; }
.commit-note-target span,
.commit-note-target small { color: var(--color-ink-faint); font-size: 10.5px; }
.commit-note-target code { margin-left: 4px; color: var(--color-accent); font: 700 10.5px var(--font-mono); }
.commit-note-target strong { overflow: hidden; color: var(--color-ink); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.commit-note-info { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 8px; padding: 10px 11px; border: 1px solid color-mix(in srgb, var(--color-accent) 22%, var(--color-border-subtle)); border-radius: 9px; color: var(--color-ink-muted); background: color-mix(in srgb, var(--color-accent) 6%, transparent); font-size: 11px; line-height: 1.45; }
.commit-note-info :global(svg) { margin-top: 1px; color: var(--color-accent); }
.commit-note-message { padding: 9px 11px; border: 1px solid; border-radius: 8px; font-size: 11px; font-weight: 700; }
.commit-note-message.error { border-color: rgba(232, 96, 96, .32); color: #ef8888; background: rgba(232, 96, 96, .08); }
.commit-note-message.success { border-color: color-mix(in srgb, #5bd18a 34%, var(--color-border)); color: #70dc99; background: color-mix(in srgb, #5bd18a 8%, transparent); }
.commit-note-form { display: grid; gap: 6px; }
.commit-note-form > label { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--color-ink-muted); font-size: 11px; font-weight: 800; }
.commit-note-form > label small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 600; }
.commit-note-editor-wrap { position: relative; }
.commit-note-editor-wrap textarea { min-height: 164px; max-height: 320px; resize: vertical; font-size: 12.5px; line-height: 1.55; }
.commit-note-loading { position: absolute; inset: 0; z-index: 1; display: flex; align-items: center; justify-content: center; gap: 8px; border-radius: 8px; color: var(--color-ink-muted); background: color-mix(in srgb, var(--app-input-bg) 92%, transparent); font-size: 11px; font-weight: 700; }
.commit-note-sync { display: grid; gap: 11px; padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--color-surface-raised); }
.commit-note-sync-copy { display: grid; gap: 4px; }
.commit-note-sync-copy strong { color: var(--color-ink); font-size: 12px; }
.commit-note-sync-copy span,
.commit-note-no-remotes { color: var(--color-ink-faint); font-size: 10.5px; line-height: 1.45; }
.commit-note-sync-controls { display: grid; grid-template-columns: minmax(130px, 1fr) auto auto; align-items: end; gap: 8px; }
.commit-note-sync-controls label { display: grid; gap: 5px; color: var(--color-ink-muted); font-size: 10px; font-weight: 800; }
.commit-note-sync-controls button { min-height: 34px; font-size: 10.5px; font-weight: 750; }
.commit-note-footer { min-height: 66px; padding: 12px 18px; border-top: 1px solid var(--color-border-subtle); }
.commit-note-delete,
.commit-note-actions { display: flex; align-items: center; gap: 8px; }
.commit-note-delete > span { color: var(--color-ink-muted); font-size: 10.5px; font-weight: 700; }
.commit-note-delete-trigger { border-color: transparent; color: #e87a7a; background: transparent; }
.commit-note-delete-trigger:hover:not(:disabled) { border-color: rgba(232, 96, 96, .24); color: #ff9a9a; background: rgba(232, 96, 96, .08); }
@media (max-width: 660px) {
.commit-note-sync-controls { grid-template-columns: 1fr; }
.commit-note-footer { align-items: stretch; flex-direction: column; }
.commit-note-delete,
.commit-note-actions { justify-content: flex-end; }
}
</style>
+69 -1
View File
@@ -12,6 +12,7 @@
FileImage, FileImage,
FileJson, FileJson,
FileSearch, FileSearch,
GitCompare,
FileSpreadsheet, FileSpreadsheet,
FileText, FileText,
FileType, FileType,
@@ -33,11 +34,16 @@
selectedExplorerKind: ExplorerNodeKind; selectedExplorerKind: ExplorerNodeKind;
hasRepository: boolean; hasRepository: boolean;
isBusy: boolean; isBusy: boolean;
language?: "en" | "de";
editorName?: string;
diffName?: string;
onToggleFolder: (node: ExplorerNode) => void; onToggleFolder: (node: ExplorerNode) => void;
onExpandAllFolders: () => void; onExpandAllFolders: () => void;
onCollapseAllFolders: () => void; onCollapseAllFolders: () => void;
onSelectNode: (node: ExplorerNode) => void; onSelectNode: (node: ExplorerNode) => void;
onOpenFile: (node: ExplorerNode) => void; onOpenFile: (node: ExplorerNode) => void;
onOpenInEditor: (node: ExplorerNode) => void;
onExternalDiff: (node: ExplorerNode) => void;
onFileHistory: (node: ExplorerNode) => void; onFileHistory: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void; onBlame: (node: ExplorerNode) => void;
collapsed?: boolean; collapsed?: boolean;
@@ -51,11 +57,16 @@
selectedExplorerKind = "file", selectedExplorerKind = "file",
hasRepository = false, hasRepository = false,
isBusy = false, isBusy = false,
language = "en",
editorName = "Editor",
diffName = "diff tool",
onToggleFolder = () => {}, onToggleFolder = () => {},
onExpandAllFolders = () => {}, onExpandAllFolders = () => {},
onCollapseAllFolders = () => {}, onCollapseAllFolders = () => {},
onSelectNode = () => {}, onSelectNode = () => {},
onOpenFile = () => {}, onOpenFile = () => {},
onOpenInEditor = () => {},
onExternalDiff = () => {},
onFileHistory = () => {}, onFileHistory = () => {},
onBlame = () => {}, onBlame = () => {},
collapsed = false, collapsed = false,
@@ -65,6 +76,7 @@
let contextNode = $state<ExplorerNode | null>(null); let contextNode = $state<ExplorerNode | null>(null);
let contextMenuX = $state(0); let contextMenuX = $state(0);
let contextMenuY = $state(0); let contextMenuY = $state(0);
const isGerman = $derived(language === "de");
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null { function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
if (!next) return current; if (!next) return current;
@@ -178,7 +190,7 @@
contextNode = node; contextNode = node;
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192)); contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 132)); contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 220));
} }
function closeFileContextMenu() { function closeFileContextMenu() {
@@ -192,6 +204,20 @@
onOpenFile(node); onOpenFile(node);
} }
function openContextFileInEditor() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onOpenInEditor(node);
}
function openContextExternalDiff() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onExternalDiff(node);
}
function openContextBlame() { function openContextBlame() {
const node = contextNode; const node = contextNode;
if (!node || node.kind !== "file") return; if (!node || node.kind !== "file") return;
@@ -213,6 +239,11 @@
let explorerTree = $derived(buildExplorerTree(repoFiles)); let explorerTree = $derived(buildExplorerTree(repoFiles));
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths)); let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder")); let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
let selectedFileNode = $derived(
selectedExplorerKind === "file"
? visibleNodes.find((node) => node.kind === "file" && node.path === selectedExplorerPath) ?? null
: null,
);
</script> </script>
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} /> <svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
@@ -224,6 +255,35 @@
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2> <h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2>
</div> </div>
<div class="explorer-head-actions"> <div class="explorer-head-actions">
<button
class="explorer-bulk-button explorer-tool-action"
type="button"
onclick={() => selectedFileNode && onOpenInEditor(selectedFileNode)}
disabled={isBusy || !selectedFileNode || selectedFileNode.status === "deleted"}
title={isGerman
? `Ausgewählte Datei in ${editorName} öffnen`
: `Open selected file in ${editorName}`}
aria-label={isGerman
? `Ausgewählte Datei in ${editorName} öffnen`
: `Open selected file in ${editorName}`}
>
<FileCode size={14} aria-hidden="true" />
</button>
<button
class="explorer-bulk-button explorer-tool-action"
type="button"
onclick={() => selectedFileNode && onExternalDiff(selectedFileNode)}
disabled={isBusy || !selectedFileNode || !selectedFileNode.tracked || selectedFileNode.status === "deleted"}
title={isGerman
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
: `Compare selected file with HEAD in ${diffName}`}
aria-label={isGerman
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
: `Compare selected file with HEAD in ${diffName}`}
>
<GitCompare size={14} aria-hidden="true" />
</button>
<span class="explorer-action-divider" aria-hidden="true"></span>
<button <button
class="explorer-bulk-button" class="explorer-bulk-button"
type="button" type="button"
@@ -366,6 +426,14 @@
tabindex="-1" tabindex="-1"
aria-label={`Actions for ${contextNode.path}`} aria-label={`Actions for ${contextNode.path}`}
> >
<button type="button" role="menuitem" onclick={openContextFileInEditor} disabled={contextNode.status === "deleted"}>
<FileCode size={14} aria-hidden="true" />
{isGerman ? `In ${editorName} öffnen` : `Open in ${editorName}`}
</button>
<button type="button" role="menuitem" onclick={openContextExternalDiff} disabled={!contextNode.tracked || contextNode.status === "deleted"}>
<GitCompare size={14} aria-hidden="true" />
{isGerman ? `Mit HEAD in ${diffName} vergleichen` : `Compare with HEAD in ${diffName}`}
</button>
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
+31 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, X } from "@lucide/svelte"; import { 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
+18 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Check, LoaderCircle, MousePointer2, X } from "@lucide/svelte"; import { Check, ExternalLink, FileDiff, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
import type { GitFileStatus, PatchApplyAction } from "../types"; import type { GitFileStatus, PatchApplyAction } from "../types";
type PatchLineKind = "context" | "add" | "delete" | "meta"; type PatchLineKind = "context" | "add" | "delete" | "meta";
@@ -34,9 +34,12 @@
isBusy: boolean; isBusy: boolean;
isLoading: boolean; isLoading: boolean;
error: string; error: string;
language?: "en" | "de";
diffName?: string;
onClose: () => void; onClose: () => void;
onRefresh: () => void | Promise<void>; onRefresh: () => void | Promise<void>;
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>; onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
onExternalDiff: () => void | Promise<void>;
} }
let { let {
@@ -46,11 +49,16 @@
isBusy = false, isBusy = false,
isLoading = false, isLoading = false,
error = "", error = "",
language = "en",
diffName = "diff tool",
onClose = () => {}, onClose = () => {},
onRefresh = () => {}, onRefresh = () => {},
onApply = () => {}, onApply = () => {},
onExternalDiff = () => {},
}: Props = $props(); }: Props = $props();
const isGerman = $derived(language === "de");
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false }); let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
let patchScroll = $state<HTMLDivElement | null>(null); let patchScroll = $state<HTMLDivElement | null>(null);
let selectedLineIds = $state<Set<string>>(new Set()); let selectedLineIds = $state<Set<string>>(new Set());
@@ -270,6 +278,15 @@
<p class="dialog-title" title={displayPath}>{displayPath}</p> <p class="dialog-title" title={displayPath}>{displayPath}</p>
</div> </div>
<div class="dialog-header-actions"> <div class="dialog-header-actions">
<div class="tool-surface-choice" aria-label={isGerman ? "Diff öffnen mit" : "Open diff with"}>
<span>{isGerman ? "Öffnen mit" : "Open with"}</span>
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty anzeigen" : "Show in Gitty"}>
<FileDiff size={13} aria-hidden="true" />Gitty
</button>
<button type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={isGerman ? `In ${diffName} öffnen` : `Open in ${diffName}`}>
<ExternalLink size={13} aria-hidden="true" /><span>{diffName}</span>
</button>
</div>
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button> <button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close"> <button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" /> <X size={16} aria-hidden="true" />
+24 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { AlertCircle, Check, LoaderCircle, X } from "@lucide/svelte"; import { AlertCircle, Check, ExternalLink, GitMerge, LoaderCircle, X } from "@lucide/svelte";
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types"; import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>; type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
@@ -24,6 +24,9 @@
onSelectFile: (path: string) => void; onSelectFile: (path: string) => void;
onMarkResolved: (path: string, resolution: PreparedResolution) => void; onMarkResolved: (path: string, resolution: PreparedResolution) => void;
onApply: () => void; onApply: () => void;
onExternalMerge: (path: string) => void | Promise<void>;
language?: "en" | "de";
mergeName?: string;
} }
let { let {
@@ -37,7 +40,11 @@
onSelectFile = () => {}, onSelectFile = () => {},
onMarkResolved = () => {}, onMarkResolved = () => {},
onApply = () => {}, onApply = () => {},
onExternalMerge = () => {},
language = "en",
mergeName = "merge tool",
}: Props = $props(); }: Props = $props();
const isGerman = $derived(language === "de");
let conflictParts = $derived<ConflictPart[]>( let conflictParts = $derived<ConflictPart[]>(
conflict && !conflict.binary ? parseConflicts(conflict.content) : [], conflict && !conflict.binary ? parseConflicts(conflict.content) : [],
@@ -268,9 +275,22 @@
<span class="eyebrow">Resolve</span> <span class="eyebrow">Resolve</span>
<h2 class="dialog-title">Conflicts</h2> <h2 class="dialog-title">Conflicts</h2>
</div> </div>
<button class="dialog-close" type="button" onclick={onClose} title="Close"> <div class="dialog-header-actions">
<X size={18} aria-hidden="true" /> {#if conflictTarget}
</button> <div class="tool-surface-choice" aria-label={isGerman ? "Konflikt bearbeiten mit" : "Edit conflict with"}>
<span>{isGerman ? "Bearbeiten mit" : "Edit with"}</span>
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty bearbeiten" : "Edit in Gitty"}>
<GitMerge size={13} aria-hidden="true" />Gitty
</button>
<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={isGerman ? `In ${mergeName} öffnen` : `Open in ${mergeName}`}>
<ExternalLink size={13} aria-hidden="true" /><span>{mergeName}</span>
</button>
</div>
{/if}
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Konflikte schließen" : "Close conflicts"}>
<X size={18} aria-hidden="true" />
</button>
</div>
</header> </header>
{#if conflictedFiles.length === 0} {#if conflictedFiles.length === 0}
+281
View File
@@ -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;
}
+49
View File
@@ -7,6 +7,9 @@ import type {
CommitAiProvider, CommitAiProvider,
CommitAiStatus, CommitAiStatus,
ConflictFile, ConflictFile,
DetectedExternalTool,
ExternalToolCommand,
ExternalDiffScope,
GitBlameResult, GitBlameResult,
GitBranch, GitBranch,
GitCommit, GitCommit,
@@ -45,6 +48,22 @@ export function openRepositoryFile(path: string, file: string): Promise<void> {
return invoke<void>("open_repository_file", { path, file }); return invoke<void>("open_repository_file", { path, file });
} }
export function detectExternalTools(): Promise<DetectedExternalTool[]> {
return invoke<DetectedExternalTool[]>("detect_external_tools");
}
export function launchExternalTool(path: string, command: ExternalToolCommand, file?: string): Promise<void> {
return invoke<void>("launch_external_tool", { path, file: file ?? null, command });
}
export function launchExternalDiff(path: string, file: string, command: ExternalToolCommand, scope: ExternalDiffScope = "head"): Promise<void> {
return invoke<void>("launch_external_diff", { path, file, command, scope });
}
export function launchExternalMerge(path: string, file: string, command: ExternalToolCommand): Promise<void> {
return invoke<void>("launch_external_merge", { path, file, command });
}
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> { export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit }); return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
} }
@@ -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 });
} }
+30
View File
@@ -69,6 +69,36 @@ export interface AnalyticsSettings {
noticeSeen: boolean; noticeSeen: boolean;
} }
export interface ExternalToolCommand {
program: string;
args: string[];
}
export interface ExternalToolSetting extends ExternalToolCommand {
preset: string;
}
export type ExternalToolKind = "editor" | "diff" | "merge" | "terminal" | "fileManager";
export type ToolOpenMode = "gitty" | "external";
export type ExternalDiffScope = "head" | "staged" | "unstaged";
export interface ExternalToolsSettings {
editor: ExternalToolSetting;
diff: ExternalToolSetting;
merge: ExternalToolSetting;
terminal: ExternalToolSetting;
fileManager: ExternalToolSetting;
diffOpenMode: ToolOpenMode;
mergeOpenMode: ToolOpenMode;
}
export interface DetectedExternalTool {
id: string;
label: string;
program: string;
kinds: ExternalToolKind[];
}
export interface GitStatus { export interface GitStatus {
repo_path: string; repo_path: string;
current_branch: string | null; current_branch: string | null;