diff --git a/.changeset/silver-toys-swim.md b/.changeset/silver-toys-swim.md new file mode 100644 index 00000000..d74656b5 --- /dev/null +++ b/.changeset/silver-toys-swim.md @@ -0,0 +1,6 @@ +--- +"@1git/gpuix": minor +"@1git/git": patch +--- + +Review diverged branches with header confirmation popups and return directly to Changes after integration. Compare either conflict source with the working result in an aligned split diff, with highlighted hunks, expandable context, and clearer file groups and operation status. Reject choosing an indexed version that changed after review. Push remains a separate action. diff --git a/.github/assets/conflict-review.png b/.github/assets/conflict-review.png new file mode 100644 index 00000000..cffacba3 Binary files /dev/null and b/.github/assets/conflict-review.png differ diff --git a/.github/assets/sync-review.png b/.github/assets/sync-review.png new file mode 100644 index 00000000..2a798229 Binary files /dev/null and b/.github/assets/sync-review.png differ diff --git a/apps/gpuix/README.md b/apps/gpuix/README.md index 467b2b3b..17a3a9c5 100644 --- a/apps/gpuix/README.md +++ b/apps/gpuix/README.md @@ -1592,9 +1592,15 @@ accessible label. Counts come from the last known remote refs, not live polling. - Pull/Push reuse authenticated, guarded upstream sync: fetch first, fast-forward incoming commits only, then push to the exact tracking destination without force, mirrors, tags, automatic commits or stashes. Incoming changes require a clean tree. -- If both counts are nonzero, the control fetches and opens a review dialog. - It offers Compare and points to Merge or rebase in the tab menu; it never - silently merges, rebases or force-pushes. +- If both counts are nonzero, the control fetches and opens a populated sync review + against the configured upstream. Incoming/Outgoing switch between each side's + commits and changes since their common ancestor. Merge and Rebase each show an + button-anchored confirmation pinned to both reviewed tips; dirty or stale workspaces + remain reviewable with an explanation of what must be resolved before writing. + Escape cancels the confirmation, then leaves the review. Conflicts open Changes + with its existing Continue/Abort controls. Successful integration returns directly + to Changes. Push remains a separate action on the rail using the refreshed count + and exact upstream destination; nothing silently integrates, pushes or force-pushes. - Without remotes, the control opens the existing right-side Publish repository form. Without an upstream, it opens Publish branch: explicitly choose a remote with a single push URL and confirm. Changing HEAD/branch invalidates the choice; @@ -2266,11 +2272,24 @@ hot reload cannot replace a loaded addon. This bridge is macOS-only. ### Manual conflict resolution -- Conflicted Changes rows show a red status icon and **Conflict** label. Selecting - one shows the read-only working file, including its conflict markers. -- **Open in editor** uses the same file-type rules and overall **External editor** +- Changes groups unresolved files above other changes, with an amber **Unresolved** + badge. Commit-inclusion controls and the ordinary draft editor are hidden while + reviewing an active operation; the draft is retained for returning to normal work. +- Selecting a conflict opens an aligned split diff: the selected indexed version + on the left, and the unchanged working result, including markers, on the right. + Both columns share vertical scrolling, with syntax highlighting, original line + numbers and highlighted changes. Unchanged gaps start collapsed; clicking a hunk + header or trailing context action expands them from the already-read text. +- Switch **Current** / **Incoming** to compare either source with the same result. + Rebase sources are labelled **Upstream version** and **Replayed commit**. Versions + come from index stages 2/3, not reconstructed conflict-marker text. Missing, binary + or oversized sources show an explanation instead of a partial preview. +- The result header shows the remaining conflict-block count. **Mark resolved** is + disabled while marker lines remain. **Use this version…** requires whole-file + replacement confirmation pinned to the reviewed content hash and indexed blob. +- **Open editor** uses the same file-type rules and overall **External editor** preference (macOS's default text editor until changed), never a repository-provided shell command. - Save edits there, then **Refresh preview**. + Save edits there, then **Refresh**. - **Mark resolved** stages only the reviewed file. It rechecks HEAD/branch, conflict status, regular-file safety, UTF-8 content, remaining marker lines, and the preview's content hash. It does not commit, continue/abort a merge or @@ -2287,7 +2306,8 @@ hot reload cannot replace a loaded addon. This bridge is macOS-only. ### Merge, squash merge, rebase, revert and cherry-pick completion -- Changes shows the active merge/squash merge/rebase/revert/cherry-pick, conflict count, **Continue** and **Abort…**. +- Changes shows a compact amber pause state for unresolved conflicts, **Continue** + and **Abort…**. Raw Git diagnostics are available in **Details…**. - Continue uses Git's staged resolutions and existing message, retaining hooks and signing. It never stages files or uses the commit-inclusion checkboxes. Unresolved conflicts or unstaged tracked edits block continuation. diff --git a/apps/gpuix/src/app.tsx b/apps/gpuix/src/app.tsx index 8f00251e..ac65a320 100644 --- a/apps/gpuix/src/app.tsx +++ b/apps/gpuix/src/app.tsx @@ -136,6 +136,7 @@ import { WorkspaceRail } from "./feature/repository-workspace/components/workspa import { RepositorySyncButton } from "./feature/repository-workspace/components/repository-sync-button"; import type { RepositoryView } from "./feature/repository-workspace/types"; import { RepositoryComparison } from "./feature/repository-comparison/repository-comparison"; +import { RepositorySyncReview } from "./feature/repository-comparison/repository-sync-review"; import { RepositoryComparison as ComparisonReader } from "./feature/repository-comparison/lib/repository-comparison"; import { RepositoryPullRequest } from "./feature/repository-pull-request/repository-pull-request"; import { RepositoryPullRequestView } from "./feature/repository-pull-request/repository-pull-request-view"; @@ -1745,7 +1746,7 @@ function AppContent({ workspace.repository.commonDirectory ) } - onCompare={(data) => { + onCompare={(data, intent) => { const repository = workspace.repository; if (!repository?.head) return; @@ -1753,16 +1754,26 @@ function AppContent({ dispatch({ type: "compare", id: activeTab.id, - comparison: { - root: repository.root, - base: repository.head, - baseName: repository.branchName ?? "HEAD", - branches: data.branches.branches.map((item) => ({ - value: item.fullName, - label: item.name, - oid: item.oid, - })), - }, + comparison: + intent === "sync" && repository.branchName + ? ComparisonReader.syncRequest( + repository.root, + repository.head, + repository.branchName, + data + ) + : { + root: repository.root, + base: repository.head, + baseName: repository.branchName ?? "HEAD", + branches: data.branches.branches.map( + (item) => ({ + value: item.fullName, + label: item.name, + oid: item.oid, + }) + ), + }, }); }} onPreviewPullRequest={() => { @@ -2217,6 +2228,24 @@ function AppContent({ diffSettings={diffSettings} onDiffSettingsChange={setDiffSettings} /> + ) : activeTab?.comparison?.intent === "sync" && workspace ? ( + branch.oid).join(":")}`} + request={activeTab.comparison} + state={workspace} + onClose={() => dispatch({ type: "compare", id: activeTab.id })} + onIntegrate={(choice) => + mutations.changeBranch(activeTab.id, choice) + } + readComparison={readComparison} + readHistory={readHistory} + readCommitFiles={readCommitFiles} + readDiff={readDiff} + previewCache={previewCache} + diffSettings={diffSettings} + onDiffSettingsChange={setDiffSettings} + /> ) : activeTab?.comparison ? ( - mutations.resolve(activeTab.id, comparison.path, hash, side) + onResolve={(hash, side, expectedSideOid) => + mutations.resolve( + activeTab.id, + comparison.path, + hash, + side, + expectedSideOid + ) } openConflictEditor={launchConflict} /> diff --git a/apps/gpuix/src/feature/repository-comparison/lib/repository-comparison.ts b/apps/gpuix/src/feature/repository-comparison/lib/repository-comparison.ts index 9068b752..1dcb92bc 100644 --- a/apps/gpuix/src/feature/repository-comparison/lib/repository-comparison.ts +++ b/apps/gpuix/src/feature/repository-comparison/lib/repository-comparison.ts @@ -2,14 +2,14 @@ import { type BranchComparison, type GitResult } from "@1git/git"; import { GitRepository } from "../../machines/repository-access"; import { RepositoryHistory } from "../../repository-workspace/lib/repository-history"; import type { WorkspaceEntry } from "../../repository-workspace/types"; +import type { WorkspaceBranches } from "../../workspace-tabs/lib/repository-branches"; /** A comparison captures references when opened, without changing the workspace. */ -export interface ComparisonRequest { +interface ComparisonBranches { readonly root: string; readonly base: string; readonly baseName: string; readonly incoming?: string; - readonly intent?: "pull-request"; readonly branches: readonly { readonly value: string; readonly label: string; @@ -17,9 +17,78 @@ export interface ComparisonRequest { }[]; } +/** Sync reviews keep both branch tips and their fetched counts together. */ +export type ComparisonRequest = ComparisonBranches & + ( + | { readonly intent?: "pull-request" } + | { + readonly intent: "sync"; + readonly incoming: string; + readonly ahead: number; + readonly behind: number; + readonly remote: string; + readonly remoteRef: string; + } + ); + +export type SyncComparisonRequest = Extract< + ComparisonRequest, + { intent: "sync" } +>; + export class RepositoryComparison { private constructor() {} + /** Select the configured tracking ref from the fetched collection without guessing a remote. */ + static syncRequest( + root: string, + head: string, + branchName: string, + data: WorkspaceBranches + ): SyncComparisonRequest { + const branch = data.branches.branches.find( + (item) => item.type === "local" && item.name === branchName + ); + + if (data.branches.current !== branchName || branch?.oid !== head) + throw new Error( + "The branch changed. Go back and sync again to refresh the review." + ); + + const upstream = branch.upstream + ? data.branches.branches.find( + (item) => item.type === "remote" && item.name === branch.upstream + ) + : undefined; + + if ( + !upstream || + !branch.upstreamRemote || + branch.upstreamRemote === "." || + !branch.upstreamRef + ) + throw new Error( + "The upstream branch is unavailable. Check Remotes, then sync again." + ); + + return { + root, + base: head, + baseName: branchName, + intent: "sync", + incoming: upstream.fullName, + ahead: branch.ahead, + behind: branch.behind, + remote: branch.upstreamRemote, + remoteRef: branch.upstreamRef, + branches: [branch, upstream].map((item) => ({ + value: item.fullName, + label: item.name, + oid: item.oid, + })), + }; + } + static async read( root: string, base: string, diff --git a/apps/gpuix/src/feature/repository-comparison/repository-comparison.tsx b/apps/gpuix/src/feature/repository-comparison/repository-comparison.tsx index 7d389149..a0733830 100644 --- a/apps/gpuix/src/feature/repository-comparison/repository-comparison.tsx +++ b/apps/gpuix/src/feature/repository-comparison/repository-comparison.tsx @@ -29,7 +29,6 @@ import { RepositoryFileLayoutToggle } from "../repository-workspace/components/r import type { WorkspaceEntry } from "../repository-workspace/types"; import { ComparisonArtwork } from "../../fixture/comparison-artwork"; import { StateMessage } from "../../fixture/state-message"; -import { EmptyListPlaceholder } from "../../fixture/empty-list-placeholder"; const themedStyles = createThemedStyles(({ tokens: appTokens }) => { const styles = StyleSheet.create({ @@ -133,7 +132,7 @@ const themedStyles = createThemedStyles(({ tokens: appTokens }) => { return { styles }; }); -type Readers = { +export type ComparisonReaders = { fileTreeScope?: string; readComparison?: typeof Reader.read; readHistory?: typeof RepositoryHistory.read; @@ -161,13 +160,17 @@ export function RepositoryComparison({ }) => ReactNode; notice?: ReactNode; backActions?: ReactNode; -} & Readers) { +} & ComparisonReaders) { const theme = useAppTheme(); const { tokens: appTokens } = theme; const { styles } = themedStyles.get(theme); const [branch, setBranch] = useState(request.incoming ?? ""); + const [direction, setDirection] = useState<"incoming" | "outgoing">( + "incoming" + ); + const initialBase = request.branches.find( (item) => item.label === request.baseName && item.oid === request.base ) ?? { @@ -190,12 +193,19 @@ export function RepositoryComparison({ return (
-
+
- item.value !== request.incoming) - : options - } - onChange={setBase} - /> - - {request.intent === "pull-request" ? ( + {request.intent === "sync" ? ( - {selected?.label} + {request.baseName} · {selected?.label} ) : ( - + <> + item.value !== request.incoming + ) + : options + } + onChange={setBase} + /> + + {request.intent === "pull-request" ? ( + + {selected?.label} + + ) : ( + + )} + {request.intent !== "pull-request" ? ( + + ) : null} + )} - {request.intent !== "pull-request" ? ( - - ) : null}
{children ? ( @@ -271,12 +302,36 @@ export function RepositoryComparison({ ) : null}
{notice} + {request.intent === "sync" ? ( +
+ {(["incoming", "outgoing"] as const).map((side) => ( + + ))} +
+ ) : null} {selected && selectedBase.oid ? ( ) : ( @@ -286,12 +341,16 @@ export function RepositoryComparison({ Incoming commits - + + Choose branches to see commits. +
Changed files - + + Choose branches to see files. +
@@ -381,6 +440,7 @@ function ComparisonResults({ request, base, head, + direction, readComparison = Reader.read, readHistory = RepositoryHistory.read, readCommitFiles = RepositoryHistory.readFiles, @@ -389,11 +449,19 @@ function ComparisonResults({ diffSettings, onDiffSettingsChange, fileTreeScope, -}: { request: ComparisonRequest; base: string; head: string } & Readers) { +}: { + request: ComparisonRequest; + base: string; + head: string; + direction: "incoming" | "outgoing"; +} & ComparisonReaders) { const theme = useAppTheme(); const { tokens: appTokens } = theme; const { styles } = themedStyles.get(theme); + const commitsLabel = + direction === "incoming" ? "Incoming commits" : "Outgoing commits"; + const [comparison, setComparison] = useState(null); const [page, setPage] = useState(null); const [offset, setOffset] = useState(0); @@ -551,7 +619,7 @@ function ComparisonResults({
- Incoming commits + {commitsLabel} {page ? ` · ${page.commits.length}${page.hasMore ? "+" : ""}` : ""} @@ -559,11 +627,15 @@ function ComparisonResults({ + + No {direction} commits. + ) : ( + + No file changes. + ) : ( ) : ( void>(); + +/** Escape leaves the review; confirmation popovers retain keyboard priority. */ +export function handleSyncReviewKeyDown( + ...[event, renderer]: Parameters +): boolean { + if ( + event.key !== "escape" || + event.isHeld || + event.modifiers?.cmd || + event.modifiers?.ctrl || + event.modifiers?.alt || + event.modifiers?.shift + ) + return false; + + const dismiss = dismissals.get(renderer); + + if (!dismiss) return false; + + dismiss(); + + return true; +} + +/** Review both fetched tips and explicitly confirm integration before returning to Changes. */ +export function RepositorySyncReview({ + request, + state, + onClose, + onIntegrate, + ...readers +}: { + request: SyncComparisonRequest; + state: RepositoryWorkspaceState; + onClose: () => void; + onIntegrate: ( + choice: Omit + ) => Promise; +} & ComparisonReaders) { + const { tokens } = useAppTheme(); + const renderer = useGpuixRequired(); + const [method, setMethod] = useState<"merge" | "rebase" | null>(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const starting = useRef(false); + const repository = state.repository; + + const upstream = request.branches.find( + (item) => item.value === request.incoming + ); + + const busy = pending || RepositoryWorkspaceModel.isBusy(state); + + const stale = + repository?.root !== request.root || + repository.head !== request.base || + repository.branchName !== request.baseName || + repository.upstream !== upstream?.label; + + const reason = + state.error || + (state.commit.needsReview + ? "Refresh the workspace before continuing." + : stale + ? "The branch changed. Go back and sync again to refresh this review." + : state.entries.changes.length > 0 + ? "Commit or stash your changes before merging or rebasing. You can still review both sides." + : null); + + const operation = repository?.operation; + + useEffect(() => { + if ((operation && operation !== "normal") || request.behind === 0) + onClose(); + }, [operation, request.behind, onClose]); + useEffect(() => { + const dismiss = (): void => { + if (busy) return; + + if (method) setMethod(null); + else onClose(); + }; + + dismissals.set(renderer, dismiss); + + return () => { + dismissals.delete(renderer); + }; + }, [renderer, busy, method, onClose]); + + const integrate = async (): Promise => { + if ( + !method || + !upstream || + reason || + busy || + state.loading || + starting.current + ) + return; + + starting.current = true; + setPending(true); + setError(null); + + try { + const failure = await onIntegrate({ + kind: method, + name: upstream.value, + expectedTarget: upstream.oid, + reviewedSource: { head: request.base, branch: request.baseName }, + }); + + if (failure) setError(failure); + else onClose(); + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : "Could not confirm integration. Refresh before trying again." + ); + } finally { + starting.current = false; + setPending(false); + } + }; + + return ( +
+ + + {error ?? reason} + +
+ ) : null + } + > + {() => ( +
+ {(["merge", "rebase"] as const).map((kind) => ( + { + if (busy) return; + + if (open) setError(null); + + setMethod(open ? kind : null); + }} + > + + + + +
+ + {kind === "merge" ? "Merge branches?" : "Rebase branch?"} + + + {kind === "merge" + ? `Merge ${upstream?.label} into ${request.baseName}, keeping both histories. Conflicts can be resolved in Changes.` + : `Replay ${request.baseName} onto ${upstream?.label}. Only unpublished commits can be rewritten.`}{" "} + Push separately when you’re ready. + + {error || reason ? ( + + {error ?? reason} + + ) : null} +
+ + +
+
+
+
+ ))} +
+ )} + +
+ ); +} diff --git a/apps/gpuix/src/feature/repository-workspace/components/repository-change-details.tsx b/apps/gpuix/src/feature/repository-workspace/components/repository-change-details.tsx index 236115e4..3f09526e 100644 --- a/apps/gpuix/src/feature/repository-workspace/components/repository-change-details.tsx +++ b/apps/gpuix/src/feature/repository-workspace/components/repository-change-details.tsx @@ -4,6 +4,7 @@ import { StyleSheet } from "@1git/gpuix-stylesheet"; import { OverflowingLabel } from "../../../fixture/overflowing-label"; import type { WorkspaceEntry } from "../types"; +import { repositoryConflictTheme } from "../lib/repository-conflict-theme"; const statusSymbols = { M: '', @@ -64,13 +65,14 @@ export function RepositoryChangeLabel({ reveal?: boolean; }) { const theme = useAppTheme(); - const { tokens: appTokens, colors: appColors } = theme; + const { colors: appColors } = theme; const { styles } = themedStyles.get(theme); const slash = entry.title.lastIndexOf("/"); const filename = entry.title.slice(slash + 1); const directory = slash < 0 ? "" : entry.title.slice(0, slash); const status = entry.status ?? "M"; + const conflictColors = repositoryConflictTheme.get(theme).warning; return (
@@ -83,7 +85,7 @@ export function RepositoryChangeLabel({ flexShrink: 0, color: status === "U" - ? appTokens.colors.danger + ? conflictColors.ink : status === "A" ? appColors.success : status === "D" @@ -110,11 +112,6 @@ export function RepositoryChangeLabel({ > {filename} - {status === "U" ? ( - - Conflict - - ) : null} {showDirectory && directory ? ( {directory} @@ -122,8 +119,18 @@ export function RepositoryChangeLabel({ ) : null} {status === "U" ? ( - - Conflict + + Unresolved ) : null}
diff --git a/apps/gpuix/src/feature/repository-workspace/components/repository-conflict-files.tsx b/apps/gpuix/src/feature/repository-workspace/components/repository-conflict-files.tsx new file mode 100644 index 00000000..5b2dd30c --- /dev/null +++ b/apps/gpuix/src/feature/repository-workspace/components/repository-conflict-files.tsx @@ -0,0 +1,122 @@ +import { useState } from "react"; +import { spacing, typography } from "@1git/design-tokens"; +import { useAppTheme } from "../../../styles/theme"; +import { repositoryConflictTheme } from "../lib/repository-conflict-theme"; +import type { RepositoryListState } from "../lib/repository-workspace-model"; +import type { WorkspaceEntry } from "../types"; +import type { FileMenuActions } from "./repository-file-menu"; +import { RepositoryList, fileRowHeight } from "./repository-list"; + +const groupHeaderHeight = typography.xs.lineHeight + spacing[2.5] * 2; +const groupInset = groupHeaderHeight + spacing[1.5]; + +/** Resolution progress replaces commit-inclusion checkboxes while a Git operation owns the index. */ +export function RepositoryConflictFiles({ + entries, + state, + onSelect, + onPreviewIntent, + fileActions, +}: { + entries: readonly WorkspaceEntry[]; + state: RepositoryListState; + onSelect: (id: string) => void; + onPreviewIntent?: (entry: WorkspaceEntry | null) => void; + fileActions?: FileMenuActions; +}) { + const theme = useAppTheme(); + const palette = repositoryConflictTheme.get(theme); + const [scroll, setScroll] = useState({ conflicts: 0, changes: 0 }); + + const conflicts = entries.filter( + (entry) => entry.change?.recordType === "unmerged" + ); + + const changes = entries.filter( + (entry) => entry.change?.recordType !== "unmerged" + ); + + const groups = [ + { id: "conflicts" as const, title: "Conflicts", entries: conflicts }, + { id: "changes" as const, title: "Other changes", entries: changes }, + ]; + + return ( +
+ {!entries.length ? ( + + No files to show. + + ) : null} + {groups + .filter((group) => group.entries.length > 0) + .map((group) => ( +
+ + {`${group.title} · ${group.entries.length}`} + + + setScroll((previous) => + previous[group.id] === y + ? previous + : { ...previous, [group.id]: y } + ) + } + onPreviewIntent={onPreviewIntent} + fileActions={fileActions} + /> +
+ ))} +
+ ); +} diff --git a/apps/gpuix/src/feature/repository-workspace/components/repository-conflict-preview.tsx b/apps/gpuix/src/feature/repository-workspace/components/repository-conflict-preview.tsx new file mode 100644 index 00000000..9979ebb5 --- /dev/null +++ b/apps/gpuix/src/feature/repository-workspace/components/repository-conflict-preview.tsx @@ -0,0 +1,594 @@ +import { useMemo, useRef, useState } from "react"; +import { spacing } from "@1git/design-tokens"; +import { Button } from "@1git/gpuix-ui/button"; +import { DiffView } from "@1git/gpuix-ui/diff-view"; +import { Text } from "@1git/gpuix-ui/text"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@1git/gpuix-ui/popover"; +import { useAppTheme } from "../../../styles/theme"; +import { + RepositoryConflicts, + type ConflictPreview, +} from "../lib/repository-conflicts"; +import { repositoryConflictTheme } from "../lib/repository-conflict-theme"; +import { repositoryDiffTheme } from "../lib/repository-diff-theme"; +import { diffLimits } from "../lib/repository-diff"; +import { DiffContext } from "../lib/diff-context"; +import { ConflictPreviewModel } from "../lib/conflict-preview"; + +/** A single native list aligns both versions; context expansion uses the already-read text. */ +function ConflictComparisonView({ + path, + source, + result, + disabled, +}: { + path: string; + source: string | null | undefined; + result: string; + disabled: boolean; +}) { + const theme = useAppTheme(); + + const comparison = useMemo(() => { + const compact = ConflictPreviewModel.compare(path, source, result); + + const full = compact.message + ? compact + : ConflictPreviewModel.compare(path, source, result, diffLimits.lines); + + return { ...compact, syntaxPatch: full.patch || undefined }; + }, [path, source, result]); + + const [expanded, setExpanded] = useState<{ + comparison: typeof comparison; + patch: string; + endReached: boolean; + } | null>(null); + + const [failure, setFailure] = useState<{ + comparison: typeof comparison; + message: string; + } | null>(null); + + const patch = + expanded?.comparison === comparison ? expanded.patch : comparison.patch; + + const expandAfter = + !(expanded?.comparison === comparison && expanded.endReached) && + DiffContext.hasAfter(patch, comparison.syntaxPatch); + + const expand = (oldLine?: number, newLine?: number, after = false): void => { + if (disabled) return; + + try { + const request = DiffContext.request(patch, oldLine, newLine, after); + + if (!request) return; + + const candidate = + comparison.syntaxPatch ?? + ConflictPreviewModel.compare(path, source, result, request.contextLines) + .patch; + + if (!candidate) + throw new Error( + "More context exceeds the preview limit. Open the file in your editor." + ); + + const next = DiffContext.expand(patch, candidate, request); + + setExpanded({ comparison, ...next }); + setFailure(null); + } catch (cause) { + setFailure({ + comparison, + message: + cause instanceof Error + ? cause.message + : "Could not expand this context.", + }); + } + }; + + if (comparison.message) + return ( + + {comparison.message} + + ); + + return ( + <> + + expand(event.oldLine, event.newLine, event.value === "after") + } + /> + {failure?.comparison === comparison ? ( + + {failure.message} + + ) : null} + + ); +} + +interface VersionChoice { + kind: "ours" | "theirs"; + hash: string; + revision: string; + label: string; +} + +/** Compare an indexed source against the working file without changing it; replacement stays explicit. */ +export function RepositoryConflictPreview({ + root, + path, + preview, + message, + disabled, + onResolve, + openEditor = RepositoryConflicts.openEditor, + onRefresh, +}: { + root: string; + path: string; + preview?: ConflictPreview; + message?: string | null; + disabled: boolean; + onResolve?: ( + hash: string, + side?: "ours" | "theirs", + expectedSideOid?: string + ) => Promise; + openEditor?: typeof RepositoryConflicts.openEditor; + onRefresh: () => void; +}) { + const theme = useAppTheme(); + const { tokens } = theme; + const palette = repositoryConflictTheme.get(theme); + const [compared, setCompared] = useState<"ours" | "theirs">("ours"); + const [acting, setActing] = useState(false); + const [choice, setChoice] = useState(null); + const [error, setError] = useState(null); + + const markers = useMemo( + () => ConflictPreviewModel.markers(preview?.text ?? ""), + [preview?.text] + ); + + const busy = disabled || acting; + const starting = useRef(false); + const source = preview?.[compared]; + + const title = + source?.label ?? + (compared === "ours" ? "Current version" : "Incoming version"); + + const tone = compared === "ours" ? palette.current : palette.incoming; + + const edit = async (): Promise => { + if (busy || starting.current) return; + + starting.current = true; + setActing(true); + setError(null); + + try { + await openEditor(root, path); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "Could not open the editor." + ); + } finally { + starting.current = false; + setActing(false); + } + }; + + const resolve = async (replacement?: VersionChoice): Promise => { + if ( + !preview || + !onResolve || + busy || + starting.current || + (!replacement && markers.unresolved) + ) + return; + + starting.current = true; + setActing(true); + setError(null); + + try { + const failure = replacement + ? await onResolve( + replacement.hash, + replacement.kind, + replacement.revision + ) + : await onResolve(preview.hash); + + setError(failure); + + if (!failure) setChoice(null); + } catch { + setError("Could not confirm resolution. Refresh before retrying."); + } finally { + starting.current = false; + setActing(false); + } + }; + + return ( +
+
+ {preview ? ( +
+ {(["ours", "theirs"] as const).map((kind) => ( + + ))} +
+ ) : null} + + + {preview ? ( + + ) : null} +
+ {preview ? ( + <> +
+
+ + {title} + + { + if (busy) return; + + setError(null); + setChoice( + open && source?.revision && preview + ? { + kind: compared, + hash: preview.hash, + revision: source.revision, + label: title, + } + : null + ); + }} + > + + + + +
+ + Use {choice?.label}? + + + Replace the whole file and mark it resolved? Current edits + to this file will be lost. + +
+ + +
+ {error ? ( + + {error} + + ) : null} +
+
+
+
+
+ + Working result + + + {markers.unresolved + ? markers.starts.length + ? `${markers.starts.length} conflict ${markers.starts.length === 1 ? "block" : "blocks"} · unresolved` + : "Unresolved markers remain" + : "Ready to mark resolved"} + +
+
+ + + ) : ( + + {message ?? "Loading conflict versions…"} + + )} +
+ + {!preview || markers.unresolved + ? "Save edits in your editor, then Refresh." + : "Mark resolved stages the file. Continue separately."} + +
+ {error && !choice ? ( + + {error} + + ) : null} +
+ ); +} diff --git a/apps/gpuix/src/feature/repository-workspace/components/repository-diff-empty.tsx b/apps/gpuix/src/feature/repository-workspace/components/repository-diff-empty.tsx index 4d8b74fa..96ea0d52 100644 --- a/apps/gpuix/src/feature/repository-workspace/components/repository-diff-empty.tsx +++ b/apps/gpuix/src/feature/repository-workspace/components/repository-diff-empty.tsx @@ -155,8 +155,13 @@ export function RepositoryDiffEmpty({ ? "Select a file to inspect" : "Working tree clean"; - hint = - "Choose a file to inspect its diff. Check the whole files you want to commit."; + hint = workspace.entries.changes.some( + (entry) => entry.change?.recordType === "unmerged" + ) + ? "Choose a conflicted file to compare its versions and review the working result." + : workspace.repository.operation !== "normal" + ? "Choose a file to review before continuing the Git operation." + : "Choose a file to inspect its diff. Check the whole files you want to commit."; } } diff --git a/apps/gpuix/src/feature/repository-workspace/components/repository-diff.tsx b/apps/gpuix/src/feature/repository-workspace/components/repository-diff.tsx index 680f9f6d..c7e400d5 100644 --- a/apps/gpuix/src/feature/repository-workspace/components/repository-diff.tsx +++ b/apps/gpuix/src/feature/repository-workspace/components/repository-diff.tsx @@ -25,6 +25,7 @@ import { } from "./repository-image-preview"; import { RepositoryTextDiff, type DiffInclusion } from "./repository-text-diff"; import { RepositoryDiffSettings } from "./repository-diff-settings"; +import { RepositoryConflictPreview } from "./repository-conflict-preview"; const themedStyles = createThemedStyles((theme) => { const { colors: appColors } = theme; @@ -97,7 +98,8 @@ interface RepositoryDiffProps { files?: readonly WorkspaceEntry[]; onResolve?: ( hash: string, - side?: "ours" | "theirs" + side?: "ours" | "theirs", + expectedSideOid?: string ) => Promise; openConflictEditor?: typeof RepositoryConflicts.openEditor; settings?: DiffSettings; @@ -179,13 +181,6 @@ function RepositoryDiffContent({ const { diffTheme, styles } = themedStyles.get(theme); const [sourcePath, setSourcePath] = useState(null); - const [conflictError, setConflictError] = useState(null); - - const [resolutionSide, setResolutionSide] = useState< - "ours" | "theirs" | null - >(null); - - const [acting, setActing] = useState(false); const conflicted = "recordType" in change && change.recordType === "unmerged"; const section = result?.sections[0]; @@ -277,210 +272,6 @@ function RepositoryDiffContent({ /> ) : null}
- {conflicted ? ( -
-
- - Conflict · Working file - - - - -
-
- {(["ours", "theirs"] as const).map((side) => ( - - ))} -
- {resolutionSide ? ( -
- - Replace the whole file with {resolutionSide} and mark it - resolved? Current edits to this file will be lost. - - - During rebase, ours is the branch being rebased onto; theirs is - the commit being replayed. - -
- - -
-
- ) : null} - - Edit and save the file, then refresh and review. Mark resolved - confirms this file’s resolution; it does not commit or continue a - merge/rebase. - - {conflictError ? ( - - {conflictError} - - ) : null} -
- ) : null} {message ? ( ) : null} - ) : section?.conflict ? ( -