Skip to content

Commit 5962aec

Browse files
committed
Revamped diff and file viewer
lint lint
1 parent 23701e2 commit 5962aec

20 files changed

Lines changed: 1022 additions & 391 deletions

File tree

apps/twig/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
"@codemirror/lang-yaml": "^6.1.2",
102102
"@codemirror/language": "^6.11.3",
103103
"@codemirror/merge": "^6.11.2",
104+
"@codemirror/search": "^6.6.0",
104105
"@codemirror/state": "^6.5.2",
105106
"@codemirror/view": "^6.38.8",
106107
"@dnd-kit/react": "^0.1.21",

apps/twig/src/main/services/git/schemas.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,43 @@ export const getCommitConventionsOutput = z.object({
247247
export type GetCommitConventionsOutput = z.infer<
248248
typeof getCommitConventionsOutput
249249
>;
250+
251+
// Diff mode for comparison mode dropdown
252+
export const diffModeSchema = z.enum([
253+
"uncommitted",
254+
"staged",
255+
"unstaged",
256+
"branch",
257+
"lastTurn",
258+
]);
259+
260+
export type DiffMode = z.infer<typeof diffModeSchema>;
261+
262+
// getChangedFilesByMode schemas
263+
export const getChangedFilesByModeInput = z.object({
264+
directoryPath: z.string(),
265+
mode: diffModeSchema.default("uncommitted"),
266+
});
267+
268+
export const getChangedFilesByModeOutput = z.array(changedFileSchema);
269+
270+
// getFileAtRef schemas
271+
export const getFileAtRefInput = z.object({
272+
directoryPath: z.string(),
273+
filePath: z.string(),
274+
ref: z.string(),
275+
});
276+
277+
export const getFileAtRefOutput = z.string().nullable();
278+
279+
// getMergeBase schemas
280+
export const getMergeBaseInput = directoryPathInput;
281+
export const getMergeBaseOutput = z.string();
282+
283+
// getDiffStatsByMode schemas
284+
export const getDiffStatsByModeInput = z.object({
285+
directoryPath: z.string(),
286+
mode: diffModeSchema.default("uncommitted"),
287+
});
288+
289+
export const getDiffStatsByModeOutput = diffStatsSchema;

apps/twig/src/main/services/git/service.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,21 @@ import fs from "node:fs";
22
import path from "node:path";
33
import { isTwigBranch } from "@shared/constants";
44
import {
5+
type ChangedFileInfo,
6+
computeDiffStatsFromFiles,
57
getAllBranches,
8+
getChangedFilesBranch,
69
getChangedFilesDetailed,
10+
getChangedFilesStaged,
11+
getChangedFilesUnstaged,
712
getCommitConventions,
813
getCurrentBranch,
914
getDefaultBranch,
1015
getDiffStats,
1116
getFileAtHead,
17+
getFileAtRef as getFileAtRefQuery,
1218
getLatestCommit,
19+
getMergeBase as getMergeBaseQuery,
1320
getRemoteUrl,
1421
getSyncStatus,
1522
fetch as gitFetch,
@@ -27,6 +34,7 @@ import type {
2734
ChangedFile,
2835
CloneProgressPayload,
2936
DetectRepoResult,
37+
DiffMode,
3038
DiffStats,
3139
GetCommitConventionsOutput,
3240
GetPrTemplateOutput,
@@ -357,4 +365,55 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
357365
): Promise<GetCommitConventionsOutput> {
358366
return getCommitConventions(directoryPath, sampleSize);
359367
}
368+
369+
private async getFilesByMode(
370+
directoryPath: string,
371+
mode: DiffMode,
372+
): Promise<ChangedFileInfo[]> {
373+
const excludePatterns = [".claude", "CLAUDE.local.md"];
374+
switch (mode) {
375+
case "staged":
376+
return getChangedFilesStaged(directoryPath, { excludePatterns });
377+
case "unstaged":
378+
return getChangedFilesUnstaged(directoryPath, { excludePatterns });
379+
case "branch":
380+
return getChangedFilesBranch(directoryPath, { excludePatterns });
381+
default:
382+
return getChangedFilesDetailed(directoryPath, { excludePatterns });
383+
}
384+
}
385+
386+
public async getChangedFilesByMode(
387+
directoryPath: string,
388+
mode: DiffMode,
389+
): Promise<ChangedFile[]> {
390+
const files = await this.getFilesByMode(directoryPath, mode);
391+
return files.map((f) => ({
392+
path: f.path,
393+
status: f.status,
394+
originalPath: f.originalPath,
395+
linesAdded: f.linesAdded,
396+
linesRemoved: f.linesRemoved,
397+
}));
398+
}
399+
400+
public async getFileAtRef(
401+
directoryPath: string,
402+
filePath: string,
403+
ref: string,
404+
): Promise<string | null> {
405+
return getFileAtRefQuery(directoryPath, filePath, ref);
406+
}
407+
408+
public async getMergeBase(directoryPath: string): Promise<string> {
409+
return getMergeBaseQuery(directoryPath);
410+
}
411+
412+
public async getDiffStatsByMode(
413+
directoryPath: string,
414+
mode: DiffMode,
415+
): Promise<DiffStats> {
416+
const files = await this.getFilesByMode(directoryPath, mode);
417+
return computeDiffStatsFromFiles(files);
418+
}
360419
}

apps/twig/src/main/trpc/routers/git.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
discardFileChangesInput,
1010
getAllBranchesInput,
1111
getAllBranchesOutput,
12+
getChangedFilesByModeInput,
13+
getChangedFilesByModeOutput,
1214
getChangedFilesHeadInput,
1315
getChangedFilesHeadOutput,
1416
getCommitConventionsInput,
@@ -17,16 +19,22 @@ import {
1719
getCurrentBranchOutput,
1820
getDefaultBranchInput,
1921
getDefaultBranchOutput,
22+
getDiffStatsByModeInput,
23+
getDiffStatsByModeOutput,
2024
getDiffStatsInput,
2125
getDiffStatsOutput,
2226
getFileAtHeadInput,
2327
getFileAtHeadOutput,
28+
getFileAtRefInput,
29+
getFileAtRefOutput,
2430
getGitRepoInfoInput,
2531
getGitRepoInfoOutput,
2632
getGitSyncStatusInput,
2733
getGitSyncStatusOutput,
2834
getLatestCommitInput,
2935
getLatestCommitOutput,
36+
getMergeBaseInput,
37+
getMergeBaseOutput,
3038
getPrTemplateInput,
3139
getPrTemplateOutput,
3240
publishInput,
@@ -122,6 +130,33 @@ export const gitRouter = router({
122130
.output(getDiffStatsOutput)
123131
.query(({ input }) => getService().getDiffStats(input.directoryPath)),
124132

133+
// Mode-aware operations
134+
getChangedFilesByMode: publicProcedure
135+
.input(getChangedFilesByModeInput)
136+
.output(getChangedFilesByModeOutput)
137+
.query(({ input }) =>
138+
getService().getChangedFilesByMode(input.directoryPath, input.mode),
139+
),
140+
141+
getFileAtRef: publicProcedure
142+
.input(getFileAtRefInput)
143+
.output(getFileAtRefOutput)
144+
.query(({ input }) =>
145+
getService().getFileAtRef(input.directoryPath, input.filePath, input.ref),
146+
),
147+
148+
getMergeBase: publicProcedure
149+
.input(getMergeBaseInput)
150+
.output(getMergeBaseOutput)
151+
.query(({ input }) => getService().getMergeBase(input.directoryPath)),
152+
153+
getDiffStatsByMode: publicProcedure
154+
.input(getDiffStatsByModeInput)
155+
.output(getDiffStatsByModeOutput)
156+
.query(({ input }) =>
157+
getService().getDiffStatsByMode(input.directoryPath, input.mode),
158+
),
159+
125160
discardFileChanges: publicProcedure
126161
.input(discardFileChangesInput)
127162
.mutation(({ input }) =>

apps/twig/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.tsx

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
import { Box, Flex, SegmentedControl, Text } from "@radix-ui/themes";
2-
import { useMemo } from "react";
1+
import { openSearchPanel } from "@codemirror/search";
2+
import { EditorView } from "@codemirror/view";
3+
import { DotsThree } from "@phosphor-icons/react";
4+
import { Box, DropdownMenu, Flex, IconButton, Text } from "@radix-ui/themes";
5+
import { useEffect, useMemo } from "react";
36
import { useCodeMirror } from "../hooks/useCodeMirror";
47
import { useEditorExtensions } from "../hooks/useEditorExtensions";
5-
import { useDiffViewerStore, type ViewMode } from "../stores/diffViewerStore";
8+
import { useDiffViewerStore } from "../stores/diffViewerStore";
69

710
interface CodeMirrorDiffEditorProps {
811
originalContent: string;
912
modifiedContent: string;
1013
filePath?: string;
1114
relativePath?: string;
1215
onContentChange?: (content: string) => void;
16+
onRefresh?: () => void;
1317
}
1418

1519
export function CodeMirrorDiffEditor({
@@ -18,15 +22,25 @@ export function CodeMirrorDiffEditor({
1822
filePath,
1923
relativePath,
2024
onContentChange,
25+
onRefresh,
2126
}: CodeMirrorDiffEditorProps) {
22-
const { viewMode, setViewMode } = useDiffViewerStore();
23-
const extensions = useEditorExtensions(filePath, true);
27+
const viewMode = useDiffViewerStore((s) => s.viewMode);
28+
const toggleViewMode = useDiffViewerStore((s) => s.toggleViewMode);
29+
const wordWrap = useDiffViewerStore((s) => s.wordWrap);
30+
const toggleWordWrap = useDiffViewerStore((s) => s.toggleWordWrap);
31+
const loadFullFiles = useDiffViewerStore((s) => s.loadFullFiles);
32+
const toggleLoadFullFiles = useDiffViewerStore((s) => s.toggleLoadFullFiles);
33+
const wordDiffs = useDiffViewerStore((s) => s.wordDiffs);
34+
const toggleWordDiffs = useDiffViewerStore((s) => s.toggleWordDiffs);
35+
const extensions = useEditorExtensions(filePath, !onContentChange, true);
2436
const options = useMemo(
2537
() => ({
2638
original: originalContent,
2739
modified: modifiedContent,
2840
extensions,
2941
mode: viewMode,
42+
loadFullFiles,
43+
wordDiffs,
3044
filePath,
3145
onContentChange,
3246
}),
@@ -35,11 +49,32 @@ export function CodeMirrorDiffEditor({
3549
modifiedContent,
3650
extensions,
3751
viewMode,
52+
loadFullFiles,
53+
wordDiffs,
3854
filePath,
3955
onContentChange,
4056
],
4157
);
42-
const containerRef = useCodeMirror(options);
58+
const { containerRef, instanceRef } = useCodeMirror(options);
59+
60+
// Capture Cmd+F / Ctrl+F globally and open CodeMirror search when diff is mounted
61+
useEffect(() => {
62+
const handleKeyDown = (e: KeyboardEvent) => {
63+
if (!(e.metaKey || e.ctrlKey) || e.key !== "f") return;
64+
65+
const instance = instanceRef.current;
66+
if (!instance) return;
67+
68+
e.preventDefault();
69+
e.stopPropagation();
70+
const editorView = instance instanceof EditorView ? instance : instance.b;
71+
openSearchPanel(editorView);
72+
};
73+
74+
document.addEventListener("keydown", handleKeyDown, { capture: true });
75+
return () =>
76+
document.removeEventListener("keydown", handleKeyDown, { capture: true });
77+
}, [instanceRef]);
4378

4479
return (
4580
<Flex direction="column" height="100%">
@@ -50,23 +85,59 @@ export function CodeMirrorDiffEditor({
5085
justify="between"
5186
style={{ borderBottom: "1px solid var(--gray-6)", flexShrink: 0 }}
5287
>
53-
{relativePath && (
88+
{relativePath ? (
5489
<Text
5590
size="1"
5691
color="gray"
5792
style={{ fontFamily: "var(--code-font-family)" }}
5893
>
5994
{relativePath}
6095
</Text>
96+
) : (
97+
<span />
6198
)}
62-
<SegmentedControl.Root
63-
size="1"
64-
value={viewMode}
65-
onValueChange={(value) => setViewMode(value as ViewMode)}
66-
>
67-
<SegmentedControl.Item value="split">Split</SegmentedControl.Item>
68-
<SegmentedControl.Item value="unified">Unified</SegmentedControl.Item>
69-
</SegmentedControl.Root>
99+
<DropdownMenu.Root>
100+
<DropdownMenu.Trigger>
101+
<IconButton
102+
size="1"
103+
variant="ghost"
104+
style={{ color: "var(--gray-9)" }}
105+
>
106+
<DotsThree size={16} weight="bold" />
107+
</IconButton>
108+
</DropdownMenu.Trigger>
109+
<DropdownMenu.Content size="1" align="end">
110+
<DropdownMenu.Item onSelect={toggleViewMode}>
111+
<Text size="1">
112+
{viewMode === "split" ? "Unified view" : "Split view"}
113+
</Text>
114+
</DropdownMenu.Item>
115+
<DropdownMenu.Item onSelect={toggleWordWrap}>
116+
<Text size="1">
117+
{wordWrap ? "Disable word wrap" : "Enable word wrap"}
118+
</Text>
119+
</DropdownMenu.Item>
120+
<DropdownMenu.Item onSelect={toggleLoadFullFiles}>
121+
<Text size="1">
122+
{loadFullFiles ? "Collapse unchanged" : "Load full files"}
123+
</Text>
124+
</DropdownMenu.Item>
125+
<DropdownMenu.Item onSelect={toggleWordDiffs}>
126+
<Text size="1">
127+
{wordDiffs ? "Disable word diffs" : "Enable word diffs"}
128+
</Text>
129+
</DropdownMenu.Item>
130+
131+
{onRefresh && (
132+
<>
133+
<DropdownMenu.Separator />
134+
<DropdownMenu.Item onSelect={onRefresh}>
135+
<Text size="1">Refresh</Text>
136+
</DropdownMenu.Item>
137+
</>
138+
)}
139+
</DropdownMenu.Content>
140+
</DropdownMenu.Root>
70141
</Flex>
71142
<Box style={{ flex: 1, overflow: "auto" }}>
72143
<div ref={containerRef} style={{ height: "100%", width: "100%" }} />

apps/twig/src/renderer/features/code-editor/components/CodeMirrorEditor.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { openSearchPanel } from "@codemirror/search";
2+
import { EditorView } from "@codemirror/view";
13
import { Box, Flex, Text } from "@radix-ui/themes";
2-
import { useMemo } from "react";
4+
import { useEffect, useMemo } from "react";
35
import { useCodeMirror } from "../hooks/useCodeMirror";
46
import { useEditorExtensions } from "../hooks/useEditorExtensions";
57

@@ -21,7 +23,24 @@ export function CodeMirrorEditor({
2123
() => ({ doc: content, extensions, filePath }),
2224
[content, extensions, filePath],
2325
);
24-
const containerRef = useCodeMirror(options);
26+
const { containerRef, instanceRef } = useCodeMirror(options);
27+
28+
useEffect(() => {
29+
const handleKeyDown = (e: KeyboardEvent) => {
30+
if (!(e.metaKey || e.ctrlKey) || e.key !== "f") return;
31+
32+
const instance = instanceRef.current;
33+
if (!instance || !(instance instanceof EditorView)) return;
34+
35+
e.preventDefault();
36+
e.stopPropagation();
37+
openSearchPanel(instance);
38+
};
39+
40+
document.addEventListener("keydown", handleKeyDown, { capture: true });
41+
return () =>
42+
document.removeEventListener("keydown", handleKeyDown, { capture: true });
43+
}, [instanceRef]);
2544

2645
if (!relativePath) {
2746
return <div ref={containerRef} style={{ height: "100%", width: "100%" }} />;

0 commit comments

Comments
 (0)