Open code test #26
File diff suppressed because it is too large
Load Diff
@@ -3129,6 +3129,230 @@ pub async fn list_commits(
|
||||
.await
|
||||
}
|
||||
|
||||
const COMMIT_NOTES_REF: &str = "refs/notes/commits";
|
||||
const COMMIT_NOTES_SYNC_REF: &str = "refs/gitlite/notes-sync";
|
||||
const MAX_COMMIT_NOTE_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_commit_note(path: String, commit: String) -> Result<Option<String>, String> {
|
||||
run_git_task("Could not load commit note", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
commit_note_for_repo(&repo, &commit)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_commit_note(path: String, commit: String, note: String) -> Result<(), String> {
|
||||
run_git_task("Could not save commit note", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
set_commit_note_for_repo(&repo, &commit, ¬e)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_commit_note(path: String, commit: String) -> Result<(), String> {
|
||||
run_git_task("Could not delete commit note", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
delete_commit_note_for_repo(&repo, &commit)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_commit_notes(
|
||||
path: String,
|
||||
remote: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
run_git_task("Could not fetch commit notes", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
fetch_commit_notes_for_repo(&repo, &remote, username.as_deref(), password.as_deref())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn push_commit_notes(
|
||||
path: String,
|
||||
remote: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
run_git_task("Could not push commit notes", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
push_commit_notes_for_repo(&repo, &remote, username.as_deref(), password.as_deref())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn commit_note_for_repo(repo: &Path, commit: &str) -> Result<Option<String>, String> {
|
||||
let commit = verify_commit(repo, commit)?;
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["notes", "--ref", COMMIT_NOTES_REF, "list", commit.as_str()])
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(None);
|
||||
}
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"Could not inspect commit note: {}",
|
||||
command_output_details(&output)
|
||||
));
|
||||
}
|
||||
|
||||
let note_object = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if note_object.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let note = run_git(repo, ["cat-file", "blob", note_object.as_str()])?;
|
||||
let mut note =
|
||||
String::from_utf8(note).map_err(|_| "Commit note is not valid UTF-8 text.".to_string())?;
|
||||
if note.ends_with('\n') {
|
||||
note.pop();
|
||||
if note.ends_with('\r') {
|
||||
note.pop();
|
||||
}
|
||||
}
|
||||
Ok(Some(note))
|
||||
}
|
||||
|
||||
fn set_commit_note_for_repo(repo: &Path, commit: &str, note: &str) -> Result<(), String> {
|
||||
let commit = verify_commit(repo, commit)?;
|
||||
if note.trim().is_empty() {
|
||||
return Err("Commit note must not be empty. Use Delete to remove it.".to_string());
|
||||
}
|
||||
if note.len() > MAX_COMMIT_NOTE_BYTES {
|
||||
return Err(format!(
|
||||
"Commit note is too large (maximum {} KiB).",
|
||||
MAX_COMMIT_NOTE_BYTES / 1024
|
||||
));
|
||||
}
|
||||
|
||||
run_git_with_stdin(
|
||||
repo,
|
||||
[
|
||||
"notes",
|
||||
"--ref",
|
||||
COMMIT_NOTES_REF,
|
||||
"add",
|
||||
"-f",
|
||||
"-F",
|
||||
"-",
|
||||
"--",
|
||||
commit.as_str(),
|
||||
],
|
||||
note.as_bytes(),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_commit_note_for_repo(repo: &Path, commit: &str) -> Result<(), String> {
|
||||
let commit = verify_commit(repo, commit)?;
|
||||
run_git(
|
||||
repo,
|
||||
[
|
||||
"notes",
|
||||
"--ref",
|
||||
COMMIT_NOTES_REF,
|
||||
"remove",
|
||||
"--ignore-missing",
|
||||
"--",
|
||||
commit.as_str(),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_commit_notes_for_repo(
|
||||
repo: &Path,
|
||||
remote: &str,
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let remote = validate_remote_name(repo, remote, true)?;
|
||||
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
|
||||
let refspec = format!("+{COMMIT_NOTES_REF}:{COMMIT_NOTES_SYNC_REF}");
|
||||
let fetch_args = ["fetch", remote.as_str(), refspec.as_str()];
|
||||
let fetched = match (username, password) {
|
||||
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
|
||||
run_git_authenticated(repo, fetch_args, user, pass)
|
||||
}
|
||||
_ => run_git(repo, fetch_args),
|
||||
};
|
||||
|
||||
if let Err(error) = fetched {
|
||||
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
|
||||
return Err(
|
||||
if error.to_lowercase().contains("couldn't find remote ref") {
|
||||
format!("Remote '{remote}' does not contain commit notes yet.")
|
||||
} else {
|
||||
error
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let merge_result = (|| -> Result<(), String> {
|
||||
if ref_exists(repo, COMMIT_NOTES_REF)? {
|
||||
run_git(
|
||||
repo,
|
||||
[
|
||||
"notes",
|
||||
"--ref",
|
||||
COMMIT_NOTES_REF,
|
||||
"merge",
|
||||
"-s",
|
||||
"cat_sort_uniq",
|
||||
COMMIT_NOTES_SYNC_REF,
|
||||
],
|
||||
)?;
|
||||
} else {
|
||||
let remote_notes_hash = run_git(repo, ["rev-parse", COMMIT_NOTES_SYNC_REF])?;
|
||||
let remote_notes_hash = String::from_utf8_lossy(&remote_notes_hash)
|
||||
.trim()
|
||||
.to_string();
|
||||
run_git(
|
||||
repo,
|
||||
["update-ref", COMMIT_NOTES_REF, remote_notes_hash.as_str()],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
let _ = run_git(repo, ["update-ref", "-d", COMMIT_NOTES_SYNC_REF]);
|
||||
merge_result
|
||||
}
|
||||
|
||||
fn push_commit_notes_for_repo(
|
||||
repo: &Path,
|
||||
remote: &str,
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let remote = validate_remote_name(repo, remote, true)?;
|
||||
if !ref_exists(repo, COMMIT_NOTES_REF)? {
|
||||
return Err("There are no local commit notes to push.".to_string());
|
||||
}
|
||||
let refspec = format!("{COMMIT_NOTES_REF}:{COMMIT_NOTES_REF}");
|
||||
let push_args = ["push", remote.as_str(), refspec.as_str()];
|
||||
match (username, password) {
|
||||
(Some(user), Some(pass)) if !user.is_empty() || !pass.is_empty() => {
|
||||
run_git_authenticated(repo, push_args, user, pass)?;
|
||||
}
|
||||
_ => {
|
||||
run_git(repo, push_args)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
|
||||
let bounded_limit = limit.unwrap_or(100).clamp(1, 500);
|
||||
commit_page_for_repo(repo, Some(bounded_limit), None)
|
||||
@@ -5923,6 +6147,91 @@ mod tests {
|
||||
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]
|
||||
fn clone_directory_name_is_inferred_from_common_remote_urls() {
|
||||
assert_eq!(
|
||||
|
||||
+26
-12
@@ -1,28 +1,33 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod badge;
|
||||
mod external_tools;
|
||||
mod git;
|
||||
mod telemetry;
|
||||
|
||||
use badge::set_sync_badge;
|
||||
use external_tools::{
|
||||
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
|
||||
};
|
||||
use git::{
|
||||
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
|
||||
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
||||
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,
|
||||
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,
|
||||
diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
|
||||
get_status, init_repository, last_commit_message, list_branches, list_commits,
|
||||
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
|
||||
list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort,
|
||||
merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository,
|
||||
open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_tag,
|
||||
read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree,
|
||||
rename_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
|
||||
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
|
||||
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
|
||||
set_branch_upstream, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop,
|
||||
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
|
||||
delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes, get_commit_note,
|
||||
get_file_blame, get_file_patch, get_remote_url, get_status, init_repository,
|
||||
last_commit_message, list_branches, list_commits, list_file_history,
|
||||
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
|
||||
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
|
||||
merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
|
||||
open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
|
||||
rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
|
||||
repair_worktree, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
||||
restore_files, restore_reflog_entry, restore_to_commit, revert_commit,
|
||||
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,
|
||||
};
|
||||
use tauri::Manager;
|
||||
@@ -123,6 +128,10 @@ async fn main() {
|
||||
clone_repository,
|
||||
open_repo_in_explorer,
|
||||
open_repository_file,
|
||||
detect_external_tools,
|
||||
launch_external_tool,
|
||||
launch_external_diff,
|
||||
launch_external_merge,
|
||||
get_status,
|
||||
list_branches,
|
||||
list_remotes,
|
||||
@@ -174,6 +183,11 @@ async fn main() {
|
||||
push,
|
||||
fetch,
|
||||
list_commits,
|
||||
get_commit_note,
|
||||
set_commit_note,
|
||||
delete_commit_note,
|
||||
fetch_commit_notes,
|
||||
push_commit_notes,
|
||||
restore_to_commit,
|
||||
restore_file_from_commit,
|
||||
merge_branch,
|
||||
|
||||
+352
-6
@@ -18,6 +18,7 @@
|
||||
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
||||
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
|
||||
import CommandPalette from "./lib/components/CommandPalette.svelte";
|
||||
import CommitNoteDialog from "./lib/components/CommitNoteDialog.svelte";
|
||||
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
||||
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
|
||||
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
|
||||
@@ -56,12 +57,15 @@
|
||||
createBranch,
|
||||
createTag,
|
||||
deleteBranch,
|
||||
deleteCommitNote,
|
||||
deleteTag,
|
||||
deleteRemoteBranch,
|
||||
initRepository,
|
||||
diffFileAgainstWorkingTree,
|
||||
compareFileToParent,
|
||||
fetchCommitNotes,
|
||||
fetchRemote,
|
||||
getCommitNote,
|
||||
getFileBlame,
|
||||
getStatus,
|
||||
lastCommitMessage,
|
||||
@@ -86,18 +90,24 @@
|
||||
pruneWorktrees,
|
||||
pull,
|
||||
push,
|
||||
pushCommitNotes,
|
||||
pushTag,
|
||||
removeRemote,
|
||||
removeWorktree,
|
||||
repairWorktree,
|
||||
revertCommit,
|
||||
setBranchUpstream,
|
||||
setCommitNote,
|
||||
updateRemote,
|
||||
renameBranch,
|
||||
rebaseAbort,
|
||||
rebaseBranch,
|
||||
rebaseContinue,
|
||||
getRemoteUrl,
|
||||
detectExternalTools,
|
||||
launchExternalDiff,
|
||||
launchExternalMerge,
|
||||
launchExternalTool,
|
||||
credLoad,
|
||||
credSave,
|
||||
credDelete,
|
||||
@@ -131,8 +141,11 @@
|
||||
AnalyticsSettings,
|
||||
CommitAiPhase,
|
||||
ConflictFile,
|
||||
DetectedExternalTool,
|
||||
ExplorerNode,
|
||||
ExplorerNodeKind,
|
||||
ExternalDiffScope,
|
||||
ExternalToolsSettings,
|
||||
GitBlameLine,
|
||||
GitBranch as GitBranchInfo,
|
||||
GitCommit,
|
||||
@@ -157,6 +170,12 @@
|
||||
RepositoryBundle,
|
||||
StoredCredential,
|
||||
} from "./lib/types";
|
||||
import {
|
||||
defaultExternalToolsSettings,
|
||||
externalToolDisplayName,
|
||||
normaliseExternalToolsSettings,
|
||||
resolveDetectedExternalToolPrograms,
|
||||
} from "./lib/externalTools";
|
||||
|
||||
import {
|
||||
orgKeyFromUrl,
|
||||
@@ -223,6 +242,7 @@
|
||||
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
|
||||
const APP_THEME_KEY = "gitlite.theme.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 COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
|
||||
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
|
||||
@@ -289,6 +309,15 @@
|
||||
let expandedExplorerPaths = new Set<string>();
|
||||
let expandedCommitHashes = new Set<string>();
|
||||
let selectedCommitHash = "";
|
||||
let commitNoteTarget: GitCommit | null = null;
|
||||
let commitNoteRepoPath = "";
|
||||
let commitNoteText = "";
|
||||
let commitNoteRemotes: GitRemote[] = [];
|
||||
let commitNotePreferredRemote = "";
|
||||
let commitNoteLoading = false;
|
||||
let commitNoteBusy = false;
|
||||
let commitNoteError = "";
|
||||
let commitNoteStatus = "";
|
||||
let fileHistory: GitCommit[] = [];
|
||||
let fileHistoryLoading = false;
|
||||
let fileHistoryError = "";
|
||||
@@ -319,6 +348,11 @@
|
||||
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
|
||||
let appTheme: AppTheme = loadThemePreference();
|
||||
let appLanguage: AppLanguage = loadLanguagePreference();
|
||||
let externalToolsSettings: ExternalToolsSettings = loadExternalToolsSettings();
|
||||
let externalToolsConfigured = hasStoredExternalToolsSettings();
|
||||
let detectedExternalTools: DetectedExternalTool[] = [];
|
||||
let externalToolsDetectionPending = true;
|
||||
let externalToolsDetectionUnavailable = false;
|
||||
let localModelOptions: LocalModelOption[] = [];
|
||||
let errorMessage = "";
|
||||
let operation = "";
|
||||
@@ -481,6 +515,11 @@
|
||||
.filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
|
||||
$: leftSidebarRows = buildLeftSidebarRows(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);
|
||||
$: applyLanguagePreference(appLanguage);
|
||||
@@ -491,6 +530,7 @@
|
||||
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
|
||||
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||
void runStartupSequence();
|
||||
void refreshDetectedExternalTools();
|
||||
void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; });
|
||||
window.addEventListener("beforeunload", handleAppShutdown);
|
||||
window.addEventListener("pagehide", handleAppShutdown);
|
||||
@@ -769,7 +809,7 @@
|
||||
}
|
||||
|
||||
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;
|
||||
autoRefreshInFlight = true;
|
||||
try {
|
||||
@@ -941,16 +981,19 @@
|
||||
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;
|
||||
analyticsSettings = next;
|
||||
appTheme = nextTheme;
|
||||
appLanguage = nextLanguage;
|
||||
autoRefreshEnabled = nextAutoRefresh;
|
||||
externalToolsSettings = nextExternalTools;
|
||||
persistAnalyticsSettings(next);
|
||||
persistThemePreference(nextTheme);
|
||||
persistLanguagePreference(nextLanguage);
|
||||
persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh);
|
||||
persistExternalToolsSettings(nextExternalTools);
|
||||
externalToolsConfigured = true;
|
||||
setTelemetryEnabled(next.enabled);
|
||||
appSettingsOpen = false;
|
||||
if (nextAutoRefresh && !autoRefreshWasEnabled) void autoRefreshTick();
|
||||
@@ -1794,6 +1837,14 @@
|
||||
selectedExplorerKind = "file";
|
||||
expandedExplorerPaths = new Set();
|
||||
expandedCommitHashes = new Set();
|
||||
commitNoteTarget = null;
|
||||
commitNoteRepoPath = "";
|
||||
commitNoteText = "";
|
||||
commitNoteRemotes = [];
|
||||
commitNoteLoading = false;
|
||||
commitNoteBusy = false;
|
||||
commitNoteError = "";
|
||||
commitNoteStatus = "";
|
||||
fileHistory = [];
|
||||
fileHistoryLoading = false;
|
||||
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) {
|
||||
if (!activeRepoPath || rebaseInProgress || cherryPickInProgress) return;
|
||||
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) {
|
||||
if (!activeRepoPath) return;
|
||||
selectedExplorerPath = file.path;
|
||||
@@ -3932,21 +4218,40 @@
|
||||
async function openResolveDialog() {
|
||||
if (!hasConflicts || isBusy) return;
|
||||
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 = {};
|
||||
resolveDialogOpen = true;
|
||||
await loadConflict(first);
|
||||
trackEvent("resolve_dialog_opened", {
|
||||
conflicts: conflictedFiles.length,
|
||||
});
|
||||
if (openExternally) await launchMergeToolForConflict(first);
|
||||
});
|
||||
}
|
||||
|
||||
async function selectConflictFile(path: string) {
|
||||
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);
|
||||
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}
|
||||
behind={status?.behind ?? 0}
|
||||
language={appLanguage}
|
||||
editorName={editorToolName}
|
||||
terminalName={terminalToolName}
|
||||
fileManagerName={fileManagerToolName}
|
||||
onFetch={fetchRepo}
|
||||
onPull={pullRepo}
|
||||
onPush={pushRepo}
|
||||
@@ -4088,7 +4396,9 @@
|
||||
onCompare={openCompareSelect}
|
||||
onInteractiveRebase={openInteractiveRebase}
|
||||
onReflog={openReflog}
|
||||
onOpenInExplorer={openActiveRepoInExplorer}
|
||||
onOpenInEditor={openActiveRepoInEditor}
|
||||
onOpenTerminal={openActiveRepoTerminal}
|
||||
onOpenInExplorer={openActiveRepoFileManager}
|
||||
onFetchPrune={fetchPruneRepo}
|
||||
onForcePush={forcePushRepo}
|
||||
onSyncOptions={openSyncOptions}
|
||||
@@ -4478,11 +4788,16 @@
|
||||
{selectedExplorerKind}
|
||||
{hasRepository}
|
||||
{isBusy}
|
||||
language={appLanguage}
|
||||
editorName={editorToolName}
|
||||
diffName={diffToolName}
|
||||
onToggleFolder={toggleExplorerFolder}
|
||||
onExpandAllFolders={expandAllExplorerFolders}
|
||||
onCollapseAllFolders={collapseAllExplorerFolders}
|
||||
onSelectNode={selectExplorerNode}
|
||||
onOpenFile={openFileFromExplorer}
|
||||
onOpenInEditor={openExplorerFileInEditor}
|
||||
onExternalDiff={compareExplorerFileExternally}
|
||||
onFileHistory={openFileHistoryDialog}
|
||||
onBlame={openBlame}
|
||||
collapsed={explorerPanelCollapsed}
|
||||
@@ -4540,7 +4855,7 @@
|
||||
onUnstage={unstageFile}
|
||||
onDiscard={discardFiles}
|
||||
onDiscardMany={discardChanges}
|
||||
onPatch={openLinePatch}
|
||||
onPatch={openPreferredFileDiff}
|
||||
onStageAll={stageAllFiles}
|
||||
onUnstageAll={unstageAllFiles}
|
||||
/>
|
||||
@@ -4629,6 +4944,7 @@
|
||||
onCreateBranchFromCommit={openNewBranchDialog}
|
||||
onCherryPickCommit={cherryPickFromCommit}
|
||||
onRevertCommit={revertHistoryCommit}
|
||||
onOpenCommitNote={openCommitNoteDialog}
|
||||
onSelectCommit={(commit) => { selectedCommitHash = commit.hash; }}
|
||||
onToggleCommitFiles={(hash) => {
|
||||
const next = new Set(expandedCommitHashes);
|
||||
@@ -4709,6 +5025,11 @@
|
||||
theme={appTheme}
|
||||
language={appLanguage}
|
||||
autoRefresh={autoRefreshEnabled}
|
||||
externalTools={externalToolsSettings}
|
||||
detectedTools={detectedExternalTools}
|
||||
detectionPending={externalToolsDetectionPending}
|
||||
detectionUnavailable={externalToolsDetectionUnavailable}
|
||||
onRefreshDetectedTools={() => refreshDetectedExternalTools(false)}
|
||||
onSave={saveAppSettings}
|
||||
onClose={() => { appSettingsOpen = false; }}
|
||||
/>
|
||||
@@ -4751,6 +5072,9 @@
|
||||
onClose={closeLinePatch}
|
||||
onRefresh={refreshLinePatch}
|
||||
onApply={applyLinePatch}
|
||||
language={appLanguage}
|
||||
diffName={diffToolName}
|
||||
onExternalDiff={openCurrentLinePatchExternally}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
@@ -4849,6 +5173,25 @@
|
||||
/>
|
||||
{/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 -->
|
||||
{#if renameBranchTarget}
|
||||
<RenameBranchDialog
|
||||
@@ -5007,10 +5350,13 @@
|
||||
{preparedResolutions}
|
||||
{isBusy}
|
||||
{operation}
|
||||
language={appLanguage}
|
||||
mergeName={mergeToolName}
|
||||
onClose={() => { resolveDialogOpen = false; }}
|
||||
onSelectFile={selectConflictFile}
|
||||
onMarkResolved={handleMarkResolved}
|
||||
onApply={applyPreparedResolutions}
|
||||
onExternalMerge={openConflictInExternalMerge}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
+30
@@ -2620,6 +2620,7 @@
|
||||
background: rgba(65,209,255,0.08);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.commit-note-button:not(:disabled) { color: color-mix(in srgb, var(--color-accent) 72%, var(--color-ink-dim)); }
|
||||
/* --- Git graph --- */
|
||||
|
||||
.graph-list {
|
||||
@@ -3188,6 +3189,9 @@
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
}
|
||||
.app-settings-dialog {
|
||||
width: min(880px, calc(100vw - 32px));
|
||||
}
|
||||
.clone-repository-dialog {
|
||||
display: block;
|
||||
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 > 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; }
|
||||
.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 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; }
|
||||
|
||||
@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; }
|
||||
.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;
|
||||
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 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; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ChevronDown,
|
||||
Code2,
|
||||
CloudDownload,
|
||||
Download,
|
||||
FolderOpen,
|
||||
@@ -12,6 +13,7 @@
|
||||
Search,
|
||||
Upload,
|
||||
Settings2,
|
||||
Terminal,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
export let hasRepository: boolean = false;
|
||||
@@ -20,6 +22,9 @@
|
||||
export let ahead: number = 0;
|
||||
export let behind: number = 0;
|
||||
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 onPull: () => void = () => {};
|
||||
export let onPush: () => void = () => {};
|
||||
@@ -29,6 +34,8 @@
|
||||
export let onInteractiveRebase: () => void = () => {};
|
||||
export let onReflog: () => void = () => {};
|
||||
export let onOpenInExplorer: () => void = () => {};
|
||||
export let onOpenInEditor: () => void = () => {};
|
||||
export let onOpenTerminal: () => void = () => {};
|
||||
export let onFetchPrune: () => void = () => {};
|
||||
export let onForcePush: () => void = () => {};
|
||||
export let onSyncOptions: () => void = () => {};
|
||||
@@ -192,15 +199,23 @@
|
||||
<div class="repo-toolbar-divider utility" aria-hidden="true"></div>
|
||||
|
||||
<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
|
||||
class="repo-action"
|
||||
onclick={onOpenInExplorer}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
|
||||
aria-label={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
|
||||
title={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
|
||||
aria-label={isGerman ? `Repository in ${fileManagerName} öffnen` : `Open repository in ${fileManagerName}`}
|
||||
>
|
||||
<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
|
||||
|
||||
@@ -1,22 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { Check, Languages, RefreshCw, Settings, X } from "@lucide/svelte";
|
||||
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
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 {
|
||||
analytics: AnalyticsSettings;
|
||||
theme: AppTheme;
|
||||
language: AppLanguage;
|
||||
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;
|
||||
}
|
||||
|
||||
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 selectedTheme = $state<AppTheme>("system");
|
||||
let selectedLanguage = $state<AppLanguage>("en");
|
||||
let autoRefreshEnabled = $state(true);
|
||||
let tools = $state<ExternalToolsSettings>(defaultExternalToolsSettings());
|
||||
const isGerman = $derived(selectedLanguage === "de");
|
||||
|
||||
$effect(() => {
|
||||
@@ -24,6 +85,7 @@
|
||||
selectedTheme = theme;
|
||||
selectedLanguage = language;
|
||||
autoRefreshEnabled = autoRefresh;
|
||||
tools = structuredClone(externalTools);
|
||||
});
|
||||
|
||||
function save() {
|
||||
@@ -31,112 +93,482 @@
|
||||
...analytics,
|
||||
enabled: analyticsEnabled,
|
||||
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>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<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 class="app-settings-title">
|
||||
<span class="app-settings-mark"><Settings2 size={18} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<span class="eyebrow">Gitty</span>
|
||||
<h2 class="dialog-title">{isGerman ? "Einstellungen" : "Settings"}</h2>
|
||||
<h2>{isGerman ? "Einstellungen" : "Settings"}</h2>
|
||||
<p>{isGerman ? "Gitty an deinen Workflow anpassen" : "Make Gitty fit your workflow"}</p>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"}>
|
||||
</div>
|
||||
<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" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">{isGerman ? "Darstellung" : "Appearance"}</span>
|
||||
<h3>{isGerman ? "Farbschema" : "Theme"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
<form class="app-settings-shell" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
||||
<div class="app-settings-body">
|
||||
<nav class="settings-nav" aria-label={isGerman ? "Einstellungsbereiche" : "Settings sections"}>
|
||||
<button type="button" class:active={activePage === "general"} onclick={() => { activePage = "general"; }}>
|
||||
<SlidersHorizontal size={16} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{isGerman ? "Allgemein" : "General"}</strong>
|
||||
<small>{isGerman ? "Darstellung & Verhalten" : "Appearance & behavior"}</small>
|
||||
</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-nav-note">
|
||||
<ShieldCheck size={15} aria-hidden="true" />
|
||||
<p>{isGerman ? "Tool-Argumente werden direkt und ohne zusätzliche Shell übergeben." : "Tool arguments are passed directly without an extra shell."}</p>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="settings-content">
|
||||
{#if activePage === "general"}
|
||||
<div class="settings-page-head">
|
||||
<div>
|
||||
<h3>{isGerman ? "Allgemein" : "General"}</h3>
|
||||
<p>{isGerman ? "Darstellung, Sprache und Hintergrundverhalten." : "Appearance, language, and background behavior."}</p>
|
||||
</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>
|
||||
<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="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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<label class="settings-toggle-row">
|
||||
<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} />
|
||||
<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"}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-settings-dialog { display: grid; grid-template-rows: auto minmax(0, 1fr); width: min(920px, calc(100vw - 32px)); height: min(720px, calc(100vh - 32px)); overflow: hidden; }
|
||||
.app-settings-head { display: flex; align-items: center; justify-content: space-between; min-height: 70px; padding: 14px 18px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||
.app-settings-title { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.app-settings-mark { display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, transparent); }
|
||||
.app-settings-title h2, .settings-page-head h3, .tool-config-summary h4, .general-setting-panel h4 { margin: 0; color: var(--color-ink); }
|
||||
.app-settings-title h2 { font-size: 18px; line-height: 1.2; }
|
||||
.app-settings-title p, .settings-page-head p, .tool-config-summary p, .general-setting-panel p { margin: 0; color: var(--color-ink-dim); }
|
||||
.app-settings-title p { margin-top: 3px; font-size: 11px; }
|
||||
.app-settings-shell { display: grid; min-height: 0; grid-template-rows: minmax(0, 1fr) auto; }
|
||||
.app-settings-body { display: grid; min-height: 0; grid-template-columns: 205px minmax(0, 1fr); }
|
||||
.settings-nav { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 14px 12px; border-right: 1px solid var(--color-border-subtle); background: color-mix(in srgb, var(--app-dialog-chrome) 72%, var(--app-dialog-bg)); }
|
||||
.settings-nav > button { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; min-height: 50px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
||||
.settings-nav > button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.settings-nav > button.active { border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
||||
.settings-nav button > :global(svg) { color: var(--color-ink-muted); }
|
||||
.settings-nav button.active > :global(svg) { color: var(--color-accent); }
|
||||
.settings-nav button span { display: grid; min-width: 0; gap: 2px; }
|
||||
.settings-nav button strong { font-size: 12px; }
|
||||
.settings-nav button small { overflow: hidden; color: var(--color-ink-faint); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.settings-nav button em { display: grid; place-items: center; min-width: 21px; height: 20px; padding-inline: 5px; border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 12%, transparent); font-size: 9px; font-style: normal; font-weight: 800; }
|
||||
.settings-nav-note { display: flex; align-items: flex-start; gap: 8px; margin-top: auto; padding: 10px; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-faint); }
|
||||
.settings-nav-note :global(svg) { flex: 0 0 auto; margin-top: 1px; color: var(--color-success); }
|
||||
.settings-nav-note p { margin: 0; font-size: 9.5px; line-height: 1.45; }
|
||||
.settings-content { min-width: 0; overflow: auto; padding: 18px 20px 22px; }
|
||||
.settings-page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||
.settings-page-head h3 { font-size: 18px; }
|
||||
.settings-page-head p { margin-top: 4px; font-size: 11px; line-height: 1.45; }
|
||||
.tool-rescan-button { display: inline-flex; align-items: center; gap: 7px; flex: 0 0 auto; min-height: 30px; padding: 0 10px; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink-muted); background: var(--color-surface-raised); font-size: 10px; font-weight: 750; }
|
||||
.tool-rescan-button:hover:not(:disabled) { color: var(--color-ink); border-color: var(--color-border-input); background: var(--color-surface-hover); }
|
||||
.tool-kind-tabs { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; margin-bottom: 14px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--app-settings-row-bg); }
|
||||
.tool-kind-tabs button { position: relative; display: flex; align-items: center; justify-content: center; gap: 7px; min-width: 0; height: 38px; padding: 0 8px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; font-size: 10.5px; font-weight: 750; }
|
||||
.tool-kind-tabs button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.tool-kind-tabs button.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-raised); box-shadow: 0 2px 8px rgba(0,0,0,.12); }
|
||||
.tool-kind-tabs button.active :global(svg) { color: var(--color-accent); }
|
||||
.tool-kind-tabs button small { position: absolute; top: 5px; right: 6px; width: 5px; height: 5px; border-radius: 50%; background: var(--color-ink-faint); }
|
||||
.tool-kind-tabs button small.available { background: var(--color-success); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-success) 15%, transparent); }
|
||||
.tool-config-panel { display: grid; gap: 14px; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 12px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
|
||||
.tool-config-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; }
|
||||
.tool-config-icon { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid color-mix(in srgb, var(--color-accent) 25%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 8%, transparent); }
|
||||
.tool-config-summary h4 { font-size: 14px; }
|
||||
.tool-config-summary p { margin-top: 3px; font-size: 10.5px; line-height: 1.4; }
|
||||
.tool-status { display: inline-flex; align-items: center; gap: 5px; padding: 5px 7px; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-ink-faint); font-size: 9px; font-weight: 750; }
|
||||
.tool-status.available { border-color: color-mix(in srgb, var(--color-success) 24%, var(--color-border)); color: var(--color-success); background: color-mix(in srgb, var(--color-success) 6%, transparent); }
|
||||
.tool-route { display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 7px 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; color: var(--color-ink-faint); background: var(--color-surface-raised); font-size: 10.5px; }
|
||||
.tool-route strong { color: var(--color-ink); }
|
||||
.tool-open-mode { display: grid; gap: 6px; min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.tool-open-mode legend { margin-bottom: 6px; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
|
||||
.tool-open-mode > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--color-surface-raised); }
|
||||
.tool-open-mode button { display: grid; justify-items: start; gap: 2px; min-width: 0; min-height: 46px; padding: 7px 10px; border: 1px solid transparent; border-radius: 7px; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
||||
.tool-open-mode button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.tool-open-mode button.active { border-color: color-mix(in srgb, var(--color-accent) 38%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-hover)); box-shadow: inset 2px 0 0 var(--color-accent); }
|
||||
.tool-open-mode button span { max-width: 100%; overflow: hidden; font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tool-open-mode button small { color: var(--color-ink-faint); font-size: 9px; font-weight: 550; }
|
||||
.tool-default-field, .tool-advanced-panel label { display: grid; gap: 6px; min-width: 0; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
|
||||
.tool-default-field select, .tool-advanced-panel input, .tool-advanced-panel textarea { width: 100%; min-width: 0; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink); background: var(--color-surface-raised); font: inherit; }
|
||||
.tool-default-field select { height: 38px; padding: 0 11px; font-size: 12px; font-weight: 700; }
|
||||
.tool-default-field small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; }
|
||||
.tool-usage-callout { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 10px; padding: 10px 11px; border-left: 2px solid var(--color-accent); border-radius: 0 7px 7px 0; background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
|
||||
.tool-usage-callout span { color: var(--color-accent); font-size: 9.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.tool-usage-callout p { margin: 0; color: var(--color-ink-muted); font-size: 10.5px; line-height: 1.45; }
|
||||
.tool-advanced-toggle { display: flex; align-items: center; justify-content: space-between; min-height: 32px; padding: 0; border: 0; border-top: 1px solid var(--color-border-subtle); color: var(--color-ink-dim); background: transparent; font-size: 10.5px; font-weight: 750; }
|
||||
.tool-advanced-toggle:hover { color: var(--color-ink); }
|
||||
.tool-advanced-panel { display: grid; gap: 11px; padding-top: 2px; }
|
||||
.tool-program-row { display: grid; grid-template-columns: minmax(0, 1fr) 34px; gap: 6px; }
|
||||
.tool-advanced-panel input { height: 34px; padding: 0 9px; font-family: var(--font-mono); font-size: 10.5px; }
|
||||
.tool-advanced-panel textarea { min-height: 80px; padding: 8px 9px; resize: vertical; font-family: var(--font-mono); font-size: 10.5px; line-height: 1.45; }
|
||||
.tool-program-row button { display: grid; place-items: center; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink-muted); background: var(--color-surface-raised); }
|
||||
.tool-program-row button:hover { color: var(--color-ink); border-color: var(--color-border-input); background: var(--color-surface-hover); }
|
||||
.tool-placeholders { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin: 0; color: var(--color-ink-faint); font-size: 9px; }
|
||||
.tool-placeholders span { margin-right: 3px; }
|
||||
.tool-placeholders code { padding: 2px 4px; border-radius: 4px; color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 9%, transparent); }
|
||||
.general-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.general-setting-panel { display: grid; align-content: start; gap: 14px; min-width: 0; padding: 14px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--app-settings-row-bg); }
|
||||
.general-setting-panel.general-setting-wide { grid-column: 1 / -1; }
|
||||
.general-setting-panel > header { display: flex; align-items: flex-start; gap: 9px; }
|
||||
.general-setting-panel > header > :global(svg) { flex: 0 0 auto; margin-top: 1px; color: var(--color-accent); }
|
||||
.general-setting-panel h4 { font-size: 12.5px; }
|
||||
.general-setting-panel p { margin-top: 3px; font-size: 9.5px; }
|
||||
.settings-segmented { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 3px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
||||
.settings-segmented.settings-language { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.settings-segmented label { display: flex; align-items: center; justify-content: center; min-height: 31px; border: 1px solid transparent; border-radius: 6px; color: var(--color-ink-dim); font-size: 10.5px; font-weight: 750; }
|
||||
.settings-segmented label.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.settings-segmented input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.settings-switch-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; }
|
||||
.settings-switch-row span { display: grid; gap: 3px; }
|
||||
.settings-switch-row strong { color: var(--color-ink); font-size: 11px; }
|
||||
.settings-switch-row small { color: var(--color-ink-dim); font-size: 9.5px; line-height: 1.4; }
|
||||
.settings-switch-row input { width: 32px; height: 18px; accent-color: var(--color-accent); }
|
||||
.app-settings-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 62px; padding: 11px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||
.app-settings-footer > span { color: var(--color-ink-faint); font-size: 9.5px; }
|
||||
.app-settings-footer > div { display: flex; gap: 8px; }
|
||||
.app-settings-footer button { min-height: 32px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-settings-dialog { height: min(760px, calc(100vh - 20px)); width: min(660px, calc(100vw - 20px)); }
|
||||
.app-settings-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
||||
.settings-nav { flex-direction: row; padding: 8px 10px; border-right: 0; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.settings-nav > button { width: auto; min-width: 0; flex: 1 1 0; min-height: 42px; }
|
||||
.settings-nav-note { display: none; }
|
||||
.settings-content { padding: 14px; }
|
||||
.tool-kind-tabs { grid-template-columns: repeat(5, minmax(42px, 1fr)); overflow-x: auto; }
|
||||
.tool-kind-tabs button { height: 40px; }
|
||||
.tool-kind-tabs button span { display: none; }
|
||||
.tool-config-summary { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.tool-status { grid-column: 1 / -1; justify-self: start; }
|
||||
.app-settings-footer > span { display: none; }
|
||||
.app-settings-footer { justify-content: flex-end; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.app-settings-head { min-height: 58px; padding: 10px 12px; }
|
||||
.app-settings-mark { width: 34px; height: 34px; }
|
||||
.settings-nav button small, .settings-nav button em { display: none; }
|
||||
.settings-nav > button { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.settings-page-head { align-items: stretch; flex-direction: column; }
|
||||
.tool-rescan-button { align-self: flex-start; }
|
||||
.general-settings-grid { grid-template-columns: 1fr; }
|
||||
.general-setting-panel.general-setting-wide { grid-column: auto; }
|
||||
.tool-config-panel { padding: 13px; }
|
||||
.tool-usage-callout { grid-template-columns: 1fr; gap: 4px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Download,
|
||||
GitCommitHorizontal,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
Save,
|
||||
StickyNote,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
import type { AppLanguage, GitCommit, GitRemote } from "../types";
|
||||
|
||||
interface Props {
|
||||
commit: GitCommit;
|
||||
note: string;
|
||||
remotes: GitRemote[];
|
||||
preferredRemote: string;
|
||||
language: AppLanguage;
|
||||
isLoading: boolean;
|
||||
isBusy: boolean;
|
||||
error: string;
|
||||
status: string;
|
||||
onSave: (note: string) => void | Promise<void>;
|
||||
onDelete: () => void | Promise<void>;
|
||||
onFetch: (remote: string) => void | Promise<void>;
|
||||
onPush: (remote: string) => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
commit,
|
||||
note = "",
|
||||
remotes = [],
|
||||
preferredRemote = "",
|
||||
language = "en",
|
||||
isLoading = false,
|
||||
isBusy = false,
|
||||
error = "",
|
||||
status = "",
|
||||
onSave = () => {},
|
||||
onDelete = () => {},
|
||||
onFetch = () => {},
|
||||
onPush = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let draft = $state("");
|
||||
let selectedRemote = $state("");
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let lastLoadedKey = $state("");
|
||||
let hasChanges = $derived(draft !== note);
|
||||
let canSave = $derived(!isLoading && !isBusy && draft.trim().length > 0 && hasChanges);
|
||||
|
||||
const text = $derived(language === "de" ? {
|
||||
eyebrow: "Interne Git-Notiz",
|
||||
title: "Commit-Notiz",
|
||||
commit: "Commit",
|
||||
explanation: "Die Notiz wird separat unter refs/notes/commits gespeichert. Hash und Commit-Historie bleiben unverändert.",
|
||||
label: "Notiz",
|
||||
placeholder: "Zum Beispiel Review-Hinweise, Ticket-Kontext, Build-ID oder Freigabestatus …",
|
||||
loading: "Notiz wird geladen …",
|
||||
syncTitle: "Mit Remote synchronisieren",
|
||||
syncHelp: "Git Notes reisen nicht automatisch mit Branches. Lade sie gezielt vom Remote oder sende deine lokalen Notizen dorthin.",
|
||||
noRemotes: "Für dieses Repository ist kein Remote eingerichtet.",
|
||||
remote: "Remote",
|
||||
fetch: "Vom Remote laden",
|
||||
push: "Zum Remote senden",
|
||||
delete: "Notiz löschen",
|
||||
deleteQuestion: "Diese Notiz wirklich löschen?",
|
||||
deleteConfirm: "Ja, löschen",
|
||||
cancel: "Abbrechen",
|
||||
close: "Schließen",
|
||||
save: "Notiz speichern",
|
||||
characters: "Zeichen",
|
||||
} : {
|
||||
eyebrow: "Internal Git note",
|
||||
title: "Commit note",
|
||||
commit: "Commit",
|
||||
explanation: "The note is stored separately under refs/notes/commits. The commit hash and history stay unchanged.",
|
||||
label: "Note",
|
||||
placeholder: "For example review findings, ticket context, a build ID, or approval status…",
|
||||
loading: "Loading note…",
|
||||
syncTitle: "Sync with a remote",
|
||||
syncHelp: "Git Notes do not travel with branches automatically. Fetch them explicitly from a remote or push your local notes there.",
|
||||
noRemotes: "No remote is configured for this repository.",
|
||||
remote: "Remote",
|
||||
fetch: "Fetch from remote",
|
||||
push: "Push to remote",
|
||||
delete: "Delete note",
|
||||
deleteQuestion: "Delete this note?",
|
||||
deleteConfirm: "Yes, delete",
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
save: "Save note",
|
||||
characters: "characters",
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const loadedKey = `${commit.hash}:${note}`;
|
||||
if (loadedKey === lastLoadedKey) return;
|
||||
draft = note;
|
||||
lastLoadedKey = loadedKey;
|
||||
deleteConfirmOpen = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (selectedRemote && remotes.some((remote) => remote.name === selectedRemote)) return;
|
||||
selectedRemote = remotes.some((remote) => remote.name === preferredRemote)
|
||||
? preferredRemote
|
||||
: (remotes[0]?.name ?? "");
|
||||
});
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!canSave) return;
|
||||
void onSave(draft);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && !isBusy) onClose();
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
if (canSave) void onSave(draft);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="dialog-backdrop app-chrome-backdrop commit-note-backdrop" role="presentation">
|
||||
<div class="commit-note-dialog" role="dialog" aria-modal="true" aria-labelledby="commit-note-title" tabindex="-1">
|
||||
<header class="commit-note-head">
|
||||
<div class="commit-note-heading">
|
||||
<span class="commit-note-icon"><StickyNote size={20} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<span class="eyebrow">{text.eyebrow}</span>
|
||||
<h2 id="commit-note-title">{text.title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={text.close} aria-label={text.close}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="commit-note-body">
|
||||
<div class="commit-note-target">
|
||||
<span class="commit-target-icon"><GitCommitHorizontal size={18} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<span>{text.commit} <code>{commit.short_hash}</code></span>
|
||||
<strong title={commit.summary}>{commit.summary}</strong>
|
||||
<small>{commit.author_name} · {new Date(commit.date).toLocaleString()}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="commit-note-info">
|
||||
<Info size={16} aria-hidden="true" />
|
||||
<span>{text.explanation}</span>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="commit-note-message error" role="alert">{error}</div>
|
||||
{:else if status}
|
||||
<div class="commit-note-message success" role="status">{status}</div>
|
||||
{/if}
|
||||
|
||||
<form class="commit-note-form" onsubmit={submit}>
|
||||
<label for="commit-note-editor">
|
||||
<span>{text.label}</span>
|
||||
<small>{draft.length.toLocaleString()} {text.characters}</small>
|
||||
</label>
|
||||
<div class="commit-note-editor-wrap">
|
||||
{#if isLoading}
|
||||
<div class="commit-note-loading"><LoaderCircle class="spin" size={18} aria-hidden="true" />{text.loading}</div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<textarea
|
||||
id="commit-note-editor"
|
||||
bind:value={draft}
|
||||
placeholder={text.placeholder}
|
||||
disabled={isLoading || isBusy}
|
||||
maxlength={262144}
|
||||
spellcheck="true"
|
||||
autofocus
|
||||
></textarea>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="commit-note-sync" aria-labelledby="commit-note-sync-title">
|
||||
<div class="commit-note-sync-copy">
|
||||
<strong id="commit-note-sync-title">{text.syncTitle}</strong>
|
||||
<span>{text.syncHelp}</span>
|
||||
</div>
|
||||
{#if remotes.length > 0}
|
||||
<div class="commit-note-sync-controls">
|
||||
<label>
|
||||
<span>{text.remote}</span>
|
||||
<select bind:value={selectedRemote} disabled={isBusy}>
|
||||
{#each remotes as remote (remote.name)}
|
||||
<option value={remote.name}>{remote.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" onclick={() => onFetch(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
|
||||
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Download size={15} aria-hidden="true" />{/if}
|
||||
{text.fetch}
|
||||
</button>
|
||||
<button type="button" onclick={() => onPush(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
|
||||
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Upload size={15} aria-hidden="true" />{/if}
|
||||
{text.push}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="commit-note-no-remotes">{text.noRemotes}</span>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="commit-note-footer">
|
||||
<div class="commit-note-delete">
|
||||
{#if deleteConfirmOpen}
|
||||
<span>{text.deleteQuestion}</span>
|
||||
<button class="btn-danger" type="button" onclick={() => onDelete()} disabled={isBusy}>{text.deleteConfirm}</button>
|
||||
<button type="button" onclick={() => { deleteConfirmOpen = false; }} disabled={isBusy}>{text.cancel}</button>
|
||||
{:else}
|
||||
<button type="button" class="commit-note-delete-trigger" onclick={() => { deleteConfirmOpen = true; }} disabled={isBusy || isLoading || !note}>
|
||||
<Trash2 size={15} aria-hidden="true" />{text.delete}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="commit-note-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{text.close}</button>
|
||||
<button class="btn-primary" type="button" onclick={() => onSave(draft)} disabled={!canSave}>
|
||||
{#if isBusy}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Save size={16} aria-hidden="true" />{/if}
|
||||
{text.save}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.commit-note-backdrop { z-index: 72; }
|
||||
.commit-note-dialog {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
max-height: min(780px, calc(100vh - 74px));
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 14px;
|
||||
background: var(--app-dialog-bg);
|
||||
box-shadow: var(--app-dialog-shadow);
|
||||
}
|
||||
.commit-note-head,
|
||||
.commit-note-footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; background: var(--app-dialog-chrome); }
|
||||
.commit-note-head { padding: 16px 18px; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.commit-note-heading { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.commit-note-heading h2 { margin: 2px 0 0; color: var(--color-ink); font-size: 18px; line-height: 1.2; }
|
||||
.commit-note-icon,
|
||||
.commit-target-icon { display: grid; flex: 0 0 auto; place-items: center; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
|
||||
.commit-note-icon { width: 40px; height: 40px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 11px; }
|
||||
.commit-note-body { display: grid; align-content: start; gap: 13px; min-height: 0; padding: 16px 18px; overflow: auto; }
|
||||
.commit-note-target { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 11px; padding: 11px 12px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--color-surface-raised); }
|
||||
.commit-target-icon { width: 34px; height: 34px; border-radius: 8px; }
|
||||
.commit-note-target > div { display: grid; gap: 3px; min-width: 0; }
|
||||
.commit-note-target span,
|
||||
.commit-note-target small { color: var(--color-ink-faint); font-size: 10.5px; }
|
||||
.commit-note-target code { margin-left: 4px; color: var(--color-accent); font: 700 10.5px var(--font-mono); }
|
||||
.commit-note-target strong { overflow: hidden; color: var(--color-ink); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.commit-note-info { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 8px; padding: 10px 11px; border: 1px solid color-mix(in srgb, var(--color-accent) 22%, var(--color-border-subtle)); border-radius: 9px; color: var(--color-ink-muted); background: color-mix(in srgb, var(--color-accent) 6%, transparent); font-size: 11px; line-height: 1.45; }
|
||||
.commit-note-info :global(svg) { margin-top: 1px; color: var(--color-accent); }
|
||||
.commit-note-message { padding: 9px 11px; border: 1px solid; border-radius: 8px; font-size: 11px; font-weight: 700; }
|
||||
.commit-note-message.error { border-color: rgba(232, 96, 96, .32); color: #ef8888; background: rgba(232, 96, 96, .08); }
|
||||
.commit-note-message.success { border-color: color-mix(in srgb, #5bd18a 34%, var(--color-border)); color: #70dc99; background: color-mix(in srgb, #5bd18a 8%, transparent); }
|
||||
.commit-note-form { display: grid; gap: 6px; }
|
||||
.commit-note-form > label { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--color-ink-muted); font-size: 11px; font-weight: 800; }
|
||||
.commit-note-form > label small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 600; }
|
||||
.commit-note-editor-wrap { position: relative; }
|
||||
.commit-note-editor-wrap textarea { min-height: 164px; max-height: 320px; resize: vertical; font-size: 12.5px; line-height: 1.55; }
|
||||
.commit-note-loading { position: absolute; inset: 0; z-index: 1; display: flex; align-items: center; justify-content: center; gap: 8px; border-radius: 8px; color: var(--color-ink-muted); background: color-mix(in srgb, var(--app-input-bg) 92%, transparent); font-size: 11px; font-weight: 700; }
|
||||
.commit-note-sync { display: grid; gap: 11px; padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: var(--color-surface-raised); }
|
||||
.commit-note-sync-copy { display: grid; gap: 4px; }
|
||||
.commit-note-sync-copy strong { color: var(--color-ink); font-size: 12px; }
|
||||
.commit-note-sync-copy span,
|
||||
.commit-note-no-remotes { color: var(--color-ink-faint); font-size: 10.5px; line-height: 1.45; }
|
||||
.commit-note-sync-controls { display: grid; grid-template-columns: minmax(130px, 1fr) auto auto; align-items: end; gap: 8px; }
|
||||
.commit-note-sync-controls label { display: grid; gap: 5px; color: var(--color-ink-muted); font-size: 10px; font-weight: 800; }
|
||||
.commit-note-sync-controls button { min-height: 34px; font-size: 10.5px; font-weight: 750; }
|
||||
.commit-note-footer { min-height: 66px; padding: 12px 18px; border-top: 1px solid var(--color-border-subtle); }
|
||||
.commit-note-delete,
|
||||
.commit-note-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.commit-note-delete > span { color: var(--color-ink-muted); font-size: 10.5px; font-weight: 700; }
|
||||
.commit-note-delete-trigger { border-color: transparent; color: #e87a7a; background: transparent; }
|
||||
.commit-note-delete-trigger:hover:not(:disabled) { border-color: rgba(232, 96, 96, .24); color: #ff9a9a; background: rgba(232, 96, 96, .08); }
|
||||
@media (max-width: 660px) {
|
||||
.commit-note-sync-controls { grid-template-columns: 1fr; }
|
||||
.commit-note-footer { align-items: stretch; flex-direction: column; }
|
||||
.commit-note-delete,
|
||||
.commit-note-actions { justify-content: flex-end; }
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,7 @@
|
||||
FileImage,
|
||||
FileJson,
|
||||
FileSearch,
|
||||
GitCompare,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileType,
|
||||
@@ -33,11 +34,16 @@
|
||||
selectedExplorerKind: ExplorerNodeKind;
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
language?: "en" | "de";
|
||||
editorName?: string;
|
||||
diffName?: string;
|
||||
onToggleFolder: (node: ExplorerNode) => void;
|
||||
onExpandAllFolders: () => void;
|
||||
onCollapseAllFolders: () => void;
|
||||
onSelectNode: (node: ExplorerNode) => void;
|
||||
onOpenFile: (node: ExplorerNode) => void;
|
||||
onOpenInEditor: (node: ExplorerNode) => void;
|
||||
onExternalDiff: (node: ExplorerNode) => void;
|
||||
onFileHistory: (node: ExplorerNode) => void;
|
||||
onBlame: (node: ExplorerNode) => void;
|
||||
collapsed?: boolean;
|
||||
@@ -51,11 +57,16 @@
|
||||
selectedExplorerKind = "file",
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
language = "en",
|
||||
editorName = "Editor",
|
||||
diffName = "diff tool",
|
||||
onToggleFolder = () => {},
|
||||
onExpandAllFolders = () => {},
|
||||
onCollapseAllFolders = () => {},
|
||||
onSelectNode = () => {},
|
||||
onOpenFile = () => {},
|
||||
onOpenInEditor = () => {},
|
||||
onExternalDiff = () => {},
|
||||
onFileHistory = () => {},
|
||||
onBlame = () => {},
|
||||
collapsed = false,
|
||||
@@ -65,6 +76,7 @@
|
||||
let contextNode = $state<ExplorerNode | null>(null);
|
||||
let contextMenuX = $state(0);
|
||||
let contextMenuY = $state(0);
|
||||
const isGerman = $derived(language === "de");
|
||||
|
||||
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
|
||||
if (!next) return current;
|
||||
@@ -178,7 +190,7 @@
|
||||
|
||||
contextNode = node;
|
||||
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() {
|
||||
@@ -192,6 +204,20 @@
|
||||
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() {
|
||||
const node = contextNode;
|
||||
if (!node || node.kind !== "file") return;
|
||||
@@ -213,6 +239,11 @@
|
||||
let explorerTree = $derived(buildExplorerTree(repoFiles));
|
||||
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
|
||||
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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<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
|
||||
class="explorer-bulk-button"
|
||||
type="button"
|
||||
@@ -366,6 +426,14 @@
|
||||
tabindex="-1"
|
||||
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
|
||||
type="button"
|
||||
role="menuitem"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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";
|
||||
|
||||
interface GraphSegment {
|
||||
@@ -52,6 +52,7 @@
|
||||
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||
onCherryPickCommit: (commit: GitCommit) => void;
|
||||
onRevertCommit: (commit: GitCommit) => void;
|
||||
onOpenCommitNote: (commit: GitCommit) => void;
|
||||
onSelectCommit: (commit: GitCommit) => void;
|
||||
}
|
||||
|
||||
@@ -75,6 +76,7 @@
|
||||
onCreateBranchFromCommit = () => {},
|
||||
onCherryPickCommit = () => {},
|
||||
onRevertCommit = () => {},
|
||||
onOpenCommitNote = () => {},
|
||||
onSelectCommit = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
@@ -445,6 +447,20 @@
|
||||
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) {
|
||||
if (event.key !== "Escape") return;
|
||||
closeCommitContextMenu();
|
||||
@@ -720,6 +736,16 @@
|
||||
<div class="commit-actions">
|
||||
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||
<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
|
||||
class="commit-menu-button"
|
||||
type="button"
|
||||
@@ -767,6 +793,10 @@
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
Branch
|
||||
</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}>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Restore
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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";
|
||||
|
||||
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
||||
@@ -34,9 +34,12 @@
|
||||
isBusy: boolean;
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
language?: "en" | "de";
|
||||
diffName?: string;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
|
||||
onExternalDiff: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -46,11 +49,16 @@
|
||||
isBusy = false,
|
||||
isLoading = false,
|
||||
error = "",
|
||||
language = "en",
|
||||
diffName = "diff tool",
|
||||
onClose = () => {},
|
||||
onRefresh = () => {},
|
||||
onApply = () => {},
|
||||
onExternalDiff = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
const isGerman = $derived(language === "de");
|
||||
|
||||
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||
let patchScroll = $state<HTMLDivElement | null>(null);
|
||||
let selectedLineIds = $state<Set<string>>(new Set());
|
||||
@@ -270,6 +278,15 @@
|
||||
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
||||
</div>
|
||||
<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 dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
<X size={16} aria-hidden="true" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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";
|
||||
|
||||
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
|
||||
@@ -24,6 +24,9 @@
|
||||
onSelectFile: (path: string) => void;
|
||||
onMarkResolved: (path: string, resolution: PreparedResolution) => void;
|
||||
onApply: () => void;
|
||||
onExternalMerge: (path: string) => void | Promise<void>;
|
||||
language?: "en" | "de";
|
||||
mergeName?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -37,7 +40,11 @@
|
||||
onSelectFile = () => {},
|
||||
onMarkResolved = () => {},
|
||||
onApply = () => {},
|
||||
onExternalMerge = () => {},
|
||||
language = "en",
|
||||
mergeName = "merge tool",
|
||||
}: Props = $props();
|
||||
const isGerman = $derived(language === "de");
|
||||
|
||||
let conflictParts = $derived<ConflictPart[]>(
|
||||
conflict && !conflict.binary ? parseConflicts(conflict.content) : [],
|
||||
@@ -268,9 +275,22 @@
|
||||
<span class="eyebrow">Resolve</span>
|
||||
<h2 class="dialog-title">Conflicts</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<div class="dialog-header-actions">
|
||||
{#if conflictTarget}
|
||||
<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>
|
||||
|
||||
{#if conflictedFiles.length === 0}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import type {
|
||||
DetectedExternalTool,
|
||||
ExternalToolKind,
|
||||
ExternalToolSetting,
|
||||
ExternalToolsSettings,
|
||||
ToolOpenMode,
|
||||
} from "./types";
|
||||
|
||||
export type { ExternalToolKind } from "./types";
|
||||
|
||||
export interface ExternalToolPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
program: string;
|
||||
args: string[];
|
||||
builtIn?: boolean;
|
||||
}
|
||||
|
||||
type ToolIdentity = Pick<ExternalToolPreset, "id" | "label" | "program">;
|
||||
|
||||
const userAgent = navigator.userAgent;
|
||||
export const externalToolsPlatform = userAgent.includes("Windows")
|
||||
? "windows"
|
||||
: userAgent.includes("Mac")
|
||||
? "macos"
|
||||
: "linux";
|
||||
|
||||
const codeFamily: ToolIdentity[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
{ id: "vscode", label: "Visual Studio Code", program: "Code.exe" },
|
||||
{ id: "vscode-insiders", label: "Visual Studio Code Insiders", program: "Code - Insiders.exe" },
|
||||
{ id: "cursor", label: "Cursor", program: "Cursor.exe" },
|
||||
{ id: "windsurf", label: "Windsurf", program: "Windsurf.exe" },
|
||||
{ id: "vscodium", label: "VSCodium", program: "VSCodium.exe" },
|
||||
]
|
||||
: [
|
||||
{ id: "vscode", label: "Visual Studio Code", program: "code" },
|
||||
{ id: "vscode-insiders", label: "Visual Studio Code Insiders", program: "code-insiders" },
|
||||
{ id: "cursor", label: "Cursor", program: "cursor" },
|
||||
{ id: "windsurf", label: "Windsurf", program: "windsurf" },
|
||||
{ id: "vscodium", label: "VSCodium", program: "codium" },
|
||||
];
|
||||
|
||||
const jetBrains: ToolIdentity[] = [
|
||||
{ id: "intellij-idea", label: "IntelliJ IDEA", program: externalToolsPlatform === "windows" ? "idea64.exe" : "idea" },
|
||||
{ id: "webstorm", label: "WebStorm", program: externalToolsPlatform === "windows" ? "webstorm64.exe" : "webstorm" },
|
||||
{ id: "pycharm", label: "PyCharm", program: externalToolsPlatform === "windows" ? "pycharm64.exe" : "pycharm" },
|
||||
{ id: "phpstorm", label: "PhpStorm", program: externalToolsPlatform === "windows" ? "phpstorm64.exe" : "phpstorm" },
|
||||
{ id: "rider", label: "JetBrains Rider", program: externalToolsPlatform === "windows" ? "rider64.exe" : "rider" },
|
||||
{ id: "clion", label: "CLion", program: externalToolsPlatform === "windows" ? "clion64.exe" : "clion" },
|
||||
{ id: "rustrover", label: "RustRover", program: externalToolsPlatform === "windows" ? "rustrover64.exe" : "rustrover" },
|
||||
{ id: "goland", label: "GoLand", program: externalToolsPlatform === "windows" ? "goland64.exe" : "goland" },
|
||||
];
|
||||
|
||||
const editor = (tool: ToolIdentity, args = ["{file}"]): ExternalToolPreset => ({ ...tool, args });
|
||||
const codeDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--wait", "--diff", "{left}", "{right}"] });
|
||||
const codeMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--wait", "--merge", "{ours}", "{theirs}", "{base}", "{result}"] });
|
||||
const jetBrainsDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["diff", "{left}", "{right}"] });
|
||||
const jetBrainsMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["merge", "{ours}", "{theirs}", "{base}", "{result}"] });
|
||||
|
||||
const commonEditors: ExternalToolPreset[] = [
|
||||
...codeFamily.map((item) => editor(item, ["--reuse-window", "{file}"])),
|
||||
editor({ id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed" }),
|
||||
editor({ id: "sublime-text", label: "Sublime Text", program: externalToolsPlatform === "windows" ? "subl.exe" : "subl" }),
|
||||
...jetBrains.map((item) => editor(item)),
|
||||
editor({ id: "neovim", label: "Neovim", program: externalToolsPlatform === "windows" ? "nvim.exe" : "nvim" }),
|
||||
editor({ id: "vim", label: "Vim", program: externalToolsPlatform === "windows" ? "gvim.exe" : "gvim" }),
|
||||
editor({ id: "emacs", label: "Emacs", program: externalToolsPlatform === "windows" ? "runemacs.exe" : "emacs" }),
|
||||
];
|
||||
|
||||
const platformEditors: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
editor({ id: "notepad-plus-plus", label: "Notepad++", program: "notepad++.exe" }),
|
||||
{ ...editor({ id: "notepad", label: "Windows Notepad", program: "notepad.exe" }), builtIn: true },
|
||||
]
|
||||
: externalToolsPlatform === "macos"
|
||||
? [
|
||||
editor({ id: "nova", label: "Nova", program: "nova" }),
|
||||
editor({ id: "textmate", label: "TextMate", program: "mate" }),
|
||||
editor({ id: "bbedit", label: "BBEdit", program: "bbedit" }),
|
||||
editor({ id: "xcode", label: "Xcode", program: "xed" }),
|
||||
]
|
||||
: [
|
||||
editor({ id: "lapce", label: "Lapce", program: "lapce" }),
|
||||
editor({ id: "kate", label: "Kate", program: "kate" }),
|
||||
editor({ id: "gedit", label: "GNOME Text Editor", program: "gnome-text-editor" }),
|
||||
editor({ id: "geany", label: "Geany", program: "geany" }),
|
||||
editor({ id: "helix", label: "Helix", program: "hx" }),
|
||||
];
|
||||
|
||||
const dedicatedDiff: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/readonly", "{left}", "{right}"] },
|
||||
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/u", "/e", "/wl", "/wr", "{left}", "{right}"] },
|
||||
{ id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "{left}", "{right}"] },
|
||||
{ id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{left}", "{right}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{left}", "{right}"] },
|
||||
{ id: "araxis-merge", label: "Araxis Merge", program: "Compare.exe", args: ["/wait", "{left}", "{right}"] },
|
||||
{ id: "tortoisegitmerge", label: "TortoiseGitMerge", program: "TortoiseGitMerge.exe", args: ["/base:{left}", "/mine:{right}"] },
|
||||
]
|
||||
: externalToolsPlatform === "macos"
|
||||
? [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["{left}", "{right}"] },
|
||||
{ id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--wait", "{left}", "{right}"] },
|
||||
{ id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{left}", "{right}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] },
|
||||
{ id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{left}", "{right}"] },
|
||||
]
|
||||
: [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["{left}", "{right}"] },
|
||||
{ id: "meld", label: "Meld", program: "meld", args: ["--wait", "{left}", "{right}"] },
|
||||
{ id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{left}", "{right}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] },
|
||||
{ id: "kompare", label: "Kompare", program: "kompare", args: ["{left}", "{right}"] },
|
||||
];
|
||||
|
||||
const dedicatedMerge: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/u", "/e", "{ours}", "{theirs}", "{base}", "/o", "{result}"] },
|
||||
{ id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] },
|
||||
{ id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
|
||||
{ id: "araxis-merge", label: "Araxis Merge", program: "Compare.exe", args: ["/wait", "{ours}", "{base}", "{theirs}", "{result}"] },
|
||||
{ id: "tortoisegitmerge", label: "TortoiseGitMerge", program: "TortoiseGitMerge.exe", args: ["/base:{base}", "/theirs:{theirs}", "/mine:{ours}", "/merged:{result}"] },
|
||||
]
|
||||
: externalToolsPlatform === "macos"
|
||||
? [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||
{ id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--merge", "--output", "{result}", "{base}", "{ours}", "{theirs}"] },
|
||||
{ id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{ours}", "{base}", "{theirs}", "{result}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
|
||||
{ id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{ours}", "{theirs}", "-ancestor", "{base}", "-merge", "{result}"] },
|
||||
]
|
||||
: [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||
{ id: "meld", label: "Meld", program: "meld", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] },
|
||||
{ id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
|
||||
];
|
||||
|
||||
const terminalPresets: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
{ id: "windows-terminal", label: "Windows Terminal", program: "wt.exe", args: ["-d", "{repo}"] },
|
||||
{ id: "powershell", label: "PowerShell 7", program: "pwsh.exe", args: ["-NoExit", "-WorkingDirectory", "{repo}"] },
|
||||
{ id: "windows-powershell", label: "Windows PowerShell", program: "powershell.exe", args: ["-NoExit"], builtIn: true },
|
||||
{ id: "git-bash", label: "Git Bash", program: "bash.exe", args: ["--login", "-i"] },
|
||||
{ id: "cmd", label: "Command Prompt", program: "cmd.exe", args: ["/K"], builtIn: true },
|
||||
{ id: "wezterm", label: "WezTerm", program: "wezterm-gui.exe", args: ["start", "--cwd", "{repo}"] },
|
||||
{ id: "alacritty", label: "Alacritty", program: "alacritty.exe", args: ["--working-directory", "{repo}"] },
|
||||
{ id: "kitty-terminal", label: "kitty", program: "kitty.exe", args: ["--directory", "{repo}"] },
|
||||
]
|
||||
: externalToolsPlatform === "macos"
|
||||
? [
|
||||
{ id: "terminal", label: "Terminal", program: "open", args: ["-a", "Terminal", "{repo}"], builtIn: true },
|
||||
{ id: "iterm2", label: "iTerm2", program: "open", args: ["-a", "iTerm", "{repo}"] },
|
||||
{ id: "warp", label: "Warp", program: "open", args: ["-a", "Warp", "{repo}"] },
|
||||
{ id: "wezterm", label: "WezTerm", program: "wezterm", args: ["start", "--cwd", "{repo}"] },
|
||||
{ id: "alacritty", label: "Alacritty", program: "alacritty", args: ["--working-directory", "{repo}"] },
|
||||
]
|
||||
: [
|
||||
{ id: "x-terminal", label: "System terminal", program: "x-terminal-emulator", args: ["--working-directory={repo}"] },
|
||||
{ id: "gnome-terminal", label: "GNOME Terminal", program: "gnome-terminal", args: ["--working-directory={repo}"] },
|
||||
{ id: "konsole", label: "Konsole", program: "konsole", args: ["--workdir", "{repo}"] },
|
||||
{ id: "kitty-terminal", label: "kitty", program: "kitty", args: ["--directory", "{repo}"] },
|
||||
{ id: "wezterm", label: "WezTerm", program: "wezterm", args: ["start", "--cwd", "{repo}"] },
|
||||
{ id: "alacritty", label: "Alacritty", program: "alacritty", args: ["--working-directory", "{repo}"] },
|
||||
{ id: "xfce-terminal", label: "Xfce Terminal", program: "xfce4-terminal", args: ["--working-directory={repo}"] },
|
||||
{ id: "tilix", label: "Tilix", program: "tilix", args: ["--working-directory={repo}"] },
|
||||
];
|
||||
|
||||
const fileManagerPresets: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
{ id: "explorer", label: "Windows Explorer", program: "explorer.exe", args: ["{repo}"], builtIn: true },
|
||||
{ id: "total-commander", label: "Total Commander", program: "TOTALCMD64.EXE", args: ["/O", "/T", "{repo}"] },
|
||||
{ id: "directory-opus", label: "Directory Opus", program: "dopus.exe", args: ["{repo}"] },
|
||||
{ id: "double-commander", label: "Double Commander", program: "doublecmd.exe", args: ["{repo}"] },
|
||||
{ id: "freecommander", label: "FreeCommander XE", program: "FreeCommander.exe", args: ["/L={repo}"] },
|
||||
{ id: "xyplorer", label: "XYplorer", program: "XYplorer.exe", args: ["{repo}"] },
|
||||
]
|
||||
: externalToolsPlatform === "macos"
|
||||
? [
|
||||
{ id: "finder", label: "Finder", program: "open", args: ["{repo}"], builtIn: true },
|
||||
{ id: "forklift", label: "ForkLift", program: "forklift", args: ["{repo}"] },
|
||||
{ id: "path-finder", label: "Path Finder", program: "Path Finder", args: ["{repo}"] },
|
||||
]
|
||||
: [
|
||||
{ id: "system-file-manager", label: "System file manager", program: "xdg-open", args: ["{repo}"], builtIn: true },
|
||||
{ id: "nautilus", label: "GNOME Files", program: "nautilus", args: ["{repo}"] },
|
||||
{ id: "dolphin", label: "Dolphin", program: "dolphin", args: ["{repo}"] },
|
||||
{ id: "thunar", label: "Thunar", program: "thunar", args: ["{repo}"] },
|
||||
{ id: "nemo", label: "Nemo", program: "nemo", args: ["{repo}"] },
|
||||
{ id: "pcmanfm", label: "PCManFM", program: "pcmanfm", args: ["{repo}"] },
|
||||
{ id: "double-commander", label: "Double Commander", program: "doublecmd", args: ["{repo}"] },
|
||||
];
|
||||
|
||||
export const externalToolPresets: Record<ExternalToolKind, ExternalToolPreset[]> = {
|
||||
editor: [...commonEditors, ...platformEditors],
|
||||
diff: [...dedicatedDiff, ...codeFamily.map(codeDiff), { id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed", args: ["--diff", "{left}", "{right}"] }, ...jetBrains.map(jetBrainsDiff)],
|
||||
merge: [...dedicatedMerge, ...codeFamily.map(codeMerge), ...jetBrains.map(jetBrainsMerge)],
|
||||
terminal: terminalPresets,
|
||||
fileManager: fileManagerPresets,
|
||||
};
|
||||
|
||||
function detectedPreset(kind: ExternalToolKind, id: string, detectedTools: DetectedExternalTool[]): DetectedExternalTool | undefined {
|
||||
return detectedTools.find((tool) => tool.id === id && tool.kinds.includes(kind));
|
||||
}
|
||||
|
||||
export function isExternalToolPresetAvailable(kind: ExternalToolKind, preset: ExternalToolPreset, detectedTools: DetectedExternalTool[]): boolean {
|
||||
return preset.builtIn === true || detectedPreset(kind, preset.id, detectedTools) != null;
|
||||
}
|
||||
|
||||
export function applyExternalToolPreset(kind: ExternalToolKind, id: string, detectedTools: DetectedExternalTool[] = []): ExternalToolSetting {
|
||||
const value = externalToolPresets[kind].find((item) => item.id === id) ?? externalToolPresets[kind][0];
|
||||
const detected = detectedPreset(kind, value.id, detectedTools);
|
||||
return { preset: value.id, program: detected?.program ?? value.program, args: [...value.args] };
|
||||
}
|
||||
|
||||
function preferredPreset(kind: ExternalToolKind, detectedTools: DetectedExternalTool[]): ExternalToolPreset {
|
||||
return externalToolPresets[kind].find((item) => isExternalToolPresetAvailable(kind, item, detectedTools))
|
||||
?? externalToolPresets[kind][0];
|
||||
}
|
||||
|
||||
export function defaultExternalToolsSettings(detectedTools: DetectedExternalTool[] = []): ExternalToolsSettings {
|
||||
return {
|
||||
editor: applyExternalToolPreset("editor", preferredPreset("editor", detectedTools).id, detectedTools),
|
||||
diff: applyExternalToolPreset("diff", preferredPreset("diff", detectedTools).id, detectedTools),
|
||||
merge: applyExternalToolPreset("merge", preferredPreset("merge", detectedTools).id, detectedTools),
|
||||
terminal: applyExternalToolPreset("terminal", preferredPreset("terminal", detectedTools).id, detectedTools),
|
||||
fileManager: applyExternalToolPreset("fileManager", preferredPreset("fileManager", detectedTools).id, detectedTools),
|
||||
diffOpenMode: "gitty",
|
||||
mergeOpenMode: "gitty",
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseSetting(value: unknown, fallback: ExternalToolSetting): ExternalToolSetting {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback;
|
||||
const candidate = value as Partial<ExternalToolSetting>;
|
||||
const program = typeof candidate.program === "string" && candidate.program.trim() ? candidate.program : fallback.program;
|
||||
const args = Array.isArray(candidate.args)
|
||||
? candidate.args.filter((item): item is string => typeof item === "string").slice(0, 64)
|
||||
: fallback.args;
|
||||
const preset = typeof candidate.preset === "string" && candidate.preset ? candidate.preset : fallback.preset;
|
||||
return { preset, program, args };
|
||||
}
|
||||
|
||||
export function normaliseExternalToolsSettings(value: unknown): ExternalToolsSettings {
|
||||
const defaults = defaultExternalToolsSettings();
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return defaults;
|
||||
const candidate = value as Partial<ExternalToolsSettings>;
|
||||
return {
|
||||
editor: normaliseSetting(candidate.editor, defaults.editor),
|
||||
diff: normaliseSetting(candidate.diff, defaults.diff),
|
||||
merge: normaliseSetting(candidate.merge, defaults.merge),
|
||||
terminal: normaliseSetting(candidate.terminal, defaults.terminal),
|
||||
fileManager: normaliseSetting(candidate.fileManager, defaults.fileManager),
|
||||
diffOpenMode: normaliseOpenMode(candidate.diffOpenMode, defaults.diffOpenMode),
|
||||
mergeOpenMode: normaliseOpenMode(candidate.mergeOpenMode, defaults.mergeOpenMode),
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseOpenMode(value: unknown, fallback: ToolOpenMode): ToolOpenMode {
|
||||
return value === "gitty" || value === "external" ? value : fallback;
|
||||
}
|
||||
|
||||
export function resolveDetectedExternalToolPrograms(settings: ExternalToolsSettings, detectedTools: DetectedExternalTool[]): ExternalToolsSettings {
|
||||
const resolved = structuredClone(settings);
|
||||
for (const kind of ["editor", "diff", "merge", "terminal", "fileManager"] as ExternalToolKind[]) {
|
||||
if (resolved[kind].preset === "custom") continue;
|
||||
const detected = detectedPreset(kind, resolved[kind].preset, detectedTools);
|
||||
if (detected) resolved[kind].program = detected.program;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function externalToolDisplayName(kind: ExternalToolKind, setting: ExternalToolSetting, detectedTools: DetectedExternalTool[] = []): string {
|
||||
if (setting.preset === "custom") return setting.program.split(/[\\/]/).pop() || setting.program;
|
||||
return detectedPreset(kind, setting.preset, detectedTools)?.label
|
||||
?? externalToolPresets[kind].find((item) => item.id === setting.preset)?.label
|
||||
?? setting.program;
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import type {
|
||||
CommitAiProvider,
|
||||
CommitAiStatus,
|
||||
ConflictFile,
|
||||
DetectedExternalTool,
|
||||
ExternalToolCommand,
|
||||
ExternalDiffScope,
|
||||
GitBlameResult,
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
@@ -45,6 +48,22 @@ export function openRepositoryFile(path: string, file: string): Promise<void> {
|
||||
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> {
|
||||
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 });
|
||||
}
|
||||
|
||||
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> {
|
||||
return invoke<GitStatus>("restore_to_commit", { path, commit });
|
||||
}
|
||||
|
||||
@@ -69,6 +69,36 @@ export interface AnalyticsSettings {
|
||||
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 {
|
||||
repo_path: string;
|
||||
current_branch: string | null;
|
||||
|
||||
Reference in New Issue
Block a user