From 95bbe171b50813ce094fde34d48f9fae7fe1abe2 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Sat, 6 Jun 2026 08:28:37 +0530 Subject: [PATCH] feat(renderer): GitStatusPanel sidebar tab + git.refresh event --- .../components/sidebar/GitStatusPanel.tsx | 101 ++++++++++++++++++ src/renderer/components/sidebar/Sidebar.tsx | 16 +++ .../lib/commands/register-menu-commands.ts | 2 +- .../component/sidebar/GitStatusPanel.test.tsx | 73 +++++++++++++ tests/component/sidebar/Sidebar.test.tsx | 1 + tests/integration/phase5-smoke.test.tsx | 13 ++- tests/integration/phase6-smoke.test.tsx | 1 + 7 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 src/renderer/components/sidebar/GitStatusPanel.tsx create mode 100644 tests/component/sidebar/GitStatusPanel.test.tsx diff --git a/src/renderer/components/sidebar/GitStatusPanel.tsx b/src/renderer/components/sidebar/GitStatusPanel.tsx new file mode 100644 index 0000000..8a38e62 --- /dev/null +++ b/src/renderer/components/sidebar/GitStatusPanel.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from 'react'; +import { RefreshCw, FileX, FilePlus, FileEdit, FileQuestion } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useFileStore } from '@/stores/file-store'; +import { ipc } from '@/lib/ipc'; + +interface GitStatus { + filePath: string; + status: 'modified' | 'added' | 'deleted' | 'untracked'; +} + +const STATUS_ICON: Record = { + modified: , + added: , + deleted: , + untracked: , +}; + +const STATUS_LABEL: Record = { + modified: 'M', + added: 'A', + deleted: 'D', + untracked: '?', +}; + +export function GitStatusPanel() { + const rootPath = useFileStore((s) => s.rootPath); + const openFile = useFileStore((s) => s.openFile); + const [status, setStatus] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const load = async () => { + if (!rootPath) { + setStatus([]); + return; + } + setLoading(true); + setError(null); + const result = await ipc.file.gitStatus({ rootPath }); + if (!result.ok) { + setError(result.error.message); + setStatus([]); + setLoading(false); + return; + } + setStatus(result.data ?? []); + setLoading(false); + }; + + useEffect(() => { + load(); + // Listen for git.refresh command via custom event + const handler = () => load(); + window.addEventListener('mc:git-refresh', handler); + return () => window.removeEventListener('mc:git-refresh', handler); + }, [rootPath]); + + if (!rootPath) { + return
No folder open
; + } + + if (error) { + return ( +
+
Error: {error}
+

Not a git repository, or git not installed.

+ +
+ ); + } + + if (status.length === 0 && !loading) { + return
Working tree clean
; + } + + return ( +
+
+ {status.length} changed file{status.length === 1 ? '' : 's'} + +
+
+ {status.map((s) => ( + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/src/renderer/components/sidebar/Sidebar.tsx b/src/renderer/components/sidebar/Sidebar.tsx index ade9746..ff65cf1 100644 --- a/src/renderer/components/sidebar/Sidebar.tsx +++ b/src/renderer/components/sidebar/Sidebar.tsx @@ -5,6 +5,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { useFileStore } from '@/stores/file-store'; import { FileTree } from './FileTree'; import { Outline } from './Outline'; +import { GitStatusPanel } from './GitStatusPanel'; export function Sidebar() { const tree = useFileStore((s) => s.tree); @@ -49,6 +50,21 @@ export function Sidebar() { + + {/* Git section */} + + + + + + + + + + ); } diff --git a/src/renderer/lib/commands/register-menu-commands.ts b/src/renderer/lib/commands/register-menu-commands.ts index be4de6a..6d9c4b6 100644 --- a/src/renderer/lib/commands/register-menu-commands.ts +++ b/src/renderer/lib/commands/register-menu-commands.ts @@ -66,7 +66,7 @@ export function registerMenuCommands(): void { if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc:print')); }, 'git.refresh': () => { - /* stub — actual refresh is a useEffect in GitStatusPanel */ + if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('mc:git-refresh')); }, }); diff --git a/tests/component/sidebar/GitStatusPanel.test.tsx b/tests/component/sidebar/GitStatusPanel.test.tsx new file mode 100644 index 0000000..3925c92 --- /dev/null +++ b/tests/component/sidebar/GitStatusPanel.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { GitStatusPanel } from '@/components/sidebar/GitStatusPanel'; +import { useFileStore } from '@/stores/file-store'; + +vi.mock('@/lib/ipc', () => ({ + ipc: { + file: { + gitStatus: vi.fn(), + }, + }, +})); + +import { ipc } from '@/lib/ipc'; + +describe('GitStatusPanel', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + it('shows "No folder open" when rootPath is null', () => { + useFileStore.setState({ rootPath: null } as any); + render(); + expect(screen.getByText(/no folder open/i)).toBeInTheDocument(); + }); + + it('fetches and shows git status', async () => { + (ipc.file.gitStatus as any).mockResolvedValueOnce({ + ok: true, + data: [ + { filePath: '/project/a.md', status: 'modified' }, + { filePath: '/project/b.md', status: 'added' }, + ], + }); + useFileStore.setState({ rootPath: '/project' } as any); + render(); + expect(await screen.findByText('a.md')).toBeInTheDocument(); + expect(await screen.findByText('b.md')).toBeInTheDocument(); + }); + + it('shows "Working tree clean" when no changes', async () => { + (ipc.file.gitStatus as any).mockResolvedValueOnce({ ok: true, data: [] }); + useFileStore.setState({ rootPath: '/project' } as any); + render(); + expect(await screen.findByText(/working tree clean/i)).toBeInTheDocument(); + }); + + it('shows error state when gitStatus fails', async () => { + (ipc.file.gitStatus as any).mockResolvedValueOnce({ + ok: false, + error: { code: 'IPC_ERROR', message: 'Not a git repository' }, + }); + useFileStore.setState({ rootPath: '/project' } as any); + render(); + // The helper text appears in a

element distinct from the error heading + expect(await screen.findByText('Not a git repository, or git not installed.')).toBeInTheDocument(); + }); + + it('opens file on click', async () => { + const openFile = vi.fn(); + (ipc.file.gitStatus as any).mockResolvedValueOnce({ + ok: true, + data: [{ filePath: '/project/a.md', status: 'modified' }], + }); + useFileStore.setState({ rootPath: '/project', openFile } as any); + render(); + const row = await screen.findByTestId('git-status-row'); + await userEvent.click(row); + expect(openFile).toHaveBeenCalledWith('/project/a.md'); + }); +}); \ No newline at end of file diff --git a/tests/component/sidebar/Sidebar.test.tsx b/tests/component/sidebar/Sidebar.test.tsx index 353311a..f92e9bf 100644 --- a/tests/component/sidebar/Sidebar.test.tsx +++ b/tests/component/sidebar/Sidebar.test.tsx @@ -13,6 +13,7 @@ vi.mock('@/lib/ipc', () => ({ pickFile: vi.fn(), read: vi.fn(), write: vi.fn(), + gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }), }, }, })); diff --git a/tests/integration/phase5-smoke.test.tsx b/tests/integration/phase5-smoke.test.tsx index 957c344..84b2e74 100644 --- a/tests/integration/phase5-smoke.test.tsx +++ b/tests/integration/phase5-smoke.test.tsx @@ -36,6 +36,7 @@ vi.mock('@/lib/ipc', () => ({ pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }), pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }), onChange: vi.fn(() => () => {}), + gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }), }, menu: { on: vi.fn(() => () => {}), @@ -54,6 +55,8 @@ describe('Phase 5 integration', () => { activeTabId: null, }); useEditorStore.setState({ buffers: new Map(), activeId: null }); + // Also clear the active buffer so breadcrumb symbols won't show stale heading text + useEditorStore.getState().buffers.clear(); useAppStore.setState({ sidebarVisible: true, previewVisible: true, @@ -128,12 +131,14 @@ describe('Phase 5 integration', () => { ], activeTabId: '/a.md', }); + // Use a buffer content that won't conflict with tab title in breadcrumb symbols + useEditorStore.setState({ buffers: new Map([['/a.md', { id: '/a.md', path: '/a.md', content: '# Hello', dirty: false }], ['/b.md', { id: '/b.md', path: '/b.md', content: '# World', dirty: false }]]), activeId: '/a.md' }); render(); - const aTab = screen.getByText('a.md').closest('[role="tab"]')!; - const bTab = screen.getByText('b.md').closest('[role="tab"]')!; - expect(aTab).toHaveAttribute('aria-current', 'page'); + // Use role="tab" to find tabs specifically, avoiding breadcrumb "a.md" text + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]).toHaveAttribute('aria-current', 'page'); await act(async () => { - fireEvent.click(bTab); + fireEvent.click(tabs[1]); }); expect(useFileStore.getState().activeTabId).toBe('/b.md'); }); diff --git a/tests/integration/phase6-smoke.test.tsx b/tests/integration/phase6-smoke.test.tsx index 894ac7d..93c716d 100644 --- a/tests/integration/phase6-smoke.test.tsx +++ b/tests/integration/phase6-smoke.test.tsx @@ -35,6 +35,7 @@ vi.mock('@/lib/ipc', () => ({ pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }), pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }), onChange: vi.fn(() => () => {}), + gitStatus: vi.fn().mockResolvedValue({ ok: true, data: [] }), }, menu: { on: vi.fn((channel: string, cb: (...args: unknown[]) => void) => {