diff --git a/wimygit-tauri/src-tauri/src/git/parsers/history.rs b/wimygit-tauri/src-tauri/src/git/parsers/history.rs index 9d8ef48..73498c8 100644 --- a/wimygit-tauri/src-tauri/src/git/parsers/history.rs +++ b/wimygit-tauri/src-tauri/src/git/parsers/history.rs @@ -268,6 +268,50 @@ pub async fn get_diff( Ok(result.stdout) } +/// Apply a patch string to the index (--cached) or working tree. +/// Writes the patch to a temp file and runs `git apply`. +#[tauri::command] +pub async fn apply_patch( + cwd: String, + patch: String, + cached: bool, + reverse: bool, +) -> Result<(), String> { + use std::fs; + + // Write patch to a uniquely named temp file + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos(); + let temp_path = std::env::temp_dir() + .join(format!("wimygit_patch_{}.patch", nanos)); + + fs::write(&temp_path, patch.as_bytes()) + .map_err(|e| format!("Failed to write patch file: {}", e))?; + + let mut args = vec!["apply".to_string()]; + if cached { + args.push("--cached".to_string()); + } + if reverse { + args.push("--reverse".to_string()); + } + args.push(temp_path.to_string_lossy().to_string()); + + let result = crate::git::run_git(args, cwd).await; + let _ = fs::remove_file(&temp_path); + + let result = result?; + if result.exit_code != 0 { + return Err(format!( + "git apply failed: {}", + result.stderr.trim() + )); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/wimygit-tauri/src-tauri/src/lib.rs b/wimygit-tauri/src-tauri/src/lib.rs index cd17bf7..3cce682 100644 --- a/wimygit-tauri/src-tauri/src/lib.rs +++ b/wimygit-tauri/src-tauri/src/lib.rs @@ -168,6 +168,7 @@ pub fn run() { git::get_commit_files, git::get_commit_diff, git::get_diff, + git::apply_patch, // Git tag commands git::get_tags, git::create_tag, diff --git a/wimygit-tauri/src/components/layout/LeftSidebar.tsx b/wimygit-tauri/src/components/layout/LeftSidebar.tsx index 0223545..64efac6 100644 --- a/wimygit-tauri/src/components/layout/LeftSidebar.tsx +++ b/wimygit-tauri/src/components/layout/LeftSidebar.tsx @@ -13,6 +13,7 @@ import { } from "../../lib"; import { type TreeNode, makeNode, patchNode } from "../tabs/DirectoryTreeTab"; import { DiffViewer } from "../shared/DiffViewer"; +import { InteractiveDiffViewer } from "../shared/InteractiveDiffViewer"; // ─── constants ──────────────────────────────────────────────────────────────── @@ -379,9 +380,10 @@ interface SidebarQuickDiffProps { repoPath: string; selectedDiff?: SelectedDiffInfo | null; pendingFilePreview?: PendingFilePreview | null; + onRefresh?: () => void; } -function SidebarQuickDiff({ repoPath, selectedDiff, pendingFilePreview }: SidebarQuickDiffProps) { +function SidebarQuickDiff({ repoPath, selectedDiff, pendingFilePreview, onRefresh }: SidebarQuickDiffProps) { const [modes, setModes] = useState([{ kind: "combined", label: "Diff" }]); const [activeMode, setActiveMode] = useState("combined"); const [contextLines, setContextLines] = useState(DEFAULT_CONTEXT); @@ -391,6 +393,7 @@ function SidebarQuickDiff({ repoPath, selectedDiff, pendingFilePreview }: Sideba const [pendingDiff, setPendingDiff] = useState(""); const [pendingLoading, setPendingLoading] = useState(false); const [imagePreviewSrc, setImagePreviewSrc] = useState(null); + const [localRefresh, setLocalRefresh] = useState(0); const isCommitMode = !!selectedDiff; const showingPendingPreview = !isCommitMode && !!pendingFilePreview; @@ -468,7 +471,7 @@ function SidebarQuickDiff({ repoPath, selectedDiff, pendingFilePreview }: Sideba .then((d) => setPendingDiff(d)) .catch(() => setPendingDiff("")) .finally(() => setPendingLoading(false)); - }, [pendingFilePreview, isCommitMode, repoPath, contextLines, ignoreWhitespace]); + }, [pendingFilePreview, isCommitMode, repoPath, contextLines, ignoreWhitespace, localRefresh]); // ── Diff Tool ── const handleDiffTool = async () => { @@ -570,6 +573,18 @@ function SidebarQuickDiff({ repoPath, selectedDiff, pendingFilePreview }: Sideba
{pendingFilePreview?.filename}
+ ) : showingPendingPreview && pendingFilePreview && !pendingFilePreview.isUntracked ? ( + { + setLocalRefresh((k) => k + 1); + onRefresh?.(); + }} + placeholder="No changes" + /> ) : ( )} diff --git a/wimygit-tauri/src/components/shared/InteractiveDiffViewer.tsx b/wimygit-tauri/src/components/shared/InteractiveDiffViewer.tsx new file mode 100644 index 0000000..ab70b3b --- /dev/null +++ b/wimygit-tauri/src/components/shared/InteractiveDiffViewer.tsx @@ -0,0 +1,158 @@ +import { useState, useMemo } from "react"; +import { + parseDiffIntoHunks, + buildBlockPatch, + type ParsedLine, +} from "../../lib/diff-parser"; +import { gitApplyPatch } from "../../lib"; + +interface InteractiveDiffViewerProps { + diff: string; + repoPath: string; + filename: string; + staged: boolean; // true = viewing staged diff → button unstages; false → stages + onApplied: () => void; + placeholder?: string; +} + +function lineBg(type: ParsedLine["type"], hovered: boolean): string { + if (type === "added") { + return hovered + ? "bg-green-200 dark:bg-green-800/60 text-green-900 dark:text-green-100 border-l-2 border-green-500" + : "bg-green-50 dark:bg-green-950/40 text-green-800 dark:text-green-200 border-l-2 border-green-400"; + } + if (type === "removed") { + return hovered + ? "bg-red-200 dark:bg-red-800/60 text-red-900 dark:text-red-100 border-l-2 border-red-500" + : "bg-red-50 dark:bg-red-950/40 text-red-800 dark:text-red-200 border-l-2 border-red-400"; + } + return "text-gray-700 dark:text-gray-300"; +} + +export function InteractiveDiffViewer({ + diff, + repoPath, + filename, + staged, + onApplied, + placeholder, +}: InteractiveDiffViewerProps) { + const [hoveredBlock, setHoveredBlock] = useState(null); + const [applyingKey, setApplyingKey] = useState(null); + + const fileDiffs = useMemo(() => parseDiffIntoHunks(diff), [diff]); + const fileDiff = useMemo( + () => fileDiffs.find((f) => f.filename === filename) ?? fileDiffs[0] ?? null, + [fileDiffs, filename] + ); + + if (!diff) { + return ( +
+ {placeholder ?? "No diff to display"} +
+ ); + } + + if (!fileDiff) { + return ( +
+ {placeholder ?? "No diff to display"} +
+ ); + } + + const handleApply = async (hunkIdx: number, blockId: number) => { + const key = `${hunkIdx}:${blockId}`; + setApplyingKey(key); + try { + const patch = buildBlockPatch(fileDiff, hunkIdx, blockId); + if (!patch) return; + // staged=true: viewing staged file → reverse to unstage + // staged=false: viewing unstaged file → apply to index (stage) + await gitApplyPatch(repoPath, patch, true, staged); + onApplied(); + } catch (e) { + alert(`Failed to apply patch: ${e}`); + } finally { + setApplyingKey(null); + } + }; + + const btnLabel = staged ? "−" : "+"; + const btnTitle = staged ? "Unstage this block" : "Stage this block"; + const btnColor = staged + ? "text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200" + : "text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-200"; + + return ( +
setHoveredBlock(null)} + > +
+        {/* File-level header lines */}
+        {fileDiff.header.map((line, i) => (
+          
+ {line || " "} +
+ ))} + + {fileDiff.hunks.map((hunk, hunkIdx) => ( +
+ {/* Hunk @@ header */} +
+ {hunk.header} +
+ + {hunk.lines.map((line, lineIdx) => { + const isChanged = line.blockId >= 0; + const isHovered = isChanged && hoveredBlock === line.blockId; + const key = `${hunkIdx}:${line.blockId}`; + const isApplying = applyingKey === key; + const prefix = + line.type === "added" + ? "+" + : line.type === "removed" + ? "-" + : " "; + + return ( +
+ setHoveredBlock(isChanged ? line.blockId : null) + } + > + {/* Line content */} +
+ {prefix} + {line.content || " "} +
+ + {/* Stage/Unstage button — visible only on hover */} + {isChanged && ( + + )} +
+ ); + })} +
+ ))} +
+
+ ); +} diff --git a/wimygit-tauri/src/lib/diff-parser.ts b/wimygit-tauri/src/lib/diff-parser.ts new file mode 100644 index 0000000..a40fba0 --- /dev/null +++ b/wimygit-tauri/src/lib/diff-parser.ts @@ -0,0 +1,191 @@ +export interface ParsedLine { + type: "added" | "removed" | "context"; + content: string; + oldLineNum: number; // 0 if not applicable (added line) + newLineNum: number; // 0 if not applicable (removed line) + blockId: number; // -1 for context; >=0 groups consecutive changed lines +} + +export interface ParsedHunk { + header: string; + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + lines: ParsedLine[]; +} + +export interface FileDiff { + filename: string; + header: string[]; // diff --git, index, ---, +++ lines + hunks: ParsedHunk[]; +} + +function parseHunkCoords(header: string) { + const m = header.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); + if (!m) return { oldStart: 1, oldCount: 0, newStart: 1, newCount: 0 }; + return { + oldStart: parseInt(m[1]), + oldCount: m[2] !== undefined ? parseInt(m[2]) : 1, + newStart: parseInt(m[3]), + newCount: m[4] !== undefined ? parseInt(m[4]) : 1, + }; +} + +export function parseDiffIntoHunks(diff: string): FileDiff[] { + const result: FileDiff[] = []; + let current: FileDiff | null = null; + let currentHunk: ParsedHunk | null = null; + let oldLineNum = 0; + let newLineNum = 0; + let blockId = -1; + let inChangedBlock = false; + + const commitHunk = () => { + if (currentHunk && current) { + current.hunks.push(currentHunk); + currentHunk = null; + } + }; + + for (const line of diff.split("\n")) { + if (line.startsWith("diff --git")) { + commitHunk(); + if (current) result.push(current); + current = { filename: "", header: [line], hunks: [] }; + inChangedBlock = false; + } else if ( + !currentHunk && + current && + (line.startsWith("--- ") || + line.startsWith("+++ ") || + line.startsWith("index ") || + line.startsWith("new file") || + line.startsWith("deleted file") || + line.startsWith("rename ") || + line.startsWith("similarity ") || + line.startsWith("Binary ")) + ) { + current.header.push(line); + if (line.startsWith("+++ b/")) { + current.filename = line.slice(6); + } else if (line.startsWith("+++ /dev/null")) { + const fromLine = current.header.find((h) => h.startsWith("--- a/")); + if (fromLine) current.filename = fromLine.slice(6); + } + } else if (line.startsWith("@@") && current) { + commitHunk(); + const coords = parseHunkCoords(line); + oldLineNum = coords.oldStart; + newLineNum = coords.newStart; + blockId = -1; + inChangedBlock = false; + currentHunk = { header: line, ...coords, lines: [] }; + } else if (currentHunk) { + if (line.startsWith("\\") || line === "") { + // "\ No newline at end of file" and trailing empty from split — skip both + } else if (line.startsWith("+")) { + if (!inChangedBlock) { + blockId++; + inChangedBlock = true; + } + currentHunk.lines.push({ + type: "added", + content: line.slice(1), + oldLineNum: 0, + newLineNum: newLineNum++, + blockId, + }); + } else if (line.startsWith("-")) { + if (!inChangedBlock) { + blockId++; + inChangedBlock = true; + } + currentHunk.lines.push({ + type: "removed", + content: line.slice(1), + oldLineNum: oldLineNum++, + newLineNum: 0, + blockId, + }); + } else { + inChangedBlock = false; + const content = line.startsWith(" ") ? line.slice(1) : line; + currentHunk.lines.push({ + type: "context", + content, + oldLineNum: oldLineNum++, + newLineNum: newLineNum++, + blockId: -1, + }); + } + } + } + + commitHunk(); + if (current) result.push(current); + + return result; +} + +// Build a minimal valid patch for a single contiguous block of changed lines. +export function buildBlockPatch( + fileDiff: FileDiff, + hunkIdx: number, + blockId: number +): string { + const hunk = fileDiff.hunks[hunkIdx]; + const lines = hunk.lines; + + // Locate the contiguous block + let firstIdx = -1; + let lastIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].blockId === blockId) { + if (firstIdx === -1) firstIdx = i; + lastIdx = i; + } + } + if (firstIdx === -1) return ""; + + // All context lines immediately before the block + const contextBefore: ParsedLine[] = []; + for (let i = firstIdx - 1; i >= 0 && lines[i].type === "context"; i--) { + contextBefore.unshift(lines[i]); + } + + // All context lines immediately after the block + const contextAfter: ParsedLine[] = []; + for ( + let i = lastIdx + 1; + i < lines.length && lines[i].type === "context"; + i++ + ) { + contextAfter.push(lines[i]); + } + + const subLines = [ + ...contextBefore, + ...lines.slice(firstIdx, lastIdx + 1), + ...contextAfter, + ]; + + // oldCount = context + removed lines; newCount = context + added lines + const oldCount = subLines.filter((l) => l.type !== "added").length; + const newCount = subLines.filter((l) => l.type !== "removed").length; + + const firstOldLine = subLines.find((l) => l.type !== "added"); + const firstNewLine = subLines.find((l) => l.type !== "removed"); + const oldStart = firstOldLine?.oldLineNum ?? hunk.oldStart; + const newStart = firstNewLine?.newLineNum ?? hunk.newStart; + + const hunkHeader = `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`; + const hunkLines = subLines.map((l) => { + if (l.type === "added") return `+${l.content}`; + if (l.type === "removed") return `-${l.content}`; + return ` ${l.content}`; + }); + + const fileHeader = fileDiff.header.join("\n"); + return `${fileHeader}\n${hunkHeader}\n${hunkLines.join("\n")}\n`; +} diff --git a/wimygit-tauri/src/lib/git-api.ts b/wimygit-tauri/src/lib/git-api.ts index 34c98bb..e124e6c 100644 --- a/wimygit-tauri/src/lib/git-api.ts +++ b/wimygit-tauri/src/lib/git-api.ts @@ -275,6 +275,15 @@ export async function gitDiff( return runGitSimple(args, cwd); } +export async function gitApplyPatch( + cwd: string, + patch: string, + cached: boolean, + reverse: boolean +): Promise { + return invoke("apply_patch", { cwd, patch, cached, reverse }); +} + // ============= Filesystem ============= export async function readTextFile(filePath: string): Promise { diff --git a/wimygit-tauri/src/lib/index.ts b/wimygit-tauri/src/lib/index.ts index 5133f64..4bb52bc 100644 --- a/wimygit-tauri/src/lib/index.ts +++ b/wimygit-tauri/src/lib/index.ts @@ -2,3 +2,4 @@ export * from "./git-types"; export * from "./git-api"; export * from "./git-log"; export * from "./git-lfs"; +export * from "./diff-parser";