feat: start of git integration
This commit is contained in:
31
package-lock.json
generated
31
package-lock.json
generated
@@ -34,6 +34,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"sharp": "^0.34.5",
|
||||
"simple-git": "^3.31.1",
|
||||
"snowball-stemmers": "^0.6.0",
|
||||
"turndown": "^7.2.2",
|
||||
"zod": "^4.3.6",
|
||||
@@ -3502,6 +3503,21 @@
|
||||
"tslib": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/@kwsites/file-exists": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
|
||||
"integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@kwsites/promise-deferred": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
|
||||
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
|
||||
@@ -12096,6 +12112,21 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/simple-git": {
|
||||
"version": "3.31.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.31.1.tgz",
|
||||
"integrity": "sha512-oiWP4Q9+kO8q9hHqkX35uuHmxiEbZNTrZ5IPxgMGrJwN76pzjm/jabkZO0ItEcqxAincqGAzL3QHSaHt4+knBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kwsites/file-exists": "^1.1.1",
|
||||
"@kwsites/promise-deferred": "^1.1.1",
|
||||
"debug": "^4.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/steveukx/git-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"sharp": "^0.34.5",
|
||||
"simple-git": "^3.31.1",
|
||||
"snowball-stemmers": "^0.6.0",
|
||||
"turndown": "^7.2.2",
|
||||
"zod": "^4.3.6",
|
||||
|
||||
182
src/main/engine/GitEngine.ts
Normal file
182
src/main/engine/GitEngine.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { simpleGit } from 'simple-git';
|
||||
|
||||
export interface GitAvailability {
|
||||
gitFound: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface RepoState {
|
||||
isRepo: boolean;
|
||||
rootPath?: string;
|
||||
currentBranch?: string;
|
||||
hasRemote: boolean;
|
||||
}
|
||||
|
||||
export type GitFileStatus = 'untracked' | 'modified' | 'deleted' | 'renamed' | 'staged';
|
||||
|
||||
export interface GitStatusFile {
|
||||
path: string;
|
||||
status: GitFileStatus;
|
||||
previousPath?: string;
|
||||
}
|
||||
|
||||
export interface GitStatusCounts {
|
||||
untracked: number;
|
||||
modified: number;
|
||||
deleted: number;
|
||||
renamed: number;
|
||||
staged: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GitStatusDto {
|
||||
files: GitStatusFile[];
|
||||
counts: GitStatusCounts;
|
||||
}
|
||||
|
||||
export interface GitInitResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
code?: 'git-missing' | 'git-lfs-missing' | 'init-failed' | 'remote-failed';
|
||||
}
|
||||
|
||||
let gitEngineInstance: GitEngine | null = null;
|
||||
|
||||
export function getGitEngine(): GitEngine {
|
||||
if (!gitEngineInstance) {
|
||||
gitEngineInstance = new GitEngine();
|
||||
}
|
||||
return gitEngineInstance;
|
||||
}
|
||||
|
||||
export class GitEngine {
|
||||
async checkAvailability(): Promise<GitAvailability> {
|
||||
try {
|
||||
const versionResult = await simpleGit().version();
|
||||
return {
|
||||
gitFound: true,
|
||||
version: `${versionResult.major}.${versionResult.minor}.${versionResult.patch}`,
|
||||
};
|
||||
} catch {
|
||||
return { gitFound: false };
|
||||
}
|
||||
}
|
||||
|
||||
async getRepoState(projectPath: string): Promise<RepoState> {
|
||||
const git = simpleGit(projectPath);
|
||||
const isRepo = await git.checkIsRepo();
|
||||
|
||||
if (!isRepo) {
|
||||
return {
|
||||
isRepo: false,
|
||||
hasRemote: false,
|
||||
};
|
||||
}
|
||||
|
||||
const [rootPath, status] = await Promise.all([
|
||||
git.revparse(['--show-toplevel']),
|
||||
git.status(),
|
||||
]);
|
||||
|
||||
return {
|
||||
isRepo: true,
|
||||
rootPath: rootPath.trim(),
|
||||
currentBranch: status.current ?? undefined,
|
||||
hasRemote: Boolean(status.tracking),
|
||||
};
|
||||
}
|
||||
|
||||
async getStatus(projectPath: string): Promise<GitStatusDto> {
|
||||
const git = simpleGit(projectPath);
|
||||
const status = await git.status();
|
||||
|
||||
const files: GitStatusFile[] = [
|
||||
...status.not_added.map((filePath) => ({ path: filePath, status: 'untracked' as const })),
|
||||
...status.modified.map((filePath) => ({ path: filePath, status: 'modified' as const })),
|
||||
...status.deleted.map((filePath) => ({ path: filePath, status: 'deleted' as const })),
|
||||
...status.renamed.map((renamed) => ({
|
||||
path: renamed.to,
|
||||
status: 'renamed' as const,
|
||||
previousPath: renamed.from,
|
||||
})),
|
||||
...status.created.map((filePath) => ({ path: filePath, status: 'staged' as const })),
|
||||
];
|
||||
|
||||
const counts: GitStatusCounts = {
|
||||
untracked: status.not_added.length,
|
||||
modified: status.modified.length,
|
||||
deleted: status.deleted.length,
|
||||
renamed: status.renamed.length,
|
||||
staged: status.created.length,
|
||||
total: files.length,
|
||||
};
|
||||
|
||||
return {
|
||||
files,
|
||||
counts,
|
||||
};
|
||||
}
|
||||
|
||||
async initializeRepo(projectPath: string, remoteUrl?: string): Promise<GitInitResult> {
|
||||
const availability = await this.checkAvailability();
|
||||
if (!availability.gitFound) {
|
||||
return {
|
||||
success: false,
|
||||
code: 'git-missing',
|
||||
error: 'Git executable not found. Please install Git and restart the app.',
|
||||
};
|
||||
}
|
||||
|
||||
const git = simpleGit(projectPath);
|
||||
|
||||
try {
|
||||
await git.init();
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
code: 'init-failed',
|
||||
error: 'Failed to initialize repository for this project.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await git.raw(['lfs', 'install', '--local']);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
code: 'git-lfs-missing',
|
||||
error: 'Git LFS executable not found. Please install Git LFS and try again.',
|
||||
};
|
||||
}
|
||||
|
||||
const imagePatterns = ['*.png', '*.jpg', '*.jpeg', '*.gif', '*.webp', '*.svg', '*.avif', '*.heic'];
|
||||
|
||||
for (const pattern of imagePatterns) {
|
||||
await git.raw(['lfs', 'track', pattern]);
|
||||
}
|
||||
|
||||
await git.add('.gitattributes');
|
||||
|
||||
const normalizedRemoteUrl = remoteUrl?.trim();
|
||||
if (normalizedRemoteUrl) {
|
||||
try {
|
||||
const remotes = await git.getRemotes(true);
|
||||
const hasOrigin = remotes.some((remote) => remote.name === 'origin');
|
||||
|
||||
if (hasOrigin) {
|
||||
await git.remote(['set-url', 'origin', normalizedRemoteUrl]);
|
||||
} else {
|
||||
await git.addRemote('origin', normalizedRemoteUrl);
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
code: 'remote-failed',
|
||||
error: 'Failed to configure remote repository.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -72,4 +72,14 @@ export {
|
||||
type DiffField,
|
||||
type ScanResult,
|
||||
type TableStats,
|
||||
} from './MetadataDiffEngine';
|
||||
} from './MetadataDiffEngine';
|
||||
export {
|
||||
GitEngine,
|
||||
getGitEngine,
|
||||
type GitAvailability,
|
||||
type RepoState,
|
||||
type GitStatusDto,
|
||||
type GitStatusFile,
|
||||
type GitStatusCounts,
|
||||
type GitInitResult,
|
||||
} from './GitEngine';
|
||||
@@ -8,6 +8,7 @@ import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
|
||||
import { getMetaEngine } from '../engine/MetaEngine';
|
||||
import { getTagEngine } from '../engine/TagEngine';
|
||||
import { getPostMediaEngine } from '../engine/PostMediaEngine';
|
||||
import { getGitEngine } from '../engine/GitEngine';
|
||||
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
||||
import { getDatabase } from '../database';
|
||||
import { media } from '../database/schema';
|
||||
@@ -30,6 +31,31 @@ function safeHandle(channel: string, handler: (...args: any[]) => Promise<any>):
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
// ============ Git Handlers ============
|
||||
|
||||
safeHandle('git:checkAvailability', async () => {
|
||||
const engine = getGitEngine();
|
||||
return engine.checkAvailability();
|
||||
});
|
||||
|
||||
safeHandle('git:getRepoState', async (_, projectPath: string) => {
|
||||
const engine = getGitEngine();
|
||||
return engine.getRepoState(projectPath);
|
||||
});
|
||||
|
||||
safeHandle('git:status', async (_, projectPath: string) => {
|
||||
const engine = getGitEngine();
|
||||
return engine.getStatus(projectPath);
|
||||
});
|
||||
|
||||
safeHandle('git:init', async (_, projectPath: string, remoteUrl?: string) => {
|
||||
const engine = getGitEngine();
|
||||
if (remoteUrl) {
|
||||
return engine.initializeRepo(projectPath, remoteUrl);
|
||||
}
|
||||
return engine.initializeRepo(projectPath);
|
||||
});
|
||||
|
||||
// ============ Project Handlers ============
|
||||
|
||||
safeHandle('projects:create', async (_, data: { name: string; description?: string; slug?: string; dataPath?: string }) => {
|
||||
|
||||
@@ -4,6 +4,19 @@ import type { ElectronAPI } from './shared/electronApi';
|
||||
// Expose protected methods that allow the renderer process to use
|
||||
// ipcRenderer without exposing the entire object
|
||||
export const electronAPI: ElectronAPI = {
|
||||
// Git
|
||||
git: {
|
||||
checkAvailability: () => ipcRenderer.invoke('git:checkAvailability'),
|
||||
getRepoState: (projectPath: string) => ipcRenderer.invoke('git:getRepoState', projectPath),
|
||||
getStatus: (projectPath: string) => ipcRenderer.invoke('git:status', projectPath),
|
||||
init: (projectPath: string, remoteUrl?: string) => {
|
||||
if (remoteUrl) {
|
||||
return ipcRenderer.invoke('git:init', projectPath, remoteUrl);
|
||||
}
|
||||
return ipcRenderer.invoke('git:init', projectPath);
|
||||
},
|
||||
},
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
create: (data: { name: string; description?: string; slug?: string; dataPath?: string }) => ipcRenderer.invoke('projects:create', data),
|
||||
|
||||
@@ -202,6 +202,46 @@ export interface SyncTagsResult {
|
||||
added: string[];
|
||||
}
|
||||
|
||||
export interface GitAvailability {
|
||||
gitFound: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface GitRepoState {
|
||||
isRepo: boolean;
|
||||
rootPath?: string;
|
||||
currentBranch?: string;
|
||||
hasRemote: boolean;
|
||||
}
|
||||
|
||||
export type GitFileStatus = 'untracked' | 'modified' | 'deleted' | 'renamed' | 'staged';
|
||||
|
||||
export interface GitStatusFile {
|
||||
path: string;
|
||||
status: GitFileStatus;
|
||||
previousPath?: string;
|
||||
}
|
||||
|
||||
export interface GitStatusCounts {
|
||||
untracked: number;
|
||||
modified: number;
|
||||
deleted: number;
|
||||
renamed: number;
|
||||
staged: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GitStatusDto {
|
||||
files: GitStatusFile[];
|
||||
counts: GitStatusCounts;
|
||||
}
|
||||
|
||||
export interface GitInitResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
code?: 'git-missing' | 'git-lfs-missing' | 'init-failed' | 'remote-failed';
|
||||
}
|
||||
|
||||
// Post-Media Link types
|
||||
export interface MediaLinkData {
|
||||
id: string;
|
||||
@@ -272,6 +312,12 @@ export interface ChatTitleUpdate {
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
git: {
|
||||
checkAvailability: () => Promise<GitAvailability>;
|
||||
getRepoState: (projectPath: string) => Promise<GitRepoState>;
|
||||
getStatus: (projectPath: string) => Promise<GitStatusDto>;
|
||||
init: (projectPath: string, remoteUrl?: string) => Promise<GitInitResult>;
|
||||
};
|
||||
projects: {
|
||||
create: (data: { name: string; description?: string; slug?: string; dataPath?: string }) => Promise<ProjectData>;
|
||||
update: (id: string, data: Partial<ProjectData>) => Promise<ProjectData | null>;
|
||||
|
||||
@@ -49,6 +49,12 @@ const ImportIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const GitIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22 11.73L12.27 2a1 1 0 0 0-1.41 0L8.84 4.02l2.56 2.56a1.2 1.2 0 0 1 1.52 1.53l2.47 2.47a1.2 1.2 0 1 1-.72.67l-2.3-2.3v6.06a1.2 1.2 0 1 1-.85 0V8.9a1.2 1.2 0 0 1-.66-1.59L8.35 4.8 2 11.16a1 1 0 0 0 0 1.41L11.73 22a1 1 0 0 0 1.41 0L22 13.14a1 1 0 0 0 0-1.41z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ActivityBar: React.FC = () => {
|
||||
const { activeView, setActiveView, sidebarVisible, toggleSidebar, openTab, tabs, activeTabId } = useAppStore();
|
||||
|
||||
@@ -66,9 +72,10 @@ export const ActivityBar: React.FC = () => {
|
||||
|
||||
// Check if import sidebar is active
|
||||
const isImportActive = activeView === 'import' && sidebarVisible;
|
||||
const isGitActive = activeView === 'git' && sidebarVisible;
|
||||
|
||||
// Handle view click - toggle sidebar if clicking on active view, otherwise switch view
|
||||
const handleViewClick = (view: 'posts' | 'pages' | 'media' | 'chat') => {
|
||||
const handleViewClick = (view: 'posts' | 'pages' | 'media' | 'chat' | 'git') => {
|
||||
if (activeView === view && sidebarVisible) {
|
||||
// Clicking on active view toggles sidebar off
|
||||
toggleSidebar();
|
||||
@@ -162,6 +169,13 @@ export const ActivityBar: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="activity-bar-bottom">
|
||||
<button
|
||||
className={`activity-bar-item ${isGitActive ? 'active' : ''}`}
|
||||
onClick={() => handleViewClick('git')}
|
||||
title="Source Control (click again to toggle sidebar)"
|
||||
>
|
||||
<GitIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${isSettingsActive ? 'active' : ''}`}
|
||||
onClick={handleSettingsClick}
|
||||
|
||||
53
src/renderer/components/GitSidebar/GitSidebar.css
Normal file
53
src/renderer/components/GitSidebar/GitSidebar.css
Normal file
@@ -0,0 +1,53 @@
|
||||
.git-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.git-sidebar-header {
|
||||
padding: 10px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--vscode-sideBar-foreground);
|
||||
}
|
||||
|
||||
.git-sidebar-empty {
|
||||
padding: 16px 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.git-sidebar-empty p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.git-sidebar-error {
|
||||
color: var(--vscode-errorForeground);
|
||||
}
|
||||
|
||||
.git-sidebar-input {
|
||||
width: 100%;
|
||||
margin: 0 0 10px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.git-sidebar-button {
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.git-sidebar-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
133
src/renderer/components/GitSidebar/GitSidebar.tsx
Normal file
133
src/renderer/components/GitSidebar/GitSidebar.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import './GitSidebar.css';
|
||||
|
||||
export const GitSidebar: React.FC = () => {
|
||||
const { activeProject } = useAppStore();
|
||||
const [projectPath, setProjectPath] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [initializing, setInitializing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRepo, setIsRepo] = useState(false);
|
||||
const [currentBranch, setCurrentBranch] = useState<string | null>(null);
|
||||
const remoteUrlInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const resolveProjectPath = useCallback(async (): Promise<string | null> => {
|
||||
if (!activeProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeProject.dataPath) {
|
||||
return activeProject.dataPath;
|
||||
}
|
||||
|
||||
return window.electronAPI.app.getDefaultProjectPath(activeProject.id);
|
||||
}, [activeProject]);
|
||||
|
||||
const loadRepoState = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const availability = await window.electronAPI.git.checkAvailability();
|
||||
if (!availability.gitFound) {
|
||||
setError('Git executable not found. Please install Git and restart the app.');
|
||||
setIsRepo(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedProjectPath = await resolveProjectPath();
|
||||
setProjectPath(resolvedProjectPath);
|
||||
|
||||
if (!resolvedProjectPath) {
|
||||
setError('No active project selected.');
|
||||
setIsRepo(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const repoState = await window.electronAPI.git.getRepoState(resolvedProjectPath);
|
||||
setIsRepo(repoState.isRepo);
|
||||
setCurrentBranch(repoState.currentBranch || null);
|
||||
} catch {
|
||||
setError('Unable to load repository status.');
|
||||
setIsRepo(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [resolveProjectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRepoState();
|
||||
}, [loadRepoState]);
|
||||
|
||||
const handleInitialize = async () => {
|
||||
if (!projectPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInitializing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const normalizedRemoteUrl = remoteUrlInputRef.current?.value.trim() || '';
|
||||
const result = normalizedRemoteUrl
|
||||
? await window.electronAPI.git.init(projectPath, normalizedRemoteUrl)
|
||||
: await window.electronAPI.git.init(projectPath);
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Failed to initialize git repository.');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadRepoState();
|
||||
} catch {
|
||||
setError('Failed to initialize git repository.');
|
||||
} finally {
|
||||
setInitializing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="git-sidebar">
|
||||
<div className="git-sidebar-header">SOURCE CONTROL</div>
|
||||
<div className="git-sidebar-empty">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRepo) {
|
||||
return (
|
||||
<div className="git-sidebar">
|
||||
<div className="git-sidebar-header">SOURCE CONTROL</div>
|
||||
<div className="git-sidebar-empty">
|
||||
<p>Git repository ready</p>
|
||||
{currentBranch && <p>Branch: {currentBranch}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="git-sidebar">
|
||||
<div className="git-sidebar-header">SOURCE CONTROL</div>
|
||||
<div className="git-sidebar-empty">
|
||||
<p>This project is not a git repository.</p>
|
||||
<input
|
||||
ref={remoteUrlInputRef}
|
||||
className="git-sidebar-input"
|
||||
type="text"
|
||||
placeholder="Optional remote repository URL"
|
||||
disabled={initializing}
|
||||
/>
|
||||
{error && <p className="git-sidebar-error">{error}</p>}
|
||||
<button
|
||||
className="git-sidebar-button"
|
||||
onClick={handleInitialize}
|
||||
disabled={initializing || !projectPath}
|
||||
>
|
||||
{initializing ? 'Initializing...' : 'Initialize Git'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { useAppStore, PostData, MediaData } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import { getContrastColor, groupPostsByStatus } from '../../utils';
|
||||
import type { ChatConversation, ImportDefinitionData } from '../../types/electron';
|
||||
import { GitSidebar } from '../GitSidebar/GitSidebar';
|
||||
import './Sidebar.css';
|
||||
|
||||
/** Get display name for media: title (truncated to 60 chars) or fallback to filename */
|
||||
@@ -1640,6 +1641,7 @@ export const Sidebar: React.FC = () => {
|
||||
{activeView === 'tags' && <TagsNav />}
|
||||
{activeView === 'chat' && <ChatList />}
|
||||
{activeView === 'import' && <ImportList />}
|
||||
{activeView === 'git' && <GitSidebar />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ interface AppState {
|
||||
activeTabId: string | null;
|
||||
|
||||
// UI State
|
||||
activeView: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import';
|
||||
activeView: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import' | 'git';
|
||||
sidebarVisible: boolean;
|
||||
panelVisible: boolean;
|
||||
selectedPostId: string | null;
|
||||
@@ -96,7 +96,7 @@ interface AppState {
|
||||
restoreTabState: (state: TabState) => void;
|
||||
|
||||
// Actions
|
||||
setActiveView: (view: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import') => void;
|
||||
setActiveView: (view: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import' | 'git') => void;
|
||||
toggleSidebar: () => void;
|
||||
togglePanel: () => void;
|
||||
setSelectedPost: (id: string | null) => void;
|
||||
|
||||
177
tests/engine/GitEngine.test.ts
Normal file
177
tests/engine/GitEngine.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
const mockVersion = vi.fn();
|
||||
const mockCheckIsRepo = vi.fn();
|
||||
const mockRevparse = vi.fn();
|
||||
const mockStatus = vi.fn();
|
||||
const mockInit = vi.fn();
|
||||
const mockRaw = vi.fn();
|
||||
const mockAdd = vi.fn();
|
||||
const mockGetRemotes = vi.fn();
|
||||
const mockAddRemote = vi.fn();
|
||||
const mockRemote = vi.fn();
|
||||
|
||||
vi.mock('simple-git', () => ({
|
||||
simpleGit: vi.fn(() => ({
|
||||
version: mockVersion,
|
||||
checkIsRepo: mockCheckIsRepo,
|
||||
revparse: mockRevparse,
|
||||
status: mockStatus,
|
||||
init: mockInit,
|
||||
raw: mockRaw,
|
||||
add: mockAdd,
|
||||
getRemotes: mockGetRemotes,
|
||||
addRemote: mockAddRemote,
|
||||
remote: mockRemote,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GitEngine } from '../../src/main/engine/GitEngine';
|
||||
|
||||
describe('GitEngine', () => {
|
||||
let gitEngine: GitEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
gitEngine = new GitEngine();
|
||||
});
|
||||
|
||||
describe('checkAvailability', () => {
|
||||
it('should return gitFound true with version when git is available', async () => {
|
||||
mockVersion.mockResolvedValue({
|
||||
major: 2,
|
||||
minor: 49,
|
||||
patch: 0,
|
||||
agent: 'git/version',
|
||||
installed: true,
|
||||
});
|
||||
|
||||
const result = await gitEngine.checkAvailability();
|
||||
|
||||
expect(result).toEqual({ gitFound: true, version: '2.49.0' });
|
||||
});
|
||||
|
||||
it('should return gitFound false when git is not available', async () => {
|
||||
mockVersion.mockRejectedValue(new Error('git: command not found'));
|
||||
|
||||
const result = await gitEngine.checkAvailability();
|
||||
|
||||
expect(result).toEqual({ gitFound: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRepoState', () => {
|
||||
it('should return non-repo state when project is not a git repository', async () => {
|
||||
mockCheckIsRepo.mockResolvedValue(false);
|
||||
|
||||
const result = await gitEngine.getRepoState('/tmp/project');
|
||||
|
||||
expect(result).toEqual({
|
||||
isRepo: false,
|
||||
hasRemote: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return repo details for a valid repository', async () => {
|
||||
mockCheckIsRepo.mockResolvedValue(true);
|
||||
mockRevparse.mockResolvedValue('/tmp/project');
|
||||
mockStatus.mockResolvedValue({
|
||||
current: 'main',
|
||||
tracking: 'origin/main',
|
||||
});
|
||||
|
||||
const result = await gitEngine.getRepoState('/tmp/project');
|
||||
|
||||
expect(result).toEqual({
|
||||
isRepo: true,
|
||||
rootPath: '/tmp/project',
|
||||
currentBranch: 'main',
|
||||
hasRemote: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('should normalize changed files and counts from git status', async () => {
|
||||
mockStatus.mockResolvedValue({
|
||||
not_added: ['new-file.md'],
|
||||
modified: ['edited.md'],
|
||||
deleted: ['removed.md'],
|
||||
renamed: [{ from: 'old.md', to: 'new.md' }],
|
||||
created: ['staged.md'],
|
||||
});
|
||||
|
||||
const result = await gitEngine.getStatus('/tmp/project');
|
||||
|
||||
expect(result.counts).toEqual({
|
||||
untracked: 1,
|
||||
modified: 1,
|
||||
deleted: 1,
|
||||
renamed: 1,
|
||||
staged: 1,
|
||||
total: 5,
|
||||
});
|
||||
expect(result.files).toEqual([
|
||||
{ path: 'new-file.md', status: 'untracked' },
|
||||
{ path: 'edited.md', status: 'modified' },
|
||||
{ path: 'removed.md', status: 'deleted' },
|
||||
{ path: 'new.md', status: 'renamed', previousPath: 'old.md' },
|
||||
{ path: 'staged.md', status: 'staged' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeRepo', () => {
|
||||
it('should initialize git repo, configure lfs and track image patterns', async () => {
|
||||
mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true });
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockRaw.mockResolvedValue('ok');
|
||||
mockAdd.mockResolvedValue(undefined);
|
||||
|
||||
const result = await gitEngine.initializeRepo('/tmp/project');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockInit).toHaveBeenCalled();
|
||||
expect(mockRaw).toHaveBeenCalledWith(['lfs', 'install', '--local']);
|
||||
expect(mockAdd).toHaveBeenCalledWith('.gitattributes');
|
||||
expect(mockAddRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should configure origin remote when a remote url is provided', async () => {
|
||||
mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true });
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockRaw.mockResolvedValue('ok');
|
||||
mockAdd.mockResolvedValue(undefined);
|
||||
mockGetRemotes.mockResolvedValue([]);
|
||||
mockAddRemote.mockResolvedValue(undefined);
|
||||
|
||||
const result = await gitEngine.initializeRepo('/tmp/project', 'https://github.com/example/repo.git');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockGetRemotes).toHaveBeenCalledWith(true);
|
||||
expect(mockAddRemote).toHaveBeenCalledWith('origin', 'https://github.com/example/repo.git');
|
||||
});
|
||||
|
||||
it('should return explicit git missing guidance when git is unavailable', async () => {
|
||||
mockVersion.mockRejectedValue(new Error('git: command not found'));
|
||||
|
||||
const result = await gitEngine.initializeRepo('/tmp/project');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.code).toBe('git-missing');
|
||||
expect(result.error).toContain('install Git');
|
||||
});
|
||||
|
||||
it('should return explicit git-lfs missing guidance when lfs command fails', async () => {
|
||||
mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true });
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockRaw.mockRejectedValue(new Error('git: lfs is not a git command'));
|
||||
|
||||
const result = await gitEngine.initializeRepo('/tmp/project');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.code).toBe('git-lfs-missing');
|
||||
expect(result.error).toContain('install Git LFS');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -139,6 +139,13 @@ const mockPostMediaEngine = {
|
||||
rebuildFromSidecars: vi.fn(),
|
||||
};
|
||||
|
||||
const mockGitEngine = {
|
||||
checkAvailability: vi.fn(),
|
||||
getRepoState: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
initializeRepo: vi.fn(),
|
||||
};
|
||||
|
||||
const mockTaskManager = {
|
||||
getAllTasks: vi.fn(),
|
||||
cancelTask: vi.fn(),
|
||||
@@ -193,6 +200,10 @@ vi.mock('../../src/main/engine/PostMediaEngine', () => ({
|
||||
getPostMediaEngine: vi.fn(() => mockPostMediaEngine),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/GitEngine', () => ({
|
||||
getGitEngine: vi.fn(() => mockGitEngine),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/TaskManager', () => ({
|
||||
taskManager: mockTaskManager,
|
||||
TaskProgress: {},
|
||||
@@ -241,6 +252,81 @@ describe('IPC Handlers', () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
// ============ Git Handlers ============
|
||||
describe('Git Handlers', () => {
|
||||
describe('git:checkAvailability', () => {
|
||||
it('should return availability from GitEngine', async () => {
|
||||
mockGitEngine.checkAvailability.mockResolvedValue({ gitFound: true, version: '2.49.0' });
|
||||
|
||||
const result = await invokeHandler('git:checkAvailability');
|
||||
|
||||
expect(mockGitEngine.checkAvailability).toHaveBeenCalled();
|
||||
expect(result).toEqual({ gitFound: true, version: '2.49.0' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('git:getRepoState', () => {
|
||||
it('should pass project path to GitEngine.getRepoState', async () => {
|
||||
mockGitEngine.getRepoState.mockResolvedValue({
|
||||
isRepo: true,
|
||||
rootPath: '/repo',
|
||||
currentBranch: 'main',
|
||||
hasRemote: true,
|
||||
});
|
||||
|
||||
const result = await invokeHandler('git:getRepoState', '/repo');
|
||||
|
||||
expect(mockGitEngine.getRepoState).toHaveBeenCalledWith('/repo');
|
||||
expect(result).toEqual({
|
||||
isRepo: true,
|
||||
rootPath: '/repo',
|
||||
currentBranch: 'main',
|
||||
hasRemote: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('git:status', () => {
|
||||
it('should pass project path to GitEngine.getStatus', async () => {
|
||||
mockGitEngine.getStatus.mockResolvedValue({
|
||||
files: [{ path: 'file.md', status: 'modified' }],
|
||||
counts: {
|
||||
untracked: 0,
|
||||
modified: 1,
|
||||
deleted: 0,
|
||||
renamed: 0,
|
||||
staged: 0,
|
||||
total: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await invokeHandler('git:status', '/repo');
|
||||
|
||||
expect(mockGitEngine.getStatus).toHaveBeenCalledWith('/repo');
|
||||
expect(result.counts.total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('git:init', () => {
|
||||
it('should pass project path to GitEngine.initializeRepo', async () => {
|
||||
mockGitEngine.initializeRepo.mockResolvedValue({ success: true });
|
||||
|
||||
const result = await invokeHandler('git:init', '/repo');
|
||||
|
||||
expect(mockGitEngine.initializeRepo).toHaveBeenCalledWith('/repo');
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should pass optional remote url to GitEngine.initializeRepo', async () => {
|
||||
mockGitEngine.initializeRepo.mockResolvedValue({ success: true });
|
||||
|
||||
await invokeHandler('git:init', '/repo', 'https://github.com/example/repo.git');
|
||||
|
||||
expect(mockGitEngine.initializeRepo).toHaveBeenCalledWith('/repo', 'https://github.com/example/repo.git');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Project Handlers ============
|
||||
describe('Project Handlers', () => {
|
||||
describe('projects:create', () => {
|
||||
|
||||
92
tests/renderer/components/GitSidebar.test.tsx
Normal file
92
tests/renderer/components/GitSidebar.test.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { GitSidebar } from '../../../src/renderer/components/GitSidebar/GitSidebar';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
describe('GitSidebar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: {
|
||||
id: 'project-1',
|
||||
name: 'Test Project',
|
||||
slug: 'test-project',
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
(window as any).electronAPI = {
|
||||
...(window as any).electronAPI,
|
||||
app: {
|
||||
...(window as any).electronAPI?.app,
|
||||
getDefaultProjectPath: vi.fn().mockResolvedValue('/repo/path'),
|
||||
},
|
||||
git: {
|
||||
checkAvailability: vi.fn().mockResolvedValue({ gitFound: true, version: '2.49.0' }),
|
||||
getRepoState: vi.fn().mockResolvedValue({ isRepo: false, hasRemote: false }),
|
||||
init: vi.fn().mockResolvedValue({ success: true }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('shows Initialize Git button when active project is not a git repository', async () => {
|
||||
render(<GitSidebar />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /initialize git/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes repository and refreshes repo state after clicking Initialize Git', async () => {
|
||||
const getRepoStateMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ isRepo: false, hasRemote: false })
|
||||
.mockResolvedValueOnce({ isRepo: true, rootPath: '/repo/path', currentBranch: 'main', hasRemote: true });
|
||||
|
||||
(window as any).electronAPI.git.getRepoState = getRepoStateMock;
|
||||
(window as any).electronAPI.git.init = vi.fn().mockResolvedValue({ success: true });
|
||||
|
||||
render(<GitSidebar />);
|
||||
|
||||
const initButton = await screen.findByRole('button', { name: /initialize git/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(initButton);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect((window as any).electronAPI.git.init).toHaveBeenCalledWith('/repo/path');
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText(/git repository ready/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes remote url to init when user provides one', async () => {
|
||||
(window as any).electronAPI.git.init = vi.fn().mockResolvedValue({ success: true });
|
||||
(window as any).electronAPI.git.getRepoState = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ isRepo: false, hasRemote: false })
|
||||
.mockResolvedValueOnce({ isRepo: true, rootPath: '/repo/path', currentBranch: 'main', hasRemote: true });
|
||||
|
||||
render(<GitSidebar />);
|
||||
|
||||
const remoteInput = await screen.findByPlaceholderText(/optional remote repository url/i);
|
||||
await act(async () => {
|
||||
fireEvent.change(remoteInput, { target: { value: 'https://github.com/example/repo.git' } });
|
||||
});
|
||||
|
||||
expect((remoteInput as HTMLInputElement).value).toBe('https://github.com/example/repo.git');
|
||||
|
||||
const initButton = screen.getByRole('button', { name: /initialize git/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(initButton);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect((window as any).electronAPI.git.init).toHaveBeenCalledWith('/repo/path', 'https://github.com/example/repo.git');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,12 @@ Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
localStorage: localStorageMock,
|
||||
electronAPI: {
|
||||
git: {
|
||||
checkAvailability: vi.fn(),
|
||||
getRepoState: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
init: vi.fn(),
|
||||
},
|
||||
posts: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
|
||||
Reference in New Issue
Block a user