Merge pull request 'Implement interactive hunk staging and discarding' (#3) from Features/lineStageDiscard into master
publish / publish-tauri (, windows-latest) (release) Successful in 8m48s
publish / publish-tauri (, windows-latest) (release) Successful in 8m48s
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -385,6 +385,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)?;
|
||||||
@@ -2184,6 +2237,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;
|
||||||
@@ -3388,6 +3480,95 @@ mod tests {
|
|||||||
assert!(err.contains("existiert bereits"));
|
assert!(err.contains("existiert bereits"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
|||||||
@@ -3,14 +3,13 @@
|
|||||||
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,
|
diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches,
|
||||||
list_file_history, list_repository_files, merge_branch, open_repository,
|
list_commits, list_file_history, list_repository_files, merge_branch, open_repository,
|
||||||
open_repository_bundle, pull, push,
|
open_repository_bundle, pull, push, read_conflict, resolve_conflict, resolve_conflict_side,
|
||||||
read_conflict, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions,
|
||||||
restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
stage_files, unstage_files, SearchCancellationState,
|
||||||
SearchCancellationState,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -27,6 +26,8 @@ fn main() {
|
|||||||
stage_files,
|
stage_files,
|
||||||
unstage_files,
|
unstage_files,
|
||||||
restore_files,
|
restore_files,
|
||||||
|
get_file_patch,
|
||||||
|
apply_file_patch,
|
||||||
commit,
|
commit,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
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 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";
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
commit,
|
commit,
|
||||||
compareCommits,
|
compareCommits,
|
||||||
cancelCodeSearch,
|
cancelCodeSearch,
|
||||||
|
applyFilePatch,
|
||||||
createBranch,
|
createBranch,
|
||||||
diffFileAgainstWorkingTree,
|
diffFileAgainstWorkingTree,
|
||||||
compareFileToParent,
|
compareFileToParent,
|
||||||
@@ -41,6 +43,7 @@
|
|||||||
credLoad,
|
credLoad,
|
||||||
credSave,
|
credSave,
|
||||||
credDelete,
|
credDelete,
|
||||||
|
getFilePatch,
|
||||||
readConflict,
|
readConflict,
|
||||||
resolveConflict,
|
resolveConflict,
|
||||||
resolveConflictSide,
|
resolveConflictSide,
|
||||||
@@ -65,6 +68,7 @@
|
|||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
|
PatchApplyAction,
|
||||||
PreparedResolution,
|
PreparedResolution,
|
||||||
StoredCredential,
|
StoredCredential,
|
||||||
} from "./lib/types";
|
} from "./lib/types";
|
||||||
@@ -103,6 +107,12 @@
|
|||||||
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[] = [];
|
||||||
@@ -675,6 +685,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;
|
||||||
@@ -1129,6 +1210,7 @@
|
|||||||
onStage={stageFile}
|
onStage={stageFile}
|
||||||
onUnstage={unstageFile}
|
onUnstage={unstageFile}
|
||||||
onDiscard={discardFile}
|
onDiscard={discardFile}
|
||||||
|
onPatch={openLinePatch}
|
||||||
onStageAll={stageAllFiles}
|
onStageAll={stageAllFiles}
|
||||||
onUnstageAll={unstageAllFiles}
|
onUnstageAll={unstageAllFiles}
|
||||||
/>
|
/>
|
||||||
@@ -1189,6 +1271,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}
|
||||||
|
|||||||
+111
@@ -1094,6 +1094,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));
|
||||||
@@ -1311,6 +1316,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);
|
||||||
|
|||||||
@@ -50,6 +50,13 @@
|
|||||||
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);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
||||||
@@ -121,7 +128,12 @@
|
|||||||
<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 localBranches as branch (branch.name)}
|
||||||
<article class="branch-row" class:current={branch.current}>
|
<article
|
||||||
|
class="branch-row"
|
||||||
|
class:current={branch.current}
|
||||||
|
ondblclick={(event) => checkoutOnDoubleClick(event, branch)}
|
||||||
|
title={branch.current ? "Current branch" : "Double-click to checkout"}
|
||||||
|
>
|
||||||
<div class="branch-info">
|
<div class="branch-info">
|
||||||
<GitBranch size={16} aria-hidden="true" />
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
@@ -169,7 +181,12 @@
|
|||||||
<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 remoteBranches as branch (branch.name)}
|
||||||
<article class="branch-row" class:current={branch.current}>
|
<article
|
||||||
|
class="branch-row"
|
||||||
|
class:current={branch.current}
|
||||||
|
ondblclick={(event) => checkoutOnDoubleClick(event, branch)}
|
||||||
|
title={branch.current ? "Current branch" : "Double-click to checkout"}
|
||||||
|
>
|
||||||
<div class="branch-info">
|
<div class="branch-info">
|
||||||
<GitBranch size={16} aria-hidden="true" />
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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" />
|
||||||
|
Lines
|
||||||
|
</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" />
|
||||||
|
Lines
|
||||||
|
</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";
|
||||||
@@ -56,6 +57,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