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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions moon/apps/web/components/DiffView/FileDiff.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { DiffFile, DiffModeEnum, DiffView } from '@git-diff-view/react'

import { ExpandIcon, SparklesIcon } from '@gitmono/ui/Icons'
import { cn } from '@gitmono/ui/src/utils'
import { parsedDiffs } from '@/components/DiffView/parsedDiffs'

function calculateDiffStatsFromRawDiff(diffText: string): { additions: number; deletions: number } {
const lines = diffText.split('\n');

let additions = 0;

let deletions = 0;

for (const line of lines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
additions++
} else if (line.startsWith('-') && !line.startsWith('---')) {
deletions++
}
}

return { additions, deletions }
}

function generateParsedFiles(diffFiles: { path: string; lang: string; diff: string }[]): {
file: { path: string; lang: string; diff: string };
instance: DiffFile;
stats: { additions: number; deletions: number }
}[] {
return diffFiles.map((file) => {
const instance = new DiffFile('', '', '', '', [file.diff], file.lang);

instance.init();
instance.buildSplitDiffLines();
instance.buildUnifiedDiffLines();
const stats = calculateDiffStatsFromRawDiff(file.diff);

return { file, instance, stats }
})
}

export default function FileDiff({ diffs }: { diffs: string }) {
const diffFiles = useMemo(() => parsedDiffs(diffs), [diffs]);

const parsedFiles = useMemo(() => generateParsedFiles(diffFiles), [diffFiles]);

const [selectedPath, setSelectedPath] = useState<string | null>(null);

const [expandedMap, setExpandedMap] = useState<Record<string, boolean>>(() =>
Comment thread
Ceron-CSS marked this conversation as resolved.
Object.fromEntries(diffFiles.map((f) => [f.path, false]))
);

const fileRefs = useRef<Record<string, HTMLDivElement | null>>({});

const toggleExpanded = (path: string) => {
setExpandedMap((prev) => ({ ...prev, [path]: !prev[path] }))
};

useEffect(() => {
setExpandedMap(Object.fromEntries(diffFiles.map((f) => [f.path, false])));
}, [diffFiles]);

const RenderDiffView = ({ file, instance }: {
file: { path: string; lang: string; diff: string };
instance: DiffFile;
}) => {
if (file.lang === 'plaintext') {
return <div className='text-center p-2'>Binary file</div>
}

return (
<DiffView
diffFile={instance}
diffViewFontSize={14}
diffViewWrap
diffViewMode={DiffModeEnum.Unified}
diffViewHighlight
/>
)
}

return (
<div className='flex font-sans'>
<div
className='rounded-lg bg-gray-100 w-[300px] h-[85vh] border border-[#ddd] p-2 overflow-y-auto sticky top-5'
Comment thread
Ceron-CSS marked this conversation as resolved.
>
<ul>
{parsedFiles.map(({ file }) => (
<li
key={file.path}
onClick={() => {
setSelectedPath(file.path)
setExpandedMap((prev) => ({ ...prev, [file.path]: true }))
const el = fileRefs.current[file.path]

if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}}
className={cn('px-2 py-1 text-sm cursor-pointer rounded-md mb-1', selectedPath === file.path ? 'bg-[#e6f0ff]' : 'bg-transparent')}
>
{file.path}
</li>
))}
</ul>
</div>

<div className='flex-1 overflow-y-auto px-4'>
{parsedFiles.map(({ file, instance, stats }) => {
const isExpanded = expandedMap[file.path]

return (
<div
key={file.path}
ref={(el) => void (fileRefs.current[file.path] = el)}
className='mb-4 rounded-lg border border-gray-300'
>
<div
onClick={() => toggleExpanded(file.path)}
className={cn('flex items-center justify-between px-4 py-2 text-sm', isExpanded && 'border-b border-gray-300')}
>
<span className='flex items-center cursor-pointer'>
{isExpanded ? (
<SparklesIcon className='align-middle text-xl' />
) : (
<ExpandIcon className='align-middle text-xl' />
)}
<span className='ml-1'>{file.path}</span>
</span>
<span className='text-xs font-bold'>
<span className='text-green-500'>+{stats.additions}</span>{' '}
<span className='text-red-500'>−{stats.deletions}</span>
</span>
</div>

{isExpanded && <RenderDiffView file={file} instance={instance} />}
</div>
)
})}
</div>
</div>
)
}
86 changes: 86 additions & 0 deletions moon/apps/web/components/DiffView/parsedDiffs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
const extensionToLangMap: Record<string, string> = {
".ts": "typescript",
".tsx": "typescriptreact",
".js": "javascript",
".jsx": "javascriptreact",
".json": "json",
".md": "markdown",
".py": "python",
".rs": "rust",
".cpp": "cpp",
".c": "c",
".h": "cpp",
".java": "java",
".go": "go",
".sh": "bash",
".yml": "yaml",
".yaml": "yaml",
".css": "css",
".scss": "scss",
".html": "html",
".vue": "vue",
".toml": "toml",
};

function getLangFromPath(path: string): string {
const ext = path.match(/\.[^./\\]+$/)?.[0]?.toLowerCase();

return ext ? extensionToLangMap[ext] ?? "plaintext" : "plaintext";
}

