diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f68fbd8..7fe88da5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.. diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index e45a5eae..eb415ebc 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -91,6 +91,7 @@ export const iconSpriteData = { "file-code": ``, "file-copy-2": ``, "file-copy": ``, + "file-download": ``, "file-edit": ``, "file-image": ``, "file": ``, diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index c27f5237..89dbe2b1 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -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(({ @@ -547,6 +563,7 @@ const MultiFileDiffEntry = React.memo(({ onOpenInEditor, staged = false, stagedRevision = 0, + loadFullFiles = false, }) => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); @@ -569,13 +586,17 @@ const MultiFileDiffEntry = React.memo(({ const descriptor = React.useMemo(() => describeChange(file), [file]); const renderSideBySide = layout === 'side-by-side'; + const desiredContextMode: DiffContextMode = loadFullFiles ? 'full' : 'patch'; const diffData = React.useMemo(() => { 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(({ 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(({ 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((_, reject) => { setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs); @@ -630,7 +652,7 @@ const MultiFileDiffEntry = React.memo(({ 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(({ 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(({ 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 = ({ const [pinnedStackedTarget, setPinnedStackedTarget] = React.useState(null); const [expandedFiles, setExpandedFiles] = React.useState>(() => new Set()); const [mountedStackedFiles, setMountedStackedFiles] = React.useState>(() => 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 = ({ }} staged={getFileStaged(file.path)} stagedRevision={indexRevision} + loadFullFiles={loadFullFiles} /> ))} @@ -1527,6 +1552,28 @@ export const DiffView: React.FC = ({ )} + {changedFiles.length > 0 && ( + + + 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', + )} + > + + + + + {loadFullFiles ? t('diffView.actions.disableFullFiles') : t('diffView.actions.loadFullFiles')} + + + )} {selectedFileEntry && ( = ({ 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, diff --git a/packages/ui/src/lib/diff/patchFileDiff.ts b/packages/ui/src/lib/diff/patchFileDiff.ts index 4d1fcd07..85cb3c6f 100644 --- a/packages/ui/src/lib/diff/patchFileDiff.ts +++ b/packages/ui/src/lib/diff/patchFileDiff.ts @@ -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(); -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); diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 016137c4..ca48034d 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 136a78f8..3f0dfc38 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1175,6 +1175,8 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index e8ffe365..92bfe923 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index f9a49eb3..7b41e83f 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1212,6 +1212,8 @@ export const dict: Record = { 'diffView.actions.renderAnyway': '그래도 렌더링', 'diffView.actions.expandAll': '모두 펼치기', 'diffView.actions.collapseAll': '모두 접기', + 'diffView.actions.loadFullFiles': '전체 파일 불러오기', + 'diffView.actions.disableFullFiles': '전체 파일 불러오기 끄기', 'diffView.actions.disableLineWrap': '줄 바꿈 끄기', 'diffView.actions.enableLineWrap': '줄 바꿈 켜기', 'diffView.actions.openFileInEditorAtChange': '변경 위치에서 이 파일을 에디터로 열기', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index fd96371a..8ba97414 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1412,6 +1412,8 @@ export const dict: Record = { '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 0cce9e95..b86430ca 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1175,6 +1175,8 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 3b201eef..c6ab56b1 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1175,6 +1175,8 @@ export const dict: Record = { "diffView.actions.renderAnyway": "Все одно відрендерити", "diffView.actions.expandAll": "Розгорнути все", "diffView.actions.collapseAll": "Згорнути все", + "diffView.actions.loadFullFiles": "Завантажити повні файли", + "diffView.actions.disableFullFiles": "Не завантажувати повні файли", "diffView.actions.disableLineWrap": "Вимкнути перенос рядків", "diffView.actions.enableLineWrap": "Увімкнути перенос рядків", "diffView.actions.openFileInEditorAtChange": "Відкрити цей файл у редакторі на зміні", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index b63d7443..25968a68 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1175,6 +1175,8 @@ export const dict: Record = { 'diffView.actions.renderAnyway': '仍然渲染', 'diffView.actions.expandAll': '全部展开', 'diffView.actions.collapseAll': '全部折叠', + 'diffView.actions.loadFullFiles': '加载完整文件', + 'diffView.actions.disableFullFiles': '停止加载完整文件', 'diffView.actions.disableLineWrap': '关闭自动换行', 'diffView.actions.enableLineWrap': '开启自动换行', 'diffView.actions.openFileInEditorAtChange': '在编辑器中打开此文件并定位变更', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 344dc9f0..d54125fa 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1185,6 +1185,8 @@ export const dict: Record = { 'diffView.actions.renderAnyway': '仍然渲染', 'diffView.actions.expandAll': '全部展開', 'diffView.actions.collapseAll': '全部折疊', + 'diffView.actions.loadFullFiles': '載入完整檔案', + 'diffView.actions.disableFullFiles': '停止載入完整檔案', 'diffView.actions.disableLineWrap': '關閉自動換行', 'diffView.actions.enableLineWrap': '開啟自動換行', 'diffView.actions.openFileInEditorAtChange': '在編輯器中開啟此檔案並定位變更',
{loadFullFiles ? t('diffView.actions.disableFullFiles') : t('diffView.actions.loadFullFiles')}