diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index a5f4041..45f69eb 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -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 { + 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 { const git = simpleGit(projectPath); const history = await git.log({ maxCount: limit }); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index c826206..069b582 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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); diff --git a/src/main/preload.ts b/src/main/preload.ts index 1e3c1ed..9560cd2 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index e061bb6..ae12a7d 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -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; getDiff: (projectPath: string, filePath: string) => Promise; getDiffContent: (projectPath: string, filePath: string) => Promise; + getCommitDiffContent: (projectPath: string, commitHash: string) => Promise; getHistory: (projectPath: string, limit?: number) => Promise; fetch: (projectPath: string) => Promise; pull: (projectPath: string) => Promise; diff --git a/src/renderer/components/GitDiffView/GitDiffView.css b/src/renderer/components/GitDiffView/GitDiffView.css index e5aa04f..999d9d5 100644 --- a/src/renderer/components/GitDiffView/GitDiffView.css +++ b/src/renderer/components/GitDiffView/GitDiffView.css @@ -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; diff --git a/src/renderer/components/GitDiffView/GitDiffView.tsx b/src/renderer/components/GitDiffView/GitDiffView.tsx index 6394558..a2e5436 100644 --- a/src/renderer/components/GitDiffView/GitDiffView.tsx +++ b/src/renderer/components/GitDiffView/GitDiffView.tsx @@ -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 = ({ filePath }) => { @@ -46,6 +52,40 @@ export const GitDiffView: React.FC = ({ filePath }) => { const [error, setError] = useState(null); const [original, setOriginal] = useState(''); const [modified, setModified] = useState(''); + const [commitFiles, setCommitFiles] = useState([]); + const [selectedCommitFilePath, setSelectedCommitFilePath] = useState(''); + 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 = ({ 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 = ({ filePath }) => { }; void loadDiff(); - }, [activeProject, filePath]); + }, [activeProject, filePath, isCommitDiff, commitHash]); if (loading) { return (
-
Diff: {filePath}
+
Diff: {isCommitDiff ? `Commit ${commitHash}` : filePath}
Loading diff...
); @@ -92,7 +150,7 @@ export const GitDiffView: React.FC = ({ filePath }) => { if (error) { return (
-
Diff: {filePath}
+
Diff: {isCommitDiff ? `Commit ${commitHash}` : filePath}
{error}
); @@ -100,16 +158,54 @@ export const GitDiffView: React.FC = ({ filePath }) => { return (
-
Diff: {filePath}
+
Diff: {isCommitDiff ? `Commit ${commitHash}` : filePath}
+ {isCommitDiff && commitFiles.length > 0 && ( +
+ + + + +
+ )}
{ const commitMessageInputRef = useRef(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 => { if (!activeProject) { return null; @@ -367,14 +379,21 @@ export const GitSidebar: React.FC = () => { ) : (
{historyEntries.map((entry) => ( -
+
+ ))}
)} diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx index 059d3e2..754e16d 100644 --- a/src/renderer/components/TabBar/TabBar.tsx +++ b/src/renderer/components/TabBar/TabBar.tsx @@ -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, media: { id: string; originalName: string }[], chatTitles: Map, - importDefTitles: Map + importDefTitles: Map, + commitTitles: Map ): 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>(new Map()); const [chatTitles, setChatTitles] = useState>(new Map()); const [importDefTitles, setImportDefTitles] = useState>(new Map()); + const [commitTitles, setCommitTitles] = useState>(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 ( diff --git a/tests/engine/GitEngine.test.ts b/tests/engine/GitEngine.test.ts index eefa306..758e0ad 100644 --- a/tests/engine/GitEngine.test.ts +++ b/tests/engine/GitEngine.test.ts @@ -203,6 +203,48 @@ describe('GitEngine', () => { }); }); + describe('getCommitDiffContent', () => { + it('should return commit patch text in diff content shape', async () => { + mockShow.mockResolvedValue([ + 'diff --git a/posts/first.md b/posts/first.md', + 'index 1234567..89abcde 100644', + '--- a/posts/first.md', + '+++ b/posts/first.md', + '@@ -1 +1 @@', + '-old', + '+new', + 'diff --git a/src/main.ts b/src/main.ts', + 'index 1234567..89abcde 100644', + '--- a/src/main.ts', + '+++ b/src/main.ts', + '@@ -1 +1 @@', + '-const oldValue = 1;', + '+const newValue = 2;', + ].join('\n')); + + const result = await gitEngine.getCommitDiffContent('/tmp/project', 'abc123def456'); + + expect(mockShow).toHaveBeenCalledWith(['--format=', '--patch', 'abc123def456']); + expect(result).toEqual({ + commitHash: 'abc123def456', + original: 'old', + modified: 'new', + files: [ + { + filePath: 'posts/first.md', + original: 'old', + modified: 'new', + }, + { + filePath: 'src/main.ts', + original: 'const oldValue = 1;', + modified: 'const newValue = 2;', + }, + ], + }); + }); + }); + describe('getHistory', () => { it('should return latest commits from git log', async () => { mockLog.mockResolvedValue({ diff --git a/tests/renderer/components/GitDiffView.test.tsx b/tests/renderer/components/GitDiffView.test.tsx index 05eff38..3b15675 100644 --- a/tests/renderer/components/GitDiffView.test.tsx +++ b/tests/renderer/components/GitDiffView.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { GitDiffView } from '../../../src/renderer/components/GitDiffView/GitDiffView'; import { useAppStore } from '../../../src/renderer/store'; @@ -58,6 +58,23 @@ describe('GitDiffView', () => { original: '# old line', modified: '# new line', }), + getCommitDiffContent: vi.fn().mockResolvedValue({ + commitHash: 'abc123def456', + original: '--- posts/first.md\nold', + modified: '--- posts/first.md\nnew', + files: [ + { + filePath: 'posts/first.md', + original: 'old', + modified: 'new', + }, + { + filePath: 'src/main.ts', + original: 'const oldValue = 1;', + modified: 'const newValue = 2;', + }, + ], + }), }, app: { ...(window as any).electronAPI?.app, @@ -79,4 +96,33 @@ describe('GitDiffView', () => { expect(screen.getByText('keepOriginal:true')).toBeInTheDocument(); expect(screen.getByText('keepModified:true')).toBeInTheDocument(); }); + + it('loads commit diff content when a commit tab identifier is used', async () => { + render(); + + const diffEditor = await screen.findByTestId('monaco-diff-editor'); + expect(diffEditor).toBeInTheDocument(); + expect(await screen.findByRole('combobox', { name: /changed files/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /previous file/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /next file/i })).toBeEnabled(); + expect((window as any).electronAPI.git.getCommitDiffContent).toHaveBeenCalledWith('/repo/path', 'abc123def456'); + expect(diffEditor).toHaveTextContent(/original:\s*old/); + expect(diffEditor).toHaveTextContent(/modified:\s*new/); + expect(screen.getByText('language:markdown')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /next file/i })); + + expect(diffEditor).toHaveTextContent(/original:\s*const oldValue = 1;/); + expect(diffEditor).toHaveTextContent(/modified:\s*const newValue = 2;/); + expect(screen.getByText('language:typescript')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /previous file/i })).toBeEnabled(); + expect(screen.getByRole('button', { name: /next file/i })).toBeDisabled(); + + fireEvent.click(screen.getByRole('button', { name: /previous file/i })); + + expect(diffEditor).toHaveTextContent(/original:\s*old/); + expect(diffEditor).toHaveTextContent(/modified:\s*new/); + expect(screen.getByRole('button', { name: /previous file/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /next file/i })).toBeEnabled(); + }); }); diff --git a/tests/renderer/components/GitSidebar.test.tsx b/tests/renderer/components/GitSidebar.test.tsx index 4bdada0..ce9a241 100644 --- a/tests/renderer/components/GitSidebar.test.tsx +++ b/tests/renderer/components/GitSidebar.test.tsx @@ -35,6 +35,7 @@ describe('GitSidebar', () => { 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' }), getDiffContent: vi.fn().mockResolvedValue({ filePath: 'posts/a.md', original: '', modified: '' }), + getCommitDiffContent: vi.fn().mockResolvedValue({ commitHash: 'abc123', original: '', modified: '' }), getHistory: vi.fn().mockResolvedValue([]), fetch: vi.fn().mockResolvedValue({ success: true }), pull: vi.fn().mockResolvedValue({ success: true }), @@ -171,6 +172,72 @@ describe('GitSidebar', () => { expect(getStore().tabs[0]).toMatchObject({ type: 'git-diff', id: 'git-diff:posts/first.md', isTransient: false }); }); + it('single click on a commit opens a transient git-diff commit tab', async () => { + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: true, + }); + (window as any).electronAPI.git.getHistory = vi.fn().mockResolvedValue([ + { + hash: 'abc123def456', + shortHash: 'abc123d', + date: '2026-02-16T10:00:00.000Z', + subject: 'feat: add sidebar history click', + author: 'Dev One', + }, + ]); + + render(); + + const commitItem = await screen.findByRole('button', { name: /feat: add sidebar history click/i }); + + await act(async () => { + fireEvent.click(commitItem); + }); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().tabs[0]).toMatchObject({ + type: 'git-diff', + id: 'git-diff:commit:abc123def456', + isTransient: true, + }); + }); + + it('double click on a commit opens a persistent git-diff commit tab', async () => { + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: true, + }); + (window as any).electronAPI.git.getHistory = vi.fn().mockResolvedValue([ + { + hash: 'abc123def456', + shortHash: 'abc123d', + date: '2026-02-16T10:00:00.000Z', + subject: 'feat: add sidebar history click', + author: 'Dev One', + }, + ]); + + render(); + + const commitItem = await screen.findByRole('button', { name: /feat: add sidebar history click/i }); + + await act(async () => { + fireEvent.doubleClick(commitItem); + }); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().tabs[0]).toMatchObject({ + type: 'git-diff', + id: 'git-diff:commit:abc123def456', + isTransient: false, + }); + }); + it('initializes repository and refreshes repo state after clicking Initialize Git', async () => { const getRepoStateMock = vi .fn() diff --git a/tests/renderer/components/TabBar.test.tsx b/tests/renderer/components/TabBar.test.tsx new file mode 100644 index 0000000..122d914 --- /dev/null +++ b/tests/renderer/components/TabBar.test.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { TabBar } from '../../../src/renderer/components/TabBar/TabBar'; +import { useAppStore } from '../../../src/renderer/store'; + +describe('TabBar', () => { + beforeEach(() => { + vi.clearAllMocks(); + + (window as any).addEventListener = vi.fn(); + (window as any).removeEventListener = vi.fn(); + + if (!(globalThis as any).ResizeObserver) { + (globalThis as any).ResizeObserver = class { + observe() {} + disconnect() {} + }; + } + + useAppStore.setState({ + activeProject: { + id: 'project-1', + name: 'Test Project', + slug: 'test-project', + isActive: true, + dataPath: '/repo/path', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + tabs: [ + { type: 'git-diff', id: 'git-diff:commit:abc123def456', isTransient: false }, + ], + activeTabId: 'git-diff:commit:abc123def456', + media: [], + dirtyPosts: new Set(), + sidebarVisible: true, + }); + + (window as any).electronAPI = { + ...(window as any).electronAPI, + git: { + ...(window as any).electronAPI?.git, + getHistory: vi.fn().mockResolvedValue([ + { + hash: 'abc123def456', + shortHash: 'abc123d', + date: '2026-02-16T10:00:00.000Z', + subject: 'feat: improve commit diff tabs', + author: 'Dev One', + }, + ]), + }, + app: { + ...(window as any).electronAPI?.app, + getDefaultProjectPath: vi.fn().mockResolvedValue('/repo/path'), + }, + posts: { + ...(window as any).electronAPI?.posts, + get: vi.fn(), + }, + chat: { + ...(window as any).electronAPI?.chat, + getConversation: vi.fn(), + onTitleUpdated: vi.fn(() => () => {}), + }, + importDefinitions: { + ...(window as any).electronAPI?.importDefinitions, + get: vi.fn(), + onNameUpdated: vi.fn(() => () => {}), + }, + }; + }); + + it('renders commit subject in git-diff commit tab titles when available', async () => { + render(); + + expect(await screen.findByText('abc123d feat: improve commit diff tabs')).toBeInTheDocument(); + expect((window as any).electronAPI.git.getHistory).toHaveBeenCalledWith('/repo/path', 200); + }); +});