Improve Changes diff experience

This commit is contained in:
Bohdan Triapitsyn
2026-06-14 01:39:49 +03:00
parent 2538a08370
commit 8174ede976
14 changed files with 103 additions and 15 deletions
+1
View File
@@ -18,6 +18,7 @@ All notable changes to this project will be documented in this file.
- Sessions: switching sessions no longer leaves the chat area blank in some cases (thanks to @panzeyu2013).
- Sessions: selected rows now highlight across the full sidebar gutter.
- Comments: inline file/diff comment drafts now stay in place when focus changes.
- Git/Diff: redesigned the Changes diff view with faster multi-file rendering, expandable hunk separators, a full-file loading toggle, compact responsive controls, and a unified changed/staged context panel workflow.
- GitHub: GitHub settings can now use credentials from the `gh` CLI when available (thanks to @tomzx).
- Settings/MCP: importing MCP snippets from OpenCode config works again (thanks to @youzini).
- Usage: added Cursor plan as a usage-tracking provider..
@@ -91,6 +91,7 @@ export const iconSpriteData = {
"file-code": `<path d="M15 4H5V20H19V8H15V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM17.6569 12L14.1213 15.5355L12.7071 14.1213L14.8284 12L12.7071 9.87868L14.1213 8.46447L17.6569 12ZM6.34315 12L9.87868 8.46447L11.2929 9.87868L9.17157 12L11.2929 14.1213L9.87868 15.5355L6.34315 12Z" fill="currentColor"/>`,
"file-copy-2": `<path d="M6.9998 6V3C6.9998 2.44772 7.44752 2 7.9998 2H19.9998C20.5521 2 20.9998 2.44772 20.9998 3V17C20.9998 17.5523 20.5521 18 19.9998 18H16.9998V20.9991C16.9998 21.5519 16.5499 22 15.993 22H4.00666C3.45059 22 3 21.5554 3 20.9991L3.0026 7.00087C3.0027 6.44811 3.45264 6 4.00942 6H6.9998ZM5.00242 8L5.00019 20H14.9998V8H5.00242ZM8.9998 6H16.9998V16H18.9998V4H8.9998V6ZM7 11H13V13H7V11ZM7 15H13V17H7V15Z" fill="currentColor"/>`,
"file-copy": `<path d="M6.9998 6V3C6.9998 2.44772 7.44752 2 7.9998 2H19.9998C20.5521 2 20.9998 2.44772 20.9998 3V17C20.9998 17.5523 20.5521 18 19.9998 18H16.9998V20.9991C16.9998 21.5519 16.5499 22 15.993 22H4.00666C3.45059 22 3 21.5554 3 20.9991L3.0026 7.00087C3.0027 6.44811 3.45264 6 4.00942 6H6.9998ZM5.00242 8L5.00019 20H14.9998V8H5.00242ZM8.9998 6H16.9998V16H18.9998V4H8.9998V6Z" fill="currentColor"/>`,
"file-download": `<path d="M13 12H16L12 16L8 12H11V8H13V12ZM15 4H5V20H19V8H15V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918Z" fill="currentColor"/>`,
"file-edit": `<path d="M21 6.75736L19 8.75736V4H10V9H5V20H19V17.2426L21 15.2426V21.0082C21 21.556 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5501 3 20.9932V8L9.00319 2H19.9978C20.5513 2 21 2.45531 21 2.9918V6.75736ZM21.7782 8.80761L23.1924 10.2218L15.4142 18L13.9979 17.9979L14 16.5858L21.7782 8.80761Z" fill="currentColor"/>`,
"file-image": `<path d="M15 8V4H5V20H19V8H15ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11 9.5C11 10.3284 10.3284 11 9.5 11C8.67157 11 8 10.3284 8 9.5C8 8.67157 8.67157 8 9.5 8C10.3284 8 11 8.67157 11 9.5ZM17.5 17L13.5 10L8 17H17.5Z" fill="currentColor"/>`,
"file": `<path d="M9 2.00318V2H19.9978C20.5513 2 21 2.45531 21 2.9918V21.0082C21 21.556 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5501 3 20.9932V8L9 2.00318ZM5.82918 8H9V4.83086L5.82918 8ZM11 4V9C11 9.55228 10.5523 10 10 10H5V20H19V4H11Z" fill="currentColor"/>`,
+57 -10
View File
@@ -12,6 +12,11 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
@@ -38,6 +43,7 @@ const DIFF_REQUEST_TIMEOUT_MS = 15000;
const LARGE_DIFF_CHANGED_LINES = 500;
const STACKED_DIFF_MOUNT_MARGIN = 300;
const FULL_CONTEXT_DIFF_LINES = 1_000_000;
const DEFAULT_CONTEXT_DIFF_LINES = 3;
// Perf: limit concurrent expanded diffs in stacked view.
// Expanding many diffs mounts many Pierre instances + lots of DOM.
@@ -54,7 +60,15 @@ type FileEntry = GitStatus['files'][number] & {
isNew: boolean;
};
type DiffData = { original: string; modified: string; isBinary?: boolean; patch?: string; fileDiff?: FileDiffMetadata };
type DiffContextMode = 'patch' | 'full';
type DiffData = {
original: string;
modified: string;
isBinary?: boolean;
patch?: string;
fileDiff?: FileDiffMetadata;
contextMode?: DiffContextMode;
};
type DiffScope = 'all' | 'staged' | 'working';
const BinaryDiffPlaceholder = React.memo(() => {
@@ -163,9 +177,9 @@ const getFirstVisibleModifiedLineFromPatch = (patch: string): number | null => {
const isBinaryPatch = (patch: string): boolean =>
/^Binary files .+ differ$/m.test(patch) || /^GIT binary patch$/m.test(patch);
const createTextDiffDataFromPatch = (filePath: string, patch: string): DiffData => {
const createTextDiffDataFromPatch = (filePath: string, patch: string, contextMode: DiffContextMode): DiffData => {
if (isBinaryPatch(patch)) {
return { original: '', modified: '', isBinary: true, patch };
return { original: '', modified: '', isBinary: true, patch, contextMode };
}
return {
@@ -173,6 +187,7 @@ const createTextDiffDataFromPatch = (filePath: string, patch: string): DiffData
modified: '',
patch,
fileDiff: fileDiffFromPatch(filePath, patch),
contextMode,
};
};
@@ -529,6 +544,7 @@ interface MultiFileDiffEntryProps {
onOpenInEditor?: (filePath: string, diffData: DiffData | null) => void;
staged?: boolean;
stagedRevision?: number;
loadFullFiles?: boolean;
}
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
@@ -547,6 +563,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
onOpenInEditor,
staged = false,
stagedRevision = 0,
loadFullFiles = false,
}) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
@@ -569,13 +586,17 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
const descriptor = React.useMemo(() => describeChange(file), [file]);
const renderSideBySide = layout === 'side-by-side';
const desiredContextMode: DiffContextMode = loadFullFiles ? 'full' : 'patch';
const diffData = React.useMemo<DiffData | null>(() => {
if (staged) return stagedDiffData;
if (!cachedDiff) return localDiffData;
return { original: cachedDiff.original, modified: cachedDiff.modified, isBinary: cachedDiff.isBinary };
if (localDiffData) return localDiffData;
if (!cachedDiff) return null;
return { original: cachedDiff.original, modified: cachedDiff.modified, isBinary: cachedDiff.isBinary, contextMode: 'full' };
}, [cachedDiff, localDiffData, staged, stagedDiffData]);
const diffDataMatchesContextMode = diffData?.contextMode === desiredContextMode;
const setSectionRef = React.useCallback((node: HTMLDivElement | null) => {
sectionRef.current = node;
registerSectionRef(file.path, node);
@@ -602,13 +623,13 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
React.useEffect(() => {
if (!isExpanded || !isMounted) return;
if (!directory || diffData) {
if (!directory || (diffData && diffDataMatchesContextMode)) {
lastDiffRequestRef.current = null;
setIsLoading(false);
return;
}
const requestKey = `${directory}::${file.path}::${staged ? `staged:${stagedRevision}` : 'unstaged'}::${diffRetryNonce}`;
const requestKey = `${directory}::${file.path}::${staged ? `staged:${stagedRevision}` : 'unstaged'}::${desiredContextMode}::${diffRetryNonce}`;
if (lastDiffRequestRef.current === requestKey) {
return;
}
@@ -617,9 +638,10 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
setIsLoading(true);
let cancelled = false;
const contextLines = loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES;
const fetchPromise = isImageFile(file.path)
? git.getGitFileDiff(directory, { path: file.path, staged })
: git.getGitDiff(directory, { path: file.path, staged, contextLines: FULL_CONTEXT_DIFF_LINES });
: git.getGitDiff(directory, { path: file.path, staged, contextLines });
const timeoutMs = DIFF_REQUEST_TIMEOUT_MS;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
@@ -630,7 +652,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
if (cancelled) return;
if ('diff' in response) {
const nextDiff = createTextDiffDataFromPatch(file.path, response.diff);
const nextDiff = createTextDiffDataFromPatch(file.path, response.diff, desiredContextMode);
if (staged) {
setStagedDiffData(nextDiff);
} else {
@@ -641,6 +663,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
original: response.original ?? '',
modified: response.modified ?? '',
isBinary: response.isBinary,
contextMode: 'full' as const,
};
if (staged) {
setStagedDiffData(nextDiff);
@@ -663,7 +686,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
lastDiffRequestRef.current = null;
}
};
}, [directory, diffData, diffRetryNonce, file.path, git, isExpanded, isMounted, setDiff, staged, stagedRevision]);
}, [desiredContextMode, diffData, diffDataMatchesContextMode, diffRetryNonce, directory, file.path, git, isExpanded, isMounted, loadFullFiles, setDiff, staged, stagedRevision]);
const handleToggle = React.useCallback(() => {
handleOpenChange(!isExpanded);
@@ -879,6 +902,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
const [pinnedStackedTarget, setPinnedStackedTarget] = React.useState<string | null>(null);
const [expandedFiles, setExpandedFiles] = React.useState<Set<string>>(() => new Set());
const [mountedStackedFiles, setMountedStackedFiles] = React.useState<Set<string>>(() => new Set());
const [loadFullFiles, setLoadFullFiles] = React.useState(false);
const pendingDiffFile = useUIStore((state) => state.pendingDiffFile);
const pendingDiffStaged = useUIStore((state) => state.pendingDiffStaged);
@@ -1428,6 +1452,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
}}
staged={getFileStaged(file.path)}
stagedRevision={indexRevision}
loadFullFiles={loadFullFiles}
/>
))}
</div>
@@ -1527,6 +1552,28 @@ export const DiffView: React.FC<DiffViewProps> = ({
</span>
</Button>
)}
{changedFiles.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setLoadFullFiles((value) => !value)}
aria-pressed={loadFullFiles}
aria-label={loadFullFiles ? t('diffView.actions.disableFullFiles') : t('diffView.actions.loadFullFiles')}
className={cn(
'h-7 w-7 flex-shrink-0 p-0 text-muted-foreground hover:text-foreground',
loadFullFiles && 'bg-interactive-selection text-interactive-selection-foreground',
)}
>
<Icon name="file-download" className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{loadFullFiles ? t('diffView.actions.disableFullFiles') : t('diffView.actions.loadFullFiles')}</p>
</TooltipContent>
</Tooltip>
)}
{selectedFileEntry && (
<Button
variant="ghost"
@@ -747,6 +747,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
maxLineDiffLength: isLargeContent ? 0 : 1000,
maxLineLengthForHighlighting: isLargeContent ? 1 : 1000,
tokenizeMaxLineLength: isLargeContent ? 1 : 1000,
collapsedContextThreshold: 0,
expansionLineCount: 20,
overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
disableFileHeader: true,
+25 -5
View File
@@ -1,9 +1,21 @@
import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from '@pierre/diffs';
import {
parseDiffFromFile,
parsePatchFiles,
processFile,
trimPatchContext,
type FileDiffMetadata,
} from '@pierre/diffs';
const PATCH_DIFF_CACHE_LIMIT = 64;
const DEFAULT_PATCH_CONTEXT_LINES = 3;
const patchFileDiffCache = new Map<string, FileDiffMetadata>();
export const fileDiffFromContent = (file: string, before: string, after: string): FileDiffMetadata => {
export const fileDiffFromContent = (
file: string,
before: string,
after: string,
contextLines = DEFAULT_PATCH_CONTEXT_LINES
): FileDiffMetadata => {
if (!before && !after) {
return emptyFileDiff(file);
}
@@ -11,11 +23,16 @@ export const fileDiffFromContent = (file: string, before: string, after: string)
return parseDiffFromFile(
{ name: file, contents: before },
{ name: file, contents: after },
{ context: contextLines },
);
};
export const fileDiffFromPatch = (file: string, patch: string): FileDiffMetadata => {
const key = `${file}\0${patch}`;
export const fileDiffFromPatch = (
file: string,
patch: string,
contextLines = DEFAULT_PATCH_CONTEXT_LINES
): FileDiffMetadata => {
const key = `${file}\0${contextLines}\0${patch}`;
const cached = patchFileDiffCache.get(key);
if (cached) {
patchFileDiffCache.delete(key);
@@ -25,7 +42,10 @@ export const fileDiffFromPatch = (file: string, patch: string): FileDiffMetadata
const completeContents = completePatchContents(patch);
const value = completeContents
? fileDiffFromContent(file, completeContents.before, completeContents.after)
? (processFile(trimPatchContext(patch, contextLines), {
oldFile: { name: file, contents: completeContents.before },
newFile: { name: file, contents: completeContents.after },
}) ?? emptyFileDiff(file))
: (parsePatchFiles(withPatchHeader(file, patch))[0]?.files[0] ?? emptyFileDiff(file));
patchFileDiffCache.set(key, value);
+2
View File
@@ -1209,6 +1209,8 @@ export const dict = {
'diffView.actions.renderAnyway': 'Render anyway',
'diffView.actions.expandAll': 'Expand all',
'diffView.actions.collapseAll': 'Collapse all',
'diffView.actions.loadFullFiles': 'Load full files',
'diffView.actions.disableFullFiles': 'Unload full files',
'diffView.actions.disableLineWrap': 'Disable line wrap',
'diffView.actions.enableLineWrap': 'Enable line wrap',
'diffView.actions.openFileInEditorAtChange': 'Open this file in editor at change',
+2
View File
@@ -1175,6 +1175,8 @@ export const dict: Record<I18nKey, string> = {
"diffView.actions.renderAnyway": "Renderizar de todos modos",
"diffView.actions.expandAll": "Expandir todo",
"diffView.actions.collapseAll": "Contraer todo",
"diffView.actions.loadFullFiles": "Cargar archivos completos",
"diffView.actions.disableFullFiles": "No cargar archivos completos",
"diffView.actions.disableLineWrap": "Desactivar ajuste de línea",
"diffView.actions.enableLineWrap": "Activar ajuste de línea",
"diffView.actions.openFileInEditorAtChange": "Abrir este archivo en el editor en el cambio",
+2
View File
@@ -1082,6 +1082,8 @@ export const dict = {
'diffView.actions.renderAnyway': 'Afficher quand même',
'diffView.actions.expandAll': 'Tout développer',
'diffView.actions.collapseAll': 'Tout réduire',
'diffView.actions.loadFullFiles': 'Charger les fichiers complets',
'diffView.actions.disableFullFiles': 'Ne plus charger les fichiers complets',
'diffView.actions.disableLineWrap': 'Désactiver le retour à la ligne',
'diffView.actions.enableLineWrap': 'Activer le retour à la ligne',
'diffView.actions.openFileInEditorAtChange': 'Ouvrez ce fichier dans l\'éditeur lors du changement',
+2
View File
@@ -1212,6 +1212,8 @@ export const dict: Record<I18nKey, string> = {
'diffView.actions.renderAnyway': '그래도 렌더링',
'diffView.actions.expandAll': '모두 펼치기',
'diffView.actions.collapseAll': '모두 접기',
'diffView.actions.loadFullFiles': '전체 파일 불러오기',
'diffView.actions.disableFullFiles': '전체 파일 불러오기 끄기',
'diffView.actions.disableLineWrap': '줄 바꿈 끄기',
'diffView.actions.enableLineWrap': '줄 바꿈 켜기',
'diffView.actions.openFileInEditorAtChange': '변경 위치에서 이 파일을 에디터로 열기',
+2
View File
@@ -1412,6 +1412,8 @@ export const dict: Record<I18nKey, string> = {
'diffView.actions.enableLineWrap': 'Włącz zawijanie linii',
'diffView.actions.expandAll': 'Rozwiń wszystko',
'diffView.actions.collapseAll': 'Zwiń wszystko',
'diffView.actions.loadFullFiles': 'Wczytaj pełne pliki',
'diffView.actions.disableFullFiles': 'Nie wczytuj pełnych plików',
'diffView.actions.openFileAtFirstChangedLine': 'Otwórz plik na pierwszej zmienionej linii',
'diffView.actions.openFileInEditorAtChange': 'Otwórz plik w edytorze na zmianie',
'diffView.actions.renderAnyway': 'Renderuj mimo to',
@@ -1175,6 +1175,8 @@ export const dict: Record<I18nKey, string> = {
"diffView.actions.renderAnyway": "Renderizar mesmo assim",
"diffView.actions.expandAll": "Expandir tudo",
"diffView.actions.collapseAll": "Recolher tudo",
"diffView.actions.loadFullFiles": "Carregar arquivos completos",
"diffView.actions.disableFullFiles": "Não carregar arquivos completos",
"diffView.actions.disableLineWrap": "Desativar ajuste de linha",
"diffView.actions.enableLineWrap": "Ativar ajuste de linha",
"diffView.actions.openFileInEditorAtChange": "Abrir este arquivo no editor nesta alteração",
+2
View File
@@ -1175,6 +1175,8 @@ export const dict: Record<I18nKey, string> = {
"diffView.actions.renderAnyway": "Все одно відрендерити",
"diffView.actions.expandAll": "Розгорнути все",
"diffView.actions.collapseAll": "Згорнути все",
"diffView.actions.loadFullFiles": "Завантажити повні файли",
"diffView.actions.disableFullFiles": "Не завантажувати повні файли",
"diffView.actions.disableLineWrap": "Вимкнути перенос рядків",
"diffView.actions.enableLineWrap": "Увімкнути перенос рядків",
"diffView.actions.openFileInEditorAtChange": "Відкрити цей файл у редакторі на зміні",
@@ -1175,6 +1175,8 @@ export const dict: Record<I18nKey, string> = {
'diffView.actions.renderAnyway': '仍然渲染',
'diffView.actions.expandAll': '全部展开',
'diffView.actions.collapseAll': '全部折叠',
'diffView.actions.loadFullFiles': '加载完整文件',
'diffView.actions.disableFullFiles': '停止加载完整文件',
'diffView.actions.disableLineWrap': '关闭自动换行',
'diffView.actions.enableLineWrap': '开启自动换行',
'diffView.actions.openFileInEditorAtChange': '在编辑器中打开此文件并定位变更',
@@ -1185,6 +1185,8 @@ export const dict: Record<I18nKey, string> = {
'diffView.actions.renderAnyway': '仍然渲染',
'diffView.actions.expandAll': '全部展開',
'diffView.actions.collapseAll': '全部折疊',
'diffView.actions.loadFullFiles': '載入完整檔案',
'diffView.actions.disableFullFiles': '停止載入完整檔案',
'diffView.actions.disableLineWrap': '關閉自動換行',
'diffView.actions.enableLineWrap': '開啟自動換行',
'diffView.actions.openFileInEditorAtChange': '在編輯器中開啟此檔案並定位變更',