Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions wimygit-tauri/src-tauri/src/git/parsers/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
1 change: 1 addition & 0 deletions wimygit-tauri/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 18 additions & 2 deletions wimygit-tauri/src/components/layout/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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<DiffMode[]>([{ kind: "combined", label: "Diff" }]);
const [activeMode, setActiveMode] = useState<DiffModeKind>("combined");
const [contextLines, setContextLines] = useState(DEFAULT_CONTEXT);
Expand All @@ -391,6 +393,7 @@ function SidebarQuickDiff({ repoPath, selectedDiff, pendingFilePreview }: Sideba
const [pendingDiff, setPendingDiff] = useState("");
const [pendingLoading, setPendingLoading] = useState(false);
const [imagePreviewSrc, setImagePreviewSrc] = useState<string | null>(null);
const [localRefresh, setLocalRefresh] = useState(0);

const isCommitMode = !!selectedDiff;
const showingPendingPreview = !isCommitMode && !!pendingFilePreview;
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -570,6 +573,18 @@ function SidebarQuickDiff({ repoPath, selectedDiff, pendingFilePreview }: Sideba
<div className="flex items-center justify-center h-full p-4 overflow-auto bg-gray-50 dark:bg-gray-900">
<img src={imagePreviewSrc} alt={pendingFilePreview?.filename} className="max-w-full max-h-full object-contain" />
</div>
) : showingPendingPreview && pendingFilePreview && !pendingFilePreview.isUntracked ? (
<InteractiveDiffViewer
diff={pendingDiff}
repoPath={repoPath}
filename={pendingFilePreview.filename}
staged={pendingFilePreview.staged}
onApplied={() => {
setLocalRefresh((k) => k + 1);
onRefresh?.();
}}
placeholder="No changes"
/>
) : (
<DiffViewer
diff={displayDiff}
Expand Down Expand Up @@ -732,6 +747,7 @@ export function LeftSidebar({ repoPath, refreshKey, selectedDiff, pendingFilePre
repoPath={repoPath}
selectedDiff={selectedDiff}
pendingFilePreview={pendingFilePreview}
onRefresh={onRefresh}
/>
)}
</div>
Expand Down
158 changes: 158 additions & 0 deletions wimygit-tauri/src/components/shared/InteractiveDiffViewer.tsx
Original file line number Diff line number Diff line change
@@ -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<number | null>(null);
const [applyingKey, setApplyingKey] = useState<string | null>(null);

const fileDiffs = useMemo(() => parseDiffIntoHunks(diff), [diff]);
const fileDiff = useMemo(
() => fileDiffs.find((f) => f.filename === filename) ?? fileDiffs[0] ?? null,
[fileDiffs, filename]
);

if (!diff) {
return (
<div className="flex items-center justify-center h-full text-gray-400 dark:text-gray-500 text-sm">
{placeholder ?? "No diff to display"}
</div>
);
}

if (!fileDiff) {
return (
<div className="flex items-center justify-center h-full text-gray-400 dark:text-gray-500 text-sm">
{placeholder ?? "No diff to display"}
</div>
);
}

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 (
<div
className="h-full overflow-auto"
onMouseLeave={() => setHoveredBlock(null)}
>
<pre className="text-xs font-mono leading-5 p-0 m-0">
{/* File-level header lines */}
{fileDiff.header.map((line, i) => (
<div
key={`fh${i}`}
className="px-4 py-0 text-gray-500 dark:text-gray-400 whitespace-pre-wrap break-all"
>
{line || " "}
</div>
))}

{fileDiff.hunks.map((hunk, hunkIdx) => (
<div key={hunkIdx}>
{/* Hunk @@ header */}
<div className="px-4 py-0 bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-300 font-medium whitespace-pre-wrap break-all">
{hunk.header}
</div>

{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 (
<div
key={lineIdx}
className={`flex items-stretch ${lineBg(line.type, isHovered)}`}
onMouseEnter={() =>
setHoveredBlock(isChanged ? line.blockId : null)
}
>
{/* Line content */}
<div className="flex-1 px-4 py-0 whitespace-pre-wrap break-all min-w-0">
{prefix}
{line.content || " "}
</div>

{/* Stage/Unstage button — visible only on hover */}
{isChanged && (
<button
onClick={() => handleApply(hunkIdx, line.blockId)}
disabled={applyingKey !== null}
title={btnTitle}
className={`shrink-0 w-6 text-center font-bold transition-opacity ${btnColor} disabled:opacity-30 ${
isHovered ? "opacity-100" : "opacity-0"
}`}
>
{isApplying ? "…" : btnLabel}
</button>
)}
</div>
);
})}
</div>
))}
</pre>
</div>
);
}
Loading
Loading