feat: version diffs work now

This commit is contained in:
2026-02-16 14:03:09 +01:00
parent 3c9d4b6bce
commit b19e92f729
13 changed files with 655 additions and 20 deletions

View File

@@ -47,6 +47,19 @@ export interface GitDiffContentDto {
modified: string;
}
export interface GitCommitDiffContentDto {
commitHash: string;
original: string;
modified: string;
files: GitCommitDiffFileDto[];
}
export interface GitCommitDiffFileDto {
filePath: string;
original: string;
modified: string;
}
export interface GitHistoryEntry {
hash: string;
shortHash: string;
@@ -505,6 +518,129 @@ export class GitEngine {
};
}
async getCommitDiffContent(projectPath: string, commitHash: string): Promise<GitCommitDiffContentDto> {
const git = simpleGit(projectPath);
const patch = await git.show(['--format=', '--patch', commitHash]);
const files = this.parseUnifiedPatchFiles(patch);
if (files.length === 0) {
return {
commitHash,
original: '',
modified: patch,
files: [],
};
}
const firstFile = files[0];
return {
commitHash,
original: firstFile.original,
modified: firstFile.modified,
files,
};
}
private parseUnifiedPatchFiles(patch: string): GitCommitDiffFileDto[] {
interface FileDiffBuffers {
path: string;
original: string[];
modified: string[];
inHunk: boolean;
touched: boolean;
}
const lines = patch.split('\n');
const files: FileDiffBuffers[] = [];
let currentFile: FileDiffBuffers | null = null;
const flushCurrent = () => {
if (!currentFile) {
return;
}
if (currentFile.touched || currentFile.original.length > 0 || currentFile.modified.length > 0) {
files.push(currentFile);
}
currentFile = null;
};
for (const line of lines) {
if (line.startsWith('diff --git ')) {
flushCurrent();
const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
const filePath = match ? match[2] : line;
currentFile = {
path: filePath,
original: [],
modified: [],
inHunk: false,
touched: false,
};
continue;
}
if (!currentFile) {
continue;
}
if (line.startsWith('@@')) {
currentFile.inHunk = true;
continue;
}
if (line.startsWith('Binary files ')) {
currentFile.original.push(line);
currentFile.modified.push(line);
currentFile.touched = true;
continue;
}
if (!currentFile.inHunk) {
continue;
}
if (line.startsWith('\\ No newline at end of file')) {
continue;
}
if (line.startsWith('+')) {
currentFile.modified.push(line.slice(1));
currentFile.touched = true;
continue;
}
if (line.startsWith('-')) {
currentFile.original.push(line.slice(1));
currentFile.touched = true;
continue;
}
if (line.startsWith(' ')) {
const contextLine = line.slice(1);
currentFile.original.push(contextLine);
currentFile.modified.push(contextLine);
currentFile.touched = true;
continue;
}
currentFile.original.push(line);
currentFile.modified.push(line);
currentFile.touched = true;
}
flushCurrent();
return files.map((file) => ({
filePath: file.path,
original: file.original.join('\n'),
modified: file.modified.join('\n'),
}));
}
async getHistory(projectPath: string, limit = 20): Promise<GitHistoryEntry[]> {
const git = simpleGit(projectPath);
const history = await git.log({ maxCount: limit });

View File

@@ -58,6 +58,11 @@ export function registerIpcHandlers(): void {
return engine.getDiffContent(projectPath, filePath);
});
safeHandle('git:commitDiffContent', async (_, projectPath: string, commitHash: string) => {
const engine = getGitEngine();
return engine.getCommitDiffContent(projectPath, commitHash);
});
safeHandle('git:history', async (_, projectPath: string, limit?: number) => {
const engine = getGitEngine();
return engine.getHistory(projectPath, limit);

View File

@@ -12,6 +12,7 @@ export const electronAPI: ElectronAPI = {
getStatus: (projectPath: string) => ipcRenderer.invoke('git:status', projectPath),
getDiff: (projectPath: string, filePath: string) => ipcRenderer.invoke('git:diff', projectPath, filePath),
getDiffContent: (projectPath: string, filePath: string) => ipcRenderer.invoke('git:diffContent', projectPath, filePath),
getCommitDiffContent: (projectPath: string, commitHash: string) => ipcRenderer.invoke('git:commitDiffContent', projectPath, commitHash),
getHistory: (projectPath: string, limit?: number) => ipcRenderer.invoke('git:history', projectPath, limit),
fetch: (projectPath: string) => ipcRenderer.invoke('git:fetch', projectPath),
pull: (projectPath: string) => ipcRenderer.invoke('git:pull', projectPath),

View File

@@ -247,6 +247,19 @@ export interface GitDiffContentDto {
modified: string;
}
export interface GitCommitDiffContentDto {
commitHash: string;
original: string;
modified: string;
files: GitCommitDiffFileDto[];
}
export interface GitCommitDiffFileDto {
filePath: string;
original: string;
modified: string;
}
export interface GitHistoryEntry {
hash: string;
shortHash: string;
@@ -376,6 +389,7 @@ export interface ElectronAPI {
getStatus: (projectPath: string) => Promise<GitStatusDto>;
getDiff: (projectPath: string, filePath: string) => Promise<GitDiffDto>;
getDiffContent: (projectPath: string, filePath: string) => Promise<GitDiffContentDto>;
getCommitDiffContent: (projectPath: string, commitHash: string) => Promise<GitCommitDiffContentDto>;
getHistory: (projectPath: string, limit?: number) => Promise<GitHistoryEntry[]>;
fetch: (projectPath: string) => Promise<GitActionResult>;
pull: (projectPath: string) => Promise<GitActionResult>;

View File

@@ -19,6 +19,43 @@
min-height: 0;
}
.git-diff-commit-nav {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--vscode-editorWidget-border);
}
.git-diff-commit-label {
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.git-diff-commit-select {
flex: 1;
min-width: 0;
padding: 4px 6px;
border: 1px solid var(--vscode-input-border);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font-size: 12px;
}
.git-diff-commit-button {
padding: 4px 8px;
border: 1px solid var(--vscode-button-border);
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
font-size: 12px;
cursor: pointer;
}
.git-diff-commit-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.git-diff-message,
.git-diff-error {
padding: 12px;

View File

@@ -3,6 +3,12 @@ import { DiffEditor } from '@monaco-editor/react';
import { useAppStore } from '../../store';
import './GitDiffView.css';
interface CommitFileDiff {
filePath: string;
original: string;
modified: string;
}
interface GitDiffViewProps {
filePath: string;
}
@@ -35,9 +41,9 @@ function detectLanguage(filePath: string): string {
}
}
function toModelPath(filePath: string, side: 'original' | 'modified'): string {
function toModelPath(filePath: string, side: 'original' | 'modified', scope: string): string {
const normalized = filePath.replace(/^\/+/, '');
return `inmemory://model/git-diff/${side}/${normalized}`;
return `inmemory://model/git-diff/${scope}/${side}/${normalized}`;
}
export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
@@ -46,6 +52,40 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
const [error, setError] = useState<string | null>(null);
const [original, setOriginal] = useState('');
const [modified, setModified] = useState('');
const [commitFiles, setCommitFiles] = useState<CommitFileDiff[]>([]);
const [selectedCommitFilePath, setSelectedCommitFilePath] = useState<string>('');
const isCommitDiff = filePath.startsWith('commit:');
const commitHash = isCommitDiff ? filePath.slice('commit:'.length) : '';
const selectedCommitFile = commitFiles.find((entry) => entry.filePath === selectedCommitFilePath) ?? null;
const selectedCommitFileIndex = selectedCommitFilePath
? commitFiles.findIndex((entry) => entry.filePath === selectedCommitFilePath)
: -1;
const canSelectPreviousFile = selectedCommitFileIndex > 0;
const canSelectNextFile = selectedCommitFileIndex >= 0 && selectedCommitFileIndex < commitFiles.length - 1;
const displayedOriginal = selectedCommitFile ? selectedCommitFile.original : original;
const displayedModified = selectedCommitFile ? selectedCommitFile.modified : modified;
const activeFilePath = selectedCommitFile ? selectedCommitFile.filePath : filePath;
const modelScope = isCommitDiff ? `commit-${commitHash}` : 'working-tree';
const selectPreviousCommitFile = () => {
if (!canSelectPreviousFile) {
return;
}
const previousFile = commitFiles[selectedCommitFileIndex - 1];
if (previousFile) {
setSelectedCommitFilePath(previousFile.filePath);
}
};
const selectNextCommitFile = () => {
if (!canSelectNextFile) {
return;
}
const nextFile = commitFiles[selectedCommitFileIndex + 1];
if (nextFile) {
setSelectedCommitFilePath(nextFile.filePath);
}
};
useEffect(() => {
const loadDiff = async () => {
@@ -67,9 +107,27 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
return;
}
const diff = await window.electronAPI.git.getDiffContent(projectPath, filePath);
setOriginal(diff.original || '');
setModified(diff.modified || '');
if (isCommitDiff) {
const diff = await window.electronAPI.git.getCommitDiffContent(projectPath, commitHash);
const files = diff.files || [];
setCommitFiles(files);
if (files.length > 0) {
setSelectedCommitFilePath(files[0].filePath);
setOriginal(files[0].original || '');
setModified(files[0].modified || '');
} else {
setSelectedCommitFilePath('');
setOriginal(diff.original || '');
setModified(diff.modified || '');
}
} else {
setCommitFiles([]);
setSelectedCommitFilePath('');
const diff = await window.electronAPI.git.getDiffContent(projectPath, filePath);
setOriginal(diff.original || '');
setModified(diff.modified || '');
}
} catch {
setError('Failed to load diff.');
} finally {
@@ -78,12 +136,12 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
};
void loadDiff();
}, [activeProject, filePath]);
}, [activeProject, filePath, isCommitDiff, commitHash]);
if (loading) {
return (
<div className="git-diff-view">
<div className="git-diff-header">Diff: {filePath}</div>
<div className="git-diff-header">Diff: {isCommitDiff ? `Commit ${commitHash}` : filePath}</div>
<div className="git-diff-message">Loading diff...</div>
</div>
);
@@ -92,7 +150,7 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
if (error) {
return (
<div className="git-diff-view">
<div className="git-diff-header">Diff: {filePath}</div>
<div className="git-diff-header">Diff: {isCommitDiff ? `Commit ${commitHash}` : filePath}</div>
<div className="git-diff-error">{error}</div>
</div>
);
@@ -100,16 +158,54 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
return (
<div className="git-diff-view">
<div className="git-diff-header">Diff: {filePath}</div>
<div className="git-diff-header">Diff: {isCommitDiff ? `Commit ${commitHash}` : filePath}</div>
{isCommitDiff && commitFiles.length > 0 && (
<div className="git-diff-commit-nav">
<label htmlFor="git-diff-commit-files" className="git-diff-commit-label">
Changed files
</label>
<button
type="button"
className="git-diff-commit-button"
onClick={selectPreviousCommitFile}
disabled={!canSelectPreviousFile}
aria-label="Previous file"
>
Previous
</button>
<select
id="git-diff-commit-files"
className="git-diff-commit-select"
value={selectedCommitFilePath}
onChange={(event) => setSelectedCommitFilePath(event.target.value)}
aria-label="Changed files"
>
{commitFiles.map((entry) => (
<option key={entry.filePath} value={entry.filePath}>
{entry.filePath}
</option>
))}
</select>
<button
type="button"
className="git-diff-commit-button"
onClick={selectNextCommitFile}
disabled={!canSelectNextFile}
aria-label="Next file"
>
Next
</button>
</div>
)}
<div className="git-diff-editor-wrap">
<DiffEditor
original={original}
modified={modified}
originalModelPath={toModelPath(filePath, 'original')}
modifiedModelPath={toModelPath(filePath, 'modified')}
original={displayedOriginal}
modified={displayedModified}
originalModelPath={toModelPath(activeFilePath, 'original', modelScope)}
modifiedModelPath={toModelPath(activeFilePath, 'modified', modelScope)}
keepCurrentOriginalModel
keepCurrentModifiedModel
language={detectLanguage(filePath)}
language={detectLanguage(activeFilePath)}
theme="vs-dark"
height="100%"
options={{

View File

@@ -112,9 +112,18 @@
}
.git-sidebar-history-item {
width: 100%;
border: none;
padding: 6px 8px;
border-radius: 4px;
background: var(--vscode-input-background);
color: inherit;
text-align: left;
cursor: pointer;
}
.git-sidebar-history-item:hover {
background: var(--vscode-list-hoverBackground);
}
.git-sidebar-history-subject {

View File

@@ -26,6 +26,7 @@ export const GitSidebar: React.FC = () => {
const commitMessageInputRef = useRef<HTMLInputElement | null>(null);
const getDiffTabId = (filePath: string): string => `git-diff:${filePath}`;
const getCommitDiffTabId = (commitHash: string): string => `git-diff:commit:${commitHash}`;
const getActionProgressMessage = (action: 'fetch' | 'pull' | 'push' | 'commit'): string => {
if (action === 'push') {
@@ -51,6 +52,17 @@ export const GitSidebar: React.FC = () => {
[openTab],
);
const openCommitDiffTab = useCallback(
(commitHash: string, isTransient: boolean) => {
openTab({
type: 'git-diff',
id: getCommitDiffTabId(commitHash),
isTransient,
});
},
[openTab],
);
const resolveProjectPath = useCallback(async (): Promise<string | null> => {
if (!activeProject) {
return null;
@@ -367,14 +379,21 @@ export const GitSidebar: React.FC = () => {
) : (
<div className="git-sidebar-history-list" role="list" aria-label="Version History">
{historyEntries.map((entry) => (
<div key={entry.hash} className="git-sidebar-history-item">
<button
key={entry.hash}
type="button"
className="git-sidebar-history-item"
onClick={() => openCommitDiffTab(entry.hash, true)}
onDoubleClick={() => openCommitDiffTab(entry.hash, false)}
title={`${entry.shortHash}: ${entry.subject}`}
>
<div className="git-sidebar-history-subject">{entry.subject}</div>
<div className="git-sidebar-history-meta">
<span>{entry.shortHash}</span>
<span>{entry.author}</span>
<span>{new Date(entry.date).toLocaleDateString()}</span>
</div>
</div>
</button>
))}
</div>
)}

View File

@@ -4,15 +4,36 @@ import './TabBar.css';
const MAX_CHAT_TITLE_LENGTH = 18;
function getGitDiffResource(tabId: string): string {
return tabId.startsWith('git-diff:') ? tabId.slice('git-diff:'.length) : tabId;
}
function getCommitHashFromGitDiffTabId(tabId: string): string | null {
const resource = getGitDiffResource(tabId);
if (!resource.startsWith('commit:')) {
return null;
}
return resource.slice('commit:'.length);
}
const getTabTitle = (
tab: Tab,
postTitles: Map<string, string>,
media: { id: string; originalName: string }[],
chatTitles: Map<string, string>,
importDefTitles: Map<string, string>
importDefTitles: Map<string, string>,
commitTitles: Map<string, string>
): string => {
if (tab.type === 'git-diff') {
const filePath = tab.id.startsWith('git-diff:') ? tab.id.slice('git-diff:'.length) : tab.id;
const filePath = getGitDiffResource(tab.id);
const commitHash = getCommitHashFromGitDiffTabId(tab.id);
if (commitHash) {
const commitTitle = commitTitles.get(commitHash);
if (commitTitle) {
return commitTitle;
}
return `Commit ${commitHash.slice(0, 7)}`;
}
const filename = filePath.split('/').pop();
return filename || filePath;
}
@@ -138,6 +159,7 @@ export const TabBar: React.FC = () => {
tabs,
activeTabId,
media,
activeProject,
dirtyPosts,
sidebarVisible,
toggleSidebar,
@@ -152,6 +174,7 @@ export const TabBar: React.FC = () => {
const [postTitles, setPostTitles] = useState<Map<string, string>>(new Map());
const [chatTitles, setChatTitles] = useState<Map<string, string>>(new Map());
const [importDefTitles, setImportDefTitles] = useState<Map<string, string>>(new Map());
const [commitTitles, setCommitTitles] = useState<Map<string, string>>(new Map());
// Fetch post titles from database for post tabs
useEffect(() => {
@@ -289,6 +312,65 @@ export const TabBar: React.FC = () => {
};
}, []);
// Fetch commit subjects for commit-based git-diff tabs
useEffect(() => {
const commitHashes = tabs
.filter((tab) => tab.type === 'git-diff')
.map((tab) => getCommitHashFromGitDiffTabId(tab.id))
.filter((hash): hash is string => Boolean(hash));
if (commitHashes.length === 0 || !activeProject) {
return;
}
const missingHashes = commitHashes.filter((hash) => !commitTitles.has(hash));
if (missingHashes.length === 0) {
return;
}
let cancelled = false;
const fetchCommitTitles = async () => {
try {
const projectPath = activeProject.dataPath
? activeProject.dataPath
: await window.electronAPI?.app.getDefaultProjectPath(activeProject.id);
if (!projectPath) {
return;
}
const history = await window.electronAPI?.git.getHistory(projectPath, 200);
if (!history || cancelled) {
return;
}
setCommitTitles((previous) => {
const updated = new Map(previous);
let changed = false;
for (const hash of missingHashes) {
const match = history.find((entry) => entry.hash === hash);
if (match) {
updated.set(hash, `${match.shortHash} ${match.subject}`);
changed = true;
}
}
return changed ? updated : previous;
});
} catch (error) {
console.error('Failed to fetch commit titles:', error);
}
};
void fetchCommitTitles();
return () => {
cancelled = true;
};
}, [tabs, activeProject]);
// Check if arrows are needed based on scroll position
const updateArrowVisibility = useCallback(() => {
const container = tabsContainerRef.current;
@@ -419,7 +501,7 @@ export const TabBar: React.FC = () => {
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id);
const title = getTabTitle(tab, postTitles, media, chatTitles, importDefTitles);
const title = getTabTitle(tab, postTitles, media, chatTitles, importDefTitles, commitTitles);
const icon = getTabIcon(tab);
return (