feat: better diff. integration
This commit is contained in:
@@ -41,6 +41,20 @@ export interface GitDiffDto {
|
|||||||
patch: string;
|
patch: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitDiffContentDto {
|
||||||
|
filePath: string;
|
||||||
|
original: string;
|
||||||
|
modified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitHistoryEntry {
|
||||||
|
hash: string;
|
||||||
|
shortHash: string;
|
||||||
|
date: string;
|
||||||
|
subject: string;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type GitInitPhase =
|
export type GitInitPhase =
|
||||||
| 'checking-git'
|
| 'checking-git'
|
||||||
| 'initializing-repo'
|
| 'initializing-repo'
|
||||||
@@ -232,6 +246,34 @@ export class GitEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDiffContent(projectPath: string, filePath: string): Promise<GitDiffContentDto> {
|
||||||
|
const git = simpleGit(projectPath);
|
||||||
|
|
||||||
|
const [original, modified] = await Promise.all([
|
||||||
|
git.show([`HEAD:${filePath}`]).catch(() => ''),
|
||||||
|
fsPromises.readFile(path.join(projectPath, filePath), 'utf8').catch(() => ''),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
original,
|
||||||
|
modified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistory(projectPath: string, limit = 20): Promise<GitHistoryEntry[]> {
|
||||||
|
const git = simpleGit(projectPath);
|
||||||
|
const history = await git.log({ maxCount: limit });
|
||||||
|
|
||||||
|
return history.all.map((entry) => ({
|
||||||
|
hash: entry.hash,
|
||||||
|
shortHash: entry.hash.slice(0, 7),
|
||||||
|
date: entry.date,
|
||||||
|
subject: entry.message,
|
||||||
|
author: entry.author_name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async ensureGitignore(projectPath: string): Promise<GitIgnoreEnsureResult> {
|
async ensureGitignore(projectPath: string): Promise<GitIgnoreEnsureResult> {
|
||||||
const gitignorePath = path.join(projectPath, '.gitignore');
|
const gitignorePath = path.join(projectPath, '.gitignore');
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export {
|
|||||||
type RepoState,
|
type RepoState,
|
||||||
type GitStatusDto,
|
type GitStatusDto,
|
||||||
type GitDiffDto,
|
type GitDiffDto,
|
||||||
|
type GitDiffContentDto,
|
||||||
|
type GitHistoryEntry,
|
||||||
type GitStatusFile,
|
type GitStatusFile,
|
||||||
type GitStatusCounts,
|
type GitStatusCounts,
|
||||||
type GitInitResult,
|
type GitInitResult,
|
||||||
|
|||||||
@@ -53,6 +53,16 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.getDiff(projectPath, filePath);
|
return engine.getDiff(projectPath, filePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
safeHandle('git:diffContent', async (_, projectPath: string, filePath: string) => {
|
||||||
|
const engine = getGitEngine();
|
||||||
|
return engine.getDiffContent(projectPath, filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('git:history', async (_, projectPath: string, limit?: number) => {
|
||||||
|
const engine = getGitEngine();
|
||||||
|
return engine.getHistory(projectPath, limit);
|
||||||
|
});
|
||||||
|
|
||||||
safeHandle('git:init', async (event, projectPath: string, remoteUrl?: string) => {
|
safeHandle('git:init', async (event, projectPath: string, remoteUrl?: string) => {
|
||||||
const engine = getGitEngine();
|
const engine = getGitEngine();
|
||||||
return engine.initializeRepo(projectPath, remoteUrl, (progress) => {
|
return engine.initializeRepo(projectPath, remoteUrl, (progress) => {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export const electronAPI: ElectronAPI = {
|
|||||||
getRepoState: (projectPath: string) => ipcRenderer.invoke('git:getRepoState', projectPath),
|
getRepoState: (projectPath: string) => ipcRenderer.invoke('git:getRepoState', projectPath),
|
||||||
getStatus: (projectPath: string) => ipcRenderer.invoke('git:status', projectPath),
|
getStatus: (projectPath: string) => ipcRenderer.invoke('git:status', projectPath),
|
||||||
getDiff: (projectPath: string, filePath: string) => ipcRenderer.invoke('git:diff', projectPath, filePath),
|
getDiff: (projectPath: string, filePath: string) => ipcRenderer.invoke('git:diff', projectPath, filePath),
|
||||||
|
getDiffContent: (projectPath: string, filePath: string) => ipcRenderer.invoke('git:diffContent', projectPath, filePath),
|
||||||
|
getHistory: (projectPath: string, limit?: number) => ipcRenderer.invoke('git:history', projectPath, limit),
|
||||||
ensureGitignore: (projectPath: string) => ipcRenderer.invoke('git:ensureGitignore', projectPath),
|
ensureGitignore: (projectPath: string) => ipcRenderer.invoke('git:ensureGitignore', projectPath),
|
||||||
pruneLfs: (projectPath: string, options?: { dryRun?: boolean; verifyRemote?: boolean }) => ipcRenderer.invoke('git:pruneLfs', projectPath, options),
|
pruneLfs: (projectPath: string, options?: { dryRun?: boolean; verifyRemote?: boolean }) => ipcRenderer.invoke('git:pruneLfs', projectPath, options),
|
||||||
init: (projectPath: string, remoteUrl?: string) => {
|
init: (projectPath: string, remoteUrl?: string) => {
|
||||||
|
|||||||
@@ -241,6 +241,20 @@ export interface GitDiffDto {
|
|||||||
patch: string;
|
patch: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitDiffContentDto {
|
||||||
|
filePath: string;
|
||||||
|
original: string;
|
||||||
|
modified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitHistoryEntry {
|
||||||
|
hash: string;
|
||||||
|
shortHash: string;
|
||||||
|
date: string;
|
||||||
|
subject: string;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type GitInitPhase =
|
export type GitInitPhase =
|
||||||
| 'checking-git'
|
| 'checking-git'
|
||||||
| 'initializing-repo'
|
| 'initializing-repo'
|
||||||
@@ -354,6 +368,8 @@ export interface ElectronAPI {
|
|||||||
getRepoState: (projectPath: string) => Promise<GitRepoState>;
|
getRepoState: (projectPath: string) => Promise<GitRepoState>;
|
||||||
getStatus: (projectPath: string) => Promise<GitStatusDto>;
|
getStatus: (projectPath: string) => Promise<GitStatusDto>;
|
||||||
getDiff: (projectPath: string, filePath: string) => Promise<GitDiffDto>;
|
getDiff: (projectPath: string, filePath: string) => Promise<GitDiffDto>;
|
||||||
|
getDiffContent: (projectPath: string, filePath: string) => Promise<GitDiffContentDto>;
|
||||||
|
getHistory: (projectPath: string, limit?: number) => Promise<GitHistoryEntry[]>;
|
||||||
ensureGitignore: (projectPath: string) => Promise<GitIgnoreEnsureResult>;
|
ensureGitignore: (projectPath: string) => Promise<GitIgnoreEnsureResult>;
|
||||||
pruneLfs: (projectPath: string, options?: { dryRun?: boolean; verifyRemote?: boolean }) => Promise<GitLfsPruneResult>;
|
pruneLfs: (projectPath: string, options?: { dryRun?: boolean; verifyRemote?: boolean }) => Promise<GitLfsPruneResult>;
|
||||||
init: (projectPath: string, remoteUrl?: string) => Promise<GitInitResult>;
|
init: (projectPath: string, remoteUrl?: string) => Promise<GitInitResult>;
|
||||||
|
|||||||
@@ -14,14 +14,9 @@
|
|||||||
color: var(--vscode-sideBar-foreground);
|
color: var(--vscode-sideBar-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.git-diff-patch {
|
.git-diff-editor-wrap {
|
||||||
margin: 0;
|
flex: 1;
|
||||||
padding: 12px;
|
min-height: 0;
|
||||||
overflow: auto;
|
|
||||||
white-space: pre;
|
|
||||||
font-family: var(--vscode-editor-font-family);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.git-diff-message,
|
.git-diff-message,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { DiffEditor } from '@monaco-editor/react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import './GitDiffView.css';
|
import './GitDiffView.css';
|
||||||
|
|
||||||
@@ -6,11 +7,40 @@ interface GitDiffViewProps {
|
|||||||
filePath: string;
|
filePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectLanguage(filePath: string): string {
|
||||||
|
const extension = filePath.split('.').pop()?.toLowerCase();
|
||||||
|
switch (extension) {
|
||||||
|
case 'md':
|
||||||
|
case 'markdown':
|
||||||
|
return 'markdown';
|
||||||
|
case 'ts':
|
||||||
|
return 'typescript';
|
||||||
|
case 'tsx':
|
||||||
|
return 'typescript';
|
||||||
|
case 'js':
|
||||||
|
return 'javascript';
|
||||||
|
case 'jsx':
|
||||||
|
return 'javascript';
|
||||||
|
case 'json':
|
||||||
|
return 'json';
|
||||||
|
case 'css':
|
||||||
|
return 'css';
|
||||||
|
case 'html':
|
||||||
|
return 'html';
|
||||||
|
case 'yml':
|
||||||
|
case 'yaml':
|
||||||
|
return 'yaml';
|
||||||
|
default:
|
||||||
|
return 'plaintext';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
||||||
const { activeProject } = useAppStore();
|
const { activeProject } = useAppStore();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [patch, setPatch] = useState('');
|
const [original, setOriginal] = useState('');
|
||||||
|
const [modified, setModified] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDiff = async () => {
|
const loadDiff = async () => {
|
||||||
@@ -32,8 +62,9 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const diff = await window.electronAPI.git.getDiff(projectPath, filePath);
|
const diff = await window.electronAPI.git.getDiffContent(projectPath, filePath);
|
||||||
setPatch(diff.patch || '');
|
setOriginal(diff.original || '');
|
||||||
|
setModified(diff.modified || '');
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load diff.');
|
setError('Failed to load diff.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -65,7 +96,27 @@ export const GitDiffView: React.FC<GitDiffViewProps> = ({ filePath }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="git-diff-view">
|
<div className="git-diff-view">
|
||||||
<div className="git-diff-header">Diff: {filePath}</div>
|
<div className="git-diff-header">Diff: {filePath}</div>
|
||||||
{patch ? <pre className="git-diff-patch">{patch}</pre> : <div className="git-diff-message">No diff available.</div>}
|
<div className="git-diff-editor-wrap">
|
||||||
|
<DiffEditor
|
||||||
|
original={original}
|
||||||
|
modified={modified}
|
||||||
|
language={detectLanguage(filePath)}
|
||||||
|
theme="vs-dark"
|
||||||
|
height="100%"
|
||||||
|
options={{
|
||||||
|
readOnly: true,
|
||||||
|
renderSideBySide: false,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
lineNumbers: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
renderOverviewRuler: true,
|
||||||
|
originalEditable: false,
|
||||||
|
diffCodeLens: false,
|
||||||
|
wordWrap: 'off',
|
||||||
|
ignoreTrimWhitespace: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,15 +42,6 @@
|
|||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.git-sidebar-section-header {
|
|
||||||
padding: 0 12px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--vscode-sideBar-foreground);
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.git-sidebar-empty-state {
|
.git-sidebar-empty-state {
|
||||||
padding: 0 12px 8px;
|
padding: 0 12px 8px;
|
||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
@@ -96,6 +87,34 @@
|
|||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-item {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-subject {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
.git-sidebar-main {
|
.git-sidebar-main {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import type { GitInitProgress } from '../../../main/shared/electronApi';
|
import type { GitInitProgress, GitHistoryEntry } from '../../../main/shared/electronApi';
|
||||||
import './GitSidebar.css';
|
import './GitSidebar.css';
|
||||||
|
import '../Sidebar/Sidebar.css';
|
||||||
|
|
||||||
export const GitSidebar: React.FC = () => {
|
export const GitSidebar: React.FC = () => {
|
||||||
const { activeProject, openTab } = useAppStore();
|
const { activeProject, openTab } = useAppStore();
|
||||||
@@ -13,6 +14,8 @@ export const GitSidebar: React.FC = () => {
|
|||||||
const [isRepo, setIsRepo] = useState(false);
|
const [isRepo, setIsRepo] = useState(false);
|
||||||
const [currentBranch, setCurrentBranch] = useState<string | null>(null);
|
const [currentBranch, setCurrentBranch] = useState<string | null>(null);
|
||||||
const [statusFiles, setStatusFiles] = useState<Array<{ path: string; status: string }>>([]);
|
const [statusFiles, setStatusFiles] = useState<Array<{ path: string; status: string }>>([]);
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
|
const [historyEntries, setHistoryEntries] = useState<GitHistoryEntry[]>([]);
|
||||||
const [initProgress, setInitProgress] = useState<GitInitProgress | null>(null);
|
const [initProgress, setInitProgress] = useState<GitInitProgress | null>(null);
|
||||||
const [initTranscript, setInitTranscript] = useState<GitInitProgress[]>([]);
|
const [initTranscript, setInitTranscript] = useState<GitInitProgress[]>([]);
|
||||||
const [isTranscriptExpanded, setIsTranscriptExpanded] = useState(false);
|
const [isTranscriptExpanded, setIsTranscriptExpanded] = useState(false);
|
||||||
@@ -70,19 +73,27 @@ export const GitSidebar: React.FC = () => {
|
|||||||
|
|
||||||
if (repoState.isRepo) {
|
if (repoState.isRepo) {
|
||||||
setStatusLoading(true);
|
setStatusLoading(true);
|
||||||
|
setHistoryLoading(true);
|
||||||
try {
|
try {
|
||||||
const status = await window.electronAPI.git.getStatus(resolvedProjectPath);
|
const [status, history] = await Promise.all([
|
||||||
|
window.electronAPI.git.getStatus(resolvedProjectPath),
|
||||||
|
window.electronAPI.git.getHistory(resolvedProjectPath, 20),
|
||||||
|
]);
|
||||||
setStatusFiles(status.files);
|
setStatusFiles(status.files);
|
||||||
|
setHistoryEntries(history);
|
||||||
} finally {
|
} finally {
|
||||||
setStatusLoading(false);
|
setStatusLoading(false);
|
||||||
|
setHistoryLoading(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setStatusFiles([]);
|
setStatusFiles([]);
|
||||||
|
setHistoryEntries([]);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Unable to load repository status.');
|
setError('Unable to load repository status.');
|
||||||
setIsRepo(false);
|
setIsRepo(false);
|
||||||
setStatusFiles([]);
|
setStatusFiles([]);
|
||||||
|
setHistoryEntries([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -176,7 +187,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
<div className="git-sidebar-header">SOURCE CONTROL</div>
|
<div className="git-sidebar-header">SOURCE CONTROL</div>
|
||||||
<div className="git-sidebar-content">
|
<div className="git-sidebar-content">
|
||||||
<div className="git-sidebar-section">
|
<div className="git-sidebar-section">
|
||||||
<div className="git-sidebar-section-header">OPEN CHANGES</div>
|
<div className="sidebar-section-title">Open Changes ({statusFiles.length})</div>
|
||||||
{statusLoading ? (
|
{statusLoading ? (
|
||||||
<div className="git-sidebar-empty-state">Loading changes...</div>
|
<div className="git-sidebar-empty-state">Loading changes...</div>
|
||||||
) : statusFiles.length === 0 ? (
|
) : statusFiles.length === 0 ? (
|
||||||
@@ -201,11 +212,27 @@ export const GitSidebar: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="git-sidebar-section git-sidebar-history">
|
<div className="git-sidebar-section git-sidebar-history">
|
||||||
<div className="git-sidebar-section-header">VERSION HISTORY</div>
|
<div className="sidebar-section-title">Version History ({historyEntries.length})</div>
|
||||||
<div className="git-sidebar-empty-state">
|
{historyLoading ? (
|
||||||
{currentBranch ? `Branch: ${currentBranch}` : 'No branch information'}
|
<div className="git-sidebar-empty-state">Loading history...</div>
|
||||||
|
) : historyEntries.length === 0 ? (
|
||||||
|
<div className="git-sidebar-empty-state">No commits yet</div>
|
||||||
|
) : (
|
||||||
|
<div className="git-sidebar-history-list" role="list" aria-label="Version History">
|
||||||
|
{historyEntries.map((entry) => (
|
||||||
|
<div key={entry.hash} className="git-sidebar-history-item">
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentBranch && <div className="git-sidebar-empty-state">Branch: {currentBranch}</div>}
|
||||||
|
</div>
|
||||||
{transcriptSection}
|
{transcriptSection}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ const mockCheckIsRepo = vi.fn();
|
|||||||
const mockRevparse = vi.fn();
|
const mockRevparse = vi.fn();
|
||||||
const mockStatus = vi.fn();
|
const mockStatus = vi.fn();
|
||||||
const mockDiff = vi.fn();
|
const mockDiff = vi.fn();
|
||||||
|
const mockShow = vi.fn();
|
||||||
|
const mockLog = vi.fn();
|
||||||
const mockInit = vi.fn();
|
const mockInit = vi.fn();
|
||||||
const mockRaw = vi.fn();
|
const mockRaw = vi.fn();
|
||||||
const mockAdd = vi.fn();
|
const mockAdd = vi.fn();
|
||||||
@@ -32,6 +34,8 @@ vi.mock('simple-git', () => ({
|
|||||||
revparse: mockRevparse,
|
revparse: mockRevparse,
|
||||||
status: mockStatus,
|
status: mockStatus,
|
||||||
diff: mockDiff,
|
diff: mockDiff,
|
||||||
|
show: mockShow,
|
||||||
|
log: mockLog,
|
||||||
init: mockInit,
|
init: mockInit,
|
||||||
raw: mockRaw,
|
raw: mockRaw,
|
||||||
add: mockAdd,
|
add: mockAdd,
|
||||||
@@ -155,6 +159,55 @@ describe('GitEngine', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getDiffContent', () => {
|
||||||
|
it('should return original and modified text for a file', async () => {
|
||||||
|
mockShow.mockResolvedValue('# old content');
|
||||||
|
mockReadFile.mockResolvedValue('# new content');
|
||||||
|
|
||||||
|
const result = await gitEngine.getDiffContent('/tmp/project', 'posts/first.md');
|
||||||
|
|
||||||
|
expect(mockShow).toHaveBeenCalledWith(['HEAD:posts/first.md']);
|
||||||
|
expect(result).toEqual({
|
||||||
|
filePath: 'posts/first.md',
|
||||||
|
original: '# old content',
|
||||||
|
modified: '# new content',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getHistory', () => {
|
||||||
|
it('should return latest commits from git log', async () => {
|
||||||
|
mockLog.mockResolvedValue({
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
hash: 'abc123',
|
||||||
|
date: '2026-02-16T10:00:00.000Z',
|
||||||
|
message: 'feat: add git sidebar',
|
||||||
|
author_name: 'Dev One',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hash: 'def456',
|
||||||
|
date: '2026-02-15T09:00:00.000Z',
|
||||||
|
message: 'fix: sidebar styles',
|
||||||
|
author_name: 'Dev Two',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await gitEngine.getHistory('/tmp/project', 20);
|
||||||
|
|
||||||
|
expect(mockLog).toHaveBeenCalledWith({ maxCount: 20 });
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
hash: 'abc123',
|
||||||
|
shortHash: 'abc123',
|
||||||
|
date: '2026-02-16T10:00:00.000Z',
|
||||||
|
subject: 'feat: add git sidebar',
|
||||||
|
author: 'Dev One',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('ensureGitignore', () => {
|
describe('ensureGitignore', () => {
|
||||||
it('should create .gitignore with default system metadata entries when missing', async () => {
|
it('should create .gitignore with default system metadata entries when missing', async () => {
|
||||||
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ const mockGitEngine = {
|
|||||||
getRepoState: vi.fn(),
|
getRepoState: vi.fn(),
|
||||||
getStatus: vi.fn(),
|
getStatus: vi.fn(),
|
||||||
getDiff: vi.fn(),
|
getDiff: vi.fn(),
|
||||||
|
getDiffContent: vi.fn(),
|
||||||
|
getHistory: vi.fn(),
|
||||||
initializeRepo: vi.fn(),
|
initializeRepo: vi.fn(),
|
||||||
ensureGitignore: vi.fn(),
|
ensureGitignore: vi.fn(),
|
||||||
pruneLfsCache: vi.fn(),
|
pruneLfsCache: vi.fn(),
|
||||||
@@ -336,6 +338,44 @@ describe('IPC Handlers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('git:history', () => {
|
||||||
|
it('should pass project path and limit to GitEngine.getHistory', async () => {
|
||||||
|
mockGitEngine.getHistory.mockResolvedValue([
|
||||||
|
{
|
||||||
|
hash: 'abc123',
|
||||||
|
shortHash: 'abc123',
|
||||||
|
date: '2026-02-16T10:00:00.000Z',
|
||||||
|
subject: 'feat: add git sidebar',
|
||||||
|
author: 'Dev One',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await invokeHandler('git:history', '/repo', 20);
|
||||||
|
|
||||||
|
expect(mockGitEngine.getHistory).toHaveBeenCalledWith('/repo', 20);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('git:diffContent', () => {
|
||||||
|
it('should pass project path and file path to GitEngine.getDiffContent', async () => {
|
||||||
|
mockGitEngine.getDiffContent.mockResolvedValue({
|
||||||
|
filePath: 'posts/first.md',
|
||||||
|
original: '# old content',
|
||||||
|
modified: '# new content',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await invokeHandler('git:diffContent', '/repo', 'posts/first.md');
|
||||||
|
|
||||||
|
expect(mockGitEngine.getDiffContent).toHaveBeenCalledWith('/repo', 'posts/first.md');
|
||||||
|
expect(result).toEqual({
|
||||||
|
filePath: 'posts/first.md',
|
||||||
|
original: '# old content',
|
||||||
|
modified: '# new content',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('git:init', () => {
|
describe('git:init', () => {
|
||||||
it('should pass project path to GitEngine.initializeRepo', async () => {
|
it('should pass project path to GitEngine.initializeRepo', async () => {
|
||||||
mockGitEngine.initializeRepo.mockResolvedValue({ success: true });
|
mockGitEngine.initializeRepo.mockResolvedValue({ success: true });
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ import { render, screen } from '@testing-library/react';
|
|||||||
import { GitDiffView } from '../../../src/renderer/components/GitDiffView/GitDiffView';
|
import { GitDiffView } from '../../../src/renderer/components/GitDiffView/GitDiffView';
|
||||||
import { useAppStore } from '../../../src/renderer/store';
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
|
vi.mock('@monaco-editor/react', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (_props: unknown) => null,
|
||||||
|
DiffEditor: (props: { original: string; modified: string; language?: string }) => (
|
||||||
|
<div data-testid="monaco-diff-editor">
|
||||||
|
<div>original:{props.original}</div>
|
||||||
|
<div>modified:{props.modified}</div>
|
||||||
|
<div>language:{props.language}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('GitDiffView', () => {
|
describe('GitDiffView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -24,9 +36,10 @@ describe('GitDiffView', () => {
|
|||||||
...(window as any).electronAPI,
|
...(window as any).electronAPI,
|
||||||
git: {
|
git: {
|
||||||
...(window as any).electronAPI?.git,
|
...(window as any).electronAPI?.git,
|
||||||
getDiff: vi.fn().mockResolvedValue({
|
getDiffContent: vi.fn().mockResolvedValue({
|
||||||
filePath: 'posts/first.md',
|
filePath: 'posts/first.md',
|
||||||
patch: 'diff --git a/posts/first.md b/posts/first.md\n+hello',
|
original: '# old line',
|
||||||
|
modified: '# new line',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
@@ -36,10 +49,12 @@ describe('GitDiffView', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads and renders the git diff patch for the selected file', async () => {
|
it('loads and renders Monaco diff editor with original and modified content', async () => {
|
||||||
render(<GitDiffView filePath="posts/first.md" />);
|
render(<GitDiffView filePath="posts/first.md" />);
|
||||||
|
|
||||||
expect(await screen.findByText(/diff --git a\/posts\/first\.md b\/posts\/first\.md/i)).toBeInTheDocument();
|
expect(await screen.findByTestId('monaco-diff-editor')).toBeInTheDocument();
|
||||||
expect((window as any).electronAPI.git.getDiff).toHaveBeenCalledWith('/repo/path', 'posts/first.md');
|
expect((window as any).electronAPI.git.getDiffContent).toHaveBeenCalledWith('/repo/path', 'posts/first.md');
|
||||||
|
expect(screen.getByText('original:# old line')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('modified:# new line')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ describe('GitSidebar', () => {
|
|||||||
getRepoState: vi.fn().mockResolvedValue({ isRepo: false, hasRemote: false }),
|
getRepoState: vi.fn().mockResolvedValue({ isRepo: false, hasRemote: false }),
|
||||||
getStatus: vi.fn().mockResolvedValue({ files: [], counts: { untracked: 0, modified: 0, deleted: 0, renamed: 0, staged: 0, total: 0 } }),
|
getStatus: vi.fn().mockResolvedValue({ files: [], counts: { untracked: 0, modified: 0, deleted: 0, renamed: 0, staged: 0, total: 0 } }),
|
||||||
getDiff: vi.fn().mockResolvedValue({ filePath: 'posts/a.md', patch: 'diff --git a/posts/a.md b/posts/a.md' }),
|
getDiff: vi.fn().mockResolvedValue({ filePath: 'posts/a.md', patch: 'diff --git a/posts/a.md b/posts/a.md' }),
|
||||||
|
getHistory: vi.fn().mockResolvedValue([]),
|
||||||
init: vi.fn().mockResolvedValue({ success: true }),
|
init: vi.fn().mockResolvedValue({ success: true }),
|
||||||
ensureGitignore: vi.fn().mockResolvedValue({ updated: false, created: false, addedEntries: [] }),
|
ensureGitignore: vi.fn().mockResolvedValue({ updated: false, created: false, addedEntries: [] }),
|
||||||
onInitProgress: vi.fn().mockImplementation(() => () => {}),
|
onInitProgress: vi.fn().mockImplementation(() => () => {}),
|
||||||
@@ -69,6 +70,15 @@ describe('GitSidebar', () => {
|
|||||||
],
|
],
|
||||||
counts: { untracked: 1, modified: 1, deleted: 0, renamed: 0, staged: 0, total: 2 },
|
counts: { untracked: 1, modified: 1, deleted: 0, renamed: 0, staged: 0, total: 2 },
|
||||||
});
|
});
|
||||||
|
(window as any).electronAPI.git.getHistory = vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
hash: 'abc123',
|
||||||
|
shortHash: 'abc123',
|
||||||
|
date: '2026-02-16T10:00:00.000Z',
|
||||||
|
subject: 'feat: add git sidebar',
|
||||||
|
author: 'Dev One',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
render(<GitSidebar />);
|
render(<GitSidebar />);
|
||||||
|
|
||||||
@@ -76,6 +86,25 @@ describe('GitSidebar', () => {
|
|||||||
expect(screen.getByText('posts/first.md')).toBeInTheDocument();
|
expect(screen.getByText('posts/first.md')).toBeInTheDocument();
|
||||||
expect(screen.getByText('posts/second.md')).toBeInTheDocument();
|
expect(screen.getByText('posts/second.md')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/version history/i)).toBeInTheDocument();
|
expect(screen.getByText(/version history/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/feat: add git sidebar/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/abc123/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the same section-title class as posts published heading', async () => {
|
||||||
|
(window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({
|
||||||
|
isRepo: true,
|
||||||
|
rootPath: '/repo/path',
|
||||||
|
currentBranch: 'main',
|
||||||
|
hasRemote: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<GitSidebar />);
|
||||||
|
|
||||||
|
const openChangesHeader = await screen.findByText(/open changes/i);
|
||||||
|
const historyHeader = screen.getByText(/version history/i);
|
||||||
|
|
||||||
|
expect(openChangesHeader).toHaveClass('sidebar-section-title');
|
||||||
|
expect(historyHeader).toHaveClass('sidebar-section-title');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('single click opens and reuses a transient git-diff tab', async () => {
|
it('single click opens and reuses a transient git-diff tab', async () => {
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ Object.defineProperty(globalThis, 'window', {
|
|||||||
getRepoState: vi.fn(),
|
getRepoState: vi.fn(),
|
||||||
getStatus: vi.fn(),
|
getStatus: vi.fn(),
|
||||||
getDiff: vi.fn(),
|
getDiff: vi.fn(),
|
||||||
|
getDiffContent: vi.fn(),
|
||||||
|
getHistory: vi.fn(),
|
||||||
init: vi.fn(),
|
init: vi.fn(),
|
||||||
},
|
},
|
||||||
posts: {
|
posts: {
|
||||||
|
|||||||
Reference in New Issue
Block a user