Open code test #26
+79
-3
@@ -2,9 +2,82 @@
|
||||
|
||||
All notable user-facing changes to Gitty are documented in this file.
|
||||
|
||||
The project uses calendar versions. Displayed release names use `YYYY.MM.DD`;
|
||||
package metadata uses the equivalent numeric form without leading zeroes where
|
||||
required by the package manager.
|
||||
The project uses calendar-style versions in the form `YYYY.M.PATCH`.
|
||||
|
||||
## [2026.8.3] - 2026-08-13
|
||||
|
||||
### Added
|
||||
|
||||
- Configurable external tools for editors, diff viewers, merge tools,
|
||||
terminals, and file managers, including automatic cross-platform discovery
|
||||
and presets for VS Code, JetBrains IDEs, Beyond Compare, and other common
|
||||
applications.
|
||||
- Repository and file actions for opening content in the configured external
|
||||
application. Supported tools open in a separate window.
|
||||
- A choice between Gitty's internal diff/merge views and the configured
|
||||
external applications.
|
||||
- Git Notes support for attaching editable notes to commits without rewriting
|
||||
commit history, including fetch and push synchronization.
|
||||
- A command palette for quickly opening repository actions, files, and commits.
|
||||
- Complete branch-to-branch comparisons for local and remote branches. The
|
||||
comparison dialog shows every changed file and its side-by-side diff.
|
||||
- Safe remote branch renaming from the branch context menu.
|
||||
|
||||
### Changed
|
||||
|
||||
- Redesigned the settings window with tool categories, detected applications,
|
||||
preset dropdowns, and clearer explanations of where each tool is used.
|
||||
- Redesigned the history graph's branch presentation with compact labels,
|
||||
hover details, cleaner flag connectors, and branch visibility controls.
|
||||
- Reduced the minimum width of the commit history panel so the workspace can
|
||||
be resized more freely.
|
||||
- Local-only branches are now identified consistently in the toolbar,
|
||||
repository summary, status bar, and commit graph. Their first push is labeled
|
||||
Publish and configures the remote tracking branch automatically.
|
||||
- Git operations now run asynchronously to keep the application responsive
|
||||
during slower repository commands.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Closing supported external tools no longer reports their documented
|
||||
comparison result codes as application errors.
|
||||
- External tools that otherwise reuse an existing process are explicitly
|
||||
opened in a new window where supported.
|
||||
- Remote branch renaming uses an atomic push with lease checks, preventing an
|
||||
existing destination branch or a newly changed remote branch from being
|
||||
overwritten.
|
||||
|
||||
## [2026.8.2] - 2026-08-10
|
||||
|
||||
### Changed
|
||||
|
||||
- History graph colors remain stable across parent lanes, making longer and
|
||||
branching histories easier to follow.
|
||||
- Release artifacts are published to the matching Gitea release automatically
|
||||
without creating duplicate assets.
|
||||
- Application shutdown now completes telemetry cleanup more reliably.
|
||||
|
||||
## [2026.8.1] - 2026-08-04
|
||||
|
||||
### Added
|
||||
|
||||
- Paginated commit history that loads older commits on demand instead of
|
||||
limiting the visible repository history to the initial page.
|
||||
- A dedicated file-history dialog opened from the explorer context menu.
|
||||
- Windows and Ubuntu release publishing plus improved AUR packaging workflows.
|
||||
|
||||
### Changed
|
||||
|
||||
- File history moved out of the permanent workspace panel into a focused,
|
||||
larger dialog.
|
||||
- Dialogs close more consistently with the Escape key.
|
||||
- Arch Linux installation documentation now uses the `gitty-desktop` AUR
|
||||
package.
|
||||
|
||||
### Fixed
|
||||
|
||||
- AUR SSH setup, package installation timeouts, and clone/push retries are more
|
||||
robust in the release workflow.
|
||||
|
||||
## [2026.07.22] - 2026-07-22
|
||||
|
||||
@@ -88,3 +161,6 @@ required by the package manager.
|
||||
[2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22
|
||||
[2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21
|
||||
[2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20
|
||||
[2026.8.3]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.3
|
||||
[2026.8.2]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.2
|
||||
[2026.8.1]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.1
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "2026.8.2",
|
||||
"version": "2026.8.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitty",
|
||||
"version": "2026.8.2",
|
||||
"version": "2026.8.3",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "2026.8.2",
|
||||
"version": "2026.8.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+538
-18
@@ -66,6 +66,7 @@ pub struct GitBranch {
|
||||
pub name: String,
|
||||
pub current: bool,
|
||||
pub remote: bool,
|
||||
pub upstream: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
@@ -675,6 +676,99 @@ pub fn delete_remote_branch(
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn rename_remote_branch(
|
||||
path: String,
|
||||
remote: String,
|
||||
old_branch: String,
|
||||
new_branch: String,
|
||||
) -> Result<GitStatus, String> {
|
||||
run_git_task("Could not rename remote branch", move || {
|
||||
rename_remote_branch_core(path, remote, old_branch, new_branch)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn rename_remote_branch_core(
|
||||
path: String,
|
||||
remote: String,
|
||||
old_branch: String,
|
||||
new_branch: String,
|
||||
) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let remote = validate_remote_name(&repo, &remote, true)?;
|
||||
let old_branch = validate_branch_ref_name(old_branch.trim())?;
|
||||
let new_branch = validate_branch_ref_name(new_branch.trim())?;
|
||||
|
||||
if old_branch == new_branch {
|
||||
return Err("The new remote branch name is unchanged.".to_string());
|
||||
}
|
||||
|
||||
let old_tracking_ref = format!("refs/remotes/{remote}/{old_branch}");
|
||||
let new_tracking_ref = format!("refs/remotes/{remote}/{new_branch}");
|
||||
if !ref_exists(&repo, &old_tracking_ref)? {
|
||||
return Err(format!(
|
||||
"Remote branch '{remote}/{old_branch}' was not found."
|
||||
));
|
||||
}
|
||||
if ref_exists(&repo, &new_tracking_ref)? {
|
||||
return Err(format!(
|
||||
"Remote branch '{remote}/{new_branch}' already exists."
|
||||
));
|
||||
}
|
||||
|
||||
let old_hash = run_git(&repo, ["rev-parse", "--verify", old_tracking_ref.as_str()])?;
|
||||
let old_hash = String::from_utf8_lossy(&old_hash).trim().to_string();
|
||||
let old_remote_ref = format!("refs/heads/{old_branch}");
|
||||
let new_remote_ref = format!("refs/heads/{new_branch}");
|
||||
let source_lease = format!("--force-with-lease={old_remote_ref}:{old_hash}");
|
||||
// An empty expected value means the destination must not exist on the remote.
|
||||
let destination_lease = format!("--force-with-lease={new_remote_ref}:");
|
||||
let create_refspec = format!("{old_tracking_ref}:{new_remote_ref}");
|
||||
let delete_refspec = format!(":{old_remote_ref}");
|
||||
|
||||
// Git has no standalone remote-rename command. Create the new ref and delete
|
||||
// the old one in a single atomic push so a rejected update leaves both untouched.
|
||||
run_git(
|
||||
&repo,
|
||||
[
|
||||
"push",
|
||||
"--atomic",
|
||||
source_lease.as_str(),
|
||||
destination_lease.as_str(),
|
||||
remote.as_str(),
|
||||
create_refspec.as_str(),
|
||||
delete_refspec.as_str(),
|
||||
],
|
||||
)?;
|
||||
|
||||
// Git normally updates remote-tracking refs after a successful push. Keep the
|
||||
// local view consistent as a fallback for unusual remote/refspec setups.
|
||||
if !ref_exists(&repo, &new_tracking_ref)? {
|
||||
if let Err(error) = run_git(
|
||||
&repo,
|
||||
["update-ref", new_tracking_ref.as_str(), old_hash.as_str()],
|
||||
) {
|
||||
log::warn!(target: "gitty::remote", "remote rename succeeded, but the new tracking ref could not be updated: {error}");
|
||||
}
|
||||
}
|
||||
if ref_exists(&repo, &old_tracking_ref)? {
|
||||
if let Err(error) = run_git(
|
||||
&repo,
|
||||
[
|
||||
"update-ref",
|
||||
"-d",
|
||||
old_tracking_ref.as_str(),
|
||||
old_hash.as_str(),
|
||||
],
|
||||
) {
|
||||
log::warn!(target: "gitty::remote", "remote rename succeeded, but the old tracking ref could not be removed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
|
||||
run_git_task("Could not load stashes", move || {
|
||||
@@ -749,7 +843,7 @@ fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
||||
repo,
|
||||
[
|
||||
"for-each-ref",
|
||||
"--format=%(refname)\t%(HEAD)",
|
||||
"--format=%(refname)\t%(HEAD)\t%(upstream:short)",
|
||||
"refs/heads",
|
||||
"refs/remotes",
|
||||
],
|
||||
@@ -758,9 +852,10 @@ fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
||||
let mut branches = Vec::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let Some((ref_name, head_marker)) = line.split_once('\t') else {
|
||||
continue;
|
||||
};
|
||||
let mut parts = line.splitn(3, '\t');
|
||||
let ref_name = parts.next().unwrap_or_default();
|
||||
let head_marker = parts.next().unwrap_or_default();
|
||||
let configured_upstream = parts.next().unwrap_or_default().trim();
|
||||
|
||||
let (name, remote) = if let Some(name) = ref_name.strip_prefix("refs/heads/") {
|
||||
(name, false)
|
||||
@@ -777,6 +872,11 @@ fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
||||
name: name.to_string(),
|
||||
current: head_marker.trim() == "*",
|
||||
remote,
|
||||
upstream: if remote || configured_upstream.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(configured_upstream.to_string())
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2332,8 +2432,6 @@ const CRED_SERVICE: &str = "tauri_git_lite";
|
||||
pub struct StoredCredential {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(default, rename = "expiresAt", skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
|
||||
@@ -2516,19 +2614,9 @@ pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn cred_save(
|
||||
key: String,
|
||||
username: String,
|
||||
password: String,
|
||||
expires_at: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
pub fn cred_save(key: String, username: String, password: String) -> Result<(), String> {
|
||||
let entry = cred_entry(&key)?;
|
||||
let expires_at = expires_at.filter(|value| !value.trim().is_empty());
|
||||
let cred = StoredCredential {
|
||||
username,
|
||||
password,
|
||||
expires_at,
|
||||
};
|
||||
let cred = StoredCredential { username, password };
|
||||
let json = serde_json::to_string(&cred)
|
||||
.map_err(|err| format!("Could not serialize credentials: {err}"))?;
|
||||
entry
|
||||
@@ -3129,6 +3217,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 +6235,141 @@ mod tests {
|
||||
run_git_test(repo, ["commit", "-q", "-m", "init"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branches_report_configured_upstream_and_local_only_state() {
|
||||
let repo = init_temp_repo("branch_upstream_state");
|
||||
commit_initial_file(&repo.path);
|
||||
run_git_test(&repo.path, ["branch", "feature/local-only"]);
|
||||
run_git_test(&repo.path, ["branch", "feature/tracked"]);
|
||||
run_git_test(&repo.path, ["remote", "add", "origin", "."]);
|
||||
run_git_test(
|
||||
&repo.path,
|
||||
[
|
||||
"update-ref",
|
||||
"refs/remotes/origin/feature/published",
|
||||
"HEAD",
|
||||
],
|
||||
);
|
||||
run_git_test(
|
||||
&repo.path,
|
||||
["config", "branch.feature/tracked.remote", "origin"],
|
||||
);
|
||||
run_git_test(
|
||||
&repo.path,
|
||||
[
|
||||
"config",
|
||||
"branch.feature/tracked.merge",
|
||||
"refs/heads/feature/published",
|
||||
],
|
||||
);
|
||||
|
||||
let branches = branches_for_repo(&repo.path).expect("branches should load");
|
||||
let local_only = branches
|
||||
.iter()
|
||||
.find(|branch| branch.name == "feature/local-only")
|
||||
.expect("local-only branch should exist");
|
||||
let tracked = branches
|
||||
.iter()
|
||||
.find(|branch| branch.name == "feature/tracked")
|
||||
.expect("tracked branch should exist");
|
||||
let remote = branches
|
||||
.iter()
|
||||
.find(|branch| branch.name == "origin/feature/published")
|
||||
.expect("remote branch should exist");
|
||||
|
||||
assert_eq!(local_only.upstream, None);
|
||||
assert_eq!(
|
||||
tracked.upstream.as_deref(),
|
||||
Some("origin/feature/published")
|
||||
);
|
||||
assert_eq!(remote.upstream, None);
|
||||
}
|
||||
|
||||
#[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!(
|
||||
@@ -6377,6 +6824,31 @@ mod tests {
|
||||
assert!(comparison.patch.contains("second line"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_commits_accepts_branch_refs_for_a_full_repository_diff() {
|
||||
let repo = init_temp_repo("compare_branches");
|
||||
commit_initial_file(&repo.path);
|
||||
run_git_test(&repo.path, ["branch", "base"]);
|
||||
|
||||
fs::write(repo.path.join("branch-only.txt"), "only on feature\n")
|
||||
.expect("branch file should be written");
|
||||
run_git_test(&repo.path, ["add", "branch-only.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "feature change"]);
|
||||
run_git_test(&repo.path, ["branch", "feature/complete-compare"]);
|
||||
|
||||
let comparison = compare_commits(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"refs/heads/base".to_string(),
|
||||
"refs/heads/feature/complete-compare".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(comparison.files.iter().any(|file| {
|
||||
file.path == "branch-only.txt" && file.status == FileStatusKind::Added
|
||||
}));
|
||||
assert!(comparison.patch.contains("only on feature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_commits_includes_full_file_context() {
|
||||
let repo = init_temp_repo("compare_full_context");
|
||||
@@ -6986,6 +7458,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(
|
||||
windows,
|
||||
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
|
||||
)]
|
||||
fn rename_remote_branch_moves_the_remote_ref_atomically() {
|
||||
let repo = init_temp_repo("rename_remote_branch");
|
||||
let remote = init_bare_temp_repo("rename_remote_branch_remote");
|
||||
commit_initial_file(&repo.path);
|
||||
let commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
||||
let remote_url = format!(
|
||||
"file:///{}",
|
||||
remote.path.to_string_lossy().replace('\\', "/")
|
||||
);
|
||||
|
||||
run_git_test(&repo.path, ["remote", "add", "origin", remote_url.as_str()]);
|
||||
run_git_test(
|
||||
&repo.path,
|
||||
["push", "-q", "origin", "HEAD:refs/heads/feature/old-name"],
|
||||
);
|
||||
run_git_test(&repo.path, ["fetch", "-q", "origin"]);
|
||||
|
||||
rename_remote_branch_core(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"origin".to_string(),
|
||||
"feature/old-name".to_string(),
|
||||
"feature/new-name".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!ref_exists(&remote.path, "refs/heads/feature/old-name").unwrap(),
|
||||
"old remote branch should be gone"
|
||||
);
|
||||
assert_eq!(
|
||||
git_output_test(&remote.path, ["rev-parse", "refs/heads/feature/new-name"]),
|
||||
commit
|
||||
);
|
||||
assert!(
|
||||
!ref_exists(&repo.path, "refs/remotes/origin/feature/old-name").unwrap(),
|
||||
"old remote-tracking branch should be gone"
|
||||
);
|
||||
assert!(
|
||||
ref_exists(&repo.path, "refs/remotes/origin/feature/new-name").unwrap(),
|
||||
"new remote-tracking branch should exist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_branch_removes_local_branch_but_rejects_current_branch() {
|
||||
let repo = init_temp_repo("delete_branch");
|
||||
|
||||
+27
-11
@@ -1,29 +1,35 @@
|
||||
#![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,
|
||||
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,
|
||||
rename_remote_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,
|
||||
stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote,
|
||||
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;
|
||||
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
||||
@@ -123,6 +129,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,
|
||||
@@ -135,6 +145,7 @@ async fn main() {
|
||||
checkout_branch,
|
||||
create_branch,
|
||||
rename_branch,
|
||||
rename_remote_branch,
|
||||
delete_branch,
|
||||
list_worktrees,
|
||||
add_worktree,
|
||||
@@ -174,6 +185,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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gitty",
|
||||
"version": "2026.8.2",
|
||||
"version": "2026.8.3",
|
||||
"identifier": "com.gitty",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+544
-51
File diff suppressed because it is too large
Load Diff
+555
-132
@@ -736,6 +736,29 @@
|
||||
}
|
||||
.repo-action-count.behind { color: #7aacff; }
|
||||
.repo-action-count.ahead { color: #e0a040; }
|
||||
.repo-action.sync-primary.publish-local {
|
||||
color: #f0bd6b;
|
||||
background: linear-gradient(180deg, rgba(224,160,64,.1), rgba(224,160,64,.045));
|
||||
}
|
||||
.repo-action.sync-primary.publish-local:hover:not(:disabled) {
|
||||
color: #ffd48c;
|
||||
background: rgba(224,160,64,.14);
|
||||
}
|
||||
.repo-action-local-marker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border: 1px dashed rgba(240,189,107,.5);
|
||||
border-radius: 4px;
|
||||
color: #f0bd6b;
|
||||
background: rgba(224,160,64,.08);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 7.5px;
|
||||
font-weight: 900;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.repo-toolbar-divider {
|
||||
width: 1px;
|
||||
height: 30px;
|
||||
@@ -1506,7 +1529,7 @@
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 8px minmax(0, 1fr) 8px minmax(560px, var(--history-aside-width, 620px));
|
||||
grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 8px minmax(0, 1fr) 8px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 620px));
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -1670,6 +1693,27 @@
|
||||
.sync-stats strong:first-of-type { color: #e0a040; background: rgba(224,160,64,0.13); }
|
||||
.sync-stats strong:last-of-type { color: #7aacff; background: rgba(122,172,255,0.13); }
|
||||
.sync-stats span { color: var(--color-ink-dim); background: rgba(94,110,156,0.13); }
|
||||
.sync-stats .sync-local-only {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 7px;
|
||||
border: 1px dashed rgba(224,160,64,.38);
|
||||
color: #f0bd6b;
|
||||
background: rgba(224,160,64,.09);
|
||||
}
|
||||
.sync-stats .sync-local-only strong {
|
||||
padding: 0;
|
||||
color: #f0bd6b;
|
||||
background: transparent;
|
||||
font-size: 10.5px;
|
||||
font-weight: 850;
|
||||
}
|
||||
.sync-stats .sync-local-only small {
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 9px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.top-section {
|
||||
display: grid;
|
||||
@@ -2408,6 +2452,7 @@
|
||||
.commit-row { display: grid; min-width: 0; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); transition: border-color 120ms; }
|
||||
.commit-row + .commit-row { margin-top: 5px; }
|
||||
.commit-row:hover { border-color: var(--color-border); }
|
||||
.commit-row.selected { border-color: color-mix(in srgb, var(--color-primary) 58%, var(--color-border)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent) inset; }
|
||||
.commit-row.compact { padding: 8px; }
|
||||
|
||||
.commit-line { display: flex; align-items: flex-start; min-width: 0; gap: 8px; }
|
||||
@@ -2491,38 +2536,6 @@
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.commit-local-branches {
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
max-width: min(100%, 260px);
|
||||
}
|
||||
.commit-branch-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
height: 17px;
|
||||
padding: 0 6px 0 5px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(91,209,138,0.22);
|
||||
border-radius: 999px;
|
||||
color: #a8eeba;
|
||||
background: rgba(34,68,48,0.42);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.commit-branch-chip svg {
|
||||
flex: 0 0 auto;
|
||||
color: #76d995;
|
||||
}
|
||||
.commit-author {
|
||||
flex: 1 1 80px;
|
||||
overflow: hidden;
|
||||
@@ -2530,30 +2543,270 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ref-list { display: flex; flex-wrap: wrap; gap: 3px; }
|
||||
.ref-list .ref-chip {
|
||||
.commit-ref-area {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.commit-ref-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
min-height: 20px;
|
||||
}
|
||||
.branch-ref-cluster {
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin-left: -10px;
|
||||
}
|
||||
.compact-ref-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: min(58%, 230px);
|
||||
height: 19px;
|
||||
padding: 0 6px 0 5px;
|
||||
overflow: hidden;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
color: var(--color-accent);
|
||||
background: rgba(106,154,255,0.09);
|
||||
border: 1px solid rgba(106,154,255,0.16);
|
||||
border: 1px solid rgba(91,209,138,0.2);
|
||||
border-radius: 5px;
|
||||
color: #a8eeba;
|
||||
background: rgba(34,68,48,0.3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.compact-ref-chip.branch {
|
||||
flex: 0 1 auto;
|
||||
max-width: 22px;
|
||||
height: 20px;
|
||||
margin-left: 0;
|
||||
padding: 0 5px;
|
||||
border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 34%, transparent);
|
||||
border-left-width: 2px;
|
||||
border-radius: 0 5px 5px 0 !important;
|
||||
color: #dce9ff;
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #69a7ff) 14%, rgba(18,24,36,.96)), rgba(18,24,36,.82));
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--ref-lane-color, #69a7ff) 72%, transparent);
|
||||
transition:
|
||||
max-width 190ms cubic-bezier(.2,.75,.25,1),
|
||||
padding-right 190ms cubic-bezier(.2,.75,.25,1),
|
||||
border-color 140ms ease,
|
||||
background 140ms ease,
|
||||
box-shadow 140ms ease;
|
||||
}
|
||||
.branch-ref-cluster.local-only .compact-ref-chip.branch {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
.compact-ref-local-marker {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 20px;
|
||||
margin-left: -1px;
|
||||
padding: 0 5px 0 4px;
|
||||
border: 1px dashed rgba(240,189,107,.58);
|
||||
border-left-style: solid;
|
||||
border-radius: 0 5px 5px 0;
|
||||
color: #f0bd6b;
|
||||
background: linear-gradient(90deg, rgba(224,160,64,.13), rgba(224,160,64,.06));
|
||||
font-family: var(--font-mono);
|
||||
font-size: 7.5px;
|
||||
font-weight: 900;
|
||||
letter-spacing: .05em;
|
||||
line-height: 1;
|
||||
box-shadow: inset 1px 0 0 rgba(240,189,107,.2);
|
||||
}
|
||||
.compact-ref-branch-icon {
|
||||
flex: 0 0 auto;
|
||||
color: var(--ref-lane-color, #69a7ff);
|
||||
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 28%, transparent));
|
||||
transition: transform 190ms cubic-bezier(.2,.75,.25,1);
|
||||
}
|
||||
.compact-ref-chip > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.commit-ref-detail-item > i {
|
||||
flex: 0 0 auto;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--ref-lane-color, #69a7ff);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 18%, transparent);
|
||||
}
|
||||
.compact-ref-chip.current {
|
||||
border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 62%, transparent);
|
||||
color: #eaf2ff;
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #69a7ff) 25%, rgba(18,24,36,.96)), rgba(18,24,36,.9));
|
||||
}
|
||||
.compact-ref-chip.remote {
|
||||
border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 38%, transparent);
|
||||
color: #bcd2ff;
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #69a7ff) 14%, rgba(20,27,42,.94)), rgba(20,27,42,.84));
|
||||
}
|
||||
.compact-ref-chip.branch > span,
|
||||
.compact-ref-chip.branch > small {
|
||||
opacity: 0;
|
||||
transition: opacity 80ms ease;
|
||||
}
|
||||
.graph-row:hover .compact-ref-chip.branch,
|
||||
.graph-row.selected .compact-ref-chip.branch,
|
||||
.graph-row:focus-within .compact-ref-chip.branch {
|
||||
flex: 1 1 auto;
|
||||
max-width: 100%;
|
||||
padding-right: 8px;
|
||||
border-color: color-mix(in srgb, var(--ref-lane-color, #69a7ff) 64%, transparent);
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #69a7ff) 24%, rgba(18,24,36,.98)), rgba(18,24,36,.9));
|
||||
box-shadow:
|
||||
inset 2px 0 0 var(--ref-lane-color, #69a7ff),
|
||||
0 3px 12px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 12%, rgba(0,0,0,.24));
|
||||
}
|
||||
.graph-row:hover .commit-ref-strip,
|
||||
.graph-row.selected .commit-ref-strip,
|
||||
.graph-row:focus-within .commit-ref-strip {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
}
|
||||
.graph-row:hover .branch-ref-cluster,
|
||||
.graph-row.selected .branch-ref-cluster,
|
||||
.graph-row:focus-within .branch-ref-cluster {
|
||||
flex: 0 0 auto;
|
||||
max-width: min(100%, 320px);
|
||||
}
|
||||
.graph-row:hover .compact-ref-branch-icon,
|
||||
.graph-row.selected .compact-ref-branch-icon,
|
||||
.graph-row:focus-within .compact-ref-branch-icon {
|
||||
transform: translateX(1px);
|
||||
}
|
||||
.graph-row:hover .compact-ref-chip.branch > span,
|
||||
.graph-row:hover .compact-ref-chip.branch > small,
|
||||
.graph-row.selected .compact-ref-chip.branch > span,
|
||||
.graph-row.selected .compact-ref-chip.branch > small,
|
||||
.graph-row:focus-within .compact-ref-chip.branch > span,
|
||||
.graph-row:focus-within .compact-ref-chip.branch > small {
|
||||
opacity: 1;
|
||||
transition-delay: 55ms;
|
||||
transition-duration: 120ms;
|
||||
}
|
||||
.compact-ref-chip.tag {
|
||||
flex: 0 1 auto;
|
||||
max-width: min(32%, 150px);
|
||||
padding-inline: 4px;
|
||||
border-color: transparent;
|
||||
color: #dbc078;
|
||||
background: transparent;
|
||||
}
|
||||
.compact-ref-chip.tag svg { flex: 0 0 auto; }
|
||||
.compact-ref-chip small {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-left: 4px;
|
||||
border-left: 1px solid rgba(255,255,255,.12);
|
||||
color: #f0bd6b;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 8.5px;
|
||||
font-weight: 850;
|
||||
}
|
||||
.compact-ref-chip small.up-to-date { color: #7de39b; }
|
||||
.compact-ref-overflow {
|
||||
flex: 0 0 auto;
|
||||
min-width: 29px;
|
||||
min-height: 19px;
|
||||
height: 19px;
|
||||
padding: 0 5px;
|
||||
border-color: transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--color-ink-dim);
|
||||
background: rgba(255,255,255,0.025);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.compact-ref-overflow:hover:not(:disabled),
|
||||
.compact-ref-overflow[aria-expanded="true"] {
|
||||
border-color: rgba(101,162,255,.34);
|
||||
color: var(--color-ink);
|
||||
background: rgba(101,162,255,.1);
|
||||
}
|
||||
.commit-ref-details {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(94,110,156,.2);
|
||||
border-radius: 7px;
|
||||
background: rgba(10,14,21,.68);
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--ref-lane-color, #69a7ff) 65%, transparent);
|
||||
}
|
||||
.commit-ref-details > strong {
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.commit-ref-details section {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 7px;
|
||||
}
|
||||
.commit-ref-details section > span {
|
||||
padding-top: 3px;
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 8px;
|
||||
font-weight: 850;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.commit-ref-details section > div { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; }
|
||||
.commit-ref-detail-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
min-height: 20px;
|
||||
padding: 2px 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(94,110,156,.18);
|
||||
border-radius: 5px;
|
||||
color: var(--color-ink-dim);
|
||||
background: rgba(255,255,255,.025);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ref-list .ref-chip.head {
|
||||
color: #061021;
|
||||
border-color: rgba(65,209,255,0.48);
|
||||
background: linear-gradient(135deg, #41d1ff, #7c6cff);
|
||||
box-shadow: 0 0 14px rgba(65,209,255,0.2);
|
||||
.commit-ref-detail-item.local { color: #a8eeba; border-color: rgba(91,209,138,.18); }
|
||||
.commit-ref-detail-item.remote { color: #bcd2ff; border-color: rgba(122,172,255,.2); }
|
||||
.commit-ref-detail-item.tag { color: #dbc078; border-color: rgba(224,180,92,.2); }
|
||||
.commit-ref-detail-item small {
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.commit-ref-detail-item small.local-only {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: #d99532;
|
||||
}
|
||||
.ref-list .ref-chip.branch { color: #7ddf9c; background: rgba(78,202,118,0.08); border-color: rgba(78,202,118,0.18); }
|
||||
.ref-list .ref-chip.remote { color: #aeb6ff; background: rgba(124,108,255,0.08); border-color: rgba(124,108,255,0.18); }
|
||||
.ref-list .ref-chip.tag { color: #dbc078; background: rgba(224,180,92,0.08); border-color: rgba(224,180,92,0.2); }
|
||||
|
||||
.commit-files {
|
||||
display: grid;
|
||||
@@ -2619,6 +2872,16 @@
|
||||
background: rgba(65,209,255,0.08);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.branch-filter-group-label {
|
||||
padding: 9px 9px 3px;
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 9px;
|
||||
font-weight: 850;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.commit-note-button:not(:disabled) { color: color-mix(in srgb, var(--color-accent) 72%, var(--color-ink-dim)); }
|
||||
/* --- Git graph --- */
|
||||
|
||||
.graph-list {
|
||||
@@ -2692,6 +2955,10 @@
|
||||
stroke-dasharray: 4 4;
|
||||
filter: drop-shadow(0 0 3px rgba(122,172,255,0.2));
|
||||
}
|
||||
.graph-svg path.graph-ref-connector {
|
||||
opacity: 0.35;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.graph-dot {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
@@ -2725,64 +2992,13 @@
|
||||
border-color: var(--dot-color, #5a8cf8);
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,0.1);
|
||||
}
|
||||
.graph-hover-branches {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 50%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
max-width: 190px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%) translateX(-4px);
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
.graph-gutter:hover .graph-hover-branches {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
.graph-hover-branches span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
max-width: 180px;
|
||||
height: 18px;
|
||||
padding: 0 6px 0 5px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(91,209,138,0.28);
|
||||
border-radius: 999px;
|
||||
color: #b2f0c2;
|
||||
background: rgba(14,36,27,0.94);
|
||||
box-shadow: 0 8px 22px rgba(0,0,0,0.24);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.graph-hover-branches span.remote,
|
||||
.branch-filter-option.remote {
|
||||
border-color: rgba(122,172,255,0.32);
|
||||
color: #bcd2ff;
|
||||
background: rgba(31,43,72,0.88);
|
||||
}
|
||||
.graph-hover-branches span.ahead {
|
||||
border-color: rgba(224,160,64,0.42);
|
||||
color: #ffd99a;
|
||||
background: rgba(58,42,20,0.94);
|
||||
}
|
||||
.graph-hover-branches span.behind {
|
||||
border-color: rgba(122,172,255,0.46);
|
||||
color: #c7dbff;
|
||||
background: rgba(25,39,70,0.94);
|
||||
}
|
||||
.graph-hover-branches svg {
|
||||
flex: 0 0 auto;
|
||||
color: #76d995;
|
||||
}
|
||||
.commit-body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
@@ -2790,6 +3006,39 @@
|
||||
background: rgba(18,24,36,0.5);
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.commit-body.has-branch-ref::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 24px;
|
||||
bottom: calc(50% + 16px);
|
||||
left: -16px;
|
||||
width: 16px;
|
||||
min-height: 14px;
|
||||
border-top: 1.5px solid var(--ref-lane-color, #69a7ff);
|
||||
border-left: 1.5px solid var(--ref-lane-color, #69a7ff);
|
||||
border-top-left-radius: 14px;
|
||||
opacity: .35;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 12%, transparent));
|
||||
transition: opacity 140ms ease, filter 140ms ease;
|
||||
}
|
||||
.commit-body.has-branch-ref::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: calc(50% - 16px);
|
||||
left: -32px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-right: 1.5px solid var(--ref-lane-color, #69a7ff);
|
||||
border-bottom: 1.5px solid var(--ref-lane-color, #69a7ff);
|
||||
border-bottom-right-radius: 14px;
|
||||
opacity: .35;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 12%, transparent));
|
||||
transition: opacity 140ms ease, filter 140ms ease;
|
||||
}
|
||||
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(226,232,240,0.075); }
|
||||
.graph-row.graph-ahead-row .commit-body {
|
||||
box-shadow: inset 3px 0 0 rgba(224,160,64,0.72);
|
||||
@@ -2799,6 +3048,18 @@
|
||||
}
|
||||
.graph-row:hover .commit-body { background: rgba(30,39,57,0.72); }
|
||||
.graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; }
|
||||
.graph-row:hover .graph-svg path.graph-ref-connector,
|
||||
.graph-row.selected .graph-svg path.graph-ref-connector,
|
||||
.graph-row:focus-within .graph-svg path.graph-ref-connector { opacity: .95; stroke-width: 1.8; }
|
||||
.graph-row:hover .commit-body.has-branch-ref::before,
|
||||
.graph-row:hover .commit-body.has-branch-ref::after,
|
||||
.graph-row.selected .commit-body.has-branch-ref::before,
|
||||
.graph-row.selected .commit-body.has-branch-ref::after,
|
||||
.graph-row:focus-within .commit-body.has-branch-ref::before,
|
||||
.graph-row:focus-within .commit-body.has-branch-ref::after {
|
||||
opacity: .95;
|
||||
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--ref-lane-color, #69a7ff) 24%, transparent));
|
||||
}
|
||||
.graph-row:hover .graph-svg path.hidden-branch { opacity: 0.12; stroke-width: 2.2; }
|
||||
.graph-row:hover .graph-dot { transform: translate(-50%, -50%) scale(1.12); }
|
||||
.graph-row:hover .graph-dot.hidden-branch { transform: translate(-50%, -50%) scale(1); }
|
||||
@@ -2811,6 +3072,37 @@
|
||||
rgba(20,27,40,0.62);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.compact-ref-chip.branch,
|
||||
.compact-ref-chip.branch > span,
|
||||
.compact-ref-chip.branch > small,
|
||||
.compact-ref-branch-icon,
|
||||
.commit-body.has-branch-ref::before,
|
||||
.commit-body.has-branch-ref::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.commit-ref-strip {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
}
|
||||
.branch-ref-cluster {
|
||||
flex: 0 0 auto;
|
||||
max-width: min(100%, 320px);
|
||||
}
|
||||
.compact-ref-chip.branch {
|
||||
flex: 1 1 auto;
|
||||
max-width: 100%;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.compact-ref-chip.branch > span,
|
||||
.compact-ref-chip.branch > small {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Compare panel --- */
|
||||
|
||||
.compare-panel {
|
||||
@@ -2834,6 +3126,16 @@
|
||||
.compare-field { display: grid; gap: 4px; min-width: 0; }
|
||||
.compare-field span { color: var(--color-ink-faint); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.compare-arrow { margin-bottom: 6px; color: var(--color-ink-faint); }
|
||||
.compare-target-help {
|
||||
margin: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle));
|
||||
border-radius: 8px;
|
||||
color: var(--color-ink-dim);
|
||||
background: color-mix(in srgb, var(--color-accent) 5%, var(--color-surface-raised));
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.compare-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; padding: 10px 12px; }
|
||||
.compare-range { display: flex; align-items: center; gap: 7px; color: var(--color-ink-muted); }
|
||||
@@ -3187,6 +3489,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));
|
||||
@@ -3493,6 +3798,34 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.new-branch-field > div { display: flex; min-width: 0; }
|
||||
.new-branch-field > div > input { width: 100%; min-width: 0; }
|
||||
.remote-branch-name-field > strong {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
height: 34px;
|
||||
padding: 0 0 0 11px;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-right: 0;
|
||||
border-radius: var(--ui-radius-sm) 0 0 var(--ui-radius-sm);
|
||||
color: var(--color-ink-faint);
|
||||
background: var(--color-surface-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.remote-branch-name-field > input { border-radius: 0 var(--ui-radius-sm) var(--ui-radius-sm) 0; }
|
||||
.rename-remote-note {
|
||||
margin: -2px 0 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 8px;
|
||||
color: var(--color-ink-dim);
|
||||
background: var(--color-surface-dim);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.rename-remote-note strong { color: var(--color-ink); font-family: var(--font-mono); font-weight: 700; }
|
||||
.new-branch-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -3502,14 +3835,26 @@
|
||||
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||
.dialog-header > 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; }
|
||||
|
||||
.dialog-range { display: flex; align-items: center; gap: 8px; margin: 2px 0 0; color: var(--color-accent); font-size: 15px; }
|
||||
.dialog-range { display: flex; align-items: center; gap: 8px; max-width: min(68vw, 780px); margin: 2px 0 0; color: var(--color-accent); font-size: 15px; }
|
||||
.dialog-range .hash { min-width: 0; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dialog-title { margin: 2px 0 0; color: var(--color-ink); font-size: 15px; font-weight: 600; }
|
||||
|
||||
.dialog-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); }
|
||||
|
||||
@@ -3649,6 +3994,11 @@
|
||||
}
|
||||
.split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); }
|
||||
.split-col-hash {
|
||||
min-width: 0;
|
||||
max-width: min(42%, 260px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 1px 6px;
|
||||
border-radius: 5px;
|
||||
background: rgba(90,140,248,0.12);
|
||||
@@ -5519,7 +5869,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
||||
.repo-toolbar-divider { height: 34px; margin-inline: 6px; }
|
||||
|
||||
.workspace {
|
||||
grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 7px minmax(360px, 1fr) 7px minmax(560px, var(--history-aside-width, 620px));
|
||||
grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 7px minmax(360px, 1fr) 7px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 620px));
|
||||
flex: 1 1 0;
|
||||
padding: 0;
|
||||
background: var(--color-border-subtle);
|
||||
@@ -5640,7 +5990,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
||||
contain-intrinsic-block-size: 108px;
|
||||
}
|
||||
.commit-avatar { border-radius: 50%; }
|
||||
.commit-kind, .commit-branch-chip, .ref-chip { border-radius: 4px !important; }
|
||||
.commit-kind, .compact-ref-chip { border-radius: 4px !important; }
|
||||
.workspace-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -5658,6 +6008,17 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
||||
.workspace-health.clean > span { background: #2da44e; }
|
||||
.workspace-statusbar .ahead { color: #d9891b; }
|
||||
.workspace-statusbar .behind { color: var(--color-primary); }
|
||||
.workspace-statusbar .workspace-local-only {
|
||||
padding: 2px 6px;
|
||||
border: 1px dashed rgba(224,160,64,.42);
|
||||
border-radius: 4px;
|
||||
color: #f0bd6b;
|
||||
background: rgba(224,160,64,.08);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 850;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.workspace-auto i { width: 7px; height: 7px; border-radius: 50%; background: var(--color-ink-faint); }
|
||||
.workspace-auto.active i { background: #2da44e; }
|
||||
.app-version {
|
||||
@@ -5886,46 +6247,80 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
||||
color: #315fd6;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .commit-branch-chip,
|
||||
:root[data-theme="light"] .graph-hover-branches span {
|
||||
border-color: rgba(31,128,76,0.22);
|
||||
color: #146b3b;
|
||||
background: rgba(224,246,233,0.92);
|
||||
box-shadow: 0 8px 22px rgba(28,44,74,0.12);
|
||||
:root[data-theme="light"] .compact-ref-chip.branch {
|
||||
border-color: color-mix(in srgb, var(--ref-lane-color, #315fd6) 48%, rgba(49,95,214,.16));
|
||||
color: #18345f;
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #315fd6) 14%, #f7faff), #f7faff);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .compact-ref-local-marker {
|
||||
border-color: rgba(154,82,0,.46);
|
||||
color: #8b5207;
|
||||
background: linear-gradient(90deg, rgba(217,137,27,.14), rgba(217,137,27,.06));
|
||||
box-shadow: inset 1px 0 0 rgba(154,82,0,.14);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .compact-ref-chip.current {
|
||||
border-color: color-mix(in srgb, var(--ref-lane-color, #315fd6) 58%, rgba(49,95,214,.2));
|
||||
color: #18345f;
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #315fd6) 20%, #f7faff), #f7faff);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .graph-hover-branches span.remote,
|
||||
:root[data-theme="light"] .branch-filter-option.remote {
|
||||
border-color: rgba(49,95,214,0.22);
|
||||
color: #315fd6;
|
||||
background: rgba(231,237,255,0.92);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .graph-hover-branches span.ahead {
|
||||
border-color: rgba(150,98,15,0.28);
|
||||
color: #96620f;
|
||||
background: rgba(255,244,224,0.95);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .graph-hover-branches span.behind {
|
||||
border-color: rgba(49,95,214,0.26);
|
||||
:root[data-theme="light"] .compact-ref-chip.remote {
|
||||
border-color: color-mix(in srgb, var(--ref-lane-color, #315fd6) 42%, rgba(49,95,214,.16));
|
||||
color: #315fd6;
|
||||
background: rgba(231,237,255,0.95);
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #315fd6) 12%, #f7faff), #f7faff);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .ref-list .ref-chip.head {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #0f8fb5, #315fd6);
|
||||
:root[data-theme="light"] .graph-row:hover .compact-ref-chip.branch,
|
||||
:root[data-theme="light"] .graph-row.selected .compact-ref-chip.branch,
|
||||
:root[data-theme="light"] .graph-row:focus-within .compact-ref-chip.branch {
|
||||
border-color: color-mix(in srgb, var(--ref-lane-color, #315fd6) 62%, rgba(49,95,214,.2));
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--ref-lane-color, #315fd6) 22%, #f7faff), #ffffff);
|
||||
box-shadow:
|
||||
inset 2px 0 0 var(--ref-lane-color, #315fd6),
|
||||
0 3px 12px color-mix(in srgb, var(--ref-lane-color, #315fd6) 10%, rgba(34,49,78,.16));
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .ref-list .ref-chip.branch {
|
||||
color: #16723b;
|
||||
background: rgba(78,202,118,0.12);
|
||||
:root[data-theme="light"] .compact-ref-chip.tag,
|
||||
:root[data-theme="light"] .commit-ref-detail-item.tag {
|
||||
color: #8b5d0e;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .ref-list .ref-chip.remote {
|
||||
color: #315fd6;
|
||||
background: rgba(49,95,214,0.09);
|
||||
:root[data-theme="light"] .compact-ref-chip.tag {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .compact-ref-overflow {
|
||||
color: #60708a;
|
||||
border-color: transparent;
|
||||
background: rgba(49,95,214,.035);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .graph-branch-dialog-button {
|
||||
color: #475873;
|
||||
border-color: rgba(49,95,214,.16);
|
||||
background: rgba(244,247,252,.9);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .commit-ref-details {
|
||||
border-color: rgba(49,95,214,.16);
|
||||
background: rgba(247,249,253,.94);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .commit-ref-detail-item {
|
||||
color: #475873;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .commit-files,
|
||||
@@ -6133,16 +6528,16 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
||||
/* --- Responsive breakpoints --- */
|
||||
|
||||
@media (min-width: 1800px) {
|
||||
.workspace { grid-template-columns: minmax(220px, var(--left-sidebar-width, 320px)) 8px minmax(0, 1fr) 8px minmax(560px, var(--history-aside-width, 680px)); }
|
||||
.workspace { grid-template-columns: minmax(220px, var(--left-sidebar-width, 320px)) 8px minmax(0, 1fr) 8px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 680px)); }
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.workspace { grid-template-columns: minmax(200px, var(--left-sidebar-width, 265px)) 8px minmax(0, 1fr) 8px minmax(540px, var(--history-aside-width, 580px)); }
|
||||
.workspace { grid-template-columns: minmax(200px, var(--left-sidebar-width, 265px)) 8px minmax(0, 1fr) 8px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 580px)); }
|
||||
}
|
||||
|
||||
/* Stack CommitPanel below StatusPanel; history panels stay side by side */
|
||||
@media (max-width: 1100px) {
|
||||
.workspace { grid-template-columns: minmax(185px, var(--left-sidebar-width, 220px)) 8px minmax(0, 1fr) 8px minmax(500px, var(--history-aside-width, 560px)); }
|
||||
.workspace { grid-template-columns: minmax(185px, var(--left-sidebar-width, 220px)) 8px minmax(0, 1fr) 8px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 560px)); }
|
||||
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); }
|
||||
}
|
||||
|
||||
@@ -6435,6 +6830,19 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
||||
color: #0755c8;
|
||||
font-weight: 800;
|
||||
}
|
||||
:root[data-theme="light"] .repo-action.sync-primary.publish-local,
|
||||
:root[data-theme="light"] .repo-action-local-marker,
|
||||
:root[data-theme="light"] .workspace-statusbar .workspace-local-only {
|
||||
color: #8b5207;
|
||||
border-color: rgba(154,82,0,.4);
|
||||
background: rgba(217,137,27,.09);
|
||||
}
|
||||
:root[data-theme="light"] .sync-stats .sync-local-only {
|
||||
border-color: rgba(154,82,0,.36);
|
||||
color: #8b5207;
|
||||
background: rgba(217,137,27,.09);
|
||||
}
|
||||
:root[data-theme="light"] .sync-stats .sync-local-only strong { color: #8b5207; }
|
||||
|
||||
/* AI pre-commit review --------------------------------------------------- */
|
||||
.commit-review-button {
|
||||
@@ -6588,6 +6996,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,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ChevronDown,
|
||||
Code2,
|
||||
CloudDownload,
|
||||
CloudOff,
|
||||
Download,
|
||||
FolderOpen,
|
||||
GitCompare,
|
||||
@@ -12,6 +14,7 @@
|
||||
Search,
|
||||
Upload,
|
||||
Settings2,
|
||||
Terminal,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
export let hasRepository: boolean = false;
|
||||
@@ -19,7 +22,11 @@
|
||||
export let operation: string = "";
|
||||
export let ahead: number = 0;
|
||||
export let behind: number = 0;
|
||||
export let localOnly: boolean = false;
|
||||
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 +36,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 = () => {};
|
||||
@@ -38,6 +47,12 @@
|
||||
let toolbarElement: HTMLDivElement;
|
||||
|
||||
$: isGerman = language === "de";
|
||||
$: pushLabel = localOnly ? (isGerman ? "Veröffentlichen" : "Publish") : "Push";
|
||||
$: pushTitle = localOnly
|
||||
? (isGerman
|
||||
? "Dieser Branch existiert nur lokal. Veröffentlichen erstellt den Remote-Branch und richtet das Tracking ein."
|
||||
: "This branch exists only locally. Publish creates the remote branch and configures tracking.")
|
||||
: "Push";
|
||||
|
||||
function runHistoryAction(action: () => void) {
|
||||
historyOpen = false;
|
||||
@@ -96,10 +111,13 @@
|
||||
|
||||
<button
|
||||
class="repo-action sync-primary"
|
||||
class:publish-local={localOnly}
|
||||
onclick={onPush}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Push"
|
||||
aria-label={ahead > 0
|
||||
title={pushTitle}
|
||||
aria-label={localOnly
|
||||
? pushTitle
|
||||
: ahead > 0
|
||||
? `Push, ${ahead} ${isGerman ? (ahead === 1 ? "lokaler Commit voraus" : "lokale Commits voraus") : (ahead === 1 ? "commit ahead" : "commits ahead")}`
|
||||
: "Push"}
|
||||
>
|
||||
@@ -108,8 +126,12 @@
|
||||
{:else}
|
||||
<Upload size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="repo-action-label">Push</span>
|
||||
{#if ahead > 0}<span class="repo-action-count ahead">↑{ahead}</span>{/if}
|
||||
<span class="repo-action-label">{pushLabel}</span>
|
||||
{#if localOnly}
|
||||
<span class="repo-action-local-marker"><CloudOff size={9} aria-hidden="true" />{isGerman ? "NUR LOKAL" : "LOCAL"}</span>
|
||||
{:else if ahead > 0}
|
||||
<span class="repo-action-count ahead">↑{ahead}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<div class="repo-history-wrap">
|
||||
<button class="repo-action" type="button" onclick={() => { syncOpen = !syncOpen; historyOpen = false; }} disabled={!hasRepository || isBusy} aria-label={isGerman ? "Sync-Optionen" : "Sync options"} aria-haspopup="menu">
|
||||
@@ -144,8 +166,8 @@
|
||||
class="repo-action"
|
||||
onclick={onCompare}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title={isGerman ? "Commits vergleichen" : "Compare commits"}
|
||||
aria-label={isGerman ? "Commits vergleichen" : "Compare commits"}
|
||||
title={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
|
||||
aria-label={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}
|
||||
>
|
||||
<GitCompare size={15} aria-hidden="true" />
|
||||
<span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span>
|
||||
@@ -192,15 +214,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
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
const key = CRED_KEYS[target];
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
await credSave(key, "api-key", trimmed, null);
|
||||
await credSave(key, "api-key", trimmed);
|
||||
} else {
|
||||
await credDelete(key);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<div>
|
||||
<span class="eyebrow">Gitty</span>
|
||||
<h2 class="dialog-title">{isGerman ? "Einstellungen" : "Settings"}</h2>
|
||||
<header class="app-settings-head">
|
||||
<div class="app-settings-title">
|
||||
<span class="app-settings-mark"><Settings2 size={18} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<h2>{isGerman ? "Einstellungen" : "Settings"}</h2>
|
||||
<p>{isGerman ? "Gitty an deinen Workflow anpassen" : "Make Gitty fit your workflow"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"}>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Einstellungen schließen" : "Close settings"}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</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-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}>
|
||||
<label class:active={selectedTheme === "system"}>
|
||||
<input type="radio" bind:group={selectedTheme} value="system" />
|
||||
<span>System</span>
|
||||
</label>
|
||||
<label class:active={selectedTheme === "light"}>
|
||||
<input type="radio" bind:group={selectedTheme} value="light" />
|
||||
<span>{isGerman ? "Hell" : "Light"}</span>
|
||||
</label>
|
||||
<label class:active={selectedTheme === "dark"}>
|
||||
<input type="radio" bind:group={selectedTheme} value="dark" />
|
||||
<span>{isGerman ? "Dunkel" : "Dark"}</span>
|
||||
</label>
|
||||
<div 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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="general-setting-panel">
|
||||
<header><Languages size={16} /><div><h4>{isGerman ? "Sprache" : "Language"}</h4><p>{isGerman ? "Sprache der Oberfläche." : "Language used by the interface."}</p></div></header>
|
||||
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
|
||||
<label class:active={selectedLanguage === "en"}><input type="radio" bind:group={selectedLanguage} value="en" /><span>EN · English</span></label>
|
||||
<label class:active={selectedLanguage === "de"}><input type="radio" bind:group={selectedLanguage} value="de" /><span>DE · Deutsch</span></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="general-setting-panel general-setting-wide">
|
||||
<header><RefreshCw size={16} /><div><h4>{isGerman ? "Repository-Aktualisierung" : "Repository refresh"}</h4><p>{isGerman ? "Arbeitsbereich und Remotes aktuell halten." : "Keep the working tree and remotes current."}</p></div></header>
|
||||
<label class="settings-switch-row">
|
||||
<span><strong>{isGerman ? "Automatisch aktualisieren" : "Refresh automatically"}</strong><small>{isGerman ? "Branch-Status und Änderungen regelmäßig im Hintergrund prüfen." : "Periodically check branch state and working-tree changes."}</small></span>
|
||||
<input type="checkbox" bind:checked={autoRefreshEnabled} />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="general-setting-panel general-setting-wide">
|
||||
<header><ShieldCheck size={16} /><div><h4>{isGerman ? "Datenschutz" : "Privacy"}</h4><p>{isGerman ? "Anonyme Produkt- und Fehlerdiagnose." : "Anonymous product and error diagnostics."}</p></div></header>
|
||||
<label class="settings-switch-row">
|
||||
<span><strong>{isGerman ? "Anonyme Analytics erlauben" : "Allow anonymous analytics"}</strong><small>{isGerman ? "Keine Pfade, Remotes, Branches, Diffs, Zugangsdaten oder Quelltexte." : "No paths, remotes, branches, diffs, credentials, or source code."}</small></span>
|
||||
<input type="checkbox" bind:checked={analyticsEnabled} />
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="settings-page-head tools-page-head">
|
||||
<div>
|
||||
<h3>{isGerman ? "Externe Tools" : "External tools"}</h3>
|
||||
<p>
|
||||
{detectionUnavailable
|
||||
? (isGerman ? "Automatische Erkennung ist in dieser Umgebung nicht verfügbar." : "Automatic detection is unavailable in this environment.")
|
||||
: (isGerman ? `${detectedTools.length} installierte Programme erkannt.` : `${detectedTools.length} installed applications detected.`)}
|
||||
</p>
|
||||
</div>
|
||||
<button class="tool-rescan-button" type="button" onclick={onRefreshDetectedTools} disabled={detectionPending}>
|
||||
<RotateCw class={detectionPending ? "spin" : ""} size={14} aria-hidden="true" />
|
||||
{detectionPending ? (isGerman ? "Erkennung läuft…" : "Detecting…") : (isGerman ? "Neu erkennen" : "Detect again")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-kind-tabs" role="tablist" aria-label={isGerman ? "Tool-Kategorie" : "Tool category"}>
|
||||
{#each toolKinds as kind}
|
||||
<button type="button" role="tab" aria-selected={activeToolKind === kind} class:active={activeToolKind === kind} onclick={() => selectToolKind(kind)}>
|
||||
{#if kind === "editor"}<Code2 size={16} />
|
||||
{:else if kind === "diff"}<GitCompare size={16} />
|
||||
{:else if kind === "merge"}<GitMerge size={16} />
|
||||
{:else if kind === "terminal"}<Terminal size={16} />
|
||||
{:else}<FolderOpen size={16} />{/if}
|
||||
<span>{toolLabel(kind)}</span>
|
||||
<small class:available={selectionAvailable(kind)}></small>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<section class="tool-config-panel" aria-label={`${toolLabel(activeToolKind)} ${isGerman ? "konfigurieren" : "configuration"}`}>
|
||||
<div class="tool-config-summary">
|
||||
<span class="tool-config-icon">
|
||||
{#if activeToolKind === "editor"}<Code2 size={22} />
|
||||
{:else if activeToolKind === "diff"}<GitCompare size={22} />
|
||||
{:else if activeToolKind === "merge"}<GitMerge size={22} />
|
||||
{:else if activeToolKind === "terminal"}<Terminal size={22} />
|
||||
{:else}<FolderOpen size={22} />{/if}
|
||||
</span>
|
||||
<div>
|
||||
<h4>{toolLabel(activeToolKind)}</h4>
|
||||
<p>{toolDescription(activeToolKind)}</p>
|
||||
</div>
|
||||
<span class="tool-status" class:available={selectionAvailable(activeToolKind)}>
|
||||
{#if selectionAvailable(activeToolKind)}<CheckCircle2 size={13} />{:else}<CircleDashed size={13} />{/if}
|
||||
{selectionStatus(activeToolKind)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if activeToolKind === "diff" || activeToolKind === "merge"}
|
||||
{@const openModeKind = activeToolKind as "diff" | "merge"}
|
||||
<div class="tool-route" aria-label={isGerman ? "Aktuelle Standardansicht" : "Current default view"}>
|
||||
<span>{isGerman ? "Standard" : "Default"}</span><ChevronRight size={15} aria-hidden="true" />
|
||||
<strong>{openMode(openModeKind) === "gitty" ? (isGerman ? "Gitty · integriert" : "Gitty · built in") : selectedToolName(activeToolKind)}</strong>
|
||||
</div>
|
||||
|
||||
<fieldset class="tool-open-mode">
|
||||
<legend>{isGerman ? "Beim Öffnen verwenden" : "Use when opening"}</legend>
|
||||
<div>
|
||||
<button type="button" class:active={openMode(openModeKind) === "gitty"} aria-pressed={openMode(openModeKind) === "gitty"} onclick={() => setOpenMode(openModeKind, "gitty")}>
|
||||
<span>Gitty</span><small>{isGerman ? "Integrierte Ansicht" : "Built-in view"}</small>
|
||||
</button>
|
||||
<button type="button" class:active={openMode(openModeKind) === "external"} aria-pressed={openMode(openModeKind) === "external"} onclick={() => setOpenMode(openModeKind, "external")}>
|
||||
<span>{selectedToolName(activeToolKind)}</span><small>{isGerman ? "Externes Programm" : "External application"}</small>
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
{:else}
|
||||
<div class="tool-route" aria-label={isGerman ? "Aktuelle Standardzuordnung" : "Current default mapping"}>
|
||||
<span>Gitty</span><ChevronRight size={15} aria-hidden="true" /><strong>{selectedToolName(activeToolKind)}</strong>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="tool-default-field">
|
||||
<span>{isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}</span>
|
||||
<select value={tools[activeToolKind].preset} onchange={(event) => changePreset(activeToolKind, event.currentTarget.value)} aria-label={isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}>
|
||||
{#if availablePresets(activeToolKind).length > 0}
|
||||
<optgroup label={isGerman ? "Installiert" : "Installed"}>
|
||||
{#each availablePresets(activeToolKind) as preset}<option value={preset.id}>✓ {preset.label}</option>{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
<optgroup label={isGerman ? "Weitere unterstützte Programme" : "Other supported applications"}>
|
||||
{#each otherPresets(activeToolKind) as preset}<option value={preset.id}>{preset.label}</option>{/each}
|
||||
</optgroup>
|
||||
<option value="custom">{isGerman ? "Eigenes Programm auswählen…" : "Choose a custom application…"}</option>
|
||||
</select>
|
||||
<small>{isGerman ? "Diese Auswahl wird gespeichert und für alle passenden Aktionen verwendet." : "This selection is saved and used for every matching action."}</small>
|
||||
</label>
|
||||
|
||||
<div class="tool-usage-callout">
|
||||
<span>{isGerman ? "So öffnest du es" : "How to open it"}</span>
|
||||
<p>{toolUsage(activeToolKind)}</p>
|
||||
</div>
|
||||
|
||||
<button class="tool-advanced-toggle" type="button" aria-expanded={advancedOpen} onclick={() => { advancedOpen = !advancedOpen; }}>
|
||||
<span>{isGerman ? "Programmpfad und Argumente" : "Application path and arguments"}</span>
|
||||
{#if advancedOpen}<ChevronDown size={15} />{:else}<ChevronRight size={15} />{/if}
|
||||
</button>
|
||||
|
||||
{#if advancedOpen}
|
||||
<div class="tool-advanced-panel">
|
||||
<label>
|
||||
<span>{isGerman ? "Programmpfad" : "Application path"}</span>
|
||||
<div class="tool-program-row">
|
||||
<input value={tools[activeToolKind].program} oninput={(event) => updateProgram(activeToolKind, event.currentTarget.value)} spellcheck="false" />
|
||||
<button type="button" onclick={() => browseProgram(activeToolKind)} title={isGerman ? "Programm auswählen" : "Choose application"} aria-label={isGerman ? "Programm auswählen" : "Choose application"}><FolderOpen size={15} /></button>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>{isGerman ? "Argumente · eine Zeile pro Argument" : "Arguments · one per line"}</span>
|
||||
<textarea value={tools[activeToolKind].args.join("\n")} oninput={(event) => updateArgs(activeToolKind, event.currentTarget.value)} spellcheck="false"></textarea>
|
||||
</label>
|
||||
<p class="tool-placeholders">
|
||||
<span>{isGerman ? "Verfügbare Platzhalter" : "Available placeholders"}</span>
|
||||
<code>{"{repo}"}</code><code>{"{file}"}</code><code>{"{parent}"}</code><code>{"{left}"}</code><code>{"{right}"}</code><code>{"{base}"}</code><code>{"{ours}"}</code><code>{"{theirs}"}</code><code>{"{result}"}</code>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">Repository</span>
|
||||
<h3>{isGerman ? "Automatische Aktualisierung" : "Auto refresh"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<label class="settings-toggle-row">
|
||||
<input type="checkbox" bind:checked={autoRefreshEnabled} />
|
||||
<span>
|
||||
<strong>{isGerman ? "Repositories automatisch aktualisieren" : "Refresh repositories automatically"}</strong>
|
||||
<small>{isGerman ? "Aktualisiert Arbeitsbereich, Branch-Status und Remotes regelmäßig im Hintergrund." : "Periodically refreshes the working tree, branch status, and remotes in the background."}</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<Languages size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">{isGerman ? "Sprache" : "Language"}</span>
|
||||
<h3>{isGerman ? "App-Sprache" : "App language"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
|
||||
<label class:active={selectedLanguage === "en"}>
|
||||
<input type="radio" bind:group={selectedLanguage} value="en" />
|
||||
<span>EN · English</span>
|
||||
</label>
|
||||
<label class:active={selectedLanguage === "de"}>
|
||||
<input type="radio" bind:group={selectedLanguage} value="de" />
|
||||
<span>DE · Deutsch</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">Analytics</span>
|
||||
<h3>{isGerman ? "Anonyme Nutzungsanalyse" : "Anonymous usage analytics"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<label class="settings-toggle-row">
|
||||
<input type="checkbox" bind:checked={analyticsEnabled} />
|
||||
<span>
|
||||
<strong>{isGerman ? "Anonyme Analytics und Fehlerberichte erlauben" : "Allow anonymous analytics and error reports"}</strong>
|
||||
<small>{isGerman ? "Es werden keine Repository-Pfade, Remotes, Branches, Commit-Nachrichten, Dateinamen, Diffs, Zugangsdaten oder Code übertragen." : "No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent."}</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="new-branch-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
|
||||
<button class="btn-primary" type="submit">
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{isGerman ? "Speichern" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
|
||||
|
||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||
@@ -49,6 +49,7 @@
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
onCheckout: (branch: GitBranchInfo) => void;
|
||||
onCompareBranch: (branch: GitBranchInfo) => void;
|
||||
onMerge: (branch: GitBranchInfo) => void;
|
||||
onRebase: (branch: GitBranchInfo) => void;
|
||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||
@@ -72,6 +73,7 @@
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
onCheckout = () => {},
|
||||
onCompareBranch = () => {},
|
||||
onMerge = () => {},
|
||||
onRebase = () => {},
|
||||
onCreateBranch = () => {},
|
||||
@@ -250,7 +252,7 @@
|
||||
const rawX = rect ? event.clientX - rect.left : event.offsetX;
|
||||
const rawY = rect ? event.clientY - rect.top : event.offsetY;
|
||||
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
|
||||
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 190);
|
||||
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 226);
|
||||
|
||||
contextBranch = branch;
|
||||
contextMenuX = Math.max(8, Math.min(rawX, maxX));
|
||||
@@ -268,6 +270,13 @@
|
||||
await onRenameBranch(branch);
|
||||
}
|
||||
|
||||
function compareContextBranch() {
|
||||
const branch = contextBranch;
|
||||
if (!branch || isBusy) return;
|
||||
closeBranchContextMenu();
|
||||
onCompareBranch(branch);
|
||||
}
|
||||
|
||||
async function deleteContextBranch() {
|
||||
const branch = contextBranch;
|
||||
if (!branch || branch.current || isBusy) return;
|
||||
@@ -677,6 +686,10 @@
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
Checkout
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={compareContextBranch} disabled={isBusy}>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
Compare with...
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
|
||||
<GitMerge size={14} aria-hidden="true" />
|
||||
Merge into current
|
||||
@@ -690,9 +703,9 @@
|
||||
Open in new worktree
|
||||
</button>
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
|
||||
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
Rename
|
||||
{contextBranch.remote ? "Rename remote..." : "Rename"}
|
||||
</button>
|
||||
<button
|
||||
class="danger"
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
ArrowDownToLine, ArrowUpFromLine, Boxes, CircleHelp, FileCode, GitBranch,
|
||||
GitCompare, History, RefreshCw, Search, Settings, SlidersHorizontal, Sparkles,
|
||||
} from "@lucide/svelte";
|
||||
import type { AppLanguage, GitBranch as GitBranchInfo, GitCommit, GitRepositoryFile } from "../types";
|
||||
|
||||
type ItemKind = "fetch" | "pull" | "push" | "refresh" | "search" | "compare" | "reflog" | "rebase" | "worktrees" | "sync" | "settings" | "ai-settings" | "help" | "branch" | "file" | "commit";
|
||||
interface Item { id: string; group: string; kind: ItemKind; title: string; subtitle: string; search: string; disabled?: boolean; run: () => void | Promise<void>; }
|
||||
interface Props {
|
||||
language: AppLanguage; hasRepository: boolean; isBusy: boolean;
|
||||
branches: GitBranchInfo[]; files: GitRepositoryFile[]; commits: GitCommit[];
|
||||
onClose: () => void;
|
||||
onCheckoutBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
onOpenFile: (file: GitRepositoryFile) => void | Promise<void>;
|
||||
onSelectCommit: (commit: GitCommit) => void | Promise<void>;
|
||||
onFetch: () => void | Promise<void>; onPull: () => void | Promise<void>; onPush: () => void | Promise<void>;
|
||||
onRefresh: () => void | Promise<void>; onOpenSearch: () => void; onOpenCompare: () => void;
|
||||
onOpenReflog: () => void | Promise<void>; onOpenInteractiveRebase: () => void;
|
||||
onOpenWorktrees: () => void | Promise<void>; onOpenSyncSettings: () => void | Promise<void>;
|
||||
onOpenSettings: () => void; onOpenAiSettings: () => void; onOpenHelp: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
language = "en", hasRepository = false, isBusy = false, branches = [], files = [], commits = [], onClose = () => {},
|
||||
onCheckoutBranch = () => {}, onOpenFile = () => {}, onSelectCommit = () => {}, onFetch = () => {}, onPull = () => {},
|
||||
onPush = () => {}, onRefresh = () => {}, onOpenSearch = () => {}, onOpenCompare = () => {}, onOpenReflog = () => {},
|
||||
onOpenInteractiveRebase = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {},
|
||||
onOpenAiSettings = () => {}, onOpenHelp = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let query = $state("");
|
||||
let activeIndex = $state(0);
|
||||
let inputElement = $state<HTMLInputElement | null>(null);
|
||||
let listElement = $state<HTMLElement | null>(null);
|
||||
const isGerman = $derived(language === "de");
|
||||
const repositoryActionDisabled = $derived(!hasRepository || isBusy);
|
||||
|
||||
function action(id: string, kind: ItemKind, title: string, subtitle: string, run: () => void | Promise<void>, requiresRepository = true): Item {
|
||||
return { id, group: isGerman ? "Aktionen" : "Actions", kind, title, subtitle, search: `${title} ${subtitle}`.toLowerCase(), disabled: requiresRepository ? repositoryActionDisabled : false, run };
|
||||
}
|
||||
|
||||
const actionItems = $derived([
|
||||
action("fetch", "fetch", "Fetch", isGerman ? "Remote-Änderungen abrufen" : "Download remote changes", onFetch),
|
||||
action("pull", "pull", "Pull", isGerman ? "Änderungen abrufen und integrieren" : "Download and integrate changes", onPull),
|
||||
action("push", "push", "Push", isGerman ? "Lokale Commits veröffentlichen" : "Publish local commits", onPush),
|
||||
action("refresh", "refresh", isGerman ? "Repository aktualisieren" : "Refresh repository", isGerman ? "Status und Historie neu laden" : "Reload status and history", onRefresh),
|
||||
action("search", "search", isGerman ? "Globale Codesuche" : "Global code search", isGerman ? "Code und Dateihistorie durchsuchen" : "Search code and file history", onOpenSearch),
|
||||
action("compare", "compare", isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits", isGerman ? "Zwei vollständige Revisionen vergleichen" : "Diff two complete revisions", onOpenCompare),
|
||||
action("reflog", "reflog", "Reflog", isGerman ? "Verlorene Commits finden und wiederherstellen" : "Find and recover lost commits", onOpenReflog),
|
||||
action("rebase", "rebase", "Interactive Rebase", isGerman ? "Commit-Historie bearbeiten" : "Edit commit history", onOpenInteractiveRebase),
|
||||
action("worktrees", "worktrees", "Worktrees", isGerman ? "Arbeitsverzeichnisse verwalten" : "Manage linked working trees", onOpenWorktrees),
|
||||
action("sync", "sync", isGerman ? "Synchronisierung konfigurieren" : "Configure synchronization", isGerman ? "Remote, Upstream und Pull-Strategie" : "Remote, upstream and pull strategy", onOpenSyncSettings),
|
||||
action("settings", "settings", isGerman ? "Einstellungen" : "Settings", isGerman ? "Darstellung, Sprache und Verhalten" : "Appearance, language and behavior", onOpenSettings, false),
|
||||
action("ai-settings", "ai-settings", "AI Settings", isGerman ? "Provider und Modell konfigurieren" : "Configure provider and model", onOpenAiSettings, false),
|
||||
action("help", "help", isGerman ? "Hilfe öffnen" : "Open help", isGerman ? "Git-Dokumentation und Tastenkürzel" : "Git documentation and keyboard shortcuts", onOpenHelp, false),
|
||||
]);
|
||||
|
||||
const dynamicItems = $derived.by(() => {
|
||||
if (!hasRepository) return [];
|
||||
const branchItems: Item[] = branches.map((branch) => ({
|
||||
id: `branch:${branch.remote ? "remote" : "local"}:${branch.name}`, group: "Branches", kind: "branch", title: branch.name,
|
||||
subtitle: branch.current ? (isGerman ? "Aktueller Branch" : "Current branch") : branch.remote ? (isGerman ? "Remote-Branch auschecken" : "Check out remote branch") : (isGerman ? "Branch auschecken" : "Check out branch"),
|
||||
search: `${branch.name} branch ${branch.remote ? "remote" : "local"}`.toLowerCase(), disabled: isBusy || branch.current, run: () => onCheckoutBranch(branch),
|
||||
}));
|
||||
const fileItems: Item[] = files.map((file) => ({
|
||||
id: `file:${file.path}`, group: isGerman ? "Dateien" : "Files", kind: "file", title: file.path.split(/[\\/]/).pop() ?? file.path,
|
||||
subtitle: file.path, search: `${file.path} file datei`.toLowerCase(), disabled: isBusy, run: () => onOpenFile(file),
|
||||
}));
|
||||
const commitItems: Item[] = commits.map((commit) => ({
|
||||
id: `commit:${commit.hash}`, group: isGerman ? "Geladene Commits" : "Loaded commits", kind: "commit", title: commit.summary || (isGerman ? "Ohne Commit-Nachricht" : "No commit message"),
|
||||
subtitle: `${commit.short_hash} · ${commit.author_name}`, search: `${commit.hash} ${commit.short_hash} ${commit.summary} ${commit.author_name} ${commit.author_email} ${commit.refs.join(" ")}`.toLowerCase(), run: () => onSelectCommit(commit),
|
||||
}));
|
||||
return [...branchItems, ...fileItems, ...commitItems];
|
||||
});
|
||||
|
||||
const visibleItems = $derived.by(() => {
|
||||
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const allItems = [...actionItems, ...dynamicItems];
|
||||
if (terms.length === 0) return allItems.slice(0, 35);
|
||||
return allItems.filter((item) => terms.every((term) => item.search.includes(term))).slice(0, 80);
|
||||
});
|
||||
|
||||
$effect(() => { query; activeIndex = 0; });
|
||||
$effect(() => { if (activeIndex >= visibleItems.length) activeIndex = Math.max(0, visibleItems.length - 1); });
|
||||
$effect(() => { activeIndex; queueMicrotask(() => listElement?.querySelector<HTMLElement>("[data-active='true']")?.scrollIntoView({ block: "nearest" })); });
|
||||
onMount(() => inputElement?.focus());
|
||||
|
||||
function execute(item: Item | undefined) { if (!item || item.disabled) return; onClose(); queueMicrotask(() => { void item.run(); }); }
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); return; }
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); activeIndex = Math.min(activeIndex + 1, visibleItems.length - 1); return; }
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); return; }
|
||||
if (event.key === "Enter") { event.preventDefault(); execute(visibleItems[activeIndex]); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="command-palette-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) onClose(); }}>
|
||||
<div class="command-palette" role="dialog" aria-modal="true" aria-label={isGerman ? "Befehlspalette" : "Command palette"}>
|
||||
<div class="command-palette-search">
|
||||
<Search size={19} aria-hidden="true" />
|
||||
<input bind:this={inputElement} bind:value={query} onkeydown={handleKeydown} placeholder={isGerman ? "Aktion, Branch, Datei oder Commit suchen…" : "Search actions, branches, files, or commits…"} aria-label={isGerman ? "Befehl suchen" : "Search commands"} autocomplete="off" spellcheck="false" />
|
||||
<kbd>ESC</kbd>
|
||||
</div>
|
||||
<div class="command-palette-results" bind:this={listElement} role="listbox" aria-label={isGerman ? "Ergebnisse" : "Results"}>
|
||||
{#if visibleItems.length === 0}
|
||||
<div class="command-palette-empty"><Search size={24} aria-hidden="true" /><strong>{isGerman ? "Keine Treffer" : "No results"}</strong><span>{isGerman ? "Versuche einen anderen Suchbegriff." : "Try a different search term."}</span></div>
|
||||
{:else}
|
||||
{#each visibleItems as item, index (item.id)}
|
||||
{#if index === 0 || visibleItems[index - 1].group !== item.group}<div class="command-palette-group">{item.group}</div>{/if}
|
||||
<button class="command-palette-item" class:active={index === activeIndex} type="button" role="option" aria-selected={index === activeIndex} data-active={index === activeIndex} disabled={item.disabled} onmouseenter={() => { activeIndex = index; }} onclick={() => execute(item)}>
|
||||
<span class={`command-palette-icon ${item.kind}`}>
|
||||
{#if item.kind === "fetch" || item.kind === "pull"}<ArrowDownToLine size={16} />
|
||||
{:else if item.kind === "push"}<ArrowUpFromLine size={16} />
|
||||
{:else if item.kind === "refresh"}<RefreshCw size={16} />
|
||||
{:else if item.kind === "search"}<Search size={16} />
|
||||
{:else if item.kind === "compare"}<GitCompare size={16} />
|
||||
{:else if item.kind === "reflog" || item.kind === "commit"}<History size={16} />
|
||||
{:else if item.kind === "rebase" || item.kind === "branch"}<GitBranch size={16} />
|
||||
{:else if item.kind === "worktrees"}<Boxes size={16} />
|
||||
{:else if item.kind === "sync"}<SlidersHorizontal size={16} />
|
||||
{:else if item.kind === "settings"}<Settings size={16} />
|
||||
{:else if item.kind === "ai-settings"}<Sparkles size={16} />
|
||||
{:else if item.kind === "help"}<CircleHelp size={16} />
|
||||
{:else}<FileCode size={16} />{/if}
|
||||
</span>
|
||||
<span class="command-palette-copy"><strong>{item.title}</strong><small>{item.subtitle}</small></span>
|
||||
{#if item.kind === "branch" && item.disabled && !isBusy}<span class="command-palette-current">{isGerman ? "AKTUELL" : "CURRENT"}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<footer class="command-palette-footer"><span><kbd>↑</kbd><kbd>↓</kbd>{isGerman ? "Navigieren" : "Navigate"}</span><span><kbd>↵</kbd>{isGerman ? "Öffnen" : "Open"}</span>{#if commits.length > 0}<span class="command-palette-hint">{isGerman ? `${commits.length} geladene Commits` : `${commits.length} loaded commits`}</span>{/if}</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.command-palette-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: start center; padding: min(14vh, 120px) 20px 20px; background: color-mix(in srgb, var(--app-dialog-backdrop) 76%, transparent); backdrop-filter: blur(7px) saturate(.82); }
|
||||
.command-palette { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(720px, 100%); max-height: min(650px, 76vh); overflow: hidden; border: 1px solid color-mix(in srgb, var(--color-primary) 22%, var(--color-border)); border-radius: 14px; background: var(--app-dialog-bg); box-shadow: 0 28px 90px rgba(0,0,0,.42); }
|
||||
.command-palette-search { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 15px 17px; border-bottom: 1px solid var(--color-border); color: var(--color-primary); }
|
||||
.command-palette-search input { min-width: 0; border: 0; outline: 0; color: var(--color-ink); background: transparent; font: inherit; font-size: 15px; }
|
||||
.command-palette-search input::placeholder { color: var(--color-ink-dim); }
|
||||
kbd { display: inline-grid; place-items: center; min-width: 23px; height: 21px; padding: 0 5px; border: 1px solid var(--color-border); border-radius: 5px; color: var(--color-ink-dim); background: var(--color-surface-raised); font-family: var(--font-mono); font-size: 9px; font-weight: 700; }
|
||||
.command-palette-results { min-height: 120px; overflow-y: auto; padding: 7px; }
|
||||
.command-palette-group { padding: 10px 9px 5px; color: var(--color-ink-dim); font-size: 10px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
||||
.command-palette-item { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; width: 100%; gap: 10px; padding: 8px 10px; border: 1px solid transparent; border-radius: 8px; text-align: left; color: var(--color-ink); background: transparent; cursor: pointer; }
|
||||
.command-palette-item.active:not(:disabled) { border-color: color-mix(in srgb, var(--color-primary) 24%, transparent); background: color-mix(in srgb, var(--color-primary) 11%, var(--color-surface-raised)); }
|
||||
.command-palette-item:disabled { cursor: default; opacity: .48; }
|
||||
.command-palette-icon { display: grid; place-items: center; width: 31px; height: 31px; border: 1px solid var(--color-border-subtle); border-radius: 8px; color: var(--color-ink-muted); background: var(--color-surface-raised); }
|
||||
.command-palette-icon.branch { color: #65c98b; } .command-palette-icon.file { color: #69a7ff; } .command-palette-icon.commit { color: #ba82ff; }
|
||||
.command-palette-copy { display: grid; min-width: 0; gap: 2px; } .command-palette-copy strong, .command-palette-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.command-palette-copy strong { font-size: 12.5px; font-weight: 700; } .command-palette-copy small { color: var(--color-ink-dim); font-size: 10.5px; }
|
||||
.command-palette-current { color: var(--color-ink-dim); font-family: var(--font-mono); font-size: 9px; font-weight: 700; }
|
||||
.command-palette-empty { display: grid; place-items: center; gap: 5px; padding: 54px 20px; color: var(--color-ink-dim); } .command-palette-empty strong { margin-top: 5px; color: var(--color-ink); font-size: 13px; } .command-palette-empty span { font-size: 11px; }
|
||||
.command-palette-footer { display: flex; align-items: center; gap: 16px; min-height: 38px; padding: 7px 12px; border-top: 1px solid var(--color-border); color: var(--color-ink-dim); font-size: 10px; }
|
||||
.command-palette-footer span { display: flex; align-items: center; gap: 5px; } .command-palette-footer span kbd + kbd { margin-left: -3px; } .command-palette-hint { margin-left: auto; }
|
||||
@media (max-width: 640px) { .command-palette-backdrop { padding: 60px 10px 10px; } .command-palette { max-height: calc(100vh - 80px); } .command-palette-hint { display: none !important; } }
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -20,6 +20,8 @@
|
||||
comparison: GitCommitComparison;
|
||||
selectedDiffPath: string;
|
||||
isBusy: boolean;
|
||||
fromLabel?: string;
|
||||
toLabel?: string;
|
||||
restoreLabel?: string;
|
||||
/** When opened from a search hit, the term to highlight on matching lines. */
|
||||
highlightQuery?: string;
|
||||
@@ -32,6 +34,8 @@
|
||||
comparison,
|
||||
selectedDiffPath = "",
|
||||
isBusy = false,
|
||||
fromLabel = "",
|
||||
toLabel = "",
|
||||
restoreLabel = "",
|
||||
highlightQuery = "",
|
||||
onClose = () => {},
|
||||
@@ -219,15 +223,15 @@
|
||||
class="dialog-backdrop compare-dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
|
||||
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Branch or commit comparison" tabindex="-1">
|
||||
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2 class="dialog-range">
|
||||
<span class="hash">{comparison.from_short}</span>
|
||||
<span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
|
||||
<ArrowRight size={14} aria-hidden="true" />
|
||||
<span class="hash">{comparison.to_short}</span>
|
||||
<span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
@@ -289,11 +293,11 @@
|
||||
<div class="split-col-headers">
|
||||
<div class="split-col-label">
|
||||
<span>Before</span>
|
||||
<span class="split-col-hash">{comparison.from_short}</span>
|
||||
<span class="split-col-hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
|
||||
</div>
|
||||
<div class="split-col-label">
|
||||
<span>After</span>
|
||||
<span class="split-col-hash">{comparison.to_short}</span>
|
||||
<span class="split-col-hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitCommit } from "../types";
|
||||
import type { GitBranch, GitCommit } from "../types";
|
||||
|
||||
interface Props {
|
||||
commits: GitCommit[];
|
||||
branches: GitBranch[];
|
||||
compareFrom: string;
|
||||
compareTo: string;
|
||||
canCompare: boolean;
|
||||
@@ -17,6 +18,7 @@
|
||||
|
||||
let {
|
||||
commits = [],
|
||||
branches = [],
|
||||
compareFrom = "",
|
||||
compareTo = "",
|
||||
canCompare = false,
|
||||
@@ -32,6 +34,14 @@
|
||||
return `${item.short_hash} - ${item.summary}`;
|
||||
}
|
||||
|
||||
function branchValue(branch: GitBranch): string {
|
||||
return branch.remote ? `refs/remotes/${branch.name}` : `refs/heads/${branch.name}`;
|
||||
}
|
||||
|
||||
let localBranches = $derived(branches.filter((branch) => !branch.remote));
|
||||
let remoteBranches = $derived(branches.filter((branch) => branch.remote));
|
||||
let targetCount = $derived(branches.length + commits.length);
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
onCompare();
|
||||
@@ -42,53 +52,89 @@
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
|
||||
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select branches or commits to compare" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Select commits</h2>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Compare branches or commits</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if commits.length < 2}
|
||||
<div class="blank-state">At least two commits are needed to compare.</div>
|
||||
{#if targetCount < 2}
|
||||
<div class="blank-state">At least two branches or commits are needed to compare.</div>
|
||||
{:else}
|
||||
<form class="compare-form" onsubmit={handleSubmit}>
|
||||
<label class="compare-field">
|
||||
<span>From (older)</span>
|
||||
<span>Base</span>
|
||||
<select
|
||||
value={compareFrom}
|
||||
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
<option value="" disabled>Select a branch or commit</option>
|
||||
{#if localBranches.length > 0}
|
||||
<optgroup label="Local branches">
|
||||
{#each localBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if remoteBranches.length > 0}
|
||||
<optgroup label="Remote branches">
|
||||
{#each remoteBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if commits.length > 0}
|
||||
<optgroup label="Recent commits">
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
|
||||
|
||||
<label class="compare-field">
|
||||
<span>To (newer)</span>
|
||||
<span>Compare with</span>
|
||||
<select
|
||||
value={compareTo}
|
||||
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
<option value="" disabled>Select a branch or commit</option>
|
||||
{#if localBranches.length > 0}
|
||||
<optgroup label="Local branches">
|
||||
{#each localBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if remoteBranches.length > 0}
|
||||
<optgroup label="Remote branches">
|
||||
{#each remoteBranches as branch (branch.name)}
|
||||
<option value={branchValue(branch)}>{branch.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if commits.length > 0}
|
||||
<optgroup label="Recent commits">
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button class="btn-primary" type="submit" disabled={!canCompare}>
|
||||
{#if operation === "Comparing commits"}
|
||||
{#if operation === "Comparing revisions"}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitCompare size={16} aria-hidden="true" />
|
||||
@@ -98,9 +144,11 @@
|
||||
</form>
|
||||
|
||||
{#if compareFrom && compareTo && compareFrom === compareTo}
|
||||
<div class="blank-state">Select two different commits to compare.</div>
|
||||
<div class="blank-state">Select two different branches or commits to compare.</div>
|
||||
{:else}
|
||||
<div class="blank-state">Pick two commits and run a comparison.</div>
|
||||
<div class="compare-target-help">
|
||||
The two branch tips are compared across the entire repository. Uncommitted working-tree changes are not included.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
action: "push" | "pull" | "fetch" | "clone";
|
||||
error: string;
|
||||
isBusy: boolean;
|
||||
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
|
||||
onSubmit: (username: string, password: string, save: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
let password = $state("");
|
||||
let showPassword = $state(false);
|
||||
let saveSession = $state(true);
|
||||
let expiresAt = $state("");
|
||||
|
||||
let canSubmit = $derived(
|
||||
!isBusy &&
|
||||
@@ -62,12 +61,7 @@
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
onSubmit(
|
||||
mode === "token" ? "oauth2" : username,
|
||||
password,
|
||||
saveSession,
|
||||
saveSession && expiresAt ? expiresAt : null,
|
||||
);
|
||||
onSubmit(mode === "token" ? "oauth2" : username, password, saveSession);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -191,19 +185,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if saveSession}
|
||||
<div class="cred-expiry">
|
||||
<label class="cred-field-label" for="cred-expiry">Expiration date (optional)</label>
|
||||
<input
|
||||
id="cred-expiry"
|
||||
type="date"
|
||||
bind:value={expiresAt}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
<span class="cred-expiry-hint">After this date you'll automatically be asked to log in again.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="cred-footer">
|
||||
<label class="cred-save">
|
||||
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1361,6 +1361,45 @@
|
||||
label: "Neu in Gitty",
|
||||
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
|
||||
sections: [
|
||||
{
|
||||
id: "changelog-2026-8-3",
|
||||
title: "Version 2026.8.3",
|
||||
summary: "Dieses Release verbindet Gitty enger mit deinen Entwicklungswerkzeugen und macht Branches, Historie und Vergleiche deutlich leistungsfähiger.",
|
||||
steps: [
|
||||
"Externe Tools: Editor, Diff-Tool, Merge-Tool, Terminal und Dateimanager lassen sich in den neu gestalteten Einstellungen erkennen, auswählen und individuell konfigurieren.",
|
||||
"VS Code, JetBrains-IDEs, Beyond Compare und weitere unterstützte Programme werden in einem eigenen Fenster geöffnet; dokumentierte Ergebnis-Codes werden beim Schließen korrekt behandelt.",
|
||||
"Git Notes: Commits erhalten lokale Notizen, ohne ihre Historie umzuschreiben. Notizen lassen sich bearbeiten, löschen sowie gezielt vom Remote abrufen oder dorthin übertragen.",
|
||||
"Vollständiger Branch-Vergleich: Lokale Branches, Remote-Branches und Commits können direkt ausgewählt und dateiweise im Side-by-Side-Diff verglichen werden.",
|
||||
"Remote-Branches lassen sich im Kontextmenü sicher umbenennen. Gitty schützt dabei vorhandene Ziel-Branches und zwischenzeitlich geänderte Remote-Stände.",
|
||||
"Der überarbeitete Commit-Graph zeigt Branches kompakter, reduziert überladene Commit-Zeilen und blendet zusätzliche Flag-Details beim Darüberfahren ein.",
|
||||
"Noch nicht veröffentlichte Branches sind als „Nur lokal“ erkennbar – in der Werkzeugleiste, Repository-Übersicht, Statuszeile und direkt an der Branch-Flag. Die erste Push-Aktion heißt passend „Veröffentlichen“.",
|
||||
"Branch-Sichtbarkeit, feinere Graph-Verbindungen und ein kleineres Mindestmaß des Verlaufsbereichs verbessern die Übersicht bei großen Repositories.",
|
||||
"Die neue Befehlspalette öffnet Aktionen, Dateien und Commits schneller; asynchrone Git-Befehle halten Gitty auch bei langsameren Operationen reaktionsfähig.",
|
||||
],
|
||||
note: "Der Branch-Vergleich zeigt die vollständig festgeschriebenen Zustände der beiden Branch-Spitzen. Nicht commitete Änderungen im Arbeitsverzeichnis sind nicht enthalten.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-2",
|
||||
title: "Version 2026.8.2",
|
||||
summary: "Dieses Release stabilisiert die Darstellung komplexer Verläufe und verbessert die Veröffentlichung neuer Gitty-Versionen.",
|
||||
steps: [
|
||||
"Branch-Farben bleiben über Eltern-Lanes hinweg stabil, sodass sich Linien in längeren und verzweigten Historien leichter verfolgen lassen.",
|
||||
"Release-Artefakte werden automatisch und ohne doppelte Dateien an das passende Gitea-Release angehängt.",
|
||||
"Beim Beenden der Anwendung wird die Telemetrie zuverlässiger abgeschlossen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-1",
|
||||
title: "Version 2026.8.1",
|
||||
summary: "Dieses Release macht große Commit-Verläufe und die Historie einzelner Dateien leichter zugänglich und verbessert die Paketverteilung.",
|
||||
steps: [
|
||||
"Die Commit-Historie lädt ältere Einträge seitenweise nach und ist nicht mehr auf die erste Ergebnismenge begrenzt.",
|
||||
"Die Dateihistorie öffnet sich aus dem Explorer-Kontextmenü in einem eigenen, größeren Dialog statt in einem dauerhaft belegten Seitenbereich.",
|
||||
"Dialoge reagieren konsistenter auf die Escape-Taste.",
|
||||
"Windows- und Ubuntu-Releases sowie der AUR-Paketablauf wurden erweitert und robuster gemacht.",
|
||||
"Die Arch-Linux-Anleitung verwendet jetzt das AUR-Paket gitty-desktop; SSH-Einrichtung, Zeitlimits und Wiederholungsversuche wurden verbessert.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-07-22",
|
||||
title: "Version 2026.07.22",
|
||||
@@ -1411,6 +1450,45 @@
|
||||
label: "What's new",
|
||||
description: "Changes since the latest published version and notable additions from earlier releases.",
|
||||
sections: [
|
||||
{
|
||||
id: "changelog-2026-8-3",
|
||||
title: "Version 2026.8.3",
|
||||
summary: "This release connects Gitty more closely with your development tools and makes branches, history, and comparisons substantially more capable.",
|
||||
steps: [
|
||||
"External tools: editors, diff tools, merge tools, terminals, and file managers can be detected, selected, and customized in the redesigned settings.",
|
||||
"VS Code, JetBrains IDEs, Beyond Compare, and other supported applications open in a separate window; documented result codes are handled correctly when they close.",
|
||||
"Git Notes: attach local notes to commits without rewriting history. Notes can be edited, deleted, fetched from a remote, or pushed explicitly.",
|
||||
"Complete branch comparison: choose local branches, remote branches, or commits and inspect every changed file in a side-by-side diff.",
|
||||
"Remote branches can be renamed safely from the context menu. Gitty protects existing destination branches and remote branches that changed after the last fetch.",
|
||||
"The redesigned commit graph presents branches more compactly, reduces crowded commit rows, and reveals additional flag details on hover.",
|
||||
"Unpublished branches are clearly marked as Local only in the toolbar, repository summary, status bar, and on the graph flag. Their first push is labeled Publish.",
|
||||
"Branch visibility controls, refined graph connectors, and a smaller minimum history width improve navigation in large repositories.",
|
||||
"The new command palette opens actions, files, and commits faster; asynchronous Git commands keep Gitty responsive during slower operations.",
|
||||
],
|
||||
note: "Branch comparison uses the fully committed state at each branch tip. Uncommitted working-tree changes are not included.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-2",
|
||||
title: "Version 2026.8.2",
|
||||
summary: "This release stabilizes complex history rendering and improves publication of new Gitty versions.",
|
||||
steps: [
|
||||
"Branch colors remain stable across parent lanes, making longer and branching histories easier to follow.",
|
||||
"Release artifacts are attached to the matching Gitea release automatically without uploading duplicates.",
|
||||
"Telemetry cleanup completes more reliably while the application is shutting down.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-1",
|
||||
title: "Version 2026.8.1",
|
||||
summary: "This release makes large commit histories and individual file histories easier to access and improves package distribution.",
|
||||
steps: [
|
||||
"Commit history loads older entries page by page instead of stopping after the initial result set.",
|
||||
"File history opens from the explorer context menu in a dedicated larger dialog instead of occupying a permanent workspace panel.",
|
||||
"Dialogs respond more consistently to the Escape key.",
|
||||
"Windows and Ubuntu publishing plus the AUR package workflow were expanded and made more robust.",
|
||||
"The Arch Linux guide now uses the gitty-desktop AUR package; SSH setup, timeouts, and retry handling were improved.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-07-22",
|
||||
title: "Version 2026.07.22",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
|
||||
import { Cherry, ChevronDown, ChevronRight, CloudOff, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, Tag, X } from "@lucide/svelte";
|
||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||
|
||||
interface GraphSegment {
|
||||
@@ -22,11 +22,34 @@
|
||||
graphCommit: GitCommit;
|
||||
}
|
||||
|
||||
type BranchVisibilityMode = "focus" | "local" | "all" | "custom";
|
||||
type CommitRefKind = "local" | "remote" | "head";
|
||||
|
||||
interface CommitBranchDecoration {
|
||||
label: string;
|
||||
kind: CommitRefKind;
|
||||
current: boolean;
|
||||
trackedRemote: string;
|
||||
localOnly: boolean;
|
||||
representedBranches: string[];
|
||||
}
|
||||
|
||||
interface CommitRefSummary {
|
||||
branches: CommitBranchDecoration[];
|
||||
tags: string[];
|
||||
other: string[];
|
||||
primaryBranch: CommitBranchDecoration | null;
|
||||
primaryTag: string;
|
||||
overflowCount: number;
|
||||
}
|
||||
|
||||
const GRAPH_COLORS = [
|
||||
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
|
||||
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
|
||||
];
|
||||
const GRAPH_LANE = 18;
|
||||
const GRAPH_REF_ARM = 16;
|
||||
const GRAPH_VISIBILITY_STORAGE_PREFIX = "gitlite.graphVisibility.v1:";
|
||||
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
@@ -35,8 +58,12 @@
|
||||
interface Props {
|
||||
commits: GitCommit[];
|
||||
localBranchNames: string[];
|
||||
localBranchUpstreams: Record<string, string>;
|
||||
remoteBranchNames: string[];
|
||||
activeBranch: string;
|
||||
activeUpstream: string;
|
||||
activeAhead: number;
|
||||
activeBehind: number;
|
||||
repositoryKey: string;
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
@@ -44,6 +71,7 @@
|
||||
isLoadingMore: boolean;
|
||||
loadMoreError: string;
|
||||
expandedCommitHashes: Set<string>;
|
||||
selectedCommitHash: string;
|
||||
onLoadMore: () => void | Promise<void>;
|
||||
onRestoreCommit: (commit: GitCommit) => void;
|
||||
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
||||
@@ -51,13 +79,19 @@
|
||||
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||
onCherryPickCommit: (commit: GitCommit) => void;
|
||||
onRevertCommit: (commit: GitCommit) => void;
|
||||
onOpenCommitNote: (commit: GitCommit) => void;
|
||||
onSelectCommit: (commit: GitCommit) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
commits = [],
|
||||
localBranchNames = [],
|
||||
localBranchUpstreams = {},
|
||||
remoteBranchNames = [],
|
||||
activeBranch = "",
|
||||
activeUpstream = "",
|
||||
activeAhead = 0,
|
||||
activeBehind = 0,
|
||||
repositoryKey = "",
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
@@ -65,6 +99,7 @@
|
||||
isLoadingMore = false,
|
||||
loadMoreError = "",
|
||||
expandedCommitHashes = new Set(),
|
||||
selectedCommitHash = "",
|
||||
onLoadMore = () => {},
|
||||
onRestoreCommit = () => {},
|
||||
onPreviewCommitFile = () => {},
|
||||
@@ -72,12 +107,15 @@
|
||||
onCreateBranchFromCommit = () => {},
|
||||
onCherryPickCommit = () => {},
|
||||
onRevertCommit = () => {},
|
||||
onOpenCommitNote = () => {},
|
||||
onSelectCommit = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
||||
let branchVisibilityMode = $state<BranchVisibilityMode>("focus");
|
||||
let customVisibleBranches = $state<Set<string>>(new Set());
|
||||
let loadedVisibilityRepository = $state("");
|
||||
let branchDialogOpen = $state(false);
|
||||
let userAdjustedBranchFilter = $state(false);
|
||||
let lastDefaultFilterKey = $state("");
|
||||
let expandedRefsCommitHash = $state("");
|
||||
let panelElement = $state<HTMLElement | null>(null);
|
||||
let contextCommit = $state<GitCommit | null>(null);
|
||||
let contextMenuX = $state(0);
|
||||
@@ -226,39 +264,40 @@
|
||||
}
|
||||
|
||||
let localBranchNameSet = $derived(new Set(localBranchNames));
|
||||
let remoteBranchNameSet = $derived(new Set(remoteBranchNames));
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, activeUpstream].filter(Boolean)));
|
||||
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, ...remoteBranchNames, activeUpstream].filter(Boolean)));
|
||||
let graphBranchNameSet = $derived(new Set(graphBranchNames));
|
||||
|
||||
function focusBranchNames(): string[] {
|
||||
const focused = uniqueStrings([activeBranch, activeUpstream].filter((branch) => graphBranchNameSet.has(branch)));
|
||||
if (focused.length > 0) return focused;
|
||||
return localBranchNames[0] ? [localBranchNames[0]] : graphBranchNames.slice(0, 1);
|
||||
}
|
||||
|
||||
function branchNamesForMode(): string[] {
|
||||
if (branchVisibilityMode === "focus") return focusBranchNames();
|
||||
if (branchVisibilityMode === "local") return localBranchNames;
|
||||
if (branchVisibilityMode === "all") return graphBranchNames;
|
||||
return graphBranchNames.filter((branch) => customVisibleBranches.has(branch));
|
||||
}
|
||||
|
||||
let visibleGraphBranchNames = $derived(branchNamesForMode());
|
||||
let visibleGraphBranchNameSet = $derived(new Set(visibleGraphBranchNames));
|
||||
|
||||
function branchIsVisible(branch: string): boolean {
|
||||
if (branch === activeUpstream && activeUpstream) {
|
||||
return !activeBranch || !hiddenGraphBranches.has(activeBranch);
|
||||
}
|
||||
return !hiddenGraphBranches.has(branch);
|
||||
}
|
||||
|
||||
function visibleBranchLabels(labels: string[]): string[] {
|
||||
return labels.filter(branchIsVisible);
|
||||
}
|
||||
|
||||
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
|
||||
const directBranches = graphBranchRefs(commit);
|
||||
if (directBranches.length > 0) return directBranches;
|
||||
|
||||
const containingBranches = row?.branchLabels ?? [];
|
||||
if (containingBranches.length <= 3) return containingBranches;
|
||||
return [...containingBranches.slice(0, 3), `+${containingBranches.length - 3} more`];
|
||||
return visibleGraphBranchNameSet.has(branch);
|
||||
}
|
||||
|
||||
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
||||
const directBranches = graphBranchRefs(commit);
|
||||
const directBranches = graphBranchRefs(commit).filter(branchIsVisible);
|
||||
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
||||
|
||||
const containingBranches = row?.branchLabels ?? [];
|
||||
const containingBranches = (row?.branchLabels ?? []).filter(branchIsVisible);
|
||||
if (containingBranches.length === 0) return commit.short_hash;
|
||||
return `Branches containing this commit: ${containingBranches.join(", ")}`;
|
||||
}
|
||||
@@ -278,7 +317,7 @@
|
||||
|
||||
function branchesAreVisible(branches: string[]): boolean {
|
||||
if (graphBranchNames.length === 0) return true;
|
||||
return branches.some(branchIsVisible);
|
||||
return branches.some((branch) => visibleGraphBranchNameSet.has(branch));
|
||||
}
|
||||
|
||||
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
|
||||
@@ -355,20 +394,25 @@
|
||||
}
|
||||
|
||||
function toggleGraphBranch(branch: string) {
|
||||
const next = new Set(hiddenGraphBranches);
|
||||
const next = branchVisibilityMode === "custom"
|
||||
? new Set(customVisibleBranches)
|
||||
: new Set(visibleGraphBranchNames);
|
||||
if (next.has(branch)) next.delete(branch); else next.add(branch);
|
||||
hiddenGraphBranches = next;
|
||||
userAdjustedBranchFilter = true;
|
||||
customVisibleBranches = next;
|
||||
branchVisibilityMode = "custom";
|
||||
}
|
||||
|
||||
function showAllGraphBranches() {
|
||||
hiddenGraphBranches = new Set();
|
||||
userAdjustedBranchFilter = true;
|
||||
branchVisibilityMode = "all";
|
||||
}
|
||||
|
||||
function hideAllGraphBranches() {
|
||||
hiddenGraphBranches = new Set(localBranchNames);
|
||||
userAdjustedBranchFilter = true;
|
||||
customVisibleBranches = new Set();
|
||||
branchVisibilityMode = "custom";
|
||||
}
|
||||
|
||||
function showFocusGraphBranches() {
|
||||
branchVisibilityMode = "focus";
|
||||
}
|
||||
|
||||
function openBranchDialog() {
|
||||
@@ -385,6 +429,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCommitRefs(commit: GitCommit) {
|
||||
expandedRefsCommitHash = expandedRefsCommitHash === commit.hash ? "" : commit.hash;
|
||||
}
|
||||
|
||||
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -441,9 +489,24 @@
|
||||
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();
|
||||
expandedRefsCommitHash = "";
|
||||
handleBranchDialogKeydown(event);
|
||||
}
|
||||
|
||||
@@ -471,22 +534,140 @@
|
||||
return labels;
|
||||
}
|
||||
|
||||
function visibleRefs(commit: GitCommit): string[] {
|
||||
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
|
||||
}
|
||||
|
||||
function refClass(ref: string): string {
|
||||
if (ref.startsWith("HEAD")) return "head";
|
||||
if (ref.startsWith("tag:")) return "tag";
|
||||
if (localBranchNameSet.has(refLabel(ref))) return "branch";
|
||||
if (ref.includes("/")) return "remote";
|
||||
return "branch";
|
||||
}
|
||||
|
||||
function refLabel(ref: string): string {
|
||||
return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, "");
|
||||
}
|
||||
|
||||
function isSymbolicRemoteHead(ref: string): boolean {
|
||||
if (ref.startsWith("HEAD ->") || ref.startsWith("tag:")) return false;
|
||||
const label = refLabel(ref);
|
||||
return !localBranchNameSet.has(label) && /(?:^|\/)HEAD(?:\s*->|$)/.test(ref);
|
||||
}
|
||||
|
||||
function configuredUpstreamForBranch(local: string): string {
|
||||
return localBranchUpstreams[local] ?? (local === activeBranch ? activeUpstream : "");
|
||||
}
|
||||
|
||||
function matchingRemoteBranch(local: string, remoteBranches: string[]): string {
|
||||
const configuredUpstream = configuredUpstreamForBranch(local);
|
||||
return configuredUpstream && remoteBranches.includes(configuredUpstream) ? configuredUpstream : "";
|
||||
}
|
||||
|
||||
function compareBranchDecorations(left: CommitBranchDecoration, right: CommitBranchDecoration): number {
|
||||
const priority = (item: CommitBranchDecoration) => {
|
||||
if (item.label === activeBranch) return 0;
|
||||
if (item.kind === "head") return 1;
|
||||
if (item.kind === "local") return 2;
|
||||
if (item.label === activeUpstream) return 3;
|
||||
return 4;
|
||||
};
|
||||
return priority(left) - priority(right) || left.label.localeCompare(right.label, undefined, { numeric: true });
|
||||
}
|
||||
|
||||
function commitRefSummary(commit: GitCommit): CommitRefSummary {
|
||||
const local = new Set<string>();
|
||||
const remote = new Set<string>();
|
||||
const tags = new Set<string>();
|
||||
const other = new Set<string>();
|
||||
let detachedHead = false;
|
||||
|
||||
for (const ref of commit.refs) {
|
||||
if (isSymbolicRemoteHead(ref)) continue;
|
||||
const label = refLabel(ref);
|
||||
if (!label) continue;
|
||||
if (ref.startsWith("tag:")) {
|
||||
tags.add(label);
|
||||
} else if (localBranchNameSet.has(label)) {
|
||||
local.add(label);
|
||||
} else if (remoteBranchNameSet.has(label) || label === activeUpstream) {
|
||||
remote.add(label);
|
||||
} else if (label === "HEAD") {
|
||||
detachedHead = true;
|
||||
} else {
|
||||
other.add(label);
|
||||
}
|
||||
}
|
||||
|
||||
const remainingRemote = [...remote];
|
||||
const branches: CommitBranchDecoration[] = [...local].map((label) => {
|
||||
const configuredUpstream = configuredUpstreamForBranch(label);
|
||||
const trackedRemote = matchingRemoteBranch(label, remainingRemote);
|
||||
if (trackedRemote) remainingRemote.splice(remainingRemote.indexOf(trackedRemote), 1);
|
||||
return {
|
||||
label,
|
||||
kind: "local",
|
||||
current: label === activeBranch,
|
||||
trackedRemote,
|
||||
localOnly: !configuredUpstream,
|
||||
representedBranches: trackedRemote ? [label, trackedRemote] : [label],
|
||||
};
|
||||
});
|
||||
|
||||
if (detachedHead) {
|
||||
branches.push({
|
||||
label: "HEAD",
|
||||
kind: "head",
|
||||
current: true,
|
||||
trackedRemote: "",
|
||||
localOnly: false,
|
||||
representedBranches: [],
|
||||
});
|
||||
}
|
||||
|
||||
branches.push(...remainingRemote.map((label) => ({
|
||||
label,
|
||||
kind: "remote" as const,
|
||||
current: false,
|
||||
trackedRemote: "",
|
||||
localOnly: false,
|
||||
representedBranches: [label],
|
||||
})));
|
||||
|
||||
const sortedBranches = branches.sort(compareBranchDecorations);
|
||||
const primaryBranch = sortedBranches.find(
|
||||
(branch) => branch.kind === "head" || branch.representedBranches.some(branchIsVisible),
|
||||
) ?? null;
|
||||
const sortedTags = [...tags].sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
|
||||
const sortedOther = [...other].sort((left, right) => left.localeCompare(right));
|
||||
const primaryTag = sortedTags[0] ?? "";
|
||||
|
||||
return {
|
||||
branches: sortedBranches,
|
||||
tags: sortedTags,
|
||||
other: sortedOther,
|
||||
primaryBranch,
|
||||
primaryTag,
|
||||
overflowCount:
|
||||
Math.max(0, sortedBranches.length - (primaryBranch ? 1 : 0))
|
||||
+ Math.max(0, sortedTags.length - (primaryTag ? 1 : 0))
|
||||
+ sortedOther.length,
|
||||
};
|
||||
}
|
||||
|
||||
function branchStatusLabel(branch: CommitBranchDecoration): string {
|
||||
if (branch.label === activeBranch) {
|
||||
const parts = [];
|
||||
if (activeAhead > 0) parts.push(`↑${activeAhead}`);
|
||||
if (activeBehind > 0) parts.push(`↓${activeBehind}`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
if (branch.label === activeUpstream && activeBehind > 0) return `↓${activeBehind}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function branchDecorationTitle(branch: CommitBranchDecoration): string {
|
||||
if (branch.localOnly) {
|
||||
const status = branchStatusLabel(branch);
|
||||
return `${branch.label} · Local only — not published yet${status ? ` · ${status}` : ""}`;
|
||||
}
|
||||
const status = branchStatusLabel(branch);
|
||||
if (branch.trackedRemote) {
|
||||
return `${branch.label} · Tracks ${branch.trackedRemote}${status ? ` · ${status}` : ""}`;
|
||||
}
|
||||
if (status) return `${branch.label} · ${status}`;
|
||||
return branch.kind === "remote" ? `Remote branch ${branch.label}` : `Local branch ${branch.label}`;
|
||||
}
|
||||
|
||||
function formatCommitDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
@@ -494,43 +675,76 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const available = new Set(localBranchNames);
|
||||
const nextHidden = new Set([...hiddenGraphBranches].filter((branch) => available.has(branch)));
|
||||
if (nextHidden.size !== hiddenGraphBranches.size) {
|
||||
hiddenGraphBranches = nextHidden;
|
||||
const currentRepository = repositoryKey;
|
||||
if (loadedVisibilityRepository === currentRepository) return;
|
||||
loadedVisibilityRepository = currentRepository;
|
||||
expandedRefsCommitHash = "";
|
||||
|
||||
if (!currentRepository) {
|
||||
branchVisibilityMode = "focus";
|
||||
customVisibleBranches = new Set();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`);
|
||||
if (!stored) {
|
||||
branchVisibilityMode = "focus";
|
||||
customVisibleBranches = new Set();
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(stored) as { mode?: unknown; branches?: unknown };
|
||||
const mode = parsed.mode;
|
||||
branchVisibilityMode = mode === "focus" || mode === "local" || mode === "all" || mode === "custom"
|
||||
? mode
|
||||
: "focus";
|
||||
customVisibleBranches = new Set(
|
||||
Array.isArray(parsed.branches)
|
||||
? parsed.branches.filter((branch): branch is string => typeof branch === "string")
|
||||
: [],
|
||||
);
|
||||
} catch {
|
||||
branchVisibilityMode = "focus";
|
||||
customVisibleBranches = new Set();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
|
||||
? activeBranch
|
||||
: (graphBranchNames[0] ?? "");
|
||||
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
|
||||
|
||||
if (!defaultBranch) {
|
||||
if (lastDefaultFilterKey !== defaultFilterKey) {
|
||||
hiddenGraphBranches = new Set();
|
||||
userAdjustedBranchFilter = false;
|
||||
lastDefaultFilterKey = defaultFilterKey;
|
||||
}
|
||||
return;
|
||||
const currentRepository = repositoryKey;
|
||||
const mode = branchVisibilityMode;
|
||||
const branches = [...customVisibleBranches];
|
||||
if (!currentRepository || loadedVisibilityRepository !== currentRepository) return;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`,
|
||||
JSON.stringify({ mode, branches }),
|
||||
);
|
||||
} catch {
|
||||
// The graph still works if storage is unavailable.
|
||||
}
|
||||
});
|
||||
|
||||
if (lastDefaultFilterKey !== defaultFilterKey) {
|
||||
userAdjustedBranchFilter = false;
|
||||
lastDefaultFilterKey = defaultFilterKey;
|
||||
}
|
||||
$effect(() => {
|
||||
if (graphBranchNames.length === 0 || customVisibleBranches.size === 0) return;
|
||||
const available = new Set(graphBranchNames);
|
||||
const next = new Set([...customVisibleBranches].filter((branch) => available.has(branch)));
|
||||
if (next.size !== customVisibleBranches.size) customVisibleBranches = next;
|
||||
});
|
||||
|
||||
if (!userAdjustedBranchFilter) {
|
||||
hiddenGraphBranches = new Set(localBranchNames.filter((branch) => branch !== defaultBranch));
|
||||
}
|
||||
$effect(() => {
|
||||
if (!selectedCommitHash) return;
|
||||
queueMicrotask(() => {
|
||||
panelElement
|
||||
?.querySelector<HTMLElement>(`[data-commit-hash="${CSS.escape(selectedCommitHash)}"]`)
|
||||
?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
});
|
||||
});
|
||||
|
||||
let branchMembership = $derived(branchMembershipByHash(commits));
|
||||
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
|
||||
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
|
||||
let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit));
|
||||
let visibleBranchCount = $derived(localBranchNames.filter(branchIsVisible).length);
|
||||
let visibleBranchCount = $derived(visibleGraphBranchNames.length);
|
||||
let graph = $derived(computeGraph(graphCommits, branchMembership));
|
||||
let graphRows = $derived(graph.rows);
|
||||
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
||||
@@ -544,17 +758,18 @@
|
||||
<span class="eyebrow">History</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
||||
</div>
|
||||
{#if localBranchNames.length > 0}
|
||||
{#if graphBranchNames.length > 0}
|
||||
<div class="section-head-actions">
|
||||
<button
|
||||
class="graph-branch-dialog-button"
|
||||
type="button"
|
||||
onclick={openBranchDialog}
|
||||
title="Select branches shown in the graph"
|
||||
title="Customize visible branches"
|
||||
aria-label={`${visibleBranchCount} of ${graphBranchNames.length} branches visible. Customize branches.`}
|
||||
>
|
||||
<GitBranch size={13} aria-hidden="true" />
|
||||
Branches
|
||||
<span>{visibleBranchCount}/{localBranchNames.length}</span>
|
||||
<span>{visibleBranchCount}/{graphBranchNames.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -572,11 +787,12 @@
|
||||
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
||||
{@const item = entry.commit}
|
||||
{@const row = graphRows[rowIndex]}
|
||||
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
|
||||
{@const otherRefs = visibleRefs(item)}
|
||||
{@const refSummary = commitRefSummary(item)}
|
||||
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
|
||||
<article
|
||||
class="commit-row graph-row"
|
||||
class:selected={selectedCommitHash === item.hash}
|
||||
data-commit-hash={item.hash}
|
||||
class:graph-ahead-row={rowSyncClass === "ahead"}
|
||||
class:graph-behind-row={rowSyncClass === "behind"}
|
||||
class:merge-row={item.parents.length > 1}
|
||||
@@ -608,6 +824,15 @@
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/each}
|
||||
{#if refSummary.primaryBranch}
|
||||
<path
|
||||
class="graph-ref-connector"
|
||||
d={`M ${graphColX(row.dotCol)} 50 L ${graphWidth - GRAPH_REF_ARM * 2} 50`}
|
||||
stroke={row.dotColor}
|
||||
stroke-width="1.5"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
<span
|
||||
class="graph-dot"
|
||||
@@ -619,25 +844,111 @@
|
||||
title={commitHoverTitle(item, row)}
|
||||
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
||||
></span>
|
||||
{#if hoverBranchRefs.length > 0}
|
||||
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
|
||||
{#each hoverBranchRefs as branch}
|
||||
<span
|
||||
class:remote={branch === activeUpstream}
|
||||
class:ahead={activeBranch === branch && rowSyncClass === "ahead"}
|
||||
class:behind={activeUpstream === branch && rowSyncClass === "behind"}
|
||||
title={branch}
|
||||
>
|
||||
<GitBranch size={10} aria-hidden="true" />
|
||||
{branch}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="commit-body">
|
||||
<div
|
||||
class="commit-body"
|
||||
class:has-branch-ref={Boolean(refSummary.primaryBranch)}
|
||||
style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`}
|
||||
>
|
||||
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
|
||||
<div class="commit-ref-area">
|
||||
<div class="commit-ref-strip" aria-label="Commit references">
|
||||
{#if refSummary.primaryBranch}
|
||||
<span class="branch-ref-cluster" class:local-only={refSummary.primaryBranch.localOnly}>
|
||||
<span
|
||||
class="compact-ref-chip branch"
|
||||
class:current={refSummary.primaryBranch.current}
|
||||
class:remote={refSummary.primaryBranch.kind === "remote"}
|
||||
title={branchDecorationTitle(refSummary.primaryBranch)}
|
||||
>
|
||||
<GitBranch class="compact-ref-branch-icon" size={10} aria-hidden="true" />
|
||||
<span>{refSummary.primaryBranch.label}</span>
|
||||
{#if !refSummary.primaryBranch.localOnly && branchStatusLabel(refSummary.primaryBranch)}
|
||||
<small>{branchStatusLabel(refSummary.primaryBranch)}</small>
|
||||
{/if}
|
||||
</span>
|
||||
{#if refSummary.primaryBranch.localOnly}
|
||||
<span class="compact-ref-local-marker" title="This branch exists only locally and has not been published yet">
|
||||
LOCAL
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if refSummary.primaryTag}
|
||||
<span class="compact-ref-chip tag" title={`Tag ${refSummary.primaryTag}`}>
|
||||
<Tag size={10} aria-hidden="true" />
|
||||
<span>{refSummary.primaryTag}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if refSummary.overflowCount > 0}
|
||||
<button
|
||||
class="compact-ref-overflow"
|
||||
type="button"
|
||||
onclick={() => toggleCommitRefs(item)}
|
||||
aria-expanded={expandedRefsCommitHash === item.hash}
|
||||
aria-controls={`commit-refs-${item.hash}`}
|
||||
title={`Show ${refSummary.overflowCount} more ${refSummary.overflowCount === 1 ? "reference" : "references"}`}
|
||||
>
|
||||
+{refSummary.overflowCount}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if refSummary.overflowCount > 0 && expandedRefsCommitHash === item.hash}
|
||||
<div class="commit-ref-details" id={`commit-refs-${item.hash}`}>
|
||||
<strong>References on this commit</strong>
|
||||
{#if refSummary.branches.some((branch) => branch.kind !== "remote")}
|
||||
<section>
|
||||
<span>Local</span>
|
||||
<div>
|
||||
{#each refSummary.branches.filter((branch) => branch.kind !== "remote") as branch}
|
||||
<span class="commit-ref-detail-item local" title={branchDecorationTitle(branch)}>
|
||||
<i aria-hidden="true"></i>{branch.label}
|
||||
{#if branch.current}<small>Current</small>{/if}
|
||||
{#if branch.trackedRemote}<small>{branch.trackedRemote}</small>{/if}
|
||||
{#if branch.localOnly}<small class="local-only"><CloudOff size={9} aria-hidden="true" />Local only</small>{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{#if refSummary.branches.some((branch) => branch.kind === "remote")}
|
||||
<section>
|
||||
<span>Remote</span>
|
||||
<div>
|
||||
{#each refSummary.branches.filter((branch) => branch.kind === "remote") as branch}
|
||||
<span class="commit-ref-detail-item remote"><i aria-hidden="true"></i>{branch.label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{#if refSummary.tags.length > 0}
|
||||
<section>
|
||||
<span>Tags</span>
|
||||
<div>
|
||||
{#each refSummary.tags as tag}
|
||||
<span class="commit-ref-detail-item tag"><Tag size={10} aria-hidden="true" />{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{#if refSummary.other.length > 0}
|
||||
<section>
|
||||
<span>Other</span>
|
||||
<div>
|
||||
{#each refSummary.other as ref}
|
||||
<span class="commit-ref-detail-item">{ref}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="commit-card-head">
|
||||
<span class="commit-avatar">
|
||||
{authorInitials(item.author_name)}
|
||||
@@ -659,14 +970,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if otherRefs.length > 0}
|
||||
<div class="ref-list" aria-label="Commit refs">
|
||||
{#each otherRefs as ref}
|
||||
<span class={`ref-chip ${refClass(ref)}`}>{refLabel(ref)}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item.files.length > 0}
|
||||
<div class="commit-files">
|
||||
<button
|
||||
@@ -705,6 +1008,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"
|
||||
@@ -752,6 +1065,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
|
||||
@@ -792,25 +1109,43 @@
|
||||
</header>
|
||||
|
||||
<div class="branch-filter-summary">
|
||||
<span>{visibleBranchCount} of {localBranchNames.length} branches selected</span>
|
||||
<span>{visibleBranchCount} of {graphBranchNames.length} branches selected</span>
|
||||
<div class="branch-filter-actions">
|
||||
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === localBranchNames.length}>Show all</button>
|
||||
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>Focus</button>
|
||||
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>Show all</button>
|
||||
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="branch-filter-dialog-list">
|
||||
{#each localBranchNames as branch}
|
||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branchIsVisible(branch)}
|
||||
onchange={() => toggleGraphBranch(branch)}
|
||||
/>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
<span>{branch}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{#if localBranchNames.length > 0}
|
||||
<span class="branch-filter-group-label">Local</span>
|
||||
{#each localBranchNames as branch}
|
||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branchIsVisible(branch)}
|
||||
onchange={() => toggleGraphBranch(branch)}
|
||||
/>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
<span>{branch}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if remoteBranchNames.length > 0}
|
||||
<span class="branch-filter-group-label">Remote</span>
|
||||
{#each remoteBranchNames as branch}
|
||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option remote" title={branch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branchIsVisible(branch)}
|
||||
onchange={() => toggleGraphBranch(branch)}
|
||||
/>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
<span>{branch}</span>
|
||||
</label>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -18,14 +18,29 @@
|
||||
|
||||
let name = $state("");
|
||||
|
||||
function remoteName(branch: GitBranchInfo): string {
|
||||
if (!branch.remote) return "";
|
||||
const slash = branch.name.indexOf("/");
|
||||
return slash > 0 ? branch.name.slice(0, slash) : branch.name;
|
||||
}
|
||||
|
||||
function editableName(branch: GitBranchInfo): string {
|
||||
if (!branch.remote) return branch.name;
|
||||
const slash = branch.name.indexOf("/");
|
||||
return slash >= 0 ? branch.name.slice(slash + 1) : branch.name;
|
||||
}
|
||||
|
||||
let originalName = $derived(editableName(branch));
|
||||
let remote = $derived(remoteName(branch));
|
||||
|
||||
$effect(() => {
|
||||
name = branch.name;
|
||||
name = originalName;
|
||||
});
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const value = name.trim();
|
||||
if (!value || value === branch.name) return;
|
||||
if (!value || value === originalName) return;
|
||||
onRename(value);
|
||||
}
|
||||
</script>
|
||||
@@ -34,10 +49,10 @@
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
|
||||
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label={branch.remote ? "Rename remote branch" : "Rename branch"} tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Rename branch</span>
|
||||
<span class="eyebrow">{branch.remote ? "Rename remote branch" : "Rename branch"}</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
@@ -48,27 +63,37 @@
|
||||
<form class="rename-branch-form" onsubmit={submit}>
|
||||
<label class="new-branch-field">
|
||||
<span>Branch name</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={name}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
disabled={isBusy}
|
||||
autofocus
|
||||
/>
|
||||
<div class:remote-branch-name-field={branch.remote}>
|
||||
{#if branch.remote}<strong>{remote}/</strong>{/if}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={name}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
disabled={isBusy}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{#if branch.remote}
|
||||
<p class="rename-remote-note">
|
||||
Gitty creates <strong>{remote}/{name.trim() || "new-name"}</strong> and removes
|
||||
<strong>{branch.name}</strong> in one atomic push. The operation stops if the remote changed in the meantime.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="new-branch-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === branch.name}>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === originalName}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Rename
|
||||
{branch.remote ? "Rename on remote" : "Rename"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<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}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { StoredCredential } from "./types";
|
||||
|
||||
/**
|
||||
* Derives a credential key from a remote URL, scoped to host + organisation —
|
||||
* the same granularity Azure DevOps / GitHub use. Examples:
|
||||
@@ -37,13 +35,6 @@ export function orgKeyFromUrl(raw: string): string | null {
|
||||
return org ? `${host}/${org}` : host;
|
||||
}
|
||||
|
||||
/** A stored credential is expired only if it carries a past expiry date. */
|
||||
export function isCredentialExpired(cred: StoredCredential): boolean {
|
||||
if (!cred.expiresAt) return false;
|
||||
const time = new Date(cred.expiresAt).getTime();
|
||||
return !Number.isNaN(time) && time < Date.now();
|
||||
}
|
||||
|
||||
const AUTH_PREFIX = "AUTH_FAILED:";
|
||||
|
||||
export function isAuthError(message: string): boolean {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import type {
|
||||
DetectedExternalTool,
|
||||
ExternalToolKind,
|
||||
ExternalToolSetting,
|
||||
ExternalToolsSettings,
|
||||
ToolOpenMode,
|
||||
} from "./types";
|
||||
|
||||
export type { ExternalToolKind } from "./types";
|
||||
|
||||
export interface ExternalToolPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
program: string;
|
||||
args: string[];
|
||||
builtIn?: boolean;
|
||||
}
|
||||
|
||||
type ToolIdentity = Pick<ExternalToolPreset, "id" | "label" | "program">;
|
||||
|
||||
const userAgent = navigator.userAgent;
|
||||
export const externalToolsPlatform = userAgent.includes("Windows")
|
||||
? "windows"
|
||||
: userAgent.includes("Mac")
|
||||
? "macos"
|
||||
: "linux";
|
||||
|
||||
const codeFamily: ToolIdentity[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
{ id: "vscode", label: "Visual Studio Code", program: "Code.exe" },
|
||||
{ id: "vscode-insiders", label: "Visual Studio Code Insiders", program: "Code - Insiders.exe" },
|
||||
{ id: "cursor", label: "Cursor", program: "Cursor.exe" },
|
||||
{ id: "windsurf", label: "Windsurf", program: "Windsurf.exe" },
|
||||
{ id: "vscodium", label: "VSCodium", program: "VSCodium.exe" },
|
||||
]
|
||||
: [
|
||||
{ id: "vscode", label: "Visual Studio Code", program: "code" },
|
||||
{ id: "vscode-insiders", label: "Visual Studio Code Insiders", program: "code-insiders" },
|
||||
{ id: "cursor", label: "Cursor", program: "cursor" },
|
||||
{ id: "windsurf", label: "Windsurf", program: "windsurf" },
|
||||
{ id: "vscodium", label: "VSCodium", program: "codium" },
|
||||
];
|
||||
|
||||
const jetBrains: ToolIdentity[] = [
|
||||
{ id: "intellij-idea", label: "IntelliJ IDEA", program: externalToolsPlatform === "windows" ? "idea64.exe" : "idea" },
|
||||
{ id: "webstorm", label: "WebStorm", program: externalToolsPlatform === "windows" ? "webstorm64.exe" : "webstorm" },
|
||||
{ id: "pycharm", label: "PyCharm", program: externalToolsPlatform === "windows" ? "pycharm64.exe" : "pycharm" },
|
||||
{ id: "phpstorm", label: "PhpStorm", program: externalToolsPlatform === "windows" ? "phpstorm64.exe" : "phpstorm" },
|
||||
{ id: "rider", label: "JetBrains Rider", program: externalToolsPlatform === "windows" ? "rider64.exe" : "rider" },
|
||||
{ id: "clion", label: "CLion", program: externalToolsPlatform === "windows" ? "clion64.exe" : "clion" },
|
||||
{ id: "rustrover", label: "RustRover", program: externalToolsPlatform === "windows" ? "rustrover64.exe" : "rustrover" },
|
||||
{ id: "goland", label: "GoLand", program: externalToolsPlatform === "windows" ? "goland64.exe" : "goland" },
|
||||
];
|
||||
|
||||
const editor = (tool: ToolIdentity, args = ["{file}"]): ExternalToolPreset => ({ ...tool, args });
|
||||
const codeDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--new-window", "--wait", "--diff", "{left}", "{right}"] });
|
||||
const codeMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["--new-window", "--wait", "--merge", "{ours}", "{theirs}", "{base}", "{result}"] });
|
||||
const jetBrainsDiff = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["diff", "{left}", "{right}"] });
|
||||
const jetBrainsMerge = (tool: ToolIdentity): ExternalToolPreset => ({ ...tool, args: ["merge", "{ours}", "{theirs}", "{base}", "{result}"] });
|
||||
|
||||
const commonEditors: ExternalToolPreset[] = [
|
||||
...codeFamily.map((item) => editor(item, ["--new-window", "{file}"])),
|
||||
editor({ id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed" }, ["--new", "{file}"]),
|
||||
editor({ id: "sublime-text", label: "Sublime Text", program: externalToolsPlatform === "windows" ? "subl.exe" : "subl" }, ["--new-window", "{file}"]),
|
||||
...jetBrains.map((item) => editor(item)),
|
||||
editor({ id: "neovim", label: "Neovim", program: externalToolsPlatform === "windows" ? "nvim.exe" : "nvim" }),
|
||||
editor({ id: "vim", label: "Vim", program: externalToolsPlatform === "windows" ? "gvim.exe" : "gvim" }),
|
||||
editor({ id: "emacs", label: "Emacs", program: externalToolsPlatform === "windows" ? "runemacs.exe" : "emacs" }),
|
||||
];
|
||||
|
||||
const platformEditors: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
editor({ id: "notepad-plus-plus", label: "Notepad++", program: "notepad++.exe" }, ["-multiInst", "{file}"]),
|
||||
{ ...editor({ id: "notepad", label: "Windows Notepad", program: "notepad.exe" }), builtIn: true },
|
||||
]
|
||||
: externalToolsPlatform === "macos"
|
||||
? [
|
||||
editor({ id: "nova", label: "Nova", program: "nova" }),
|
||||
editor({ id: "textmate", label: "TextMate", program: "mate" }),
|
||||
editor({ id: "bbedit", label: "BBEdit", program: "bbedit" }),
|
||||
editor({ id: "xcode", label: "Xcode", program: "xed" }),
|
||||
]
|
||||
: [
|
||||
editor({ id: "lapce", label: "Lapce", program: "lapce" }),
|
||||
editor({ id: "kate", label: "Kate", program: "kate" }, ["--new", "{file}"]),
|
||||
editor({ id: "gedit", label: "GNOME Text Editor", program: "gnome-text-editor" }),
|
||||
editor({ id: "geany", label: "Geany", program: "geany" }, ["--new-instance", "{file}"]),
|
||||
editor({ id: "helix", label: "Helix", program: "hx" }),
|
||||
];
|
||||
|
||||
const dedicatedDiff: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/solo", "/readonly", "{left}", "{right}"] },
|
||||
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/s-", "/u", "/e", "/wl", "/wr", "{left}", "{right}"] },
|
||||
{ id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "{left}", "{right}"] },
|
||||
{ id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{left}", "{right}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{left}", "{right}"] },
|
||||
{ id: "araxis-merge", label: "Araxis Merge", program: "Compare.exe", args: ["/wait", "{left}", "{right}"] },
|
||||
{ id: "tortoisegitmerge", label: "TortoiseGitMerge", program: "TortoiseGitMerge.exe", args: ["/base:{left}", "/mine:{right}"] },
|
||||
]
|
||||
: externalToolsPlatform === "macos"
|
||||
? [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{left}", "{right}"] },
|
||||
{ id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--wait", "{left}", "{right}"] },
|
||||
{ id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{left}", "{right}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] },
|
||||
{ id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{left}", "{right}"] },
|
||||
]
|
||||
: [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{left}", "{right}"] },
|
||||
{ id: "meld", label: "Meld", program: "meld", args: ["--wait", "{left}", "{right}"] },
|
||||
{ id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{left}", "{right}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{left}", "{right}"] },
|
||||
{ id: "kompare", label: "Kompare", program: "kompare", args: ["{left}", "{right}"] },
|
||||
];
|
||||
|
||||
const dedicatedMerge: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "BCompare.exe", args: ["/solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||
{ id: "winmerge", label: "WinMerge", program: "WinMergeU.exe", args: ["/s-", "/u", "/e", "{ours}", "{theirs}", "{base}", "/o", "{result}"] },
|
||||
{ id: "meld", label: "Meld", program: "meld.exe", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] },
|
||||
{ id: "kdiff3", label: "KDiff3", program: "kdiff3.exe", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge.exe", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
|
||||
{ id: "araxis-merge", label: "Araxis Merge", program: "Compare.exe", args: ["/wait", "{ours}", "{base}", "{theirs}", "{result}"] },
|
||||
{ id: "tortoisegitmerge", label: "TortoiseGitMerge", program: "TortoiseGitMerge.exe", args: ["/base:{base}", "/theirs:{theirs}", "/mine:{ours}", "/merged:{result}"] },
|
||||
]
|
||||
: externalToolsPlatform === "macos"
|
||||
? [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||
{ id: "kaleidoscope", label: "Kaleidoscope", program: "ksdiff", args: ["--merge", "--output", "{result}", "{base}", "{ours}", "{theirs}"] },
|
||||
{ id: "araxis-merge", label: "Araxis Merge", program: "compare", args: ["-wait", "{ours}", "{base}", "{theirs}", "{result}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
|
||||
{ id: "opendiff", label: "FileMerge", program: "opendiff", args: ["{ours}", "{theirs}", "-ancestor", "{base}", "-merge", "{result}"] },
|
||||
]
|
||||
: [
|
||||
{ id: "beyond-compare", label: "Beyond Compare", program: "bcompare", args: ["-solo", "{ours}", "{theirs}", "{base}", "/mergeoutput={result}"] },
|
||||
{ id: "meld", label: "Meld", program: "meld", args: ["--wait", "--auto-merge", "{ours}", "{base}", "{theirs}", "--output={result}"] },
|
||||
{ id: "kdiff3", label: "KDiff3", program: "kdiff3", args: ["{base}", "{ours}", "{theirs}", "-o", "{result}"] },
|
||||
{ id: "p4merge", label: "P4Merge", program: "p4merge", args: ["{base}", "{theirs}", "{ours}", "{result}"] },
|
||||
];
|
||||
|
||||
const terminalPresets: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
{ id: "windows-terminal", label: "Windows Terminal", program: "wt.exe", args: ["-w", "new", "-d", "{repo}"] },
|
||||
{ id: "powershell", label: "PowerShell 7", program: "pwsh.exe", args: ["-NoExit", "-WorkingDirectory", "{repo}"] },
|
||||
{ id: "windows-powershell", label: "Windows PowerShell", program: "powershell.exe", args: ["-NoExit"], builtIn: true },
|
||||
{ id: "git-bash", label: "Git Bash", program: "git-bash.exe", args: ["--login", "-i"] },
|
||||
{ id: "cmd", label: "Command Prompt", program: "cmd.exe", args: ["/K"], builtIn: true },
|
||||
{ id: "wezterm", label: "WezTerm", program: "wezterm-gui.exe", args: ["start", "--cwd", "{repo}"] },
|
||||
{ id: "alacritty", label: "Alacritty", program: "alacritty.exe", args: ["--working-directory", "{repo}"] },
|
||||
{ id: "kitty-terminal", label: "kitty", program: "kitty.exe", args: ["--directory", "{repo}"] },
|
||||
]
|
||||
: externalToolsPlatform === "macos"
|
||||
? [
|
||||
{ id: "terminal", label: "Terminal", program: "open", args: ["-n", "-a", "Terminal", "{repo}"], builtIn: true },
|
||||
{ id: "iterm2", label: "iTerm2", program: "open", args: ["-n", "-a", "iTerm", "{repo}"] },
|
||||
{ id: "warp", label: "Warp", program: "open", args: ["-n", "-a", "Warp", "{repo}"] },
|
||||
{ id: "wezterm", label: "WezTerm", program: "wezterm", args: ["start", "--cwd", "{repo}"] },
|
||||
{ id: "alacritty", label: "Alacritty", program: "alacritty", args: ["--working-directory", "{repo}"] },
|
||||
]
|
||||
: [
|
||||
{ id: "x-terminal", label: "System terminal", program: "x-terminal-emulator", args: ["--working-directory={repo}"] },
|
||||
{ id: "gnome-terminal", label: "GNOME Terminal", program: "gnome-terminal", args: ["--working-directory={repo}"] },
|
||||
{ id: "konsole", label: "Konsole", program: "konsole", args: ["--workdir", "{repo}"] },
|
||||
{ id: "kitty-terminal", label: "kitty", program: "kitty", args: ["--directory", "{repo}"] },
|
||||
{ id: "wezterm", label: "WezTerm", program: "wezterm", args: ["start", "--cwd", "{repo}"] },
|
||||
{ id: "alacritty", label: "Alacritty", program: "alacritty", args: ["--working-directory", "{repo}"] },
|
||||
{ id: "xfce-terminal", label: "Xfce Terminal", program: "xfce4-terminal", args: ["--working-directory={repo}"] },
|
||||
{ id: "tilix", label: "Tilix", program: "tilix", args: ["--working-directory={repo}"] },
|
||||
];
|
||||
|
||||
const fileManagerPresets: ExternalToolPreset[] = externalToolsPlatform === "windows"
|
||||
? [
|
||||
{ id: "explorer", label: "Windows Explorer", program: "explorer.exe", args: ["/n,", "{repo}"], builtIn: true },
|
||||
{ id: "total-commander", label: "Total Commander", program: "TOTALCMD64.EXE", args: ["/N", "/T", "{repo}"] },
|
||||
{ id: "directory-opus", label: "Directory Opus", program: "dopus.exe", args: ["{repo}"] },
|
||||
{ id: "double-commander", label: "Double Commander", program: "doublecmd.exe", args: ["{repo}"] },
|
||||
{ id: "freecommander", label: "FreeCommander XE", program: "FreeCommander.exe", args: ["/L={repo}"] },
|
||||
{ id: "xyplorer", label: "XYplorer", program: "XYplorer.exe", args: ["{repo}"] },
|
||||
]
|
||||
: externalToolsPlatform === "macos"
|
||||
? [
|
||||
{ id: "finder", label: "Finder", program: "open", args: ["-n", "{repo}"], builtIn: true },
|
||||
{ id: "forklift", label: "ForkLift", program: "forklift", args: ["{repo}"] },
|
||||
{ id: "path-finder", label: "Path Finder", program: "Path Finder", args: ["{repo}"] },
|
||||
]
|
||||
: [
|
||||
{ id: "system-file-manager", label: "System file manager", program: "xdg-open", args: ["{repo}"], builtIn: true },
|
||||
{ id: "nautilus", label: "GNOME Files", program: "nautilus", args: ["{repo}"] },
|
||||
{ id: "dolphin", label: "Dolphin", program: "dolphin", args: ["{repo}"] },
|
||||
{ id: "thunar", label: "Thunar", program: "thunar", args: ["{repo}"] },
|
||||
{ id: "nemo", label: "Nemo", program: "nemo", args: ["{repo}"] },
|
||||
{ id: "pcmanfm", label: "PCManFM", program: "pcmanfm", args: ["{repo}"] },
|
||||
{ id: "double-commander", label: "Double Commander", program: "doublecmd", args: ["{repo}"] },
|
||||
];
|
||||
|
||||
export const externalToolPresets: Record<ExternalToolKind, ExternalToolPreset[]> = {
|
||||
editor: [...commonEditors, ...platformEditors],
|
||||
diff: [...dedicatedDiff, ...codeFamily.map(codeDiff), { id: "zed", label: "Zed", program: externalToolsPlatform === "windows" ? "zed.exe" : "zed", args: ["--new", "--diff", "{left}", "{right}"] }, ...jetBrains.map(jetBrainsDiff)],
|
||||
merge: [...dedicatedMerge, ...codeFamily.map(codeMerge), ...jetBrains.map(jetBrainsMerge)],
|
||||
terminal: terminalPresets,
|
||||
fileManager: fileManagerPresets,
|
||||
};
|
||||
|
||||
function detectedPreset(kind: ExternalToolKind, id: string, detectedTools: DetectedExternalTool[]): DetectedExternalTool | undefined {
|
||||
return detectedTools.find((tool) => tool.id === id && tool.kinds.includes(kind));
|
||||
}
|
||||
|
||||
export function isExternalToolPresetAvailable(kind: ExternalToolKind, preset: ExternalToolPreset, detectedTools: DetectedExternalTool[]): boolean {
|
||||
return preset.builtIn === true || detectedPreset(kind, preset.id, detectedTools) != null;
|
||||
}
|
||||
|
||||
export function applyExternalToolPreset(kind: ExternalToolKind, id: string, detectedTools: DetectedExternalTool[] = []): ExternalToolSetting {
|
||||
const value = externalToolPresets[kind].find((item) => item.id === id) ?? externalToolPresets[kind][0];
|
||||
const detected = detectedPreset(kind, value.id, detectedTools);
|
||||
return { preset: value.id, program: detected?.program ?? value.program, args: [...value.args] };
|
||||
}
|
||||
|
||||
function preferredPreset(kind: ExternalToolKind, detectedTools: DetectedExternalTool[]): ExternalToolPreset {
|
||||
return externalToolPresets[kind].find((item) => isExternalToolPresetAvailable(kind, item, detectedTools))
|
||||
?? externalToolPresets[kind][0];
|
||||
}
|
||||
|
||||
export function defaultExternalToolsSettings(detectedTools: DetectedExternalTool[] = []): ExternalToolsSettings {
|
||||
return {
|
||||
editor: applyExternalToolPreset("editor", preferredPreset("editor", detectedTools).id, detectedTools),
|
||||
diff: applyExternalToolPreset("diff", preferredPreset("diff", detectedTools).id, detectedTools),
|
||||
merge: applyExternalToolPreset("merge", preferredPreset("merge", detectedTools).id, detectedTools),
|
||||
terminal: applyExternalToolPreset("terminal", preferredPreset("terminal", detectedTools).id, detectedTools),
|
||||
fileManager: applyExternalToolPreset("fileManager", preferredPreset("fileManager", detectedTools).id, detectedTools),
|
||||
diffOpenMode: "gitty",
|
||||
mergeOpenMode: "gitty",
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseSetting(kind: ExternalToolKind, value: unknown, fallback: ExternalToolSetting): ExternalToolSetting {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback;
|
||||
const candidate = value as Partial<ExternalToolSetting>;
|
||||
const program = typeof candidate.program === "string" && candidate.program.trim() ? candidate.program : fallback.program;
|
||||
const storedArgs = Array.isArray(candidate.args)
|
||||
? candidate.args.filter((item): item is string => typeof item === "string").slice(0, 64)
|
||||
: fallback.args;
|
||||
const preset = typeof candidate.preset === "string" && candidate.preset ? candidate.preset : fallback.preset;
|
||||
// Preset arguments are application-owned and can be upgraded safely. Editing either
|
||||
// the executable or arguments in Settings changes the preset to "custom", which keeps
|
||||
// genuine user commands untouched.
|
||||
const currentPreset = preset === "custom"
|
||||
? undefined
|
||||
: externalToolPresets[kind].find((item) => item.id === preset);
|
||||
const args = currentPreset ? [...currentPreset.args] : storedArgs;
|
||||
return { preset, program, args };
|
||||
}
|
||||
|
||||
export function normaliseExternalToolsSettings(value: unknown): ExternalToolsSettings {
|
||||
const defaults = defaultExternalToolsSettings();
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return defaults;
|
||||
const candidate = value as Partial<ExternalToolsSettings>;
|
||||
return {
|
||||
editor: normaliseSetting("editor", candidate.editor, defaults.editor),
|
||||
diff: normaliseSetting("diff", candidate.diff, defaults.diff),
|
||||
merge: normaliseSetting("merge", candidate.merge, defaults.merge),
|
||||
terminal: normaliseSetting("terminal", candidate.terminal, defaults.terminal),
|
||||
fileManager: normaliseSetting("fileManager", candidate.fileManager, defaults.fileManager),
|
||||
diffOpenMode: normaliseOpenMode(candidate.diffOpenMode, defaults.diffOpenMode),
|
||||
mergeOpenMode: normaliseOpenMode(candidate.mergeOpenMode, defaults.mergeOpenMode),
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseOpenMode(value: unknown, fallback: ToolOpenMode): ToolOpenMode {
|
||||
return value === "gitty" || value === "external" ? value : fallback;
|
||||
}
|
||||
|
||||
export function resolveDetectedExternalToolPrograms(settings: ExternalToolsSettings, detectedTools: DetectedExternalTool[]): ExternalToolsSettings {
|
||||
const resolved = structuredClone(settings);
|
||||
for (const kind of ["editor", "diff", "merge", "terminal", "fileManager"] as ExternalToolKind[]) {
|
||||
if (resolved[kind].preset === "custom") continue;
|
||||
const detected = detectedPreset(kind, resolved[kind].preset, detectedTools);
|
||||
if (detected) resolved[kind].program = detected.program;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function externalToolDisplayName(kind: ExternalToolKind, setting: ExternalToolSetting, detectedTools: DetectedExternalTool[] = []): string {
|
||||
if (setting.preset === "custom") return setting.program.split(/[\\/]/).pop() || setting.program;
|
||||
return detectedPreset(kind, setting.preset, detectedTools)?.label
|
||||
?? externalToolPresets[kind].find((item) => item.id === setting.preset)?.label
|
||||
?? setting.program;
|
||||
}
|
||||
+60
-7
@@ -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 });
|
||||
}
|
||||
@@ -114,6 +133,15 @@ export function renameBranch(
|
||||
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
|
||||
}
|
||||
|
||||
export function renameRemoteBranch(
|
||||
path: string,
|
||||
remote: string,
|
||||
oldBranch: string,
|
||||
newBranch: string,
|
||||
): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("rename_remote_branch", { path, remote, oldBranch, newBranch });
|
||||
}
|
||||
|
||||
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
||||
}
|
||||
@@ -353,13 +381,8 @@ export function credLoad(key: string): Promise<StoredCredential | null> {
|
||||
return invoke<StoredCredential | null>("cred_load", { key });
|
||||
}
|
||||
|
||||
export function credSave(
|
||||
key: string,
|
||||
username: string,
|
||||
password: string,
|
||||
expiresAt: string | null,
|
||||
): Promise<void> {
|
||||
return invoke<void>("cred_save", { key, username, password, expiresAt });
|
||||
export function credSave(key: string, username: string, password: string): Promise<void> {
|
||||
return invoke<void>("cred_save", { key, username, password });
|
||||
}
|
||||
|
||||
export function credDelete(key: string): Promise<void> {
|
||||
@@ -370,6 +393,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 });
|
||||
}
|
||||
|
||||
+31
-1
@@ -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;
|
||||
@@ -99,6 +129,7 @@ export interface GitBranch {
|
||||
name: string;
|
||||
current: boolean;
|
||||
remote: boolean;
|
||||
upstream: string | null;
|
||||
}
|
||||
|
||||
export interface GitWorktree {
|
||||
@@ -279,5 +310,4 @@ export interface ReflogEntry {
|
||||
export interface StoredCredential {
|
||||
username: string;
|
||||
password: string;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user