From e6797bdb813641550aaa298ebcfaa0115a16eb86 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 18 Sep 2026 20:05:19 +0200 Subject: [PATCH 1/5] feat(sidebar): add section menu to toggle left-sidebar panels Add a floating SidebarSectionMenu component and wiring in App.svelte to let users show/hide individual left-sidebar panels (worktrees, tags, stashes, files). Visibility is tracked in new sidebarVisibility state and persisted to localStorage under SIDEBAR_VISIBILITY_KEY. The menu opens via contextmenu on the left sidebar and returns focus to the invoking element when closed. - Panels and resize handles now respect visibility (buildLeftSidebarRows, sidebarHandleVisible, expandedSidebarPanels). - Branch panel remains always visible and cannot be toggled; stored preferences only apply to the other panels. - Safe fallbacks: localStorage errors are ignored so the feature still works without persistence; menu includes keyboard navigation and appropriate ARIA roles. --- src/App.svelte | 80 +++++++++++++++++--- src/lib/components/SidebarSectionMenu.svelte | 79 +++++++++++++++++++ 2 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 src/lib/components/SidebarSectionMenu.svelte diff --git a/src/App.svelte b/src/App.svelte index 1c6af8b..394857e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -18,6 +18,7 @@ import IssueCenter from "./lib/components/IssueCenter.svelte"; import { readWorkspaces, WORKSPACES_KEY, type Workspace, type WorkspaceState } from "./lib/workspaces"; import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte"; + import SidebarSectionMenu from "./lib/components/SidebarSectionMenu.svelte"; import ReviewCenter from "./lib/components/ReviewCenter.svelte"; import RepoTabs from "./lib/RepoTabs.svelte"; import AiReviewDialog from "./lib/components/AiReviewDialog.svelte"; @@ -296,6 +297,7 @@ const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1"; const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1"; const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1"; + const SIDEBAR_VISIBILITY_KEY = "gitlite.sidebarVisibility.v1"; const SIDEBAR_PANEL_HEIGHTS_KEY = "gitlite.sidebarPanelHeights.v2"; const BRANCH_PANEL_COLLAPSED_KEY = "gitlite.branchPanelCollapsed.v1"; const STASH_PANEL_COLLAPSED_KEY = "gitlite.stashPanelCollapsed.v2"; @@ -564,6 +566,9 @@ let resizingLeftSidebar = false; let leftSidebarResizeStartX = 0; let leftSidebarResizeStartWidth = 0; + let sidebarVisibility = loadSidebarVisibility(); + let sidebarSectionMenu: { x: number; y: number } | null = null; + let sidebarMenuReturnFocus: HTMLElement | null = null; let sidebarPanelHeights: Record = loadSidebarPanelHeights(); let resizingSidebarPanel: SidebarPanelId | null = null; let sidebarResizeStartY = 0; @@ -585,6 +590,7 @@ $: isBusy = operation.length > 0; $: hasRepository = activeRepoPath.length > 0 && status !== null; + $: if (activeView !== "repository") sidebarSectionMenu = null; $: workspaceActive = activeView === "repository" && hasRepository; $: openingRepo = operation === "Opening repository"; $: cloningRepo = operation === "Cloning repository"; @@ -625,8 +631,8 @@ ) as Record; $: remoteBranches = branches.filter((b) => b.remote); $: currentBranchIsLocalOnly = Boolean(status?.current_branch) && !status?.upstream; - $: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, sidebarPanelHeights); - $: allLeftPanelsCollapsed = branchPanelCollapsed && worktreePanelCollapsed && tagsPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed; + $: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, sidebarPanelHeights, sidebarVisibility); + $: allLeftPanelsCollapsed = branchPanelCollapsed && (!sidebarVisibility.worktree || worktreePanelCollapsed) && (!sidebarVisibility.tags || tagsPanelCollapsed) && (!sidebarVisibility.stash || stashPanelCollapsed) && (!sidebarVisibility.explorer || explorerPanelCollapsed); $: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools); $: diffToolName = externalToolDisplayName("diff", externalToolsSettings.diff, detectedExternalTools); $: mergeToolName = externalToolDisplayName("merge", externalToolsSettings.merge, detectedExternalTools); @@ -2062,6 +2068,38 @@ // ── Sidebar panel sizing ─────────────────────────────────────────────────── + function loadSidebarVisibility(): Record { + const visible = { branch: true, worktree: true, tags: true, stash: true, explorer: true }; + try { + const stored = JSON.parse(localStorage.getItem(SIDEBAR_VISIBILITY_KEY) ?? "{}"); + for (const panel of SIDEBAR_PANEL_ORDER) { + if (panel !== "branch" && typeof stored?.[panel] === "boolean") visible[panel] = stored[panel]; + } + } catch { /* Keep all sections visible if stored preferences are unavailable. */ } + return visible; + } + + function toggleSidebarVisibility(id: string) { + if (id === "branch" || !SIDEBAR_PANEL_ORDER.includes(id as SidebarPanelId)) return; + const panel = id as SidebarPanelId; + sidebarVisibility = { ...sidebarVisibility, [panel]: !sidebarVisibility[panel] }; + try { localStorage.setItem(SIDEBAR_VISIBILITY_KEY, JSON.stringify(sidebarVisibility)); } + catch { /* Visibility changes still work without persistent storage. */ } + } + + function closeSidebarSectionMenu() { + sidebarSectionMenu = null; + if (sidebarMenuReturnFocus?.isConnected) sidebarMenuReturnFocus.focus({ preventScroll: true }); + } + + function openSidebarSectionMenu(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + closeRepoTabContextMenu(); + sidebarMenuReturnFocus = event.currentTarget as HTMLElement; + sidebarSectionMenu = { x: event.clientX, y: event.clientY }; + } + function clampSidebarPanelHeight(panel: SidebarPanelId, value: number): number { return Math.min(SIDEBAR_PANEL_MAX_HEIGHT, Math.max(SIDEBAR_PANEL_MIN_HEIGHT[panel], Math.round(value))); } @@ -2100,7 +2138,7 @@ /** Expanded panels, top to bottom. The last one always fills the leftover space. */ function expandedSidebarPanels(): SidebarPanelId[] { - return SIDEBAR_PANEL_ORDER.filter((panel) => !sidebarPanelIsCollapsed(panel)); + return SIDEBAR_PANEL_ORDER.filter((panel) => sidebarVisibility[panel] && !sidebarPanelIsCollapsed(panel)); } /** The first expanded panel below `panel` — the one that gives way while dragging. */ @@ -2111,9 +2149,9 @@ } /** A handle only makes sense between two expanded panels. */ - function sidebarHandleVisible(panel: SidebarPanelId, ...markers: boolean[]): boolean { + function sidebarHandleVisible(panel: SidebarPanelId, ...markers: unknown[]): boolean { void markers; - return !sidebarPanelIsCollapsed(panel) && nextExpandedSidebarPanel(panel) !== null; + return sidebarVisibility[panel] && !sidebarPanelIsCollapsed(panel) && nextExpandedSidebarPanel(panel) !== null; } function buildLeftSidebarRows( @@ -2123,6 +2161,7 @@ stashCollapsed: boolean, explorerCollapsed: boolean, heights: Record, + visibility: Record, ): string { void branchCollapsed; void worktreeCollapsed; void tagsCollapsed; void stashCollapsed; void explorerCollapsed; @@ -2131,6 +2170,7 @@ const rows: string[] = []; for (const panel of SIDEBAR_PANEL_ORDER) { + if (!visibility[panel]) continue; if (sidebarPanelIsCollapsed(panel)) rows.push("auto"); else if (panel === flexible) rows.push(`minmax(${SIDEBAR_PANEL_MIN_HEIGHT[panel]}px, 1fr)`); else rows.push(`${heights[panel]}px`); @@ -5902,6 +5942,17 @@ +{#if sidebarSectionMenu && activeView === "repository"} + +{/if} +
+ diff --git a/src/lib/components/SidebarSectionMenu.svelte b/src/lib/components/SidebarSectionMenu.svelte new file mode 100644 index 0000000..f8c88b6 --- /dev/null +++ b/src/lib/components/SidebarSectionMenu.svelte @@ -0,0 +1,79 @@ + + + { if (!menu.contains(event.target as Node)) onClose(); }} + onresize={() => { viewport = { width: window.innerWidth, height: window.innerHeight }; }} +/> + + + -- 2.54.0 From aeacb50ea66249ca884b51e3fbca791a73bae378 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 18 Sep 2026 20:12:34 +0200 Subject: [PATCH 2/5] fix(repo-tabs): use pointer cursor for repository select Previously the enabled repository select used cursor:grab, which could mislead users into thinking the tab was draggable even when reordering wasn't active. Change the non-disabled state to cursor:pointer to more accurately indicate clickability. The grabbing cursor is still used while reordering, so no change to drag UX. --- src/lib/RepoTabs.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/RepoTabs.svelte b/src/lib/RepoTabs.svelte index 59f5792..69e4604 100644 --- a/src/lib/RepoTabs.svelte +++ b/src/lib/RepoTabs.svelte @@ -142,7 +142,7 @@ .repository-navigation.reordering,.reordering .repository-select{cursor:grabbing} .repository-select{touch-action:pan-y;user-select:none} @media(prefers-reduced-motion:reduce){.reordering .repository-tab{transition:none}} - .repository-select:not(:disabled){cursor:grab} + .repository-select:not(:disabled){cursor:pointer} .reordering .repository-select:not(:disabled){cursor:grabbing} .repository-select{display:flex;flex:1;min-width:0;align-items:center;gap:7px;min-height:29px;padding:0 9px;font-size:12px;text-align:left} .repository-select span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -- 2.54.0 From 096f62907c7d39a4dc244d227531bcc8197408b0 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 18 Sep 2026 20:20:46 +0200 Subject: [PATCH 3/5] feat(file-history): mark latest history entry identical to working tree Annotate file history entries with matches_working_tree and surface that information in the UI so the most recent commit can be identified as "current version" when its content equals the working tree. - Backend: add FileHistoryCommit and annotate_file_history(...) which checks (only for regular files) whether the newest commit's blob matches the working tree via `git diff --quiet`. list_file_history now returns the annotated commits. - Types: add optional matches_working_tree to GitCommit shape used by the UI. - UI: show a "Current version"/"Aktueller Stand" badge and disable Diff/Restore actions for entries that match the working tree (text localized for de/en). Also add a test that verifies the matching behavior across file edits, staging, committing and deletion. No external API breaking changes. --- src-tauri/src/git.rs | 47 ++++++++++++++++++++- src/App.svelte | 1 + src/lib/components/FileHistoryDialog.svelte | 11 ++++- src/lib/types.ts | 2 + 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index b3962ae..6354bba 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -4334,6 +4334,29 @@ pub async fn list_repository_files(path: String) -> Result, cancellation: Option<&SearchCancellation>) -> Result, String> { + // Keep folder history unchanged: Git diff would omit untracked children. + let regular_file = fs::symlink_metadata(repo.join(file)).is_ok_and(|metadata| metadata.is_file()); + let mut result = Vec::with_capacity(commits.len()); + for (index, commit) in commits.into_iter().enumerate() { + check_search_cancelled(cancellation)?; + let matches_working_tree = index == 0 && regular_file && run_git_cancellable( + repo, ["diff", "--quiet", "--no-ext-diff", "--no-textconv", &commit.hash, "--", file], + cancellation, "Could not compare current file version", + ).is_ok(); + check_search_cancelled(cancellation)?; + result.push(FileHistoryCommit { commit, matches_working_tree }); + } + Ok(result) +} + #[tauri::command] pub async fn list_file_history( path: String, @@ -4341,7 +4364,7 @@ pub async fn list_file_history( limit: Option, request_id: Option, state: tauri::State<'_, SearchCancellationState>, -) -> Result, String> { +) -> Result, String> { let state = state.inner().clone(); tauri::async_runtime::spawn_blocking(move || { @@ -4354,7 +4377,8 @@ pub async fn list_file_history( search_id: request_id.clone(), }); - let result = list_file_history_core(&repo, file, limit, cancellation.as_ref()); + let result = list_file_history_core(&repo, file.clone(), limit, cancellation.as_ref()) + .and_then(|commits| annotate_file_history(&repo, &file, commits, cancellation.as_ref())); if let Some(request_id) = request_id.as_deref() { let _ = state.clear(request_id); @@ -10275,6 +10299,25 @@ mod tests { assert_eq!(commits[1].summary, "init"); } + #[test] + fn file_history_marks_only_an_identical_current_file() { + let repo = init_temp_repo("history_current_version"); + commit_initial_file(&repo.path); + let matches = || { + let commits = list_file_history_core(&repo.path, "old.txt".into(), Some(10), None).unwrap(); + annotate_file_history(&repo.path, "old.txt", commits, None).unwrap()[0].matches_working_tree + }; + assert!(matches()); + fs::write(repo.path.join("old.txt"), "local changes\n").unwrap(); + assert!(!matches()); + run_git_test(&repo.path, ["add", "old.txt"]); + assert!(!matches()); + run_git_test(&repo.path, ["commit", "-q", "-m", "updated"]); + assert!(matches()); + fs::remove_file(repo.path.join("old.txt")).unwrap(); + assert!(!matches()); + } + #[test] fn list_file_history_returns_commits_for_selected_folder() { let repo = init_temp_repo("folder_history"); diff --git a/src/App.svelte b/src/App.svelte index 394857e..d6e9af9 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -6730,6 +6730,7 @@ void; onRestore: (commit: GitCommit) => void; onClose: () => void; @@ -24,6 +25,7 @@ isBusy = false, isLoading = false, error = "", + language = "en", onDiff = () => {}, onRestore = () => {}, onClose = () => {}, @@ -86,14 +88,15 @@
{item.summary} {item.short_hash} · {item.author_name} + {#if item.matches_working_tree}{language === "de" ? "Aktueller Stand" : "Current version"}{/if}
- -
@@ -104,3 +107,7 @@ + + diff --git a/src/lib/types.ts b/src/lib/types.ts index f3e69dd..9670142 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -260,6 +260,8 @@ export interface GitStash { } export interface GitCommit { + /** Set for the latest file-history entry when its diff against the working tree is empty. */ + matches_working_tree?: boolean; hash: string; short_hash: string; summary: string; -- 2.54.0 From 96f7c9f2dfbfc04315ea65ef4b1eddd30bfb1056 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 18 Sep 2026 20:31:33 +0200 Subject: [PATCH 4/5] feat: add selective line restoration from historical commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new Tauri command to produce a diff between the working file and a historical commit (get_file_restore_patch) and support a new apply action ("restore-lines") that validates and applies only text-line changes for a single regular file. Behavior changes and constraints: - Fetch a filtered reverse diff for a file in a commit so UI can display selectable lines from an older revision. - Applying "restore-lines" verifies the target is a regular file, rejects binary/metadata patches, and ensures the patch only modifies the selected file. - Restored lines are applied to the working tree without staging other changes; the index is preserved. - The operation rejects stale patches or patches targeting the wrong file. UI wiring: - Compare dialog gets a "Restore lines…" action for applicable modified files and opens the line-patch dialog in restore mode. - Line-patch dialog gains a restore mode (restoreCommit) with adjusted UI/rendering to pair removed/added lines, helper text, and dedicated "Restore selected" / "Restore hunk" actions. - App integration handles fetching the restore patch, applying selected lines, and refreshing views. Tests: - Add tests covering correct behavior (preserve unstaged/staged changes and index) and guard cases (stale/wrong-file patches). --- src-tauri/src/git.rs | 78 +++++++++++++++++++++++ src-tauri/src/main.rs | 3 +- src/App.svelte | 54 ++++++++++++++++ src/lib/components/CompareDialog.svelte | 7 ++ src/lib/components/LinePatchDialog.svelte | 46 +++++++++++-- src/lib/git.ts | 4 ++ src/lib/types.ts | 2 +- 7 files changed, 186 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 6354bba..affa2cb 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -2530,6 +2530,41 @@ pub async fn commit_ai_review( parse_ai_review(&raw) } +/// Forward patch from the working file to a historical version, for selective restoration. +#[tauri::command(async)] +pub fn get_file_restore_patch(path: String, commit: String, file: String) -> Result { + let repo = resolve_repo(&path)?; + validate_files(std::slice::from_ref(&file))?; + let commit = verify_commit(&repo, &commit)?; + if !fs::symlink_metadata(repo.join(&file)).is_ok_and(|metadata| metadata.is_file()) { + return Err("Line restoration requires an existing regular file.".into()); + } + let entry = run_git(&repo, ["ls-tree", "-z", &commit, "--", &file])?; + if !entry.starts_with(b"100644 ") && !entry.starts_with(b"100755 ") { + return Err("This revision does not contain a regular file at this path.".into()); + } + let output = run_git(&repo, ["diff", "-R", "--no-renames", "--no-ext-diff", "--no-textconv", "--unified=3", &commit, "--", &file])?; + let patch = String::from_utf8_lossy(&output).lines() + .filter(|line| !line.starts_with("old mode ") && !line.starts_with("new mode ")) + .collect::>().join("\n"); + Ok(if patch.is_empty() { patch } else { format!("{patch}\n") }) +} + +fn validate_restore_patch(repo: &Path, file: &str, patch: &str, patch_path: &Path) -> Result<(), String> { + if !fs::symlink_metadata(repo.join(file)).is_ok_and(|metadata| metadata.is_file()) { + return Err("Line restoration requires an existing regular file.".into()); + } + if patch.lines().any(|line| ["old mode ", "new mode ", "new file mode ", "deleted file mode ", "rename from ", "rename to ", "copy from ", "copy to ", "GIT binary patch", "Binary files "].iter().any(|prefix| line.starts_with(prefix))) { + return Err("Only text-line changes can be restored here.".into()); + } + let stats = run_git(repo, [OsStr::new("apply"), OsStr::new("--numstat"), OsStr::new("-z"), patch_path.as_os_str()])?; + let entries: Vec<_> = stats.split(|byte| *byte == 0).filter(|entry| !entry.is_empty()).collect(); + if entries.len() != 1 || entries[0].splitn(3, |byte| *byte == b'\t').nth(2) != Some(file.as_bytes()) { + return Err("The selected patch must only modify the selected file.".into()); + } + Ok(()) +} + #[tauri::command(async)] pub fn apply_file_patch( path: String, @@ -2545,6 +2580,9 @@ pub fn apply_file_patch( let patch_path = write_temp_patch(&patch)?; let result = match action.as_str() { + "restore-lines" => validate_restore_patch(&repo, &file, &patch, &patch_path) + .and_then(|_| check_apply_patch(&repo, &patch_path, &[])) + .and_then(|_| run_apply_patch(&repo, &patch_path, &[])), "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"]) @@ -10007,6 +10045,46 @@ mod tests { ); } + #[test] + fn restore_lines_preserves_unselected_changes_and_index() { + let repo = init_temp_repo("restore_selected_lines"); + fs::write(repo.path.join("file.txt"), "old\nkeep old\nbase\n").unwrap(); + run_git_test(&repo.path, ["add", "file.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "historical"]); + let commit = verify_commit(&repo.path, "HEAD").unwrap(); + fs::write(repo.path.join("file.txt"), "current\nkeep current\nstaged\n").unwrap(); + run_git_test(&repo.path, ["add", "file.txt"]); + let index_before = run_git(&repo.path, ["show", ":file.txt"]).unwrap(); + let full_patch = get_file_restore_patch(repo.path.to_string_lossy().into_owned(), commit.clone(), "file.txt".into()).unwrap(); + assert!(full_patch.contains("-current\n")); + assert!(full_patch.contains("+old\n")); + let selected = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,3 @@\n-current\n+old\n keep current\n staged\n"; + apply_file_patch(repo.path.to_string_lossy().into_owned(), "file.txt".into(), selected.into(), "restore-lines".into()).unwrap(); + assert_eq!(fs::read_to_string(repo.path.join("file.txt")).unwrap(), "old\nkeep current\nstaged\n"); + assert_eq!(run_git(&repo.path, ["show", ":file.txt"]).unwrap(), index_before); + let remaining = get_file_restore_patch(repo.path.to_string_lossy().into_owned(), commit, "file.txt".into()).unwrap(); + apply_file_patch(repo.path.to_string_lossy().into_owned(), "file.txt".into(), remaining, "restore-lines".into()).unwrap(); + assert_eq!(fs::read_to_string(repo.path.join("file.txt")).unwrap(), "old\nkeep old\nbase\n"); + assert_eq!(run_git(&repo.path, ["show", ":file.txt"]).unwrap(), index_before); + } + + #[test] + fn restore_lines_rejects_stale_or_wrong_file_patches() { + let repo = init_temp_repo("restore_lines_guard"); + fs::write(repo.path.join("file.txt"), "before\n").unwrap(); + run_git_test(&repo.path, ["add", "file.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "before"]); + let commit = verify_commit(&repo.path, "HEAD").unwrap(); + fs::write(repo.path.join("file.txt"), "after\n").unwrap(); + let patch = get_file_restore_patch(repo.path.to_string_lossy().into_owned(), commit, "file.txt".into()).unwrap(); + fs::write(repo.path.join("other.txt"), "after\n").unwrap(); + assert!(apply_file_patch(repo.path.to_string_lossy().into_owned(), "other.txt".into(), patch.clone(), "restore-lines".into()).is_err()); + fs::write(repo.path.join("file.txt"), "newer work\n").unwrap(); + assert!(apply_file_patch(repo.path.to_string_lossy().into_owned(), "file.txt".into(), patch, "restore-lines".into()).is_err()); + assert_eq!(fs::read_to_string(repo.path.join("file.txt")).unwrap(), "newer work\n"); + assert_eq!(fs::read_to_string(repo.path.join("other.txt")).unwrap(), "after\n"); + } + #[test] fn apply_file_patch_stages_and_discards_selected_changes() { let repo = init_temp_repo("apply_file_patch"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index cbf787c..46c2850 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -19,7 +19,7 @@ use git::{ compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch, delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes, get_bisect_state, get_commit_note, - get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune, + get_file_blame, get_file_patch, get_file_restore_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune, git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository, last_commit_message, list_branches, list_commits, list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files, @@ -392,6 +392,7 @@ async fn main() { stash_drop, restore_files, get_file_patch, + get_file_restore_patch, apply_file_patch, commit, amend_commit, diff --git a/src/App.svelte b/src/App.svelte index d6e9af9..5c54df2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -71,6 +71,7 @@ cancelCodeSearch, cancelFileHistory, applyFilePatch, + getFileRestorePatch, createBranch, createTag, deleteBranch, @@ -495,6 +496,8 @@ let selectedDiffPath = ""; let diffHighlightQuery = ""; let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null; + let linePatchRestoreCommit = ""; + let linePatchRestoreRepo = ""; let linePatchOpen = false; let linePatchFile: GitFileStatus | null = null; let linePatchStaged = false; @@ -5019,6 +5022,8 @@ async function openLinePatch(file: GitFileStatus, staged: boolean) { if (!activeRepoPath) return; + linePatchRestoreCommit = ""; + linePatchRestoreRepo = ""; linePatchOpen = true; linePatchFile = file; linePatchStaged = staged; @@ -5039,13 +5044,59 @@ } } + async function openHistoricalLineRestore() { + if (!activeRepoPath || !comparison || comparison.to_hash || isBusy) return; + const file = comparison.files.find(item => item.path === selectedDiffPath); + if (!file || file.status !== "modified" || file.old_path) return; + linePatchRestoreCommit = comparison.from_hash; + linePatchRestoreRepo = activeRepoPath; + linePatchFile = { path: file.path, old_path: null, staged: null, unstaged: "modified" }; + linePatchStaged = false; + linePatchText = ""; + linePatchError = ""; + compareDialogOpen = false; + fileHistoryDialogOpen = false; + globalSearchOpen = false; + linePatchOpen = true; + await refreshLinePatch(); + } + + async function restoreSelectedLines(patch: string) { + if (!linePatchFile || !linePatchRestoreCommit || isBusy) return; + const file = linePatchFile.path; + const repo = linePatchRestoreRepo; + const commit = linePatchRestoreCommit; + if (repo !== activeRepoPath) { linePatchError = "The active repository changed. Reopen the comparison."; return; } + operation = appLanguage === "de" ? "Ausgewählte Zeilen wiederherstellen" : "Restoring selected lines"; + linePatchError = ""; + try { + applyStatus(await applyFilePatch(repo, file, patch, "restore-lines")); + linePatchText = await getFileRestorePatch(repo, commit, file); + comparison = await diffFileAgainstWorkingTree(repo, commit, file); + await refreshRepositoryViews(repo, { branches: false, commits: false }); + await refreshFileHistory(repo, file, true); + } catch (error) { linePatchError = errorToMessage(error); } + finally { operation = ""; } + } + async function refreshLinePatch() { if (!activeRepoPath || !linePatchFile) return; + if (linePatchRestoreCommit) { + linePatchLoading = true; + linePatchError = ""; + try { linePatchText = await getFileRestorePatch(linePatchRestoreRepo, linePatchRestoreCommit, linePatchFile.path); } + catch (error) { linePatchError = errorToMessage(error); } + finally { linePatchLoading = false; } + return; + } await openLinePatch(linePatchFile, linePatchStaged); } function closeLinePatch() { if (isBusy) return; + if (linePatchRestoreCommit) compareDialogOpen = !!comparison; + linePatchRestoreCommit = ""; + linePatchRestoreRepo = ""; linePatchOpen = false; linePatchFile = null; linePatchText = ""; @@ -5132,6 +5183,7 @@ } async function applyLinePatch(action: PatchApplyAction, patch: string, scope: "hunk" | "lines") { + if (action === "restore-lines") { await restoreSelectedLines(patch); return; } if (!activeRepoPath || !linePatchFile || isBusy) return; const file = linePatchFile; const staged = linePatchStaged; @@ -6681,6 +6733,7 @@ {/await} diff --git a/src/lib/components/CompareDialog.svelte b/src/lib/components/CompareDialog.svelte index fcae5f8..d3f2982 100644 --- a/src/lib/components/CompareDialog.svelte +++ b/src/lib/components/CompareDialog.svelte @@ -28,6 +28,7 @@ language?: "en" | "de"; onClose: () => void; onRestore?: () => void; + onRestoreLines?: () => void; onSelectFile: (file: GitDiffFile) => void; } @@ -42,6 +43,7 @@ language = "en", onClose = () => {}, onRestore = undefined, + onRestoreLines = undefined, onSelectFile = () => {}, }: Props = $props(); @@ -242,6 +244,11 @@
+ {#if onRestoreLines && !comparison.to_hash && comparison.files.some(file => file.path === selectedDiffPath && file.status === "modified" && !file.old_path)} + + {/if} {#if restoreLabel && onRestore} + {#if !restoreCommit}{/if}
+ {#if restoreCommit}

{t("Grün: aus der alten Version übernehmen. Rot: aus der aktuellen Datei entfernen. Für einen Zeilenaustausch beide Zeilen auswählen. Die Auswahl wird nicht gestagt.", "Green: take from the old version. Red: remove from the current file. Select both lines to replace a line. Changes remain unstaged.")}

{/if}
{#if isLoading}
{t("Änderungen werden geladen …", "Loading changes …")}
@@ -330,8 +360,10 @@ {t("Abschnitt", "Hunk")} {index + 1}{hunk.header}
+ {#if restoreCommit}{:else} + {/if}
@@ -358,14 +390,16 @@ {#if hasTextPatch}
0}>{selectedCount} {t(selectedCount === 1 ? "Zeile ausgewählt" : "Zeilen ausgewählt", selectedCount === 1 ? "line selected" : "lines selected")}
-
+
{#if restoreCommit}{:else}{/if}
{/if}