export function parsedDiffs(diffText: string): { path: string; lang: string; diff: string }[] {
if (!diffText) return [];

const parts = diffText
.split(/(?=^diff --git )/gm)
.map((block) => block.trim())
.filter(Boolean);

return parts.map((block) => {
let path = "";

const diffGitMatch = block.match(/^diff --git a\/[^\s]+ b\/([^\s]+)/m);

if (diffGitMatch) {
const originalPath = diffGitMatch[1]?.trim();
const newPath = diffGitMatch[2]?.trim();

if (newPath && newPath !== '/dev/null') {
path = newPath;
} else {
path = originalPath;
}
}

if (getLangFromPath(path) === "plaintext") {
return {
path,
lang: getLangFromPath(path),
diff: block,
};
}

let diffWithHeader = block;
const plusMatch = block.match(/^\+\+\+ b\/([^\n\r]+)/m);

if(!plusMatch){
const hunkIndex = block.indexOf("@@");

let prefix = `--- a/${path}\n+++ b/${path}\n`;

diffWithHeader = hunkIndex >= 0
? block.slice(0, hunkIndex) + prefix + block.slice(hunkIndex)
: prefix + block;

if (!diffWithHeader.endsWith("\n")) {
diffWithHeader += "\n";
}
}

return {
path,
lang: getLangFromPath(path),
diff: diffWithHeader,
};
});
}
1 change: 1 addition & 0 deletions moon/apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@ant-design/icons": "catalog:",
"@emoji-mart/data": "catalog:",
"@emotion/styled": "catalog:",
"@git-diff-view/react": "0.0.26",
"@gitmono/config": "workspace:*",
"@gitmono/editor": "workspace:*",
"@gitmono/regex": "workspace:*",
Expand Down
29 changes: 6 additions & 23 deletions moon/apps/web/pages/[org]/mr/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
'use client'

import React, { useCallback, useState } from 'react';
import React, { useState } from 'react';
import { Card, Tabs, TabsProps,Timeline,} from 'antd';
// import { CommentOutlined, MergeOutlined, CloseCircleOutlined, PullRequestOutlined } from '@ant-design/icons';
import { ChevronRightCircleIcon, ChevronSelectIcon,AlarmIcon,ClockIcon} from '@gitmono/ui/Icons'
import { formatDistance, fromUnixTime } from 'date-fns';
import RichEditor from '@/components/MrView/rich-editor/RichEditor';
import MRComment from '@/components/MrView/MRComment';
import { useRouter } from 'next/router';
import * as Diff2Html from 'diff2html';
import 'diff2html/bundles/css/diff2html.min.css';
import FilesChanged from '@/components/MrView/files-changed';
import FileDiff from '@/components/DiffView/FileDiff';
import { Button } from '@gitmono/ui';
// import { ReloadIcon } from '@radix-ui/react-icons';
import {DownloadIcon } from '@gitmono/ui'
Expand Down Expand Up @@ -46,7 +45,6 @@ const MRDetailPage:PageWithLayout<any> = () =>{
const [editorState, setEditorState] = useState("");
const [editorHasText, setEditorHasText] = useState(false);
const [login, _setLogin] = useState(true);
const [outputHtml, setOutputHtml] = useState('');

const id = typeof tempId === 'string' ? tempId : '';
const { data: MrDetailData } = useGetMrDetail(id)
Expand All @@ -58,17 +56,6 @@ const MRDetailPage:PageWithLayout<any> = () =>{
}

const { data: MrFilesChangedData} = useGetMrFilesChanged(id)
const get_diff_content = useCallback(() => {
const content = MrFilesChangedData?.data?.content;

if (typeof content !== 'string') return;
const diff = Diff2Html.html(content, {
drawFileList: true,
matching: 'lines',
});

setOutputHtml(diff);
}, [MrFilesChangedData]);

const { mutate: approveMr, isPending : mrMergeIsPending } = usePostMrMerge(id)
const handleMrApprove = () => {
Expand Down Expand Up @@ -128,12 +115,6 @@ const MRDetailPage:PageWithLayout<any> = () =>{
return element
});

const onTabsChange = (key: string) => {
if (key === '2') {
get_diff_content()
}
};

const buttonClasses= 'cursor-pointer';

const tab_items: TabsProps['items'] = [
Expand Down Expand Up @@ -183,7 +164,9 @@ const MRDetailPage:PageWithLayout<any> = () =>{
{
key: '2',
label: 'Files Changed',
children: <FilesChanged outputHtml={outputHtml}/>
children: MrFilesChangedData?.data?.content ?
<FileDiff diffs={MrFilesChangedData.data.content} /> :
<div>No files changed</div>
Comment thread
Ceron-CSS marked this conversation as resolved.
}
];

Expand All @@ -200,7 +183,7 @@ const MRDetailPage:PageWithLayout<any> = () =>{
Merge MR
</Button>
}
<Tabs defaultActiveKey="1" items={tab_items} onChange={onTabsChange}/>
<Tabs defaultActiveKey="1" items={tab_items} />
</Card>
)
}
Expand Down
1 change: 1 addition & 0 deletions moon/apps/web/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'styles/editor.css'
import 'styles/global.css' // web only
import 'styles/prose.css'
import '@radix-ui/themes/styles.css'
import '@git-diff-view/react/styles/diff-view.css';

import { useEffect } from 'react'
import { IS_PRODUCTION, LAST_CLIENT_JS_BUILD_ID_LS_KEY } from '@gitmono/config'
Expand Down
Loading