diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index 32d0c1f..c923f2c 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -41,6 +41,20 @@ export interface GitDiffDto { 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 = | 'checking-git' | 'initializing-repo' @@ -232,6 +246,34 @@ export class GitEngine { }; } + async getDiffContent(projectPath: string, filePath: string): Promise { + 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 { + 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 { const gitignorePath = path.join(projectPath, '.gitignore'); diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index 3fc34d5..75957af 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -80,6 +80,8 @@ export { type RepoState, type GitStatusDto, type GitDiffDto, + type GitDiffContentDto, + type GitHistoryEntry, type GitStatusFile, type GitStatusCounts, type GitInitResult, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 80897cd..a0f8266 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -53,6 +53,16 @@ export function registerIpcHandlers(): void { 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) => { const engine = getGitEngine(); return engine.initializeRepo(projectPath, remoteUrl, (progress) => { diff --git a/src/main/preload.ts b/src/main/preload.ts index 8387666..824ef05 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -11,6 +11,8 @@ export const electronAPI: ElectronAPI = { getRepoState: (projectPath: string) => ipcRenderer.invoke('git:getRepoState', projectPath), 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), + getHistory: (projectPath: string, limit?: number) => ipcRenderer.invoke('git:history', projectPath, limit), ensureGitignore: (projectPath: string) => ipcRenderer.invoke('git:ensureGitignore', projectPath), pruneLfs: (projectPath: string, options?: { dryRun?: boolean; verifyRemote?: boolean }) => ipcRenderer.invoke('git:pruneLfs', projectPath, options), init: (projectPath: string, remoteUrl?: string) => { diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 9323878..e93b5de 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -241,6 +241,20 @@ export interface GitDiffDto { 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 = | 'checking-git' | 'initializing-repo' @@ -354,6 +368,8 @@ export interface ElectronAPI { getRepoState: (projectPath: string) => Promise; getStatus: (projectPath: string) => Promise; getDiff: (projectPath: string, filePath: string) => Promise; + getDiffContent: (projectPath: string, filePath: string) => Promise; + getHistory: (projectPath: string, limit?: number) => Promise; ensureGitignore: (projectPath: string) => Promise; pruneLfs: (projectPath: string, options?: { dryRun?: boolean; verifyRemote?: boolean }) => Promise; init: (projectPath: string, remoteUrl?: string) => Promise; diff --git a/src/renderer/components/GitDiffView/GitDiffView.css b/src/renderer/components/GitDiffView/GitDiffView.css index 9f7adf7..e5aa04f 100644 --- a/src/renderer/components/GitDiffView/GitDiffView.css +++ b/src/renderer/components/GitDiffView/GitDiffView.css @@ -14,14 +14,9 @@ color: var(--vscode-sideBar-foreground); } -.git-diff-patch { - margin: 0; - padding: 12px; - overflow: auto; - white-space: pre; - font-family: var(--vscode-editor-font-family); - font-size: 12px; - line-height: 1.5; +.git-diff-editor-wrap { + flex: 1; + min-height: 0; } .git-diff-message, diff --git a/src/renderer/components/GitDiffView/GitDiffView.tsx b/src/renderer/components/GitDiffView/GitDiffView.tsx index 4e85449..1c33830 100644 --- a/src/renderer/components/GitDiffView/GitDiffView.tsx +++ b/src/renderer/components/GitDiffView/GitDiffView.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { DiffEditor } from '@monaco-editor/react'; import { useAppStore } from '../../store'; import './GitDiffView.css'; @@ -6,11 +7,40 @@ interface GitDiffViewProps { 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 = ({ filePath }) => { const { activeProject } = useAppStore(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [patch, setPatch] = useState(''); + const [original, setOriginal] = useState(''); + const [modified, setModified] = useState(''); useEffect(() => { const loadDiff = async () => { @@ -32,8 +62,9 @@ export const GitDiffView: React.FC = ({ filePath }) => { return; } - const diff = await window.electronAPI.git.getDiff(projectPath, filePath); - setPatch(diff.patch || ''); + const diff = await window.electronAPI.git.getDiffContent(projectPath, filePath); + setOriginal(diff.original || ''); + setModified(diff.modified || ''); } catch { setError('Failed to load diff.'); } finally { @@ -65,7 +96,27 @@ export const GitDiffView: React.FC = ({ filePath }) => { return (
Diff: {filePath}
- {patch ?
{patch}
:
No diff available.
} +
+ +
); }; diff --git a/src/renderer/components/GitSidebar/GitSidebar.css b/src/renderer/components/GitSidebar/GitSidebar.css index b91be13..1d2d4f3 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.css +++ b/src/renderer/components/GitSidebar/GitSidebar.css @@ -42,15 +42,6 @@ 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 { padding: 0 12px 8px; color: var(--vscode-descriptionForeground); @@ -96,6 +87,34 @@ 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 { min-height: 0; } diff --git a/src/renderer/components/GitSidebar/GitSidebar.tsx b/src/renderer/components/GitSidebar/GitSidebar.tsx index 815d760..b1eeff7 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.tsx +++ b/src/renderer/components/GitSidebar/GitSidebar.tsx @@ -1,7 +1,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useAppStore } from '../../store'; -import type { GitInitProgress } from '../../../main/shared/electronApi'; +import type { GitInitProgress, GitHistoryEntry } from '../../../main/shared/electronApi'; import './GitSidebar.css'; +import '../Sidebar/Sidebar.css'; export const GitSidebar: React.FC = () => { const { activeProject, openTab } = useAppStore(); @@ -13,6 +14,8 @@ export const GitSidebar: React.FC = () => { const [isRepo, setIsRepo] = useState(false); const [currentBranch, setCurrentBranch] = useState(null); const [statusFiles, setStatusFiles] = useState>([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyEntries, setHistoryEntries] = useState([]); const [initProgress, setInitProgress] = useState(null); const [initTranscript, setInitTranscript] = useState([]); const [isTranscriptExpanded, setIsTranscriptExpanded] = useState(false); @@ -70,19 +73,27 @@ export const GitSidebar: React.FC = () => { if (repoState.isRepo) { setStatusLoading(true); + setHistoryLoading(true); 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); + setHistoryEntries(history); } finally { setStatusLoading(false); + setHistoryLoading(false); } } else { setStatusFiles([]); + setHistoryEntries([]); } } catch { setError('Unable to load repository status.'); setIsRepo(false); setStatusFiles([]); + setHistoryEntries([]); } finally { setLoading(false); } @@ -176,7 +187,7 @@ export const GitSidebar: React.FC = () => {
SOURCE CONTROL
-
OPEN CHANGES
+
Open Changes ({statusFiles.length})
{statusLoading ? (
Loading changes...
) : statusFiles.length === 0 ? ( @@ -201,10 +212,26 @@ export const GitSidebar: React.FC = () => {
-
VERSION HISTORY
-
- {currentBranch ? `Branch: ${currentBranch}` : 'No branch information'} -
+
Version History ({historyEntries.length})
+ {historyLoading ? ( +
Loading history...
+ ) : historyEntries.length === 0 ? ( +
No commits yet
+ ) : ( +
+ {historyEntries.map((entry) => ( +
+
{entry.subject}
+
+ {entry.shortHash} + {entry.author} + {new Date(entry.date).toLocaleDateString()} +
+
+ ))} +
+ )} + {currentBranch &&
Branch: {currentBranch}
}
{transcriptSection}
diff --git a/tests/engine/GitEngine.test.ts b/tests/engine/GitEngine.test.ts index f23d76c..3dc103f 100644 --- a/tests/engine/GitEngine.test.ts +++ b/tests/engine/GitEngine.test.ts @@ -5,6 +5,8 @@ const mockCheckIsRepo = vi.fn(); const mockRevparse = vi.fn(); const mockStatus = vi.fn(); const mockDiff = vi.fn(); +const mockShow = vi.fn(); +const mockLog = vi.fn(); const mockInit = vi.fn(); const mockRaw = vi.fn(); const mockAdd = vi.fn(); @@ -32,6 +34,8 @@ vi.mock('simple-git', () => ({ revparse: mockRevparse, status: mockStatus, diff: mockDiff, + show: mockShow, + log: mockLog, init: mockInit, raw: mockRaw, 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', () => { it('should create .gitignore with default system metadata entries when missing', async () => { mockReadFile.mockRejectedValue(new Error('ENOENT')); diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index eff7d9d..2019403 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -145,6 +145,8 @@ const mockGitEngine = { getRepoState: vi.fn(), getStatus: vi.fn(), getDiff: vi.fn(), + getDiffContent: vi.fn(), + getHistory: vi.fn(), initializeRepo: vi.fn(), ensureGitignore: 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', () => { it('should pass project path to GitEngine.initializeRepo', async () => { mockGitEngine.initializeRepo.mockResolvedValue({ success: true }); diff --git a/tests/renderer/components/GitDiffView.test.tsx b/tests/renderer/components/GitDiffView.test.tsx index 8ac32fa..9c1cef7 100644 --- a/tests/renderer/components/GitDiffView.test.tsx +++ b/tests/renderer/components/GitDiffView.test.tsx @@ -4,6 +4,18 @@ import { render, screen } from '@testing-library/react'; import { GitDiffView } from '../../../src/renderer/components/GitDiffView/GitDiffView'; 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 }) => ( +
+
original:{props.original}
+
modified:{props.modified}
+
language:{props.language}
+
+ ), +})); + describe('GitDiffView', () => { beforeEach(() => { vi.clearAllMocks(); @@ -24,9 +36,10 @@ describe('GitDiffView', () => { ...(window as any).electronAPI, git: { ...(window as any).electronAPI?.git, - getDiff: vi.fn().mockResolvedValue({ + getDiffContent: vi.fn().mockResolvedValue({ filePath: 'posts/first.md', - patch: 'diff --git a/posts/first.md b/posts/first.md\n+hello', + original: '# old line', + modified: '# new line', }), }, 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(); - expect(await screen.findByText(/diff --git a\/posts\/first\.md b\/posts\/first\.md/i)).toBeInTheDocument(); - expect((window as any).electronAPI.git.getDiff).toHaveBeenCalledWith('/repo/path', 'posts/first.md'); + expect(await screen.findByTestId('monaco-diff-editor')).toBeInTheDocument(); + 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(); }); }); diff --git a/tests/renderer/components/GitSidebar.test.tsx b/tests/renderer/components/GitSidebar.test.tsx index 45ddff0..67c184b 100644 --- a/tests/renderer/components/GitSidebar.test.tsx +++ b/tests/renderer/components/GitSidebar.test.tsx @@ -34,6 +34,7 @@ describe('GitSidebar', () => { 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 } }), 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 }), ensureGitignore: vi.fn().mockResolvedValue({ updated: false, created: false, addedEntries: [] }), onInitProgress: vi.fn().mockImplementation(() => () => {}), @@ -69,6 +70,15 @@ describe('GitSidebar', () => { ], 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(); @@ -76,6 +86,25 @@ describe('GitSidebar', () => { expect(screen.getByText('posts/first.md')).toBeInTheDocument(); expect(screen.getByText('posts/second.md')).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(); + + 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 () => { diff --git a/tests/setup.ts b/tests/setup.ts index cd9458e..c442868 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -49,6 +49,8 @@ Object.defineProperty(globalThis, 'window', { getRepoState: vi.fn(), getStatus: vi.fn(), getDiff: vi.fn(), + getDiffContent: vi.fn(), + getHistory: vi.fn(), init: vi.fn(), }, posts: {