Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d34147b41 | ||
|
|
e18f8f1040 | ||
|
|
310a0fb09a | ||
|
|
b04158926d | ||
|
|
eef4869bbb | ||
|
|
97fc4fc1e0 | ||
|
|
e6d22765be | ||
|
|
312727ee73 | ||
|
|
5752243e6e |
@@ -28,7 +28,10 @@
|
|||||||
"Bash(xxd)",
|
"Bash(xxd)",
|
||||||
"Bash(python3 -)",
|
"Bash(python3 -)",
|
||||||
"Bash(echo \"exit: $?\")",
|
"Bash(echo \"exit: $?\")",
|
||||||
"Bash(grep -n \"input,\\\\|select,\\\\|input {\\\\|select {\\\\|.repo-form input\\\\|input:focus\\\\|::placeholder\" src/app.css)"
|
"Bash(grep -n \"input,\\\\|select,\\\\|input {\\\\|select {\\\\|.repo-form input\\\\|input:focus\\\\|::placeholder\" src/app.css)",
|
||||||
|
"Bash(sudo -n true)",
|
||||||
|
"Bash(rustc --version)",
|
||||||
|
"Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -4,4 +4,5 @@
|
|||||||
*.log
|
*.log
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
~
|
~
|
||||||
|
.codex*
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "tauri-git-lite",
|
"name": "tauri-git-lite",
|
||||||
"version": "2026.7.1",
|
"version": "2026.7.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tauri-git-lite",
|
"name": "tauri-git-lite",
|
||||||
"version": "2026.7.1",
|
"version": "2026.7.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/svelte": "^1.21.0",
|
"@lucide/svelte": "^1.21.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "git-lite",
|
"name": "git-lite",
|
||||||
"version": "2026.7.1",
|
"version": "2026.7.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+533
-9
@@ -208,6 +208,12 @@ pub fn open_repository(path: String) -> Result<GitStatus, String> {
|
|||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
open_path_in_file_manager(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct RepositoryBundle {
|
pub struct RepositoryBundle {
|
||||||
pub status: GitStatus,
|
pub status: GitStatus,
|
||||||
@@ -341,13 +347,65 @@ pub fn create_branch(
|
|||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn rename_branch(
|
||||||
|
path: String,
|
||||||
|
old_branch: String,
|
||||||
|
new_branch: String,
|
||||||
|
) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let old_branch = validate_existing_local_branch_name(&repo, &old_branch)?;
|
||||||
|
let new_branch = validate_new_branch_name(&repo, &new_branch)?;
|
||||||
|
|
||||||
|
run_git(
|
||||||
|
&repo,
|
||||||
|
[
|
||||||
|
"branch",
|
||||||
|
"-m",
|
||||||
|
"--",
|
||||||
|
old_branch.as_str(),
|
||||||
|
new_branch.as_str(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
status_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let branch = validate_existing_local_branch_name(&repo, &branch)?;
|
||||||
|
let status = status_for_repo(&repo)?;
|
||||||
|
if status.current_branch.as_deref() == Some(branch.as_str()) {
|
||||||
|
return Err("Der aktuelle Branch kann nicht geloescht werden.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
run_git(&repo, ["branch", "-d", "--", branch.as_str()])?;
|
||||||
|
status_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
validate_files(&files)?;
|
validate_files(&files)?;
|
||||||
|
|
||||||
if !files.is_empty() {
|
if !files.is_empty() {
|
||||||
run_git_with_paths(&repo, &["add"], &files)?;
|
// A file we've detected as an unstaged rename (see `detect_worktree_renames`) has
|
||||||
|
// to be staged with both its old and new path so `git add` records it as a rename
|
||||||
|
// instead of leaving the old path's deletion unstaged.
|
||||||
|
let current_status = status_for_repo(&repo)?;
|
||||||
|
let mut add_paths: Vec<String> = Vec::new();
|
||||||
|
for file in &files {
|
||||||
|
match find_status(¤t_status.files, file) {
|
||||||
|
Some(entry) => {
|
||||||
|
if let Some(old_path) = &entry.old_path {
|
||||||
|
add_paths.push(old_path.clone());
|
||||||
|
}
|
||||||
|
add_paths.push(entry.path.clone());
|
||||||
|
}
|
||||||
|
None => add_paths.push(file.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_git_with_paths(&repo, &["add"], &add_paths)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
@@ -385,6 +443,59 @@ pub fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<G
|
|||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_file_patch(path: String, file: String, staged: bool) -> Result<String, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
validate_files(std::slice::from_ref(&file))?;
|
||||||
|
|
||||||
|
let base_args = if staged {
|
||||||
|
&[
|
||||||
|
"diff",
|
||||||
|
"--cached",
|
||||||
|
"--no-ext-diff",
|
||||||
|
"--no-textconv",
|
||||||
|
"--unified=3",
|
||||||
|
][..]
|
||||||
|
} else {
|
||||||
|
&["diff", "--no-ext-diff", "--no-textconv", "--unified=3"][..]
|
||||||
|
};
|
||||||
|
let output = run_git_with_paths(&repo, base_args, &[file])?;
|
||||||
|
Ok(String::from_utf8_lossy(&output).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn apply_file_patch(
|
||||||
|
path: String,
|
||||||
|
file: String,
|
||||||
|
patch: String,
|
||||||
|
action: String,
|
||||||
|
) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
validate_files(std::slice::from_ref(&file))?;
|
||||||
|
if patch.trim().is_empty() {
|
||||||
|
return Err("Kein Patch ausgewaehlt.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let patch_path = write_temp_patch(&patch)?;
|
||||||
|
let result = match action.as_str() {
|
||||||
|
"stage" => check_apply_patch(&repo, &patch_path, &["--cached"])
|
||||||
|
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached"])),
|
||||||
|
"unstage" => check_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])
|
||||||
|
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])),
|
||||||
|
"discard-unstaged" => check_apply_patch(&repo, &patch_path, &["--reverse"])
|
||||||
|
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--reverse"])),
|
||||||
|
"discard-staged" => check_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])
|
||||||
|
.and_then(|_| check_apply_patch(&repo, &patch_path, &["--reverse"]))
|
||||||
|
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached", "--reverse"]))
|
||||||
|
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--reverse"])),
|
||||||
|
_ => Err("Ungueltige Patch-Aktion.".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&patch_path);
|
||||||
|
result?;
|
||||||
|
status_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
|
pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
@@ -1261,6 +1372,36 @@ fn resolve_repo(path: &str) -> Result<PathBuf, String> {
|
|||||||
Ok(PathBuf::from(top_level))
|
Ok(PathBuf::from(top_level))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
|
||||||
|
let native_path = path.to_string_lossy().replace('/', "\\");
|
||||||
|
let mut command = Command::new("explorer.exe");
|
||||||
|
command.arg(native_path);
|
||||||
|
command.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
command
|
||||||
|
.spawn()
|
||||||
|
.map_err(|err| format!("Explorer konnte nicht gestartet werden: {err}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
|
||||||
|
Command::new("open")
|
||||||
|
.arg(path)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|err| format!("Finder konnte nicht gestartet werden: {err}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
|
||||||
|
Command::new("xdg-open")
|
||||||
|
.arg(path)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|err| format!("Dateimanager konnte nicht gestartet werden: {err}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn verify_commit(repo: &Path, commit: &str) -> Result<String, String> {
|
fn verify_commit(repo: &Path, commit: &str) -> Result<String, String> {
|
||||||
let commit = commit.trim();
|
let commit = commit.trim();
|
||||||
if commit.is_empty() {
|
if commit.is_empty() {
|
||||||
@@ -1290,7 +1431,8 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
|||||||
"--untracked-files=all",
|
"--untracked-files=all",
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
let (branch, files) = parse_status_output(&output)?;
|
let (branch, mut files) = parse_status_output(&output)?;
|
||||||
|
detect_worktree_renames(repo, &mut files);
|
||||||
|
|
||||||
Ok(GitStatus {
|
Ok(GitStatus {
|
||||||
repo_path: repo.to_string_lossy().to_string(),
|
repo_path: repo.to_string_lossy().to_string(),
|
||||||
@@ -1303,6 +1445,127 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `git status` only auto-detects renames between HEAD and the index (staged changes).
|
||||||
|
// A file renamed on disk but not yet `git add`ed shows up as a plain delete + untracked
|
||||||
|
// pair instead. We detect that case ourselves by comparing content hashes: if an unstaged
|
||||||
|
// deletion and an untracked file share the same blob hash (and the match is unambiguous),
|
||||||
|
// we merge them into a single unstaged "renamed" entry, mirroring how git reports staged
|
||||||
|
// renames. This is intentionally content-hash based (not similarity-based) so it never
|
||||||
|
// mutates the repository's real index.
|
||||||
|
const WORKTREE_RENAME_DETECTION_LIMIT: usize = 300;
|
||||||
|
|
||||||
|
fn detect_worktree_renames(repo: &Path, files: &mut Vec<GitFileStatus>) {
|
||||||
|
let deleted_paths: Vec<String> = files
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.staged.is_none() && f.unstaged == Some(FileStatusKind::Deleted))
|
||||||
|
.map(|f| f.path.clone())
|
||||||
|
.collect();
|
||||||
|
let untracked_paths: Vec<String> = files
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.staged.is_none() && f.unstaged == Some(FileStatusKind::Untracked))
|
||||||
|
.map(|f| f.path.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if deleted_paths.is_empty()
|
||||||
|
|| untracked_paths.is_empty()
|
||||||
|
|| deleted_paths.len() > WORKTREE_RENAME_DETECTION_LIMIT
|
||||||
|
|| untracked_paths.len() > WORKTREE_RENAME_DETECTION_LIMIT
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(deleted_hashes) = index_blob_hashes(repo, &deleted_paths) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(untracked_hashes) = worktree_blob_hashes(repo, &untracked_paths) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut hash_to_deleted: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||||
|
for (path, hash) in &deleted_hashes {
|
||||||
|
hash_to_deleted.entry(hash).or_default().push(path);
|
||||||
|
}
|
||||||
|
let mut hash_to_untracked: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||||
|
for (path, hash) in &untracked_hashes {
|
||||||
|
hash_to_untracked.entry(hash).or_default().push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut renames: Vec<(String, String)> = Vec::new();
|
||||||
|
for (hash, olds) in &hash_to_deleted {
|
||||||
|
if olds.len() != 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(news) = hash_to_untracked.get(hash) {
|
||||||
|
if news.len() == 1 {
|
||||||
|
renames.push((olds[0].to_string(), news[0].to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (old_path, new_path) in renames {
|
||||||
|
files.retain(|f| {
|
||||||
|
!(f.path == old_path && f.staged.is_none() && f.unstaged == Some(FileStatusKind::Deleted))
|
||||||
|
&& !(f.path == new_path
|
||||||
|
&& f.staged.is_none()
|
||||||
|
&& f.unstaged == Some(FileStatusKind::Untracked))
|
||||||
|
});
|
||||||
|
files.push(GitFileStatus {
|
||||||
|
path: new_path,
|
||||||
|
old_path: Some(old_path),
|
||||||
|
staged: None,
|
||||||
|
unstaged: Some(FileStatusKind::Renamed),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batched via `git ls-files -s -z` (one process for every deleted path) rather than one
|
||||||
|
// `git rev-parse` call per file, since this runs on every status refresh.
|
||||||
|
fn index_blob_hashes(repo: &Path, paths: &[String]) -> Result<Vec<(String, String)>, String> {
|
||||||
|
let mut args: Vec<OsString> = vec![
|
||||||
|
OsString::from("ls-files"),
|
||||||
|
OsString::from("-s"),
|
||||||
|
OsString::from("-z"),
|
||||||
|
OsString::from("--"),
|
||||||
|
];
|
||||||
|
args.extend(paths.iter().map(OsString::from));
|
||||||
|
let output = run_git(repo, args)?;
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for entry in output.split(|byte| *byte == 0).filter(|e| !e.is_empty()) {
|
||||||
|
let text = String::from_utf8_lossy(entry);
|
||||||
|
let Some((meta, path)) = text.split_once('\t') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(hash) = meta.split_whitespace().nth(1) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
result.push((path.to_string(), hash.to_string()));
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batched via `git hash-object --stdin-paths` (one process for every untracked path).
|
||||||
|
fn worktree_blob_hashes(repo: &Path, paths: &[String]) -> Result<Vec<(String, String)>, String> {
|
||||||
|
let stdin_data = paths.join("\n");
|
||||||
|
let output = run_git_with_stdin(
|
||||||
|
repo,
|
||||||
|
["hash-object", "--stdin-paths"],
|
||||||
|
stdin_data.as_bytes(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let hashes: Vec<String> = String::from_utf8_lossy(&output)
|
||||||
|
.lines()
|
||||||
|
.map(|line| line.trim().to_string())
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(paths
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.zip(hashes)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
fn repository_files(repo: &Path) -> Result<Vec<GitRepositoryFile>, String> {
|
fn repository_files(repo: &Path) -> Result<Vec<GitRepositoryFile>, String> {
|
||||||
let status = status_for_repo(repo)?;
|
let status = status_for_repo(repo)?;
|
||||||
repository_files_with_status(repo, &status)
|
repository_files_with_status(repo, &status)
|
||||||
@@ -2014,6 +2277,41 @@ fn validate_new_branch_name(repo: &Path, branch: &str) -> Result<String, String>
|
|||||||
Ok(normalized)
|
Ok(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result<String, String> {
|
||||||
|
let branch = branch.trim();
|
||||||
|
if branch.is_empty() {
|
||||||
|
return Err("Branch-Name darf nicht leer sein.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = validate_branch_ref_name(branch)?;
|
||||||
|
if !ref_exists(repo, &format!("refs/heads/{normalized}"))? {
|
||||||
|
return Err(format!(
|
||||||
|
"Lokaler Branch '{normalized}' wurde nicht gefunden."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_branch_ref_name(branch: &str) -> Result<String, String> {
|
||||||
|
let output = git_command()
|
||||||
|
.args(["check-ref-format", "--branch", branch])
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let details = command_output_details(&output);
|
||||||
|
return Err(format!("Ungueltiger Branch-Name: {details}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
Ok(if normalized.is_empty() {
|
||||||
|
branch.to_string()
|
||||||
|
} else {
|
||||||
|
normalized
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> {
|
fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> {
|
||||||
let output = git_command()
|
let output = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
@@ -2047,13 +2345,22 @@ fn restore_worktree_files(
|
|||||||
|
|
||||||
for file in files {
|
for file in files {
|
||||||
let status = find_status(statuses, file);
|
let status = find_status(statuses, file);
|
||||||
if matches!(
|
match status.and_then(|entry| entry.unstaged) {
|
||||||
status.and_then(|entry| entry.unstaged),
|
Some(FileStatusKind::Untracked) => clean_paths.push(file.clone()),
|
||||||
Some(FileStatusKind::Untracked)
|
// An unstaged rename (see `detect_worktree_renames`) has no index entry for the
|
||||||
) {
|
// new path, so `git restore` can't act on it directly: restore the original
|
||||||
clean_paths.push(file.clone());
|
// content at the old path and drop the untracked new file instead.
|
||||||
} else {
|
Some(FileStatusKind::Renamed) => {
|
||||||
restore_paths.push(file.clone());
|
if let Some(entry) = status {
|
||||||
|
if let Some(old_path) = entry.old_path.clone() {
|
||||||
|
restore_paths.push(old_path);
|
||||||
|
}
|
||||||
|
clean_paths.push(entry.path.clone());
|
||||||
|
} else {
|
||||||
|
restore_paths.push(file.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => restore_paths.push(file.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2184,6 +2491,45 @@ fn validate_files(files: &[String]) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_temp_patch(patch: &str) -> Result<PathBuf, String> {
|
||||||
|
let counter = CANCELLABLE_GIT_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"gitlite_patch_{}_{}.patch",
|
||||||
|
std::process::id(),
|
||||||
|
counter
|
||||||
|
));
|
||||||
|
std::fs::write(&path, patch.as_bytes())
|
||||||
|
.map_err(|err| format!("Patch-Datei konnte nicht geschrieben werden: {err}"))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_apply_patch(repo: &Path, patch_path: &Path, options: &[&str]) -> Result<(), String> {
|
||||||
|
run_apply_patch_command(repo, patch_path, options, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_apply_patch(repo: &Path, patch_path: &Path, options: &[&str]) -> Result<(), String> {
|
||||||
|
run_apply_patch_command(repo, patch_path, options, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_apply_patch_command(
|
||||||
|
repo: &Path,
|
||||||
|
patch_path: &Path,
|
||||||
|
options: &[&str],
|
||||||
|
check_only: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut args = Vec::with_capacity(options.len() + 5);
|
||||||
|
args.push(OsString::from("apply"));
|
||||||
|
if check_only {
|
||||||
|
args.push(OsString::from("--check"));
|
||||||
|
}
|
||||||
|
args.extend(options.iter().map(OsString::from));
|
||||||
|
args.push(OsString::from("--recount"));
|
||||||
|
args.push(OsString::from("--whitespace=nowarn"));
|
||||||
|
args.push(patch_path.as_os_str().to_os_string());
|
||||||
|
|
||||||
|
run_git(repo, args).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
|
fn write_askpass_script() -> Result<std::path::PathBuf, String> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
@@ -2434,6 +2780,50 @@ where
|
|||||||
Err(format!("{context}: {details}"))
|
Err(format!("{context}: {details}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_git_with_stdin<I, S>(repo: &Path, args: I, stdin_data: &[u8]) -> Result<Vec<u8>, String>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: AsRef<OsStr>,
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let mut child = git_command()
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?;
|
||||||
|
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
stdin
|
||||||
|
.write_all(stdin_data)
|
||||||
|
.map_err(|err| format!("Eingabe konnte nicht an Git gesendet werden: {err}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = child
|
||||||
|
.wait_with_output()
|
||||||
|
.map_err(|err| format!("Git-Ausgabe konnte nicht gelesen werden: {err}"))?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
return Ok(output.stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let details = if !stderr.trim().is_empty() {
|
||||||
|
stderr.trim()
|
||||||
|
} else if !stdout.trim().is_empty() {
|
||||||
|
stdout.trim()
|
||||||
|
} else {
|
||||||
|
"unbekannter Fehler"
|
||||||
|
};
|
||||||
|
|
||||||
|
Err(format!("Git-Befehl fehlgeschlagen: {details}"))
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_status_output(output: &[u8]) -> Result<(BranchInfo, Vec<GitFileStatus>), String> {
|
fn parse_status_output(output: &[u8]) -> Result<(BranchInfo, Vec<GitFileStatus>), String> {
|
||||||
let entries: Vec<&[u8]> = output
|
let entries: Vec<&[u8]> = output
|
||||||
.split(|byte| *byte == 0)
|
.split(|byte| *byte == 0)
|
||||||
@@ -3388,6 +3778,140 @@ mod tests {
|
|||||||
assert!(err.contains("existiert bereits"));
|
assert!(err.contains("existiert bereits"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_branch_renames_existing_local_branch() {
|
||||||
|
let repo = init_temp_repo("rename_branch");
|
||||||
|
commit_initial_file(&repo.path);
|
||||||
|
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||||
|
run_git_test(&repo.path, ["branch", "feature/old-panel"]);
|
||||||
|
|
||||||
|
let status = rename_branch(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
"feature/old-panel".to_string(),
|
||||||
|
"feature/new-panel".to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(status.current_branch.as_deref(), Some(current.as_str()));
|
||||||
|
assert!(
|
||||||
|
!ref_exists(&repo.path, "refs/heads/feature/old-panel").unwrap(),
|
||||||
|
"old branch should be gone"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ref_exists(&repo.path, "refs/heads/feature/new-panel").unwrap(),
|
||||||
|
"new branch should exist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_branch_removes_local_branch_but_rejects_current_branch() {
|
||||||
|
let repo = init_temp_repo("delete_branch");
|
||||||
|
commit_initial_file(&repo.path);
|
||||||
|
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||||
|
run_git_test(&repo.path, ["branch", "stale"]);
|
||||||
|
|
||||||
|
let status =
|
||||||
|
delete_branch(repo.path.to_string_lossy().to_string(), "stale".to_string()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(status.current_branch.as_deref(), Some(current.as_str()));
|
||||||
|
assert!(
|
||||||
|
!ref_exists(&repo.path, "refs/heads/stale").unwrap(),
|
||||||
|
"deleted branch should be gone"
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = delete_branch(repo.path.to_string_lossy().to_string(), current).unwrap_err();
|
||||||
|
assert!(err.contains("aktuelle Branch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_file_patch_stages_and_discards_selected_changes() {
|
||||||
|
let repo = init_temp_repo("apply_file_patch");
|
||||||
|
fs::write(repo.path.join("old.txt"), "one\ntwo\nthree\n")
|
||||||
|
.expect("initial file should be written");
|
||||||
|
run_git_test(&repo.path, ["add", "old.txt"]);
|
||||||
|
run_git_test(&repo.path, ["commit", "-q", "-m", "init"]);
|
||||||
|
|
||||||
|
fs::write(repo.path.join("old.txt"), "one\nTWO\nthree\nfour\n")
|
||||||
|
.expect("changed file should be written");
|
||||||
|
|
||||||
|
let selected_patch = "diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n";
|
||||||
|
let status = apply_file_patch(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
"old.txt".to_string(),
|
||||||
|
selected_patch.to_string(),
|
||||||
|
"stage".to_string(),
|
||||||
|
)
|
||||||
|
.expect("selected line should stage");
|
||||||
|
|
||||||
|
assert_eq!(status.files[0].staged, Some(FileStatusKind::Modified));
|
||||||
|
assert_eq!(status.files[0].unstaged, Some(FileStatusKind::Modified));
|
||||||
|
assert_eq!(
|
||||||
|
git_output_test(&repo.path, ["show", ":old.txt"]),
|
||||||
|
"one\nTWO\nthree"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(repo.path.join("old.txt"))
|
||||||
|
.expect("working tree should be readable")
|
||||||
|
.replace("\r\n", "\n"),
|
||||||
|
"one\nTWO\nthree\nfour\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let unstaged_patch = get_file_patch(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
"old.txt".to_string(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("unstaged patch should load");
|
||||||
|
assert!(unstaged_patch.contains("+four"));
|
||||||
|
|
||||||
|
let status = apply_file_patch(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
"old.txt".to_string(),
|
||||||
|
unstaged_patch,
|
||||||
|
"discard-unstaged".to_string(),
|
||||||
|
)
|
||||||
|
.expect("unstaged line should discard");
|
||||||
|
|
||||||
|
assert_eq!(status.files[0].staged, Some(FileStatusKind::Modified));
|
||||||
|
assert_eq!(status.files[0].unstaged, None);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(repo.path.join("old.txt"))
|
||||||
|
.expect("working tree should be readable")
|
||||||
|
.replace("\r\n", "\n"),
|
||||||
|
"one\nTWO\nthree\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_file_patch_splits_distant_changes_like_interactive_diff() {
|
||||||
|
let repo = init_temp_repo("file_patch_hunks");
|
||||||
|
let original = (1..=30)
|
||||||
|
.map(|line| format!("line {line}\n"))
|
||||||
|
.collect::<String>();
|
||||||
|
fs::write(repo.path.join("old.txt"), original).expect("initial file should be written");
|
||||||
|
run_git_test(&repo.path, ["add", "old.txt"]);
|
||||||
|
run_git_test(&repo.path, ["commit", "-q", "-m", "init"]);
|
||||||
|
|
||||||
|
let changed = (1..=30)
|
||||||
|
.map(|line| match line {
|
||||||
|
5 => "line five changed\n".to_string(),
|
||||||
|
20 => "line twenty changed\n".to_string(),
|
||||||
|
_ => format!("line {line}\n"),
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
fs::write(repo.path.join("old.txt"), changed).expect("changed file should be written");
|
||||||
|
|
||||||
|
let patch = get_file_patch(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
"old.txt".to_string(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("patch should load");
|
||||||
|
let hunk_count = patch.lines().filter(|line| line.starts_with("@@ ")).count();
|
||||||
|
|
||||||
|
assert_eq!(hunk_count, 2, "{patch}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restore_to_commit_resets_branch_to_selected_commit() {
|
fn restore_to_commit_resets_branch_to_selected_commit() {
|
||||||
let repo = init_temp_repo("restore_to_commit");
|
let repo = init_temp_repo("restore_to_commit");
|
||||||
|
|||||||
+11
-6
@@ -3,12 +3,12 @@
|
|||||||
mod git;
|
mod git;
|
||||||
|
|
||||||
use git::{
|
use git::{
|
||||||
cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head,
|
apply_file_patch, cancel_code_search, checkout_branch, commit, compare_commits,
|
||||||
compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save,
|
compare_file_to_head, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save,
|
||||||
diff_file_against_working_tree, get_remote_url, get_status, list_branches, list_commits,
|
delete_branch, diff_file_against_working_tree, get_file_patch, get_remote_url, get_status,
|
||||||
list_file_history, list_repository_files, merge_branch, open_repository,
|
list_branches, list_commits, list_file_history, list_repository_files, merge_branch,
|
||||||
open_repository_bundle, pull, push,
|
open_repo_in_explorer, open_repository, open_repository_bundle, pull, push, read_conflict,
|
||||||
read_conflict, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
||||||
restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
||||||
SearchCancellationState,
|
SearchCancellationState,
|
||||||
};
|
};
|
||||||
@@ -20,13 +20,18 @@ 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,
|
||||||
|
open_repo_in_explorer,
|
||||||
get_status,
|
get_status,
|
||||||
list_branches,
|
list_branches,
|
||||||
checkout_branch,
|
checkout_branch,
|
||||||
create_branch,
|
create_branch,
|
||||||
|
rename_branch,
|
||||||
|
delete_branch,
|
||||||
stage_files,
|
stage_files,
|
||||||
unstage_files,
|
unstage_files,
|
||||||
restore_files,
|
restore_files,
|
||||||
|
get_file_patch,
|
||||||
|
apply_file_patch,
|
||||||
commit,
|
commit,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
|
|||||||
@@ -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.1",
|
"version": "2026.7.3",
|
||||||
"identifier": "com.git-lite",
|
"identifier": "com.git-lite",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+576
-57
@@ -2,7 +2,7 @@
|
|||||||
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, Check, FolderOpen, GitBranch, GitMerge, LoaderCircle } from "@lucide/svelte";
|
import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
|
||||||
|
|
||||||
import TitleBar from "./lib/TitleBar.svelte";
|
import TitleBar from "./lib/TitleBar.svelte";
|
||||||
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
|
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
|
||||||
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
|
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
|
||||||
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
||||||
|
import LinePatchDialog from "./lib/components/LinePatchDialog.svelte";
|
||||||
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
|
import NewBranchDialog from "./lib/components/NewBranchDialog.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 StatusPanel from "./lib/components/StatusPanel.svelte";
|
import StatusPanel from "./lib/components/StatusPanel.svelte";
|
||||||
@@ -25,7 +27,9 @@
|
|||||||
commit,
|
commit,
|
||||||
compareCommits,
|
compareCommits,
|
||||||
cancelCodeSearch,
|
cancelCodeSearch,
|
||||||
|
applyFilePatch,
|
||||||
createBranch,
|
createBranch,
|
||||||
|
deleteBranch,
|
||||||
diffFileAgainstWorkingTree,
|
diffFileAgainstWorkingTree,
|
||||||
compareFileToParent,
|
compareFileToParent,
|
||||||
getStatus,
|
getStatus,
|
||||||
@@ -34,13 +38,16 @@
|
|||||||
listFileHistory,
|
listFileHistory,
|
||||||
listRepositoryFiles,
|
listRepositoryFiles,
|
||||||
mergeBranch,
|
mergeBranch,
|
||||||
|
openRepoInExplorer,
|
||||||
openRepositoryBundle,
|
openRepositoryBundle,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
|
renameBranch,
|
||||||
getRemoteUrl,
|
getRemoteUrl,
|
||||||
credLoad,
|
credLoad,
|
||||||
credSave,
|
credSave,
|
||||||
credDelete,
|
credDelete,
|
||||||
|
getFilePatch,
|
||||||
readConflict,
|
readConflict,
|
||||||
resolveConflict,
|
resolveConflict,
|
||||||
resolveConflictSide,
|
resolveConflictSide,
|
||||||
@@ -65,6 +72,7 @@
|
|||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
|
PatchApplyAction,
|
||||||
PreparedResolution,
|
PreparedResolution,
|
||||||
StoredCredential,
|
StoredCredential,
|
||||||
} from "./lib/types";
|
} from "./lib/types";
|
||||||
@@ -77,11 +85,29 @@
|
|||||||
} from "./lib/credentials";
|
} from "./lib/credentials";
|
||||||
|
|
||||||
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
||||||
|
type AppView = "management" | "repository";
|
||||||
|
|
||||||
|
interface RepoTab {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
branch: string | null;
|
||||||
|
ahead: number;
|
||||||
|
behind: number;
|
||||||
|
changed: number;
|
||||||
|
lastOpened: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
|
||||||
|
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let repoPath = "";
|
let repoPath = "";
|
||||||
let activeRepoPath = "";
|
let activeRepoPath = "";
|
||||||
|
let activeView: AppView = "management";
|
||||||
|
let repoTabs: RepoTab[] = [];
|
||||||
|
let recentRepoPaths: string[] = [];
|
||||||
|
let repoSearch = "";
|
||||||
let status: GitStatus | null = null;
|
let status: GitStatus | null = null;
|
||||||
let branches: GitBranchInfo[] = [];
|
let branches: GitBranchInfo[] = [];
|
||||||
let commits: GitCommit[] = [];
|
let commits: GitCommit[] = [];
|
||||||
@@ -91,6 +117,8 @@
|
|||||||
let expandedExplorerPaths = new Set<string>();
|
let expandedExplorerPaths = new Set<string>();
|
||||||
let expandedCommitHashes = new Set<string>();
|
let expandedCommitHashes = new Set<string>();
|
||||||
let fileHistory: GitCommit[] = [];
|
let fileHistory: GitCommit[] = [];
|
||||||
|
let fileHistoryLoading = false;
|
||||||
|
let fileHistoryRequestId = 0;
|
||||||
let commitMessage = "";
|
let commitMessage = "";
|
||||||
let errorMessage = "";
|
let errorMessage = "";
|
||||||
let operation = "";
|
let operation = "";
|
||||||
@@ -98,11 +126,18 @@
|
|||||||
let compareTo = "";
|
let compareTo = "";
|
||||||
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 compareSelectOpen = false;
|
let compareSelectOpen = false;
|
||||||
let compareDialogOpen = false;
|
let compareDialogOpen = false;
|
||||||
let selectedDiffPath = "";
|
let selectedDiffPath = "";
|
||||||
let diffHighlightQuery = "";
|
let diffHighlightQuery = "";
|
||||||
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
|
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
|
||||||
|
let linePatchOpen = false;
|
||||||
|
let linePatchFile: GitFileStatus | null = null;
|
||||||
|
let linePatchStaged = false;
|
||||||
|
let linePatchText = "";
|
||||||
|
let linePatchLoading = false;
|
||||||
|
let linePatchError = "";
|
||||||
let globalSearchOpen = false;
|
let globalSearchOpen = false;
|
||||||
let lastSearchQuery = "";
|
let lastSearchQuery = "";
|
||||||
let globalSearchResults: GitSearchHit[] = [];
|
let globalSearchResults: GitSearchHit[] = [];
|
||||||
@@ -137,6 +172,7 @@
|
|||||||
|
|
||||||
$: isBusy = operation.length > 0;
|
$: isBusy = operation.length > 0;
|
||||||
$: hasRepository = activeRepoPath.length > 0 && status !== null;
|
$: hasRepository = activeRepoPath.length > 0 && status !== null;
|
||||||
|
$: workspaceActive = activeView === "repository" && hasRepository;
|
||||||
$: openingRepo = operation === "Opening repository";
|
$: openingRepo = operation === "Opening repository";
|
||||||
$: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
|
$: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
|
||||||
$: changedFiles = status?.files ?? [];
|
$: changedFiles = status?.files ?? [];
|
||||||
@@ -151,10 +187,20 @@
|
|||||||
$: 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);
|
||||||
$: remoteBranches = branches.filter((b) => b.remote);
|
$: remoteBranches = branches.filter((b) => b.remote);
|
||||||
|
$: repoSearchTerm = repoSearch.trim().toLowerCase();
|
||||||
|
$: openRepoRows = repoTabs.filter(repoMatchesSearch);
|
||||||
|
$: recentRepoRows = recentRepoPaths
|
||||||
|
.filter((path) => !repoTabs.some((tab) => sameRepoPath(tab.path, path)))
|
||||||
|
.map(repoRowFromPath)
|
||||||
|
.filter(repoMatchesSearch);
|
||||||
|
$: allRepoRows = uniqueRepoPaths([...repoTabs.map((tab) => tab.path), ...recentRepoPaths])
|
||||||
|
.map(repoRowFromPath)
|
||||||
|
.filter(repoMatchesSearch);
|
||||||
|
|
||||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
loadRepoLists();
|
||||||
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
|
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
|
||||||
void checkForUpdates();
|
void checkForUpdates();
|
||||||
});
|
});
|
||||||
@@ -170,7 +216,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function autoRefreshTick() {
|
async function autoRefreshTick() {
|
||||||
if (!autoRefreshEnabled || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
|
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
|
||||||
autoRefreshInFlight = true;
|
autoRefreshInFlight = true;
|
||||||
try {
|
try {
|
||||||
// Cheap fast path: only fetch status; skip the heavy reload if nothing changed.
|
// Cheap fast path: only fetch status; skip the heavy reload if nothing changed.
|
||||||
@@ -267,11 +313,148 @@
|
|||||||
|
|
||||||
// ── Utilities ──────────────────────────────────────────────────────────────
|
// ── Utilities ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function repoNameFromPath(path: string): string {
|
||||||
|
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repoKey(path: string): string {
|
||||||
|
return path.replace(/\\/g, "/").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameRepoPath(left: string, right: string): boolean {
|
||||||
|
return repoKey(left) === repoKey(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueRepoPaths(paths: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const path of paths) {
|
||||||
|
const trimmed = path.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const key = repoKey(trimmed);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push(trimmed);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repoRowFromPath(path: string): RepoTab {
|
||||||
|
return repoTabs.find((tab) => sameRepoPath(tab.path, path)) ?? {
|
||||||
|
path,
|
||||||
|
name: repoNameFromPath(path),
|
||||||
|
branch: null,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
changed: 0,
|
||||||
|
lastOpened: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function repoMatchesSearch(repo: RepoTab): boolean {
|
||||||
|
if (!repoSearchTerm) return true;
|
||||||
|
return repo.name.toLowerCase().includes(repoSearchTerm)
|
||||||
|
|| repo.path.toLowerCase().includes(repoSearchTerm)
|
||||||
|
|| (repo.branch ?? "").toLowerCase().includes(repoSearchTerm);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRepoLists() {
|
||||||
|
try {
|
||||||
|
const openValue = JSON.parse(localStorage.getItem(OPEN_REPOS_KEY) ?? "[]") as unknown;
|
||||||
|
const recentValue = JSON.parse(localStorage.getItem(RECENT_REPOS_KEY) ?? "[]") as unknown;
|
||||||
|
const openPaths = Array.isArray(openValue)
|
||||||
|
? openValue.map((item) => typeof item === "string" ? item : "").filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const recentPaths = Array.isArray(recentValue)
|
||||||
|
? recentValue.map((item) => typeof item === "string" ? item : "").filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
repoTabs = uniqueRepoPaths(openPaths).map((path) => ({
|
||||||
|
path,
|
||||||
|
name: repoNameFromPath(path),
|
||||||
|
branch: null,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
changed: 0,
|
||||||
|
lastOpened: 0,
|
||||||
|
}));
|
||||||
|
recentRepoPaths = uniqueRepoPaths([...recentPaths, ...openPaths]);
|
||||||
|
} catch {
|
||||||
|
repoTabs = [];
|
||||||
|
recentRepoPaths = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistRepoLists() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(OPEN_REPOS_KEY, JSON.stringify(repoTabs.map((tab) => tab.path)));
|
||||||
|
localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(recentRepoPaths));
|
||||||
|
} catch {
|
||||||
|
// Local storage is best-effort only; the Git workflow must keep working without it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberRecentRepo(path: string) {
|
||||||
|
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
|
||||||
|
persistRepoLists();
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertRepoTab(path: string, nextStatus?: GitStatus | null) {
|
||||||
|
const existing = repoTabs.find((tab) => sameRepoPath(tab.path, path));
|
||||||
|
const next: RepoTab = {
|
||||||
|
path,
|
||||||
|
name: repoNameFromPath(path),
|
||||||
|
branch: nextStatus?.current_branch ?? existing?.branch ?? null,
|
||||||
|
ahead: nextStatus?.ahead ?? existing?.ahead ?? 0,
|
||||||
|
behind: nextStatus?.behind ?? existing?.behind ?? 0,
|
||||||
|
changed: nextStatus?.files.length ?? existing?.changed ?? 0,
|
||||||
|
lastOpened: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
repoTabs = existing
|
||||||
|
? repoTabs.map((tab) => sameRepoPath(tab.path, path) ? next : tab)
|
||||||
|
: [...repoTabs, next];
|
||||||
|
rememberRecentRepo(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRepositoryState(clearActive = false) {
|
||||||
|
if (clearActive) {
|
||||||
|
activeRepoPath = "";
|
||||||
|
repoPath = "";
|
||||||
|
status = null;
|
||||||
|
lastStatusFingerprint = "";
|
||||||
|
}
|
||||||
|
branches = [];
|
||||||
|
commits = [];
|
||||||
|
repoFiles = [];
|
||||||
|
selectedExplorerPath = "";
|
||||||
|
selectedExplorerKind = "file";
|
||||||
|
expandedExplorerPaths = new Set();
|
||||||
|
expandedCommitHashes = new Set();
|
||||||
|
fileHistory = [];
|
||||||
|
compareFrom = "";
|
||||||
|
compareTo = "";
|
||||||
|
comparison = null;
|
||||||
|
compareSelectOpen = false;
|
||||||
|
compareDialogOpen = false;
|
||||||
|
selectedDiffPath = "";
|
||||||
|
pendingRestoreFile = null;
|
||||||
|
newBranchCommit = null;
|
||||||
|
globalSearchResults = [];
|
||||||
|
globalSearchOpen = false;
|
||||||
|
globalSearchError = "";
|
||||||
|
resolveDialogOpen = false;
|
||||||
|
conflictTarget = "";
|
||||||
|
conflict = null;
|
||||||
|
preparedResolutions = {};
|
||||||
|
}
|
||||||
|
|
||||||
function applyStatus(nextStatus: GitStatus) {
|
function applyStatus(nextStatus: GitStatus) {
|
||||||
status = nextStatus;
|
status = nextStatus;
|
||||||
activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim();
|
activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim();
|
||||||
repoPath = activeRepoPath;
|
repoPath = activeRepoPath;
|
||||||
lastStatusFingerprint = statusFingerprint(nextStatus);
|
lastStatusFingerprint = statusFingerprint(nextStatus);
|
||||||
|
upsertRepoTab(activeRepoPath, nextStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorToMessage(error: unknown): string {
|
function errorToMessage(error: unknown): string {
|
||||||
@@ -356,7 +539,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
|
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
|
||||||
fileHistory = file ? await listFileHistory(path, file, 100) : [];
|
const requestId = ++fileHistoryRequestId;
|
||||||
|
const history = file ? await listFileHistory(path, file, 100) : [];
|
||||||
|
if (requestId === fileHistoryRequestId) fileHistory = history;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Repository operations ──────────────────────────────────────────────────
|
// ── Repository operations ──────────────────────────────────────────────────
|
||||||
@@ -375,17 +560,10 @@
|
|||||||
// Single backend round-trip: resolves the repo and reads status, branches,
|
// Single backend round-trip: resolves the repo and reads status, branches,
|
||||||
// commits and files in one pass instead of four sequential git calls.
|
// commits and files in one pass instead of four sequential git calls.
|
||||||
const bundle = await openRepositoryBundle(path, 100);
|
const bundle = await openRepositoryBundle(path, 100);
|
||||||
|
resetRepositoryState(false);
|
||||||
applyStatus(bundle.status);
|
applyStatus(bundle.status);
|
||||||
branches = []; commits = []; repoFiles = [];
|
|
||||||
selectedExplorerPath = ""; selectedExplorerKind = "file";
|
|
||||||
expandedExplorerPaths = new Set(); expandedCommitHashes = new Set();
|
|
||||||
fileHistory = []; compareFrom = ""; compareTo = "";
|
|
||||||
comparison = null; compareSelectOpen = false; compareDialogOpen = false; selectedDiffPath = ""; pendingRestoreFile = null;
|
|
||||||
newBranchCommit = null;
|
|
||||||
if (globalSearchBusy) void cancelGlobalSearch();
|
if (globalSearchBusy) void cancelGlobalSearch();
|
||||||
globalSearchResults = []; globalSearchOpen = false; globalSearchError = "";
|
activeView = "repository";
|
||||||
resolveDialogOpen = false; conflictTarget = ""; conflict = null;
|
|
||||||
preparedResolutions = {};
|
|
||||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||||
@@ -409,6 +587,55 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openRepoManagement() {
|
||||||
|
if (isBusy) return;
|
||||||
|
activeView = "management";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectRepoTab(path: string) {
|
||||||
|
if (isBusy) return;
|
||||||
|
if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return;
|
||||||
|
await openRepo(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeRepoTab(path: string, event?: MouseEvent) {
|
||||||
|
event?.stopPropagation();
|
||||||
|
if (isBusy) return;
|
||||||
|
|
||||||
|
const index = repoTabs.findIndex((tab) => sameRepoPath(tab.path, path));
|
||||||
|
const remaining = repoTabs.filter((tab) => !sameRepoPath(tab.path, path));
|
||||||
|
const next = remaining[index] ?? remaining[index - 1] ?? null;
|
||||||
|
repoTabs = remaining;
|
||||||
|
persistRepoLists();
|
||||||
|
|
||||||
|
if (!sameRepoPath(activeRepoPath, path)) return;
|
||||||
|
if (next) {
|
||||||
|
await openRepo(next.path);
|
||||||
|
} else {
|
||||||
|
resetRepositoryState(true);
|
||||||
|
activeView = "management";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRepoFromManagement(path: string, event?: MouseEvent) {
|
||||||
|
event?.stopPropagation();
|
||||||
|
if (isBusy) return;
|
||||||
|
recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path));
|
||||||
|
persistRepoLists();
|
||||||
|
if (repoTabs.some((tab) => sameRepoPath(tab.path, path))) {
|
||||||
|
await closeRepoTab(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openActiveRepoInExplorer() {
|
||||||
|
if (!activeRepoPath || isBusy) return;
|
||||||
|
try {
|
||||||
|
await openRepoInExplorer(activeRepoPath);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = errorToMessage(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshRepo() {
|
async function refreshRepo() {
|
||||||
if (!activeRepoPath) { await openRepo(); return; }
|
if (!activeRepoPath) { await openRepo(); return; }
|
||||||
await runOperation("Refreshing", async () => {
|
await runOperation("Refreshing", async () => {
|
||||||
@@ -443,6 +670,45 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renameLocalBranch(branch: GitBranchInfo) {
|
||||||
|
if (!activeRepoPath || branch.remote) return;
|
||||||
|
renameBranchTarget = branch;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRenameBranch(branchName: string) {
|
||||||
|
const branch = renameBranchTarget;
|
||||||
|
const name = branchName.trim();
|
||||||
|
if (!activeRepoPath || !branch || branch.remote || !name || name === branch.name) return;
|
||||||
|
|
||||||
|
await runOperation(`Renaming ${branch.name}`, async () => {
|
||||||
|
applyStatus(await renameBranch(activeRepoPath, branch.name, name));
|
||||||
|
renameBranchTarget = null;
|
||||||
|
await refreshBranchList(activeRepoPath);
|
||||||
|
await refreshCommitHistory(activeRepoPath);
|
||||||
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
|
await refreshFileHistory(activeRepoPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteLocalBranch(branch: GitBranchInfo) {
|
||||||
|
if (!activeRepoPath || branch.remote) return;
|
||||||
|
if (branch.current) {
|
||||||
|
errorMessage = "The current branch cannot be deleted.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = window.confirm(`Delete local branch "${branch.name}"?\n\nGit will refuse if the branch has unmerged changes.`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
await runOperation(`Deleting ${branch.name}`, async () => {
|
||||||
|
applyStatus(await deleteBranch(activeRepoPath, branch.name));
|
||||||
|
await refreshBranchList(activeRepoPath);
|
||||||
|
await refreshCommitHistory(activeRepoPath);
|
||||||
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
|
await refreshFileHistory(activeRepoPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function openNewBranchDialog(commit: GitCommit) {
|
function openNewBranchDialog(commit: GitCommit) {
|
||||||
if (!activeRepoPath || isBusy) return;
|
if (!activeRepoPath || isBusy) return;
|
||||||
newBranchCommit = commit;
|
newBranchCommit = commit;
|
||||||
@@ -675,6 +941,77 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openLinePatch(file: GitFileStatus, staged: boolean) {
|
||||||
|
if (!activeRepoPath) return;
|
||||||
|
linePatchOpen = true;
|
||||||
|
linePatchFile = file;
|
||||||
|
linePatchStaged = staged;
|
||||||
|
linePatchText = "";
|
||||||
|
linePatchError = "";
|
||||||
|
linePatchLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
linePatchText = await getFilePatch(activeRepoPath, file.path, staged);
|
||||||
|
} catch (error) {
|
||||||
|
linePatchError = errorToMessage(error);
|
||||||
|
errorMessage = linePatchError;
|
||||||
|
} finally {
|
||||||
|
linePatchLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLinePatch() {
|
||||||
|
if (!activeRepoPath || !linePatchFile) return;
|
||||||
|
await openLinePatch(linePatchFile, linePatchStaged);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLinePatch() {
|
||||||
|
if (isBusy) return;
|
||||||
|
linePatchOpen = false;
|
||||||
|
linePatchFile = null;
|
||||||
|
linePatchText = "";
|
||||||
|
linePatchError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchOperationLabel(action: PatchApplyAction, file: GitFileStatus): string {
|
||||||
|
switch (action) {
|
||||||
|
case "stage":
|
||||||
|
return `Staging hunk in ${file.path}`;
|
||||||
|
case "unstage":
|
||||||
|
return `Unstaging hunk in ${file.path}`;
|
||||||
|
default:
|
||||||
|
return `Discarding hunk in ${file.path}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyLinePatch(action: PatchApplyAction, patch: string) {
|
||||||
|
if (!activeRepoPath || !linePatchFile || isBusy) return;
|
||||||
|
const file = linePatchFile;
|
||||||
|
operation = patchOperationLabel(action, file);
|
||||||
|
errorMessage = "";
|
||||||
|
linePatchError = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
applyStatus(await applyFilePatch(activeRepoPath, file.path, patch, action));
|
||||||
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
|
await refreshFileHistory(activeRepoPath);
|
||||||
|
|
||||||
|
const updatedPatch = await getFilePatch(activeRepoPath, file.path, linePatchStaged);
|
||||||
|
if (updatedPatch.trim()) {
|
||||||
|
linePatchText = updatedPatch;
|
||||||
|
} else {
|
||||||
|
linePatchOpen = false;
|
||||||
|
linePatchFile = null;
|
||||||
|
linePatchText = "";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
linePatchError = errorToMessage(error);
|
||||||
|
errorMessage = linePatchError;
|
||||||
|
} finally {
|
||||||
|
operation = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function stageAllFiles() {
|
async function stageAllFiles() {
|
||||||
const paths = changedFiles.filter((f) => f.unstaged !== null).map((f) => f.path);
|
const paths = changedFiles.filter((f) => f.unstaged !== null).map((f) => f.path);
|
||||||
if (paths.length === 0) return;
|
if (paths.length === 0) return;
|
||||||
@@ -779,13 +1116,30 @@
|
|||||||
return folders;
|
return folders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loads history for a selected explorer node without blocking the rest of the UI
|
||||||
|
// (isBusy/runOperation would disable every button in the app while this awaits).
|
||||||
|
// A request id guards against a slower, stale request overwriting a newer selection.
|
||||||
|
async function loadSelectedFileHistory(path: string, repo = activeRepoPath) {
|
||||||
|
const requestId = ++fileHistoryRequestId;
|
||||||
|
fileHistoryLoading = true;
|
||||||
|
try {
|
||||||
|
const history = await listFileHistory(repo, path, 100);
|
||||||
|
if (requestId === fileHistoryRequestId) fileHistory = history;
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId === fileHistoryRequestId) {
|
||||||
|
fileHistory = [];
|
||||||
|
errorMessage = errorToMessage(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === fileHistoryRequestId) fileHistoryLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function selectExplorerNode(node: ExplorerNode) {
|
async function selectExplorerNode(node: ExplorerNode) {
|
||||||
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
|
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
|
||||||
selectedExplorerPath = node.path;
|
selectedExplorerPath = node.path;
|
||||||
selectedExplorerKind = node.kind;
|
selectedExplorerKind = node.kind;
|
||||||
await runOperation(`Loading ${node.path} history`, async () => {
|
await loadSelectedFileHistory(node.path);
|
||||||
await refreshFileHistory(activeRepoPath, node.path);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectFileFromSearch(file: GitRepositoryFile) {
|
async function selectFileFromSearch(file: GitRepositoryFile) {
|
||||||
@@ -794,9 +1148,7 @@
|
|||||||
selectedExplorerKind = "file";
|
selectedExplorerKind = "file";
|
||||||
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
||||||
|
|
||||||
await runOperation(`Loading ${file.path} history`, async () => {
|
await loadSelectedFileHistory(file.path);
|
||||||
await refreshFileHistory(activeRepoPath, file.path);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restoreSelectedFileFromCommit(target: GitCommit) {
|
async function restoreSelectedFileFromCommit(target: GitCommit) {
|
||||||
@@ -978,21 +1330,24 @@
|
|||||||
|
|
||||||
// ── Event handlers ─────────────────────────────────────────────────────────
|
// ── Event handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function submitRepo(event: SubmitEvent) { event.preventDefault(); void openRepo(); }
|
|
||||||
|
|
||||||
function handleWindowKeydown(event: KeyboardEvent) {
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
|
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" && 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleWindowContextMenu(event: MouseEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>GitLite</title>
|
<title>GitLite</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<svelte:window on:keydown={handleWindowKeydown} />
|
<svelte:window on:keydown={handleWindowKeydown} on:contextmenu={handleWindowContextMenu} />
|
||||||
|
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
<TitleBar
|
<TitleBar
|
||||||
@@ -1000,7 +1355,7 @@
|
|||||||
ahead={status?.ahead ?? 0}
|
ahead={status?.ahead ?? 0}
|
||||||
behind={status?.behind ?? 0}
|
behind={status?.behind ?? 0}
|
||||||
repoName={activeRepoPath ? (activeRepoPath.split(/[\\/]/).filter(Boolean).slice(-1)[0] ?? "") : ""}
|
repoName={activeRepoPath ? (activeRepoPath.split(/[\\/]/).filter(Boolean).slice(-1)[0] ?? "") : ""}
|
||||||
{hasRepository}
|
hasRepository={workspaceActive}
|
||||||
{isBusy}
|
{isBusy}
|
||||||
{operation}
|
{operation}
|
||||||
{autoRefreshEnabled}
|
{autoRefreshEnabled}
|
||||||
@@ -1010,43 +1365,65 @@
|
|||||||
onRefresh={refreshRepo}
|
onRefresh={refreshRepo}
|
||||||
onSearch={() => { globalSearchOpen = true; }}
|
onSearch={() => { globalSearchOpen = true; }}
|
||||||
onCompare={openCompareSelect}
|
onCompare={openCompareSelect}
|
||||||
|
onOpenInExplorer={openActiveRepoInExplorer}
|
||||||
onToggleAutoRefresh={toggleAutoRefresh}
|
onToggleAutoRefresh={toggleAutoRefresh}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="shell-body">
|
<div class="shell-body">
|
||||||
|
|
||||||
<!-- Repository path form -->
|
<header class="repo-tabbar" aria-label="Repository tabs">
|
||||||
<header class="topbar">
|
<button
|
||||||
<form class="repo-form" onsubmit={submitRepo}>
|
class="repo-tab management"
|
||||||
<label for="repo-path">Repository</label>
|
class:active={activeView === "management"}
|
||||||
<input
|
type="button"
|
||||||
id="repo-path"
|
onclick={openRepoManagement}
|
||||||
bind:value={repoPath}
|
disabled={isBusy}
|
||||||
autocomplete="off"
|
title="Repository Management"
|
||||||
spellcheck="false"
|
>
|
||||||
placeholder="/path/to/repository"
|
<BookOpen size={14} aria-hidden="true" />
|
||||||
disabled={isBusy}
|
Repository Management
|
||||||
/>
|
</button>
|
||||||
<button
|
|
||||||
class="btn-secondary repo-browse"
|
<div class="repo-tabs-scroll">
|
||||||
type="button"
|
{#each repoTabs as repo (repo.path)}
|
||||||
onclick={chooseRepositoryFolder}
|
<div class="repo-tab-wrap" class:active={activeView === "repository" && sameRepoPath(activeRepoPath, repo.path)}>
|
||||||
disabled={isBusy}
|
<button
|
||||||
title="Repository-Ordner auswaehlen"
|
class="repo-tab"
|
||||||
aria-label="Repository-Ordner auswaehlen"
|
type="button"
|
||||||
>
|
onclick={() => selectRepoTab(repo.path)}
|
||||||
<FolderOpen size={16} aria-hidden="true" />
|
disabled={isBusy}
|
||||||
Browse
|
title={repo.path}
|
||||||
</button>
|
>
|
||||||
<button class="btn-primary" type="submit" disabled={isBusy || repoPath.trim().length === 0}>
|
<FolderOpen size={14} aria-hidden="true" />
|
||||||
{#if operation === "Opening repository"}
|
<span>{repo.name}</span>
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
{#if repo.branch}
|
||||||
{:else}
|
<strong>{repo.branch}</strong>
|
||||||
<Check size={16} aria-hidden="true" />
|
{/if}
|
||||||
{/if}
|
</button>
|
||||||
Open
|
<button
|
||||||
</button>
|
class="repo-tab-close"
|
||||||
</form>
|
type="button"
|
||||||
|
onclick={(event) => closeRepoTab(repo.path, event)}
|
||||||
|
disabled={isBusy}
|
||||||
|
aria-label={`Close ${repo.name}`}
|
||||||
|
title="Close repository tab"
|
||||||
|
>
|
||||||
|
<X size={13} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="repo-tab-add"
|
||||||
|
type="button"
|
||||||
|
onclick={chooseRepositoryFolder}
|
||||||
|
disabled={isBusy}
|
||||||
|
title="Open repository folder"
|
||||||
|
aria-label="Open repository folder"
|
||||||
|
>
|
||||||
|
<Plus size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Status notices -->
|
<!-- Status notices -->
|
||||||
@@ -1064,7 +1441,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasConflicts}
|
{#if workspaceActive && hasConflicts}
|
||||||
<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"} merge conflicts.</span>
|
||||||
@@ -1072,8 +1449,121 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Workspace -->
|
{#if activeView === "management"}
|
||||||
<section class="workspace" aria-label="Git workspace">
|
<section class="repo-management" aria-label="Repository Management">
|
||||||
|
<div class="repo-management-head">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Repository Management</span>
|
||||||
|
<h1>Repositories</h1>
|
||||||
|
</div>
|
||||||
|
<div class="repo-management-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
|
||||||
|
<FolderOpen size={15} aria-hidden="true" />
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="repo-management-tools">
|
||||||
|
<div class="repo-search">
|
||||||
|
<Search size={15} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
bind:value={repoSearch}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="Search repositories"
|
||||||
|
aria-label="Search repositories"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="repo-sections">
|
||||||
|
<section class="repo-section">
|
||||||
|
<header>
|
||||||
|
<h2>Open repositories</h2>
|
||||||
|
<span>{openRepoRows.length}</span>
|
||||||
|
</header>
|
||||||
|
{#if openRepoRows.length === 0}
|
||||||
|
<div class="repo-empty">No open repositories.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="repo-table">
|
||||||
|
{#each openRepoRows as repo (repo.path)}
|
||||||
|
<div class="repo-row">
|
||||||
|
<button class="repo-row-main" type="button" onclick={() => selectRepoTab(repo.path)} disabled={isBusy}>
|
||||||
|
<span class="repo-row-name">{repo.name}</span>
|
||||||
|
<span class="repo-row-path">{repo.path}</span>
|
||||||
|
<span class="repo-row-meta">
|
||||||
|
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{/if}
|
||||||
|
{#if repo.ahead > 0}<em class="ahead">↑ {repo.ahead}</em>{/if}
|
||||||
|
{#if repo.behind > 0}<em class="behind">↓ {repo.behind}</em>{/if}
|
||||||
|
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button class="repo-row-icon" type="button" onclick={(event) => closeRepoTab(repo.path, event)} disabled={isBusy} title="Close tab" aria-label={`Close ${repo.name}`}>
|
||||||
|
<X size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="repo-section">
|
||||||
|
<header>
|
||||||
|
<h2>Recent repositories</h2>
|
||||||
|
<span>{recentRepoRows.length}</span>
|
||||||
|
</header>
|
||||||
|
{#if recentRepoRows.length === 0}
|
||||||
|
<div class="repo-empty">No recent repositories.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="repo-table">
|
||||||
|
{#each recentRepoRows as repo (repo.path)}
|
||||||
|
<div class="repo-row">
|
||||||
|
<button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}>
|
||||||
|
<span class="repo-row-name">{repo.name}</span>
|
||||||
|
<span class="repo-row-path">{repo.path}</span>
|
||||||
|
<span class="repo-row-meta quiet">recent</span>
|
||||||
|
</button>
|
||||||
|
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}>
|
||||||
|
<X size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="repo-section">
|
||||||
|
<header>
|
||||||
|
<h2>All repositories</h2>
|
||||||
|
<span>{allRepoRows.length}</span>
|
||||||
|
</header>
|
||||||
|
{#if allRepoRows.length === 0}
|
||||||
|
<div class="repo-empty">Browse for a repository to add it here.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="repo-table">
|
||||||
|
{#each allRepoRows as repo (repo.path)}
|
||||||
|
<div class="repo-row">
|
||||||
|
<button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}>
|
||||||
|
<span class="repo-row-name">{repo.name}</span>
|
||||||
|
<span class="repo-row-path">{repo.path}</span>
|
||||||
|
<span class="repo-row-meta">
|
||||||
|
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em>known repo</em>{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove" aria-label={`Remove ${repo.name}`}>
|
||||||
|
<X size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<!-- Workspace -->
|
||||||
|
<section class="workspace" aria-label="Git workspace">
|
||||||
|
|
||||||
<!-- Left sidebar: branches + explorer -->
|
<!-- Left sidebar: branches + explorer -->
|
||||||
<aside class="left-sidebar" aria-label="Repository navigation">
|
<aside class="left-sidebar" aria-label="Repository navigation">
|
||||||
@@ -1086,6 +1576,8 @@
|
|||||||
onCheckout={checkout}
|
onCheckout={checkout}
|
||||||
onMerge={merge}
|
onMerge={merge}
|
||||||
onCreateBranch={createNewBranch}
|
onCreateBranch={createNewBranch}
|
||||||
|
onRenameBranch={renameLocalBranch}
|
||||||
|
onDeleteBranch={deleteLocalBranch}
|
||||||
/>
|
/>
|
||||||
<ExplorerPanel
|
<ExplorerPanel
|
||||||
{repoFiles}
|
{repoFiles}
|
||||||
@@ -1129,6 +1621,7 @@
|
|||||||
onStage={stageFile}
|
onStage={stageFile}
|
||||||
onUnstage={unstageFile}
|
onUnstage={unstageFile}
|
||||||
onDiscard={discardFile}
|
onDiscard={discardFile}
|
||||||
|
onPatch={openLinePatch}
|
||||||
onStageAll={stageAllFiles}
|
onStageAll={stageAllFiles}
|
||||||
onUnstageAll={unstageAllFiles}
|
onUnstageAll={unstageAllFiles}
|
||||||
/>
|
/>
|
||||||
@@ -1168,11 +1661,13 @@
|
|||||||
selectedExplorerLabel={selectedExplorerPath ? `${selectedExplorerKind === "folder" ? "Folder" : "File"} history` : "File history"}
|
selectedExplorerLabel={selectedExplorerPath ? `${selectedExplorerKind === "folder" ? "Folder" : "File"} history` : "File history"}
|
||||||
{hasRepository}
|
{hasRepository}
|
||||||
{isBusy}
|
{isBusy}
|
||||||
|
isLoading={fileHistoryLoading}
|
||||||
onDiff={diffSelectedFileFromCommit}
|
onDiff={diffSelectedFileFromCommit}
|
||||||
onRestore={restoreSelectedFileFromCommit}
|
onRestore={restoreSelectedFileFromCommit}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -1189,6 +1684,20 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if linePatchOpen && linePatchFile}
|
||||||
|
<LinePatchDialog
|
||||||
|
file={linePatchFile}
|
||||||
|
staged={linePatchStaged}
|
||||||
|
patch={linePatchText}
|
||||||
|
{isBusy}
|
||||||
|
isLoading={linePatchLoading}
|
||||||
|
error={linePatchError}
|
||||||
|
onClose={closeLinePatch}
|
||||||
|
onRefresh={refreshLinePatch}
|
||||||
|
onApply={applyLinePatch}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if globalSearchOpen}
|
{#if globalSearchOpen}
|
||||||
<GlobalSearchDialog
|
<GlobalSearchDialog
|
||||||
{hasRepository}
|
{hasRepository}
|
||||||
@@ -1219,6 +1728,16 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Rename a local branch from the branch context menu -->
|
||||||
|
{#if renameBranchTarget}
|
||||||
|
<RenameBranchDialog
|
||||||
|
branch={renameBranchTarget}
|
||||||
|
{isBusy}
|
||||||
|
onRename={submitRenameBranch}
|
||||||
|
onClose={() => { renameBranchTarget = null; }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Compare: pick the two commits to diff -->
|
<!-- Compare: pick the two commits to diff -->
|
||||||
{#if compareSelectOpen}
|
{#if compareSelectOpen}
|
||||||
<CompareSelectDialog
|
<CompareSelectDialog
|
||||||
|
|||||||
+579
@@ -365,6 +365,271 @@
|
|||||||
.repo-form label { color: var(--color-ink-faint); font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
|
.repo-form label { color: var(--color-ink-faint); font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
|
||||||
.repo-browse { min-width: 104px; }
|
.repo-browse { min-width: 104px; }
|
||||||
|
|
||||||
|
/* --- Repository tabs + management --- */
|
||||||
|
|
||||||
|
.repo-tabbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(16, 17, 29, 0.9);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-tabs-scroll {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-tab-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
border-right: 1px solid var(--color-border-subtle);
|
||||||
|
background: rgba(255,255,255,0.015);
|
||||||
|
}
|
||||||
|
.repo-tab-wrap.active {
|
||||||
|
background: rgba(90, 140, 248, 0.16);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-tab {
|
||||||
|
min-height: 38px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 250px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.repo-tab.management {
|
||||||
|
max-width: none;
|
||||||
|
min-width: 190px;
|
||||||
|
}
|
||||||
|
.repo-tab.active,
|
||||||
|
.repo-tab-wrap.active .repo-tab {
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
.repo-tab span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.repo-tab strong {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 90px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(94, 110, 156, 0.18);
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-tab-close,
|
||||||
|
.repo-tab-add {
|
||||||
|
min-height: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
}
|
||||||
|
.repo-tab-add {
|
||||||
|
border-left: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-management {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(20, 21, 34, 0.82);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-management-head,
|
||||||
|
.repo-management-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
background: var(--color-surface-dim);
|
||||||
|
}
|
||||||
|
.repo-management-head h1 {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.repo-management-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-management-tools {
|
||||||
|
padding: 9px 16px;
|
||||||
|
background: rgba(12, 13, 24, 0.72);
|
||||||
|
}
|
||||||
|
.repo-search {
|
||||||
|
position: relative;
|
||||||
|
width: min(620px, 100%);
|
||||||
|
}
|
||||||
|
.repo-search svg {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
top: 50%;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.repo-search input {
|
||||||
|
padding-left: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-sections {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-section {
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,0.018);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.repo-section + .repo-section { margin-top: 12px; }
|
||||||
|
.repo-section > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
background: rgba(255,255,255,0.045);
|
||||||
|
}
|
||||||
|
.repo-section h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.repo-section > header span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 20px;
|
||||||
|
min-height: 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(94, 110, 156, 0.18);
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-empty {
|
||||||
|
padding: 20px 24px;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-table {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.repo-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 36px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.035);
|
||||||
|
}
|
||||||
|
.repo-row:last-child { border-bottom: 0; }
|
||||||
|
.repo-row-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 220px) minmax(220px, 1fr) minmax(160px, 360px);
|
||||||
|
justify-content: stretch;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 10px 0 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.repo-row-main:hover:not(:disabled) {
|
||||||
|
background: rgba(90, 140, 248, 0.08);
|
||||||
|
}
|
||||||
|
.repo-row-name,
|
||||||
|
.repo-row-path,
|
||||||
|
.repo-row-meta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.repo-row-path {
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.repo-row-meta {
|
||||||
|
gap: 5px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
}
|
||||||
|
.repo-row-meta strong,
|
||||||
|
.repo-row-meta em {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(94, 110, 156, 0.18);
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.repo-row-meta .ahead { color: #4eca76; }
|
||||||
|
.repo-row-meta .behind { color: #e0a040; }
|
||||||
|
.repo-row-icon {
|
||||||
|
min-height: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-left: 1px solid rgba(255,255,255,0.035);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Notices --- */
|
/* --- Notices --- */
|
||||||
|
|
||||||
.notice {
|
.notice {
|
||||||
@@ -721,6 +986,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
.branch-panel { position: relative; }
|
||||||
|
|
||||||
.branch-create-toggle {
|
.branch-create-toggle {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
min-width: 26px;
|
min-width: 26px;
|
||||||
@@ -827,6 +1094,60 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.branch-folder-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: stretch;
|
||||||
|
gap: 7px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
padding-left: calc(8px + var(--branch-indent, 0px));
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.branch-folder-row + .branch-folder-row,
|
||||||
|
.branch-folder-row + .branch-row,
|
||||||
|
.branch-row + .branch-folder-row {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.branch-folder-row:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-border-subtle);
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
.branch-folder-row.current {
|
||||||
|
border-color: rgba(78,202,118,0.2);
|
||||||
|
background: rgba(78,202,118,0.08);
|
||||||
|
}
|
||||||
|
.branch-folder-row svg { color: var(--color-ink-faint); }
|
||||||
|
.branch-folder-row.current svg { color: #4eca76; }
|
||||||
|
.branch-folder-name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.branch-folder-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 20px;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
background: rgba(94,110,156,0.14);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.branch-row {
|
.branch-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -834,6 +1155,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 46px;
|
min-height: 46px;
|
||||||
padding: 7px 8px;
|
padding: 7px 8px;
|
||||||
|
padding-left: calc(8px + var(--branch-indent, 0px));
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: background 120ms, border-color 120ms;
|
transition: background 120ms, border-color 120ms;
|
||||||
@@ -853,6 +1175,57 @@
|
|||||||
|
|
||||||
.branch-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; }
|
.branch-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; }
|
||||||
|
|
||||||
|
.branch-context-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 120;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 184px;
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
box-shadow: 0 18px 50px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-context-menu button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-color: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-context-menu button:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-border-subtle);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-context-menu button.danger {
|
||||||
|
color: #ff9aa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-context-menu button.danger:hover:not(:disabled) {
|
||||||
|
border-color: rgba(255,92,117,0.34);
|
||||||
|
background: rgba(255,92,117,0.12);
|
||||||
|
color: #ffd0d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-context-menu button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Explorer --- */
|
/* --- Explorer --- */
|
||||||
|
|
||||||
.explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
|
.explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
|
||||||
@@ -998,6 +1371,82 @@
|
|||||||
.file-history-actions .commit-action-buttons { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; justify-content: stretch; }
|
.file-history-actions .commit-action-buttons { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; justify-content: stretch; }
|
||||||
.file-history-actions .commit-action-buttons button { min-width: 0; justify-content: center; padding-inline: 6px; }
|
.file-history-actions .commit-action-buttons button { min-width: 0; justify-content: center; padding-inline: 6px; }
|
||||||
|
|
||||||
|
/* --- File history loading (scoped, non-blocking) --- */
|
||||||
|
|
||||||
|
.file-history-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 140px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-history-loading-graph { width: 56px; height: 56px; overflow: visible; }
|
||||||
|
|
||||||
|
.file-history-loading-graph .fhl-ring {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(100, 108, 255, 0.22);
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-dasharray: 26 18;
|
||||||
|
transform-origin: 60px 60px;
|
||||||
|
animation: fhl-ring-spin 5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-history-loading-graph .fhl-trunk,
|
||||||
|
.file-history-loading-graph .fhl-branch {
|
||||||
|
fill: none;
|
||||||
|
stroke: url(#fhl-line);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-history-loading-graph .fhl-branch {
|
||||||
|
stroke-dasharray: 90;
|
||||||
|
stroke-dashoffset: 90;
|
||||||
|
animation: fhl-branch-draw 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-history-loading-graph .fhl-node {
|
||||||
|
fill: var(--color-surface-alt);
|
||||||
|
stroke: url(#fhl-line);
|
||||||
|
stroke-width: 5;
|
||||||
|
animation: fhl-node-pulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.file-history-loading-graph .fhl-n1 { animation-delay: 0s; }
|
||||||
|
.file-history-loading-graph .fhl-n2 { animation-delay: 0.5s; }
|
||||||
|
.file-history-loading-graph .fhl-n3 { animation-delay: 1s; }
|
||||||
|
.file-history-loading-graph .fhl-n4 { animation-delay: 1.5s; }
|
||||||
|
|
||||||
|
.file-history-loading-label {
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fhl-ring-spin { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes fhl-branch-draw {
|
||||||
|
0% { stroke-dashoffset: 90; opacity: 0.35; }
|
||||||
|
45% { stroke-dashoffset: 0; opacity: 1; }
|
||||||
|
100% { stroke-dashoffset: 0; opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes fhl-node-pulse {
|
||||||
|
0%, 100% { fill: var(--color-surface-alt); filter: none; }
|
||||||
|
50% {
|
||||||
|
fill: var(--color-primary);
|
||||||
|
filter: drop-shadow(0 0 6px rgba(100, 108, 255, 0.8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.file-history-loading-graph .fhl-ring,
|
||||||
|
.file-history-loading-graph .fhl-branch,
|
||||||
|
.file-history-loading-graph .fhl-node { animation: none; }
|
||||||
|
.file-history-loading-graph .fhl-branch { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Git graph --- */
|
/* --- Git graph --- */
|
||||||
|
|
||||||
.graph-list { padding: 0; }
|
.graph-list { padding: 0; }
|
||||||
@@ -1094,6 +1543,11 @@
|
|||||||
width: min(1180px, calc(100vw - 32px));
|
width: min(1180px, calc(100vw - 32px));
|
||||||
height: min(840px, calc(100vh - 32px));
|
height: min(840px, calc(100vh - 32px));
|
||||||
}
|
}
|
||||||
|
.line-patch-dialog {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
width: min(1320px, calc(100vw - 32px));
|
||||||
|
height: min(860px, calc(100vh - 32px));
|
||||||
|
}
|
||||||
.compare-select-dialog {
|
.compare-select-dialog {
|
||||||
display: block;
|
display: block;
|
||||||
width: min(720px, calc(100vw - 32px));
|
width: min(720px, calc(100vw - 32px));
|
||||||
@@ -1108,12 +1562,25 @@
|
|||||||
max-height: calc(100vh - 32px);
|
max-height: calc(100vh - 32px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
.rename-branch-dialog {
|
||||||
|
display: block;
|
||||||
|
width: min(520px, calc(100vw - 32px));
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
.new-branch-form {
|
.new-branch-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
.rename-branch-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
.new-branch-target {
|
.new-branch-target {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1311,6 +1778,112 @@
|
|||||||
|
|
||||||
.prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; }
|
.prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; }
|
||||||
|
|
||||||
|
.line-patch-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-patch-scroll {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
background: #0b0b14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-patch-hunk {
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-patch-hunk-head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
background: rgba(20, 22, 36, 0.96);
|
||||||
|
}
|
||||||
|
.line-patch-hunk-head code {
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.line-patch-hunk-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
.line-patch-hunk-button {
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.line-patch-hunk-button:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.line-patch-hunk-button.discard {
|
||||||
|
border-color: rgba(255, 90, 103, 0.7);
|
||||||
|
color: #ffccd1;
|
||||||
|
}
|
||||||
|
.line-patch-hunk-button.stage,
|
||||||
|
.line-patch-hunk-button.unstage {
|
||||||
|
border-color: rgba(78, 202, 118, 0.72);
|
||||||
|
color: #bff1ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-patch-lines {
|
||||||
|
min-width: max-content;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-patch-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px minmax(max-content, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 1px 10px 1px 28px;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
}
|
||||||
|
.line-patch-row.add {
|
||||||
|
background: rgba(78, 202, 118, 0.09);
|
||||||
|
color: #bff1ce;
|
||||||
|
}
|
||||||
|
.line-patch-row.delete {
|
||||||
|
background: rgba(255, 90, 103, 0.1);
|
||||||
|
color: #ffccd1;
|
||||||
|
}
|
||||||
|
.line-patch-row.meta {
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
}
|
||||||
|
.line-patch-prefix {
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.line-patch-row.add .line-patch-prefix { color: #4eca76; }
|
||||||
|
.line-patch-row.delete .line-patch-prefix { color: #ff6b7a; }
|
||||||
|
.line-patch-row code {
|
||||||
|
white-space: pre;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
.global-search-body {
|
.global-search-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto minmax(0, 1fr);
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
@@ -2269,6 +2842,12 @@
|
|||||||
.left-sidebar { grid-template-rows: minmax(200px, 1fr) minmax(240px, 1.2fr); min-height: 440px; }
|
.left-sidebar { grid-template-rows: minmax(200px, 1fr) minmax(240px, 1.2fr); min-height: 440px; }
|
||||||
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 190px; }
|
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 190px; }
|
||||||
.repo-form { grid-template-columns: 1fr; }
|
.repo-form { grid-template-columns: 1fr; }
|
||||||
|
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
|
||||||
|
.repo-tab.management { min-width: 0; }
|
||||||
|
.repo-tabs-scroll { grid-column: 1 / -1; order: 2; border-top: 1px solid var(--color-border-subtle); }
|
||||||
|
.repo-row-main { grid-template-columns: minmax(0, 1fr); gap: 2px; align-content: center; padding-left: 12px; }
|
||||||
|
.repo-row { min-height: 58px; }
|
||||||
|
.repo-row-icon { min-height: 58px; }
|
||||||
.repo-path { display: none; }
|
.repo-path { display: none; }
|
||||||
.repo-summary { height: 40px; }
|
.repo-summary { height: 40px; }
|
||||||
.change-lanes { grid-template-columns: 1fr; }
|
.change-lanes { grid-template-columns: 1fr; }
|
||||||
|
|||||||
+13
-1
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { Download, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
|
import { Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
|
||||||
|
|
||||||
export let branch: string = "";
|
export let branch: string = "";
|
||||||
export let ahead: number = 0;
|
export let ahead: number = 0;
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
export let onRefresh: () => void = () => {};
|
export let onRefresh: () => void = () => {};
|
||||||
export let onSearch: () => void = () => {};
|
export let onSearch: () => void = () => {};
|
||||||
export let onCompare: () => void = () => {};
|
export let onCompare: () => void = () => {};
|
||||||
|
export let onOpenInExplorer: () => void = () => {};
|
||||||
export let onToggleAutoRefresh: () => void = () => {};
|
export let onToggleAutoRefresh: () => void = () => {};
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
const win = getCurrentWindow();
|
||||||
@@ -82,6 +83,17 @@
|
|||||||
<!-- Right: actions + window controls -->
|
<!-- Right: actions + window controls -->
|
||||||
<div class="titlebar-right">
|
<div class="titlebar-right">
|
||||||
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
|
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
|
||||||
|
<button
|
||||||
|
class="tb-action"
|
||||||
|
onclick={onOpenInExplorer}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title="Open repository in Explorer"
|
||||||
|
aria-label="Open repository in Explorer"
|
||||||
|
>
|
||||||
|
<FolderOpen size={14} aria-hidden="true" />
|
||||||
|
<span class="tb-action-label">Explorer</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="tb-action"
|
class="tb-action"
|
||||||
onclick={onSearch}
|
onclick={onSearch}
|
||||||
|
|||||||
@@ -1,7 +1,46 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, ChevronDown, ChevronRight, GitBranch, GitMerge, Plus, X } from "@lucide/svelte";
|
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Trash2, X } from "@lucide/svelte";
|
||||||
import type { GitBranch as GitBranchInfo } from "../types";
|
import type { GitBranch as GitBranchInfo } from "../types";
|
||||||
|
|
||||||
|
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||||
|
|
||||||
|
interface BranchFolderNode {
|
||||||
|
kind: "folder";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
children: BranchTreeNode[];
|
||||||
|
branchCount: number;
|
||||||
|
current: boolean;
|
||||||
|
folders: Map<string, BranchFolderNode>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BranchLeafNode {
|
||||||
|
kind: "branch";
|
||||||
|
id: string;
|
||||||
|
branch: GitBranchInfo;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BranchRow = BranchFolderRow | BranchLeafRow;
|
||||||
|
|
||||||
|
interface BranchFolderRow {
|
||||||
|
kind: "folder";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
depth: number;
|
||||||
|
branchCount: number;
|
||||||
|
current: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BranchLeafRow {
|
||||||
|
kind: "branch";
|
||||||
|
id: string;
|
||||||
|
branch: GitBranchInfo;
|
||||||
|
displayName: string;
|
||||||
|
depth: number;
|
||||||
|
scopeLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
branches: GitBranchInfo[];
|
branches: GitBranchInfo[];
|
||||||
localBranches: GitBranchInfo[];
|
localBranches: GitBranchInfo[];
|
||||||
@@ -11,6 +50,8 @@
|
|||||||
onCheckout: (branch: GitBranchInfo) => void;
|
onCheckout: (branch: GitBranchInfo) => void;
|
||||||
onMerge: (branch: GitBranchInfo) => void;
|
onMerge: (branch: GitBranchInfo) => void;
|
||||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||||
|
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
|
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -22,6 +63,8 @@
|
|||||||
onCheckout = () => {},
|
onCheckout = () => {},
|
||||||
onMerge = () => {},
|
onMerge = () => {},
|
||||||
onCreateBranch = () => {},
|
onCreateBranch = () => {},
|
||||||
|
onRenameBranch = () => {},
|
||||||
|
onDeleteBranch = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let localOpen = $state(true);
|
let localOpen = $state(true);
|
||||||
@@ -29,6 +72,118 @@
|
|||||||
let createOpen = $state(false);
|
let createOpen = $state(false);
|
||||||
let newBranchName = $state("");
|
let newBranchName = $state("");
|
||||||
let createInput = $state<HTMLInputElement | null>(null);
|
let createInput = $state<HTMLInputElement | null>(null);
|
||||||
|
let panelElement = $state<HTMLElement | null>(null);
|
||||||
|
let contextBranch = $state<GitBranchInfo | null>(null);
|
||||||
|
let contextMenuX = $state(0);
|
||||||
|
let contextMenuY = $state(0);
|
||||||
|
let collapsedBranchFolders = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
|
||||||
|
let remoteBranchRows = $derived(buildBranchRows("remote", remoteBranches, "remote"));
|
||||||
|
|
||||||
|
function createFolder(id: string, name: string): BranchFolderNode {
|
||||||
|
return {
|
||||||
|
kind: "folder",
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
children: [],
|
||||||
|
branchCount: 0,
|
||||||
|
current: false,
|
||||||
|
folders: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBranchRows(scope: string, branchList: GitBranchInfo[], scopeLabel: string): BranchRow[] {
|
||||||
|
const root = createFolder(`${scope}:root`, "");
|
||||||
|
|
||||||
|
for (const branch of branchList) {
|
||||||
|
const parts = branch.name.split("/").filter(Boolean);
|
||||||
|
const displayName = parts.length > 0 ? parts[parts.length - 1] : branch.name;
|
||||||
|
const folderParts = parts.slice(0, -1);
|
||||||
|
let parent = root;
|
||||||
|
|
||||||
|
for (let index = 0; index < folderParts.length; index += 1) {
|
||||||
|
const folderName = folderParts[index];
|
||||||
|
const folderPath = folderParts.slice(0, index + 1).join("/");
|
||||||
|
let folder = parent.folders.get(folderName);
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
folder = createFolder(`${scope}:folder:${folderPath}`, folderName);
|
||||||
|
parent.folders.set(folderName, folder);
|
||||||
|
parent.children.push(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
folder.branchCount += 1;
|
||||||
|
folder.current ||= branch.current;
|
||||||
|
parent = folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.children.push({
|
||||||
|
kind: "branch",
|
||||||
|
id: `${scope}:branch:${branch.name}`,
|
||||||
|
branch,
|
||||||
|
displayName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sortBranchNodes(root.children);
|
||||||
|
|
||||||
|
const rows: BranchRow[] = [];
|
||||||
|
flattenBranchNodes(root.children, rows, 0, scopeLabel);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortBranchNodes(nodes: BranchTreeNode[]) {
|
||||||
|
nodes.sort((left, right) => {
|
||||||
|
if (left.kind !== right.kind) return left.kind === "folder" ? -1 : 1;
|
||||||
|
const leftName = left.kind === "folder" ? left.name : left.displayName;
|
||||||
|
const rightName = right.kind === "folder" ? right.name : right.displayName;
|
||||||
|
return leftName.localeCompare(rightName, undefined, { sensitivity: "base" });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.kind === "folder") sortBranchNodes(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenBranchNodes(nodes: BranchTreeNode[], rows: BranchRow[], depth: number, scopeLabel: string) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.kind === "folder") {
|
||||||
|
rows.push({
|
||||||
|
kind: "folder",
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
depth,
|
||||||
|
branchCount: node.branchCount,
|
||||||
|
current: node.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isBranchFolderOpen(node.id)) {
|
||||||
|
flattenBranchNodes(node.children, rows, depth + 1, scopeLabel);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rows.push({
|
||||||
|
kind: "branch",
|
||||||
|
id: node.id,
|
||||||
|
branch: node.branch,
|
||||||
|
displayName: node.displayName,
|
||||||
|
depth,
|
||||||
|
scopeLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBranchFolderOpen(id: string) {
|
||||||
|
return !collapsedBranchFolders.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBranchFolder(id: string) {
|
||||||
|
const next = new Set(collapsedBranchFolders);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
collapsedBranchFolders = next;
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateForm() {
|
function openCreateForm() {
|
||||||
if (!hasRepository || isBusy) return;
|
if (!hasRepository || isBusy) return;
|
||||||
@@ -50,9 +205,56 @@
|
|||||||
createOpen = false;
|
createOpen = false;
|
||||||
localOpen = true;
|
localOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkoutOnDoubleClick(event: MouseEvent, branch: GitBranchInfo) {
|
||||||
|
if (branch.current || isBusy) return;
|
||||||
|
const target = event.target instanceof HTMLElement ? event.target : null;
|
||||||
|
if (target?.closest("button")) return;
|
||||||
|
onCheckout(branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (isBusy || branch.remote) return;
|
||||||
|
|
||||||
|
const rect = panelElement?.getBoundingClientRect();
|
||||||
|
const rawX = rect ? event.clientX - rect.left : event.offsetX;
|
||||||
|
const rawY = rect ? event.clientY - rect.top : event.offsetY;
|
||||||
|
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
|
||||||
|
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 92);
|
||||||
|
|
||||||
|
contextBranch = branch;
|
||||||
|
contextMenuX = Math.max(8, Math.min(rawX, maxX));
|
||||||
|
contextMenuY = Math.max(8, Math.min(rawY, maxY));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBranchContextMenu() {
|
||||||
|
contextBranch = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameContextBranch() {
|
||||||
|
const branch = contextBranch;
|
||||||
|
if (!branch || isBusy) return;
|
||||||
|
closeBranchContextMenu();
|
||||||
|
await onRenameBranch(branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteContextBranch() {
|
||||||
|
const branch = contextBranch;
|
||||||
|
if (!branch || branch.current || isBusy) return;
|
||||||
|
closeBranchContextMenu();
|
||||||
|
await onDeleteBranch(branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") closeBranchContextMenu();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} />
|
||||||
|
|
||||||
|
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Branches</span>
|
<span class="eyebrow">Branches</span>
|
||||||
@@ -120,29 +322,58 @@
|
|||||||
{#if localBranches.length === 0}
|
{#if localBranches.length === 0}
|
||||||
<div class="branch-empty">No local branches.</div>
|
<div class="branch-empty">No local branches.</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each localBranches as branch (branch.name)}
|
{#each localBranchRows as row (row.id)}
|
||||||
<article class="branch-row" class:current={branch.current}>
|
{#if row.kind === "folder"}
|
||||||
<div class="branch-info">
|
<button
|
||||||
<GitBranch size={16} aria-hidden="true" />
|
class="branch-folder-row"
|
||||||
<div>
|
class:current={row.current}
|
||||||
<strong>{branch.name}</strong>
|
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||||
<span>local</span>
|
type="button"
|
||||||
|
onclick={() => toggleBranchFolder(row.id)}
|
||||||
|
aria-expanded={isBranchFolderOpen(row.id)}
|
||||||
|
title={`${row.name} (${row.branchCount})`}
|
||||||
|
>
|
||||||
|
{#if isBranchFolderOpen(row.id)}
|
||||||
|
<ChevronDown size={14} aria-hidden="true" />
|
||||||
|
<FolderOpen size={15} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<ChevronRight size={14} aria-hidden="true" />
|
||||||
|
<Folder size={15} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
<span class="branch-folder-name">{row.name}</span>
|
||||||
|
<span class="branch-folder-count">{row.branchCount}</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<article
|
||||||
|
class="branch-row"
|
||||||
|
class:current={row.branch.current}
|
||||||
|
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||||
|
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
|
||||||
|
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
|
||||||
|
title={row.branch.current ? "Current branch" : row.branch.name}
|
||||||
|
>
|
||||||
|
<div class="branch-info">
|
||||||
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{row.displayName}</strong>
|
||||||
|
<span>{row.scopeLabel}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{#if row.branch.current}
|
||||||
{#if branch.current}
|
<span class="pill pill-active">Current</span>
|
||||||
<span class="pill pill-active">Current</span>
|
{:else}
|
||||||
{:else}
|
<div class="branch-actions">
|
||||||
<div class="branch-actions">
|
<button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
|
||||||
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
|
Checkout
|
||||||
Checkout
|
</button>
|
||||||
</button>
|
<button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
|
||||||
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
|
<GitMerge size={15} aria-hidden="true" />
|
||||||
<GitMerge size={15} aria-hidden="true" />
|
Merge
|
||||||
Merge
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
</article>
|
||||||
</article>
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -168,33 +399,87 @@
|
|||||||
{#if remoteBranches.length === 0}
|
{#if remoteBranches.length === 0}
|
||||||
<div class="branch-empty">No remote branches.</div>
|
<div class="branch-empty">No remote branches.</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each remoteBranches as branch (branch.name)}
|
{#each remoteBranchRows as row (row.id)}
|
||||||
<article class="branch-row" class:current={branch.current}>
|
{#if row.kind === "folder"}
|
||||||
<div class="branch-info">
|
<button
|
||||||
<GitBranch size={16} aria-hidden="true" />
|
class="branch-folder-row"
|
||||||
<div>
|
class:current={row.current}
|
||||||
<strong>{branch.name}</strong>
|
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||||
<span>remote</span>
|
type="button"
|
||||||
|
onclick={() => toggleBranchFolder(row.id)}
|
||||||
|
aria-expanded={isBranchFolderOpen(row.id)}
|
||||||
|
title={`${row.name} (${row.branchCount})`}
|
||||||
|
>
|
||||||
|
{#if isBranchFolderOpen(row.id)}
|
||||||
|
<ChevronDown size={14} aria-hidden="true" />
|
||||||
|
<FolderOpen size={15} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<ChevronRight size={14} aria-hidden="true" />
|
||||||
|
<Folder size={15} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
<span class="branch-folder-name">{row.name}</span>
|
||||||
|
<span class="branch-folder-count">{row.branchCount}</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<article
|
||||||
|
class="branch-row"
|
||||||
|
class:current={row.branch.current}
|
||||||
|
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||||
|
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
|
||||||
|
title={row.branch.current ? "Current branch" : row.branch.name}
|
||||||
|
>
|
||||||
|
<div class="branch-info">
|
||||||
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{row.displayName}</strong>
|
||||||
|
<span>{row.scopeLabel}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{#if row.branch.current}
|
||||||
{#if branch.current}
|
<span class="pill pill-active">Current</span>
|
||||||
<span class="pill pill-active">Current</span>
|
{:else}
|
||||||
{:else}
|
<div class="branch-actions">
|
||||||
<div class="branch-actions">
|
<button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
|
||||||
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
|
Checkout
|
||||||
Checkout
|
</button>
|
||||||
</button>
|
<button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
|
||||||
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
|
<GitMerge size={15} aria-hidden="true" />
|
||||||
<GitMerge size={15} aria-hidden="true" />
|
Merge
|
||||||
Merge
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
</article>
|
||||||
</article>
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if contextBranch}
|
||||||
|
<div
|
||||||
|
class="branch-context-menu"
|
||||||
|
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||||
|
role="menu"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label={`Actions for ${contextBranch.name}`}
|
||||||
|
>
|
||||||
|
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
|
||||||
|
<Pencil size={14} aria-hidden="true" />
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="danger"
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onclick={deleteContextBranch}
|
||||||
|
disabled={isBusy || contextBranch.current}
|
||||||
|
title={contextBranch.current ? "Current branch cannot be deleted" : "Delete local branch"}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
selectedExplorerLabel: string;
|
selectedExplorerLabel: string;
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
onDiff: (commit: GitCommit) => void;
|
onDiff: (commit: GitCommit) => void;
|
||||||
onRestore: (commit: GitCommit) => void;
|
onRestore: (commit: GitCommit) => void;
|
||||||
}
|
}
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
selectedExplorerLabel = "File history",
|
selectedExplorerLabel = "File history",
|
||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
isLoading = false,
|
||||||
onDiff = () => {},
|
onDiff = () => {},
|
||||||
onRestore = () => {},
|
onRestore = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -89,6 +91,25 @@
|
|||||||
<div class="blank-state">No repository loaded.</div>
|
<div class="blank-state">No repository loaded.</div>
|
||||||
{:else if !selectedExplorerPath}
|
{:else if !selectedExplorerPath}
|
||||||
<div class="blank-state">Select a file in Explorer.</div>
|
<div class="blank-state">Select a file in Explorer.</div>
|
||||||
|
{:else if isLoading}
|
||||||
|
<div class="file-history-loading" role="status" aria-live="polite">
|
||||||
|
<svg class="file-history-loading-graph" viewBox="0 0 120 120" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fhl-line" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#646cff" />
|
||||||
|
<stop offset="100%" stop-color="#41d1ff" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle class="fhl-ring" cx="60" cy="60" r="52" />
|
||||||
|
<path class="fhl-trunk" d="M42 22 L42 98" />
|
||||||
|
<path class="fhl-branch" d="M42 44 C42 62, 82 58, 82 76 L82 88" />
|
||||||
|
<circle class="fhl-node fhl-n1" cx="42" cy="30" r="6" />
|
||||||
|
<circle class="fhl-node fhl-n2" cx="42" cy="60" r="6" />
|
||||||
|
<circle class="fhl-node fhl-n3" cx="82" cy="88" r="6" />
|
||||||
|
<circle class="fhl-node fhl-n4" cx="42" cy="90" r="6" />
|
||||||
|
</svg>
|
||||||
|
<span class="file-history-loading-label">Loading history…</span>
|
||||||
|
</div>
|
||||||
{:else if fileHistory.length === 0}
|
{:else if fileHistory.length === 0}
|
||||||
<div class="blank-state">No history returned for this selection.</div>
|
<div class="blank-state">No history returned for this selection.</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { LoaderCircle, X } from "@lucide/svelte";
|
||||||
|
import type { GitFileStatus, PatchApplyAction } from "../types";
|
||||||
|
|
||||||
|
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
||||||
|
|
||||||
|
interface PatchLine {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
kind: PatchLineKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PatchHunk {
|
||||||
|
id: string;
|
||||||
|
header: string;
|
||||||
|
lines: PatchLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedPatch {
|
||||||
|
headerLines: string[];
|
||||||
|
hunks: PatchHunk[];
|
||||||
|
binary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
file: GitFileStatus;
|
||||||
|
staged: boolean;
|
||||||
|
patch: string;
|
||||||
|
isBusy: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onRefresh: () => void | Promise<void>;
|
||||||
|
onApply: (action: PatchApplyAction, patch: string) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
file,
|
||||||
|
staged = false,
|
||||||
|
patch = "",
|
||||||
|
isBusy = false,
|
||||||
|
isLoading = false,
|
||||||
|
error = "",
|
||||||
|
onClose = () => {},
|
||||||
|
onRefresh = () => {},
|
||||||
|
onApply = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||||
|
|
||||||
|
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
||||||
|
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
parsed = parsePatch(patch);
|
||||||
|
});
|
||||||
|
|
||||||
|
function parsePatch(input: string): ParsedPatch {
|
||||||
|
const normalized = input.replace(/\r\n/g, "\n");
|
||||||
|
const lines = normalized.split("\n");
|
||||||
|
if (lines[lines.length - 1] === "") lines.pop();
|
||||||
|
|
||||||
|
const headerLines: string[] = [];
|
||||||
|
const hunks: PatchHunk[] = [];
|
||||||
|
let current: PatchHunk | null = null;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("@@ ")) {
|
||||||
|
current = { id: `hunk-${hunks.length}`, header: line, lines: [] };
|
||||||
|
hunks.push(current);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
headerLines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = patchLineKind(line);
|
||||||
|
current.lines.push({
|
||||||
|
id: `${current.id}-line-${current.lines.length}`,
|
||||||
|
text: line,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
headerLines,
|
||||||
|
hunks,
|
||||||
|
binary: /(^|\n)(Binary files|GIT binary patch|literal \d+)/.test(normalized),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchLineKind(line: string): PatchLineKind {
|
||||||
|
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
|
||||||
|
if (line.startsWith("-") && !line.startsWith("---")) return "delete";
|
||||||
|
if (line.startsWith(" ")) return "context";
|
||||||
|
return "meta";
|
||||||
|
}
|
||||||
|
|
||||||
|
function linePrefix(line: PatchLine): string {
|
||||||
|
if (line.kind === "add") return "+";
|
||||||
|
if (line.kind === "delete") return "-";
|
||||||
|
if (line.kind === "meta") return "\\";
|
||||||
|
return " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineBody(line: PatchLine): string {
|
||||||
|
if (line.kind === "meta") return line.text;
|
||||||
|
return line.text.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHunkPatch(hunk: PatchHunk): string {
|
||||||
|
return `${[...parsed.headerLines, hunk.header, ...hunk.lines.map((line) => line.text)].join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
|
||||||
|
if (isBusy || isLoading) return;
|
||||||
|
await onApply(action, buildHunkPatch(hunk));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label="Line patch">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">{scopeLabel}</span>
|
||||||
|
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-header-actions">
|
||||||
|
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button>
|
||||||
|
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="line-patch-body">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="blank-state">
|
||||||
|
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
||||||
|
Loading patch...
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="blank-state">{error}</div>
|
||||||
|
{:else if !patch.trim()}
|
||||||
|
<div class="blank-state">No line patch available for this file.</div>
|
||||||
|
{:else if parsed.binary || parsed.hunks.length === 0}
|
||||||
|
<div class="blank-state">This change cannot be split into text lines.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="line-patch-scroll">
|
||||||
|
{#each parsed.hunks as hunk (hunk.id)}
|
||||||
|
<section class="line-patch-hunk">
|
||||||
|
<div class="line-patch-hunk-head">
|
||||||
|
<code>{hunk.header}</code>
|
||||||
|
<div class="line-patch-hunk-actions">
|
||||||
|
{#if staged}
|
||||||
|
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
|
||||||
|
Discard Hunk
|
||||||
|
</button>
|
||||||
|
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}>
|
||||||
|
Unstage Hunk
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}>
|
||||||
|
Discard Hunk
|
||||||
|
</button>
|
||||||
|
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}>
|
||||||
|
Stage Hunk
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="line-patch-lines">
|
||||||
|
{#each hunk.lines as line (line.id)}
|
||||||
|
<div class={`line-patch-row ${line.kind}`}>
|
||||||
|
<span class="line-patch-prefix">{linePrefix(line)}</span>
|
||||||
|
<code>{lineBody(line)}</code>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { GitBranch, LoaderCircle, X } from "@lucide/svelte";
|
||||||
|
import type { GitBranch as GitBranchInfo } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
branch: GitBranchInfo;
|
||||||
|
isBusy: boolean;
|
||||||
|
onRename: (name: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
branch,
|
||||||
|
isBusy = false,
|
||||||
|
onRename = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let name = $state("");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
name = branch.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const value = name.trim();
|
||||||
|
if (!value || value === branch.name) return;
|
||||||
|
onRename(value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="dialog-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
>
|
||||||
|
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Rename branch</span>
|
||||||
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="rename-branch-form" onsubmit={submit}>
|
||||||
|
<label class="new-branch-field">
|
||||||
|
<span>Branch name</span>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
bind:value={name}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
disabled={isBusy}
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="new-branch-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === branch.name}>
|
||||||
|
{#if isBusy}
|
||||||
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, RotateCcw, Undo2 } from "@lucide/svelte";
|
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
|
||||||
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
|
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
onStage: (file: GitFileStatus) => void;
|
onStage: (file: GitFileStatus) => void;
|
||||||
onUnstage: (file: GitFileStatus) => void;
|
onUnstage: (file: GitFileStatus) => void;
|
||||||
onDiscard: (file: GitFileStatus, staged: boolean) => void;
|
onDiscard: (file: GitFileStatus, staged: boolean) => void;
|
||||||
|
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
||||||
onStageAll: () => void;
|
onStageAll: () => void;
|
||||||
onUnstageAll: () => void;
|
onUnstageAll: () => void;
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
onStage = () => {},
|
onStage = () => {},
|
||||||
onUnstage = () => {},
|
onUnstage = () => {},
|
||||||
onDiscard = () => {},
|
onDiscard = () => {},
|
||||||
|
onPatch = () => {},
|
||||||
onStageAll = () => {},
|
onStageAll = () => {},
|
||||||
onUnstageAll = () => {},
|
onUnstageAll = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -46,6 +48,10 @@
|
|||||||
return file.old_path ? `${baseName(file.old_path)} -> ${baseName(file.path)}` : baseName(file.path);
|
return file.old_path ? `${baseName(file.old_path)} -> ${baseName(file.path)}` : baseName(file.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canPatch(kind: FileStatusKind | null): boolean {
|
||||||
|
return kind === "modified";
|
||||||
|
}
|
||||||
|
|
||||||
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
||||||
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
||||||
</script>
|
</script>
|
||||||
@@ -113,6 +119,10 @@
|
|||||||
<Undo2 size={14} aria-hidden="true" />
|
<Undo2 size={14} aria-hidden="true" />
|
||||||
Unstage
|
Unstage
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
|
||||||
|
<FileDiff size={14} aria-hidden="true" />
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
|
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
Discard
|
Discard
|
||||||
@@ -134,6 +144,10 @@
|
|||||||
<Check size={14} aria-hidden="true" />
|
<Check size={14} aria-hidden="true" />
|
||||||
Stage
|
Stage
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
|
||||||
|
<FileDiff size={14} aria-hidden="true" />
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
|
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
Discard
|
Discard
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
|
PatchApplyAction,
|
||||||
RepositoryBundle,
|
RepositoryBundle,
|
||||||
StoredCredential,
|
StoredCredential,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -16,6 +17,10 @@ export function openRepository(path: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("open_repository", { path });
|
return invoke<GitStatus>("open_repository", { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function openRepoInExplorer(path: string): Promise<void> {
|
||||||
|
return invoke<void>("open_repo_in_explorer", { path });
|
||||||
|
}
|
||||||
|
|
||||||
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
|
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
|
||||||
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
||||||
}
|
}
|
||||||
@@ -40,6 +45,18 @@ export function createBranch(
|
|||||||
return invoke<GitStatus>("create_branch", { path, branch, startPoint: startPoint ?? null });
|
return invoke<GitStatus>("create_branch", { path, branch, startPoint: startPoint ?? null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renameBranch(
|
||||||
|
path: string,
|
||||||
|
oldBranch: string,
|
||||||
|
newBranch: string,
|
||||||
|
): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBranch(path: string, branch: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("delete_branch", { path, branch });
|
||||||
|
}
|
||||||
|
|
||||||
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
|
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("stage_files", { path, files });
|
return invoke<GitStatus>("stage_files", { path, files });
|
||||||
}
|
}
|
||||||
@@ -56,6 +73,19 @@ export function restoreFiles(
|
|||||||
return invoke<GitStatus>("restore_files", { path, files, staged });
|
return invoke<GitStatus>("restore_files", { path, files, staged });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFilePatch(path: string, file: string, staged: boolean): Promise<string> {
|
||||||
|
return invoke<string>("get_file_patch", { path, file, staged });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFilePatch(
|
||||||
|
path: string,
|
||||||
|
file: string,
|
||||||
|
patch: string,
|
||||||
|
action: PatchApplyAction,
|
||||||
|
): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("apply_file_patch", { path, file, patch, action });
|
||||||
|
}
|
||||||
|
|
||||||
export function commit(path: string, message: string): Promise<GitStatus> {
|
export function commit(path: string, message: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("commit", { path, message });
|
return invoke<GitStatus>("commit", { path, message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface GitFileStatus {
|
|||||||
unstaged: FileStatusKind | null;
|
unstaged: FileStatusKind | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PatchApplyAction = "stage" | "unstage" | "discard-unstaged" | "discard-staged";
|
||||||
|
|
||||||
export interface GitBranch {
|
export interface GitBranch {
|
||||||
name: string;
|
name: string;
|
||||||
current: boolean;
|
current: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user