feat: start of git integration

This commit is contained in:
2026-02-16 09:45:34 +01:00
parent f34195bd76
commit d7286ef92f
16 changed files with 876 additions and 4 deletions

View 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 };
}
}

View File

@@ -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';

View File

@@ -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 }) => {

View File

@@ -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),

View File

@@ -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>;

View File

@@ -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}

View 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;
}

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;