Add selective line restoration, file-history annotation, and sidebar section menu #52

Merged
Christoph merged 5 commits from UI-UX into main 2026-09-18 18:48:06 +00:00
11 changed files with 402 additions and 26 deletions
+123 -2
View File
@@ -2530,6 +2530,41 @@ pub async fn commit_ai_review(
parse_ai_review(&raw) 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<String, String> {
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::<Vec<_>>().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)] #[tauri::command(async)]
pub fn apply_file_patch( pub fn apply_file_patch(
path: String, path: String,
@@ -2545,6 +2580,9 @@ pub fn apply_file_patch(
let patch_path = write_temp_patch(&patch)?; let patch_path = write_temp_patch(&patch)?;
let result = match action.as_str() { 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"]) "stage" => check_apply_patch(&repo, &patch_path, &["--cached"])
.and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached"])), .and_then(|_| run_apply_patch(&repo, &patch_path, &["--cached"])),
"unstage" => check_apply_patch(&repo, &patch_path, &["--cached", "--reverse"]) "unstage" => check_apply_patch(&repo, &patch_path, &["--cached", "--reverse"])
@@ -4334,6 +4372,29 @@ pub async fn list_repository_files(path: String) -> Result<Vec<GitRepositoryFile
.await .await
} }
#[derive(Debug, Serialize)]
pub struct FileHistoryCommit {
#[serde(flatten)]
commit: GitCommit,
matches_working_tree: bool,
}
fn annotate_file_history(repo: &Path, file: &str, commits: Vec<GitCommit>, cancellation: Option<&SearchCancellation>) -> Result<Vec<FileHistoryCommit>, 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] #[tauri::command]
pub async fn list_file_history( pub async fn list_file_history(
path: String, path: String,
@@ -4341,7 +4402,7 @@ pub async fn list_file_history(
limit: Option<u32>, limit: Option<u32>,
request_id: Option<String>, request_id: Option<String>,
state: tauri::State<'_, SearchCancellationState>, state: tauri::State<'_, SearchCancellationState>,
) -> Result<Vec<GitCommit>, String> { ) -> Result<Vec<FileHistoryCommit>, String> {
let state = state.inner().clone(); let state = state.inner().clone();
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
@@ -4354,7 +4415,8 @@ pub async fn list_file_history(
search_id: request_id.clone(), 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() { if let Some(request_id) = request_id.as_deref() {
let _ = state.clear(request_id); 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] #[test]
fn apply_file_patch_stages_and_discards_selected_changes() { fn apply_file_patch_stages_and_discards_selected_changes() {
let repo = init_temp_repo("apply_file_patch"); let repo = init_temp_repo("apply_file_patch");
@@ -10275,6 +10377,25 @@ mod tests {
assert_eq!(commits[1].summary, "init"); 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] #[test]
fn list_file_history_returns_commits_for_selected_folder() { fn list_file_history_returns_commits_for_selected_folder() {
let repo = init_temp_repo("folder_history"); let repo = init_temp_repo("folder_history");
+2 -1
View File
@@ -19,7 +19,7 @@ use git::{
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save, 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, 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, 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, git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository,
last_commit_message, list_branches, list_commits, list_file_history, last_commit_message, list_branches, list_commits, list_file_history,
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files, list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
@@ -392,6 +392,7 @@ async fn main() {
stash_drop, stash_drop,
restore_files, restore_files,
get_file_patch, get_file_patch,
get_file_restore_patch,
apply_file_patch, apply_file_patch,
commit, commit,
amend_commit, amend_commit,
+126 -9
View File
@@ -18,6 +18,7 @@
import IssueCenter from "./lib/components/IssueCenter.svelte"; import IssueCenter from "./lib/components/IssueCenter.svelte";
import { readWorkspaces, WORKSPACES_KEY, type Workspace, type WorkspaceState } from "./lib/workspaces"; import { readWorkspaces, WORKSPACES_KEY, type Workspace, type WorkspaceState } from "./lib/workspaces";
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte"; import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
import SidebarSectionMenu from "./lib/components/SidebarSectionMenu.svelte";
import ReviewCenter from "./lib/components/ReviewCenter.svelte"; import ReviewCenter from "./lib/components/ReviewCenter.svelte";
import RepoTabs from "./lib/RepoTabs.svelte"; import RepoTabs from "./lib/RepoTabs.svelte";
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte"; import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
@@ -70,6 +71,7 @@
cancelCodeSearch, cancelCodeSearch,
cancelFileHistory, cancelFileHistory,
applyFilePatch, applyFilePatch,
getFileRestorePatch,
createBranch, createBranch,
createTag, createTag,
deleteBranch, deleteBranch,
@@ -296,6 +298,7 @@
const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1"; const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1"; const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.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 SIDEBAR_PANEL_HEIGHTS_KEY = "gitlite.sidebarPanelHeights.v2";
const BRANCH_PANEL_COLLAPSED_KEY = "gitlite.branchPanelCollapsed.v1"; const BRANCH_PANEL_COLLAPSED_KEY = "gitlite.branchPanelCollapsed.v1";
const STASH_PANEL_COLLAPSED_KEY = "gitlite.stashPanelCollapsed.v2"; const STASH_PANEL_COLLAPSED_KEY = "gitlite.stashPanelCollapsed.v2";
@@ -493,6 +496,8 @@
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 linePatchRestoreCommit = "";
let linePatchRestoreRepo = "";
let linePatchOpen = false; let linePatchOpen = false;
let linePatchFile: GitFileStatus | null = null; let linePatchFile: GitFileStatus | null = null;
let linePatchStaged = false; let linePatchStaged = false;
@@ -564,6 +569,9 @@
let resizingLeftSidebar = false; let resizingLeftSidebar = false;
let leftSidebarResizeStartX = 0; let leftSidebarResizeStartX = 0;
let leftSidebarResizeStartWidth = 0; let leftSidebarResizeStartWidth = 0;
let sidebarVisibility = loadSidebarVisibility();
let sidebarSectionMenu: { x: number; y: number } | null = null;
let sidebarMenuReturnFocus: HTMLElement | null = null;
let sidebarPanelHeights: Record<SidebarPanelId, number> = loadSidebarPanelHeights(); let sidebarPanelHeights: Record<SidebarPanelId, number> = loadSidebarPanelHeights();
let resizingSidebarPanel: SidebarPanelId | null = null; let resizingSidebarPanel: SidebarPanelId | null = null;
let sidebarResizeStartY = 0; let sidebarResizeStartY = 0;
@@ -585,6 +593,7 @@
$: isBusy = operation.length > 0; $: isBusy = operation.length > 0;
$: hasRepository = activeRepoPath.length > 0 && status !== null; $: hasRepository = activeRepoPath.length > 0 && status !== null;
$: if (activeView !== "repository") sidebarSectionMenu = null;
$: workspaceActive = activeView === "repository" && hasRepository; $: workspaceActive = activeView === "repository" && hasRepository;
$: openingRepo = operation === "Opening repository"; $: openingRepo = operation === "Opening repository";
$: cloningRepo = operation === "Cloning repository"; $: cloningRepo = operation === "Cloning repository";
@@ -625,8 +634,8 @@
) as Record<string, string>; ) as Record<string, string>;
$: remoteBranches = branches.filter((b) => b.remote); $: remoteBranches = branches.filter((b) => b.remote);
$: currentBranchIsLocalOnly = Boolean(status?.current_branch) && !status?.upstream; $: currentBranchIsLocalOnly = Boolean(status?.current_branch) && !status?.upstream;
$: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, sidebarPanelHeights); $: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, sidebarPanelHeights, sidebarVisibility);
$: allLeftPanelsCollapsed = branchPanelCollapsed && worktreePanelCollapsed && tagsPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed; $: allLeftPanelsCollapsed = branchPanelCollapsed && (!sidebarVisibility.worktree || worktreePanelCollapsed) && (!sidebarVisibility.tags || tagsPanelCollapsed) && (!sidebarVisibility.stash || stashPanelCollapsed) && (!sidebarVisibility.explorer || explorerPanelCollapsed);
$: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools); $: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools);
$: diffToolName = externalToolDisplayName("diff", externalToolsSettings.diff, detectedExternalTools); $: diffToolName = externalToolDisplayName("diff", externalToolsSettings.diff, detectedExternalTools);
$: mergeToolName = externalToolDisplayName("merge", externalToolsSettings.merge, detectedExternalTools); $: mergeToolName = externalToolDisplayName("merge", externalToolsSettings.merge, detectedExternalTools);
@@ -2062,6 +2071,38 @@
// ── Sidebar panel sizing ─────────────────────────────────────────────────── // ── Sidebar panel sizing ───────────────────────────────────────────────────
function loadSidebarVisibility(): Record<SidebarPanelId, boolean> {
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 { function clampSidebarPanelHeight(panel: SidebarPanelId, value: number): number {
return Math.min(SIDEBAR_PANEL_MAX_HEIGHT, Math.max(SIDEBAR_PANEL_MIN_HEIGHT[panel], Math.round(value))); 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. */ /** Expanded panels, top to bottom. The last one always fills the leftover space. */
function expandedSidebarPanels(): SidebarPanelId[] { 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. */ /** 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. */ /** 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; void markers;
return !sidebarPanelIsCollapsed(panel) && nextExpandedSidebarPanel(panel) !== null; return sidebarVisibility[panel] && !sidebarPanelIsCollapsed(panel) && nextExpandedSidebarPanel(panel) !== null;
} }
function buildLeftSidebarRows( function buildLeftSidebarRows(
@@ -2123,6 +2164,7 @@
stashCollapsed: boolean, stashCollapsed: boolean,
explorerCollapsed: boolean, explorerCollapsed: boolean,
heights: Record<SidebarPanelId, number>, heights: Record<SidebarPanelId, number>,
visibility: Record<SidebarPanelId, boolean>,
): string { ): string {
void branchCollapsed; void worktreeCollapsed; void tagsCollapsed; void stashCollapsed; void explorerCollapsed; void branchCollapsed; void worktreeCollapsed; void tagsCollapsed; void stashCollapsed; void explorerCollapsed;
@@ -2131,6 +2173,7 @@
const rows: string[] = []; const rows: string[] = [];
for (const panel of SIDEBAR_PANEL_ORDER) { for (const panel of SIDEBAR_PANEL_ORDER) {
if (!visibility[panel]) continue;
if (sidebarPanelIsCollapsed(panel)) rows.push("auto"); if (sidebarPanelIsCollapsed(panel)) rows.push("auto");
else if (panel === flexible) rows.push(`minmax(${SIDEBAR_PANEL_MIN_HEIGHT[panel]}px, 1fr)`); else if (panel === flexible) rows.push(`minmax(${SIDEBAR_PANEL_MIN_HEIGHT[panel]}px, 1fr)`);
else rows.push(`${heights[panel]}px`); else rows.push(`${heights[panel]}px`);
@@ -4979,6 +5022,8 @@
async function openLinePatch(file: GitFileStatus, staged: boolean) { async function openLinePatch(file: GitFileStatus, staged: boolean) {
if (!activeRepoPath) return; if (!activeRepoPath) return;
linePatchRestoreCommit = "";
linePatchRestoreRepo = "";
linePatchOpen = true; linePatchOpen = true;
linePatchFile = file; linePatchFile = file;
linePatchStaged = staged; 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() { async function refreshLinePatch() {
if (!activeRepoPath || !linePatchFile) return; 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); await openLinePatch(linePatchFile, linePatchStaged);
} }
function closeLinePatch() { function closeLinePatch() {
if (isBusy) return; if (isBusy) return;
if (linePatchRestoreCommit) compareDialogOpen = !!comparison;
linePatchRestoreCommit = "";
linePatchRestoreRepo = "";
linePatchOpen = false; linePatchOpen = false;
linePatchFile = null; linePatchFile = null;
linePatchText = ""; linePatchText = "";
@@ -5092,6 +5183,7 @@
} }
async function applyLinePatch(action: PatchApplyAction, patch: string, scope: "hunk" | "lines") { async function applyLinePatch(action: PatchApplyAction, patch: string, scope: "hunk" | "lines") {
if (action === "restore-lines") { await restoreSelectedLines(patch); return; }
if (!activeRepoPath || !linePatchFile || isBusy) return; if (!activeRepoPath || !linePatchFile || isBusy) return;
const file = linePatchFile; const file = linePatchFile;
const staged = linePatchStaged; const staged = linePatchStaged;
@@ -5902,6 +5994,17 @@
<svelte:window on:click={handleWindowClick} on:keydown={handleWindowKeydown} on:contextmenu|capture={handleWindowContextMenu} /> <svelte:window on:click={handleWindowClick} on:keydown={handleWindowKeydown} on:contextmenu|capture={handleWindowContextMenu} />
{#if sidebarSectionMenu && activeView === "repository"}
<SidebarSectionMenu x={sidebarSectionMenu.x} y={sidebarSectionMenu.y} language={appLanguage}
sections={[
{ id: "worktree", label: "Worktrees", visible: sidebarVisibility.worktree },
{ id: "tags", label: "Tags", visible: sidebarVisibility.tags },
{ id: "stash", label: "Stashes", visible: sidebarVisibility.stash },
{ id: "explorer", label: appLanguage === "de" ? "Dateien" : "Files", visible: sidebarVisibility.explorer },
]}
onToggle={toggleSidebarVisibility} onClose={closeSidebarSectionMenu} />
{/if}
<main class="shell"> <main class="shell">
<TitleBar <TitleBar
onOpenSettings={openAppSettings} onOpenSettings={openAppSettings}
@@ -6140,8 +6243,11 @@
> >
<!-- Compact repository navigation: branches, worktrees, tags, stashes, files --> <!-- Compact repository navigation: branches, worktrees, tags, stashes, files -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<aside <aside
class="left-sidebar" class="left-sidebar"
tabindex="0"
oncontextmenu={openSidebarSectionMenu}
class:branch-collapsed={branchPanelCollapsed} class:branch-collapsed={branchPanelCollapsed}
class:stash-collapsed={stashPanelCollapsed} class:stash-collapsed={stashPanelCollapsed}
class:explorer-collapsed={explorerPanelCollapsed} class:explorer-collapsed={explorerPanelCollapsed}
@@ -6169,7 +6275,7 @@
collapsed={branchPanelCollapsed} collapsed={branchPanelCollapsed}
onToggleCollapsed={toggleBranchPanelCollapsed} onToggleCollapsed={toggleBranchPanelCollapsed}
/> />
{#if sidebarHandleVisible("branch", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed)} {#if sidebarHandleVisible("branch", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, sidebarVisibility)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div <div
@@ -6193,6 +6299,7 @@
{:else} {:else}
<span class="left-panel-resize-placeholder" aria-hidden="true"></span> <span class="left-panel-resize-placeholder" aria-hidden="true"></span>
{/if} {/if}
{#if sidebarVisibility.worktree}
<WorktreePanel <WorktreePanel
{worktrees} {worktrees}
{hasRepository} {hasRepository}
@@ -6208,7 +6315,7 @@
onManage={() => { void openWorktreeDialog(); }} onManage={() => { void openWorktreeDialog(); }}
onRefresh={() => { void refreshWorktrees(); }} onRefresh={() => { void refreshWorktrees(); }}
/> />
{#if sidebarHandleVisible("worktree", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed)} {#if sidebarHandleVisible("worktree", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, sidebarVisibility)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div <div
@@ -6232,6 +6339,8 @@
{:else} {:else}
<span class="left-panel-resize-placeholder" aria-hidden="true"></span> <span class="left-panel-resize-placeholder" aria-hidden="true"></span>
{/if} {/if}
{/if}
{#if sidebarVisibility.tags}
<TagsPanel <TagsPanel
{tags} {hasRepository} {isBusy} {tags} {hasRepository} {isBusy}
collapsed={tagsPanelCollapsed} collapsed={tagsPanelCollapsed}
@@ -6243,7 +6352,7 @@
onDeleteTag={deleteLocalTag} onDeleteTag={deleteLocalTag}
onPushTag={pushLocalTag} onPushTag={pushLocalTag}
/> />
{#if sidebarHandleVisible("tags", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed)} {#if sidebarHandleVisible("tags", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, sidebarVisibility)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div <div
@@ -6267,6 +6376,8 @@
{:else} {:else}
<span class="left-panel-resize-placeholder" aria-hidden="true"></span> <span class="left-panel-resize-placeholder" aria-hidden="true"></span>
{/if} {/if}
{/if}
{#if sidebarVisibility.stash}
<StashPanel <StashPanel
{stashes} {stashes}
changedCount={changedFiles.length} changedCount={changedFiles.length}
@@ -6279,7 +6390,7 @@
collapsed={stashPanelCollapsed} collapsed={stashPanelCollapsed}
onToggleCollapsed={toggleStashPanelCollapsed} onToggleCollapsed={toggleStashPanelCollapsed}
/> />
{#if sidebarHandleVisible("stash", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed)} {#if sidebarHandleVisible("stash", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, sidebarVisibility)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div <div
@@ -6303,6 +6414,8 @@
{:else} {:else}
<span class="left-panel-resize-placeholder" aria-hidden="true"></span> <span class="left-panel-resize-placeholder" aria-hidden="true"></span>
{/if} {/if}
{/if}
{#if sidebarVisibility.explorer}
<ExplorerPanel <ExplorerPanel
{repoFiles} {repoFiles}
{expandedExplorerPaths} {expandedExplorerPaths}
@@ -6327,6 +6440,7 @@
collapsed={explorerPanelCollapsed} collapsed={explorerPanelCollapsed}
onToggleCollapsed={toggleExplorerPanelCollapsed} onToggleCollapsed={toggleExplorerPanelCollapsed}
/> />
{/if}
</aside> </aside>
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
@@ -6619,6 +6733,7 @@
<module.default <module.default
file={linePatchFile} file={linePatchFile}
staged={linePatchStaged} staged={linePatchStaged}
restoreCommit={linePatchRestoreCommit}
patch={linePatchText} patch={linePatchText}
{isBusy} {isBusy}
isLoading={linePatchLoading} isLoading={linePatchLoading}
@@ -6668,6 +6783,7 @@
<module.default <module.default
{fileHistory} {fileHistory}
filePath={selectedExplorerPath} filePath={selectedExplorerPath}
language={appLanguage}
{isBusy} {isBusy}
isLoading={fileHistoryLoading} isLoading={fileHistoryLoading}
error={fileHistoryError} error={fileHistoryError}
@@ -6884,6 +7000,7 @@
restoreLabel={pendingRestoreFile ? (appLanguage === "de" ? "Datei wiederherstellen" : "Restore file") : ""} restoreLabel={pendingRestoreFile ? (appLanguage === "de" ? "Datei wiederherstellen" : "Restore file") : ""}
onClose={closeCompareDialog} onClose={closeCompareDialog}
onRestore={restorePreviewedCommitFile} onRestore={restorePreviewedCommitFile}
onRestoreLines={openHistoricalLineRestore}
onSelectFile={selectDiffFile} onSelectFile={selectDiffFile}
/> />
{/await} {/await}
+1 -1
View File
@@ -142,7 +142,7 @@
.repository-navigation.reordering,.reordering .repository-select{cursor:grabbing} .repository-navigation.reordering,.reordering .repository-select{cursor:grabbing}
.repository-select{touch-action:pan-y;user-select:none} .repository-select{touch-action:pan-y;user-select:none}
@media(prefers-reduced-motion:reduce){.reordering .repository-tab{transition: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} .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{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} .repository-select span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+8 -4
View File
@@ -695,7 +695,7 @@
<div class="bp-scroll" bind:this={scrollElement} role="tree" aria-label={t("branches.listLabel")}> <div class="bp-scroll" bind:this={scrollElement} role="tree" aria-label={t("branches.listLabel")}>
<div class="bp-group"> <div class="bp-group">
<button <button
class="bp-group-head" class="bp-group-head local"
type="button" type="button"
onclick={() => { if (!filtering) localOpen = !localOpen; }} onclick={() => { if (!filtering) localOpen = !localOpen; }}
aria-expanded={showLocal} aria-expanded={showLocal}
@@ -717,7 +717,7 @@
<div class="bp-group"> <div class="bp-group">
<button <button
class="bp-group-head" class="bp-group-head remote"
type="button" type="button"
onclick={() => { if (!filtering) remoteOpen = !remoteOpen; }} onclick={() => { if (!filtering) remoteOpen = !remoteOpen; }}
aria-expanded={showRemote} aria-expanded={showRemote}
@@ -932,7 +932,8 @@
border: 0; border: 0;
border-bottom: 1px solid var(--color-border-subtle); border-bottom: 1px solid var(--color-border-subtle);
border-radius: 0; border-radius: 0;
background: var(--color-surface); background: color-mix(in srgb, var(--group-accent) 7%, var(--color-surface));
box-shadow: inset 2px 0 color-mix(in srgb, var(--group-accent) 55%, transparent);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
color: var(--color-ink-faint); color: var(--color-ink-faint);
font-size: 10px; font-size: 10px;
@@ -941,7 +942,10 @@
text-align: left; text-align: left;
text-transform: uppercase; text-transform: uppercase;
} }
.bp-group-head:hover:not(:disabled) { color: var(--color-ink-muted); background: var(--color-surface); } .bp-group-head.local { --group-accent: var(--color-info); }
.bp-group-head.remote { --group-accent: var(--color-accent); }
.bp-group-head { color: color-mix(in srgb, var(--group-accent) 65%, var(--color-ink-muted)); }
.bp-group-head:hover:not(:disabled) { color: var(--group-accent); background: color-mix(in srgb, var(--group-accent) 12%, var(--color-surface)); }
.bp-group-count { .bp-group-count {
color: var(--color-ink-faint); color: var(--color-ink-faint);
font-size: 10px; font-size: 10px;
+7
View File
@@ -28,6 +28,7 @@
language?: "en" | "de"; language?: "en" | "de";
onClose: () => void; onClose: () => void;
onRestore?: () => void; onRestore?: () => void;
onRestoreLines?: () => void;
onSelectFile: (file: GitDiffFile) => void; onSelectFile: (file: GitDiffFile) => void;
} }
@@ -42,6 +43,7 @@
language = "en", language = "en",
onClose = () => {}, onClose = () => {},
onRestore = undefined, onRestore = undefined,
onRestoreLines = undefined,
onSelectFile = () => {}, onSelectFile = () => {},
}: Props = $props(); }: Props = $props();
@@ -242,6 +244,11 @@
</div> </div>
</div> </div>
<div class="dialog-header-actions"> <div class="dialog-header-actions">
{#if onRestoreLines && !comparison.to_hash && comparison.files.some(file => file.path === selectedDiffPath && file.status === "modified" && !file.old_path)}
<button class="btn-secondary compare-restore" type="button" onclick={onRestoreLines} disabled={isBusy}>
<RotateCcw size={15} aria-hidden="true" /><span>{isGerman ? "Zeilen wiederherstellen …" : "Restore lines…"}</span>
</button>
{/if}
{#if restoreLabel && onRestore} {#if restoreLabel && onRestore}
<button class="btn-secondary compare-restore" type="button" onclick={onRestore} disabled={isBusy} title={restoreLabel}> <button class="btn-secondary compare-restore" type="button" onclick={onRestore} disabled={isBusy} title={restoreLabel}>
<RotateCcw size={15} aria-hidden="true" /> <RotateCcw size={15} aria-hidden="true" />
+9 -2
View File
@@ -13,6 +13,7 @@
isBusy: boolean; isBusy: boolean;
isLoading: boolean; isLoading: boolean;
error?: string; error?: string;
language?: "de" | "en";
onDiff: (commit: GitCommit) => void; onDiff: (commit: GitCommit) => void;
onRestore: (commit: GitCommit) => void; onRestore: (commit: GitCommit) => void;
onClose: () => void; onClose: () => void;
@@ -24,6 +25,7 @@
isBusy = false, isBusy = false,
isLoading = false, isLoading = false,
error = "", error = "",
language = "en",
onDiff = () => {}, onDiff = () => {},
onRestore = () => {}, onRestore = () => {},
onClose = () => {}, onClose = () => {},
@@ -86,14 +88,15 @@
<div> <div>
<strong title={item.summary}>{item.summary}</strong> <strong title={item.summary}>{item.summary}</strong>
<span><code>{item.short_hash}</code> · {item.author_name}</span> <span><code>{item.short_hash}</code> · {item.author_name}</span>
{#if item.matches_working_tree}<span class="current-version">{language === "de" ? "Aktueller Stand" : "Current version"}</span>{/if}
</div> </div>
</div> </div>
<time datetime={item.date}>{formatCommitDate(item.date)}</time> <time datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="file-history-dialog-actions"> <div class="file-history-dialog-actions">
<button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy} title="Show changes against the working tree"> <button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy || item.matches_working_tree} title={item.matches_working_tree ? (language === "de" ? "Identisch mit der aktuellen Datei" : "Identical to the current file") : "Show changes against the working tree"}>
<GitCompare size={14} aria-hidden="true" /> Diff <GitCompare size={14} aria-hidden="true" /> Diff
</button> </button>
<button class="btn-sm" type="button" onclick={() => onRestore(item)} disabled={isBusy} title="Restore this file from the selected commit"> <button class="btn-sm" type="button" onclick={() => onRestore(item)} disabled={isBusy || item.matches_working_tree} title={item.matches_working_tree ? (language === "de" ? "Identisch mit der aktuellen Datei" : "Identical to the current file") : "Restore this file from the selected commit"}>
<RotateCcw size={14} aria-hidden="true" /> Restore <RotateCcw size={14} aria-hidden="true" /> Restore
</button> </button>
</div> </div>
@@ -104,3 +107,7 @@
</div> </div>
</div> </div>
</div> </div>
<style>
.file-history-dialog-commit .current-version{display:inline-flex;width:fit-content;margin-top:4px;padding:2px 6px;border:1px solid color-mix(in srgb,var(--color-accent) 25%,transparent);border-radius:4px;background:color-mix(in srgb,var(--color-accent) 8%,transparent);color:var(--color-accent);font-size:10px;font-weight:600}
</style>
+40 -6
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { ArrowDown, ArrowUp, Check, ExternalLink, FileDiff, LoaderCircle, Minus, Plus, RefreshCw, Trash2, X } from "@lucide/svelte"; import { ArrowDown, ArrowUp, Check, ExternalLink, FileDiff, LoaderCircle, Minus, Plus, RefreshCw, RotateCcw, Trash2, X } from "@lucide/svelte";
import type { GitFileStatus, PatchApplyAction } from "../types"; import type { GitFileStatus, PatchApplyAction } from "../types";
type PatchLineKind = "context" | "add" | "delete" | "meta"; type PatchLineKind = "context" | "add" | "delete" | "meta";
@@ -36,6 +36,7 @@
error: string; error: string;
language?: "en" | "de"; language?: "en" | "de";
diffName?: string; diffName?: string;
restoreCommit?: string;
onClose: () => void; onClose: () => void;
onRefresh: () => void | Promise<void>; onRefresh: () => void | Promise<void>;
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>; onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
@@ -51,6 +52,7 @@
error = "", error = "",
language = "en", language = "en",
diffName = "diff tool", diffName = "diff tool",
restoreCommit = "",
onClose = () => {}, onClose = () => {},
onRefresh = () => {}, onRefresh = () => {},
onApply = () => {}, onApply = () => {},
@@ -66,7 +68,7 @@
let lastSelectedLineId = $state(""); let lastSelectedLineId = $state("");
const t = (de: string, en: string) => isGerman ? de : en; const t = (de: string, en: string) => isGerman ? de : en;
let scopeLabel = $derived(staged ? t("Gestagte Änderungen", "Staged changes") : t("Nicht gestagte Änderungen", "Unstaged changes")); let scopeLabel = $derived(restoreCommit ? t(`Wiederherstellen aus ${restoreCommit.slice(0, 8)}`, `Restore from ${restoreCommit.slice(0, 8)}`) : staged ? t("Gestagte Änderungen", "Staged changes") : t("Nicht gestagte Änderungen", "Unstaged changes"));
let activeHunk = $state(0); let activeHunk = $state(0);
let hasTextPatch = $derived(!isLoading && !error && !!patch.trim() && !parsed.binary && parsed.hunks.length > 0); let hasTextPatch = $derived(!isLoading && !error && !!patch.trim() && !parsed.binary && parsed.hunks.length > 0);
function clearSelection() { selectedLineIds = new Set(); lastSelectedLineId = ""; } function clearSelection() { selectedLineIds = new Set(); lastSelectedLineId = ""; }
@@ -226,7 +228,34 @@
const output: string[] = []; const output: string[] = [];
let previousIncluded = false; let previousIncluded = false;
for (const line of hunk.lines) { if (restoreCommit) {
// Pair replacement lines so restoring just one pair keeps its original position.
const metadata = new Map<string, string>();
hunk.lines.forEach((line, index) => {
if (hunk.lines[index + 1]?.kind === "meta") metadata.set(line.id, hunk.lines[index + 1].text);
});
const emit = (line: PatchLine, prefix: string) => {
output.push(prefix + line.text.slice(1));
const marker = metadata.get(line.id);
if (marker) output.push(marker);
};
for (let index = 0; index < hunk.lines.length;) {
const line = hunk.lines[index];
if (line.kind === "context") { emit(line, " "); index++; continue; }
if (line.kind === "meta") { index++; continue; }
const removed: PatchLine[] = [], added: PatchLine[] = [];
while (index < hunk.lines.length && hunk.lines[index].kind !== "context") {
const changed = hunk.lines[index++];
if (changed.kind === "delete") removed.push(changed);
if (changed.kind === "add") added.push(changed);
}
for (let offset = 0; offset < Math.max(removed.length, added.length); offset++) {
const before = removed[offset], after = added[offset];
if (before) emit(before, selectedLineIds.has(before.id) ? "-" : " ");
if (after && selectedLineIds.has(after.id)) emit(after, "+");
}
}
} else for (const line of hunk.lines) {
if (line.kind === "context") { if (line.kind === "context") {
output.push(line.text); output.push(line.text);
previousIncluded = true; previousIncluded = true;
@@ -296,16 +325,17 @@
</script> </script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation"> <div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label={t("Geänderte Zeilen", "Changed lines")}> <div class="dialog line-patch-dialog" class:restoring={!!restoreCommit} role="dialog" aria-modal="true" aria-label={t("Geänderte Zeilen", "Changed lines")}>
<header class="dialog-header unified-dialog-header"> <header class="dialog-header unified-dialog-header">
<div class="patch-identity unified-dialog-heading"><span class="unified-dialog-icon" aria-hidden="true"><FileDiff size={23} aria-hidden="true" /></span><div class="unified-dialog-text"><p class="dialog-title" title={displayPath}>{displayPath}</p><span class="patch-scope">{scopeLabel}</span></div></div> <div class="patch-identity unified-dialog-heading"><span class="unified-dialog-icon" aria-hidden="true"><FileDiff size={23} aria-hidden="true" /></span><div class="unified-dialog-text"><p class="dialog-title" title={displayPath}>{displayPath}</p><span class="patch-scope">{scopeLabel}</span></div></div>
<div class="dialog-header-actions"> <div class="dialog-header-actions">
<button class="external-diff" type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={t(`In ${diffName} öffnen`, `Open in ${diffName}`)}><span>{t("Extern öffnen", "Open externally")}</span><ExternalLink size={14} /><span>·</span><span>{diffName}</span></button> {#if !restoreCommit}<button class="external-diff" type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={t(`In ${diffName} öffnen`, `Open in ${diffName}`)}><span>{t("Extern öffnen", "Open externally")}</span><ExternalLink size={14} /><span>·</span><span>{diffName}</span></button>{/if}
<button class="icon-action" type="button" onclick={onRefresh} disabled={isBusy || isLoading} aria-label={t("Aktualisieren", "Refresh")} title={t("Aktualisieren", "Refresh")}><RefreshCw size={16} /></button> <button class="icon-action" type="button" onclick={onRefresh} disabled={isBusy || isLoading} aria-label={t("Aktualisieren", "Refresh")} title={t("Aktualisieren", "Refresh")}><RefreshCw size={16} /></button>
<button data-dialog-close class="icon-action" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={16} /></button> <button data-dialog-close class="icon-action" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={16} /></button>
</div> </div>
</header> </header>
{#if restoreCommit}<p class="restore-help">{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.")}</p>{/if}
<div class="line-patch-body"> <div class="line-patch-body">
{#if isLoading} {#if isLoading}
<div class="blank-state"><LoaderCircle class="spin" size={18} />{t("Änderungen werden geladen …", "Loading changes …")}</div> <div class="blank-state"><LoaderCircle class="spin" size={18} />{t("Änderungen werden geladen …", "Loading changes …")}</div>
@@ -330,8 +360,10 @@
</button> </button>
<strong>{t("Abschnitt", "Hunk")} {index + 1}</strong><code title={hunk.header}>{hunk.header}</code> <strong>{t("Abschnitt", "Hunk")} {index + 1}</strong><code title={hunk.header}>{hunk.header}</code>
<div class="line-patch-hunk-actions"> <div class="line-patch-hunk-actions">
{#if restoreCommit}<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("restore-lines", hunk)} disabled={isBusy}><RotateCcw size={14} />{t("Abschnitt wiederherstellen", "Restore hunk")}</button>{:else}
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction(staged ? "discard-staged" : "discard-unstaged", hunk)} disabled={isBusy}><Trash2 size={14} />{t("Verwerfen", "Discard")}</button> <button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction(staged ? "discard-staged" : "discard-unstaged", hunk)} disabled={isBusy}><Trash2 size={14} />{t("Verwerfen", "Discard")}</button>
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applyHunkAction(staged ? "unstage" : "stage", hunk)} disabled={isBusy}>{#if staged}<Minus size={14} />{:else}<Plus size={14} />{/if}{staged ? t("Abschnitt unstagen", "Unstage hunk") : t("Abschnitt stagen", "Stage hunk")}</button> <button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applyHunkAction(staged ? "unstage" : "stage", hunk)} disabled={isBusy}>{#if staged}<Minus size={14} />{:else}<Plus size={14} />{/if}{staged ? t("Abschnitt unstagen", "Unstage hunk") : t("Abschnitt stagen", "Stage hunk")}</button>
{/if}
</div> </div>
</div> </div>
<div class="line-patch-lines"> <div class="line-patch-lines">
@@ -358,14 +390,16 @@
{#if hasTextPatch} {#if hasTextPatch}
<footer class="patch-footer"> <footer class="patch-footer">
<div class="selection-summary"><span class="selection-symbol" class:has-selection={selectedCount > 0}><Check size={13} /></span><strong aria-live="polite">{selectedCount} {t(selectedCount === 1 ? "Zeile ausgewählt" : "Zeilen ausgewählt", selectedCount === 1 ? "line selected" : "lines selected")}</strong><button class="clear-selection" type="button" onclick={clearSelection} disabled={isBusy || selectedCount === 0}>{t("Auswahl aufheben", "Clear selection")}</button></div> <div class="selection-summary"><span class="selection-symbol" class:has-selection={selectedCount > 0}><Check size={13} /></span><strong aria-live="polite">{selectedCount} {t(selectedCount === 1 ? "Zeile ausgewählt" : "Zeilen ausgewählt", selectedCount === 1 ? "line selected" : "lines selected")}</strong><button class="clear-selection" type="button" onclick={clearSelection} disabled={isBusy || selectedCount === 0}>{t("Auswahl aufheben", "Clear selection")}</button></div>
<div class="selection-actions"><button class="discard-selection" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy || selectedCount === 0}>{t("Auswahl verwerfen", "Discard selected")}</button><button class="stage-selection" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy || selectedCount === 0}>{selectedCount} {t(selectedCount === 1 ? "Zeile" : "Zeilen", selectedCount === 1 ? "line" : "lines")} {staged ? t("unstagen", "to unstage") : t("stagen", "to stage")}</button></div> <div class="selection-actions">{#if restoreCommit}<button class="stage-selection" type="button" onclick={() => applySelected("restore-lines")} disabled={isBusy || selectedCount === 0}><RotateCcw size={14} />{t("Auswahl wiederherstellen", "Restore selected")}</button>{:else}<button class="discard-selection" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy || selectedCount === 0}>{t("Auswahl verwerfen", "Discard selected")}</button><button class="stage-selection" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy || selectedCount === 0}>{selectedCount} {t(selectedCount === 1 ? "Zeile" : "Zeilen", selectedCount === 1 ? "line" : "lines")} {staged ? t("unstagen", "to unstage") : t("stagen", "to stage")}</button>{/if}</div>
</footer> </footer>
{/if} {/if}
</div> </div>
</div> </div>
<style> <style>
.restore-help{margin:0;padding:10px 20px;border-bottom:1px solid var(--color-border);color:var(--color-ink-muted);font-size:12px;line-height:1.5;flex-shrink:0}
.line-patch-dialog{width:min(1700px,100%);height:min(960px,100%);grid-template-rows:auto minmax(0,1fr) auto;font-size:13px} .line-patch-dialog{width:min(1700px,100%);height:min(960px,100%);grid-template-rows:auto minmax(0,1fr) auto;font-size:13px}
.line-patch-dialog.restoring{grid-template-rows:auto auto minmax(0,1fr) auto}
.dialog-header{padding:12px 18px}.patch-identity{display:flex;align-items:center;gap:12px;min-width:0}.patch-identity>div{min-width:0}.patch-identity :global(svg){flex:none;color:var(--color-ink-muted)}.patch-identity .dialog-title{font-size:15px;line-height:1.4;margin:0;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.patch-scope{display:block;color:var(--color-ink-muted);font-size:12px;margin-top:2px} .dialog-header{padding:12px 18px}.patch-identity{display:flex;align-items:center;gap:12px;min-width:0}.patch-identity>div{min-width:0}.patch-identity :global(svg){flex:none;color:var(--color-ink-muted)}.patch-identity .dialog-title{font-size:15px;line-height:1.4;margin:0;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.patch-scope{display:block;color:var(--color-ink-muted);font-size:12px;margin-top:2px}
.dialog-header-actions{gap:8px}.dialog-header-actions .external-diff{display:flex;align-items:center;gap:8px;border:0;background:transparent;font-size:12px;color:var(--color-ink-muted);padding:5px 10px}.line-patch-dialog .icon-action{display:inline-flex;align-items:center;justify-content:center;flex:none;width:30px;min-width:30px;height:30px;min-height:30px;padding:0;border:1px solid var(--color-border);background:transparent;color:var(--color-ink-muted)} .dialog-header-actions{gap:8px}.dialog-header-actions .external-diff{display:flex;align-items:center;gap:8px;border:0;background:transparent;font-size:12px;color:var(--color-ink-muted);padding:5px 10px}.line-patch-dialog .icon-action{display:inline-flex;align-items:center;justify-content:center;flex:none;width:30px;min-width:30px;height:30px;min-height:30px;padding:0;border:1px solid var(--color-border);background:transparent;color:var(--color-ink-muted)}
.patch-toolbar{display:flex;align-items:center;gap:20px;padding:8px 18px;min-height:46px;border-bottom:1px solid var(--color-border-subtle);background:var(--app-dialog-bg)}.patch-toolbar strong{font-size:12px;font-weight:600}.range-hint{color:var(--color-ink-faint);font-size:12px}.patch-summary{display:flex;align-items:center;gap:10px;margin-left:auto;color:var(--color-ink-muted);font-size:12px;white-space:nowrap}.patch-summary .add-count{color:var(--code-add-text)}.patch-summary .delete-count{color:var(--code-delete-text);margin-right:10px} .patch-toolbar{display:flex;align-items:center;gap:20px;padding:8px 18px;min-height:46px;border-bottom:1px solid var(--color-border-subtle);background:var(--app-dialog-bg)}.patch-toolbar strong{font-size:12px;font-weight:600}.range-hint{color:var(--color-ink-faint);font-size:12px}.patch-summary{display:flex;align-items:center;gap:10px;margin-left:auto;color:var(--color-ink-muted);font-size:12px;white-space:nowrap}.patch-summary .add-count{color:var(--code-add-text)}.patch-summary .delete-count{color:var(--code-delete-text);margin-right:10px}
@@ -0,0 +1,79 @@
<script lang="ts">
import { onMount } from "svelte";
import { Check, PanelLeft, GitFork, Tags, Archive, FolderTree } from "@lucide/svelte";
let { x, y, language, sections, onToggle, onClose }: {
x: number; y: number; language: "de" | "en";
sections: { id: string; label: string; visible: boolean }[];
onToggle: (id: string) => void;
onClose: () => void;
} = $props();
let menu: HTMLDivElement;
let viewport = $state({ width: window.innerWidth, height: window.innerHeight });
let menuWidth = $state(284);
let menuHeight = $state(272);
const details: Record<string, { icon: typeof Tags; de: string; en: string }> = {
worktree: { icon: GitFork, de: "Parallele Arbeitsverzeichnisse", en: "Parallel working directories" },
tags: { icon: Tags, de: "Markierte Versionen", en: "Tagged versions" },
stash: { icon: Archive, de: "Zwischengespeicherte Änderungen", en: "Saved changes" },
explorer: { icon: FolderTree, de: "Dateien im Repository", en: "Repository files" },
};
const visibleCount = $derived(sections.filter(section => section.visible).length);
const left = $derived(Math.max(8, Math.min(x, viewport.width - menuWidth - 8)));
const top = $derived(Math.max(8, Math.min(y, viewport.height - menuHeight - 8)));
onMount(() => { menu.focus(); });
function keyboard(event: KeyboardEvent) {
const buttons = [...menu.querySelectorAll<HTMLButtonElement>("button")];
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index < 0 ? (event.key === "ArrowDown" ? 0 : buttons.length - 1) : (index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length);
buttons[next]?.focus();
} else if (event.key === "Escape" || event.key === "Tab") {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); }
onClose();
}
}
</script>
<svelte:window
onpointerdown={(event) => { if (!menu.contains(event.target as Node)) onClose(); }}
onresize={() => { viewport = { width: window.innerWidth, height: window.innerHeight }; }}
/>
<div bind:this={menu} bind:clientWidth={menuWidth} bind:clientHeight={menuHeight} class="sidebar-section-menu" style:left="{left}px" style:top="{top}px" role="menu" tabindex="-1"
aria-label={language === "de" ? "Sidebar-Bereiche" : "Sidebar sections"} onkeydown={keyboard}
oncontextmenu={(event) => { event.preventDefault(); event.stopPropagation(); }}>
<div class="menu-heading">
<span class="heading-icon" aria-hidden="true"><PanelLeft size={18} strokeWidth={1.7} /></span>
<div class="heading-copy"><span class="eyebrow">Sidebar</span><strong>{language === "de" ? "Bereiche anzeigen" : "Show sections"}</strong></div>
<span class="section-count" aria-hidden="true">{visibleCount}<span>/{sections.length}</span></span>
</div>
<div class="menu-items" role="group">
{#each sections as section (section.id)}
{@const detail = details[section.id]}
<button type="button" role="menuitemcheckbox" aria-label={section.label} aria-checked={section.visible} onclick={() => onToggle(section.id)}>
<span class="section-icon" aria-hidden="true">{#if detail}<detail.icon size={17} strokeWidth={1.65} />{/if}</span>
<span class="section-copy"><strong>{section.label}</strong>{#if detail}<small>{language === "de" ? detail.de : detail.en}</small>{/if}</span>
<span class="check" class:checked={section.visible} aria-hidden="true">{#if section.visible}<Check size={12} strokeWidth={2.3} />{/if}</span>
</button>
{/each}
</div>
</div>
<style>
.sidebar-section-menu{position:fixed;z-index:10000;box-sizing:border-box;width:284px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow-y:auto;padding:6px;border:1px solid color-mix(in srgb,var(--color-accent) 18%,var(--color-border));border-radius:10px;background:var(--app-dialog-bg);box-shadow:0 12px 36px #0004,0 2px 8px #0002;color:var(--color-ink);font:12px/1.4 var(--font-sans);outline:none}
.menu-heading{display:flex;align-items:center;gap:10px;padding:10px 9px 13px;margin-bottom:5px;border-bottom:1px solid var(--color-border-subtle)}
.heading-icon{display:grid;place-items:center;flex:0 0 34px;height:34px;border:1px solid color-mix(in srgb,var(--color-accent) 24%,transparent);border-radius:7px;background:color-mix(in srgb,var(--color-accent) 9%,transparent);color:var(--color-accent)}
.heading-copy{display:grid;gap:2px;flex:1;min-width:0}.eyebrow{font-size:9px;font-weight:650;letter-spacing:.09em;text-transform:uppercase;color:var(--color-ink-muted)}.heading-copy strong{font-size:12px;font-weight:650}
.section-count{padding:3px 6px;border:1px solid var(--color-border-subtle);border-radius:5px;background:var(--color-surface);color:var(--color-ink-muted);font-size:10px;font-variant-numeric:tabular-nums}.section-count span{color:var(--color-ink-faint);margin-left:2px}
.menu-items{display:grid;gap:2px}
.sidebar-section-menu button{display:flex;align-items:center;justify-content:flex-start;gap:11px;width:100%;min-height:48px;padding:8px 10px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--color-ink);font:inherit;text-align:left;cursor:pointer;box-shadow:none;transition:background .12s,border-color .12s}
.sidebar-section-menu button:hover{background:var(--color-surface-hover)}
.sidebar-section-menu button:focus-visible{outline:none;border-color:color-mix(in srgb,var(--color-accent) 55%,transparent);background:color-mix(in srgb,var(--color-accent) 8%,var(--app-dialog-bg))}
.section-icon{display:grid;place-items:center;width:20px;flex-shrink:0;color:var(--color-ink-muted)}
.section-copy{display:grid;gap:2px;min-width:0;flex:1}.section-copy strong{font-size:12px;font-weight:600;line-height:1.3}.section-copy small{font-size:10px;font-weight:400;line-height:1.4;color:var(--color-ink-muted)}
.check{display:grid;place-items:center;width:16px;height:16px;flex-shrink:0;border:1px solid var(--color-border-input);border-radius:4px;background:var(--app-input-bg);color:var(--color-accent)}
.check.checked{border-color:color-mix(in srgb,var(--color-accent) 45%,transparent);background:color-mix(in srgb,var(--color-accent) 12%,transparent)}
@media(prefers-reduced-motion:reduce){.sidebar-section-menu button{transition:none}}
</style>
+4
View File
@@ -753,3 +753,7 @@ export function submoduleAction(path: string, modulePath: string, action: "updat
export function checkoutSubmoduleRevision(path: string, modulePath: string, revision: string, kind: "tag" | "commit"): Promise<void> { export function checkoutSubmoduleRevision(path: string, modulePath: string, revision: string, kind: "tag" | "commit"): Promise<void> {
return invoke("checkout_submodule_revision", { path, modulePath, revision, kind }); return invoke("checkout_submodule_revision", { path, modulePath, revision, kind });
} }
export function getFileRestorePatch(path: string, commit: string, file: string): Promise<string> {
return invoke("get_file_restore_patch", { path, commit, file });
}
+3 -1
View File
@@ -214,7 +214,7 @@ export interface GitFileStatus {
unstaged: FileStatusKind | null; unstaged: FileStatusKind | null;
} }
export type PatchApplyAction = "stage" | "unstage" | "discard-unstaged" | "discard-staged"; export type PatchApplyAction = "restore-lines" | "stage" | "unstage" | "discard-unstaged" | "discard-staged";
export interface GitBranch { export interface GitBranch {
name: string; name: string;
@@ -260,6 +260,8 @@ export interface GitStash {
} }
export interface GitCommit { 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; hash: string;
short_hash: string; short_hash: string;
summary: string; summary: string;