diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index b3962ae..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"]) @@ -4334,6 +4372,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 +4402,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 +4415,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); @@ -9983,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"); @@ -10275,6 +10377,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-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 1c6af8b..5c54df2 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"; @@ -70,6 +71,7 @@ cancelCodeSearch, cancelFileHistory, applyFilePatch, + getFileRestorePatch, createBranch, createTag, deleteBranch, @@ -296,6 +298,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"; @@ -493,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; @@ -564,6 +569,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 +593,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 +634,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 +2071,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 +2141,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 +2152,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 +2164,7 @@ stashCollapsed: boolean, explorerCollapsed: boolean, heights: Record, + visibility: Record, ): string { void branchCollapsed; void worktreeCollapsed; void tagsCollapsed; void stashCollapsed; void explorerCollapsed; @@ -2131,6 +2173,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`); @@ -4979,6 +5022,8 @@ async function openLinePatch(file: GitFileStatus, staged: boolean) { if (!activeRepoPath) return; + linePatchRestoreCommit = ""; + linePatchRestoreRepo = ""; linePatchOpen = true; linePatchFile = file; linePatchStaged = staged; @@ -4999,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 = ""; @@ -5092,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; @@ -5902,6 +5994,17 @@ +{#if sidebarSectionMenu && activeView === "repository"} + +{/if} +
+ @@ -6619,6 +6733,7 @@ {/await} 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} diff --git a/src/lib/components/BranchPanel.svelte b/src/lib/components/BranchPanel.svelte index 4fa1e89..11f7f8b 100644 --- a/src/lib/components/BranchPanel.svelte +++ b/src/lib/components/BranchPanel.svelte @@ -695,7 +695,7 @@
+ {#if onRestoreLines && !comparison.to_hash && comparison.files.some(file => file.path === selectedDiffPath && file.status === "modified" && !file.old_path)} + + {/if} {#if restoreLabel && onRestore}
- -
@@ -104,3 +107,7 @@ + + diff --git a/src/lib/components/LinePatchDialog.svelte b/src/lib/components/LinePatchDialog.svelte index 949e5fc..60b4f0e 100644 --- a/src/lib/components/LinePatchDialog.svelte +++ b/src/lib/components/LinePatchDialog.svelte @@ -1,5 +1,5 @@