Compare commits

...
18 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
Christoph 65e5508d4b Merge pull request 'feat(git): make fetch command asynchronous for improved performance' (#11) from bug/blocking_ui into master
publish / publish-tauri (, windows-latest) (release) Successful in 23m55s
Reviewed-on: #11
2026-07-03 15:15:51 +00:00
Christoph Brandau 1576f61234 feat(git): make fetch command asynchronous for improved performance
The fetch command has been updated to run asynchronously, allowing for
better performance and responsiveness in the application. This change
ensures that the command does not block the main thread, enhancing the
user experience when interacting with repositories.

- Fetch command now uses async/await for non-blocking execution
- Added a grace period to prevent immediate background fetch after
  switching repositories
- Improved handling of authentication errors during fetch operations
2026-07-03 17:14:53 +02:00
Christoph 08de211ba0 Update version to 2026.7.9 2026-07-03 16:43:53 +02:00
23 changed files with 2758 additions and 171 deletions
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "tauri-git-lite", "name": "git-lite",
"version": "2026.7.8", "version": "2026.7.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tauri-git-lite", "name": "git-lite",
"version": "2026.7.8", "version": "2026.7.10",
"dependencies": { "dependencies": {
"@lucide/svelte": "^1.21.0", "@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "git-lite", "name": "git-lite",
"version": "2026.7.8", "version": "2026.7.11",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+539 -6
View File
@@ -47,6 +47,7 @@ pub struct GitStatus {
pub behind: u32, pub behind: u32,
pub files: Vec<GitFileStatus>, pub files: Vec<GitFileStatus>,
pub clean: bool, pub clean: bool,
pub rebase_in_progress: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -56,6 +57,16 @@ pub struct GitBranch {
pub remote: bool, 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitCommit { pub struct GitCommit {
pub hash: String, pub hash: String,
@@ -234,10 +245,34 @@ pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
pub struct RepositoryBundle { pub struct RepositoryBundle {
pub status: GitStatus, pub status: GitStatus,
pub branches: Vec<GitBranch>, pub branches: Vec<GitBranch>,
pub stashes: Vec<GitStash>,
pub commits: Vec<GitCommit>, pub commits: Vec<GitCommit>,
pub files: Vec<GitRepositoryFile>, 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. /// 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 /// 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 repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?; let status = status_for_repo(&repo)?;
let branches = branches_for_repo(&repo)?; let branches = branches_for_repo(&repo)?;
let stashes = stashes_for_repo(&repo)?;
let commits = commits_for_repo(&repo, commit_limit)?; let commits = commits_for_repo(&repo, commit_limit)?;
let files = repository_files_with_status(&repo, &status)?; let files = repository_files_with_status(&repo, &status)?;
Ok(RepositoryBundle { Ok(RepositoryBundle {
status, status,
branches, branches,
stashes,
commits, commits,
files, files,
}) })
@@ -277,6 +314,12 @@ pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
branches_for_repo(&repo) 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> { fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
let output = run_git( let output = run_git(
repo, repo,
@@ -316,6 +359,129 @@ fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
Ok(branches) 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] #[tauri::command]
pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> { pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
@@ -387,7 +553,11 @@ pub fn rename_branch(
} }
#[tauri::command] #[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 repo = resolve_repo(&path)?;
let branch = validate_existing_local_branch_name(&repo, &branch)?; let branch = validate_existing_local_branch_name(&repo, &branch)?;
let status = status_for_repo(&repo)?; 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()); 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) status_for_repo(&repo)
} }
@@ -718,11 +889,12 @@ pub fn pull(
} }
#[tauri::command] #[tauri::command]
pub fn fetch( pub async fn fetch(
path: String, path: String,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let fetch_args = ["fetch"]; let fetch_args = ["fetch"];
let output = match (username.as_deref(), password.as_deref()) { let output = match (username.as_deref(), password.as_deref()) {
@@ -746,6 +918,9 @@ pub fn fetch(
return Err(format!("AUTH_FAILED:{details}")); return Err(format!("AUTH_FAILED:{details}"));
} }
Err(format!("Git command failed: {details}")) Err(format!("Git command failed: {details}"))
})
.await
.map_err(|err| format!("Could not fetch repository: {err}"))?
} }
#[tauri::command] #[tauri::command]
@@ -973,6 +1148,72 @@ pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
Err(format!("Merge failed: {details}")) 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] #[tauri::command]
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> { pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
@@ -992,6 +1233,8 @@ fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, S
repo, repo,
[ [
"log", "log",
"--all",
"--topo-order",
"--decorate=short", "--decorate=short",
"--name-status", "--name-status",
"-M", "-M",
@@ -1805,9 +2048,30 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
behind: branch.behind, behind: branch.behind,
clean: files.is_empty(), clean: files.is_empty(),
files, 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). // `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 // 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 // pair instead. We detect that case ourselves by comparing content hashes: if an unstaged
@@ -1967,6 +2231,166 @@ fn repository_files_with_status(
Ok(files.into_values().collect()) 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> { fn is_repository_folder_path(repo: &Path, path: &str) -> Result<bool, String> {
let normalized = normalize_git_path(path); let normalized = normalize_git_path(path);
if repo.join(path).is_dir() { if repo.join(path).is_dir() {
@@ -3441,6 +3865,51 @@ mod tests {
run_git_test(repo, ["commit", "-q", "-m", "init"]); 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] #[test]
fn search_code_introductions_finds_added_string() { fn search_code_introductions_finds_added_string() {
let repo = init_temp_repo("search_added_string"); let repo = init_temp_repo("search_added_string");
@@ -3865,6 +4334,33 @@ mod tests {
assert!(comparison.patch.contains("+original")); 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] #[test]
#[cfg_attr( #[cfg_attr(
windows, windows,
@@ -4259,8 +4755,12 @@ mod tests {
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["branch", "stale"]); run_git_test(&repo.path, ["branch", "stale"]);
let status = let status = delete_branch(
delete_branch(repo.path.to_string_lossy().to_string(), "stale".to_string()).unwrap(); repo.path.to_string_lossy().to_string(),
"stale".to_string(),
None,
)
.unwrap();
assert_eq!(status.current_branch.as_deref(), Some(current.as_str())); assert_eq!(status.current_branch.as_deref(), Some(current.as_str()));
assert!( assert!(
@@ -4268,10 +4768,43 @@ mod tests {
"deleted branch should be gone" "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")); 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] #[test]
fn apply_file_patch_stages_and_discards_selected_changes() { fn apply_file_patch_stages_and_discards_selected_changes() {
let repo = init_temp_repo("apply_file_patch"); let repo = init_temp_repo("apply_file_patch");
+19 -8
View File
@@ -6,14 +6,16 @@ mod git;
use badge::set_sync_badge; use badge::set_sync_badge;
use git::{ use git::{
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history, SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
checkout_branch, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models, checkout_branch, clone_repository, commit, commit_ai_generate, commit_ai_load,
commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
cred_delete, cred_load, cred_save, delete_branch, diff_file_against_working_tree, fetch, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, delete_branch,
get_file_patch, get_remote_url, get_status, list_branches, list_commits, list_file_history, diff_file_against_working_tree, fetch, get_file_patch, get_remote_url, get_status,
list_repository_files, merge_branch, open_repo_in_explorer, open_repository, list_branches, list_commits, list_file_history, list_repository_files, list_stashes,
open_repository_bundle, open_repository_file, pull, push, read_conflict, rename_branch, merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle,
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files, open_repository_file, pull, push, read_conflict, rebase_abort, rebase_branch, rebase_continue,
restore_to_commit, search_code_introductions, stage_files, unstage_files, 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() { fn main() {
@@ -24,16 +26,22 @@ fn main() {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
open_repository, open_repository,
clone_repository,
open_repo_in_explorer, open_repo_in_explorer,
open_repository_file, open_repository_file,
get_status, get_status,
list_branches, list_branches,
list_stashes,
checkout_branch, checkout_branch,
create_branch, create_branch,
rename_branch, rename_branch,
delete_branch, delete_branch,
stage_files, stage_files,
unstage_files, unstage_files,
stash_push,
stash_apply,
stash_pop,
stash_drop,
restore_files, restore_files,
get_file_patch, get_file_patch,
apply_file_patch, apply_file_patch,
@@ -49,6 +57,9 @@ fn main() {
restore_to_commit, restore_to_commit,
restore_file_from_commit, restore_file_from_commit,
merge_branch, merge_branch,
rebase_branch,
rebase_continue,
rebase_abort,
list_repository_files, list_repository_files,
open_repository_bundle, open_repository_bundle,
list_file_history, list_file_history,
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "GitLite", "productName": "GitLite",
"version": "2026.7.8", "version": "2026.7.11",
"identifier": "com.git-lite", "identifier": "com.git-lite",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -29,7 +29,7 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["nsis"], "targets": ["nsis"],
"icon": ["icons/icon.ico"], "icon": ["icons/icon.png", "icons/icon.ico"],
"createUpdaterArtifacts": true, "createUpdaterArtifacts": true,
"windows": { "windows": {
"nsis": { "nsis": {
+480 -21
View File
@@ -2,11 +2,13 @@
import { onDestroy, onMount, tick } from "svelte"; import { onDestroy, onMount, tick } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; 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 TitleBar from "./lib/TitleBar.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte"; import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte";
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte"; import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareDialog from "./lib/components/CompareDialog.svelte"; import CompareDialog from "./lib/components/CompareDialog.svelte";
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte"; import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
@@ -21,11 +23,13 @@
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte"; import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte"; import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte"; import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StashPanel from "./lib/components/StashPanel.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte"; import StatusPanel from "./lib/components/StatusPanel.svelte";
import UpdateToast from "./lib/components/UpdateToast.svelte"; import UpdateToast from "./lib/components/UpdateToast.svelte";
import { import {
checkoutBranch, checkoutBranch,
cloneRepository,
commit, commit,
commitAiGenerate, commitAiGenerate,
commitAiLoad, commitAiLoad,
@@ -42,6 +46,7 @@
fetchRemote, fetchRemote,
getStatus, getStatus,
listBranches, listBranches,
listStashes,
listCommits, listCommits,
listFileHistory, listFileHistory,
listRepositoryFiles, listRepositoryFiles,
@@ -52,6 +57,9 @@
pull, pull,
push, push,
renameBranch, renameBranch,
rebaseAbort,
rebaseBranch,
rebaseContinue,
getRemoteUrl, getRemoteUrl,
credLoad, credLoad,
credSave, credSave,
@@ -66,6 +74,10 @@
searchCodeIntroductions, searchCodeIntroductions,
setSyncBadge, setSyncBadge,
stageFiles, stageFiles,
stashApply,
stashDrop,
stashPop,
stashPush,
unstageFiles, unstageFiles,
} from "./lib/git"; } from "./lib/git";
@@ -83,6 +95,7 @@
GitFileStatus, GitFileStatus,
GitRepositoryFile, GitRepositoryFile,
GitSearchHit, GitSearchHit,
GitStash,
GitStatus, GitStatus,
LocalModelOption, LocalModelOption,
PatchApplyAction, PatchApplyAction,
@@ -99,6 +112,7 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error"; type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository"; type AppView = "management" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone";
type PendingDiscard = type PendingDiscard =
| { kind: "file"; file: GitFileStatus; staged: boolean } | { kind: "file"; file: GitFileStatus; staged: boolean }
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string }; | { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
@@ -113,13 +127,24 @@
lastOpened: number; lastOpened: number;
} }
interface CloneRequest {
remoteUrl: string;
parentPath: string;
directoryName: string;
}
const OPEN_REPOS_KEY = "gitlite.openRepos.v1"; const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1"; const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1"; const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.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_DEFAULT_HEIGHT = 220;
const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT; const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT;
const COMMIT_PANEL_MAX_HEIGHT = 640; 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 ────────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────────
@@ -129,8 +154,13 @@
let repoTabs: RepoTab[] = []; let repoTabs: RepoTab[] = [];
let recentRepoPaths: string[] = []; let recentRepoPaths: string[] = [];
let repoSearch = ""; let repoSearch = "";
let cloneDialogOpen = false;
let cloneDialogError = "";
let cloneDialogErrorTimer: ReturnType<typeof setTimeout> | undefined;
let pendingClone: CloneRequest | null = null;
let status: GitStatus | null = null; let status: GitStatus | null = null;
let branches: GitBranchInfo[] = []; let branches: GitBranchInfo[] = [];
let stashes: GitStash[] = [];
let commits: GitCommit[] = []; let commits: GitCommit[] = [];
let repoFiles: GitRepositoryFile[] = []; let repoFiles: GitRepositoryFile[] = [];
let selectedExplorerPath = ""; let selectedExplorerPath = "";
@@ -157,6 +187,8 @@
let comparison: GitCommitComparison | null = null; let comparison: GitCommitComparison | null = null;
let newBranchCommit: GitCommit | null = null; let newBranchCommit: GitCommit | null = null;
let renameBranchTarget: GitBranchInfo | null = null; let renameBranchTarget: GitBranchInfo | null = null;
let deleteBranchTarget: GitBranchInfo | null = null;
let deleteBranchForce = false;
let compareSelectOpen = false; let compareSelectOpen = false;
let compareDialogOpen = false; let compareDialogOpen = false;
let selectedDiffPath = ""; let selectedDiffPath = "";
@@ -182,15 +214,17 @@
let autoRefreshEnabled = true; let autoRefreshEnabled = true;
let autoRefreshInFlight = false; let autoRefreshInFlight = false;
let credDialogOpen = false; let credDialogOpen = false;
let credDialogAction: "push" | "pull" | "fetch" | null = null; let credDialogAction: CredentialAction | null = null;
let credDialogError = ""; let credDialogError = "";
let credDialogKey: string | null = null; let credDialogKey: string | null = null;
let lastStatusFingerprint = ""; let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000; const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined; let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
const BACKGROUND_FETCH_INTERVAL = 180_000; const BACKGROUND_FETCH_INTERVAL = 180_000;
const BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS = 30_000;
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined; let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
let backgroundFetchInFlight = false; let backgroundFetchInFlight = false;
let lastRepoSwitchAt = 0;
let updateToastOpen = false; let updateToastOpen = false;
let updateToastState: UpdateToastState = "available"; let updateToastState: UpdateToastState = "available";
let pendingUpdate: Update | null = null; let pendingUpdate: Update | null = null;
@@ -201,10 +235,15 @@
let updateCheckInFlight = false; let updateCheckInFlight = false;
let updateDownloadTotal = 0; let updateDownloadTotal = 0;
let updateDownloadedBytes = 0; let updateDownloadedBytes = 0;
let errorAutoHideTimers: Partial<Record<string, ReturnType<typeof setTimeout>>> = {};
let commitPanelHeight = loadCommitPanelHeight(); let commitPanelHeight = loadCommitPanelHeight();
let resizingCommitPanel = false; let resizingCommitPanel = false;
let resizeStartY = 0; let resizeStartY = 0;
let resizeStartHeight = 0; let resizeStartHeight = 0;
let historyAsideWidth = loadHistoryAsideWidth();
let resizingHistoryAside = false;
let historyResizeStartX = 0;
let historyResizeStartWidth = 0;
// ── Derived ──────────────────────────────────────────────────────────────── // ── Derived ────────────────────────────────────────────────────────────────
@@ -212,18 +251,24 @@
$: hasRepository = activeRepoPath.length > 0 && status !== null; $: hasRepository = activeRepoPath.length > 0 && status !== null;
$: workspaceActive = activeView === "repository" && hasRepository; $: workspaceActive = activeView === "repository" && hasRepository;
$: openingRepo = operation === "Opening repository"; $: openingRepo = operation === "Opening repository";
$: cloningRepo = operation === "Cloning repository";
$: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? ""; $: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
$: cloneDisplayName = pendingClone?.directoryName || repoNameFromCloneUrl(pendingClone?.remoteUrl ?? "");
$: changedFiles = status?.files ?? []; $: changedFiles = status?.files ?? [];
$: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0; $: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0;
$: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0; $: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0;
$: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted"); $: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted");
$: hasConflicts = conflictedFiles.length > 0; $: hasConflicts = conflictedFiles.length > 0;
$: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !isBusy; $: rebaseInProgress = status?.rebase_in_progress ?? false;
$: commitBlockReason = hasConflicts $: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !rebaseInProgress && !isBusy;
? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "merge conflict must" : "merge conflicts must"} be resolved before committing.` $: 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; $: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy;
$: localBranches = branches.filter((b) => !b.remote); $: localBranches = branches.filter((b) => !b.remote);
$: localBranchNames = localBranches.map((b) => b.name);
$: remoteBranches = branches.filter((b) => b.remote); $: remoteBranches = branches.filter((b) => b.remote);
$: repoSearchTerm = repoSearch.trim().toLowerCase(); $: repoSearchTerm = repoSearch.trim().toLowerCase();
$: openRepoRows = repoTabs.filter(repoMatchesSearch); $: openRepoRows = repoTabs.filter(repoMatchesSearch);
@@ -249,9 +294,37 @@
if (autoRefreshTimer) clearInterval(autoRefreshTimer); if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer); if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
if (commitAiPollTimer) clearInterval(commitAiPollTimer); if (commitAiPollTimer) clearInterval(commitAiPollTimer);
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
Object.values(errorAutoHideTimers).forEach((timer) => {
if (timer) clearTimeout(timer);
});
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId); 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 ─────────────────────────────────────────────────────────── // ── Auto-refresh ───────────────────────────────────────────────────────────
function statusFingerprint(value: GitStatus): string { function statusFingerprint(value: GitStatus): string {
@@ -264,6 +337,7 @@
// Push buttons instead, not as a background popup. // Push buttons instead, not as a background popup.
async function backgroundFetchTick() { async function backgroundFetchTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || backgroundFetchInFlight) return; if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || backgroundFetchInFlight) return;
if (Date.now() - lastRepoSwitchAt < BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) return;
backgroundFetchInFlight = true; backgroundFetchInFlight = true;
try { try {
await fetchRemote(activeRepoPath); await fetchRemote(activeRepoPath);
@@ -286,6 +360,7 @@
const bundle = await openRepositoryBundle(activeRepoPath, 100); const bundle = await openRepositoryBundle(activeRepoPath, 100);
const previousHeadHash = lastFileHistoryHeadHash; const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(activeRepoPath, bundle.branches); await refreshBranchList(activeRepoPath, bundle.branches);
await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files); await refreshExplorerFiles(activeRepoPath, bundle.files);
// File history reflects `git log`, which only changes when HEAD actually moves // File history reflects `git log`, which only changes when HEAD actually moves
@@ -487,6 +562,41 @@
return path.split(/[\\/]/).filter(Boolean).pop() ?? path; 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 { function repoKey(path: string): string {
return path.replace(/\\/g, "/").trim().toLowerCase(); return path.replace(/\\/g, "/").trim().toLowerCase();
} }
@@ -622,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) { function startCommitPanelResize(event: PointerEvent) {
event.preventDefault(); event.preventDefault();
resizingCommitPanel = true; resizingCommitPanel = true;
@@ -650,6 +782,34 @@
persistCommitPanelHeight(commitPanelHeight); 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) { function rememberRecentRepo(path: string) {
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40); recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
persistRepoLists(); persistRepoLists();
@@ -682,6 +842,7 @@
void setSyncBadge(0, 0, 0).catch(() => {}); void setSyncBadge(0, 0, 0).catch(() => {});
} }
branches = []; branches = [];
stashes = [];
commits = []; commits = [];
lastFileHistoryHeadHash = ""; lastFileHistoryHeadHash = "";
repoFiles = []; repoFiles = [];
@@ -699,6 +860,8 @@
pendingRestoreFile = null; pendingRestoreFile = null;
newBranchCommit = null; newBranchCommit = null;
globalSearchResults = []; globalSearchResults = [];
deleteBranchTarget = null;
deleteBranchForce = false;
globalSearchOpen = false; globalSearchOpen = false;
globalSearchError = ""; globalSearchError = "";
resolveDialogOpen = false; resolveDialogOpen = false;
@@ -734,6 +897,11 @@
return (value?.files ?? []).some((file) => file.staged === "conflicted" || file.unstaged === "conflicted"); 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>) { async function runOperation(label: string, task: () => Promise<void>) {
if (isBusy) return; if (isBusy) return;
operation = label; operation = label;
@@ -773,6 +941,10 @@
branches = prefetched ?? (await listBranches(path)); branches = prefetched ?? (await listBranches(path));
} }
async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
stashes = prefetched ?? (await listStashes(path));
}
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) { async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
commits = prefetched ?? (await listCommits(path, 100)); commits = prefetched ?? (await listCommits(path, 100));
lastFileHistoryHeadHash = commits[0]?.hash ?? ""; lastFileHistoryHeadHash = commits[0]?.hash ?? "";
@@ -867,13 +1039,11 @@
if (globalSearchBusy) void cancelGlobalSearch(); if (globalSearchBusy) void cancelGlobalSearch();
activeView = "repository"; activeView = "repository";
await refreshBranchList(activeRepoPath, bundle.branches); await refreshBranchList(activeRepoPath, bundle.branches);
await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files); await refreshExplorerFiles(activeRepoPath, bundle.files);
lastRepoSwitchAt = Date.now();
}); });
// Silent background fetch on open, same as the periodic tick — no "Fetching" indicator,
// just brings ahead/behind (and the taskbar badge) up to date without blocking the
// repo-open flow.
void backgroundFetchTick();
} }
async function chooseRepositoryFolder() { async function chooseRepositoryFolder() {
@@ -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() { function openRepoManagement() {
if (isBusy) return; if (isBusy) return;
activeView = "management"; activeView = "management";
@@ -947,6 +1202,7 @@
await runOperation("Refreshing", async () => { await runOperation("Refreshing", async () => {
applyStatus(await getStatus(activeRepoPath)); applyStatus(await getStatus(activeRepoPath));
await refreshBranchList(activeRepoPath); await refreshBranchList(activeRepoPath);
await refreshStashes(activeRepoPath);
await refreshCommitHistory(activeRepoPath); await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath); await refreshFileHistory(activeRepoPath);
@@ -1003,16 +1259,41 @@
return; return;
} }
const confirmed = window.confirm(`Delete local branch "${branch.name}"?\n\nGit will refuse if the branch has unmerged changes.`); deleteBranchTarget = branch;
if (!confirmed) return; deleteBranchForce = false;
}
await runOperation(`Deleting ${branch.name}`, async () => { async function confirmDeleteBranch() {
applyStatus(await deleteBranch(activeRepoPath, branch.name)); 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 refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath); await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(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) { 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. // Resolve the keychain key (host/org) for the active repo's remote.
async function currentCredKey(): Promise<string | null> { async function currentCredKey(): Promise<string | null> {
if (!activeRepoPath) return null; if (!activeRepoPath) return null;
@@ -1065,11 +1386,11 @@
} }
} }
async function openCredentialDialog(action: "push" | "pull" | "fetch", key?: string | null) { async function openCredentialDialog(action: CredentialAction, key?: string | null) {
if (!activeRepoPath) return; if (!activeRepoPath && action !== "clone") return;
credDialogError = ""; credDialogError = "";
credDialogAction = action; credDialogAction = action;
credDialogKey = key === undefined ? await currentCredKey() : key; credDialogKey = key === undefined && action !== "clone" ? await currentCredKey() : (key ?? null);
credDialogOpen = true; credDialogOpen = true;
} }
@@ -1202,6 +1523,17 @@
if (credDialogAction === "pull") await doActualPull(username, password, key, false); if (credDialogAction === "pull") await doActualPull(username, password, key, false);
else if (credDialogAction === "push") await doActualPush(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 === "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). // Only persist once the operation actually succeeded (dialog has closed).
if (!credDialogOpen && save && key) { if (!credDialogOpen && save && key) {
@@ -1242,6 +1574,47 @@
await startRemoteAction("push"); 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 ───────────────────────────────────────────────── // ── File staging / restore ─────────────────────────────────────────────────
async function stageFile(file: GitFileStatus) { async function stageFile(file: GitFileStatus) {
@@ -1403,7 +1776,11 @@
const message = commitMessage.trim(); const message = commitMessage.trim();
if (!message || !activeRepoPath) return; if (!message || !activeRepoPath) return;
if (hasConflicts) { 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; return;
} }
await runOperation("Committing", async () => { await runOperation("Committing", async () => {
@@ -1715,6 +2092,7 @@
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog(); else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null; else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = 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" && compareSelectOpen) compareSelectOpen = false;
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog(); else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
} }
@@ -1823,14 +2201,33 @@
</section> </section>
{/if} {/if}
{#if workspaceActive && hasConflicts} {#if workspaceActive && hasConflicts && !rebaseInProgress}
<section class="notice conflict" role="alert"> <section class="notice conflict" role="alert">
<GitMerge size={17} aria-hidden="true" /> <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> <button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
</section> </section>
{/if} {/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"} {#if activeView === "management"}
<section class="repo-management" aria-label="Repository Management"> <section class="repo-management" aria-label="Repository Management">
<div class="repo-management-head"> <div class="repo-management-head">
@@ -1839,6 +2236,10 @@
<h1>Repositories</h1> <h1>Repositories</h1>
</div> </div>
<div class="repo-management-actions"> <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}> <button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
<FolderOpen size={15} aria-hidden="true" /> <FolderOpen size={15} aria-hidden="true" />
Browse Browse
@@ -1945,7 +2346,7 @@
</section> </section>
{:else} {:else}
<!-- Workspace --> <!-- 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 --> <!-- Left sidebar: branches + explorer -->
<aside class="left-sidebar" aria-label="Repository navigation"> <aside class="left-sidebar" aria-label="Repository navigation">
@@ -1957,10 +2358,21 @@
{isBusy} {isBusy}
onCheckout={checkout} onCheckout={checkout}
onMerge={merge} onMerge={merge}
onRebase={rebaseOnto}
onCreateBranch={createNewBranch} onCreateBranch={createNewBranch}
onRenameBranch={renameLocalBranch} onRenameBranch={renameLocalBranch}
onDeleteBranch={deleteLocalBranch} onDeleteBranch={deleteLocalBranch}
/> />
<StashPanel
{stashes}
changedCount={changedFiles.length}
{hasRepository}
{isBusy}
onPush={saveStash}
onApply={applyStashEntry}
onPop={popStashEntry}
onDrop={dropStashEntry}
/>
<ExplorerPanel <ExplorerPanel
{repoFiles} {repoFiles}
{expandedExplorerPaths} {expandedExplorerPaths}
@@ -2049,8 +2461,29 @@
<!-- Right sidebar: commit graph + file history --> <!-- Right sidebar: commit graph + file history -->
<aside class="history-aside" aria-label="Commit 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 <HistoryPanel
{commits} {commits}
{localBranchNames}
activeBranch={status?.current_branch ?? ""}
repositoryKey={activeRepoPath}
{hasRepository} {hasRepository}
{isBusy} {isBusy}
{expandedCommitHashes} {expandedCommitHashes}
@@ -2157,6 +2590,17 @@
/> />
{/if} {/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 --> <!-- Choose the AI provider/model used to generate commit messages -->
{#if aiSettingsOpen} {#if aiSettingsOpen}
<AiSettingsDialog <AiSettingsDialog
@@ -2208,11 +2652,26 @@
/> />
{/if} {/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 --> <!-- Full-screen overlay while a repository is being opened -->
{#if openingRepo} {#if openingRepo}
<RepoLoadingOverlay repoName={repoDisplayName} /> <RepoLoadingOverlay repoName={repoDisplayName} />
{/if} {/if}
<!-- Full-screen overlay while a repository is being cloned -->
{#if cloningRepo}
<RepoLoadingOverlay label="Cloning repository" repoName={cloneDisplayName} />
{/if}
<!-- Conflict resolve dialog --> <!-- Conflict resolve dialog -->
{#if resolveDialogOpen} {#if resolveDialogOpen}
<ResolveDialog <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 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 { .eyebrow {
display: block; display: block;
color: var(--color-ink-faint); 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.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.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.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 { .notice.conflict button {
margin-left: auto; margin-left: auto;
min-height: 26px; min-height: 26px;
@@ -684,6 +879,20 @@
border-color: rgba(224,160,64,0.45); border-color: rgba(224,160,64,0.45);
color: #f0c070; 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 --- */ /* --- Update toast --- */
@@ -862,7 +1071,7 @@
.workspace { .workspace {
display: grid; 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; flex: 1 1 0;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -870,21 +1079,57 @@
} }
.history-aside { .history-aside {
position: relative;
display: grid; display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); grid-template-columns: minmax(0, 1fr) 300px;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
gap: 8px; 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 { .left-sidebar {
display: grid; 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-width: 0;
min-height: 0; min-height: 0;
gap: 8px; gap: 8px;
} }
.left-sidebar:has(.stash-panel.collapsed) {
grid-template-rows: minmax(170px, 0.85fr) auto minmax(220px, 1.15fr);
}
/* --- Main panel --- */ /* --- Main panel --- */
.main-panel { .main-panel {
@@ -1044,6 +1289,158 @@
background: var(--color-surface-dim); 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 list --- */
.branch-head-actions { .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 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-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, .branch-context-menu,
.explorer-context-menu { .explorer-context-menu {
@@ -1281,6 +1679,12 @@
color: var(--color-ink); color: var(--color-ink);
} }
.branch-context-menu .menu-separator {
height: 1px;
margin: 4px 3px;
background: var(--color-border-subtle);
}
.branch-context-menu button.danger { .branch-context-menu button.danger {
color: #ff9aa8; 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 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; } .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; } .commit-card-head {
.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; } 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; } .ref-list { display: flex; flex-wrap: wrap; gap: 3px; }
.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 .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-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-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-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 { 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 { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; }
.commit-action-buttons button { flex: 0 0 auto; white-space: nowrap; } .commit-action-buttons button { flex: 0 0 auto; white-space: nowrap; }
.file-history-head { align-items: flex-start; } .file-history-head { align-items: flex-start; }
@@ -1551,29 +2126,131 @@
/* --- Git graph --- */ /* --- 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-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 { 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 { .graph-dot {
position: absolute; position: absolute;
z-index: 2;
top: 50%; top: 50%;
width: 10px; width: 12px;
height: 10px; height: 12px;
border-radius: 999px; border-radius: 999px;
background: var(--dot-color, #5a8cf8); background: var(--dot-color, #5a8cf8);
border: 2px solid var(--color-surface); border: 2px solid #111321;
box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); box-shadow: 0 0 0 1px rgba(255,255,255,0.07);
transform: translate(-50%, -50%); 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 --- */ /* --- Compare panel --- */
@@ -1686,6 +2363,54 @@
max-height: calc(100vh - 32px); max-height: calc(100vh - 32px);
overflow: auto; 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 { .ai-settings-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1974,6 +2699,12 @@
gap: 14px; gap: 14px;
padding: 18px 16px 16px; 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 { .discard-warning-icon {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -2008,6 +2739,11 @@
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
.branch-delete-body .discard-target {
display: flex;
align-items: center;
gap: 6px;
}
.discard-warning-text { .discard-warning-text {
color: #ffb8bf; color: #ffb8bf;
font-weight: 650; font-weight: 650;
@@ -3047,16 +3783,16 @@
/* --- Responsive breakpoints --- */ /* --- Responsive breakpoints --- */
@media (min-width: 1800px) { @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) { @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 */ /* Stack CommitPanel below StatusPanel; history panels stay side by side */
@media (max-width: 1100px) { @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); } .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) { @media (max-width: 960px) {
.workspace { grid-template-columns: 180px minmax(0, 1fr) 300px; gap: 6px; } .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-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; } .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; } .section-head { min-height: 40px; padding: 6px 10px; }
.repo-summary { height: 40px; padding: 0 10px; } .repo-summary { height: 40px; padding: 0 10px; }
.repo-branch { max-width: 160px; } .repo-branch { max-width: 160px; }
@@ -3082,12 +3820,19 @@
.shell-body { min-height: 100%; gap: 6px; } .shell-body { min-height: 100%; gap: 6px; }
.workspace { grid-template-columns: 1fr; 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; } .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); } .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-form { grid-template-columns: 1fr; }
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; } .repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
.repo-tab.management { min-width: 0; } .repo-tab.management { min-width: 0; }
.repo-tabs-scroll { grid-column: 1 / -1; order: 2; border-top: 1px solid var(--color-border-subtle); } .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-main { grid-template-columns: minmax(0, 1fr); gap: 2px; align-content: center; padding-left: 12px; }
.repo-row { min-height: 58px; } .repo-row { min-height: 58px; }
.repo-row-icon { min-height: 58px; } .repo-row-icon { min-height: 58px; }
+16 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <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 { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
import { credDelete, credLoad, credSave } from "../git"; import { credDelete, credLoad, credSave } from "../git";
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types"; import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
@@ -36,6 +36,7 @@
let loadingKeys = $state(true); let loadingKeys = $state(true);
let saving = $state(false); let saving = $state(false);
let error = $state(""); let error = $state("");
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
$effect(() => { $effect(() => {
provider = settings.provider; provider = settings.provider;
@@ -47,6 +48,16 @@
customModel = settings.customModel; customModel = settings.customModel;
}); });
$effect(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
const currentError = error;
if (currentError) {
errorHideTimer = setTimeout(() => {
if (error === currentError) error = "";
}, 6000);
}
});
onMount(() => { onMount(() => {
(async () => { (async () => {
try { try {
@@ -66,6 +77,10 @@
})(); })();
}); });
onDestroy(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
});
async function persistKey(target: CloudProvider, value: string) { async function persistKey(target: CloudProvider, value: string) {
const key = CRED_KEYS[target]; const key = CRED_KEYS[target];
const trimmed = value.trim(); const trimmed = value.trim();
@@ -126,7 +141,6 @@
<div <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" 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"> <div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
<header class="dialog-header"> <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; isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void; onCheckout: (branch: GitBranchInfo) => void;
onMerge: (branch: GitBranchInfo) => void; onMerge: (branch: GitBranchInfo) => void;
onRebase: (branch: GitBranchInfo) => void;
onCreateBranch: (branchName: string) => void | Promise<void>; onCreateBranch: (branchName: string) => void | Promise<void>;
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>; onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>; onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
@@ -62,6 +63,7 @@
isBusy = false, isBusy = false,
onCheckout = () => {}, onCheckout = () => {},
onMerge = () => {}, onMerge = () => {},
onRebase = () => {},
onCreateBranch = () => {}, onCreateBranch = () => {},
onRenameBranch = () => {}, onRenameBranch = () => {},
onDeleteBranch = () => {}, onDeleteBranch = () => {},
@@ -216,13 +218,13 @@
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) { function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (isBusy || branch.remote) return; if (isBusy) return;
const rect = panelElement?.getBoundingClientRect(); const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX; const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY; const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192); 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; contextBranch = branch;
contextMenuX = Math.max(8, Math.min(rawX, maxX)); contextMenuX = Math.max(8, Math.min(rawX, maxX));
@@ -242,11 +244,32 @@
async function deleteContextBranch() { async function deleteContextBranch() {
const branch = contextBranch; const branch = contextBranch;
if (!branch || branch.current || isBusy) return; if (!branch || branch.current || branch.remote || isBusy) return;
closeBranchContextMenu(); closeBranchContextMenu();
await onDeleteBranch(branch); 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) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeBranchContextMenu(); if (event.key === "Escape") closeBranchContextMenu();
} }
@@ -361,16 +384,6 @@
</div> </div>
{#if row.branch.current} {#if row.branch.current}
<span class="pill pill-active">Current</span> <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} {/if}
</article> </article>
{/if} {/if}
@@ -426,6 +439,7 @@
class:current={row.branch.current} class:current={row.branch.current}
style={`--branch-indent: ${row.depth * 16}px;`} style={`--branch-indent: ${row.depth * 16}px;`}
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)} ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
title={row.branch.current ? "Current branch" : row.branch.name} title={row.branch.current ? "Current branch" : row.branch.name}
> >
<div class="branch-info"> <div class="branch-info">
@@ -437,16 +451,6 @@
</div> </div>
{#if row.branch.current} {#if row.branch.current}
<span class="pill pill-active">Current</span> <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} {/if}
</article> </article>
{/if} {/if}
@@ -465,7 +469,20 @@
tabindex="-1" tabindex="-1"
aria-label={`Actions for ${contextBranch.name}`} 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" /> <Pencil size={14} aria-hidden="true" />
Rename Rename
</button> </button>
@@ -474,8 +491,8 @@
type="button" type="button"
role="menuitem" role="menuitem"
onclick={deleteContextBranch} onclick={deleteContextBranch}
disabled={isBusy || contextBranch.current} disabled={isBusy || contextBranch.current || contextBranch.remote}
title={contextBranch.current ? "Current branch cannot be deleted" : "Delete local branch"} 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" /> <Trash2 size={14} aria-hidden="true" />
Delete 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 <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" 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"> <div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
@@ -41,7 +41,6 @@
<div <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" 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"> <div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
<header class="dialog-header"> <header class="dialog-header">
+11 -4
View File
@@ -14,7 +14,7 @@
} from "@lucide/svelte"; } from "@lucide/svelte";
interface Props { interface Props {
action: "push" | "pull" | "fetch"; action: "push" | "pull" | "fetch" | "clone";
error: string; error: string;
isBusy: boolean; isBusy: boolean;
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void; onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
@@ -43,12 +43,20 @@
password.trim().length > 0 && password.trim().length > 0 &&
(mode === "token" || username.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( 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" let actionHint = $derived(action === "push"
? "The remote needs write access. Use a password or a token with the appropriate repository permissions." ? "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."); : "The remote needs access to the repository. Use your Git credentials or a personal access token.");
function handleSubmit(e: SubmitEvent) { function handleSubmit(e: SubmitEvent) {
@@ -66,7 +74,6 @@
<div <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" 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-card" role="dialog" aria-modal="true" aria-label="Git credentials" tabindex="-1">
<div class="cred-hero"> <div class="cred-hero">
@@ -25,13 +25,9 @@
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes"); let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes"); let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
function closeFromBackdrop(event: MouseEvent) {
if (isBusy || event.target !== event.currentTarget) return;
onClose();
}
</script> </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}> <div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header"> <header class="dialog-header">
<div> <div>
@@ -129,7 +129,6 @@
<div <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" 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"> <div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global search" tabindex="-1">
<header class="dialog-header"> <header class="dialog-header">
+388 -32
View File
@@ -1,28 +1,38 @@
<script lang="ts"> <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"; import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment { interface GraphSegment {
fromCol: number; fromCol: number;
toCol: number; toCol: number;
color: string; color: string;
branches: string[];
} }
interface GraphRow { interface GraphRow {
dotCol: number; dotCol: number;
dotColor: string; dotColor: string;
branchLabels: string[];
top: GraphSegment[]; top: GraphSegment[];
bottom: GraphSegment[]; bottom: GraphSegment[];
} }
interface VisibleCommitEntry {
commit: GitCommit;
graphCommit: GitCommit;
}
const GRAPH_COLORS = [ const GRAPH_COLORS = [
"#2f6fb0", "#4aa777", "#c9851f", "#a05bd0", "#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
"#cc4b6e", "#1f9ab0", "#7a8a1f", "#b0631f", "#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
]; ];
const GRAPH_LANE = 16; const GRAPH_LANE = 18;
interface Props { interface Props {
commits: GitCommit[]; commits: GitCommit[];
localBranchNames: string[];
activeBranch: string;
repositoryKey: string;
hasRepository: boolean; hasRepository: boolean;
isBusy: boolean; isBusy: boolean;
expandedCommitHashes: Set<string>; expandedCommitHashes: Set<string>;
@@ -34,6 +44,9 @@
let { let {
commits = [], commits = [],
localBranchNames = [],
activeBranch = "",
repositoryKey = "",
hasRepository = false, hasRepository = false,
isBusy = false, isBusy = false,
expandedCommitHashes = new Set(), expandedCommitHashes = new Set(),
@@ -43,6 +56,11 @@
onCreateBranchFromCommit = () => {}, onCreateBranchFromCommit = () => {},
}: Props = $props(); }: 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 { function laneColor(col: number): string {
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length]; return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
} }
@@ -51,13 +69,23 @@
return col * GRAPH_LANE + GRAPH_LANE / 2; 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[] = []; const rows: GraphRow[] = [];
let lanes: (string | null)[] = []; let lanes: (string | null)[] = [];
let laneBranches: string[][] = [];
let maxColumns = 1; let maxColumns = 1;
for (const commit of items) { for (const commit of items) {
const before = lanes.slice(); const before = lanes.slice();
const beforeBranches = laneBranches.map((branches) => branches.slice());
let col = before.indexOf(commit.hash); let col = before.indexOf(commit.hash);
if (col === -1) { if (col === -1) {
@@ -67,18 +95,27 @@
const after = before.slice(); const after = before.slice();
while (after.length <= col) after.push(null); 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++) { 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; after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
afterBranches[col] = after[col] ? commitBranches.slice() : [];
const fromCommit = new Set<number>([col]); const fromCommit = new Set<number>([col]);
for (let p = 1; p < commit.parents.length; p++) { for (let p = 1; p < commit.parents.length; p++) {
let slot = after.indexOf(null); 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]; after[slot] = commit.parents[p];
afterBranches[slot] = [];
fromCommit.add(slot); fromCommit.add(slot);
} }
@@ -86,19 +123,28 @@
for (let k = 0; k < before.length; k++) { for (let k = 0; k < before.length; k++) {
const target = before[k]; const target = before[k];
if (target == null) continue; 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[] = []; const bottom: GraphSegment[] = [];
for (let k = 0; k < after.length; k++) { for (let k = 0; k < after.length; k++) {
if (after[k] == null) continue; 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(); 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); maxColumns = Math.max(maxColumns, before.length, after.length, col + 1);
} }
@@ -123,73 +169,340 @@
: baseName(file.path); : 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 { function formatCommitDate(value: string): string {
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return value; if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); 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 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> </script>
<svelte:window onkeydown={handleBranchDialogKeydown} />
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history"> <section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
<div class="section-head"> <div class="section-head">
<div> <div>
<span class="eyebrow">History</span> <span class="eyebrow">History</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2> <h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
</div> </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> </div>
{#if !hasRepository} {#if !hasRepository}
<div class="blank-state">No repository loaded.</div> <div class="blank-state">No repository loaded.</div>
{:else if commits.length === 0} {:else if commits.length === 0}
<div class="blank-state">No commits returned.</div> <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} {:else}
<div class="history-list graph-list overflow-auto"> <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]} {@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"> <div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
{#if row} {#if row}
<svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none"> <svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none">
{#each row.top as seg} {#each row.top as seg}
<line <path
x1={graphColX(seg.fromCol)} y1="0" class:hidden-branch={!segmentIsVisible(seg)}
x2={graphColX(seg.toCol)} y2="50" d={graphPath(seg, 0, 50)}
stroke={seg.color} stroke-width="2" vector-effect="non-scaling-stroke" stroke={seg.color}
stroke-width="2.2"
vector-effect="non-scaling-stroke"
/> />
{/each} {/each}
{#each row.bottom as seg} {#each row.bottom as seg}
<line <path
x1={graphColX(seg.fromCol)} y1="50" class:hidden-branch={!segmentIsVisible(seg)}
x2={graphColX(seg.toCol)} y2="100" d={graphPath(seg, 50, 100)}
stroke={seg.color} stroke-width="2" vector-effect="non-scaling-stroke" stroke={seg.color}
stroke-width="2.2"
vector-effect="non-scaling-stroke"
/> />
{/each} {/each}
</svg> </svg>
<span <span
class="graph-dot" class="graph-dot"
class:merge={item.parents.length > 1} 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}`} style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
></span> ></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} {/if}
</div> </div>
<div class="commit-body"> <div class="commit-body">
<div class="commit-line"> <div class="commit-card-head">
<div> <span class="commit-avatar">
<strong title={item.summary}>{item.summary}</strong> {authorInitials(item.author_name)}
<span>{item.short_hash} - {item.author_name}</span> </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>
</div> </div>
{#if item.refs.length > 0} {#if otherRefs.length > 0}
<div class="ref-list" aria-label="Commit refs"> <div class="ref-list" aria-label="Commit refs">
{#each item.refs as ref} {#each otherRefs as ref}
<span>{ref}</span> <span class={`ref-chip ${refClass(ref)}`}>{refLabel(ref)}</span>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -230,7 +543,7 @@
{/if} {/if}
<div class="commit-actions"> <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"> <div class="commit-action-buttons">
<button class="btn-sm" type="button" onclick={() => onCreateBranchFromCommit(item)} disabled={isBusy} title="Create a new branch from this commit"> <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" /> <GitBranch size={15} aria-hidden="true" />
@@ -248,3 +561,46 @@
</div> </div>
{/if} {/if}
</section> </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 <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" 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"> <div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1">
<header class="dialog-header"> <header class="dialog-header">
@@ -33,7 +33,6 @@
<div <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" 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"> <div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
<header class="dialog-header"> <header class="dialog-header">
+3 -4
View File
@@ -254,20 +254,19 @@
<div <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
> >
<div <div
class="dialog" class="dialog"
style="grid-template-rows: auto minmax(0,1fr) auto;" style="grid-template-rows: auto minmax(0,1fr) auto;"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Resolve merge conflicts" aria-label="Resolve conflicts"
tabindex="-1" tabindex="-1"
> >
<header class="dialog-header"> <header class="dialog-header">
<div> <div>
<span class="eyebrow">Resolve</span> <span class="eyebrow">Resolve</span>
<h2 class="dialog-title">Merge conflicts</h2> <h2 class="dialog-title">Conflicts</h2>
</div> </div>
<button class="dialog-close" type="button" onclick={onClose} title="Close"> <button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" /> <X size={18} aria-hidden="true" />
@@ -275,7 +274,7 @@
</header> </header>
{#if conflictedFiles.length === 0} {#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} {:else}
<div class="dialog-body"> <div class="dialog-body">
<aside class="dialog-files" aria-label="Conflicted files"> <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, GitCommitComparison,
GitRepositoryFile, GitRepositoryFile,
GitSearchHit, GitSearchHit,
GitStash,
GitStatus, GitStatus,
LocalModelOption, LocalModelOption,
PatchApplyAction, PatchApplyAction,
@@ -33,6 +34,24 @@ export function openRepositoryBundle(path: string, commitLimit = 100): Promise<R
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit }); 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> { export function getStatus(path: string): Promise<GitStatus> {
return invoke<GitStatus>("get_status", { path }); return invoke<GitStatus>("get_status", { path });
} }
@@ -47,6 +66,10 @@ export function listBranches(path: string): Promise<GitBranch[]> {
return invoke<GitBranch[]>("list_branches", { path }); 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> { export function checkoutBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("checkout_branch", { path, branch }); return invoke<GitStatus>("checkout_branch", { path, branch });
} }
@@ -67,8 +90,8 @@ export function renameBranch(
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch }); return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
} }
export function deleteBranch(path: string, branch: string): Promise<GitStatus> { export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
return invoke<GitStatus>("delete_branch", { path, branch }); return invoke<GitStatus>("delete_branch", { path, branch, force });
} }
export function stageFiles(path: string, files: string[]): Promise<GitStatus> { 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 }); 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> { export function commitAiStatus(): Promise<CommitAiStatus> {
return invoke<CommitAiStatus>("commit_ai_status"); 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 }); 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[]> { export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
return invoke<GitRepositoryFile[]>("list_repository_files", { path }); return invoke<GitRepositoryFile[]>("list_repository_files", { path });
} }
+11
View File
@@ -41,6 +41,7 @@ export interface GitStatus {
behind: number; behind: number;
files: GitFileStatus[]; files: GitFileStatus[];
clean: boolean; clean: boolean;
rebase_in_progress: boolean;
} }
export interface GitFileStatus { export interface GitFileStatus {
@@ -58,6 +59,15 @@ export interface GitBranch {
remote: boolean; remote: boolean;
} }
export interface GitStash {
selector: string;
index: number;
hash: string;
branch: string | null;
message: string;
date: string;
}
export interface GitCommit { export interface GitCommit {
hash: string; hash: string;
short_hash: string; short_hash: string;
@@ -85,6 +95,7 @@ export interface GitRepositoryFile {
export interface RepositoryBundle { export interface RepositoryBundle {
status: GitStatus; status: GitStatus;
branches: GitBranch[]; branches: GitBranch[];
stashes: GitStash[];
commits: GitCommit[]; commits: GitCommit[];
files: GitRepositoryFile[]; files: GitRepositoryFile[];
} }