From b13eba025a838e1a5161c47bd57748c75e8aa57e Mon Sep 17 00:00:00 2001 From: hugo Date: Tue, 17 Feb 2026 13:13:55 +0100 Subject: [PATCH] feat: git log as panel in the panel --- src/main/engine/GitEngine.ts | 13 ++ src/main/ipc/handlers.ts | 5 + src/main/preload.ts | 1 + src/main/shared/electronApi.ts | 1 + src/renderer/components/Panel/Panel.css | 44 ++++ src/renderer/components/Panel/Panel.tsx | 274 +++++++++++++++++++---- tests/engine/GitEngine.test.ts | 50 +++++ tests/renderer/components/Panel.test.tsx | 136 +++++++++++ tests/setup.ts | 2 + 9 files changed, 487 insertions(+), 39 deletions(-) create mode 100644 tests/renderer/components/Panel.test.tsx diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index fc87c9d..9381ac4 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -780,6 +780,19 @@ export class GitEngine { }); } + async getFileHistory(projectPath: string, filePath: string, limit = 50): Promise { + const git = simpleGit(projectPath); + const history = await git.log(['--max-count', String(limit), '--', filePath]); + + 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 getRemoteState(projectPath: string): Promise { const git = simpleGit(projectPath); const status = await git.status(); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 62886da..7078b8c 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -124,6 +124,11 @@ export function registerIpcHandlers(): void { return engine.getHistory(projectPath, limit); }); + safeHandle('git:fileHistory', async (_, projectPath: string, filePath: string, limit?: number) => { + const engine = getGitEngine(); + return engine.getFileHistory(projectPath, filePath, limit); + }); + safeHandle('git:remoteState', async (_, projectPath: string) => { const engine = getGitEngine(); return engine.getRemoteState(projectPath); diff --git a/src/main/preload.ts b/src/main/preload.ts index c671082..fb2d8a6 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -14,6 +14,7 @@ export const electronAPI: ElectronAPI = { 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), + getFileHistory: (projectPath: string, filePath: string, limit?: number) => ipcRenderer.invoke('git:fileHistory', projectPath, filePath, limit), getRemoteState: (projectPath: string) => ipcRenderer.invoke('git:remoteState', projectPath), 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 e505cbb..4637263 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -405,6 +405,7 @@ export interface ElectronAPI { getDiffContent: (projectPath: string, filePath: string) => Promise; getCommitDiffContent: (projectPath: string, commitHash: string) => Promise; getHistory: (projectPath: string, limit?: number) => Promise; + getFileHistory: (projectPath: string, filePath: string, limit?: number) => Promise; getRemoteState: (projectPath: string) => Promise; fetch: (projectPath: string) => Promise; pull: (projectPath: string) => Promise; diff --git a/src/renderer/components/Panel/Panel.css b/src/renderer/components/Panel/Panel.css index 82960ca..7f42fb1 100644 --- a/src/renderer/components/Panel/Panel.css +++ b/src/renderer/components/Panel/Panel.css @@ -22,6 +22,8 @@ } .panel-tab { + background: transparent; + border: none; padding: 6px 12px; font-size: 12px; color: var(--vscode-tab-inactiveForeground); @@ -29,6 +31,11 @@ border-bottom: 2px solid transparent; } +.panel-tab[aria-disabled='true'] { + opacity: 0.5; + cursor: not-allowed; +} + .panel-tab:hover { color: var(--vscode-tab-activeForeground); } @@ -150,6 +157,43 @@ background-color: var(--vscode-button-secondaryBackground); } +.git-log-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.git-log-target { + font-size: 12px; + color: var(--vscode-descriptionForeground); + padding: 0 2px 4px; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.git-log-item { + background-color: var(--vscode-sideBar-background); + border-radius: 4px; + padding: 8px; +} + +.git-log-subject { + font-size: 12px; + color: var(--vscode-editor-foreground); + margin-bottom: 4px; +} + +.git-log-meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.git-log-hash { + font-family: var(--vscode-editor-font-family); +} + @keyframes spin { to { transform: rotate(360deg); } } diff --git a/src/renderer/components/Panel/Panel.tsx b/src/renderer/components/Panel/Panel.tsx index 19565ae..e01a7d2 100644 --- a/src/renderer/components/Panel/Panel.tsx +++ b/src/renderer/components/Panel/Panel.tsx @@ -1,23 +1,187 @@ -import React from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useAppStore } from '../../store'; import './Panel.css'; +type PanelTab = 'tasks' | 'output' | 'git-log'; + +function getPostRelativePath(createdAt: string, slug: string): string | null { + const createdDate = new Date(createdAt); + if (Number.isNaN(createdDate.getTime())) { + return null; + } + + const year = String(createdDate.getFullYear()); + const month = String(createdDate.getMonth() + 1).padStart(2, '0'); + return `posts/${year}/${month}/${slug}.md`; +} + +function normalizePath(value: string): string { + return value.replace(/\\/g, '/').replace(/\/+$/, ''); +} + +function toRelativePath(absolutePath: string, projectPath: string): string { + const normalizedAbsolute = normalizePath(absolutePath); + const normalizedProject = normalizePath(projectPath); + + if (normalizedAbsolute.toLowerCase() === normalizedProject.toLowerCase()) { + return ''; + } + + const prefix = `${normalizedProject}/`; + if (normalizedAbsolute.toLowerCase().startsWith(prefix.toLowerCase())) { + return normalizedAbsolute.slice(prefix.length); + } + + return normalizedAbsolute; +} + export const Panel: React.FC = () => { - const { panelVisible, tasks } = useAppStore(); + const { panelVisible, tasks, tabs, activeTabId, posts, media, activeProject } = useAppStore(); + const [activePanelTab, setActivePanelTab] = useState('tasks'); + const [gitLogLoading, setGitLogLoading] = useState(false); + const [gitLogError, setGitLogError] = useState(null); + const [gitLogTargetLabel, setGitLogTargetLabel] = useState(null); + const [gitLogEntries, setGitLogEntries] = useState>([]); + const requestIdRef = useRef(0); + + const recentTasks = tasks.slice(-10).reverse(); + const activeEditorTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]); + const canActivateGitLog = activeEditorTab?.type === 'post' || activeEditorTab?.type === 'media'; + + useEffect(() => { + if (!canActivateGitLog && activePanelTab === 'git-log') { + setActivePanelTab('tasks'); + } + }, [canActivateGitLog, activePanelTab]); + + useEffect(() => { + const projectPath = activeProject?.dataPath; + if (!projectPath || !activeEditorTab || (activeEditorTab.type !== 'post' && activeEditorTab.type !== 'media')) { + setGitLogEntries([]); + setGitLogTargetLabel(null); + setGitLogError(null); + setGitLogLoading(false); + return; + } + + const currentRequestId = ++requestIdRef.current; + + const loadFileHistory = async () => { + setGitLogLoading(true); + setGitLogError(null); + + try { + let targetLabel = ''; + let relativeFilePath = ''; + + if (activeEditorTab.type === 'post') { + const post = posts.find((item) => item.id === activeEditorTab.id) || await window.electronAPI?.posts.get(activeEditorTab.id); + if (!post) { + setGitLogEntries([]); + setGitLogTargetLabel(null); + setGitLogLoading(false); + return; + } + + targetLabel = post.title || post.slug; + relativeFilePath = getPostRelativePath(post.createdAt, post.slug) || ''; + } else { + const mediaItem = media.find((item) => item.id === activeEditorTab.id) || await window.electronAPI?.media.get(activeEditorTab.id); + if (!mediaItem) { + setGitLogEntries([]); + setGitLogTargetLabel(null); + setGitLogLoading(false); + return; + } + + targetLabel = mediaItem.title || mediaItem.originalName; + const absoluteMediaPath = await window.electronAPI?.media.getFilePath(activeEditorTab.id); + if (!absoluteMediaPath) { + setGitLogEntries([]); + setGitLogTargetLabel(targetLabel); + setGitLogLoading(false); + return; + } + + relativeFilePath = toRelativePath(absoluteMediaPath, projectPath); + } + + if (!relativeFilePath) { + setGitLogEntries([]); + setGitLogTargetLabel(targetLabel || null); + setGitLogLoading(false); + return; + } + + const entries = await window.electronAPI?.git.getFileHistory(projectPath, relativeFilePath, 50); + if (requestIdRef.current !== currentRequestId) { + return; + } + + setGitLogEntries(entries || []); + setGitLogTargetLabel(targetLabel || relativeFilePath); + } catch (error) { + if (requestIdRef.current !== currentRequestId) { + return; + } + setGitLogError(error instanceof Error ? error.message : 'Failed to load git log.'); + setGitLogEntries([]); + } finally { + if (requestIdRef.current === currentRequestId) { + setGitLogLoading(false); + } + } + }; + + void loadFileHistory(); + }, [activeEditorTab, activeProject?.dataPath, posts, media]); if (!panelVisible) { return null; } - const recentTasks = tasks.slice(-10).reverse(); - return (
-
-
Tasks
-
Output
-
Sync Log
+
+ + +
- {recentTasks.length === 0 ? ( -
No recent tasks
- ) : ( -
- {recentTasks.map(task => ( -
-
- {task.status === 'running' && } - {task.status === 'completed' && } - {task.status === 'failed' && } - {task.status === 'pending' && } -
-
-
{task.message}
+ {activePanelTab === 'tasks' && ( + recentTasks.length === 0 ? ( +
No recent tasks
+ ) : ( +
+ {recentTasks.map(task => ( +
+
+ {task.status === 'running' && } + {task.status === 'completed' && } + {task.status === 'failed' && } + {task.status === 'pending' && } +
+
+
{task.message}
+ {task.status === 'running' && ( +
+
+
+ )} +
{task.status === 'running' && ( -
-
-
+ )}
- {task.status === 'running' && ( - - )} -
- ))} -
+ ))} +
+ ) + )} + + {activePanelTab === 'output' && ( +
No output
+ )} + + {activePanelTab === 'git-log' && ( + !canActivateGitLog ? ( +
Open a post or media editor to view git log
+ ) : gitLogLoading ? ( +
Loading git log...
+ ) : gitLogError ? ( +
{gitLogError}
+ ) : gitLogEntries.length === 0 ? ( +
No commits found for this item
+ ) : ( +
+
{gitLogTargetLabel}
+ {gitLogEntries.map((entry) => ( +
+
{entry.subject}
+
+ {entry.shortHash} + {entry.author} + {new Date(entry.date).toLocaleString()} +
+
+ ))} +
+ ) )}
diff --git a/tests/engine/GitEngine.test.ts b/tests/engine/GitEngine.test.ts index 5ca28cd..e4fcec4 100644 --- a/tests/engine/GitEngine.test.ts +++ b/tests/engine/GitEngine.test.ts @@ -348,6 +348,47 @@ describe('GitEngine', () => { }); }); + describe('getFileHistory', () => { + it('should return commits for a specific file path', async () => { + mockLog.mockResolvedValue({ + all: [ + { + hash: 'abc123def456', + date: '2026-02-16T10:00:00.000Z', + message: 'docs: update first post', + author_name: 'Dev One', + }, + { + hash: '789fed654321', + date: '2026-02-15T09:00:00.000Z', + message: 'feat: add frontmatter field', + author_name: 'Dev Two', + }, + ], + }); + + const result = await gitEngine.getFileHistory('/tmp/project', 'posts/2026/02/first-post.md', 50); + + expect(mockLog).toHaveBeenCalledWith(['--max-count', '50', '--', 'posts/2026/02/first-post.md']); + expect(result).toEqual([ + { + hash: 'abc123def456', + shortHash: 'abc123d', + date: '2026-02-16T10:00:00.000Z', + subject: 'docs: update first post', + author: 'Dev One', + }, + { + hash: '789fed654321', + shortHash: '789fed6', + date: '2026-02-15T09:00:00.000Z', + subject: 'feat: add frontmatter field', + author: 'Dev Two', + }, + ]); + }); + }); + describe('getRemoteState', () => { it('should return upstream tracking and ahead/behind counts when tracking branch exists', async () => { mockStatus.mockResolvedValue({ @@ -602,6 +643,15 @@ describe('GitEngine', () => { }); describe('pruneLfsCache', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-02-16T12:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it('should run git lfs prune with verify-remote and aggressive recency defaults', async () => { mockLog.mockResolvedValue({ all: [ diff --git a/tests/renderer/components/Panel.test.tsx b/tests/renderer/components/Panel.test.tsx new file mode 100644 index 0000000..cec1d51 --- /dev/null +++ b/tests/renderer/components/Panel.test.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import { Panel } from '../../../src/renderer/components/Panel/Panel'; +import { useAppStore } from '../../../src/renderer/store'; +import type { PostData, MediaData } from '../../../src/main/shared/electronApi'; + +const createPost = (overrides: Partial = {}): PostData => ({ + id: 'post-1', + projectId: 'project-1', + title: 'First Post', + slug: 'first-post', + content: 'Hello', + status: 'draft', + createdAt: '2026-02-01T08:00:00.000Z', + updatedAt: '2026-02-01T08:00:00.000Z', + tags: [], + categories: ['article'], + ...overrides, +}); + +const createMedia = (overrides: Partial = {}): MediaData => ({ + id: 'media-1', + projectId: 'project-1', + filename: 'image-1.jpg', + originalName: 'image-1.jpg', + mimeType: 'image/jpeg', + size: 123, + createdAt: '2026-02-01T08:00:00.000Z', + updatedAt: '2026-02-01T08:00:00.000Z', + tags: [], + ...overrides, +}); + +describe('Panel', () => { + beforeEach(() => { + vi.clearAllMocks(); + + (window as any).electronAPI = { + ...(window as any).electronAPI, + git: { + ...(window as any).electronAPI?.git, + getFileHistory: vi.fn().mockResolvedValue([]), + }, + media: { + ...(window as any).electronAPI?.media, + getFilePath: vi.fn().mockResolvedValue('/repo/path/media/2026/02/image-1.jpg'), + }, + posts: { + ...(window as any).electronAPI?.posts, + get: vi.fn().mockResolvedValue(null), + }, + }; + + useAppStore.setState({ + panelVisible: true, + tasks: [], + activeProject: { + id: 'project-1', + name: 'Test Project', + slug: 'test-project', + isActive: true, + dataPath: '/repo/path', + createdAt: '2026-02-01T08:00:00.000Z', + updatedAt: '2026-02-01T08:00:00.000Z', + }, + posts: [createPost()], + media: [createMedia()], + tabs: [{ type: 'post', id: 'post-1', isTransient: false }], + activeTabId: 'post-1', + }); + }); + + afterEach(() => { + useAppStore.setState({ panelVisible: false }); + }); + + it('renders a Git Log tab label instead of Sync Log', () => { + render(); + + expect(screen.getByRole('tab', { name: 'Git Log' })).toBeInTheDocument(); + expect(screen.queryByText('Sync Log')).not.toBeInTheDocument(); + }); + + it('loads git history for the focused item and updates when active editor changes', async () => { + const getFileHistory = vi.fn() + .mockResolvedValueOnce([ + { + hash: 'abc123def456', + shortHash: 'abc123d', + date: '2026-02-16T10:00:00.000Z', + subject: 'docs: update first post', + author: 'Dev One', + }, + ]) + .mockResolvedValueOnce([ + { + hash: 'def456abc123', + shortHash: 'def456a', + date: '2026-02-17T09:00:00.000Z', + subject: 'chore: replace media file', + author: 'Dev Two', + }, + ]); + + (window as any).electronAPI.git.getFileHistory = getFileHistory; + + render(); + + await vi.waitFor(() => { + expect(getFileHistory).toHaveBeenCalledWith('/repo/path', 'posts/2026/02/first-post.md', 50); + }); + + act(() => { + useAppStore.setState({ + tabs: [{ type: 'media', id: 'media-1', isTransient: false }], + activeTabId: 'media-1', + }); + }); + + await vi.waitFor(() => { + expect(getFileHistory).toHaveBeenCalledWith('/repo/path', 'media/2026/02/image-1.jpg', 50); + }); + }); + + it('disables Git Log tab when focused tab is not a post or media editor', () => { + useAppStore.setState({ + tabs: [{ type: 'settings', id: 'settings', isTransient: false }], + activeTabId: 'settings', + }); + + render(); + + expect(screen.getByRole('tab', { name: 'Git Log' })).toHaveAttribute('aria-disabled', 'true'); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index edbda9f..1a5b708 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -51,6 +51,7 @@ Object.defineProperty(globalThis, 'window', { getDiff: vi.fn(), getDiffContent: vi.fn(), getHistory: vi.fn(), + getFileHistory: vi.fn(), init: vi.fn(), }, posts: { @@ -83,6 +84,7 @@ Object.defineProperty(globalThis, 'window', { regenerateThumbnails: vi.fn(), search: vi.fn(), getUrl: vi.fn(), + getFilePath: vi.fn(), }, sync: { configure: vi.fn(),