feat: version history colors and pruning
This commit is contained in:
@@ -66,8 +66,11 @@ export interface GitHistoryEntry {
|
|||||||
date: string;
|
date: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
syncStatus?: GitHistorySyncStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GitHistorySyncStatus = 'both' | 'local-only' | 'remote-only';
|
||||||
|
|
||||||
export type GitInitPhase =
|
export type GitInitPhase =
|
||||||
| 'checking-git'
|
| 'checking-git'
|
||||||
| 'initializing-repo'
|
| 'initializing-repo'
|
||||||
@@ -643,15 +646,67 @@ export class GitEngine {
|
|||||||
|
|
||||||
async getHistory(projectPath: string, limit = 20): Promise<GitHistoryEntry[]> {
|
async getHistory(projectPath: string, limit = 20): Promise<GitHistoryEntry[]> {
|
||||||
const git = simpleGit(projectPath);
|
const git = simpleGit(projectPath);
|
||||||
const history = await git.log({ maxCount: limit });
|
const status = await git.status();
|
||||||
|
const localHistory = await git.log({ maxCount: limit });
|
||||||
|
|
||||||
return history.all.map((entry) => ({
|
if (!status.tracking) {
|
||||||
hash: entry.hash,
|
return localHistory.all.map((entry) => ({
|
||||||
shortHash: entry.hash.slice(0, 7),
|
hash: entry.hash,
|
||||||
date: entry.date,
|
shortHash: entry.hash.slice(0, 7),
|
||||||
subject: entry.message,
|
date: entry.date,
|
||||||
author: entry.author_name,
|
subject: entry.message,
|
||||||
}));
|
author: entry.author_name,
|
||||||
|
syncStatus: 'local-only',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteHistory = await git.log([status.tracking, '--max-count', String(limit)]);
|
||||||
|
|
||||||
|
type CommitLike = {
|
||||||
|
hash: string;
|
||||||
|
date: string;
|
||||||
|
message: string;
|
||||||
|
author_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const localMap = new Map<string, CommitLike>();
|
||||||
|
const remoteMap = new Map<string, CommitLike>();
|
||||||
|
|
||||||
|
for (const entry of localHistory.all) {
|
||||||
|
localMap.set(entry.hash, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of remoteHistory.all) {
|
||||||
|
remoteMap.set(entry.hash, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = new Map<string, CommitLike>();
|
||||||
|
for (const entry of localMap.values()) {
|
||||||
|
combined.set(entry.hash, entry);
|
||||||
|
}
|
||||||
|
for (const entry of remoteMap.values()) {
|
||||||
|
if (!combined.has(entry.hash)) {
|
||||||
|
combined.set(entry.hash, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(combined.values())
|
||||||
|
.sort((first, second) => new Date(second.date).getTime() - new Date(first.date).getTime())
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((entry) => {
|
||||||
|
const inLocal = localMap.has(entry.hash);
|
||||||
|
const inRemote = remoteMap.has(entry.hash);
|
||||||
|
const syncStatus: GitHistorySyncStatus = inLocal && inRemote ? 'both' : inLocal ? 'local-only' : 'remote-only';
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: entry.hash,
|
||||||
|
shortHash: entry.hash.slice(0, 7),
|
||||||
|
date: entry.date,
|
||||||
|
subject: entry.message,
|
||||||
|
author: entry.author_name,
|
||||||
|
syncStatus,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetch(projectPath: string): Promise<GitActionResult> {
|
async fetch(projectPath: string): Promise<GitActionResult> {
|
||||||
|
|||||||
@@ -260,12 +260,15 @@ export interface GitCommitDiffFileDto {
|
|||||||
modified: string;
|
modified: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GitHistorySyncStatus = 'both' | 'local-only' | 'remote-only';
|
||||||
|
|
||||||
export interface GitHistoryEntry {
|
export interface GitHistoryEntry {
|
||||||
hash: string;
|
hash: string;
|
||||||
shortHash: string;
|
shortHash: string;
|
||||||
date: string;
|
date: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
syncStatus?: GitHistorySyncStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GitInitPhase =
|
export type GitInitPhase =
|
||||||
|
|||||||
@@ -46,6 +46,9 @@
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
border-top: 1px solid var(--vscode-editorWidget-border);
|
border-top: 1px solid var(--vscode-editorWidget-border);
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
|
--git-history-synced-color: var(--vscode-gitDecoration-addedResourceForeground, var(--vscode-charts-green, #89d185));
|
||||||
|
--git-history-local-color: var(--vscode-gitDecoration-untrackedResourceForeground, var(--vscode-charts-blue, #75beff));
|
||||||
|
--git-history-remote-color: var(--vscode-gitDecoration-deletedResourceForeground, var(--vscode-charts-yellow, #cca700));
|
||||||
}
|
}
|
||||||
|
|
||||||
.git-sidebar-empty-state {
|
.git-sidebar-empty-state {
|
||||||
@@ -111,6 +114,40 @@
|
|||||||
padding: 0 12px 8px;
|
padding: 0 12px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-legend-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-legend-dot--both {
|
||||||
|
background: var(--git-history-synced-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-legend-dot--local-only {
|
||||||
|
background: var(--git-history-local-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-legend-dot--remote-only {
|
||||||
|
background: var(--git-history-remote-color);
|
||||||
|
}
|
||||||
|
|
||||||
.git-sidebar-history-item {
|
.git-sidebar-history-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -126,6 +163,18 @@
|
|||||||
background: var(--vscode-list-hoverBackground);
|
background: var(--vscode-list-hoverBackground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-item--both {
|
||||||
|
border-left: 3px solid var(--git-history-synced-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-item--local-only {
|
||||||
|
border-left: 3px solid var(--git-history-local-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-item--remote-only {
|
||||||
|
border-left: 3px solid var(--git-history-remote-color);
|
||||||
|
}
|
||||||
|
|
||||||
.git-sidebar-history-subject {
|
.git-sidebar-history-subject {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--vscode-sideBar-foreground);
|
color: var(--vscode-sideBar-foreground);
|
||||||
@@ -141,6 +190,23 @@
|
|||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-status {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-status--both {
|
||||||
|
color: var(--git-history-synced-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-status--local-only {
|
||||||
|
color: var(--git-history-local-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sidebar-history-status--remote-only {
|
||||||
|
color: var(--git-history-remote-color);
|
||||||
|
}
|
||||||
|
|
||||||
.git-sidebar-main {
|
.git-sidebar-main {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [initializing, setInitializing] = useState(false);
|
const [initializing, setInitializing] = useState(false);
|
||||||
const [statusLoading, setStatusLoading] = useState(false);
|
const [statusLoading, setStatusLoading] = useState(false);
|
||||||
const [actionLoading, setActionLoading] = useState<'fetch' | 'pull' | 'push' | 'commit' | null>(null);
|
const [actionLoading, setActionLoading] = useState<'fetch' | 'pull' | 'push' | 'prune-lfs' | 'commit' | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [errorGuidance, setErrorGuidance] = useState<string[]>([]);
|
const [errorGuidance, setErrorGuidance] = useState<string[]>([]);
|
||||||
const [isRepo, setIsRepo] = useState(false);
|
const [isRepo, setIsRepo] = useState(false);
|
||||||
@@ -28,7 +28,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
const getDiffTabId = (filePath: string): string => `git-diff:${filePath}`;
|
const getDiffTabId = (filePath: string): string => `git-diff:${filePath}`;
|
||||||
const getCommitDiffTabId = (commitHash: string): string => `git-diff:commit:${commitHash}`;
|
const getCommitDiffTabId = (commitHash: string): string => `git-diff:commit:${commitHash}`;
|
||||||
|
|
||||||
const getActionProgressMessage = (action: 'fetch' | 'pull' | 'push' | 'commit'): string => {
|
const getActionProgressMessage = (action: 'fetch' | 'pull' | 'push' | 'prune-lfs' | 'commit'): string => {
|
||||||
if (action === 'push') {
|
if (action === 'push') {
|
||||||
return 'Pushing commits to remote... this can take a while for large uploads.';
|
return 'Pushing commits to remote... this can take a while for large uploads.';
|
||||||
}
|
}
|
||||||
@@ -38,9 +38,22 @@ export const GitSidebar: React.FC = () => {
|
|||||||
if (action === 'pull') {
|
if (action === 'pull') {
|
||||||
return 'Pulling latest changes...';
|
return 'Pulling latest changes...';
|
||||||
}
|
}
|
||||||
|
if (action === 'prune-lfs') {
|
||||||
|
return 'Pruning local Git LFS cache...';
|
||||||
|
}
|
||||||
return 'Creating commit...';
|
return 'Creating commit...';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getHistoryStatusLabel = (status: GitHistoryEntry['syncStatus']): string => {
|
||||||
|
if (status === 'local-only') {
|
||||||
|
return 'Local only';
|
||||||
|
}
|
||||||
|
if (status === 'remote-only') {
|
||||||
|
return 'Remote only';
|
||||||
|
}
|
||||||
|
return 'Synced';
|
||||||
|
};
|
||||||
|
|
||||||
const openDiffTab = useCallback(
|
const openDiffTab = useCallback(
|
||||||
(filePath: string, isTransient: boolean) => {
|
(filePath: string, isTransient: boolean) => {
|
||||||
openTab({
|
openTab({
|
||||||
@@ -179,7 +192,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRepoAction = async (action: 'fetch' | 'pull' | 'push') => {
|
const handleRepoAction = async (action: 'fetch' | 'pull' | 'push' | 'prune-lfs') => {
|
||||||
if (actionLoading) {
|
if (actionLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -202,10 +215,15 @@ export const GitSidebar: React.FC = () => {
|
|||||||
? await window.electronAPI.git.fetch(effectiveProjectPath)
|
? await window.electronAPI.git.fetch(effectiveProjectPath)
|
||||||
: action === 'pull'
|
: action === 'pull'
|
||||||
? await window.electronAPI.git.pull(effectiveProjectPath)
|
? await window.electronAPI.git.pull(effectiveProjectPath)
|
||||||
: await window.electronAPI.git.push(effectiveProjectPath);
|
: action === 'push'
|
||||||
|
? await window.electronAPI.git.push(effectiveProjectPath)
|
||||||
|
: await window.electronAPI.git.pruneLfs(effectiveProjectPath, {
|
||||||
|
dryRun: false,
|
||||||
|
verifyRemote: true,
|
||||||
|
});
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.error || `Failed to ${action}.`);
|
setError(result.error || `Failed to ${action}.`);
|
||||||
setErrorGuidance(result.guidance || []);
|
setErrorGuidance('guidance' in result ? result.guidance || [] : []);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadRepoState();
|
await loadRepoState();
|
||||||
@@ -317,6 +335,14 @@ export const GitSidebar: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{actionLoading === 'push' ? 'Pushing...' : 'Push'}
|
{actionLoading === 'push' ? 'Pushing...' : 'Push'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="git-sidebar-button"
|
||||||
|
onClick={() => handleRepoAction('prune-lfs')}
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
>
|
||||||
|
{actionLoading === 'prune-lfs' ? 'Pruning...' : 'Prune LFS'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{actionLoading && (
|
{actionLoading && (
|
||||||
<div className="git-sidebar-empty-state git-sidebar-progress" role="status">
|
<div className="git-sidebar-empty-state git-sidebar-progress" role="status">
|
||||||
@@ -372,6 +398,29 @@ export const GitSidebar: React.FC = () => {
|
|||||||
|
|
||||||
<div className="git-sidebar-section git-sidebar-history">
|
<div className="git-sidebar-section git-sidebar-history">
|
||||||
<div className="sidebar-section-title">Version History ({historyEntries.length})</div>
|
<div className="sidebar-section-title">Version History ({historyEntries.length})</div>
|
||||||
|
<div className="git-sidebar-history-legend" aria-label="Commit status legend">
|
||||||
|
<span className="git-sidebar-history-legend-item">
|
||||||
|
<span
|
||||||
|
className="git-sidebar-history-legend-dot git-sidebar-history-legend-dot--both"
|
||||||
|
data-testid="git-history-legend-both"
|
||||||
|
/>
|
||||||
|
Synced
|
||||||
|
</span>
|
||||||
|
<span className="git-sidebar-history-legend-item">
|
||||||
|
<span
|
||||||
|
className="git-sidebar-history-legend-dot git-sidebar-history-legend-dot--local-only"
|
||||||
|
data-testid="git-history-legend-local-only"
|
||||||
|
/>
|
||||||
|
Local only
|
||||||
|
</span>
|
||||||
|
<span className="git-sidebar-history-legend-item">
|
||||||
|
<span
|
||||||
|
className="git-sidebar-history-legend-dot git-sidebar-history-legend-dot--remote-only"
|
||||||
|
data-testid="git-history-legend-remote-only"
|
||||||
|
/>
|
||||||
|
Remote only
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{historyLoading ? (
|
{historyLoading ? (
|
||||||
<div className="git-sidebar-empty-state">Loading history...</div>
|
<div className="git-sidebar-empty-state">Loading history...</div>
|
||||||
) : historyEntries.length === 0 ? (
|
) : historyEntries.length === 0 ? (
|
||||||
@@ -382,7 +431,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
key={entry.hash}
|
key={entry.hash}
|
||||||
type="button"
|
type="button"
|
||||||
className="git-sidebar-history-item"
|
className={`git-sidebar-history-item git-sidebar-history-item--${entry.syncStatus ?? 'both'}`}
|
||||||
onClick={() => openCommitDiffTab(entry.hash, true)}
|
onClick={() => openCommitDiffTab(entry.hash, true)}
|
||||||
onDoubleClick={() => openCommitDiffTab(entry.hash, false)}
|
onDoubleClick={() => openCommitDiffTab(entry.hash, false)}
|
||||||
title={`${entry.shortHash}: ${entry.subject}`}
|
title={`${entry.shortHash}: ${entry.subject}`}
|
||||||
@@ -392,6 +441,9 @@ export const GitSidebar: React.FC = () => {
|
|||||||
<span>{entry.shortHash}</span>
|
<span>{entry.shortHash}</span>
|
||||||
<span>{entry.author}</span>
|
<span>{entry.author}</span>
|
||||||
<span>{new Date(entry.date).toLocaleDateString()}</span>
|
<span>{new Date(entry.date).toLocaleDateString()}</span>
|
||||||
|
<span className={`git-sidebar-history-status git-sidebar-history-status--${entry.syncStatus ?? 'both'}`}>
|
||||||
|
{getHistoryStatusLabel(entry.syncStatus)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -246,7 +246,8 @@ describe('GitEngine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getHistory', () => {
|
describe('getHistory', () => {
|
||||||
it('should return latest commits from git log', async () => {
|
it('should return latest commits from git log as local-only when no tracking branch exists', async () => {
|
||||||
|
mockStatus.mockResolvedValue({ current: 'main', tracking: undefined });
|
||||||
mockLog.mockResolvedValue({
|
mockLog.mockResolvedValue({
|
||||||
all: [
|
all: [
|
||||||
{
|
{
|
||||||
@@ -274,8 +275,77 @@ describe('GitEngine', () => {
|
|||||||
date: '2026-02-16T10:00:00.000Z',
|
date: '2026-02-16T10:00:00.000Z',
|
||||||
subject: 'feat: add git sidebar',
|
subject: 'feat: add git sidebar',
|
||||||
author: 'Dev One',
|
author: 'Dev One',
|
||||||
|
syncStatus: 'local-only',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should classify commits as both, local-only, and remote-only when tracking branch exists', async () => {
|
||||||
|
mockStatus.mockResolvedValue({ current: 'main', tracking: 'origin/main' });
|
||||||
|
mockLog
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
hash: 'aaa111',
|
||||||
|
date: '2026-02-16T12:00:00.000Z',
|
||||||
|
message: 'feat: local commit',
|
||||||
|
author_name: 'Local Dev',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hash: 'bbb222',
|
||||||
|
date: '2026-02-16T11:00:00.000Z',
|
||||||
|
message: 'feat: shared commit',
|
||||||
|
author_name: 'Shared Dev',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
hash: 'bbb222',
|
||||||
|
date: '2026-02-16T11:00:00.000Z',
|
||||||
|
message: 'feat: shared commit',
|
||||||
|
author_name: 'Shared Dev',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hash: 'ccc333',
|
||||||
|
date: '2026-02-16T10:00:00.000Z',
|
||||||
|
message: 'fix: remote commit',
|
||||||
|
author_name: 'Remote Dev',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await gitEngine.getHistory('/tmp/project', 20);
|
||||||
|
|
||||||
|
expect(mockLog).toHaveBeenNthCalledWith(1, { maxCount: 20 });
|
||||||
|
expect(mockLog).toHaveBeenNthCalledWith(2, ['origin/main', '--max-count', '20']);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
hash: 'aaa111',
|
||||||
|
shortHash: 'aaa111',
|
||||||
|
date: '2026-02-16T12:00:00.000Z',
|
||||||
|
subject: 'feat: local commit',
|
||||||
|
author: 'Local Dev',
|
||||||
|
syncStatus: 'local-only',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hash: 'bbb222',
|
||||||
|
shortHash: 'bbb222',
|
||||||
|
date: '2026-02-16T11:00:00.000Z',
|
||||||
|
subject: 'feat: shared commit',
|
||||||
|
author: 'Shared Dev',
|
||||||
|
syncStatus: 'both',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hash: 'ccc333',
|
||||||
|
shortHash: 'ccc333',
|
||||||
|
date: '2026-02-16T10:00:00.000Z',
|
||||||
|
subject: 'fix: remote commit',
|
||||||
|
author: 'Remote Dev',
|
||||||
|
syncStatus: 'remote-only',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ensureGitignore', () => {
|
describe('ensureGitignore', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
import { render, screen, fireEvent, act, within } from '@testing-library/react';
|
||||||
import { GitSidebar } from '../../../src/renderer/components/GitSidebar/GitSidebar';
|
import { GitSidebar } from '../../../src/renderer/components/GitSidebar/GitSidebar';
|
||||||
import { useAppStore } from '../../../src/renderer/store';
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ describe('GitSidebar', () => {
|
|||||||
fetch: vi.fn().mockResolvedValue({ success: true }),
|
fetch: vi.fn().mockResolvedValue({ success: true }),
|
||||||
pull: vi.fn().mockResolvedValue({ success: true }),
|
pull: vi.fn().mockResolvedValue({ success: true }),
|
||||||
push: vi.fn().mockResolvedValue({ success: true }),
|
push: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
pruneLfs: vi.fn().mockResolvedValue({ success: true, dryRun: false, verifyRemote: true }),
|
||||||
commitAll: vi.fn().mockResolvedValue({ success: true }),
|
commitAll: vi.fn().mockResolvedValue({ success: true }),
|
||||||
init: vi.fn().mockResolvedValue({ success: true }),
|
init: vi.fn().mockResolvedValue({ success: true }),
|
||||||
ensureGitignore: vi.fn().mockResolvedValue({ updated: false, created: false, addedEntries: [] }),
|
ensureGitignore: vi.fn().mockResolvedValue({ updated: false, created: false, addedEntries: [] }),
|
||||||
@@ -96,6 +97,85 @@ describe('GitSidebar', () => {
|
|||||||
expect(screen.getByText(/abc123/i)).toBeInTheDocument();
|
expect(screen.getByText(/abc123/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders color-coded commit state labels for local, remote, and synced commits', 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: 'aaa111',
|
||||||
|
shortHash: 'aaa111',
|
||||||
|
date: '2026-02-16T10:00:00.000Z',
|
||||||
|
subject: 'feat: local',
|
||||||
|
author: 'Dev One',
|
||||||
|
syncStatus: 'local-only',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hash: 'bbb222',
|
||||||
|
shortHash: 'bbb222',
|
||||||
|
date: '2026-02-16T09:00:00.000Z',
|
||||||
|
subject: 'feat: remote',
|
||||||
|
author: 'Dev Two',
|
||||||
|
syncStatus: 'remote-only',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hash: 'ccc333',
|
||||||
|
shortHash: 'ccc333',
|
||||||
|
date: '2026-02-16T08:00:00.000Z',
|
||||||
|
subject: 'feat: both',
|
||||||
|
author: 'Dev Three',
|
||||||
|
syncStatus: 'both',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<GitSidebar />);
|
||||||
|
|
||||||
|
expect((await screen.findAllByText('Local only')).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText('Remote only').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText('Synced').length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const localCommit = screen.getByRole('button', { name: /feat: local/i });
|
||||||
|
const remoteCommit = screen.getByRole('button', { name: /feat: remote/i });
|
||||||
|
const syncedCommit = screen.getByRole('button', { name: /feat: both/i });
|
||||||
|
|
||||||
|
expect(localCommit).toHaveClass('git-sidebar-history-item--local-only');
|
||||||
|
expect(remoteCommit).toHaveClass('git-sidebar-history-item--remote-only');
|
||||||
|
expect(syncedCommit).toHaveClass('git-sidebar-history-item--both');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders commit status legend in version history section', 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: 'aaa111',
|
||||||
|
shortHash: 'aaa111',
|
||||||
|
date: '2026-02-16T10:00:00.000Z',
|
||||||
|
subject: 'feat: local',
|
||||||
|
author: 'Dev One',
|
||||||
|
syncStatus: 'local-only',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<GitSidebar />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/version history/i)).toBeInTheDocument();
|
||||||
|
const legend = screen.getByLabelText('Commit status legend');
|
||||||
|
expect(within(legend).getByText('Synced')).toBeInTheDocument();
|
||||||
|
expect(within(legend).getByText('Local only')).toBeInTheDocument();
|
||||||
|
expect(within(legend).getByText('Remote only')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('git-history-legend-both')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('git-history-legend-local-only')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('git-history-legend-remote-only')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('uses the same section-title class as posts published heading', async () => {
|
it('uses the same section-title class as posts published heading', async () => {
|
||||||
(window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({
|
(window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({
|
||||||
isRepo: true,
|
isRepo: true,
|
||||||
@@ -401,7 +481,7 @@ describe('GitSidebar', () => {
|
|||||||
expect(screen.getByText(/100% — failed to configure remote repository/i)).toBeInTheDocument();
|
expect(screen.getByText(/100% — failed to configure remote repository/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wires fetch, pull, and push buttons', async () => {
|
it('wires fetch, pull, push, and prune lfs buttons', async () => {
|
||||||
(window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({
|
(window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({
|
||||||
isRepo: true,
|
isRepo: true,
|
||||||
rootPath: '/repo/path',
|
rootPath: '/repo/path',
|
||||||
@@ -414,16 +494,52 @@ describe('GitSidebar', () => {
|
|||||||
const fetchButton = await screen.findByRole('button', { name: /fetch/i });
|
const fetchButton = await screen.findByRole('button', { name: /fetch/i });
|
||||||
const pullButton = screen.getByRole('button', { name: /pull/i });
|
const pullButton = screen.getByRole('button', { name: /pull/i });
|
||||||
const pushButton = screen.getByRole('button', { name: /push/i });
|
const pushButton = screen.getByRole('button', { name: /push/i });
|
||||||
|
const pruneButton = screen.getByRole('button', { name: /prune lfs/i });
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(fetchButton);
|
fireEvent.click(fetchButton);
|
||||||
fireEvent.click(pullButton);
|
fireEvent.click(pullButton);
|
||||||
fireEvent.click(pushButton);
|
fireEvent.click(pushButton);
|
||||||
|
fireEvent.click(pruneButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect((window as any).electronAPI.git.fetch).toHaveBeenCalledWith('/repo/path');
|
expect((window as any).electronAPI.git.fetch).toHaveBeenCalledWith('/repo/path');
|
||||||
expect((window as any).electronAPI.git.pull).toHaveBeenCalledWith('/repo/path');
|
expect((window as any).electronAPI.git.pull).toHaveBeenCalledWith('/repo/path');
|
||||||
expect((window as any).electronAPI.git.push).toHaveBeenCalledWith('/repo/path');
|
expect((window as any).electronAPI.git.push).toHaveBeenCalledWith('/repo/path');
|
||||||
|
expect((window as any).electronAPI.git.pruneLfs).toHaveBeenCalledWith('/repo/path', {
|
||||||
|
dryRun: false,
|
||||||
|
verifyRemote: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows in-progress feedback while prune lfs is running', async () => {
|
||||||
|
let resolvePrune: ((value: { success: boolean; dryRun: boolean; verifyRemote: boolean }) => void) | null = null;
|
||||||
|
(window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({
|
||||||
|
isRepo: true,
|
||||||
|
rootPath: '/repo/path',
|
||||||
|
currentBranch: 'main',
|
||||||
|
hasRemote: true,
|
||||||
|
});
|
||||||
|
(window as any).electronAPI.git.pruneLfs = vi.fn().mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolvePrune = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<GitSidebar />);
|
||||||
|
|
||||||
|
const pruneButton = await screen.findByRole('button', { name: /prune lfs/i });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(pruneButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent(/pruning local git lfs cache/i);
|
||||||
|
expect(screen.getByRole('button', { name: /pruning/i })).toBeDisabled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolvePrune?.({ success: true, dryRun: false, verifyRemote: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('commits all changes and closes open git-diff tabs', async () => {
|
it('commits all changes and closes open git-diff tabs', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user