From efa22e470eac9f6699beb7890e2f263fe2d918cf Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 29 Jan 2026 08:50:56 -0800 Subject: [PATCH] Add diff viewer to git commit dialog (#374) Co-authored-by: Claude Opus 4.5 --- crates/yaak-git/index.ts | 1 + package-lock.json | 41 +++ .../themes-yaak/src/themes/synthwave-84.ts | 3 - src-web/components/core/Editor/DiffViewer.css | 39 +++ src-web/components/core/Editor/DiffViewer.tsx | 64 ++++ src-web/components/git/GitCommitDialog.tsx | 286 +++++++++++------- src-web/components/git/GitDropdown.tsx | 2 +- src-web/lib/diffYaml.ts | 15 + src-web/lib/theme/window.ts | 10 + src-web/package.json | 2 + 10 files changed, 356 insertions(+), 107 deletions(-) create mode 100644 src-web/components/core/Editor/DiffViewer.css create mode 100644 src-web/components/core/Editor/DiffViewer.tsx create mode 100644 src-web/lib/diffYaml.ts diff --git a/crates/yaak-git/index.ts b/crates/yaak-git/index.ts index 32c7a3da..cd9744f1 100644 --- a/crates/yaak-git/index.ts +++ b/crates/yaak-git/index.ts @@ -6,6 +6,7 @@ import { useMemo } from 'react'; import { BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git'; export * from './bindings/gen_git'; +export * from './bindings/gen_models'; export interface GitCredentials { username: string; diff --git a/package-lock.json b/package-lock.json index 6ec5c8f0..9edec906 100644 --- a/package-lock.json +++ b/package-lock.json @@ -807,6 +807,21 @@ "@lezer/xml": "^1.0.0" } }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz", + "integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, "node_modules/@codemirror/language": { "version": "6.12.1", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", @@ -832,6 +847,19 @@ "crelt": "^1.0.5" } }, + "node_modules/@codemirror/merge": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.11.2.tgz", + "integrity": "sha512-NO5EJd2rLRbwVWLgMdhIntDIhfDtMOKYEZgqV5WnkNUS2oXOCVWLPjG/kgl/Jth2fGiOuG947bteqxP9nBXmMg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/highlight": "^1.0.0", + "style-mod": "^4.1.0" + } + }, "node_modules/@codemirror/search": { "version": "6.5.11", "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", @@ -1614,6 +1642,17 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/@lezer/yaml": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.3.tgz", + "integrity": "sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", @@ -15984,7 +16023,9 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-markdown": "^6.3.2", "@codemirror/lang-xml": "^6.1.0", + "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.11.0", + "@codemirror/merge": "^6.11.2", "@codemirror/search": "^6.5.11", "@dnd-kit/core": "^6.3.1", "@gilbarbara/deep-equal": "^0.3.1", diff --git a/plugins/themes-yaak/src/themes/synthwave-84.ts b/plugins/themes-yaak/src/themes/synthwave-84.ts index e32d2ff0..2a0977f0 100644 --- a/plugins/themes-yaak/src/themes/synthwave-84.ts +++ b/plugins/themes-yaak/src/themes/synthwave-84.ts @@ -19,9 +19,6 @@ export const synthwave84: Theme = { danger: 'hsl(340, 100%, 65%)', }, components: { - dialog: { - surface: 'hsl(253, 45%, 12%)', - }, sidebar: { surface: 'hsl(253, 42%, 18%)', border: 'hsl(253, 40%, 22%)', diff --git a/src-web/components/core/Editor/DiffViewer.css b/src-web/components/core/Editor/DiffViewer.css new file mode 100644 index 00000000..4578b372 --- /dev/null +++ b/src-web/components/core/Editor/DiffViewer.css @@ -0,0 +1,39 @@ +.cm-wrapper.cm-multiline .cm-mergeView { + @apply h-full w-full overflow-auto pr-0.5; + + .cm-mergeViewEditors { + @apply w-full min-h-full; + } + + .cm-mergeViewEditor { + @apply w-full min-h-full relative; + + .cm-collapsedLines { + @apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded cursor-default; + } + } + + .cm-line { + @apply pl-1.5; + } + .cm-changedLine { + /* Round top corners only if previous line is not a changed line */ + &:not(.cm-changedLine + &) { + @apply rounded-t; + } + /* Round bottom corners only if next line is not a changed line */ + &:not(:has(+ .cm-changedLine)) { + @apply rounded-b; + } + } + + /* Let content grow and disable individual scrolling for sync */ + .cm-editor { + @apply h-auto relative !important; + position: relative !important; + } + + .cm-scroller { + @apply overflow-visible !important; + } +} diff --git a/src-web/components/core/Editor/DiffViewer.tsx b/src-web/components/core/Editor/DiffViewer.tsx new file mode 100644 index 00000000..d8d4dfb9 --- /dev/null +++ b/src-web/components/core/Editor/DiffViewer.tsx @@ -0,0 +1,64 @@ +import { yaml } from '@codemirror/lang-yaml'; +import { syntaxHighlighting } from '@codemirror/language'; +import { MergeView } from '@codemirror/merge'; +import { EditorView } from '@codemirror/view'; +import classNames from 'classnames'; +import { useEffect, useRef } from 'react'; +import './DiffViewer.css'; +import { readonlyExtensions, syntaxHighlightStyle } from './extensions'; + +interface Props { + /** Original/previous version (left side) */ + original: string; + /** Modified/current version (right side) */ + modified: string; + className?: string; +} + +export function DiffViewer({ original, modified, className }: Props) { + const containerRef = useRef(null); + const viewRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + // Clean up previous instance + viewRef.current?.destroy(); + + const sharedExtensions = [ + yaml(), + syntaxHighlighting(syntaxHighlightStyle), + ...readonlyExtensions, + EditorView.lineWrapping, + ]; + + viewRef.current = new MergeView({ + a: { + doc: original, + extensions: sharedExtensions, + }, + b: { + doc: modified, + extensions: sharedExtensions, + }, + parent: containerRef.current, + collapseUnchanged: { margin: 2, minSize: 3 }, + highlightChanges: false, + gutter: true, + orientation: 'a-b', + revertControls: undefined, + }); + + return () => { + viewRef.current?.destroy(); + viewRef.current = null; + }; + }, [original, modified]); + + return ( +
+ ); +} diff --git a/src-web/components/git/GitCommitDialog.tsx b/src-web/components/git/GitCommitDialog.tsx index eacf7b0c..7435f3b3 100644 --- a/src-web/components/git/GitCommitDialog.tsx +++ b/src-web/components/git/GitCommitDialog.tsx @@ -1,3 +1,4 @@ +import { useCachedNode } from '@dnd-kit/core/dist/hooks/utilities'; import type { GitStatusEntry } from '@yaakapp-internal/git'; import { useGit } from '@yaakapp-internal/git'; import type { @@ -9,14 +10,16 @@ import type { Workspace, } from '@yaakapp-internal/models'; import classNames from 'classnames'; - -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { modelToYaml } from '../../lib/diffYaml'; +import { isSubEnvironment } from '../../lib/model_util'; import { resolvedModelName } from '../../lib/resolvedModelName'; import { showErrorToast } from '../../lib/toast'; import { Banner } from '../core/Banner'; import { Button } from '../core/Button'; import type { CheckboxProps } from '../core/Checkbox'; import { Checkbox } from '../core/Checkbox'; +import { DiffViewer } from '../core/Editor/DiffViewer'; import { Icon } from '../core/Icon'; import { InlineCode } from '../core/InlineCode'; import { Input } from '../core/Input'; @@ -48,6 +51,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { const [isPushing, setIsPushing] = useState(false); const [commitError, setCommitError] = useState(null); const [message, setMessage] = useState(''); + const [selectedEntry, setSelectedEntry] = useState(null); const handleCreateCommit = async () => { setCommitError(null); @@ -138,6 +142,35 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { return next(workspace, []); }, [workspace, internalEntries]); + const checkNode = useCallback( + (treeNode: CommitTreeNode) => { + const checked = nodeCheckedStatus(treeNode); + const newChecked = checked === 'indeterminate' ? true : !checked; + setCheckedAndChildren(treeNode, newChecked, unstage.mutate, add.mutate); + // TODO: Also ensure parents are added properly + }, + [add.mutate, unstage.mutate], + ); + + const checkEntry = useCallback( + (entry: GitStatusEntry) => { + if (entry.staged) unstage.mutate({ relaPaths: [entry.relaPath] }); + else add.mutate({ relaPaths: [entry.relaPath] }); + }, + [add.mutate, unstage.mutate], + ); + + const handleSelectChild = useCallback( + (entry: GitStatusEntry) => { + if (entry === selectedEntry) { + setSelectedEntry(null); + } else { + setSelectedEntry(entry); + } + }, + [selectedEntry], + ); + if (tree == null) { return null; } @@ -146,77 +179,92 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { return No changes since last commit; } - const checkNode = (treeNode: CommitTreeNode) => { - const checked = nodeCheckedStatus(treeNode); - const newChecked = checked === 'indeterminate' ? true : !checked; - setCheckedAndChildren(treeNode, newChecked, unstage.mutate, add.mutate); - // TODO: Also ensure parents are added properly - }; - - const checkEntry = (entry: GitStatusEntry) => { - if (entry.staged) unstage.mutate({ relaPaths: [entry.relaPath] }); - else add.mutate({ relaPaths: [entry.relaPath] }); - }; - return ( -
+
( -
- - {externalEntries.find((e) => e.status !== 'current') && ( - <> - External file changes - {externalEntries.map((entry) => ( - + ( +
+ - ))} - - )} + {externalEntries.find((e) => e.status !== 'current') && ( + <> + External file changes + {externalEntries.map((entry) => ( + + ))} + + )} +
+ )} + secondSlot={({ style: innerStyle }) => ( +
+ + {commitError && {commitError}} + + {status.data?.headRefShorthand} + + + + + +
+ )} + />
)} secondSlot={({ style }) => ( -
- - {commitError && {commitError}} - - {status.data?.headRefShorthand} - - - - - +
+ {selectedEntry ? ( + + ) : ( + Select a change to view diff + )}
)} /> @@ -228,61 +276,77 @@ function TreeNodeChildren({ node, depth, onCheck, + onSelect, + selectedPath, }: { node: CommitTreeNode | null; depth: number; onCheck: (node: CommitTreeNode, checked: boolean) => void; + onSelect: (entry: GitStatusEntry) => void; + selectedPath: string | null; }) { if (node === null) return null; if (!isNodeRelevant(node)) return null; const checked = nodeCheckedStatus(node); + const isSelected = selectedPath === node.status.relaPath; + return (
0 && 'pl-1 ml-[10px] border-l border-dashed border-border-subtle', + depth > 0 && 'pl-4 ml-2 border-l border-dashed border-border-subtle relative', )} > -
+
+ {isSelected && ( +
+ )} onCheck(node, checked)} - title={ -
- {node.model.model !== 'http_request' && - node.model.model !== 'grpc_request' && - node.model.model !== 'websocket_request' ? ( - - ) : ( - - )} -
{resolvedModelName(node.model)}
- {node.status.status !== 'current' && ( - - {node.status.status} - - )} -
- } /> +
{node.children.map((childNode) => { @@ -292,6 +356,8 @@ function TreeNodeChildren({ node={childNode} depth={depth + 1} onCheck={onCheck} + onSelect={onSelect} + selectedPath={selectedPath} /> ); })} @@ -401,3 +467,17 @@ function isNodeRelevant(node: CommitTreeNode): boolean { // Recursively check children return node.children.some((c) => isNodeRelevant(c)); } + +function DiffPanel({ entry }: { entry: GitStatusEntry }) { + const prevYaml = modelToYaml(entry.prev); + const nextYaml = modelToYaml(entry.next); + + return ( +
+
+ {resolvedModelName(entry.next ?? entry.prev)} ({entry.status}) +
+ +
+ ); +} diff --git a/src-web/components/git/GitDropdown.tsx b/src-web/components/git/GitDropdown.tsx index 43a4e5cb..35dc0212 100644 --- a/src-web/components/git/GitDropdown.tsx +++ b/src-web/components/git/GitDropdown.tsx @@ -211,7 +211,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) { id: 'commit', title: 'Commit Changes', size: 'full', - className: '!max-h-[min(80vh,40rem)] !max-w-[min(50rem,90vw)]', + noPadding: true, render: ({ hide }) => ( ), diff --git a/src-web/lib/diffYaml.ts b/src-web/lib/diffYaml.ts new file mode 100644 index 00000000..732e4380 --- /dev/null +++ b/src-web/lib/diffYaml.ts @@ -0,0 +1,15 @@ +import type { SyncModel } from '@yaakapp-internal/git'; +import { stringify } from 'yaml'; + +/** + * Convert a SyncModel to a clean YAML string for diffing. + * Removes noisy fields like updatedAt that change on every edit. + */ +export function modelToYaml(model: SyncModel | null): string { + if (!model) return ''; + + return stringify(model, { + indent: 2, + lineWidth: 0, + }); +} diff --git a/src-web/lib/theme/window.ts b/src-web/lib/theme/window.ts index ee0a41e1..a5dd5939 100644 --- a/src-web/lib/theme/window.ts +++ b/src-web/lib/theme/window.ts @@ -138,6 +138,16 @@ function bannerColorVariables(color: YaakColor | null): Partial { }; } +function inputCSS(color: YaakColor | null): Partial { + if (color == null) return {}; + + const theme: Partial = { + border: color.css(), + }; + + return theme; +} + function buttonSolidColorVariables( color: YaakColor | null, isDefault = false, diff --git a/src-web/package.json b/src-web/package.json index 2479fe39..3f360cbc 100644 --- a/src-web/package.json +++ b/src-web/package.json @@ -14,7 +14,9 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-markdown": "^6.3.2", "@codemirror/lang-xml": "^6.1.0", + "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.11.0", + "@codemirror/merge": "^6.11.2", "@codemirror/search": "^6.5.11", "@dnd-kit/core": "^6.3.1", "@gilbarbara/deep-equal": "^0.3.1",