From 9d71aa63fb2d7da47a607b8a74f466f4cfcc59f9 Mon Sep 17 00:00:00 2001 From: hugo Date: Mon, 16 Feb 2026 11:46:47 +0100 Subject: [PATCH] feat: phase 3 for git integration --- src/main/engine/GitEngine.ts | 15 ++++ src/main/engine/index.ts | 1 + src/main/ipc/handlers.ts | 5 ++ src/main/preload.ts | 1 + src/main/shared/electronApi.ts | 6 ++ src/renderer/components/Editor/Editor.tsx | 14 +++ .../components/GitDiffView/GitDiffView.css | 36 ++++++++ .../components/GitDiffView/GitDiffView.tsx | 71 +++++++++++++++ .../components/GitSidebar/GitSidebar.css | 74 +++++++++++++++ .../components/GitSidebar/GitSidebar.tsx | 65 ++++++++++++-- src/renderer/components/TabBar/TabBar.tsx | 12 +++ src/renderer/store/appStore.ts | 2 +- tests/engine/GitEngine.test.ts | 16 ++++ tests/ipc/handlers.test.ts | 18 ++++ .../renderer/components/GitDiffView.test.tsx | 45 ++++++++++ tests/renderer/components/GitSidebar.test.tsx | 90 ++++++++++++++++++- tests/renderer/store/tabStore.test.ts | 34 +++++++ tests/setup.ts | 1 + 18 files changed, 499 insertions(+), 7 deletions(-) create mode 100644 src/renderer/components/GitDiffView/GitDiffView.css create mode 100644 src/renderer/components/GitDiffView/GitDiffView.tsx create mode 100644 tests/renderer/components/GitDiffView.test.tsx diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index 216814e..32d0c1f 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -36,6 +36,11 @@ export interface GitStatusDto { counts: GitStatusCounts; } +export interface GitDiffDto { + filePath: string; + patch: string; +} + export type GitInitPhase = | 'checking-git' | 'initializing-repo' @@ -217,6 +222,16 @@ export class GitEngine { }; } + async getDiff(projectPath: string, filePath: string): Promise { + const git = simpleGit(projectPath); + const patch = await git.diff(['--', filePath]); + + return { + filePath, + patch, + }; + } + 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 97eaf46..3fc34d5 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -79,6 +79,7 @@ export { type GitAvailability, type RepoState, type GitStatusDto, + type GitDiffDto, type GitStatusFile, type GitStatusCounts, type GitInitResult, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 8d8027b..80897cd 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -48,6 +48,11 @@ export function registerIpcHandlers(): void { return engine.getStatus(projectPath); }); + safeHandle('git:diff', async (_, projectPath: string, filePath: string) => { + const engine = getGitEngine(); + return engine.getDiff(projectPath, filePath); + }); + 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 8afc077..8387666 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -10,6 +10,7 @@ export const electronAPI: ElectronAPI = { checkAvailability: () => ipcRenderer.invoke('git:checkAvailability'), 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), 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 951e07f..9323878 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -236,6 +236,11 @@ export interface GitStatusDto { counts: GitStatusCounts; } +export interface GitDiffDto { + filePath: string; + patch: string; +} + export type GitInitPhase = | 'checking-git' | 'initializing-repo' @@ -348,6 +353,7 @@ export interface ElectronAPI { checkAvailability: () => Promise; getRepoState: (projectPath: string) => Promise; getStatus: (projectPath: string) => Promise; + getDiff: (projectPath: string, filePath: string) => 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/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 0c3aa1a..e6a90ef 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -14,6 +14,7 @@ import { TagInput } from '../TagInput'; import { ChatPanel } from '../ChatPanel'; import { ImportAnalysisView } from '../ImportAnalysisView'; import { MetadataDiffPanel } from '../MetadataDiffPanel'; +import { GitDiffView } from '../GitDiffView/GitDiffView'; import { AutoSaveManager, getContrastColor } from '../../utils'; import { parseMacros, getMacro } from '../../macros/registry'; import { InsertModal } from '../InsertModal'; @@ -2214,6 +2215,7 @@ export const Editor: React.FC = () => { const showChat = activeTab?.type === 'chat'; const showImport = activeTab?.type === 'import'; const showMetadataDiff = activeTab?.type === 'metadata-diff'; + const showGitDiff = activeTab?.type === 'git-diff'; // Clear selectedPostId if the post doesn't exist (e.g., after project switch) useEffect(() => { @@ -2313,6 +2315,18 @@ export const Editor: React.FC = () => { ); } + // Show git diff view if git-diff tab is active + if (showGitDiff && activeTabId) { + const filePath = activeTabId.startsWith('git-diff:') ? activeTabId.slice('git-diff:'.length) : activeTabId; + return ( +
+ + {renderErrorModal()} + {renderConfirmDeleteModal()} +
+ ); + } + // Show post editor if a post tab is active if (showPost && activeTabId) { return ( diff --git a/src/renderer/components/GitDiffView/GitDiffView.css b/src/renderer/components/GitDiffView/GitDiffView.css new file mode 100644 index 0000000..9f7adf7 --- /dev/null +++ b/src/renderer/components/GitDiffView/GitDiffView.css @@ -0,0 +1,36 @@ +.git-diff-view { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); +} + +.git-diff-header { + padding: 10px 12px; + font-size: 12px; + border-bottom: 1px solid var(--vscode-editorWidget-border); + 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-message, +.git-diff-error { + padding: 12px; + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +.git-diff-error { + color: var(--vscode-errorForeground); +} diff --git a/src/renderer/components/GitDiffView/GitDiffView.tsx b/src/renderer/components/GitDiffView/GitDiffView.tsx new file mode 100644 index 0000000..4e85449 --- /dev/null +++ b/src/renderer/components/GitDiffView/GitDiffView.tsx @@ -0,0 +1,71 @@ +import React, { useEffect, useState } from 'react'; +import { useAppStore } from '../../store'; +import './GitDiffView.css'; + +interface GitDiffViewProps { + filePath: string; +} + +export const GitDiffView: React.FC = ({ filePath }) => { + const { activeProject } = useAppStore(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [patch, setPatch] = useState(''); + + useEffect(() => { + const loadDiff = async () => { + setLoading(true); + setError(null); + + try { + if (!activeProject) { + setError('No active project selected.'); + return; + } + + const projectPath = activeProject.dataPath + ? activeProject.dataPath + : await window.electronAPI.app.getDefaultProjectPath(activeProject.id); + + if (!projectPath) { + setError('Unable to resolve project path.'); + return; + } + + const diff = await window.electronAPI.git.getDiff(projectPath, filePath); + setPatch(diff.patch || ''); + } catch { + setError('Failed to load diff.'); + } finally { + setLoading(false); + } + }; + + void loadDiff(); + }, [activeProject, filePath]); + + if (loading) { + return ( +
+
Diff: {filePath}
+
Loading diff...
+
+ ); + } + + if (error) { + return ( +
+
Diff: {filePath}
+
{error}
+
+ ); + } + + 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 0239d7d..b91be13 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.css +++ b/src/renderer/components/GitSidebar/GitSidebar.css @@ -22,6 +22,80 @@ font-size: 12px; } +.git-sidebar-content { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding: 8px 0 0; +} + +.git-sidebar-section { + display: flex; + flex-direction: column; + min-height: 0; +} + +.git-sidebar-history { + margin-top: 12px; + border-top: 1px solid var(--vscode-editorWidget-border); + 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); + font-size: 12px; +} + +.git-sidebar-file-list { + display: flex; + flex-direction: column; + overflow: auto; +} + +.git-sidebar-file-item { + width: 100%; + border: none; + background: transparent; + color: var(--vscode-sideBar-foreground); + text-align: left; + padding: 6px 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.git-sidebar-file-item:hover { + background: var(--vscode-list-hoverBackground); +} + +.git-sidebar-file-path { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} + +.git-sidebar-file-status { + text-transform: uppercase; + 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 433c9e2..815d760 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.tsx +++ b/src/renderer/components/GitSidebar/GitSidebar.tsx @@ -4,18 +4,33 @@ import type { GitInitProgress } from '../../../main/shared/electronApi'; import './GitSidebar.css'; export const GitSidebar: React.FC = () => { - const { activeProject } = useAppStore(); + const { activeProject, openTab } = useAppStore(); const [projectPath, setProjectPath] = useState(null); const [loading, setLoading] = useState(true); const [initializing, setInitializing] = useState(false); + const [statusLoading, setStatusLoading] = useState(false); const [error, setError] = useState(null); const [isRepo, setIsRepo] = useState(false); const [currentBranch, setCurrentBranch] = useState(null); + const [statusFiles, setStatusFiles] = useState>([]); const [initProgress, setInitProgress] = useState(null); const [initTranscript, setInitTranscript] = useState([]); const [isTranscriptExpanded, setIsTranscriptExpanded] = useState(false); const remoteUrlInputRef = useRef(null); + const getDiffTabId = (filePath: string): string => `git-diff:${filePath}`; + + const openDiffTab = useCallback( + (filePath: string, isTransient: boolean) => { + openTab({ + type: 'git-diff', + id: getDiffTabId(filePath), + isTransient, + }); + }, + [openTab], + ); + const resolveProjectPath = useCallback(async (): Promise => { if (!activeProject) { return null; @@ -52,9 +67,22 @@ export const GitSidebar: React.FC = () => { const repoState = await window.electronAPI.git.getRepoState(resolvedProjectPath); setIsRepo(repoState.isRepo); setCurrentBranch(repoState.currentBranch || null); + + if (repoState.isRepo) { + setStatusLoading(true); + try { + const status = await window.electronAPI.git.getStatus(resolvedProjectPath); + setStatusFiles(status.files); + } finally { + setStatusLoading(false); + } + } else { + setStatusFiles([]); + } } catch { setError('Unable to load repository status.'); setIsRepo(false); + setStatusFiles([]); } finally { setLoading(false); } @@ -146,10 +174,37 @@ export const GitSidebar: React.FC = () => { return (
SOURCE CONTROL
-
-
-

Git repository ready

- {currentBranch &&

Branch: {currentBranch}

} +
+
+
OPEN CHANGES
+ {statusLoading ? ( +
Loading changes...
+ ) : statusFiles.length === 0 ? ( +
No changes
+ ) : ( +
+ {statusFiles.map((file) => ( + + ))} +
+ )} +
+ +
+
VERSION HISTORY
+
+ {currentBranch ? `Branch: ${currentBranch}` : 'No branch information'} +
{transcriptSection}
diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx index e09c6b9..059d3e2 100644 --- a/src/renderer/components/TabBar/TabBar.tsx +++ b/src/renderer/components/TabBar/TabBar.tsx @@ -11,6 +11,12 @@ const getTabTitle = ( chatTitles: Map, importDefTitles: Map ): string => { + if (tab.type === 'git-diff') { + const filePath = tab.id.startsWith('git-diff:') ? tab.id.slice('git-diff:'.length) : tab.id; + const filename = filePath.split('/').pop(); + return filename || filePath; + } + if (tab.type === 'settings') { return 'Settings'; } @@ -52,6 +58,12 @@ const getTabTitle = ( const getTabIcon = (tab: Tab): React.ReactNode => { switch (tab.type) { + case 'git-diff': + return ( + + + + ); case 'post': return ( diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index dad8266..3ed4c6b 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -12,7 +12,7 @@ import type { const STORAGE_KEY = 'bds-app-state'; // Tab types -export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat' | 'import' | 'metadata-diff'; +export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat' | 'import' | 'metadata-diff' | 'git-diff'; export interface Tab { type: TabType; diff --git a/tests/engine/GitEngine.test.ts b/tests/engine/GitEngine.test.ts index f0dc80d..f23d76c 100644 --- a/tests/engine/GitEngine.test.ts +++ b/tests/engine/GitEngine.test.ts @@ -4,6 +4,7 @@ const mockVersion = vi.fn(); const mockCheckIsRepo = vi.fn(); const mockRevparse = vi.fn(); const mockStatus = vi.fn(); +const mockDiff = vi.fn(); const mockInit = vi.fn(); const mockRaw = vi.fn(); const mockAdd = vi.fn(); @@ -30,6 +31,7 @@ vi.mock('simple-git', () => ({ checkIsRepo: mockCheckIsRepo, revparse: mockRevparse, status: mockStatus, + diff: mockDiff, init: mockInit, raw: mockRaw, add: mockAdd, @@ -139,6 +141,20 @@ describe('GitEngine', () => { }); }); + describe('getDiff', () => { + it('should return patch output for a repository file', async () => { + mockDiff.mockResolvedValue('diff --git a/posts/first.md b/posts/first.md\n+hello'); + + const result = await gitEngine.getDiff('/tmp/project', 'posts/first.md'); + + expect(mockDiff).toHaveBeenCalledWith(['--', 'posts/first.md']); + expect(result).toEqual({ + filePath: 'posts/first.md', + patch: 'diff --git a/posts/first.md b/posts/first.md\n+hello', + }); + }); + }); + 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 c66abf9..eff7d9d 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -144,6 +144,7 @@ const mockGitEngine = { checkAvailability: vi.fn(), getRepoState: vi.fn(), getStatus: vi.fn(), + getDiff: vi.fn(), initializeRepo: vi.fn(), ensureGitignore: vi.fn(), pruneLfsCache: vi.fn(), @@ -318,6 +319,23 @@ describe('IPC Handlers', () => { }); }); + describe('git:diff', () => { + it('should pass project path and file path to GitEngine.getDiff', async () => { + mockGitEngine.getDiff.mockResolvedValue({ + filePath: 'posts/first.md', + patch: 'diff --git a/posts/first.md b/posts/first.md', + }); + + const result = await invokeHandler('git:diff', '/repo', 'posts/first.md'); + + expect(mockGitEngine.getDiff).toHaveBeenCalledWith('/repo', 'posts/first.md'); + expect(result).toEqual({ + filePath: 'posts/first.md', + patch: 'diff --git a/posts/first.md b/posts/first.md', + }); + }); + }); + 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 new file mode 100644 index 0000000..8ac32fa --- /dev/null +++ b/tests/renderer/components/GitDiffView.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { GitDiffView } from '../../../src/renderer/components/GitDiffView/GitDiffView'; +import { useAppStore } from '../../../src/renderer/store'; + +describe('GitDiffView', () => { + beforeEach(() => { + vi.clearAllMocks(); + + 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(), + }, + }); + + (window as any).electronAPI = { + ...(window as any).electronAPI, + git: { + ...(window as any).electronAPI?.git, + getDiff: vi.fn().mockResolvedValue({ + filePath: 'posts/first.md', + patch: 'diff --git a/posts/first.md b/posts/first.md\n+hello', + }), + }, + app: { + ...(window as any).electronAPI?.app, + getDefaultProjectPath: vi.fn().mockResolvedValue('/repo/path'), + }, + }; + }); + + it('loads and renders the git diff patch for the selected file', 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'); + }); +}); diff --git a/tests/renderer/components/GitSidebar.test.tsx b/tests/renderer/components/GitSidebar.test.tsx index 3a00d9b..45ddff0 100644 --- a/tests/renderer/components/GitSidebar.test.tsx +++ b/tests/renderer/components/GitSidebar.test.tsx @@ -4,6 +4,8 @@ import { render, screen, fireEvent, act } from '@testing-library/react'; import { GitSidebar } from '../../../src/renderer/components/GitSidebar/GitSidebar'; import { useAppStore } from '../../../src/renderer/store'; +const getStore = () => useAppStore.getState(); + describe('GitSidebar', () => { beforeEach(() => { vi.clearAllMocks(); @@ -17,6 +19,8 @@ describe('GitSidebar', () => { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, + tabs: [], + activeTabId: null, }); (window as any).electronAPI = { @@ -28,6 +32,8 @@ describe('GitSidebar', () => { git: { checkAvailability: vi.fn().mockResolvedValue({ gitFound: true, version: '2.49.0' }), 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' }), init: vi.fn().mockResolvedValue({ success: true }), ensureGitignore: vi.fn().mockResolvedValue({ updated: false, created: false, addedEntries: [] }), onInitProgress: vi.fn().mockImplementation(() => () => {}), @@ -49,6 +55,88 @@ describe('GitSidebar', () => { expect(await screen.findByRole('button', { name: /initialize git/i })).toBeInTheDocument(); }); + it('renders open changes list when repository exists', async () => { + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: true, + }); + (window as any).electronAPI.git.getStatus = vi.fn().mockResolvedValue({ + files: [ + { path: 'posts/first.md', status: 'modified' }, + { path: 'posts/second.md', status: 'untracked' }, + ], + counts: { untracked: 1, modified: 1, deleted: 0, renamed: 0, staged: 0, total: 2 }, + }); + + render(); + + expect(await screen.findByText(/open changes/i)).toBeInTheDocument(); + expect(screen.getByText('posts/first.md')).toBeInTheDocument(); + expect(screen.getByText('posts/second.md')).toBeInTheDocument(); + expect(screen.getByText(/version history/i)).toBeInTheDocument(); + }); + + it('single click opens and reuses a transient git-diff 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.getStatus = vi.fn().mockResolvedValue({ + files: [ + { path: 'posts/first.md', status: 'modified' }, + { path: 'posts/second.md', status: 'untracked' }, + ], + counts: { untracked: 1, modified: 1, deleted: 0, renamed: 0, staged: 0, total: 2 }, + }); + + render(); + + const first = await screen.findByRole('button', { name: /posts\/first\.md/i }); + const second = screen.getByRole('button', { name: /posts\/second\.md/i }); + + await act(async () => { + fireEvent.click(first); + }); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().tabs[0]).toMatchObject({ type: 'git-diff', id: 'git-diff:posts/first.md', isTransient: true }); + + await act(async () => { + fireEvent.click(second); + }); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().tabs[0]).toMatchObject({ type: 'git-diff', id: 'git-diff:posts/second.md', isTransient: true }); + }); + + it('double click opens a persistent git-diff 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.getStatus = vi.fn().mockResolvedValue({ + files: [{ path: 'posts/first.md', status: 'modified' }], + counts: { untracked: 0, modified: 1, deleted: 0, renamed: 0, staged: 0, total: 1 }, + }); + + render(); + + const first = await screen.findByRole('button', { name: /posts\/first\.md/i }); + + await act(async () => { + fireEvent.doubleClick(first); + }); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().tabs[0]).toMatchObject({ type: 'git-diff', id: 'git-diff:posts/first.md', isTransient: false }); + }); + it('initializes repository and refreshes repo state after clicking Initialize Git', async () => { const getRepoStateMock = vi .fn() @@ -70,7 +158,7 @@ describe('GitSidebar', () => { }); await vi.waitFor(() => { - expect(screen.getByText(/git repository ready/i)).toBeInTheDocument(); + expect(screen.getByText(/open changes/i)).toBeInTheDocument(); }); }); diff --git a/tests/renderer/store/tabStore.test.ts b/tests/renderer/store/tabStore.test.ts index 2842da5..23dde56 100644 --- a/tests/renderer/store/tabStore.test.ts +++ b/tests/renderer/store/tabStore.test.ts @@ -147,6 +147,40 @@ describe('Tab Management', () => { expect(getStore().tabs).toHaveLength(2); expect(getStore().activeTabId).toBe('post-1'); }); + + it('should open git diff in a transient tab on single click', () => { + getStore().openTab({ type: 'git-diff', id: 'git-diff:posts/first.md', isTransient: true }); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().tabs[0]).toMatchObject({ + type: 'git-diff', + id: 'git-diff:posts/first.md', + isTransient: true, + }); + }); + + it('should reuse transient git diff tab on subsequent single click', () => { + getStore().openTab({ type: 'git-diff', id: 'git-diff:posts/first.md', isTransient: true }); + getStore().openTab({ type: 'git-diff', id: 'git-diff:posts/second.md', isTransient: true }); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().tabs[0]).toMatchObject({ + type: 'git-diff', + id: 'git-diff:posts/second.md', + isTransient: true, + }); + }); + + it('should open git diff in a persistent tab on double click', () => { + getStore().openTab({ type: 'git-diff', id: 'git-diff:posts/first.md', isTransient: false }); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().tabs[0]).toMatchObject({ + type: 'git-diff', + id: 'git-diff:posts/first.md', + isTransient: false, + }); + }); }); describe('Closing Tabs', () => { diff --git a/tests/setup.ts b/tests/setup.ts index 5b518b8..cd9458e 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -48,6 +48,7 @@ Object.defineProperty(globalThis, 'window', { checkAvailability: vi.fn(), getRepoState: vi.fn(), getStatus: vi.fn(), + getDiff: vi.fn(), init: vi.fn(), }, posts: {