diff --git a/src/backend/api/main.py b/src/backend/api/main.py index 02975f4..47ec3da 100644 --- a/src/backend/api/main.py +++ b/src/backend/api/main.py @@ -1,9 +1,13 @@ +import git from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field from core.branchs import Branches, get_branches from core.diff_to_tree import ProjectTreeNode, diff_to_tree +from core.diff_utils import get_blob_content +from core.language_config import LANGUAGE_CONFIG +from core.diff_parser import parse_code_structure class BranchRequest(BaseModel): @@ -17,6 +21,18 @@ class DiffTreeRequest(BaseModel): tree_mode: str = Field(default="flat") +class ConflictResolverResponse(BaseModel): + dest_code: str + source_code: str + + +class ConflictResolverRequest(BaseModel): + repo_path: str + id: str + base_branch: str + compare_branch: str + + app = FastAPI(title="Backend API") app.add_middleware( CORSMiddleware, @@ -50,10 +66,100 @@ async def diff_tree(payload: DiffTreeRequest) -> list[ProjectTreeNode]: try: tree = diff_to_tree( - payload.repo_path, payload.base_branch, payload.compare_branch, payload.tree_mode + payload.repo_path, + payload.base_branch, + payload.compare_branch, + payload.tree_mode, ) except ValueError as exc: # Surface a clear 400 error when the path is not a valid git repository raise HTTPException(status_code=400, detail=str(exc)) from exc return tree + + +def get_language_for_path(file_path: str) -> str: + """Determine language from file extension.""" + for lang, cfg in LANGUAGE_CONFIG.items(): + for ext in cfg["extensions"]: + if file_path.endswith(ext): + return lang + return "" + + +def extract_def_code( + content: str, def_name: str, language: str +) -> str: + """Extract source code for a specific definition.""" + if not content: + return "" + + struct = parse_code_structure(content, language) + def_info = struct.get(def_name, {}) + return def_info.get("source", "") + + +@app.post("/get-dest-compare-code") +async def get_dest_compare_code( + payload: ConflictResolverRequest, +) -> ConflictResolverResponse: + """ + Return source and destination code for conflict resolution. + + The id can be either: + - A file path (e.g., "src/file.py") + - A definition within a file (e.g., "src/file.py:my_function") + """ + try: + repo = git.Repo(payload.repo_path) + + # Parse the id to extract file path and optional definition name + if ":" in payload.id: + file_path, def_name = payload.id.rsplit(":", 1) + else: + file_path = payload.id + def_name = None + + # Get commit objects + target_commit = repo.commit(payload.base_branch) + source_commit = repo.commit(payload.compare_branch) + + # Get file content from both branches + dest_content = get_blob_content(repo, target_commit, file_path) + source_content = get_blob_content(repo, source_commit, file_path) + + # Determine language + language = get_language_for_path(file_path) + if not language: + raise ValueError(f"Unsupported file type: {file_path}") + + # If specific definition is requested, extract it + if def_name: + dest_code = extract_def_code(dest_content, def_name, language) + source_code = extract_def_code(source_content, def_name, language) + else: + dest_code = dest_content + source_code = source_content + + if not dest_code and not source_code: + raise ValueError( + f"Could not find code for {payload.id} " + f"in branches {payload.base_branch} " + f"or {payload.compare_branch}" + ) + + return ConflictResolverResponse( + dest_code=dest_code, source_code=source_code + ) + + except git.exc.InvalidGitRepositoryError as exc: + raise HTTPException( + status_code=400, + detail=f"{payload.repo_path!r} is not a valid git repository", + ) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException( + status_code=500, detail=f"Error retrieving code: {str(exc)}" + ) from exc diff --git a/src/backend/core/diff_models.py b/src/backend/core/diff_models.py index d15204d..f4238b1 100644 --- a/src/backend/core/diff_models.py +++ b/src/backend/core/diff_models.py @@ -24,6 +24,7 @@ class ProjectTreeNode(BaseModel): children: List["ProjectTreeNode"] = Field(default_factory=list) # Unified diff text (git‑style) for this node or file. source: str = "" + has_conflict: bool = False def make_code_position(def_info: dict) -> CodePosition: @@ -34,5 +35,3 @@ def make_code_position(def_info: dict) -> CodePosition: start_column=def_info.get("start_column", 0), end_column=def_info.get("end_column", 0), ) - - diff --git a/src/backend/core/diff_utils.py b/src/backend/core/diff_utils.py index ed587f3..45bffbb 100644 --- a/src/backend/core/diff_utils.py +++ b/src/backend/core/diff_utils.py @@ -1,4 +1,5 @@ import difflib +from collections import defaultdict from typing import List import git @@ -58,6 +59,72 @@ def build_file_diff_source( return "\n".join(diff_lines) +def get_blob_content(repo: git.Repo, commit: git.Commit, path: str) -> str: + """Safely get the content of a file from a specific commit.""" + try: + return commit.tree[path].data_stream.read().decode("utf-8") + except (KeyError, AttributeError): + return "" + + +def analyze_semantic_conflicts( + repo: git.Repo, + path: str, + base_commit: git.Commit, + target_commit: git.Commit, + source_commit: git.Commit, + language: str, +) -> dict[str, str]: + """ + Analyzes a file for semantic conflicts at the function/class level. + + Returns a dictionary mapping definition names to their status, e.g., + {'my_func': 'conflict', 'other_func': 'modified_on_source'}. + """ + content_base = get_blob_content(repo, base_commit, path) + content_target = get_blob_content(repo, target_commit, path) + content_source = get_blob_content(repo, source_commit, path) + + # If the file hasn't changed on the target branch, there can be no + # conflict. All changes are from the source branch. + if content_base == content_target: + return defaultdict(lambda: "modified_on_source") + + struct_base = parse_code_structure(content_base, language) + struct_target = parse_code_structure(content_target, language) + struct_source = parse_code_structure(content_source, language) + + all_keys = ( + set(struct_base.keys()) + | set(struct_target.keys()) + | set(struct_source.keys()) + ) + conflict_map = {} + + for name in all_keys: + src_base = struct_base.get(name, {}).get("source") + src_target = struct_target.get(name, {}).get("source") + src_source = struct_source.get(name, {}).get("source") + + changed_on_target = src_base != src_target + changed_on_source = src_base != src_source + + if changed_on_target and changed_on_source: + # The definition was changed on BOTH branches. + # If the final source is different, it's a conflict. + if src_target != src_source: + conflict_map[name] = "conflict" + else: + # Convergent change: both branches made the same change. + conflict_map[name] = "modified_on_both" + elif changed_on_target: + conflict_map[name] = "modified_on_target" + elif changed_on_source: + conflict_map[name] = "modified_on_source" + + return conflict_map + + def build_project_tree_from_branch_diff( repo: git.Repo, base_branch: str, compare_branch: str, tree_mode: str ) -> list[ProjectTreeNode]: @@ -89,6 +156,10 @@ def build_project_tree_from_branch_diff( if not diff_index: return [] + # Get commits for semantic conflict analysis + target_commit = repo.commit(base_branch) + source_commit = repo.commit(compare_branch) + # First collect a flat list of file‑level nodes; we'll wrap these in a # folder hierarchy once we've processed the whole diff. file_nodes: List[ProjectTreeNode] = [] @@ -132,6 +203,11 @@ def build_project_tree_from_branch_diff( content_compare, ) + # Analyze semantic conflicts at the definition level for this file + def_conflict_map = analyze_semantic_conflicts( + repo, path, base_for_diff, target_commit, source_commit, language + ) + struct_base = parse_code_structure(content_base, language) struct_compare = parse_code_structure(content_compare, language) @@ -178,6 +254,8 @@ def build_project_tree_from_branch_diff( file_node = ProjectTreeNode( id=path, label=path, + # Files don't have conflicts; only definitions do + has_conflict=False, kind="file", status=file_status, code_position=CodePosition( @@ -205,11 +283,16 @@ def build_project_tree_from_branch_diff( "", info.get("source", ""), ) + # Check if this definition has a conflict + conflict_status = def_conflict_map.get(name) + has_def_conflict = conflict_status == "conflict" + def_nodes[name] = ProjectTreeNode( id=f"{path}:{name}", label=name.split(".")[-1], kind=def_type, status="added", + has_conflict=has_def_conflict, code_position=make_code_position(info), path=path, source=diff_source, @@ -225,12 +308,17 @@ def build_project_tree_from_branch_diff( info.get("source", ""), "", ) + # Check if this definition has a conflict + conflict_status = def_conflict_map.get(name) + has_def_conflict = conflict_status == "conflict" + def_nodes[name] = ProjectTreeNode( id=f"{path}:{name}", label=name.split(".")[-1], kind=def_type, status="removed", code_position=make_code_position(info), + has_conflict=has_def_conflict, path=path, source=diff_source, ) @@ -250,6 +338,11 @@ def build_project_tree_from_branch_diff( ) # Use the "new" position where possible position_source = compare_info or base_info + + # Check if this definition has a conflict + conflict_status = def_conflict_map.get(name) + has_def_conflict = conflict_status == "conflict" + def_nodes[name] = ProjectTreeNode( id=f"{path}:{name}", label=name.split(".")[-1], @@ -258,6 +351,7 @@ def build_project_tree_from_branch_diff( code_position=make_code_position(position_source), path=path, source=diff_source, + has_conflict=has_def_conflict, ) # Attach nodes to the correct parents based on qualified name. @@ -269,7 +363,26 @@ def build_project_tree_from_branch_diff( else: children.append(node) + # Propagate conflicts from children to parents: + # If any child has a conflict, mark the parent as conflicted too. + def propagate_conflicts(nodes: List[ProjectTreeNode]) -> None: + """Propagate conflict status from children to parents.""" + for node in nodes: + if node.children: + propagate_conflicts(node.children) + # If any child has a conflict, mark parent as conflicted + if any( + child.has_conflict for child in node.children + ): + node.has_conflict = True + + propagate_conflicts(children) + file_node.children = children + # File also inherits conflicts from its definitions + if any(child.has_conflict for child in children): + file_node.has_conflict = True + file_nodes.append(file_node) if tree_mode == "flat": diff --git a/src/frontend/.yarn/install-state.gz b/src/frontend/.yarn/install-state.gz index 6a7cf9b..1ae20c2 100644 Binary files a/src/frontend/.yarn/install-state.gz and b/src/frontend/.yarn/install-state.gz differ diff --git a/src/frontend/package.json b/src/frontend/package.json index e36d4f4..c9cfce3 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -23,6 +23,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "lucide-react": "^0.553.0", + "monaco-editor": "^0.54.0", "react": "^19.2.0", "react-diff-view": "^3.3.2", "react-dom": "^19.2.0", diff --git a/src/frontend/src/features/DiffPage/ProjectTree/ProjectTree.tsx b/src/frontend/src/features/DiffPage/ProjectTree/ProjectTree.tsx index ee37323..c549788 100644 --- a/src/frontend/src/features/DiffPage/ProjectTree/ProjectTree.tsx +++ b/src/frontend/src/features/DiffPage/ProjectTree/ProjectTree.tsx @@ -6,7 +6,7 @@ import { cn } from "@/lib/utils"; import ProjectTreeItem from "./ProjectTreeItem"; import type { ProjectTreeNode } from "./types"; import { Toggle } from "@/components/ui/toggle"; -import { FolderTree } from "lucide-react"; +import { FolderTree, AlertTriangle } from "lucide-react"; import { Input } from "@/components/ui/input"; import ProjectTreeFilterPopover from "./ProjectTreeFilterPopover"; import { filter as rFilter } from "remeda"; @@ -256,6 +256,18 @@ const ProjectTree: FC = ({ return walk(filteredData); }, [filteredData]); + const conflictCount = useMemo(() => { + const walk = (items: ProjectTreeNode[]): number => + items.reduce( + (acc, item) => + acc + + (item.has_conflict ? 1 : 0) + + (item.children ? walk(item.children) : 0), + 0 + ); + return walk(treeData); + }, [treeData]); + return (
= ({

Function/Class changes

- - {isLoading ? "…" : flatCount} - +
+ {conflictCount > 0 && ( + + + {conflictCount} + + )} + + {isLoading ? "…" : flatCount} + +
diff --git a/src/frontend/src/features/DiffPage/ProjectTree/ProjectTreeItem.tsx b/src/frontend/src/features/DiffPage/ProjectTree/ProjectTreeItem.tsx index a8581fe..d12df79 100644 --- a/src/frontend/src/features/DiffPage/ProjectTree/ProjectTreeItem.tsx +++ b/src/frontend/src/features/DiffPage/ProjectTree/ProjectTreeItem.tsx @@ -6,6 +6,7 @@ import { Folder, FunctionSquare, LayoutPanelTop, + AlertTriangle, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; @@ -111,19 +112,30 @@ const ProjectTreeItem: FC = ({ {getIcon(node)} {formatNodeLabel(node.label)} - {node.status && ( - - {statusToLabel[node.status]} - - )} + + {node.has_conflict && ( + + + Conflict + + )} + {node.status && ( + + {statusToLabel[node.status]} + + )} + ); diff --git a/src/frontend/src/features/DiffPage/ProjectTree/types.ts b/src/frontend/src/features/DiffPage/ProjectTree/types.ts index de2d28b..52a285f 100644 --- a/src/frontend/src/features/DiffPage/ProjectTree/types.ts +++ b/src/frontend/src/features/DiffPage/ProjectTree/types.ts @@ -36,4 +36,8 @@ export interface ProjectTreeNode { * Location of the node in the (new) source file. */ code_position?: CodePosition; + /** + * Whether this node has a conflict. + */ + has_conflict?: boolean; } diff --git a/src/frontend/src/features/DiffPage/components/BranchSelectionSection.tsx b/src/frontend/src/features/DiffPage/components/BranchSelectionSection.tsx new file mode 100644 index 0000000..5656349 --- /dev/null +++ b/src/frontend/src/features/DiffPage/components/BranchSelectionSection.tsx @@ -0,0 +1,60 @@ +import type { FC } from "react"; +import type { Branches } from "../../../type"; +import BranchSelect from "./BranchSelect"; +import RepoPathInfo from "./RepoPathInfo"; +import { Button } from "@/components/ui/button"; +import { ArrowLeftRight } from "lucide-react"; + +interface BranchSelectionSectionProps { + branches: Branches[]; + baseBranch: string; + compareBranch: string; + onBaseBranchChange: (value: string) => void; + onCompareBranchChange: (value: string) => void; + onSwapBranches?: () => void; + repoPath: string; +} + +const BranchSelectionSection: FC = ({ + branches, + baseBranch, + compareBranch, + onBaseBranchChange, + onCompareBranchChange, + onSwapBranches, + repoPath, +}) => { + return ( +
+
+ + + {onSwapBranches && ( + + )} + +
+ +
+ ); +}; + +export default BranchSelectionSection; diff --git a/src/frontend/src/features/DiffPage/components/DiffViewerSection.tsx b/src/frontend/src/features/DiffPage/components/DiffViewerSection.tsx new file mode 100644 index 0000000..61d4799 --- /dev/null +++ b/src/frontend/src/features/DiffPage/components/DiffViewerSection.tsx @@ -0,0 +1,123 @@ +import type { FC } from "react"; +import { useMemo } from "react"; +import type { ProjectTreeNode } from "../ProjectTree/types"; +import { useConflictResolver } from "../hooks"; +import MonacoDiffViewer from "./MonacoDiffViewer"; +import { Diff, Hunk, parseDiff } from "react-diff-view"; +import "react-diff-view/style/index.css"; + +interface DiffViewerSectionProps { + selectedNode: ProjectTreeNode | null; + baseBranch: string; + compareBranch: string; + isLoading: boolean; + error: string | null; + repoPath?: string; + viewerType?: "monaco" | "react-diff-view"; +} + +const DiffViewerSection: FC = ({ + selectedNode, + baseBranch, + compareBranch, + isLoading, + error, + repoPath = "", + viewerType = "monaco", +}) => { + const diffText = selectedNode?.source ?? ""; + const selectedPath = selectedNode?.path || selectedNode?.label || ""; + const selectedHasConflict = selectedNode?.has_conflict ?? false; + + // Fetch conflict code when in monaco view and item has conflict + const { conflictCode, isLoading: isConflictLoading } = useConflictResolver({ + repoPath, + id: selectedNode?.id || "", + baseBranch, + compareBranch, + enabled: viewerType === "monaco" && selectedHasConflict && !!selectedNode, + }); + + const diffFile = useMemo(() => { + if (!diffText) return null; + try { + const files = parseDiff(diffText); + return files[0] ?? null; + } catch { + return null; + } + }, [diffText]); + + return ( +
+
+

+ Diff + {baseBranch && compareBranch && ( + + ({baseBranch} → {compareBranch}) + + )} +

+ {selectedPath && ( + {selectedPath} + )} +
+ +

+ Use the dropdowns above to choose the base and compare branches. +

+ + {isLoading && ( +

Loading diff…

+ )} + + {error &&

{error}

} + + {diffText ? ( +
+ {viewerType === "monaco" ? ( + + ) : ( +
+
+ {diffFile ? ( + + {(hunks) => + hunks.map((hunk) => ( + + )) + } + + ) : ( +
+

+ Unable to parse diff +

+
+ )} +
+
+ )} +
+ ) : ( +
+ Diff preview will appear here. +
+ )} +
+ ); +}; + +export default DiffViewerSection; diff --git a/src/frontend/src/features/DiffPage/components/MonacoDiffViewer.tsx b/src/frontend/src/features/DiffPage/components/MonacoDiffViewer.tsx new file mode 100644 index 0000000..07f6345 --- /dev/null +++ b/src/frontend/src/features/DiffPage/components/MonacoDiffViewer.tsx @@ -0,0 +1,144 @@ +import type { FC } from "react"; +import { useEffect, useRef } from "react"; +import * as Monaco from "monaco-editor"; + +interface MonacoDiffViewerProps { + diffContent: string; + fileName?: string; + isLoading?: boolean; + leftContent?: string; + rightContent?: string; + isConflict?: boolean; +} + +const MonacoDiffViewer: FC = ({ + diffContent, + isLoading = false, + leftContent, + rightContent, + isConflict = false, +}) => { + const editorContainerRef = useRef(null); + const editorRef = useRef< + Monaco.editor.IStandaloneDiffEditor | Monaco.editor.IStandaloneCodeEditor | null + >(null); + + useEffect(() => { + if (!editorContainerRef.current) return; + + if (isConflict && leftContent !== undefined && rightContent !== undefined) { + // Create diff editor for conflict resolution + const diffEditor = Monaco.editor.createDiffEditor( + editorContainerRef.current, + { + theme: "vs-dark", + readOnly: false, + minimap: { enabled: true }, + scrollBeyondLastLine: false, + wordWrap: "on", + fontSize: 12, + lineNumbers: "on", + fontFamily: "'Fira Code', 'Courier New', monospace", + } + ); + + // Detect language from file extension or default to javascript + let language = "javascript"; + if ( + diffContent.includes("def ") || + diffContent.includes("class ") || + diffContent.includes("import ") + ) { + language = "python"; + } else if ( + diffContent.includes("function") || + diffContent.includes("const ") || + diffContent.includes("import") + ) { + language = "typescript"; + } + + const originalModel = Monaco.editor.createModel( + leftContent, + language + ); + const modifiedModel = Monaco.editor.createModel( + rightContent, + language + ); + + diffEditor.setModel({ + original: originalModel, + modified: modifiedModel, + }); + + editorRef.current = diffEditor; + + const handleResize = () => { + diffEditor.layout(); + }; + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + diffEditor.dispose(); + originalModel.dispose(); + modifiedModel.dispose(); + }; + } else { + // Create regular diff view + const editor = Monaco.editor.create(editorContainerRef.current, { + value: diffContent || "", + language: "diff", + theme: "vs-dark", + readOnly: true, + minimap: { enabled: true }, + scrollBeyondLastLine: false, + wordWrap: "on", + fontSize: 12, + lineNumbers: "on", + fontFamily: "'Fira Code', 'Courier New', monospace", + }); + + editorRef.current = editor; + + const handleResize = () => { + editor.layout(); + }; + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + editor.dispose(); + }; + } + }, [isConflict, leftContent, rightContent, diffContent]); + + // Update editor content when diffContent changes (for non-conflict view) + useEffect(() => { + if ( + !isConflict && + editorRef.current && + diffContent && + "setValue" in editorRef.current + ) { + editorRef.current.setValue(diffContent); + } + }, [diffContent, isConflict]); + + return ( +
+ {isLoading && ( +
+

Loading diff…

+
+ )} +
+ ); +}; + +export default MonacoDiffViewer; diff --git a/src/frontend/src/features/DiffPage/hooks/index.ts b/src/frontend/src/features/DiffPage/hooks/index.ts new file mode 100644 index 0000000..8e4c1ca --- /dev/null +++ b/src/frontend/src/features/DiffPage/hooks/index.ts @@ -0,0 +1,3 @@ +export { useDiffTree } from "./useDiffTree"; +export { useBranchDefaults } from "./useBranchDefaults"; +export { useConflictResolver } from "./useConflictResolver"; diff --git a/src/frontend/src/features/DiffPage/hooks/useBranchDefaults.ts b/src/frontend/src/features/DiffPage/hooks/useBranchDefaults.ts new file mode 100644 index 0000000..f2b0df7 --- /dev/null +++ b/src/frontend/src/features/DiffPage/hooks/useBranchDefaults.ts @@ -0,0 +1,32 @@ +import { useState } from "react"; +import type { Branches } from "../../../type"; + +interface UseBranchDefaultsReturn { + baseBranch: string; + compareBranch: string; + setBaseBranch: (value: string) => void; + setCompareBranch: (value: string) => void; +} + +export const useBranchDefaults = ( + branches: Branches[] +): UseBranchDefaultsReturn => { + const currentBranch = branches.find((b) => b.is_default); + + const [baseBranch, setBaseBranch] = useState( + currentBranch?.name ?? branches[0]?.name ?? "" + ); + const [compareBranch, setCompareBranch] = useState( + branches.find((b) => b.is_current || !b.is_default)?.name ?? + branches[0]?.name ?? + "" + ); + + return { + baseBranch, + compareBranch, + setBaseBranch, + setCompareBranch, + }; +}; + diff --git a/src/frontend/src/features/DiffPage/hooks/useConflictResolver.ts b/src/frontend/src/features/DiffPage/hooks/useConflictResolver.ts new file mode 100644 index 0000000..60d839b --- /dev/null +++ b/src/frontend/src/features/DiffPage/hooks/useConflictResolver.ts @@ -0,0 +1,82 @@ +import { useEffect, useState } from "react"; +import { fetchConflictCode } from "../../../services/diifs"; + +interface UseConflictResolverOptions { + repoPath: string; + id: string; + baseBranch: string; + compareBranch: string; + enabled?: boolean; +} + +interface ConflictCode { + destCode: string; + sourceCode: string; +} + +interface UseConflictResolverReturn { + conflictCode: ConflictCode | null; + isLoading: boolean; + error: string | null; +} + +export const useConflictResolver = ({ + repoPath, + id, + baseBranch, + compareBranch, + enabled = true, +}: UseConflictResolverOptions): UseConflictResolverReturn => { + const [conflictCode, setConflictCode] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!enabled || !repoPath || !id || !baseBranch || !compareBranch) { + setConflictCode(null); + return; + } + + let isCancelled = false; + + Promise.resolve() + .then(() => { + if (isCancelled) return; + setIsLoading(true); + setError(null); + return fetchConflictCode( + repoPath, + id, + baseBranch, + compareBranch + ); + }) + .then((data) => { + if (isCancelled) return; + setConflictCode( + data + ? { destCode: data.dest_code, sourceCode: data.source_code } + : null + ); + }) + .catch((err) => { + if (isCancelled) return; + const message = + err instanceof Error ? err.message : "Failed to load conflict code."; + setError(message); + setConflictCode(null); + }) + .finally(() => { + if (!isCancelled) { + setIsLoading(false); + } + }); + + return () => { + isCancelled = true; + }; + }, [repoPath, id, baseBranch, compareBranch, enabled]); + + return { conflictCode, isLoading, error }; +}; + diff --git a/src/frontend/src/features/DiffPage/hooks/useDiffTree.ts b/src/frontend/src/features/DiffPage/hooks/useDiffTree.ts new file mode 100644 index 0000000..33ee80e --- /dev/null +++ b/src/frontend/src/features/DiffPage/hooks/useDiffTree.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; +import type { ProjectTreeNode } from "../ProjectTree/types"; +import { fetchDiffTree } from "../../../services/diifs"; + +interface UseDiffTreeOptions { + repoPath: string; + baseBranch: string; + compareBranch: string; +} + +interface UseDiffTreeReturn { + treeNodes: ProjectTreeNode[]; + isLoading: boolean; + error: string | null; +} + +export const useDiffTree = ({ + repoPath, + baseBranch, + compareBranch, +}: UseDiffTreeOptions): UseDiffTreeReturn => { + const [treeNodes, setTreeNodes] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!repoPath || !baseBranch || !compareBranch) return; + + let isCancelled = false; + + Promise.resolve() + .then(() => { + if (isCancelled) return; + setIsLoading(true); + setError(null); + return fetchDiffTree(repoPath, baseBranch, compareBranch); + }) + .then((data) => { + if (isCancelled) return; + setTreeNodes(data ?? []); + }) + .catch((err) => { + if (isCancelled) return; + const message = + err instanceof Error ? err.message : "Failed to load diff tree."; + setError(message); + setTreeNodes([]); + }) + .finally(() => { + if (!isCancelled) { + setIsLoading(false); + } + }); + + return () => { + isCancelled = true; + }; + }, [repoPath, baseBranch, compareBranch]); + + return { treeNodes, isLoading, error }; +}; + diff --git a/src/frontend/src/features/DiffPage/index.tsx b/src/frontend/src/features/DiffPage/index.tsx index d11fcd1..4ab0408 100644 --- a/src/frontend/src/features/DiffPage/index.tsx +++ b/src/frontend/src/features/DiffPage/index.tsx @@ -1,16 +1,15 @@ import type { FC } from "react"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import type { Branches } from "../../type"; +import type { ProjectTreeNode } from "./ProjectTree/types"; import DiffPageHeader from "./components/DiffPageHeader"; -import RepoPathInfo from "./components/RepoPathInfo"; -import BranchSelect from "./components/BranchSelect"; import ProjectTree from "./ProjectTree/ProjectTree"; -import type { ProjectTreeNode } from "./ProjectTree/types"; -import { fetchDiffTree } from "../../services/diifs"; -import { Diff, Hunk, parseDiff } from "react-diff-view"; -import "react-diff-view/style/index.css"; +import BranchSelectionSection from "./components/BranchSelectionSection"; +import DiffViewerSection from "./components/DiffViewerSection"; +import { useDiffTree, useBranchDefaults } from "./hooks"; import { Button } from "@/components/ui/button"; -import { ArrowLeftRight } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { AlertTriangle } from "lucide-react"; interface DiffPageProps { repoPath: string; @@ -21,75 +20,52 @@ interface DiffPageProps { const DiffPage: FC = ({ repoPath, onBack, branches }) => { const currentBranch = branches.find((b) => b.is_default); - const [baseBranch, setBaseBranch] = useState( - currentBranch?.name ?? branches[0]?.name ?? "" - ); - const [compareBranch, setCompareBranch] = useState( - branches.find((b) => b.is_current || !b.is_default)?.name ?? - branches[0]?.name ?? - "" - ); + const { baseBranch, compareBranch, setBaseBranch, setCompareBranch } = + useBranchDefaults(branches); + + const { + treeNodes, + isLoading: isLoadingDiff, + error: diffError, + } = useDiffTree({ + repoPath, + baseBranch, + compareBranch, + }); - const [treeNodes, setTreeNodes] = useState([]); const [selectedNode, setSelectedNode] = useState( - null + treeNodes.length > 0 ? treeNodes[0] : null ); - const [isLoadingDiff, setIsLoadingDiff] = useState(false); - const [diffError, setDiffError] = useState(null); const [treeMode, setTreeMode] = useState<"flat" | "tree">("flat"); - - useEffect(() => { - if (!repoPath || !baseBranch || !compareBranch) return; - let isCancelled = false; - - Promise.resolve() - .then(() => { - if (isCancelled) return; - setIsLoadingDiff(true); - setDiffError(null); - return fetchDiffTree(repoPath, baseBranch, compareBranch, treeMode); - }) - .then((data) => { - if (isCancelled) return; - setTreeNodes(data ?? []); - if (data && data.length > 0) { - setSelectedNode(data[0]); - } else { - setSelectedNode(null); - } - }) - .catch((err) => { - if (isCancelled) return; - const message = - err instanceof Error ? err.message : "Failed to load diff tree."; - setDiffError(message); - setTreeNodes([]); - setSelectedNode(null); - }) - .finally(() => { - if (!isCancelled) { - setIsLoadingDiff(false); - } - }); - - return () => { - isCancelled = true; - }; - }, [repoPath, baseBranch, compareBranch, treeMode]); - - const diffText = selectedNode?.source ?? ""; - - const diffFile = useMemo(() => { - if (!diffText) return null; - try { - const files = parseDiff(diffText); - return files[0] ?? null; - } catch { - return null; - } - }, [diffText]); - - const selectedPath = selectedNode?.path || selectedNode?.label || ""; + const [diffViewerType, setDiffViewerType] = useState< + "monaco" | "react-diff-view" + >("monaco"); + + const conflictCount = useMemo(() => { + const walk = (nodes: ProjectTreeNode[]): number => + nodes.reduce( + (acc, node) => + acc + + (node.has_conflict ? 1 : 0) + + (node.children ? walk(node.children) : 0), + 0 + ); + return walk(treeNodes); + }, [treeNodes]); + + const selectedHasConflict = !!selectedNode?.has_conflict; + + // Update selected node when tree nodes change + if (treeNodes.length > 0 && !selectedNode) { + setSelectedNode(treeNodes[0]); + } else if (treeNodes.length === 0 && selectedNode) { + setSelectedNode(null); + } + + // Reset to react-diff-view if selected node loses conflict status + if (!selectedHasConflict && diffViewerType === "monaco") { + setDiffViewerType("react-diff-view"); + } const handleSwapBranches = () => { if (!baseBranch || !compareBranch) return; @@ -101,10 +77,22 @@ const DiffPage: FC = ({ repoPath, onBack, branches }) => { setTreeMode(mode); }; + const handleDiffViewerChange = () => { + // Only allow switching to Monaco if there's a conflict, otherwise force react-diff-view + if (selectedHasConflict) { + setDiffViewerType( + diffViewerType === "monaco" ? "react-diff-view" : "monaco" + ); + } else { + // If no conflict, always use react-diff-view + setDiffViewerType("react-diff-view"); + } + }; + return (
-
-
+
+
= ({ repoPath, onBack, branches }) => {
-
-
- - - - -
- -
-
-
-

- Diff - {baseBranch && compareBranch && ( - - ({baseBranch} → {compareBranch}) + + +

+
+
+

+ Diff Viewer +

+ {conflictCount > 0 && ( + + + + {conflictCount} conflict + {conflictCount > 1 ? "s" : ""} + + + )} + {selectedHasConflict && ( + + + Conflict in selected item )} -

- {selectedPath && ( - - {selectedPath} - +
+ {selectedHasConflict && ( + )}
-

- Use the dropdowns above to choose the base and compare - branches. -

- {isLoadingDiff && ( -

- Loading diff tree… -

- )} - {diffError && ( -

{diffError}

- )} - {diffFile ? ( -
-
- - {(hunks) => - hunks.map((hunk) => ( - - )) - } - -
-
- ) : ( -
- Diff preview will appear here. -
- )}
+ +
diff --git a/src/frontend/src/services/diifs.ts b/src/frontend/src/services/diifs.ts index c1185ca..923a307 100644 --- a/src/frontend/src/services/diifs.ts +++ b/src/frontend/src/services/diifs.ts @@ -8,6 +8,18 @@ interface DiffTreeRequestBody { tree_mode: "flat" | "tree"; } +interface ConflictResolverRequestBody { + repo_path: string; + id: string; + base_branch: string; + compare_branch: string; +} + +interface ConflictResolverResponse { + dest_code: string; + source_code: string; +} + async function fetchDiffTree( repoPath: string, baseBranch: string, @@ -26,6 +38,23 @@ async function fetchDiffTree( }); } -export { fetchDiffTree }; +async function fetchConflictCode( + repoPath: string, + id: string, + baseBranch: string, + compareBranch: string +): Promise { + const body: ConflictResolverRequestBody = { + repo_path: repoPath, + id, + base_branch: baseBranch, + compare_branch: compareBranch, + }; + + return api("/get-dest-compare-code", { + body, + }); +} +export { fetchDiffTree, fetchConflictCode }; diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 081426a..d9d252d 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -2040,6 +2040,13 @@ __metadata: languageName: node linkType: hard +"dompurify@npm:3.1.7": + version: 3.1.7 + resolution: "dompurify@npm:3.1.7" + checksum: 10c0/fcceef2e9f824d712a056fa699b0538f3337f5cf00ccb7227bdc7eba5463823e15d9aecc00a2fd81c726b28a71e7b09f0eb8a2fde1021c40e35f12dc67b66394 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -2399,6 +2406,7 @@ __metadata: eslint-plugin-react-refresh: "npm:^0.4.24" globals: "npm:^16.5.0" lucide-react: "npm:^0.553.0" + monaco-editor: "npm:^0.54.0" react: "npm:^19.2.0" react-diff-view: "npm:^3.3.2" react-dom: "npm:^19.2.0" @@ -2965,6 +2973,15 @@ __metadata: languageName: node linkType: hard +"marked@npm:14.0.0": + version: 14.0.0 + resolution: "marked@npm:14.0.0" + bin: + marked: bin/marked.js + checksum: 10c0/57a47cb110f7b1a10f398b0a7236f9183aad2dcd5345ee73f2732b6387e585d04cef472bc655d2f84c542296be9728e179aebe3ed7f2f8666b8a0a9dae592876 + languageName: node + linkType: hard + "merge2@npm:^1.3.0": version: 1.4.1 resolution: "merge2@npm:1.4.1" @@ -3085,6 +3102,16 @@ __metadata: languageName: node linkType: hard +"monaco-editor@npm:^0.54.0": + version: 0.54.0 + resolution: "monaco-editor@npm:0.54.0" + dependencies: + dompurify: "npm:3.1.7" + marked: "npm:14.0.0" + checksum: 10c0/40198c0bac873d10804a89c991c014088943b23625d68149cb38fc52c3438c896803f645a36a6f75e0d961c87e8726073cb504e2427fc33850e0b034ff6229ee + languageName: node + linkType: hard + "ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3"