feat: git log as panel in the panel
This commit is contained in:
@@ -780,6 +780,19 @@ export class GitEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getFileHistory(projectPath: string, filePath: string, limit = 50): Promise<GitHistoryEntry[]> {
|
||||||
|
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<GitRemoteStateDto> {
|
async getRemoteState(projectPath: string): Promise<GitRemoteStateDto> {
|
||||||
const git = simpleGit(projectPath);
|
const git = simpleGit(projectPath);
|
||||||
const status = await git.status();
|
const status = await git.status();
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.getHistory(projectPath, limit);
|
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) => {
|
safeHandle('git:remoteState', async (_, projectPath: string) => {
|
||||||
const engine = getGitEngine();
|
const engine = getGitEngine();
|
||||||
return engine.getRemoteState(projectPath);
|
return engine.getRemoteState(projectPath);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const electronAPI: ElectronAPI = {
|
|||||||
getDiffContent: (projectPath: string, filePath: string) => ipcRenderer.invoke('git:diffContent', projectPath, filePath),
|
getDiffContent: (projectPath: string, filePath: string) => ipcRenderer.invoke('git:diffContent', projectPath, filePath),
|
||||||
getCommitDiffContent: (projectPath: string, commitHash: string) => ipcRenderer.invoke('git:commitDiffContent', projectPath, commitHash),
|
getCommitDiffContent: (projectPath: string, commitHash: string) => ipcRenderer.invoke('git:commitDiffContent', projectPath, commitHash),
|
||||||
getHistory: (projectPath: string, limit?: number) => ipcRenderer.invoke('git:history', projectPath, limit),
|
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),
|
getRemoteState: (projectPath: string) => ipcRenderer.invoke('git:remoteState', projectPath),
|
||||||
fetch: (projectPath: string) => ipcRenderer.invoke('git:fetch', projectPath),
|
fetch: (projectPath: string) => ipcRenderer.invoke('git:fetch', projectPath),
|
||||||
pull: (projectPath: string) => ipcRenderer.invoke('git:pull', projectPath),
|
pull: (projectPath: string) => ipcRenderer.invoke('git:pull', projectPath),
|
||||||
|
|||||||
@@ -405,6 +405,7 @@ export interface ElectronAPI {
|
|||||||
getDiffContent: (projectPath: string, filePath: string) => Promise<GitDiffContentDto>;
|
getDiffContent: (projectPath: string, filePath: string) => Promise<GitDiffContentDto>;
|
||||||
getCommitDiffContent: (projectPath: string, commitHash: string) => Promise<GitCommitDiffContentDto>;
|
getCommitDiffContent: (projectPath: string, commitHash: string) => Promise<GitCommitDiffContentDto>;
|
||||||
getHistory: (projectPath: string, limit?: number) => Promise<GitHistoryEntry[]>;
|
getHistory: (projectPath: string, limit?: number) => Promise<GitHistoryEntry[]>;
|
||||||
|
getFileHistory: (projectPath: string, filePath: string, limit?: number) => Promise<GitHistoryEntry[]>;
|
||||||
getRemoteState: (projectPath: string) => Promise<GitRemoteStateDto>;
|
getRemoteState: (projectPath: string) => Promise<GitRemoteStateDto>;
|
||||||
fetch: (projectPath: string) => Promise<GitActionResult>;
|
fetch: (projectPath: string) => Promise<GitActionResult>;
|
||||||
pull: (projectPath: string) => Promise<GitActionResult>;
|
pull: (projectPath: string) => Promise<GitActionResult>;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-tab {
|
.panel-tab {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--vscode-tab-inactiveForeground);
|
color: var(--vscode-tab-inactiveForeground);
|
||||||
@@ -29,6 +31,11 @@
|
|||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-tab[aria-disabled='true'] {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-tab:hover {
|
.panel-tab:hover {
|
||||||
color: var(--vscode-tab-activeForeground);
|
color: var(--vscode-tab-activeForeground);
|
||||||
}
|
}
|
||||||
@@ -150,6 +157,43 @@
|
|||||||
background-color: var(--vscode-button-secondaryBackground);
|
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 {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,187 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import './Panel.css';
|
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 = () => {
|
export const Panel: React.FC = () => {
|
||||||
const { panelVisible, tasks } = useAppStore();
|
const { panelVisible, tasks, tabs, activeTabId, posts, media, activeProject } = useAppStore();
|
||||||
|
const [activePanelTab, setActivePanelTab] = useState<PanelTab>('tasks');
|
||||||
|
const [gitLogLoading, setGitLogLoading] = useState(false);
|
||||||
|
const [gitLogError, setGitLogError] = useState<string | null>(null);
|
||||||
|
const [gitLogTargetLabel, setGitLogTargetLabel] = useState<string | null>(null);
|
||||||
|
const [gitLogEntries, setGitLogEntries] = useState<Array<{
|
||||||
|
hash: string;
|
||||||
|
shortHash: string;
|
||||||
|
date: string;
|
||||||
|
subject: string;
|
||||||
|
author: string;
|
||||||
|
}>>([]);
|
||||||
|
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) {
|
if (!panelVisible) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recentTasks = tasks.slice(-10).reverse();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div className="panel-tabs">
|
<div className="panel-tabs" role="tablist" aria-label="Panel tabs">
|
||||||
<div className="panel-tab active">Tasks</div>
|
<button
|
||||||
<div className="panel-tab">Output</div>
|
type="button"
|
||||||
<div className="panel-tab">Sync Log</div>
|
role="tab"
|
||||||
|
className={`panel-tab ${activePanelTab === 'tasks' ? 'active' : ''}`}
|
||||||
|
aria-selected={activePanelTab === 'tasks'}
|
||||||
|
onClick={() => setActivePanelTab('tasks')}
|
||||||
|
>
|
||||||
|
Tasks
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={`panel-tab ${activePanelTab === 'output' ? 'active' : ''}`}
|
||||||
|
aria-selected={activePanelTab === 'output'}
|
||||||
|
onClick={() => setActivePanelTab('output')}
|
||||||
|
>
|
||||||
|
Output
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={`panel-tab ${activePanelTab === 'git-log' ? 'active' : ''}`}
|
||||||
|
aria-selected={activePanelTab === 'git-log'}
|
||||||
|
aria-disabled={!canActivateGitLog}
|
||||||
|
onClick={() => {
|
||||||
|
if (canActivateGitLog) {
|
||||||
|
setActivePanelTab('git-log');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Git Log
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="panel-close"
|
className="panel-close"
|
||||||
@@ -28,7 +192,8 @@ export const Panel: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-content">
|
<div className="panel-content">
|
||||||
{recentTasks.length === 0 ? (
|
{activePanelTab === 'tasks' && (
|
||||||
|
recentTasks.length === 0 ? (
|
||||||
<div className="panel-empty">No recent tasks</div>
|
<div className="panel-empty">No recent tasks</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="task-list">
|
<div className="task-list">
|
||||||
@@ -62,6 +227,37 @@ export const Panel: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activePanelTab === 'output' && (
|
||||||
|
<div className="panel-empty">No output</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activePanelTab === 'git-log' && (
|
||||||
|
!canActivateGitLog ? (
|
||||||
|
<div className="panel-empty">Open a post or media editor to view git log</div>
|
||||||
|
) : gitLogLoading ? (
|
||||||
|
<div className="panel-empty">Loading git log...</div>
|
||||||
|
) : gitLogError ? (
|
||||||
|
<div className="panel-empty">{gitLogError}</div>
|
||||||
|
) : gitLogEntries.length === 0 ? (
|
||||||
|
<div className="panel-empty">No commits found for this item</div>
|
||||||
|
) : (
|
||||||
|
<div className="git-log-list">
|
||||||
|
<div className="git-log-target">{gitLogTargetLabel}</div>
|
||||||
|
{gitLogEntries.map((entry) => (
|
||||||
|
<div key={entry.hash} className="git-log-item">
|
||||||
|
<div className="git-log-subject">{entry.subject}</div>
|
||||||
|
<div className="git-log-meta">
|
||||||
|
<span className="git-log-hash">{entry.shortHash}</span>
|
||||||
|
<span>{entry.author}</span>
|
||||||
|
<span>{new Date(entry.date).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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', () => {
|
describe('getRemoteState', () => {
|
||||||
it('should return upstream tracking and ahead/behind counts when tracking branch exists', async () => {
|
it('should return upstream tracking and ahead/behind counts when tracking branch exists', async () => {
|
||||||
mockStatus.mockResolvedValue({
|
mockStatus.mockResolvedValue({
|
||||||
@@ -602,6 +643,15 @@ describe('GitEngine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('pruneLfsCache', () => {
|
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 () => {
|
it('should run git lfs prune with verify-remote and aggressive recency defaults', async () => {
|
||||||
mockLog.mockResolvedValue({
|
mockLog.mockResolvedValue({
|
||||||
all: [
|
all: [
|
||||||
|
|||||||
136
tests/renderer/components/Panel.test.tsx
Normal file
136
tests/renderer/components/Panel.test.tsx
Normal file
@@ -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> = {}): 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> = {}): 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(<Panel />);
|
||||||
|
|
||||||
|
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(<Panel />);
|
||||||
|
|
||||||
|
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(<Panel />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('tab', { name: 'Git Log' })).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,7 @@ Object.defineProperty(globalThis, 'window', {
|
|||||||
getDiff: vi.fn(),
|
getDiff: vi.fn(),
|
||||||
getDiffContent: vi.fn(),
|
getDiffContent: vi.fn(),
|
||||||
getHistory: vi.fn(),
|
getHistory: vi.fn(),
|
||||||
|
getFileHistory: vi.fn(),
|
||||||
init: vi.fn(),
|
init: vi.fn(),
|
||||||
},
|
},
|
||||||
posts: {
|
posts: {
|
||||||
@@ -83,6 +84,7 @@ Object.defineProperty(globalThis, 'window', {
|
|||||||
regenerateThumbnails: vi.fn(),
|
regenerateThumbnails: vi.fn(),
|
||||||
search: vi.fn(),
|
search: vi.fn(),
|
||||||
getUrl: vi.fn(),
|
getUrl: vi.fn(),
|
||||||
|
getFilePath: vi.fn(),
|
||||||
},
|
},
|
||||||
sync: {
|
sync: {
|
||||||
configure: vi.fn(),
|
configure: vi.fn(),
|
||||||
|
|||||||
Reference in New Issue
Block a user