Compare commits

...
15 Commits
Author SHA1 Message Date
Christoph 5f7899bcee Merge pull request 'Feature/clone repo' (#15) from feature/clone_repo into main
publish / publish-tauri (, windows-latest) (release) Successful in 26m10s
Reviewed-on: #15
2026-07-04 23:24:27 +00:00
Christoph b646a2c647 feat(clone): support credentialed git clone with askpass
Cloning now accepts optional username/password and passes them to the
Rust git layer via GIT_ASKPASS, avoiding interactive prompts. The UI
detects authentication failures, opens the credential dialog for clone,
and auto-hides error messages to keep the flow smooth.

- Add username/password support to clone_repository and git.ts
- Detect auth failures and route users to the clone credential dialog
- Improve clone UX with a dedicated loading overlay and timed errors
2026-07-05 01:18:26 +02:00
Christoph 32497d53df feat(clone): add repository cloning flow to UI and backend
This introduces a new Tauri command to clone a remote repository into a
validated destination folder, then return the full repository bundle for
immediate rendering. The Svelte app gains a clone dialog and a new action
in repository management, wiring the cloned bundle into existing refresh
helpers. Dialog backdrops were also adjusted to rely on explicit close
controls.

- Add clone_repository command and core cloning logic in Rust
- Create CloneRepositoryDialog and integrate it into App.svelte
- Improve clone-related styling and tighten dialog backdrop behavior
2026-07-05 01:05:23 +02:00
Christoph 6365e164aa update for linux 2026-07-04 18:14:25 +02:00
Christoph f6af156fbd install on linux 2026-07-04 17:49:08 +02:00
Christoph 33cef059e8 Update version to 2026.7.11 2026-07-04 13:56:50 +02:00
Christoph Brandau 9a4c6e5b9b feat(branches): add force delete flow for local branches
publish / publish-tauri (, windows-latest) (release) Successful in 22m57s
Branch deletion now supports an optional force mode in the Tauri
command, enabling deletion of branches that are not fully merged.
The UI replaces the simple confirm prompt with a dedicated dialog and
auto-escalates to force delete when Git rejects a normal delete.

- Add force flag to delete_branch command and tests
- Implement BranchDeleteConfirmDialog and wire it into App.svelte
- Update branch context menu actions and styling
2026-07-04 00:52:40 +02:00
Christoph c2d7fefb47 Merge pull request 'feat(git): add rebase workflow with UI state handling' (#14) from feature/rebase into master
Reviewed-on: #14
2026-07-03 22:36:42 +00:00
Christoph ede2e46d50 Merge pull request 'feat(git stash): add stash listing and push/apply/pop/drop UI' (#13) from feature/stash into master
Reviewed-on: #13
2026-07-03 22:36:24 +00:00
Christoph Brandau 3c4425a408 feat(git): add rebase workflow with UI state handling
This introduces Git rebase commands (start, continue, abort) and
exposes whether a rebase is currently in progress via the status
payload. The UI now blocks committing during an active rebase and
shows a dedicated rebase notice with continue/abort actions.

- Add tauri commands for rebase operations and status detection
- Update frontend to render rebase state and controls
- Adjust conflict/resolution messaging and related UI styling
2026-07-04 00:34:36 +02:00
Christoph Brandau 1d67312ee4 feat(git stash): add stash listing and push/apply/pop/drop UI
This change introduces Git stash support end-to-end, including a new
backend command to list stashes and operations to push, apply, pop, and
drop them. The frontend now fetches stashes as part of the repository
bundle and provides a dedicated panel to manage shelved changes.

- Add GitStash model and Tauri commands for stash operations
- Implement StashPanel component and wire it into the app
- Adjust sidebar layout and add stash-specific styling
2026-07-04 00:16:27 +02:00
Christoph 201bb90bf7 Merge pull request 'feat(history-ui): add resizable commit history aside' (#12) from feature/git_graph into master
Reviewed-on: #12
2026-07-03 21:39:58 +00:00
Christoph Brandau 9c371ec520 feat(history-ui): add resizable commit history aside
The commit history panel is now resizable with a draggable handle and
keyboard support. The chosen width is persisted in local storage so the
layout stays consistent across sessions. Git commit loading was also
adjusted to include all branch tips for a more complete graph.

- Add width persistence and pointer/keyboard resizing for history panel
- Update commit graph query to use topo-order across all refs
- Extend tests to ensure branch tips are included in commit results
2026-07-03 23:35:46 +02:00
Christoph Brandau 835bfae254 feat(history): enhance commit history UI with richer styling
The commit history panel now renders a more structured commit header,
including an avatar, kind badge, and clearer metadata layout. The git
graph visuals were also refined with smoother paths and improved hover
effects to make the history easier to scan.

- Add commit kind/root/merge badges and author initials
- Improve graph rendering with curved paths and hover emphasis
- Update CSS for commit cards, ref chips, and graph styling
2026-07-03 21:32:20 +02:00
Christoph 2bacd473fc Update version to 2026.7.10 2026-07-03 17:17:44 +02:00
23 changed files with 2728 additions and 145 deletions
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "tauri-git-lite",
"version": "2026.7.9",
"name": "git-lite",
"version": "2026.7.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tauri-git-lite",
"version": "2026.7.9",
"name": "git-lite",
"version": "2026.7.10",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "git-lite",
"version": "2026.7.9",
"version": "2026.7.11",
"private": true,
"type": "module",
"scripts": {
+534 -5
View File
@@ -47,6 +47,7 @@ pub struct GitStatus {
pub behind: u32,
pub files: Vec<GitFileStatus>,
pub clean: bool,
pub rebase_in_progress: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -56,6 +57,16 @@ pub struct GitBranch {
pub remote: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitStash {
pub selector: String,
pub index: u32,
pub hash: String,
pub branch: Option<String>,
pub message: String,
pub date: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitCommit {
pub hash: String,
@@ -234,10 +245,34 @@ pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
pub struct RepositoryBundle {
pub status: GitStatus,
pub branches: Vec<GitBranch>,
pub stashes: Vec<GitStash>,
pub commits: Vec<GitCommit>,
pub files: Vec<GitRepositoryFile>,
}
#[tauri::command]
pub async fn clone_repository(
remote_url: String,
parent_path: String,
directory_name: Option<String>,
username: Option<String>,
password: Option<String>,
commit_limit: Option<u32>,
) -> Result<RepositoryBundle, String> {
tauri::async_runtime::spawn_blocking(move || {
clone_repository_core(
&remote_url,
&parent_path,
directory_name.as_deref(),
username.as_deref(),
password.as_deref(),
commit_limit,
)
})
.await
.map_err(|err| format!("Could not clone repository: {err}"))?
}
/// Opens a repository and gathers everything the UI needs in a single call.
///
/// Runs on a blocking thread (so the UI/overlay stays responsive) and resolves
@@ -252,11 +287,13 @@ pub async fn open_repository_bundle(
let repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?;
let branches = branches_for_repo(&repo)?;
let stashes = stashes_for_repo(&repo)?;
let commits = commits_for_repo(&repo, commit_limit)?;
let files = repository_files_with_status(&repo, &status)?;
Ok(RepositoryBundle {
status,
branches,
stashes,
commits,
files,
})
@@ -277,6 +314,12 @@ pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
branches_for_repo(&repo)
}
#[tauri::command]
pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
let repo = resolve_repo(&path)?;
stashes_for_repo(&repo)
}
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
let output = run_git(
repo,
@@ -316,6 +359,129 @@ fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
Ok(branches)
}
fn stashes_for_repo(repo: &Path) -> Result<Vec<GitStash>, String> {
let output = run_git(repo, ["stash", "list", "--format=%gd%x00%H%x00%cr%x00%gs"])?;
let text = String::from_utf8_lossy(&output);
let mut stashes = Vec::new();
for line in text.lines() {
let mut parts = line.splitn(4, '\0');
let selector = parts.next().unwrap_or_default().trim();
let hash = parts.next().unwrap_or_default().trim();
let date = parts.next().unwrap_or_default().trim();
let subject = parts.next().unwrap_or_default().trim();
if selector.is_empty() {
continue;
}
let index = stash_index_from_selector(selector).unwrap_or(stashes.len() as u32);
let (branch, message) = parse_stash_subject(subject);
stashes.push(GitStash {
selector: selector.to_string(),
index,
hash: hash.to_string(),
branch,
message,
date: date.to_string(),
});
}
Ok(stashes)
}
fn stash_index_from_selector(selector: &str) -> Option<u32> {
selector
.strip_prefix("stash@{")
.and_then(|value| value.strip_suffix('}'))
.and_then(|value| value.parse::<u32>().ok())
}
fn parse_stash_subject(subject: &str) -> (Option<String>, String) {
for prefix in ["WIP on ", "On "] {
if let Some(value) = subject.strip_prefix(prefix) {
if let Some((branch, rest)) = value.split_once(": ") {
let message = if prefix == "WIP on " {
rest.split_once(' ').map(|(_, msg)| msg).unwrap_or(rest)
} else {
rest
};
return (Some(branch.to_string()), message.to_string());
}
}
}
(None, subject.to_string())
}
#[tauri::command]
pub fn stash_push(
path: String,
message: Option<String>,
include_untracked: bool,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let trimmed_message = message.unwrap_or_default().trim().to_string();
let mut args: Vec<OsString> = vec![OsString::from("stash"), OsString::from("push")];
if include_untracked {
args.push(OsString::from("--include-untracked"));
}
if !trimmed_message.is_empty() {
args.push(OsString::from("-m"));
args.push(OsString::from(trimmed_message));
}
run_git(&repo, args)?;
status_for_repo(&repo)
}
#[tauri::command]
pub fn stash_apply(path: String, selector: String) -> Result<GitStatus, String> {
run_stash_update(path, "apply", selector)
}
#[tauri::command]
pub fn stash_pop(path: String, selector: String) -> Result<GitStatus, String> {
run_stash_update(path, "pop", selector)
}
#[tauri::command]
pub fn stash_drop(path: String, selector: String) -> Result<GitStatus, String> {
run_stash_update(path, "drop", selector)
}
fn run_stash_update(path: String, action: &str, selector: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let selector = validate_stash_selector(&selector)?;
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["stash", action, selector.as_str()])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.success() {
return status_for_repo(&repo);
}
let status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&status) {
return Ok(status);
}
Err(format!(
"Git command failed: {}",
command_output_details(&output)
))
}
fn validate_stash_selector(selector: &str) -> Result<String, String> {
let selector = selector.trim();
let Some(index) = stash_index_from_selector(selector) else {
return Err("Invalid stash selector.".to_string());
};
Ok(format!("stash@{{{index}}}"))
}
#[tauri::command]
pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
@@ -387,7 +553,11 @@ pub fn rename_branch(
}
#[tauri::command]
pub fn delete_branch(path: String, branch: String) -> Result<GitStatus, String> {
pub fn delete_branch(
path: String,
branch: String,
force: Option<bool>,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = validate_existing_local_branch_name(&repo, &branch)?;
let status = status_for_repo(&repo)?;
@@ -395,7 +565,8 @@ pub fn delete_branch(path: String, branch: String) -> Result<GitStatus, String>
return Err("The current branch cannot be deleted.".to_string());
}
run_git(&repo, ["branch", "-d", "--", branch.as_str()])?;
let delete_flag = if force.unwrap_or(false) { "-D" } else { "-d" };
run_git(&repo, ["branch", delete_flag, "--", branch.as_str()])?;
status_for_repo(&repo)
}
@@ -977,6 +1148,72 @@ pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
Err(format!("Merge failed: {details}"))
}
#[tauri::command]
pub fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = branch.trim();
if branch.is_empty() {
return Err("Branch name must not be empty.".to_string());
}
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["rebase", branch])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
rebase_status_or_error(&repo, output, "Rebase failed", true)
}
#[tauri::command]
pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !rebase_in_progress(&repo) {
return Err("No rebase is currently in progress.".to_string());
}
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["rebase", "--continue"])
.env("GIT_EDITOR", "true")
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
rebase_status_or_error(&repo, output, "Rebase continue failed", false)
}
#[tauri::command]
pub fn rebase_abort(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !rebase_in_progress(&repo) {
return Err("No rebase is currently in progress.".to_string());
}
run_git(&repo, ["rebase", "--abort"])?;
status_for_repo(&repo)
}
fn rebase_status_or_error(
repo: &Path,
output: Output,
context: &str,
ok_if_rebase_in_progress: bool,
) -> Result<GitStatus, String> {
if output.status.success() {
return status_for_repo(repo);
}
let status = status_for_repo(repo)?;
if has_unresolved_conflicts(&status) || (ok_if_rebase_in_progress && status.rebase_in_progress)
{
return Ok(status);
}
Err(format!("{context}: {}", command_output_details(&output)))
}
#[tauri::command]
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
let repo = resolve_repo(&path)?;
@@ -996,6 +1233,8 @@ fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, S
repo,
[
"log",
"--all",
"--topo-order",
"--decorate=short",
"--name-status",
"-M",
@@ -1809,9 +2048,30 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
behind: branch.behind,
clean: files.is_empty(),
files,
rebase_in_progress: rebase_in_progress(repo),
})
}
fn rebase_in_progress(repo: &Path) -> bool {
git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply")
}
fn git_path_exists(repo: &Path, name: &str) -> bool {
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
return false;
};
let value = String::from_utf8_lossy(&output).trim().to_string();
if value.is_empty() {
return false;
}
let path = PathBuf::from(value);
if path.is_absolute() {
path.exists()
} else {
repo.join(path).exists()
}
}
// `git status` only auto-detects renames between HEAD and the index (staged changes).
// A file renamed on disk but not yet `git add`ed shows up as a plain delete + untracked
// pair instead. We detect that case ourselves by comparing content hashes: if an unstaged
@@ -1971,6 +2231,166 @@ fn repository_files_with_status(
Ok(files.into_values().collect())
}
fn clone_repository_core(
remote_url: &str,
parent_path: &str,
directory_name: Option<&str>,
username: Option<&str>,
password: Option<&str>,
commit_limit: Option<u32>,
) -> Result<RepositoryBundle, String> {
let target = clone_target_path(remote_url, parent_path, directory_name)?;
run_git_clone(remote_url.trim(), &target, username, password)?;
let repo = resolve_repo(&target.to_string_lossy())?;
let status = status_for_repo(&repo)?;
let branches = branches_for_repo(&repo)?;
let stashes = stashes_for_repo(&repo)?;
let commits = commits_for_repo(&repo, commit_limit)?;
let files = repository_files_with_status(&repo, &status)?;
Ok(RepositoryBundle {
status,
branches,
stashes,
commits,
files,
})
}
fn clone_target_path(
remote_url: &str,
parent_path: &str,
directory_name: Option<&str>,
) -> Result<PathBuf, String> {
let remote = remote_url.trim();
if remote.is_empty() {
return Err("Remote URL must not be empty.".to_string());
}
if remote.starts_with('-') || remote.chars().any(|c| c.is_control()) {
return Err("Remote URL contains invalid characters.".to_string());
}
let parent = PathBuf::from(parent_path.trim());
if parent_path.trim().is_empty() {
return Err("Destination folder must not be empty.".to_string());
}
if !parent.exists() {
return Err("Destination folder does not exist.".to_string());
}
if !parent.is_dir() {
return Err("Destination path must be a folder.".to_string());
}
let raw_name = directory_name
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| infer_clone_directory_name(remote));
let name = validate_clone_directory_name(&raw_name)?;
let target = parent.join(name);
if target.exists() {
if !target.is_dir() {
return Err("Clone destination already exists and is not a folder.".to_string());
}
let mut entries = target
.read_dir()
.map_err(|err| format!("Could not inspect clone destination: {err}"))?;
if entries.next().is_some() {
return Err("Clone destination already exists and is not empty.".to_string());
}
}
Ok(target)
}
fn infer_clone_directory_name(remote_url: &str) -> String {
let trimmed = remote_url
.trim()
.split(['?', '#'])
.next()
.unwrap_or(remote_url)
.trim_end_matches(['/', '\\']);
let last_segment = trimmed
.rsplit(['/', '\\', ':'])
.find(|part| !part.trim().is_empty())
.unwrap_or("")
.trim();
last_segment
.strip_suffix(".git")
.unwrap_or(last_segment)
.trim()
.to_string()
}
fn validate_clone_directory_name(name: &str) -> Result<String, String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("Folder name could not be inferred. Enter a folder name.".to_string());
}
if trimmed == "." || trimmed == ".." {
return Err("Folder name is not valid.".to_string());
}
if trimmed.chars().any(|c| {
c.is_control() || matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*')
}) {
return Err("Folder name contains invalid characters.".to_string());
}
if Path::new(trimmed).is_absolute() {
return Err("Folder name must be relative.".to_string());
}
Ok(trimmed.to_string())
}
fn run_git_clone(
remote_url: &str,
target: &Path,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
let mut command = git_command();
command
.arg("clone")
.arg("--")
.arg(remote_url)
.arg(target)
.env("GIT_TERMINAL_PROMPT", "0");
let askpass = match (username, password) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
let askpass = write_askpass_script()?;
command
.env("GIT_ASKPASS", &askpass)
.env("GIT_CRED_USER", u)
.env("GIT_CRED_PASS", p);
Some(askpass)
}
_ => None,
};
let output = command
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"));
if let Some(path) = askpass {
let _ = std::fs::remove_file(path);
}
let output = output?;
if output.status.success() {
return Ok(());
}
let details = command_output_details(&output);
if is_auth_error(&details) {
return Err(format!("AUTH_FAILED:{details}"));
}
Err(format!("Git clone failed: {}", details))
}
fn is_repository_folder_path(repo: &Path, path: &str) -> Result<bool, String> {
let normalized = normalize_git_path(path);
if repo.join(path).is_dir() {
@@ -3445,6 +3865,51 @@ mod tests {
run_git_test(repo, ["commit", "-q", "-m", "init"]);
}
#[test]
fn clone_directory_name_is_inferred_from_common_remote_urls() {
assert_eq!(
infer_clone_directory_name("https://github.com/example/project.git"),
"project"
);
assert_eq!(
infer_clone_directory_name("git@github.com:example/project.git"),
"project"
);
assert_eq!(
infer_clone_directory_name("ssh://git@example.com/example/project.git/"),
"project"
);
}
#[test]
fn clone_repository_core_clones_and_returns_repository_bundle() {
let source = init_temp_repo("clone_source");
commit_initial_file(&source.path);
let parent = temp_dir("clone_parent");
let bundle = clone_repository_core(
source.path.to_str().expect("source path should be UTF-8"),
parent.path.to_str().expect("parent path should be UTF-8"),
Some("local-copy"),
None,
None,
Some(100),
)
.expect("repository should clone");
let cloned_repo = parent.path.join("local-copy");
assert_eq!(
PathBuf::from(bundle.status.repo_path),
cloned_repo
.canonicalize()
.expect("clone path should resolve")
);
assert!(cloned_repo.join("old.txt").exists());
assert!(bundle.status.clean);
assert_eq!(bundle.commits.len(), 1);
assert!(bundle.files.iter().any(|file| file.path == "old.txt"));
}
#[test]
fn search_code_introductions_finds_added_string() {
let repo = init_temp_repo("search_added_string");
@@ -3869,6 +4334,33 @@ mod tests {
assert!(comparison.patch.contains("+original"));
}
#[test]
fn commits_for_repo_includes_all_branch_tips_for_graph() {
let repo = init_temp_repo("commits_all_branches");
commit_initial_file(&repo.path);
let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature/graph"]);
fs::write(repo.path.join("feature.txt"), "feature\n")
.expect("feature file should be written");
run_git_test(&repo.path, ["add", "feature.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "feature graph"]);
let feature_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
run_git_test(&repo.path, ["checkout", "-q", base_branch.as_str()]);
fs::write(repo.path.join("main.txt"), "main\n").expect("main file should be written");
run_git_test(&repo.path, ["add", "main.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "main graph"]);
let main_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
let commits = commits_for_repo(&repo.path, Some(10)).expect("commits should load");
assert!(commits.iter().any(|commit| commit.hash == main_commit));
assert!(commits.iter().any(|commit| {
commit.hash == feature_commit && commit.refs.iter().any(|r| r.contains("feature/graph"))
}));
}
#[test]
#[cfg_attr(
windows,
@@ -4263,8 +4755,12 @@ mod tests {
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["branch", "stale"]);
let status =
delete_branch(repo.path.to_string_lossy().to_string(), "stale".to_string()).unwrap();
let status = delete_branch(
repo.path.to_string_lossy().to_string(),
"stale".to_string(),
None,
)
.unwrap();
assert_eq!(status.current_branch.as_deref(), Some(current.as_str()));
assert!(
@@ -4272,10 +4768,43 @@ mod tests {
"deleted branch should be gone"
);
let err = delete_branch(repo.path.to_string_lossy().to_string(), current).unwrap_err();
let err =
delete_branch(repo.path.to_string_lossy().to_string(), current, None).unwrap_err();
assert!(err.contains("current branch"));
}
#[test]
fn delete_branch_can_force_delete_unmerged_branch() {
let repo = init_temp_repo("delete_branch_force");
commit_initial_file(&repo.path);
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature/unmerged"]);
fs::write(repo.path.join("feature.txt"), "feature\n")
.expect("feature file should be written");
run_git_test(&repo.path, ["add", "feature.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "feature"]);
run_git_test(&repo.path, ["checkout", "-q", current.as_str()]);
let err = delete_branch(
repo.path.to_string_lossy().to_string(),
"feature/unmerged".to_string(),
Some(false),
)
.unwrap_err();
assert!(err.contains("not fully merged"));
delete_branch(
repo.path.to_string_lossy().to_string(),
"feature/unmerged".to_string(),
Some(true),
)
.unwrap();
assert!(
!ref_exists(&repo.path, "refs/heads/feature/unmerged").unwrap(),
"force-deleted branch should be gone"
);
}
#[test]
fn apply_file_patch_stages_and_discards_selected_changes() {
let repo = init_temp_repo("apply_file_patch");
+19 -8
View File
@@ -6,14 +6,16 @@ mod git;
use badge::set_sync_badge;
use git::{
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
checkout_branch, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models,
commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch,
cred_delete, cred_load, cred_save, delete_branch, diff_file_against_working_tree, fetch,
get_file_patch, get_remote_url, get_status, list_branches, list_commits, list_file_history,
list_repository_files, merge_branch, open_repo_in_explorer, open_repository,
open_repository_bundle, open_repository_file, pull, push, read_conflict, rename_branch,
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
restore_to_commit, search_code_introductions, stage_files, unstage_files,
checkout_branch, clone_repository, commit, commit_ai_generate, commit_ai_load,
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, delete_branch,
diff_file_against_working_tree, fetch, get_file_patch, get_remote_url, get_status,
list_branches, list_commits, list_file_history, list_repository_files, list_stashes,
merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle,
open_repository_file, pull, push, read_conflict, rebase_abort, rebase_branch, rebase_continue,
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
restore_files, restore_to_commit, search_code_introductions, stage_files, stash_apply,
stash_drop, stash_pop, stash_push, unstage_files,
};
fn main() {
@@ -24,16 +26,22 @@ fn main() {
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
open_repository,
clone_repository,
open_repo_in_explorer,
open_repository_file,
get_status,
list_branches,
list_stashes,
checkout_branch,
create_branch,
rename_branch,
delete_branch,
stage_files,
unstage_files,
stash_push,
stash_apply,
stash_pop,
stash_drop,
restore_files,
get_file_patch,
apply_file_patch,
@@ -49,6 +57,9 @@ fn main() {
restore_to_commit,
restore_file_from_commit,
merge_branch,
rebase_branch,
rebase_continue,
rebase_abort,
list_repository_files,
open_repository_bundle,
list_file_history,
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GitLite",
"version": "2026.7.9",
"version": "2026.7.11",
"identifier": "com.git-lite",
"build": {
"beforeDevCommand": "npm run dev",
@@ -29,7 +29,7 @@
"bundle": {
"active": true,
"targets": ["nsis"],
"icon": ["icons/icon.ico"],
"icon": ["icons/icon.png", "icons/icon.ico"],
"createUpdaterArtifacts": true,
"windows": {
"nsis": {
+476 -17
View File
@@ -2,11 +2,13 @@
import { onDestroy, onMount, tick } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
import { AlertCircle, BookOpen, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
import TitleBar from "./lib/TitleBar.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte";
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareDialog from "./lib/components/CompareDialog.svelte";
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
@@ -21,11 +23,13 @@
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StashPanel from "./lib/components/StashPanel.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte";
import UpdateToast from "./lib/components/UpdateToast.svelte";
import {
checkoutBranch,
cloneRepository,
commit,
commitAiGenerate,
commitAiLoad,
@@ -42,6 +46,7 @@
fetchRemote,
getStatus,
listBranches,
listStashes,
listCommits,
listFileHistory,
listRepositoryFiles,
@@ -52,6 +57,9 @@
pull,
push,
renameBranch,
rebaseAbort,
rebaseBranch,
rebaseContinue,
getRemoteUrl,
credLoad,
credSave,
@@ -66,6 +74,10 @@
searchCodeIntroductions,
setSyncBadge,
stageFiles,
stashApply,
stashDrop,
stashPop,
stashPush,
unstageFiles,
} from "./lib/git";
@@ -83,6 +95,7 @@
GitFileStatus,
GitRepositoryFile,
GitSearchHit,
GitStash,
GitStatus,
LocalModelOption,
PatchApplyAction,
@@ -99,6 +112,7 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone";
type PendingDiscard =
| { kind: "file"; file: GitFileStatus; staged: boolean }
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
@@ -113,13 +127,24 @@
lastOpened: number;
}
interface CloneRequest {
remoteUrl: string;
parentPath: string;
directoryName: string;
}
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1";
const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT;
const COMMIT_PANEL_MAX_HEIGHT = 640;
const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
const HISTORY_ASIDE_MIN_WIDTH = 560;
const HISTORY_ASIDE_MAX_WIDTH = 920;
const ERROR_AUTO_HIDE_MS = 6000;
// ── State ──────────────────────────────────────────────────────────────────
@@ -129,8 +154,13 @@
let repoTabs: RepoTab[] = [];
let recentRepoPaths: string[] = [];
let repoSearch = "";
let cloneDialogOpen = false;
let cloneDialogError = "";
let cloneDialogErrorTimer: ReturnType<typeof setTimeout> | undefined;
let pendingClone: CloneRequest | null = null;
let status: GitStatus | null = null;
let branches: GitBranchInfo[] = [];
let stashes: GitStash[] = [];
let commits: GitCommit[] = [];
let repoFiles: GitRepositoryFile[] = [];
let selectedExplorerPath = "";
@@ -157,6 +187,8 @@
let comparison: GitCommitComparison | null = null;
let newBranchCommit: GitCommit | null = null;
let renameBranchTarget: GitBranchInfo | null = null;
let deleteBranchTarget: GitBranchInfo | null = null;
let deleteBranchForce = false;
let compareSelectOpen = false;
let compareDialogOpen = false;
let selectedDiffPath = "";
@@ -182,7 +214,7 @@
let autoRefreshEnabled = true;
let autoRefreshInFlight = false;
let credDialogOpen = false;
let credDialogAction: "push" | "pull" | "fetch" | null = null;
let credDialogAction: CredentialAction | null = null;
let credDialogError = "";
let credDialogKey: string | null = null;
let lastStatusFingerprint = "";
@@ -203,10 +235,15 @@
let updateCheckInFlight = false;
let updateDownloadTotal = 0;
let updateDownloadedBytes = 0;
let errorAutoHideTimers: Partial<Record<string, ReturnType<typeof setTimeout>>> = {};
let commitPanelHeight = loadCommitPanelHeight();
let resizingCommitPanel = false;
let resizeStartY = 0;
let resizeStartHeight = 0;
let historyAsideWidth = loadHistoryAsideWidth();
let resizingHistoryAside = false;
let historyResizeStartX = 0;
let historyResizeStartWidth = 0;
// ── Derived ────────────────────────────────────────────────────────────────
@@ -214,18 +251,24 @@
$: hasRepository = activeRepoPath.length > 0 && status !== null;
$: workspaceActive = activeView === "repository" && hasRepository;
$: openingRepo = operation === "Opening repository";
$: cloningRepo = operation === "Cloning repository";
$: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
$: cloneDisplayName = pendingClone?.directoryName || repoNameFromCloneUrl(pendingClone?.remoteUrl ?? "");
$: changedFiles = status?.files ?? [];
$: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0;
$: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0;
$: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted");
$: hasConflicts = conflictedFiles.length > 0;
$: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !isBusy;
$: commitBlockReason = hasConflicts
? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "merge conflict must" : "merge conflicts must"} be resolved before committing.`
$: rebaseInProgress = status?.rebase_in_progress ?? false;
$: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !rebaseInProgress && !isBusy;
$: commitBlockReason = rebaseInProgress
? "A rebase is in progress. Resolve conflicts and use Rebase continue or abort the rebase."
: hasConflicts
? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "conflict must" : "conflicts must"} be resolved before committing.`
: "";
$: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy;
$: localBranches = branches.filter((b) => !b.remote);
$: localBranchNames = localBranches.map((b) => b.name);
$: remoteBranches = branches.filter((b) => b.remote);
$: repoSearchTerm = repoSearch.trim().toLowerCase();
$: openRepoRows = repoTabs.filter(repoMatchesSearch);
@@ -251,9 +294,37 @@
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
Object.values(errorAutoHideTimers).forEach((timer) => {
if (timer) clearTimeout(timer);
});
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
});
$: scheduleAutoHideError("errorMessage", errorMessage, (message) => {
if (errorMessage === message) errorMessage = "";
});
$: scheduleAutoHideError("linePatchError", linePatchError, (message) => {
if (linePatchError === message) linePatchError = "";
});
$: scheduleAutoHideError("globalSearchError", globalSearchError, (message) => {
if (globalSearchError === message) globalSearchError = "";
});
$: scheduleAutoHideError("credDialogError", credDialogError, (message) => {
if (credDialogError === message) credDialogError = "";
});
$: scheduleAutoHideError("updateError", updateError, (message) => {
if (updateError === message) updateError = "";
if (updateToastState === "error") updateToastOpen = false;
});
$: scheduleAutoHideError(
"updateErrorToast",
updateToastOpen && updateToastState === "error" ? (updateError || "Update failed") : "",
() => {
if (updateToastState === "error") updateToastOpen = false;
},
);
// ── Auto-refresh ───────────────────────────────────────────────────────────
function statusFingerprint(value: GitStatus): string {
@@ -289,6 +360,7 @@
const bundle = await openRepositoryBundle(activeRepoPath, 100);
const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
// File history reflects `git log`, which only changes when HEAD actually moves
@@ -490,6 +562,41 @@
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function repoNameFromCloneUrl(url: string): string {
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
return lastSegment.replace(/\.git$/i, "").trim();
}
function setCloneDialogError(message: string) {
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
cloneDialogError = message;
if (message) {
cloneDialogErrorTimer = setTimeout(() => {
if (cloneDialogError === message) cloneDialogError = "";
}, ERROR_AUTO_HIDE_MS);
}
}
function scheduleAutoHideError(
key: string,
message: string,
clearIfCurrent: (message: string) => void,
) {
const existing = errorAutoHideTimers[key];
if (existing) {
clearTimeout(existing);
delete errorAutoHideTimers[key];
}
if (!message) return;
errorAutoHideTimers[key] = setTimeout(() => {
clearIfCurrent(message);
delete errorAutoHideTimers[key];
}, ERROR_AUTO_HIDE_MS);
}
function repoKey(path: string): string {
return path.replace(/\\/g, "/").trim().toLowerCase();
}
@@ -625,6 +732,28 @@
}
}
function clampHistoryAsideWidth(value: number): number {
return Math.min(HISTORY_ASIDE_MAX_WIDTH, Math.max(HISTORY_ASIDE_MIN_WIDTH, Math.round(value)));
}
function loadHistoryAsideWidth(): number {
try {
const stored = Number(localStorage.getItem(HISTORY_ASIDE_WIDTH_KEY));
if (Number.isFinite(stored) && stored > 0) return clampHistoryAsideWidth(stored);
} catch {
// Fall through to the default below.
}
return HISTORY_ASIDE_DEFAULT_WIDTH;
}
function persistHistoryAsideWidth(value: number) {
try {
localStorage.setItem(HISTORY_ASIDE_WIDTH_KEY, String(value));
} catch {
// Local storage is best-effort only; resizing must keep working without it.
}
}
function startCommitPanelResize(event: PointerEvent) {
event.preventDefault();
resizingCommitPanel = true;
@@ -653,6 +782,34 @@
persistCommitPanelHeight(commitPanelHeight);
}
function startHistoryAsideResize(event: PointerEvent) {
event.preventDefault();
resizingHistoryAside = true;
historyResizeStartX = event.clientX;
historyResizeStartWidth = historyAsideWidth;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onHistoryAsideResizeMove(event: PointerEvent) {
if (!resizingHistoryAside) return;
historyAsideWidth = clampHistoryAsideWidth(historyResizeStartWidth + (historyResizeStartX - event.clientX));
}
function endHistoryAsideResize(event: PointerEvent) {
if (!resizingHistoryAside) return;
resizingHistoryAside = false;
persistHistoryAsideWidth(historyAsideWidth);
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onHistoryAsideResizeKeydown(event: KeyboardEvent) {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
historyAsideWidth = clampHistoryAsideWidth(historyAsideWidth + (event.key === "ArrowLeft" ? 24 : -24));
persistHistoryAsideWidth(historyAsideWidth);
}
function rememberRecentRepo(path: string) {
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
persistRepoLists();
@@ -685,6 +842,7 @@
void setSyncBadge(0, 0, 0).catch(() => {});
}
branches = [];
stashes = [];
commits = [];
lastFileHistoryHeadHash = "";
repoFiles = [];
@@ -702,6 +860,8 @@
pendingRestoreFile = null;
newBranchCommit = null;
globalSearchResults = [];
deleteBranchTarget = null;
deleteBranchForce = false;
globalSearchOpen = false;
globalSearchError = "";
resolveDialogOpen = false;
@@ -737,6 +897,11 @@
return (value?.files ?? []).some((file) => file.staged === "conflicted" || file.unstaged === "conflicted");
}
function isBranchNotFullyMergedError(message: string): boolean {
const value = message.toLowerCase();
return value.includes("not fully merged") || value.includes("run 'git branch -d'");
}
async function runOperation(label: string, task: () => Promise<void>) {
if (isBusy) return;
operation = label;
@@ -776,6 +941,10 @@
branches = prefetched ?? (await listBranches(path));
}
async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
stashes = prefetched ?? (await listStashes(path));
}
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
commits = prefetched ?? (await listCommits(path, 100));
lastFileHistoryHeadHash = commits[0]?.hash ?? "";
@@ -870,6 +1039,7 @@
if (globalSearchBusy) void cancelGlobalSearch();
activeView = "repository";
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
lastRepoSwitchAt = Date.now();
@@ -893,6 +1063,91 @@
}
}
async function cloneRepo(
remoteUrl: string,
parentPath: string,
directoryName: string,
username?: string,
password?: string,
key?: string | null,
fromStore = false,
) {
if (isBusy) return;
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
if (!parentPath) { errorMessage = "Select a destination folder."; return; }
const request: CloneRequest = { remoteUrl, parentPath, directoryName };
pendingClone = request;
const credentialKey = key === undefined ? orgKeyFromUrl(remoteUrl) : key;
if (!username && !password) {
const stored = await loadStoredCredential(credentialKey);
if (stored && !isCredentialExpired(stored)) {
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true);
return;
}
if (stored && credentialKey) await credDelete(credentialKey).catch(() => {});
}
operation = "Cloning repository";
errorMessage = "";
setCloneDialogError("");
try {
const bundle = await cloneRepository(
remoteUrl,
parentPath,
directoryName || undefined,
username,
password,
100,
);
resetRepositoryState(false);
applyStatus(bundle.status);
if (globalSearchBusy) void cancelGlobalSearch();
activeView = "repository";
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
cloneDialogOpen = false;
pendingClone = null;
if (credDialogAction === "clone") {
credDialogOpen = false;
credDialogAction = null;
credDialogError = "";
credDialogKey = null;
}
lastRepoSwitchAt = Date.now();
} catch (error) {
const rawMessage = errorToMessage(error);
const message = stripAuthPrefix(rawMessage);
if (isAuthError(rawMessage)) {
errorMessage = "";
setCloneDialogError("");
if (fromStore) {
if (credentialKey) void credDelete(credentialKey).catch(() => {});
credDialogError = "Credentials were rejected or have expired. Please sign in again.";
} else {
credDialogError = message || "Sign-in is required to clone this repository.";
}
credDialogAction = "clone";
credDialogKey = credentialKey;
credDialogOpen = true;
} else {
setCloneDialogError(message);
errorMessage = "";
}
} finally {
operation = "";
}
}
function openCloneDialog() {
if (isBusy) return;
setCloneDialogError("");
cloneDialogOpen = true;
}
function openRepoManagement() {
if (isBusy) return;
activeView = "management";
@@ -947,6 +1202,7 @@
await runOperation("Refreshing", async () => {
applyStatus(await getStatus(activeRepoPath));
await refreshBranchList(activeRepoPath);
await refreshStashes(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
@@ -1003,16 +1259,41 @@
return;
}
const confirmed = window.confirm(`Delete local branch "${branch.name}"?\n\nGit will refuse if the branch has unmerged changes.`);
if (!confirmed) return;
deleteBranchTarget = branch;
deleteBranchForce = false;
}
await runOperation(`Deleting ${branch.name}`, async () => {
applyStatus(await deleteBranch(activeRepoPath, branch.name));
async function confirmDeleteBranch() {
const branch = deleteBranchTarget;
if (!activeRepoPath || !branch || branch.remote || branch.current || isBusy) return;
operation = `${deleteBranchForce ? "Force deleting" : "Deleting"} ${branch.name}`;
errorMessage = "";
try {
applyStatus(await deleteBranch(activeRepoPath, branch.name, deleteBranchForce));
deleteBranchTarget = null;
deleteBranchForce = false;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
} catch (error) {
const message = errorToMessage(error);
if (deleteBranchForce || !isBranchNotFullyMergedError(message)) {
errorMessage = message;
return;
}
deleteBranchForce = true;
} finally {
operation = "";
}
}
function closeDeleteBranchDialog() {
if (isBusy) return;
deleteBranchTarget = null;
deleteBranchForce = false;
}
function openNewBranchDialog(commit: GitCommit) {
@@ -1045,6 +1326,46 @@
});
}
async function rebaseOnto(branch: GitBranchInfo) {
if (!activeRepoPath || branch.current || rebaseInProgress) return;
await runOperation(`Rebasing onto ${branch.name}`, async () => {
applyStatus(await rebaseBranch(activeRepoPath, branch.name));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function continueRebase() {
if (!activeRepoPath || !rebaseInProgress || hasConflicts) return;
await runOperation("Continuing rebase", async () => {
applyStatus(await rebaseContinue(activeRepoPath));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function abortRebase() {
if (!activeRepoPath || !rebaseInProgress) return;
const confirmed = window.confirm("Abort the current rebase and return to the previous state?");
if (!confirmed) return;
await runOperation("Aborting rebase", async () => {
applyStatus(await rebaseAbort(activeRepoPath));
preparedResolutions = {};
resolveDialogOpen = false;
conflict = null;
conflictTarget = "";
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
// Resolve the keychain key (host/org) for the active repo's remote.
async function currentCredKey(): Promise<string | null> {
if (!activeRepoPath) return null;
@@ -1065,11 +1386,11 @@
}
}
async function openCredentialDialog(action: "push" | "pull" | "fetch", key?: string | null) {
if (!activeRepoPath) return;
async function openCredentialDialog(action: CredentialAction, key?: string | null) {
if (!activeRepoPath && action !== "clone") return;
credDialogError = "";
credDialogAction = action;
credDialogKey = key === undefined ? await currentCredKey() : key;
credDialogKey = key === undefined && action !== "clone" ? await currentCredKey() : (key ?? null);
credDialogOpen = true;
}
@@ -1202,6 +1523,17 @@
if (credDialogAction === "pull") await doActualPull(username, password, key, false);
else if (credDialogAction === "push") await doActualPush(username, password, key, false);
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false);
else if (credDialogAction === "clone" && pendingClone) {
await cloneRepo(
pendingClone.remoteUrl,
pendingClone.parentPath,
pendingClone.directoryName,
username,
password,
key,
false,
);
}
// Only persist once the operation actually succeeded (dialog has closed).
if (!credDialogOpen && save && key) {
@@ -1242,6 +1574,47 @@
await startRemoteAction("push");
}
async function saveStash(message: string, includeUntracked: boolean) {
if (!activeRepoPath || changedFiles.length === 0) return;
await runOperation("Stashing changes", async () => {
applyStatus(await stashPush(activeRepoPath, message, includeUntracked));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function applyStashEntry(stash: GitStash) {
if (!activeRepoPath) return;
await runOperation(`Applying ${stash.selector}`, async () => {
applyStatus(await stashApply(activeRepoPath, stash.selector));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function popStashEntry(stash: GitStash) {
if (!activeRepoPath) return;
await runOperation(`Popping ${stash.selector}`, async () => {
applyStatus(await stashPop(activeRepoPath, stash.selector));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function dropStashEntry(stash: GitStash) {
if (!activeRepoPath) return;
const confirmed = window.confirm(`Delete ${stash.selector}?\n\n"${stash.message || stash.selector}"`);
if (!confirmed) return;
await runOperation(`Dropping ${stash.selector}`, async () => {
applyStatus(await stashDrop(activeRepoPath, stash.selector));
await refreshStashes(activeRepoPath);
});
}
// ── File staging / restore ─────────────────────────────────────────────────
async function stageFile(file: GitFileStatus) {
@@ -1403,7 +1776,11 @@
const message = commitMessage.trim();
if (!message || !activeRepoPath) return;
if (hasConflicts) {
errorMessage = "Resolve all merge conflicts before committing.";
errorMessage = "Resolve all conflicts before committing.";
return;
}
if (rebaseInProgress) {
errorMessage = "A rebase is in progress. Use Rebase continue or abort the rebase.";
return;
}
await runOperation("Committing", async () => {
@@ -1715,6 +2092,7 @@
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
}
@@ -1823,14 +2201,33 @@
</section>
{/if}
{#if workspaceActive && hasConflicts}
{#if workspaceActive && hasConflicts && !rebaseInProgress}
<section class="notice conflict" role="alert">
<GitMerge size={17} aria-hidden="true" />
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} merge conflicts.</span>
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span>
<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
</section>
{/if}
{#if workspaceActive && rebaseInProgress}
<section class="notice rebase" role="status">
<GitBranch size={17} aria-hidden="true" />
<span>
Rebase in progress.
{#if hasConflicts}
Resolve conflicts, then continue.
{:else}
Continue when the index is ready, or abort to return to the previous state.
{/if}
</span>
{#if hasConflicts}
<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
{/if}
<button type="button" onclick={continueRebase} disabled={isBusy || hasConflicts}>Continue</button>
<button type="button" onclick={abortRebase} disabled={isBusy}>Abort</button>
</section>
{/if}
{#if activeView === "management"}
<section class="repo-management" aria-label="Repository Management">
<div class="repo-management-head">
@@ -1839,6 +2236,10 @@
<h1>Repositories</h1>
</div>
<div class="repo-management-actions">
<button class="btn-primary" type="button" onclick={openCloneDialog} disabled={isBusy}>
<Download size={15} aria-hidden="true" />
Clone
</button>
<button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
<FolderOpen size={15} aria-hidden="true" />
Browse
@@ -1945,7 +2346,7 @@
</section>
{:else}
<!-- Workspace -->
<section class="workspace" aria-label="Git workspace">
<section class="workspace" aria-label="Git workspace" style="--history-aside-width: {historyAsideWidth}px;">
<!-- Left sidebar: branches + explorer -->
<aside class="left-sidebar" aria-label="Repository navigation">
@@ -1957,10 +2358,21 @@
{isBusy}
onCheckout={checkout}
onMerge={merge}
onRebase={rebaseOnto}
onCreateBranch={createNewBranch}
onRenameBranch={renameLocalBranch}
onDeleteBranch={deleteLocalBranch}
/>
<StashPanel
{stashes}
changedCount={changedFiles.length}
{hasRepository}
{isBusy}
onPush={saveStash}
onApply={applyStashEntry}
onPop={popStashEntry}
onDrop={dropStashEntry}
/>
<ExplorerPanel
{repoFiles}
{expandedExplorerPaths}
@@ -2049,8 +2461,29 @@
<!-- Right sidebar: commit graph + file history -->
<aside class="history-aside" aria-label="Commit history">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="history-resize-handle"
class:resizing={resizingHistoryAside}
role="separator"
aria-orientation="vertical"
aria-label="Resize history panel width"
aria-valuenow={historyAsideWidth}
aria-valuemin={HISTORY_ASIDE_MIN_WIDTH}
aria-valuemax={HISTORY_ASIDE_MAX_WIDTH}
tabindex="0"
onpointerdown={startHistoryAsideResize}
onpointermove={onHistoryAsideResizeMove}
onpointerup={endHistoryAsideResize}
onpointercancel={endHistoryAsideResize}
onkeydown={onHistoryAsideResizeKeydown}
></div>
<HistoryPanel
{commits}
{localBranchNames}
activeBranch={status?.current_branch ?? ""}
repositoryKey={activeRepoPath}
{hasRepository}
{isBusy}
{expandedCommitHashes}
@@ -2157,6 +2590,17 @@
/>
{/if}
<!-- Delete a local branch from the branch context menu -->
{#if deleteBranchTarget}
<BranchDeleteConfirmDialog
branch={deleteBranchTarget}
force={deleteBranchForce}
{isBusy}
onConfirm={confirmDeleteBranch}
onClose={closeDeleteBranchDialog}
/>
{/if}
<!-- Choose the AI provider/model used to generate commit messages -->
{#if aiSettingsOpen}
<AiSettingsDialog
@@ -2208,11 +2652,26 @@
/>
{/if}
<!-- Clone repository dialog -->
{#if cloneDialogOpen}
<CloneRepositoryDialog
isBusy={operation === "Cloning repository"}
error={cloneDialogError}
onClone={cloneRepo}
onClose={() => { if (!isBusy) cloneDialogOpen = false; }}
/>
{/if}
<!-- Full-screen overlay while a repository is being opened -->
{#if openingRepo}
<RepoLoadingOverlay repoName={repoDisplayName} />
{/if}
<!-- Full-screen overlay while a repository is being cloned -->
{#if cloningRepo}
<RepoLoadingOverlay label="Cloning repository" repoName={cloneDisplayName} />
{/if}
<!-- Conflict resolve dialog -->
{#if resolveDialogOpen}
<ResolveDialog
+772 -27
View File
@@ -187,6 +187,199 @@
}
.section-head h2 { margin: 1px 0 0; color: var(--color-ink); font-size: 14px; line-height: 1.2; font-weight: 600; }
.section-head-actions {
display: flex;
align-items: center;
flex: 0 0 auto;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
.graph-branch-dialog-button {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 26px;
padding: 0 9px;
overflow: hidden;
border: 1px solid rgba(91,209,138,0.24);
border-radius: 999px;
color: #b6f1c4;
background: rgba(34,68,48,0.28);
font-size: 11px;
font-weight: 800;
white-space: nowrap;
transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
}
.graph-branch-dialog-button:hover {
border-color: rgba(91,209,138,0.42);
background: rgba(34,68,48,0.42);
}
.graph-branch-dialog-button span {
padding: 1px 5px;
border-radius: 999px;
color: #061021;
background: #6ce18f;
font-size: 10px;
line-height: 1.2;
}
.branch-filter-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 24px;
background: rgba(4,8,18,0.58);
backdrop-filter: blur(8px);
}
.branch-filter-dialog {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
width: min(520px, 100%);
max-height: min(680px, calc(100vh - 48px));
overflow: hidden;
border: 1px solid rgba(94,110,156,0.24);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(255,255,255,0.045), transparent 70%),
var(--color-surface);
box-shadow: 0 24px 80px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.05);
}
.branch-filter-dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 14px 12px;
border-bottom: 1px solid var(--color-border-subtle);
background: linear-gradient(90deg, rgba(100,108,255,0.12), rgba(65,209,255,0.04));
}
.branch-filter-dialog-head h3 {
margin: 1px 0 0;
color: var(--color-ink);
font-size: 16px;
font-weight: 750;
}
.dialog-icon-button {
display: inline-grid;
place-items: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid rgba(94,110,156,0.18);
border-radius: 7px;
color: var(--color-ink-dim);
background: rgba(255,255,255,0.035);
}
.dialog-icon-button:hover {
color: var(--color-ink);
border-color: rgba(65,209,255,0.28);
background: rgba(65,209,255,0.08);
}
.branch-filter-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--color-border-subtle);
color: var(--color-ink-dim);
font-size: 12px;
font-weight: 700;
}
.branch-filter-actions {
display: inline-flex;
gap: 6px;
}
.branch-filter-actions button {
min-height: 24px;
padding: 0 8px;
border-color: rgba(94,110,156,0.16);
border-radius: 999px;
color: var(--color-ink-dim);
background: rgba(255,255,255,0.035);
font-size: 10.5px;
font-weight: 800;
}
.branch-filter-actions button:hover:not(:disabled) {
border-color: rgba(65,209,255,0.28);
color: var(--color-ink);
background: rgba(65,209,255,0.08);
}
.branch-filter-actions button:disabled {
cursor: default;
opacity: 0.45;
}
.branch-filter-dialog-list {
display: grid;
align-content: start;
gap: 4px;
min-height: 0;
padding: 8px;
overflow: auto;
scrollbar-width: thin;
}
.branch-filter-option {
display: grid;
grid-template-columns: 16px 16px minmax(0, 1fr);
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 7px;
color: #a8eeba;
background: transparent;
cursor: pointer;
font-size: 12px;
font-weight: 750;
}
.branch-filter-option:hover {
border-color: rgba(91,209,138,0.18);
background: rgba(34,68,48,0.22);
}
.branch-filter-option.muted {
color: var(--color-ink-faint);
opacity: 0.72;
}
.branch-filter-option input {
width: 14px;
height: 14px;
accent-color: #6ce18f;
}
.branch-filter-option svg {
color: currentColor;
}
.branch-filter-option span {
min-width: 0;
overflow: hidden;
font-family: var(--font-mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.eyebrow {
display: block;
color: var(--color-ink-faint);
@@ -669,6 +862,8 @@
.notice.error { border-color: rgba(232,96,90,0.3); color: #f09090; background: rgba(232,96,90,0.08); }
.notice.busy { border-color: rgba(90,140,248,0.28); color: #8ab0f8; background: rgba(90,140,248,0.07); }
.notice.conflict { border-color: rgba(224,160,64,0.3); color: #e8b060; background: rgba(224,160,64,0.07); }
.notice.rebase { flex-wrap: wrap; border-color: rgba(186,130,255,0.3); color: #c9a8ff; background: rgba(186,130,255,0.075); }
.notice.rebase span { min-width: 0; flex: 1 1 auto; }
.notice.conflict button {
margin-left: auto;
min-height: 26px;
@@ -684,6 +879,20 @@
border-color: rgba(224,160,64,0.45);
color: #f0c070;
}
.notice.rebase button {
min-height: 26px;
padding: 0 10px;
border-color: rgba(186,130,255,0.28);
color: #d5bdff;
background: rgba(186,130,255,0.1);
font-size: 12px;
font-weight: 700;
}
.notice.rebase button:hover:not(:disabled) {
background: rgba(186,130,255,0.18);
border-color: rgba(186,130,255,0.45);
color: #eadfff;
}
/* --- Update toast --- */
@@ -862,7 +1071,7 @@
.workspace {
display: grid;
grid-template-columns: clamp(220px, 18vw, 280px) minmax(0, 1fr) clamp(400px, 40vw, 620px);
grid-template-columns: clamp(220px, 18vw, 280px) minmax(0, 1fr) minmax(560px, var(--history-aside-width, 620px));
flex: 1 1 0;
min-width: 0;
min-height: 0;
@@ -870,21 +1079,57 @@
}
.history-aside {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
grid-template-columns: minmax(0, 1fr) 300px;
min-width: 0;
min-height: 0;
gap: 8px;
}
.history-resize-handle {
position: absolute;
z-index: 20;
top: 0;
bottom: 0;
left: -7px;
width: 12px;
cursor: col-resize;
touch-action: none;
}
.history-resize-handle::before {
content: "";
position: absolute;
top: 12px;
bottom: 12px;
left: 5px;
width: 2px;
border-radius: 999px;
background: transparent;
transition: background 120ms ease, box-shadow 120ms ease;
}
.history-resize-handle:hover::before,
.history-resize-handle.resizing::before {
background: rgba(65,209,255,0.62);
box-shadow: 0 0 14px rgba(65,209,255,0.3);
}
.history-resize-handle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.left-sidebar {
display: grid;
grid-template-rows: minmax(200px, 0.9fr) minmax(240px, 1.1fr);
grid-template-rows: minmax(170px, 0.75fr) minmax(150px, 0.55fr) minmax(220px, 1fr);
min-width: 0;
min-height: 0;
gap: 8px;
}
.left-sidebar:has(.stash-panel.collapsed) {
grid-template-rows: minmax(170px, 0.85fr) auto minmax(220px, 1.15fr);
}
/* --- Main panel --- */
.main-panel {
@@ -1044,6 +1289,158 @@
background: var(--color-surface-dim);
}
/* --- Stash panel --- */
.stash-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
}
.stash-panel.collapsed {
grid-template-rows: auto;
min-height: 0;
}
.stash-head-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
.stash-toggle {
display: inline-grid;
place-items: center;
width: 26px;
min-width: 26px;
min-height: 26px;
padding: 0;
border-color: rgba(94,110,156,0.18);
border-radius: 7px;
color: var(--color-ink-dim);
background: rgba(255,255,255,0.035);
}
.stash-toggle:hover:not(:disabled) {
border-color: rgba(65,209,255,0.28);
color: var(--color-ink);
background: rgba(65,209,255,0.08);
}
.stash-create {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 6px;
padding: 7px;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface-dim);
}
.stash-input {
min-width: 0;
height: 28px;
padding: 0 8px;
border: 1px solid var(--color-border-subtle);
border-radius: 7px;
color: var(--color-ink);
background: rgba(255,255,255,0.035);
font-size: 12px;
}
.stash-input:focus {
border-color: rgba(65,209,255,0.42);
outline: none;
box-shadow: 0 0 0 2px rgba(65,209,255,0.1);
}
.stash-check {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--color-ink-dim);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.stash-check input {
width: 13px;
height: 13px;
accent-color: var(--color-accent);
}
.stash-save-button {
border-color: rgba(65,209,255,0.18);
color: var(--color-ink);
background: rgba(65,209,255,0.075);
}
.stash-list {
display: grid;
align-content: start;
gap: 6px;
min-height: 0;
padding: 7px;
overflow: auto;
}
.stash-empty {
min-height: 48px;
}
.stash-row {
display: grid;
gap: 7px;
padding: 8px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: var(--color-surface-raised);
}
.stash-row-main {
display: grid;
gap: 2px;
min-width: 0;
}
.stash-row-main strong,
.stash-row-main span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stash-row-main strong {
color: var(--color-ink);
font-size: 12px;
font-weight: 750;
}
.stash-row-main span {
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 10.5px;
}
.stash-actions {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.btn-sm.danger {
border-color: rgba(232,96,96,0.2);
color: #ef9b9b;
background: rgba(232,96,96,0.08);
}
.btn-sm.danger:hover:not(:disabled) {
border-color: rgba(232,96,96,0.38);
color: #ffd2d2;
background: rgba(232,96,96,0.14);
}
/* --- Branch list --- */
.branch-head-actions {
@@ -1238,7 +1635,8 @@
.branch-info strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--color-ink); }
.branch-info span { display: block; margin-top: 2px; color: var(--color-ink-dim); font-size: 11px; }
.branch-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; }
.branch-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 5px; }
.branch-actions .btn-sm { min-height: 24px; padding: 0 6px; font-size: 11px; }
.branch-context-menu,
.explorer-context-menu {
@@ -1281,6 +1679,12 @@
color: var(--color-ink);
}
.branch-context-menu .menu-separator {
height: 1px;
margin: 4px 3px;
background: var(--color-border-subtle);
}
.branch-context-menu button.danger {
color: #ff9aa8;
}
@@ -1427,19 +1831,190 @@
.commit-line strong { display: block; overflow: hidden; color: var(--color-ink); font-size: 13px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }
.commit-line span { display: block; overflow: hidden; margin-top: 3px; color: var(--color-ink-dim); font-family: var(--font-mono); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.ref-list { display: flex; flex-wrap: wrap; gap: 4px; }
.ref-list span { max-width: 100%; overflow: hidden; padding: 2px 7px; border-radius: 999px; color: var(--color-accent); background: rgba(106,154,255,0.13); border: 1px solid rgba(106,154,255,0.22); font-size: 10.5px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.commit-card-head {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 7px;
min-width: 0;
}
.commit-avatar {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border: 1px solid rgba(122,172,255,0.18);
border-radius: 999px;
color: #b8c5df;
background: rgba(18, 22, 38, 0.72);
font-size: 9px;
font-weight: 800;
}
.commit-card-main { display: grid; gap: 2px; min-width: 0; }
.commit-title-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
min-width: 0;
}
.commit-summary {
display: block;
overflow: hidden;
color: #edf2ff;
font-size: 12.5px;
font-weight: 700;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-kind {
display: inline-flex;
align-items: center;
gap: 3px;
min-width: 0;
padding: 1px 5px;
border: 1px solid rgba(94,110,156,0.18);
border-radius: 999px;
color: var(--color-ink-dim);
background: rgba(255,255,255,0.025);
font-size: 9px;
font-weight: 800;
line-height: 1.3;
text-transform: uppercase;
}
.commit-kind.merge { color: #dca6da; border-color: rgba(208,96,192,0.24); background: rgba(208,96,192,0.07); }
.commit-kind.root { color: #d7b66b; border-color: rgba(224,180,92,0.22); background: rgba(224,180,92,0.07); }
.commit-meta-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
color: var(--color-ink-faint);
font-size: 10.5px;
}
.commit-meta-line > * { min-width: 0; }
.commit-hash {
flex: 0 0 auto;
padding: 0;
border: 0;
color: #8db8ff;
background: transparent;
font-family: var(--font-mono);
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;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-files { display: grid; gap: 5px; }
.commit-files-toggle { justify-content: flex-start; gap: 5px; min-height: 24px; padding: 0 7px; border-color: transparent; background: transparent; color: var(--color-ink-dim); font-size: 11.5px; font-weight: 700; }
.ref-list { display: flex; flex-wrap: wrap; gap: 3px; }
.ref-list .ref-chip {
max-width: 100%;
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);
font-size: 9.5px;
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);
}
.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;
gap: 5px;
padding: 6px;
border: 1px solid rgba(94,110,156,0.14);
border-radius: 8px;
background: rgba(7,8,16,0.16);
}
.commit-files-toggle {
justify-content: flex-start;
gap: 6px;
min-height: 26px;
padding: 0 8px;
border-color: transparent;
background: transparent;
color: var(--color-ink-dim);
font-size: 11.5px;
font-weight: 800;
}
.commit-files-toggle:hover:not(:disabled) { border-color: var(--color-border-subtle); background: var(--color-surface-hover); color: var(--color-ink); }
.commit-file-list { display: grid; gap: 4px; }
.commit-file-button { display: grid; grid-template-columns: auto minmax(0, 1fr); justify-content: stretch; width: 100%; min-height: 28px; padding: 4px 7px; text-align: left; border-color: var(--color-border-subtle); background: rgba(255,255,255,0.025); }
.commit-file-button {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
justify-content: stretch;
width: 100%;
min-height: 30px;
padding: 4px 7px;
text-align: left;
border-color: rgba(94,110,156,0.16);
background: rgba(255,255,255,0.03);
}
.commit-file-button:hover:not(:disabled) { border-color: rgba(65,209,255,0.22); background: rgba(65,209,255,0.055); }
.commit-file-button strong { overflow: hidden; color: var(--color-ink-muted); font-family: var(--font-mono); font-size: 11.5px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.commit-actions { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; }
.commit-actions time { min-width: 0; overflow: hidden; color: var(--color-ink-faint); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.commit-actions time,
.commit-time {
min-width: 0;
overflow: hidden;
color: var(--color-ink-faint);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-action-buttons { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; }
.commit-action-buttons button { flex: 0 0 auto; white-space: nowrap; }
.file-history-head { align-items: flex-start; }
@@ -1551,29 +2126,131 @@
/* --- Git graph --- */
.graph-list { padding: 0; }
.graph-list {
padding: 0;
background: var(--color-surface-dim);
}
.graph-row { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0; margin: 0; padding: 0; border: none; border-radius: 0; background: none; }
.graph-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0;
min-height: 58px;
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: none;
}
.graph-row + .graph-row { margin-top: 0; }
.graph-gutter { position: relative; align-self: stretch; background: var(--color-surface); }
.graph-gutter {
position: relative;
align-self: stretch;
min-width: 42px;
border-right: 1px solid rgba(94,110,156,0.12);
background: rgba(7,8,16,0.2);
}
.graph-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.graph-svg path {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.76;
transition: opacity 120ms ease, stroke-width 120ms ease;
}
.graph-svg path.hidden-branch {
opacity: 0.08;
}
.graph-dot {
position: absolute;
z-index: 2;
top: 50%;
width: 10px;
height: 10px;
width: 12px;
height: 12px;
border-radius: 999px;
background: var(--dot-color, #5a8cf8);
border: 2px solid var(--color-surface);
box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8);
border: 2px solid #111321;
box-shadow: 0 0 0 1px rgba(255,255,255,0.07);
transform: translate(-50%, -50%);
transition: transform 120ms ease, box-shadow 120ms ease;
}
.graph-dot.hidden-branch {
opacity: 0.16;
box-shadow: none;
}
.graph-dot.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); }
.graph-dot.merge {
width: 15px;
height: 15px;
background: var(--color-surface-alt);
border-color: var(--dot-color, #5a8cf8);
box-shadow: 0 0 0 1px rgba(255,255,255,0.08);
}
.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,
.graph-row: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(20,35,29,0.94);
box-shadow: 0 8px 22px rgba(0,0,0,0.3);
font-family: var(--font-mono);
font-size: 9.5px;
font-weight: 750;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.graph-hover-branches svg {
flex: 0 0 auto;
color: #76d995;
}
.commit-body {
display: grid;
gap: 5px;
min-width: 0;
padding: 8px 10px;
background: rgba(28,29,48,0.34);
transition: background 120ms ease;
}
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(94,110,156,0.1); }
.graph-row:hover .commit-body { background: rgba(37,40,62,0.54); }
.graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; }
.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); }
.graph-row.merge-row .commit-body {
background: rgba(36,31,54,0.46);
}
.graph-row.tip-row .commit-body {
background:
linear-gradient(90deg, rgba(105,167,255,0.055), transparent 32%),
rgba(28,29,48,0.38);
}
.graph-dot.merge { width: 12px; height: 12px; background: var(--color-surface); border-color: var(--dot-color, #5a8cf8); }
.commit-body { display: grid; gap: 7px; min-width: 0; padding: 10px 12px; }
.graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); }
.graph-row:hover .commit-body { background: var(--color-surface-hover); }
/* --- Compare panel --- */
@@ -1686,6 +2363,54 @@
max-height: calc(100vh - 32px);
overflow: auto;
}
.clone-repository-dialog {
display: block;
width: min(620px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.clone-dialog-form {
display: grid;
gap: 14px;
padding: 16px;
}
.clone-dialog-field {
display: grid;
gap: 5px;
min-width: 0;
}
.clone-dialog-field > span {
overflow: hidden;
color: var(--color-ink-faint);
font-size: 10.5px;
font-weight: 800;
text-transform: uppercase;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.04em;
}
.clone-dialog-path-field {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
min-width: 0;
}
.clone-dialog-error {
padding: 9px 10px;
border: 1px solid rgba(232,96,90,0.3);
border-radius: 7px;
color: #f09090;
background: rgba(232,96,90,0.08);
font-size: 12.5px;
line-height: 1.45;
}
.clone-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 2px;
}
.ai-settings-form {
display: flex;
flex-direction: column;
@@ -1974,6 +2699,12 @@
gap: 14px;
padding: 18px 16px 16px;
}
.branch-delete-body {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 14px;
padding: 18px 16px 16px;
}
.discard-warning-icon {
display: grid;
place-items: center;
@@ -2008,6 +2739,11 @@
white-space: pre-wrap;
word-break: break-word;
}
.branch-delete-body .discard-target {
display: flex;
align-items: center;
gap: 6px;
}
.discard-warning-text {
color: #ffb8bf;
font-weight: 650;
@@ -3047,16 +3783,16 @@
/* --- Responsive breakpoints --- */
@media (min-width: 1800px) {
.workspace { grid-template-columns: 320px minmax(0, 1fr) 680px; }
.workspace { grid-template-columns: 320px minmax(0, 1fr) minmax(560px, var(--history-aside-width, 680px)); }
}
@media (max-width: 1400px) {
.workspace { grid-template-columns: clamp(200px, 17vw, 265px) minmax(0, 1fr) clamp(400px, 40vw, 580px); }
.workspace { grid-template-columns: clamp(200px, 17vw, 265px) minmax(0, 1fr) minmax(540px, var(--history-aside-width, 580px)); }
}
/* Stack CommitPanel below StatusPanel; history panels stay side by side */
@media (max-width: 1100px) {
.workspace { grid-template-columns: clamp(185px, 16vw, 220px) minmax(0, 1fr) clamp(380px, 38vw, 500px); }
.workspace { grid-template-columns: clamp(185px, 16vw, 220px) minmax(0, 1fr) minmax(500px, 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); }
}
@@ -3064,8 +3800,10 @@
@media (max-width: 960px) {
.workspace { grid-template-columns: 180px minmax(0, 1fr) 300px; gap: 6px; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1.3fr) minmax(0, 0.7fr); }
.history-resize-handle { display: none; }
.shell-body { gap: 6px; }
.left-sidebar { gap: 6px; grid-template-rows: minmax(180px, 0.8fr) minmax(200px, 1.2fr); }
.left-sidebar { gap: 6px; grid-template-rows: minmax(150px, 0.7fr) minmax(145px, 0.55fr) minmax(190px, 1fr); }
.left-sidebar:has(.stash-panel.collapsed) { grid-template-rows: minmax(150px, 0.8fr) auto minmax(190px, 1.1fr); }
.section-head { min-height: 40px; padding: 6px 10px; }
.repo-summary { height: 40px; padding: 0 10px; }
.repo-branch { max-width: 160px; }
@@ -3082,12 +3820,19 @@
.shell-body { min-height: 100%; gap: 6px; }
.workspace { grid-template-columns: 1fr; gap: 6px; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(200px, 1fr) minmax(150px, 0.5fr); min-height: 380px; }
.left-sidebar { grid-template-rows: minmax(200px, 1fr) minmax(240px, 1.2fr); min-height: 440px; }
.left-sidebar { grid-template-rows: minmax(180px, 0.9fr) minmax(150px, 0.55fr) minmax(220px, 1fr); min-height: 560px; }
.left-sidebar:has(.stash-panel.collapsed) { grid-template-rows: minmax(180px, 1fr) auto minmax(220px, 1.1fr); }
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); }
.repo-form { grid-template-columns: 1fr; }
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
.repo-tab.management { min-width: 0; }
.repo-tabs-scroll { grid-column: 1 / -1; order: 2; border-top: 1px solid var(--color-border-subtle); }
.repo-management-head,
.repo-management-tools { align-items: stretch; flex-direction: column; }
.repo-management-actions { justify-content: flex-start; }
.clone-dialog-path-field { grid-template-columns: minmax(0, 1fr); }
.clone-dialog-actions { flex-direction: column-reverse; }
.clone-dialog-actions button { width: 100%; }
.repo-row-main { grid-template-columns: minmax(0, 1fr); gap: 2px; align-content: center; padding-left: 12px; }
.repo-row { min-height: 58px; }
.repo-row-icon { min-height: 58px; }
+16 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
import { onDestroy, onMount } from "svelte";
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
import { credDelete, credLoad, credSave } from "../git";
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
@@ -36,6 +36,7 @@
let loadingKeys = $state(true);
let saving = $state(false);
let error = $state("");
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
provider = settings.provider;
@@ -47,6 +48,16 @@
customModel = settings.customModel;
});
$effect(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
const currentError = error;
if (currentError) {
errorHideTimer = setTimeout(() => {
if (error === currentError) error = "";
}, 6000);
}
});
onMount(() => {
(async () => {
try {
@@ -66,6 +77,10 @@
})();
});
onDestroy(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
});
async function persistKey(target: CloudProvider, value: string) {
const key = CRED_KEYS[target];
const trimmed = value.trim();
@@ -126,7 +141,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
<header class="dialog-header">
@@ -0,0 +1,76 @@
<script lang="ts">
import { AlertTriangle, GitBranch, LoaderCircle, Trash2, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types";
interface Props {
branch: GitBranchInfo;
force: boolean;
isBusy: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
}
let {
branch,
force = false,
isBusy = false,
onConfirm = () => {},
onClose = () => {},
}: Props = $props();
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header">
<div>
<span class="eyebrow">{force ? "Force delete" : "Delete branch"}</span>
<p class="dialog-title">{title}</p>
</div>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="branch-delete-body">
<div class="discard-warning-icon" aria-hidden="true">
<AlertTriangle size={22} />
</div>
<div class="discard-confirm-copy">
<p>
{#if force}
This branch is not fully merged. Force deleting removes the branch pointer even if some commits are only reachable from this branch.
{:else}
Delete this local branch from the repository?
{/if}
</p>
<code class="discard-target" title={branch.name}>
<GitBranch size={13} aria-hidden="true" />
{branch.name}
</code>
<p class="discard-warning-text">
{#if force}
Make sure you no longer need the unique commits on this branch.
{:else}
Git will refuse if the branch is not fully merged.
{/if}
</p>
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-danger" type="button" onclick={onConfirm} disabled={isBusy}>
{#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else}
<Trash2 size={15} aria-hidden="true" />
{/if}
{force ? "Force delete" : "Delete"}
</button>
</footer>
</div>
</div>
+43 -26
View File
@@ -49,6 +49,7 @@
isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void;
onMerge: (branch: GitBranchInfo) => void;
onRebase: (branch: GitBranchInfo) => void;
onCreateBranch: (branchName: string) => void | Promise<void>;
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
@@ -62,6 +63,7 @@
isBusy = false,
onCheckout = () => {},
onMerge = () => {},
onRebase = () => {},
onCreateBranch = () => {},
onRenameBranch = () => {},
onDeleteBranch = () => {},
@@ -216,13 +218,13 @@
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
event.preventDefault();
event.stopPropagation();
if (isBusy || branch.remote) return;
if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
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) - 92);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 190);
contextBranch = branch;
contextMenuX = Math.max(8, Math.min(rawX, maxX));
@@ -242,11 +244,32 @@
async function deleteContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
if (!branch || branch.current || branch.remote || isBusy) return;
closeBranchContextMenu();
await onDeleteBranch(branch);
}
async function checkoutContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onCheckout(branch);
}
async function mergeContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onMerge(branch);
}
async function rebaseContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onRebase(branch);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeBranchContextMenu();
}
@@ -361,16 +384,6 @@
</div>
{#if row.branch.current}
<span class="pill pill-active">Current</span>
{:else}
<div class="branch-actions">
<button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/if}
@@ -426,6 +439,7 @@
class:current={row.branch.current}
style={`--branch-indent: ${row.depth * 16}px;`}
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
title={row.branch.current ? "Current branch" : row.branch.name}
>
<div class="branch-info">
@@ -437,16 +451,6 @@
</div>
{#if row.branch.current}
<span class="pill pill-active">Current</span>
{:else}
<div class="branch-actions">
<button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/if}
@@ -465,7 +469,20 @@
tabindex="-1"
aria-label={`Actions for ${contextBranch.name}`}
>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
<button type="button" role="menuitem" onclick={checkoutContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Checkout
</button>
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
<GitMerge size={14} aria-hidden="true" />
Merge into current
</button>
<button type="button" role="menuitem" onclick={rebaseContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Rebase current onto this
</button>
<div class="menu-separator" role="separator"></div>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
<Pencil size={14} aria-hidden="true" />
Rename
</button>
@@ -474,8 +491,8 @@
type="button"
role="menuitem"
onclick={deleteContextBranch}
disabled={isBusy || contextBranch.current}
title={contextBranch.current ? "Current branch cannot be deleted" : "Delete local branch"}
disabled={isBusy || contextBranch.current || contextBranch.remote}
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Remote branch cannot be deleted here" : "Delete local branch"}
>
<Trash2 size={14} aria-hidden="true" />
Delete
@@ -0,0 +1,175 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { Download, FolderOpen, LoaderCircle, X } from "@lucide/svelte";
interface Props {
isBusy: boolean;
error: string;
onClone: (remoteUrl: string, parentPath: string, directoryName: string) => void;
onClose: () => void;
}
let {
isBusy = false,
error = "",
onClone = () => {},
onClose = () => {},
}: Props = $props();
let remoteUrl = $state("");
let parentPath = $state("");
let directoryName = $state("");
let directoryNameEdited = $state(false);
let directoryAutoName = $state("");
let browseError = $state("");
let visibleError = $state("");
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
let directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
let canSubmit = $derived(
!isBusy &&
remoteUrl.trim().length > 0 &&
parentPath.trim().length > 0,
);
$effect(() => {
const nextError = error || browseError;
if (errorHideTimer) clearTimeout(errorHideTimer);
visibleError = nextError;
if (nextError) {
errorHideTimer = setTimeout(() => {
visibleError = "";
}, 6000);
}
});
onDestroy(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
});
function directoryNameFromRemoteUrl(url: string): string {
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
return lastSegment.replace(/\.git$/i, "").trim();
}
function errorToMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
try { return JSON.stringify(error) ?? "Unknown error"; } catch { return "Unknown error"; }
}
async function chooseParentFolder() {
if (isBusy) return;
browseError = "";
try {
const selected = await openDialog({
title: "Select clone destination",
directory: true,
multiple: false,
defaultPath: parentPath.trim() || undefined,
});
if (typeof selected !== "string") return;
parentPath = selected;
} catch (error) {
browseError = errorToMessage(error);
}
}
function handleRemoteInput(event: Event) {
const nextRemoteUrl = (event.currentTarget as HTMLInputElement).value;
if (directoryNameEdited) return;
directoryAutoName = directoryNameFromRemoteUrl(nextRemoteUrl);
directoryName = directoryAutoName;
}
function handleDirectoryInput(event: Event) {
const nextDirectoryName = (event.currentTarget as HTMLInputElement).value;
directoryNameEdited = nextDirectoryName.trim().length > 0 && nextDirectoryName !== directoryAutoName;
}
function submit(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit) return;
onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim());
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label="Clone repository" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Repository Management</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Clone repository</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<form class="clone-dialog-form" onsubmit={submit}>
<label class="clone-dialog-field">
<span>Remote URL</span>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={remoteUrl}
oninput={handleRemoteInput}
autocomplete="off"
spellcheck="false"
placeholder="https://github.com/org/project.git"
disabled={isBusy}
autofocus
/>
</label>
<label class="clone-dialog-field">
<span>Destination</span>
<div class="clone-dialog-path-field">
<input
bind:value={parentPath}
autocomplete="off"
spellcheck="false"
placeholder="Choose parent folder"
disabled={isBusy}
/>
<button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}>
<FolderOpen size={14} aria-hidden="true" />
Browse
</button>
</div>
</label>
<label class="clone-dialog-field">
<span>Folder name</span>
<input
bind:value={directoryName}
oninput={handleDirectoryInput}
autocomplete="off"
spellcheck="false"
placeholder={directorySuggestion || "Optional"}
disabled={isBusy}
/>
</label>
{#if visibleError}
<div class="clone-dialog-error" role="alert">{visibleError}</div>
{/if}
<div class="clone-dialog-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
Cancel
</button>
<button class="btn-primary" type="submit" disabled={!canSubmit}>
{#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Download size={16} aria-hidden="true" />
{/if}
Clone
</button>
</div>
</form>
</div>
</div>
-1
View File
@@ -182,7 +182,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
@@ -41,7 +41,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
<header class="dialog-header">
+11 -4
View File
@@ -14,7 +14,7 @@
} from "@lucide/svelte";
interface Props {
action: "push" | "pull" | "fetch";
action: "push" | "pull" | "fetch" | "clone";
error: string;
isBusy: boolean;
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
@@ -43,12 +43,20 @@
password.trim().length > 0 &&
(mode === "token" || username.trim().length > 0),
);
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : "Pull");
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull");
let actionTitle = $derived(
action === "push" ? "Authenticate push" : action === "fetch" ? "Authenticate fetch" : "Authenticate pull",
action === "push"
? "Authenticate push"
: action === "fetch"
? "Authenticate fetch"
: action === "clone"
? "Authenticate clone"
: "Authenticate pull",
);
let actionHint = $derived(action === "push"
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: action === "clone"
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
: "The remote needs access to the repository. Use your Git credentials or a personal access token.");
function handleSubmit(e: SubmitEvent) {
@@ -66,7 +74,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div class="cred-card" role="dialog" aria-modal="true" aria-label="Git credentials" tabindex="-1">
<div class="cred-hero">
@@ -25,13 +25,9 @@
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
function closeFromBackdrop(event: MouseEvent) {
if (isBusy || event.target !== event.currentTarget) return;
onClose();
}
</script>
<div class="dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
<div class="dialog-backdrop" role="presentation">
<div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header">
<div>
@@ -129,7 +129,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global search" tabindex="-1">
<header class="dialog-header">
+388 -32
View File
@@ -1,28 +1,38 @@
<script lang="ts">
import { ChevronDown, ChevronRight, GitBranch, RotateCcw } from "@lucide/svelte";
import { ChevronDown, ChevronRight, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment {
fromCol: number;
toCol: number;
color: string;
branches: string[];
}
interface GraphRow {
dotCol: number;
dotColor: string;
branchLabels: string[];
top: GraphSegment[];
bottom: GraphSegment[];
}
interface VisibleCommitEntry {
commit: GitCommit;
graphCommit: GitCommit;
}
const GRAPH_COLORS = [
"#2f6fb0", "#4aa777", "#c9851f", "#a05bd0",
"#cc4b6e", "#1f9ab0", "#7a8a1f", "#b0631f",
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
];
const GRAPH_LANE = 16;
const GRAPH_LANE = 18;
interface Props {
commits: GitCommit[];
localBranchNames: string[];
activeBranch: string;
repositoryKey: string;
hasRepository: boolean;
isBusy: boolean;
expandedCommitHashes: Set<string>;
@@ -34,6 +44,9 @@
let {
commits = [],
localBranchNames = [],
activeBranch = "",
repositoryKey = "",
hasRepository = false,
isBusy = false,
expandedCommitHashes = new Set(),
@@ -43,6 +56,11 @@
onCreateBranchFromCommit = () => {},
}: Props = $props();
let hiddenGraphBranches = $state<Set<string>>(new Set());
let branchDialogOpen = $state(false);
let userAdjustedBranchFilter = $state(false);
let lastDefaultFilterKey = $state("");
function laneColor(col: number): string {
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
}
@@ -51,13 +69,23 @@
return col * GRAPH_LANE + GRAPH_LANE / 2;
}
function computeGraph(items: GitCommit[]): { rows: GraphRow[]; columns: number } {
function graphPath(seg: GraphSegment, fromY: number, toY: number): string {
const x1 = graphColX(seg.fromCol);
const x2 = graphColX(seg.toCol);
if (x1 === x2) return `M ${x1} ${fromY} L ${x2} ${toY}`;
const midY = (fromY + toY) / 2;
return `M ${x1} ${fromY} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${toY}`;
}
function computeGraph(items: GitCommit[], branchMembership = new Map<string, string[]>()): { rows: GraphRow[]; columns: number } {
const rows: GraphRow[] = [];
let lanes: (string | null)[] = [];
let laneBranches: string[][] = [];
let maxColumns = 1;
for (const commit of items) {
const before = lanes.slice();
const beforeBranches = laneBranches.map((branches) => branches.slice());
let col = before.indexOf(commit.hash);
if (col === -1) {
@@ -67,18 +95,27 @@
const after = before.slice();
while (after.length <= col) after.push(null);
const afterBranches = beforeBranches.map((branches) => branches.slice());
while (afterBranches.length <= col) afterBranches.push([]);
for (let k = 0; k < after.length; k++) {
if (after[k] === commit.hash) after[k] = null;
if (after[k] === commit.hash) {
after[k] = null;
afterBranches[k] = [];
}
}
const currentBranches = branchMembership.get(commit.hash) ?? localBranchRefs(commit);
const commitBranches = uniqueStrings([...(beforeBranches[col] ?? []), ...currentBranches]);
after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
afterBranches[col] = after[col] ? commitBranches.slice() : [];
const fromCommit = new Set<number>([col]);
for (let p = 1; p < commit.parents.length; p++) {
let slot = after.indexOf(null);
if (slot === -1) { slot = after.length; after.push(null); }
if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
after[slot] = commit.parents[p];
afterBranches[slot] = [];
fromCommit.add(slot);
}
@@ -86,19 +123,28 @@
for (let k = 0; k < before.length; k++) {
const target = before[k];
if (target == null) continue;
top.push({ fromCol: k, toCol: target === commit.hash ? col : k, color: laneColor(k) });
top.push({ fromCol: k, toCol: target === commit.hash ? col : k, color: laneColor(k), branches: beforeBranches[k] ?? [] });
}
const bottom: GraphSegment[] = [];
for (let k = 0; k < after.length; k++) {
if (after[k] == null) continue;
bottom.push({ fromCol: fromCommit.has(k) ? col : k, toCol: k, color: laneColor(k) });
bottom.push({
fromCol: fromCommit.has(k) ? col : k,
toCol: k,
color: laneColor(k),
branches: fromCommit.has(k) ? commitBranches : afterBranches[k] ?? [],
});
}
rows.push({ dotCol: col, dotColor: laneColor(col), top, bottom });
rows.push({ dotCol: col, dotColor: laneColor(col), branchLabels: currentBranches, top, bottom });
lanes = after.slice();
while (lanes.length > 0 && lanes[lanes.length - 1] == null) lanes.pop();
laneBranches = afterBranches.map((branches) => branches.slice());
while (lanes.length > 0 && lanes[lanes.length - 1] == null) {
lanes.pop();
laneBranches.pop();
}
maxColumns = Math.max(maxColumns, before.length, after.length, col + 1);
}
@@ -123,73 +169,340 @@
: baseName(file.path);
}
function authorInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
function commitKind(commit: GitCommit): "merge" | "root" | "commit" {
if (commit.parents.length > 1) return "merge";
if (commit.parents.length === 0) return "root";
return "commit";
}
function commitKindLabel(commit: GitCommit): string {
const kind = commitKind(commit);
if (kind === "merge") return "merge";
if (kind === "root") return "root";
return "commit";
}
let localBranchNameSet = $derived(new Set(localBranchNames));
function uniqueStrings(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
function branchIsVisible(branch: string): boolean {
return !hiddenGraphBranches.has(branch);
}
function visibleBranchLabels(labels: string[]): string[] {
return labels.filter(branchIsVisible);
}
function segmentIsVisible(segment: GraphSegment): boolean {
return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
}
function branchesAreVisible(branches: string[]): boolean {
if (localBranchNames.length === 0) return true;
return branches.some(branchIsVisible);
}
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
return branchesAreVisible(row?.branchLabels ?? []);
}
function nearestVisibleGraphParents(
hash: string,
visibleHashes: Set<string>,
commitByHash: Map<string, GitCommit>,
seen: Set<string>,
): string[] {
if (visibleHashes.has(hash)) return [hash];
if (seen.has(hash)) return [];
seen.add(hash);
const commit = commitByHash.get(hash);
if (!commit) return [];
return uniqueStrings(
commit.parents.flatMap((parentHash) => (
nearestVisibleGraphParents(parentHash, visibleHashes, commitByHash, new Set(seen))
)),
);
}
function branchMembershipByHash(items: GitCommit[]): Map<string, string[]> {
const commitByHash = new Map(items.map((commit) => [commit.hash, commit]));
const membership = new Map<string, Set<string>>();
for (const commit of items) {
for (const branch of localBranchRefs(commit)) {
const stack = [commit.hash];
const seen = new Set<string>();
while (stack.length > 0) {
const hash = stack.pop();
if (!hash || seen.has(hash)) continue;
seen.add(hash);
let branches = membership.get(hash);
if (!branches) {
branches = new Set<string>();
membership.set(hash, branches);
}
branches.add(branch);
const parentCommit = commitByHash.get(hash);
if (parentCommit) stack.push(...parentCommit.parents);
}
}
}
return new Map(
items.map((commit) => [
commit.hash,
localBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
]),
);
}
function visibleCommitEntriesForGraph(items: GitCommit[], branchMembership: Map<string, string[]>): VisibleCommitEntry[] {
const visibleItems = items.filter((commit) => branchesAreVisible(branchMembership.get(commit.hash) ?? []));
const visibleHashes = new Set(visibleItems.map((commit) => commit.hash));
const commitByHash = new Map(items.map((commit) => [commit.hash, commit]));
return visibleItems.map((commit) => {
const parents = uniqueStrings(
commit.parents.flatMap((parentHash) => (
nearestVisibleGraphParents(parentHash, visibleHashes, commitByHash, new Set())
)),
);
return { commit, graphCommit: { ...commit, parents } };
});
}
function toggleGraphBranch(branch: string) {
const next = new Set(hiddenGraphBranches);
if (next.has(branch)) next.delete(branch); else next.add(branch);
hiddenGraphBranches = next;
userAdjustedBranchFilter = true;
}
function showAllGraphBranches() {
hiddenGraphBranches = new Set();
userAdjustedBranchFilter = true;
}
function hideAllGraphBranches() {
hiddenGraphBranches = new Set(localBranchNames);
userAdjustedBranchFilter = true;
}
function openBranchDialog() {
branchDialogOpen = true;
}
function closeBranchDialog() {
branchDialogOpen = false;
}
function handleBranchDialogKeydown(event: KeyboardEvent) {
if (branchDialogOpen && event.key === "Escape") {
closeBranchDialog();
}
}
function localBranchRefs(commit: GitCommit): string[] {
const seen = new Set<string>();
const labels: string[] = [];
for (const ref of commit.refs) {
const label = refLabel(ref);
if (!localBranchNameSet.has(label) || seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
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 formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
}
let graph = $derived(computeGraph(commits));
$effect(() => {
const available = new Set(localBranchNames);
const nextHidden = new Set([...hiddenGraphBranches].filter((branch) => available.has(branch)));
if (nextHidden.size !== hiddenGraphBranches.size) {
hiddenGraphBranches = nextHidden;
}
});
$effect(() => {
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
? activeBranch
: (localBranchNames[0] ?? "");
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
if (!defaultBranch) {
if (lastDefaultFilterKey !== defaultFilterKey) {
hiddenGraphBranches = new Set();
userAdjustedBranchFilter = false;
lastDefaultFilterKey = defaultFilterKey;
}
return;
}
if (lastDefaultFilterKey !== defaultFilterKey) {
userAdjustedBranchFilter = false;
lastDefaultFilterKey = defaultFilterKey;
}
if (!userAdjustedBranchFilter) {
hiddenGraphBranches = new Set(localBranchNames.filter((branch) => branch !== defaultBranch));
}
});
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 graph = $derived(computeGraph(graphCommits, branchMembership));
let graphRows = $derived(graph.rows);
let graphWidth = $derived(Math.max(graph.columns, 1) * GRAPH_LANE);
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
</script>
<svelte:window onkeydown={handleBranchDialogKeydown} />
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
<div class="section-head">
<div>
<span class="eyebrow">History</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
</div>
<span class="pill pill-count">{commits.length}</span>
<div class="section-head-actions">
{#if localBranchNames.length > 0}
<button
class="graph-branch-dialog-button"
type="button"
onclick={openBranchDialog}
title="Select branches shown in the graph"
>
<GitBranch size={13} aria-hidden="true" />
Branches
<span>{visibleBranchCount}/{localBranchNames.length}</span>
</button>
{/if}
<span class="pill pill-count" title={visibleCommits.length === commits.length ? "Commits" : `${visibleCommits.length} of ${commits.length} commits shown`}>
{visibleCommits.length}
</span>
</div>
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else if commits.length === 0}
<div class="blank-state">No commits returned.</div>
{:else if visibleCommits.length === 0}
<div class="blank-state">No commits match the selected branches.</div>
{:else}
<div class="history-list graph-list overflow-auto">
{#each commits as item, rowIndex (item.hash)}
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
{@const item = entry.commit}
{@const row = graphRows[rowIndex]}
<article class="commit-row graph-row">
{@const hoverBranchRefs = visibleBranchLabels(row?.branchLabels ?? [])}
{@const otherRefs = visibleRefs(item)}
<article class="commit-row graph-row" class:merge-row={item.parents.length > 1} class:root-row={item.parents.length === 0} class:tip-row={item.refs.length > 0}>
<div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
{#if row}
<svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none">
{#each row.top as seg}
<line
x1={graphColX(seg.fromCol)} y1="0"
x2={graphColX(seg.toCol)} y2="50"
stroke={seg.color} stroke-width="2" vector-effect="non-scaling-stroke"
<path
class:hidden-branch={!segmentIsVisible(seg)}
d={graphPath(seg, 0, 50)}
stroke={seg.color}
stroke-width="2.2"
vector-effect="non-scaling-stroke"
/>
{/each}
{#each row.bottom as seg}
<line
x1={graphColX(seg.fromCol)} y1="50"
x2={graphColX(seg.toCol)} y2="100"
stroke={seg.color} stroke-width="2" vector-effect="non-scaling-stroke"
<path
class:hidden-branch={!segmentIsVisible(seg)}
d={graphPath(seg, 50, 100)}
stroke={seg.color}
stroke-width="2.2"
vector-effect="non-scaling-stroke"
/>
{/each}
</svg>
<span
class="graph-dot"
class:merge={item.parents.length > 1}
class:tip={item.refs.length > 0}
class:hidden-branch={!rowGraphIsVisible(row)}
title={hoverBranchRefs.length > 0 ? `Contained in: ${hoverBranchRefs.join(", ")}` : item.short_hash}
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 title={branch}>
<GitBranch size={10} aria-hidden="true" />
{branch}
</span>
{/each}
</div>
{/if}
{/if}
</div>
<div class="commit-body">
<div class="commit-line">
<div>
<strong title={item.summary}>{item.summary}</strong>
<span>{item.short_hash} - {item.author_name}</span>
<div class="commit-card-head">
<span class="commit-avatar">
{authorInitials(item.author_name)}
</span>
<div class="commit-card-main">
<div class="commit-title-row">
<strong class="commit-summary" title={item.summary}>{item.summary}</strong>
<span class={`commit-kind ${commitKind(item)}`}>
{#if item.parents.length > 1}
<GitMerge size={12} aria-hidden="true" />
{/if}
{commitKindLabel(item)}
</span>
</div>
<div class="commit-meta-line">
<span class="commit-hash">{item.short_hash}</span>
<span class="commit-author" title={item.author_email}>{item.author_name}</span>
</div>
</div>
</div>
{#if item.refs.length > 0}
{#if otherRefs.length > 0}
<div class="ref-list" aria-label="Commit refs">
{#each item.refs as ref}
<span>{ref}</span>
{#each otherRefs as ref}
<span class={`ref-chip ${refClass(ref)}`}>{refLabel(ref)}</span>
{/each}
</div>
{/if}
@@ -230,7 +543,7 @@
{/if}
<div class="commit-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons">
<button class="btn-sm" type="button" onclick={() => onCreateBranchFromCommit(item)} disabled={isBusy} title="Create a new branch from this commit">
<GitBranch size={15} aria-hidden="true" />
@@ -248,3 +561,46 @@
</div>
{/if}
</section>
{#if branchDialogOpen}
<div class="branch-filter-backdrop" role="presentation">
<div
class="branch-filter-dialog"
role="dialog"
aria-modal="true"
aria-label="Select visible branches"
>
<header class="branch-filter-dialog-head">
<div>
<span class="eyebrow">Git graph</span>
<h3>Visible branches</h3>
</div>
<button class="dialog-icon-button" type="button" onclick={closeBranchDialog} aria-label="Close branch selection">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="branch-filter-summary">
<span>{visibleBranchCount} of {localBranchNames.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={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}
</div>
</div>
</div>
{/if}
@@ -29,7 +29,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1">
<header class="dialog-header">
@@ -33,7 +33,6 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
<header class="dialog-header">
+3 -4
View File
@@ -254,20 +254,19 @@
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
class="dialog"
style="grid-template-rows: auto minmax(0,1fr) auto;"
role="dialog"
aria-modal="true"
aria-label="Resolve merge conflicts"
aria-label="Resolve conflicts"
tabindex="-1"
>
<header class="dialog-header">
<div>
<span class="eyebrow">Resolve</span>
<h2 class="dialog-title">Merge conflicts</h2>
<h2 class="dialog-title">Conflicts</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
@@ -275,7 +274,7 @@
</header>
{#if conflictedFiles.length === 0}
<div class="blank-state">All conflicts resolved. You can commit the merge now.</div>
<div class="blank-state">All conflicts resolved. Continue the current operation when ready.</div>
{:else}
<div class="dialog-body">
<aside class="dialog-files" aria-label="Conflicted files">
+134
View File
@@ -0,0 +1,134 @@
<script lang="ts">
import { Archive, ChevronDown, ChevronRight, Download, Trash2, Upload } from "@lucide/svelte";
import type { GitStash } from "../types";
interface Props {
stashes: GitStash[];
changedCount: number;
hasRepository: boolean;
isBusy: boolean;
onPush: (message: string, includeUntracked: boolean) => void;
onApply: (stash: GitStash) => void;
onPop: (stash: GitStash) => void;
onDrop: (stash: GitStash) => void;
}
let {
stashes = [],
changedCount = 0,
hasRepository = false,
isBusy = false,
onPush = () => {},
onApply = () => {},
onPop = () => {},
onDrop = () => {},
}: Props = $props();
let message = $state("");
let includeUntracked = $state(true);
let open = $state(false);
function submitPush() {
onPush(message, includeUntracked);
message = "";
}
function stashTitle(stash: GitStash): string {
return stash.message || stash.selector;
}
</script>
<section class="panel stash-panel overflow-hidden" class:collapsed={!open} aria-label="Git stash">
<div class="section-head">
<div>
<span class="eyebrow">Stash</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Shelved changes</h2>
</div>
<div class="stash-head-actions">
<button
class="stash-toggle"
type="button"
onclick={() => { open = !open; }}
aria-expanded={open}
title={open ? "Collapse stash panel" : "Expand stash panel"}
>
{#if open}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
</button>
<span class="pill pill-count">{stashes.length}</span>
</div>
</div>
{#if !open}
<!-- collapsed -->
{:else if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else}
<div class="stash-create">
<input
class="stash-input"
type="text"
bind:value={message}
placeholder="Optional message"
disabled={isBusy || changedCount === 0}
onkeydown={(event) => {
if (event.key === "Enter" && changedCount > 0 && !isBusy) submitPush();
}}
/>
<label class="stash-check">
<input type="checkbox" bind:checked={includeUntracked} disabled={isBusy || changedCount === 0} />
Untracked
</label>
<button
class="btn-sm stash-save-button"
type="button"
onclick={submitPush}
disabled={isBusy || changedCount === 0}
title="Save current working tree changes to a stash"
>
<Archive size={14} aria-hidden="true" />
Stash
</button>
</div>
{#if stashes.length === 0}
<div class="blank-state stash-empty">No stashes saved.</div>
{:else}
<div class="stash-list">
{#each stashes as stash (stash.selector)}
<article class="stash-row">
<div class="stash-row-main">
<strong title={stashTitle(stash)}>{stashTitle(stash)}</strong>
<span>
{stash.selector}
{#if stash.branch}
on {stash.branch}
{/if}
{#if stash.date}
- {stash.date}
{/if}
</span>
</div>
<div class="stash-actions">
<button class="btn-sm" type="button" onclick={() => onApply(stash)} disabled={isBusy} title="Apply stash and keep it">
<Download size={13} aria-hidden="true" />
Apply
</button>
<button class="btn-sm" type="button" onclick={() => onPop(stash)} disabled={isBusy} title="Apply stash and remove it if successful">
<Upload size={13} aria-hidden="true" />
Pop
</button>
<button class="btn-sm danger" type="button" onclick={() => onDrop(stash)} disabled={isBusy} title="Delete stash">
<Trash2 size={13} aria-hidden="true" />
Drop
</button>
</div>
</article>
{/each}
</div>
{/if}
{/if}
</section>
+61 -2
View File
@@ -10,6 +10,7 @@ import type {
GitCommitComparison,
GitRepositoryFile,
GitSearchHit,
GitStash,
GitStatus,
LocalModelOption,
PatchApplyAction,
@@ -33,6 +34,24 @@ export function openRepositoryBundle(path: string, commitLimit = 100): Promise<R
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
}
export function cloneRepository(
remoteUrl: string,
parentPath: string,
directoryName?: string,
username?: string,
password?: string,
commitLimit = 100,
): Promise<RepositoryBundle> {
return invoke<RepositoryBundle>("clone_repository", {
remoteUrl,
parentPath,
directoryName: directoryName?.trim() ? directoryName.trim() : null,
username: username ?? null,
password: password ?? null,
commitLimit,
});
}
export function getStatus(path: string): Promise<GitStatus> {
return invoke<GitStatus>("get_status", { path });
}
@@ -47,6 +66,10 @@ export function listBranches(path: string): Promise<GitBranch[]> {
return invoke<GitBranch[]>("list_branches", { path });
}
export function listStashes(path: string): Promise<GitStash[]> {
return invoke<GitStash[]>("list_stashes", { path });
}
export function checkoutBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("checkout_branch", { path, branch });
}
@@ -67,8 +90,8 @@ export function renameBranch(
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
}
export function deleteBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("delete_branch", { path, branch });
export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
return invoke<GitStatus>("delete_branch", { path, branch, force });
}
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
@@ -104,6 +127,30 @@ export function commit(path: string, message: string): Promise<GitStatus> {
return invoke<GitStatus>("commit", { path, message });
}
export function stashPush(
path: string,
message?: string,
includeUntracked = true,
): Promise<GitStatus> {
return invoke<GitStatus>("stash_push", {
path,
message: message?.trim() ? message.trim() : null,
includeUntracked,
});
}
export function stashApply(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_apply", { path, selector });
}
export function stashPop(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_pop", { path, selector });
}
export function stashDrop(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_drop", { path, selector });
}
export function commitAiStatus(): Promise<CommitAiStatus> {
return invoke<CommitAiStatus>("commit_ai_status");
}
@@ -190,6 +237,18 @@ export function mergeBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("merge_branch", { path, branch });
}
export function rebaseBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_branch", { path, branch });
}
export function rebaseContinue(path: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_continue", { path });
}
export function rebaseAbort(path: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_abort", { path });
}
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
}
+11
View File
@@ -41,6 +41,7 @@ export interface GitStatus {
behind: number;
files: GitFileStatus[];
clean: boolean;
rebase_in_progress: boolean;
}
export interface GitFileStatus {
@@ -58,6 +59,15 @@ export interface GitBranch {
remote: boolean;
}
export interface GitStash {
selector: string;
index: number;
hash: string;
branch: string | null;
message: string;
date: string;
}
export interface GitCommit {
hash: string;
short_hash: string;
@@ -85,6 +95,7 @@ export interface GitRepositoryFile {
export interface RepositoryBundle {
status: GitStatus;
branches: GitBranch[];
stashes: GitStash[];
commits: GitCommit[];
files: GitRepositoryFile[];
}