Added new github integration tasks

This commit is contained in:
Richard Osborne
2026-01-18 14:38:32 +01:00
parent addd4d9c4a
commit bf07f1cb4a
44 changed files with 12015 additions and 402 deletions

View File

@@ -6,7 +6,8 @@
*/
import { ipcRenderer, shell } from 'electron';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { clone } from '@noodl/git/src/core/clone';
import { filesystem } from '@noodl/platform';
import { CreateProjectModal } from '@noodl-core-ui/preview/launcher/Launcher/components/CreateProjectModal';
@@ -14,12 +15,18 @@ import {
CloudSyncType,
LauncherProjectData
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
import {
useGitHubRepos,
NoodlGitHubRepo,
GitHubClientInterface
} from '@noodl-core-ui/preview/launcher/Launcher/hooks/useGitHubRepos';
import { Launcher } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
import { useEventListener } from '../../hooks/useEventListener';
import { DialogLayerModel } from '../../models/DialogLayerModel';
import { detectRuntimeVersion } from '../../models/migration/ProjectScanner';
import { IRouteProps } from '../../pages/AppRoute';
import { GitHubOAuthService, GitHubClient } from '../../services/github';
import { ProjectOrganizationService } from '../../services/ProjectOrganizationService';
import { LocalProjectsModel, ProjectItemWithRuntime } from '../../utils/LocalProjectsModel';
import { tracker } from '../../utils/tracker';
@@ -56,6 +63,332 @@ export function ProjectsPage(props: ProjectsPageProps) {
// Create project modal state
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
// GitHub OAuth state
const [githubIsAuthenticated, setGithubIsAuthenticated] = useState(false);
const [githubIsConnecting, setGithubIsConnecting] = useState(false);
const [githubUser, setGithubUser] = useState<ReturnType<typeof GitHubOAuthService.instance.getCurrentUser>>(null);
const oauthService = GitHubOAuthService.instance;
// Initialize GitHub OAuth state on mount
useEffect(() => {
console.log('🔧 [ProjectsPage] Initializing GitHub OAuth...');
oauthService.initialize().then(() => {
const isAuth = oauthService.isAuthenticated();
const user = oauthService.getCurrentUser();
console.log('🔧 [ProjectsPage] GitHub auth state:', isAuth, user?.login);
setGithubIsAuthenticated(isAuth);
setGithubUser(user);
});
}, [oauthService]);
// Listen for GitHub auth state changes
useEventListener(oauthService, 'auth-state-changed', (event: { authenticated: boolean }) => {
console.log('🔔 [ProjectsPage] GitHub auth state changed:', event.authenticated);
setGithubIsAuthenticated(event.authenticated);
if (event.authenticated) {
setGithubUser(oauthService.getCurrentUser());
} else {
setGithubUser(null);
}
});
// Listen for OAuth success
useEventListener(oauthService, 'oauth-success', () => {
setGithubIsConnecting(false);
});
useEventListener(oauthService, 'oauth-error', () => {
setGithubIsConnecting(false);
ToastLayer.showError('GitHub authentication failed');
});
// GitHub OAuth handlers
const handleGitHubConnect = useCallback(async () => {
console.log('🔘 [ProjectsPage] handleGitHubConnect called');
setGithubIsConnecting(true);
try {
await oauthService.initiateOAuth();
console.log('✅ [ProjectsPage] OAuth initiated');
} catch (error) {
console.error('❌ [ProjectsPage] OAuth error:', error);
setGithubIsConnecting(false);
ToastLayer.showError('Failed to connect GitHub');
}
}, [oauthService]);
const handleGitHubDisconnect = useCallback(async () => {
console.log('🔘 [ProjectsPage] handleGitHubDisconnect called');
await oauthService.disconnect();
ToastLayer.showSuccess('GitHub account disconnected');
}, [oauthService]);
// Create GitHubClient adapter for useGitHubRepos hook
const githubClient = useMemo((): GitHubClientInterface | null => {
if (!githubIsAuthenticated) return null;
const client = GitHubClient.instance;
return {
listRepositories: async (options?: { per_page?: number; sort?: string }) => {
const result = await client.listRepositories(options as TSFixme);
return result;
},
listOrganizations: async () => {
const result = await client.listOrganizations();
return result;
},
listOrganizationRepositories: async (org, options) => {
const result = await client.listOrganizationRepositories(org, options);
return result;
},
isNoodlProject: async (owner, repo) => {
return client.isNoodlProject(owner, repo);
}
};
}, [githubIsAuthenticated]);
// Use the GitHub repos hook
const githubRepos = useGitHubRepos(githubClient, githubIsAuthenticated);
/**
* Handle cloning a GitHub repository
* Follows the same legacy detection flow as handleOpenProject
*/
const handleCloneRepo = useCallback(
async (repo: NoodlGitHubRepo) => {
console.log('🔵 [handleCloneRepo] Starting clone for:', repo.full_name);
// Ask user where to clone
try {
const targetDir = await filesystem.openDialog({
allowCreateDirectory: true
});
if (!targetDir) {
console.log('🔵 [handleCloneRepo] User cancelled');
return;
}
// Create path with repo name
const clonePath = filesystem.join(targetDir, repo.name);
// Check if directory already exists
if (await filesystem.exists(clonePath)) {
ToastLayer.showError(`A folder named "${repo.name}" already exists at that location`);
return;
}
const activityId = 'cloning-repo';
ToastLayer.showActivity(`Cloning ${repo.name}...`, activityId);
// Get clone URL (prefer HTTPS with token for authenticated access)
const token = await oauthService.getToken();
const cloneUrl = repo.html_url.replace('https://', `https://x-access-token:${token}@`) + '.git';
await clone(cloneUrl, clonePath, {
singleBranch: false,
defaultBranch: repo.default_branch
});
ToastLayer.hideActivity(activityId);
ToastLayer.showSuccess(`Cloned "${repo.name}" successfully!`);
tracker.track('GitHub Repository Cloned', {
repoName: repo.name,
isPrivate: repo.private
});
// Now detect runtime and follow the same flow as handleOpenProject
const runtimeActivityId = 'checking-compatibility';
ToastLayer.showActivity('Checking project compatibility...', runtimeActivityId);
try {
const runtimeInfo = await detectRuntimeVersion(clonePath);
ToastLayer.hideActivity(runtimeActivityId);
console.log('🔵 [handleCloneRepo] Runtime detected:', runtimeInfo);
// If legacy or unknown, show warning dialog
if (runtimeInfo.version === 'react17' || runtimeInfo.version === 'unknown') {
const projectName = repo.name;
// Show legacy project warning dialog
const userChoice = await new Promise<'migrate' | 'readonly' | 'cancel'>((resolve) => {
const confirmed = confirm(
`⚠️ Legacy Project Detected\n\n` +
`This project "${projectName}" was created with an earlier version of Noodl (React 17).\n\n` +
`OpenNoodl uses React 19, which requires migrating your project to ensure compatibility.\n\n` +
`What would you like to do?\n\n` +
`OK - Migrate Project (Recommended)\n` +
`Cancel - View options`
);
if (confirmed) {
resolve('migrate');
} else {
// Show second dialog for Read-Only or Cancel
const openReadOnly = confirm(
`Would you like to open this project in Read-Only mode?\n\n` +
`You can inspect the project safely without making changes.\n\n` +
`OK - Open Read-Only\n` +
`Cancel - Return to launcher`
);
if (openReadOnly) {
resolve('readonly');
} else {
resolve('cancel');
}
}
});
console.log('🔵 [handleCloneRepo] User choice:', userChoice);
if (userChoice === 'cancel') {
// Add to projects list but don't open
await LocalProjectsModel.instance.openProjectFromFolder(clonePath);
await LocalProjectsModel.instance.fetch();
LocalProjectsModel.instance.detectAllProjectRuntimes();
ToastLayer.showSuccess(`Project "${repo.name}" added to your projects.`);
return;
}
if (userChoice === 'migrate') {
// Launch migration wizard
tracker.track('Legacy Project Migration Started from Clone', { projectName });
DialogLayerModel.instance.showDialog(
(close) =>
React.createElement(MigrationWizard, {
sourcePath: clonePath,
projectName,
onComplete: async (targetPath: string) => {
close();
const migrateActivityId = 'opening-migrated';
ToastLayer.showActivity('Opening migrated project', migrateActivityId);
try {
const migratedProject = await LocalProjectsModel.instance.openProjectFromFolder(targetPath);
if (!migratedProject.name) {
migratedProject.name = projectName + ' (React 19)';
}
await LocalProjectsModel.instance.fetch();
await LocalProjectsModel.instance.detectProjectRuntime(targetPath);
LocalProjectsModel.instance.detectAllProjectRuntimes();
const projects = LocalProjectsModel.instance.getProjects();
const projectEntry = projects.find((p) => p.id === migratedProject.id);
if (projectEntry) {
const loaded = await LocalProjectsModel.instance.loadProject(projectEntry);
ToastLayer.hideActivity(migrateActivityId);
if (loaded) {
ToastLayer.showSuccess('Project migrated and opened successfully!');
props.route.router.route({ to: 'editor', project: loaded });
}
}
} catch (error) {
ToastLayer.hideActivity(migrateActivityId);
ToastLayer.showError('Could not open migrated project');
console.error(error);
}
},
onCancel: () => {
close();
}
}),
{
onClose: () => {
LocalProjectsModel.instance.fetch();
}
}
);
return;
}
// Read-only mode
tracker.track('Legacy Project Opened Read-Only from Clone', { projectName });
const readOnlyActivityId = 'opening-project-readonly';
ToastLayer.showActivity('Opening project in read-only mode', readOnlyActivityId);
const readOnlyProject = await LocalProjectsModel.instance.openProjectFromFolder(clonePath);
if (!readOnlyProject) {
ToastLayer.hideActivity(readOnlyActivityId);
ToastLayer.showError('Could not open project');
return;
}
if (!readOnlyProject.name) {
readOnlyProject.name = repo.name;
}
const readOnlyProjects = LocalProjectsModel.instance.getProjects();
const readOnlyProjectEntry = readOnlyProjects.find((p) => p.id === readOnlyProject.id);
if (!readOnlyProjectEntry) {
ToastLayer.hideActivity(readOnlyActivityId);
ToastLayer.showError('Could not find project in recent list');
return;
}
const loadedReadOnly = await LocalProjectsModel.instance.loadProject(readOnlyProjectEntry);
ToastLayer.hideActivity(readOnlyActivityId);
if (!loadedReadOnly) {
ToastLayer.showError('Could not load project');
return;
}
ToastLayer.showError('⚠️ READ-ONLY MODE - No changes will be saved to this legacy project');
props.route.router.route({ to: 'editor', project: loadedReadOnly, readOnly: true });
return;
}
} catch (error) {
ToastLayer.hideActivity(runtimeActivityId);
console.error('Failed to detect runtime:', error);
// Continue with normal flow if detection fails
}
// Modern project - add to list and ask to open
const project = await LocalProjectsModel.instance.openProjectFromFolder(clonePath);
if (project) {
if (!project.name) {
project.name = repo.name;
}
await LocalProjectsModel.instance.fetch();
LocalProjectsModel.instance.detectAllProjectRuntimes();
const shouldOpen = confirm(`Project "${repo.name}" cloned successfully!\n\nWould you like to open it now?`);
if (shouldOpen) {
const projects = LocalProjectsModel.instance.getProjects();
const projectEntry = projects.find((p) => p.id === project.id);
if (projectEntry) {
const loaded = await LocalProjectsModel.instance.loadProject(projectEntry);
if (loaded) {
props.route.router.route({ to: 'editor', project: loaded });
}
}
}
}
} catch (error) {
ToastLayer.hideActivity('cloning-repo');
console.error('Failed to clone repository:', error);
ToastLayer.showError(`Failed to clone repository: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
},
[oauthService, props.route]
);
// Initialize and fetch projects on mount
useEffect(() => {
// Switch main window size to editor size
@@ -590,11 +923,13 @@ export function ProjectsPage(props: ProjectsPageProps) {
onMigrateProject={handleMigrateProject}
onOpenReadOnly={handleOpenReadOnly}
projectOrganizationService={ProjectOrganizationService.instance}
githubUser={null}
githubIsAuthenticated={false}
githubIsConnecting={false}
onGitHubConnect={() => {}}
onGitHubDisconnect={() => {}}
githubUser={githubUser}
githubIsAuthenticated={githubIsAuthenticated}
githubIsConnecting={githubIsConnecting}
onGitHubConnect={handleGitHubConnect}
onGitHubDisconnect={handleGitHubDisconnect}
githubRepos={githubRepos}
onCloneRepo={handleCloneRepo}
/>
<CreateProjectModal

View File

@@ -1,48 +1,17 @@
/**
* GitHubOAuthService
*
* Manages GitHub OAuth authentication using PKCE flow.
* Provides token management and user information retrieval.
* Manages GitHub OAuth authentication via IPC with the main process.
* The main process handles the OAuth flow and protocol callbacks,
* this service coordinates with it and manages state in the renderer.
*
* @module noodl-editor/services
*/
import crypto from 'crypto';
import { shell } from 'electron';
import { shell, ipcRenderer } from 'electron';
import { EventDispatcher } from '../../../shared/utils/EventDispatcher';
/**
* IMPORTANT: GitHub App Setup Instructions
*
* This service uses PKCE (Proof Key for Code Exchange) combined with a client secret.
*
* To set up:
* 1. Go to https://github.com/settings/apps/new
* 2. Fill in:
* - GitHub App name: "OpenNoodl" (or your choice)
* - Homepage URL: https://github.com/The-Low-Code-Foundation/OpenNoodl
* - Callback URL: noodl://github-callback
* - Check "Request user authorization (OAuth) during installation"
* - Uncheck "Webhook > Active"
* - Permissions:
* * Repository permissions → Contents: Read and write
* * Account permissions → Email addresses: Read-only
* 3. Click "Create GitHub App"
* 4. Copy the Client ID
* 5. Generate a Client Secret and copy it
* 6. Update GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET below
*
* Security Note:
* While storing client secrets in desktop apps is not ideal (they can be extracted),
* this is GitHub's requirement for token exchange. The PKCE flow still adds security
* by preventing authorization code interception attacks.
*/
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Iv23lib1WdrimUdyvZui'; // Replace with your GitHub App Client ID
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || '9bd56694d6d300bf86b1999bab523b32654ec375'; // Replace with your GitHub App Client Secret
const GITHUB_REDIRECT_URI = 'noodl://github-callback';
const GITHUB_SCOPES = ['repo', 'read:org', 'read:user'];
export interface GitHubUser {
id: number;
login: string;
@@ -65,23 +34,41 @@ interface GitHubToken {
scope: string;
}
interface PKCEChallenge {
verifier: string;
challenge: string;
state: string;
interface OAuthCompleteResult {
token: GitHubToken;
user: GitHubUser;
installations: unknown[];
authMethod: string;
}
interface OAuthErrorResult {
error: string;
message: string;
}
/**
* Service for managing GitHub OAuth authentication
*
* This service coordinates with the main process which handles:
* - State generation and validation
* - Protocol callback handling (noodl://github-callback)
* - Token exchange with GitHub
*
* The renderer process handles:
* - Opening the auth URL in the browser
* - Storing tokens securely
* - Managing user state
*/
export class GitHubOAuthService extends EventDispatcher {
private static _instance: GitHubOAuthService;
private currentUser: GitHubUser | null = null;
private accessToken: string | null = null;
private pendingPKCE: PKCEChallenge | null = null;
private isAuthenticating: boolean = false;
private constructor() {
super();
console.log('🔧 [GitHubOAuthService] Constructor called - setting up IPC listeners');
this.setupIPCListeners();
}
static get instance(): GitHubOAuthService {
@@ -92,147 +79,119 @@ export class GitHubOAuthService extends EventDispatcher {
}
/**
* Generate PKCE challenge for secure OAuth flow
* Set up IPC listeners for OAuth callbacks from main process
*/
private generatePKCE(): PKCEChallenge {
// Generate code verifier (random string)
const verifier = crypto.randomBytes(32).toString('base64url');
private setupIPCListeners(): void {
console.log('🔌 [GitHubOAuthService] Setting up IPC listeners for github-oauth-complete and github-oauth-error');
// Generate code challenge (SHA256 hash of verifier)
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
// Generate state for CSRF protection
const state = crypto.randomBytes(16).toString('hex');
return { verifier, challenge, state };
}
/**
* Initiate OAuth flow by opening GitHub authorization in browser
*/
async initiateOAuth(): Promise<void> {
console.log('🔐 Initiating GitHub OAuth flow');
// Generate PKCE challenge
this.pendingPKCE = this.generatePKCE();
// Build authorization URL
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: GITHUB_REDIRECT_URI,
scope: GITHUB_SCOPES.join(' '),
state: this.pendingPKCE.state,
code_challenge: this.pendingPKCE.challenge,
code_challenge_method: 'S256'
// Listen for successful OAuth completion
ipcRenderer.on('github-oauth-complete', (_event, result: OAuthCompleteResult) => {
console.log('✅ [GitHubOAuthService] IPC RECEIVED: github-oauth-complete');
console.log('✅ [GitHubOAuthService] Result:', result);
this.handleOAuthComplete(result);
});
const authUrl = `https://github.com/login/oauth/authorize?${params.toString()}`;
// Listen for OAuth errors
ipcRenderer.on('github-oauth-error', (_event, error: OAuthErrorResult) => {
console.error('❌ [GitHubOAuthService] IPC RECEIVED: github-oauth-error');
console.error('❌ [GitHubOAuthService] Error:', error);
this.handleOAuthError(error);
});
console.log('🌐 Opening GitHub authorization URL:', authUrl);
// Open in system browser
await shell.openExternal(authUrl);
// Notify listeners that OAuth flow started
this.notifyListeners('oauth-started');
console.log('✅ [GitHubOAuthService] IPC listeners registered');
}
/**
* Handle OAuth callback with authorization code
* Handle successful OAuth completion from main process
*/
async handleCallback(code: string, state: string): Promise<void> {
console.log('🔄 Handling OAuth callback');
private async handleOAuthComplete(result: OAuthCompleteResult): Promise<void> {
try {
// Verify state to prevent CSRF
if (!this.pendingPKCE || state !== this.pendingPKCE.state) {
throw new Error('Invalid OAuth state - possible CSRF attack');
}
console.log('🔄 [GitHub OAuth] Processing OAuth result for user:', result.user.login);
// Exchange code for token
const token = await this.exchangeCodeForToken(code, this.pendingPKCE.verifier);
// Store token
this.accessToken = token.access_token;
// Clear pending PKCE
this.pendingPKCE = null;
// Fetch user information
await this.fetchCurrentUser();
// Store the token
this.accessToken = result.token.access_token;
this.currentUser = result.user;
// Persist token securely
await this.saveToken(token.access_token);
await this.saveToken(result.token.access_token);
console.log('✅ GitHub OAuth successful, user:', this.currentUser?.login);
console.log('✅ [GitHub OAuth] Authentication successful');
// Notify listeners
this.isAuthenticating = false;
this.notifyListeners('oauth-success', { user: this.currentUser });
this.notifyListeners('auth-state-changed', { authenticated: true });
} catch (error) {
console.error('❌ OAuth callback error:', error);
this.pendingPKCE = null;
this.notifyListeners('oauth-error', { error: error.message });
console.error('❌ [GitHub OAuth] Failed to process OAuth result:', error);
this.handleOAuthError({
error: 'processing_failed',
message: error instanceof Error ? error.message : 'Failed to process OAuth result'
});
}
}
/**
* Handle OAuth error from main process
*/
private handleOAuthError(error: OAuthErrorResult): void {
console.error('❌ [GitHub OAuth] OAuth error:', error.error, error.message);
this.isAuthenticating = false;
this.notifyListeners('oauth-error', { error: error.message });
}
/**
* Initiate OAuth flow by requesting auth URL from main process
* and opening it in the system browser
*/
async initiateOAuth(): Promise<void> {
if (this.isAuthenticating) {
console.warn('[GitHub OAuth] OAuth flow already in progress');
return;
}
console.log('🔐 [GitHub OAuth] Initiating OAuth flow');
this.isAuthenticating = true;
try {
// Request auth URL from main process
// Main process generates the state and stores it for validation
const result = await ipcRenderer.invoke('github-oauth-start');
if (!result.success) {
throw new Error(result.error || 'Failed to start OAuth flow');
}
console.log('🌐 [GitHub OAuth] Opening auth URL in browser');
// Open the auth URL in the system browser
await shell.openExternal(result.authUrl);
// Notify listeners that OAuth flow started
this.notifyListeners('oauth-started');
// The main process will handle the callback and send us the result
// via 'github-oauth-complete' or 'github-oauth-error' IPC events
} catch (error) {
console.error('❌ [GitHub OAuth] Failed to initiate OAuth:', error);
this.isAuthenticating = false;
this.notifyListeners('oauth-error', {
error: error instanceof Error ? error.message : 'Failed to start OAuth'
});
throw error;
}
}
/**
* Exchange authorization code for access token
* Cancel any pending OAuth flow
*/
private async exchangeCodeForToken(code: string, verifier: string): Promise<GitHubToken> {
console.log('🔄 Exchanging code for access token');
// Exchange authorization code for access token using PKCE + client secret
const response = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
code_verifier: verifier,
redirect_uri: GITHUB_REDIRECT_URI
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to exchange code for token: ${response.status} ${errorText}`);
async cancelOAuth(): Promise<void> {
if (this.isAuthenticating) {
console.log('🚫 [GitHub OAuth] Cancelling OAuth flow');
await ipcRenderer.invoke('github-oauth-stop');
this.isAuthenticating = false;
this.notifyListeners('oauth-cancelled');
}
const data = await response.json();
if (data.error) {
throw new Error(`GitHub OAuth error: ${data.error_description || data.error}`);
}
return data;
}
/**
* Fetch current user information from GitHub API
*/
private async fetchCurrentUser(): Promise<void> {
if (!this.accessToken) {
throw new Error('No access token available');
}
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
Accept: 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.status}`);
}
this.currentUser = await response.json();
}
/**
@@ -282,11 +241,18 @@ export class GitHubOAuthService extends EventDispatcher {
return this.accessToken !== null && this.currentUser !== null;
}
/**
* Check if OAuth flow is in progress
*/
isOAuthInProgress(): boolean {
return this.isAuthenticating;
}
/**
* Revoke token and disconnect
*/
async disconnect(): Promise<void> {
console.log('🔌 Disconnecting GitHub account');
console.log('🔌 [GitHub OAuth] Disconnecting GitHub account');
this.accessToken = null;
this.currentUser = null;
@@ -300,15 +266,15 @@ export class GitHubOAuthService extends EventDispatcher {
}
/**
* Save token securely using Electron's safeStorage
* Save token securely using Electron's safeStorage via IPC
*/
private async saveToken(token: string): Promise<void> {
try {
const { ipcRenderer } = window.require('electron');
await ipcRenderer.invoke('github-save-token', token);
console.log('💾 [GitHub OAuth] Token saved');
} catch (error) {
console.error('Failed to save token:', error);
// Fallback: keep in memory only
console.error('❌ [GitHub OAuth] Failed to save token:', error);
// Token is still in memory, just not persisted
}
}
@@ -317,32 +283,56 @@ export class GitHubOAuthService extends EventDispatcher {
*/
private async loadToken(): Promise<void> {
try {
const { ipcRenderer } = window.require('electron');
const token = await ipcRenderer.invoke('github-load-token');
if (token) {
console.log('🔑 [GitHub OAuth] Token loaded from storage, verifying...');
this.accessToken = token;
// Fetch user info to verify token is still valid
await this.fetchCurrentUser();
this.notifyListeners('auth-state-changed', { authenticated: true });
console.log('✅ [GitHub OAuth] Token verified, user:', this.currentUser?.login);
}
} catch (error) {
console.error('Failed to load token:', error);
console.error('❌ [GitHub OAuth] Failed to load/verify token:', error);
// Token may be invalid, clear it
this.accessToken = null;
this.currentUser = null;
await this.clearToken();
}
}
/**
* Fetch current user information from GitHub API
*/
private async fetchCurrentUser(): Promise<void> {
if (!this.accessToken) {
throw new Error('No access token available');
}
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
Accept: 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.status}`);
}
this.currentUser = await response.json();
}
/**
* Clear stored token
*/
private async clearToken(): Promise<void> {
try {
const { ipcRenderer } = window.require('electron');
await ipcRenderer.invoke('github-clear-token');
} catch (error) {
console.error('Failed to clear token:', error);
console.error('❌ [GitHub OAuth] Failed to clear token:', error);
}
}
@@ -350,7 +340,7 @@ export class GitHubOAuthService extends EventDispatcher {
* Initialize service and restore session if available
*/
async initialize(): Promise<void> {
console.log('🔧 Initializing GitHubOAuthService');
console.log('🔧 [GitHub OAuth] Initializing GitHubOAuthService');
await this.loadToken();
}
}

View File

@@ -15,6 +15,7 @@ import type {
GitHubIssue,
GitHubPullRequest,
GitHubRepository,
GitHubOrganization,
GitHubComment,
GitHubCommit,
GitHubLabel,
@@ -23,6 +24,7 @@ import type {
GitHubIssueFilters,
CreateIssueOptions,
UpdateIssueOptions,
CreateRepositoryOptions,
GitHubApiError
} from './GitHubTypes';
@@ -190,21 +192,22 @@ export class GitHubClient extends EventDispatcher {
/**
* Get data from cache if valid
* Returns undefined if not in cache, the cached value (which could be null) if present
*/
private getFromCache<T>(key: string, ttl: number = DEFAULT_CACHE_TTL): T | null {
private getFromCache<T>(key: string, ttl: number = DEFAULT_CACHE_TTL): T | undefined {
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
if (!entry) {
return null;
return undefined; // Not in cache
}
const age = Date.now() - entry.timestamp;
if (age > ttl) {
this.cache.delete(key);
return null;
return undefined; // Cache expired
}
return entry.data;
return entry.data; // Return cached value (could be null)
}
/**
@@ -302,17 +305,23 @@ export class GitHubClient extends EventDispatcher {
per_page?: number;
page?: number;
}): Promise<GitHubApiResponse<GitHubRepository[]>> {
console.log('🔍 [GitHubClient] listRepositories called with:', options);
const cacheKey = this.getCacheKey('listRepositories', options || {});
const cached = this.getFromCache<GitHubRepository[]>(cacheKey, 60000);
if (cached) {
console.log('🔍 [GitHubClient] Returning cached repos:', cached.length);
return { data: cached, rateLimit: this.rateLimit! };
}
try {
console.log('🔍 [GitHubClient] Calling octokit.repos.listForAuthenticatedUser...');
const octokit = await this.ensureAuthenticated();
const response = await octokit.repos.listForAuthenticatedUser(options);
console.log('🔍 [GitHubClient] Got repos from API:', response.data?.length || 0);
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
this.setCache(cacheKey, response.data);
@@ -321,6 +330,7 @@ export class GitHubClient extends EventDispatcher {
rateLimit: this.rateLimit!
};
} catch (error) {
console.error('❌ [GitHubClient] listRepositories error:', error);
this.handleApiError(error);
}
}
@@ -679,6 +689,222 @@ export class GitHubClient extends EventDispatcher {
}
}
// ==================== ORGANIZATION METHODS ====================
/**
* List organizations for the authenticated user
*/
async listOrganizations(): Promise<GitHubApiResponse<GitHubOrganization[]>> {
console.log('🔍 [GitHubClient] listOrganizations called');
const cacheKey = this.getCacheKey('listOrganizations', {});
const cached = this.getFromCache<GitHubOrganization[]>(cacheKey, 60000); // 1 minute cache
if (cached) {
console.log('🔍 [GitHubClient] Returning cached orgs:', cached.length);
return { data: cached, rateLimit: this.rateLimit! };
}
try {
console.log('🔍 [GitHubClient] Calling octokit.orgs.listForAuthenticatedUser...');
const octokit = await this.ensureAuthenticated();
const response = await octokit.orgs.listForAuthenticatedUser({
per_page: 100
});
console.log('🔍 [GitHubClient] Got orgs from API:', response.data?.length || 0);
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
this.setCache(cacheKey, response.data);
return {
data: response.data as unknown as GitHubOrganization[],
rateLimit: this.rateLimit!
};
} catch (error) {
console.error('❌ [GitHubClient] listOrganizations error:', error);
this.handleApiError(error);
}
}
/**
* List repositories for an organization
*/
async listOrganizationRepositories(
org: string,
options?: {
type?: 'all' | 'public' | 'private' | 'forks' | 'sources' | 'member';
sort?: 'created' | 'updated' | 'pushed' | 'full_name';
direction?: 'asc' | 'desc';
per_page?: number;
page?: number;
}
): Promise<GitHubApiResponse<GitHubRepository[]>> {
const cacheKey = this.getCacheKey('listOrganizationRepositories', { org, ...options });
const cached = this.getFromCache<GitHubRepository[]>(cacheKey, 60000); // 1 minute cache
if (cached) {
return { data: cached, rateLimit: this.rateLimit! };
}
try {
const octokit = await this.ensureAuthenticated();
const response = await octokit.repos.listForOrg({
org,
...options
});
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
this.setCache(cacheKey, response.data);
return {
data: response.data as unknown as GitHubRepository[],
rateLimit: this.rateLimit!
};
} catch (error) {
this.handleApiError(error);
}
}
/**
* Create a new repository
*
* @param options - Repository creation options
* @returns The created repository
*/
async createRepository(options: CreateRepositoryOptions): Promise<GitHubApiResponse<GitHubRepository>> {
console.log('🔧 [GitHubClient] createRepository called with:', options);
try {
const octokit = await this.ensureAuthenticated();
let response;
if (options.org) {
// Create repository in organization
console.log('🔧 [GitHubClient] Creating repo in org:', options.org);
response = await octokit.repos.createInOrg({
org: options.org,
name: options.name,
description: options.description,
private: options.private ?? true,
auto_init: options.auto_init ?? false,
gitignore_template: options.gitignore_template,
license_template: options.license_template
});
} else {
// Create repository in user account
console.log('🔧 [GitHubClient] Creating repo in user account');
response = await octokit.repos.createForAuthenticatedUser({
name: options.name,
description: options.description,
private: options.private ?? true,
auto_init: options.auto_init ?? false,
gitignore_template: options.gitignore_template,
license_template: options.license_template
});
}
console.log('✅ [GitHubClient] Repository created:', response.data.full_name);
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
// Invalidate repo list caches
this.clearCacheForPattern('listRepositories');
this.clearCacheForPattern('listOrganizationRepositories');
return {
data: response.data as unknown as GitHubRepository,
rateLimit: this.rateLimit!
};
} catch (error) {
console.error('❌ [GitHubClient] createRepository error:', error);
this.handleApiError(error);
}
}
// ==================== FILE CONTENT METHODS ====================
/**
* Get file content from a repository
* @returns File content as string, or null if file doesn't exist
*/
async getFileContent(owner: string, repo: string, path: string): Promise<string | null> {
const cacheKey = this.getCacheKey('getFileContent', { owner, repo, path });
const cached = this.getFromCache<string | null>(cacheKey, 60000); // 1 minute cache
if (cached !== undefined) {
console.log('📦 [getFileContent] Cache hit for', `${owner}/${repo}/${path}`);
return cached;
}
console.log('🔍 [getFileContent] Fetching', `${owner}/${repo}/${path}`);
try {
const octokit = await this.ensureAuthenticated();
const response = await octokit.repos.getContent({
owner,
repo,
path
});
const responseType = !Array.isArray(response.data) && 'type' in response.data ? response.data.type : 'unknown';
console.log('✅ [getFileContent] Got response for', `${owner}/${repo}/${path}`, responseType);
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
// Handle file content (not directory)
if (!Array.isArray(response.data) && 'content' in response.data && response.data.type === 'file') {
const content = Buffer.from(response.data.content, 'base64').toString('utf-8');
this.setCache(cacheKey, content);
console.log('✅ [getFileContent] Found file', `${owner}/${repo}/${path}`, content.substring(0, 50) + '...');
return content;
}
// It's a directory or something else
console.log('⚠️ [getFileContent] Not a file:', `${owner}/${repo}/${path}`, responseType);
this.setCache(cacheKey, null);
return null;
} catch (error) {
const errorStatus =
error && typeof error === 'object' && 'status' in error ? (error as { status: number }).status : 'unknown';
console.log('❌ [getFileContent] Error for', `${owner}/${repo}/${path}`, 'status:', errorStatus);
// 404 means file doesn't exist - cache that result
if (errorStatus === 404) {
this.setCache(cacheKey, null);
return null;
}
// Log the full error for non-404 errors
console.error('❌ [getFileContent] Full error:', error);
// For other errors, don't cache and rethrow
throw error;
}
}
/**
* Check if a repository is a Noodl project
* Checks for project.json at the root of the repo
*/
async isNoodlProject(owner: string, repo: string): Promise<boolean> {
console.log('🔍 [GitHubClient] isNoodlProject checking:', `${owner}/${repo}`);
try {
const projectJson = await this.getFileContent(owner, repo, 'project.json');
if (projectJson !== null) {
console.log('✅ [GitHubClient] Found project.json in', `${owner}/${repo}`);
return true;
}
console.log('❌ [GitHubClient] No project.json found in', `${owner}/${repo}`);
return false;
} catch (error) {
console.error('❌ [GitHubClient] Error checking isNoodlProject for', `${owner}/${repo}`, error);
return false;
}
}
// ==================== UTILITY METHODS ====================
/**

View File

@@ -267,6 +267,26 @@ export interface UpdateIssueOptions {
milestone?: number | null;
}
/**
* Create repository options
*/
export interface CreateRepositoryOptions {
/** Repository name */
name: string;
/** Repository description */
description?: string;
/** Whether the repo is private (default: true) */
private?: boolean;
/** Organization name (if creating in an org, otherwise creates in user account) */
org?: string;
/** Initialize with README */
auto_init?: boolean;
/** .gitignore template */
gitignore_template?: string;
/** License template */
license_template?: string;
}
/**
* Error response from GitHub API
*/

View File

@@ -13,7 +13,7 @@ import Model from '../../../shared/model';
import { detectRuntimeVersion } from '../models/migration/ProjectScanner';
import { RuntimeVersionInfo } from '../models/migration/types';
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
import { GitHubAuth } from '../services/github';
import { GitHubOAuthService } from '../services/GitHubOAuthService';
import FileSystem from './filesystem';
import { tracker } from './tracker';
import { guid } from './utils';
@@ -336,14 +336,19 @@ export class LocalProjectsModel extends Model {
setCurrentGlobalGitAuth(projectId: string) {
const func = async (endpoint: string) => {
if (endpoint.includes('github.com')) {
// Priority 1: Check for global OAuth token
const authState = GitHubAuth.getAuthState();
if (authState.isAuthenticated && authState.token) {
console.log('[Git Auth] Using GitHub OAuth token for:', endpoint);
return {
username: authState.username || 'oauth',
password: authState.token.access_token // Extract actual access token string
};
// Priority 1: Check for global OAuth token from GitHubOAuthService
try {
const token = await GitHubOAuthService.instance.getToken();
const user = GitHubOAuthService.instance.getCurrentUser();
if (token) {
console.log('[Git Auth] Using GitHub OAuth token for:', endpoint, 'user:', user?.login);
return {
username: user?.login || 'oauth',
password: token
};
}
} catch (err) {
console.warn('[Git Auth] Failed to get OAuth token:', err);
}
// Priority 2: Fall back to project-specific PAT

View File

@@ -5,11 +5,14 @@
* with filtering, search, and detail views.
*/
import React, { useState } from 'react';
import { useEventListener } from '@noodl-hooks/useEventListener';
import React, { useState, useEffect } from 'react';
import { GitHubClient, GitHubOAuthService } from '../../../services/github';
import { ConnectToGitHubView } from './components/ConnectToGitHub';
import { IssuesList } from './components/IssuesTab/IssuesList';
import { PRsList } from './components/PullRequestsTab/PRsList';
import { SyncToolbar } from './components/SyncToolbar';
import styles from './GitHubPanel.module.scss';
import { useGitHubRepository } from './hooks/useGitHubRepository';
import { useIssues } from './hooks/useIssues';
@@ -19,11 +22,37 @@ type TabType = 'issues' | 'pullRequests';
export function GitHubPanel() {
const [activeTab, setActiveTab] = useState<TabType>('issues');
const [isConnected, setIsConnected] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const client = GitHubClient.instance;
const { owner, repo, isGitHub, isReady } = useGitHubRepository();
const { owner, repo, isGitHub, isReady, gitState, remoteUrl, provider, refetch } = useGitHubRepository();
// Check if GitHub is connected
const isConnected = client.isReady();
// Initialize GitHubOAuthService on mount
useEffect(() => {
console.log('🔧 [GitHubPanel] useEffect running - initializing OAuth service');
const initAuth = async () => {
try {
console.log('🔧 [GitHubPanel] Calling GitHubOAuthService.instance.initialize()...');
await GitHubOAuthService.instance.initialize();
const ready = client.isReady();
console.log('🔧 [GitHubPanel] After initialize - client.isReady():', ready);
setIsConnected(ready);
} catch (error) {
console.error('[GitHubPanel] Failed to initialize OAuth service:', error);
} finally {
setIsInitialized(true);
console.log('🔧 [GitHubPanel] Initialization complete');
}
};
initAuth();
}, [client]);
// Listen for auth state changes
console.log('🎧 [GitHubPanel] Setting up useEventListener for auth-state-changed');
useEventListener(GitHubOAuthService.instance, 'auth-state-changed', (event: { authenticated: boolean }) => {
console.log('🔔 [GitHubPanel] AUTH STATE CHANGED EVENT RECEIVED:', event.authenticated);
setIsConnected(event.authenticated);
});
const handleConnectGitHub = async () => {
try {
@@ -33,6 +62,38 @@ export function GitHubPanel() {
}
};
const handleConnected = () => {
// Refetch git state after connecting
refetch();
};
// Show loading while initializing
if (!isInitialized) {
return (
<div className={styles.GitHubPanel}>
<div className={styles.EmptyState}>
<div className={styles.EmptyStateIcon}></div>
<h3>Initializing</h3>
<p>Checking GitHub connection...</p>
</div>
</div>
);
}
// Loading state while determining git state
if (gitState === 'loading') {
return (
<div className={styles.GitHubPanel}>
<div className={styles.EmptyState}>
<div className={styles.EmptyStateIcon}></div>
<h3>Loading</h3>
<p>Checking repository status...</p>
</div>
</div>
);
}
// Not connected to GitHub account
if (!isConnected) {
return (
<div className={styles.GitHubPanel}>
@@ -48,6 +109,20 @@ export function GitHubPanel() {
);
}
// Project not connected to GitHub - show connect options
if (gitState === 'no-git' || gitState === 'git-no-remote' || gitState === 'remote-not-github') {
return (
<div className={styles.GitHubPanel}>
<ConnectToGitHubView
gitState={gitState}
remoteUrl={remoteUrl}
provider={provider}
onConnected={handleConnected}
/>
</div>
);
}
if (!isGitHub) {
return (
<div className={styles.GitHubPanel}>
@@ -74,6 +149,8 @@ export function GitHubPanel() {
return (
<div className={styles.GitHubPanel}>
<SyncToolbar owner={owner} repo={repo} />
<div className={styles.Header}>
<div className={styles.Tabs}>
<button

View File

@@ -0,0 +1,459 @@
/**
* ConnectToGitHub styles
* Uses design tokens for theming
*/
// Main view
.ConnectView {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
color: var(--theme-color-fg-default-shy);
min-height: 300px;
h3 {
margin: 16px 0 8px;
color: var(--theme-color-fg-default);
font-size: 18px;
font-weight: 600;
}
p {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
max-width: 400px;
}
}
.Icon {
color: var(--theme-color-fg-default-shy);
opacity: 0.7;
margin-bottom: 8px;
svg {
display: block;
}
}
.Actions {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 20px;
width: 100%;
max-width: 280px;
}
.PrimaryButton {
padding: 12px 24px;
background-color: var(--theme-color-primary);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
&:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
&:active:not(:disabled) {
opacity: 0.8;
transform: translateY(0);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.SecondaryButton {
padding: 12px 24px;
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background-color: var(--theme-color-bg-4);
border-color: var(--theme-color-border-strong);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.RemoteUrl {
display: inline-block;
padding: 4px 8px;
margin-top: 8px;
background-color: var(--theme-color-bg-3);
border-radius: 4px;
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
.HintText {
font-size: 12px;
color: var(--theme-color-fg-default-shy);
opacity: 0.8;
}
.ErrorMessage {
padding: 12px 16px;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 6px;
color: var(--theme-color-danger);
font-size: 13px;
margin-top: 16px;
width: 100%;
max-width: 400px;
text-align: left;
}
// Modal styles
.ModalBackdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.Modal {
background-color: var(--theme-color-bg-2);
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 420px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ModalLarge {
max-width: 560px;
}
.ModalHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--theme-color-border-default);
h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
}
.CloseButton {
background: none;
border: none;
font-size: 24px;
color: var(--theme-color-fg-default-shy);
cursor: pointer;
padding: 0;
line-height: 1;
transition: color 0.2s ease;
&:hover:not(:disabled) {
color: var(--theme-color-fg-default);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.ModalBody {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.ModalFooter {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid var(--theme-color-border-default);
background-color: var(--theme-color-bg-2);
.PrimaryButton,
.SecondaryButton {
width: auto;
padding: 10px 20px;
}
}
// Form styles
.FormGroup {
margin-bottom: 20px;
label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--theme-color-fg-default);
}
}
.Input,
.Select {
width: 100%;
padding: 10px 12px;
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
color: var(--theme-color-fg-default);
font-size: 14px;
transition: border-color 0.2s ease;
&::placeholder {
color: var(--theme-color-fg-default-shy);
}
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.Select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M3 4.5L6 7.5L9 4.5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.RadioGroup {
display: flex;
gap: 16px;
}
.RadioLabel {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
color: var(--theme-color-fg-default);
input[type='radio'] {
appearance: none;
width: 18px;
height: 18px;
border: 2px solid var(--theme-color-border-strong);
border-radius: 50%;
cursor: pointer;
position: relative;
&:checked {
border-color: var(--theme-color-primary);
&::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 8px;
height: 8px;
background-color: var(--theme-color-primary);
border-radius: 50%;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.RadioIcon {
font-size: 14px;
}
// Repository list styles
.SearchContainer {
margin-bottom: 16px;
}
.SearchInput {
width: 100%;
padding: 10px 12px;
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
color: var(--theme-color-fg-default);
font-size: 14px;
&::placeholder {
color: var(--theme-color-fg-default-shy);
}
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
}
.RepoList {
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--theme-color-border-default);
border-radius: 8px;
}
.RepoGroup {
&:not(:last-child) {
border-bottom: 1px solid var(--theme-color-border-default);
}
}
.RepoGroupHeader {
padding: 10px 14px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--theme-color-fg-default-shy);
background-color: var(--theme-color-bg-3);
position: sticky;
top: 0;
}
.RepoItem {
padding: 12px 14px;
cursor: pointer;
transition: background-color 0.15s ease;
border-bottom: 1px solid var(--theme-color-border-default);
&:last-child {
border-bottom: none;
}
&:hover {
background-color: var(--theme-color-bg-3);
}
&.RepoItemSelected {
background-color: rgba(var(--theme-color-primary-rgb, 59, 130, 246), 0.1);
border-left: 3px solid var(--theme-color-primary);
padding-left: 11px;
}
}
.RepoInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.RepoName {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: var(--theme-color-fg-default);
}
.PrivateBadge {
padding: 2px 6px;
background-color: var(--theme-color-bg-4);
border-radius: 4px;
font-size: 11px;
font-weight: 400;
color: var(--theme-color-fg-default-shy);
}
.RepoDescription {
font-size: 13px;
color: var(--theme-color-fg-default-shy);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.RepoMeta {
font-size: 12px;
color: var(--theme-color-fg-default-shy);
opacity: 0.8;
}
// Loading state
.LoadingState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: var(--theme-color-fg-default-shy);
p {
margin-top: 16px;
font-size: 13px;
}
}
.Spinner {
width: 32px;
height: 32px;
border: 3px solid var(--theme-color-bg-4);
border-top-color: var(--theme-color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// Empty state
.EmptyState {
padding: 32px 24px;
text-align: center;
color: var(--theme-color-fg-default-shy);
p {
margin: 0;
font-size: 13px;
}
}

View File

@@ -0,0 +1,299 @@
/**
* ConnectToGitHubView
*
* Displays appropriate UI based on project's git state:
* - no-git: Offer to initialize git and create/connect repo
* - git-no-remote: Offer to create or connect to existing repo
* - remote-not-github: Show info that it's not a GitHub repo
*/
import React, { useState, useCallback } from 'react';
import { Git } from '@noodl/git';
import { ProjectModel } from '@noodl-models/projectmodel';
import { LocalProjectsModel } from '@noodl-utils/LocalProjectsModel';
import { mergeProject } from '@noodl-utils/projectmerger';
import { GitHubClient, GitHubOAuthService } from '../../../../../services/github';
import type { ProjectGitState } from '../../hooks/useGitHubRepository';
import styles from './ConnectToGitHub.module.scss';
import { CreateRepoModal } from './CreateRepoModal';
import { SelectRepoModal } from './SelectRepoModal';
interface ConnectToGitHubViewProps {
gitState: ProjectGitState;
remoteUrl?: string | null;
provider?: string | null;
onConnected: () => void;
}
export function ConnectToGitHubView({ gitState, remoteUrl, provider, onConnected }: ConnectToGitHubViewProps) {
const [showCreateModal, setShowCreateModal] = useState(false);
const [showSelectModal, setShowSelectModal] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const isGitHubConnected = GitHubOAuthService.instance.isAuthenticated();
const handleConnectGitHub = async () => {
try {
await GitHubOAuthService.instance.initiateOAuth();
} catch (err) {
console.error('Failed to initiate GitHub OAuth:', err);
setError('Failed to connect to GitHub. Please try again.');
}
};
const handleCreateRepo = useCallback(
async (options: { name: string; description?: string; private?: boolean; org?: string }) => {
setIsConnecting(true);
setError(null);
try {
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
if (!projectDirectory) {
throw new Error('No project directory found');
}
console.log('🔧 [ConnectToGitHub] Creating repository:', options);
// 1. Create repo on GitHub
const client = GitHubClient.instance;
const result = await client.createRepository({
name: options.name,
description: options.description,
private: options.private ?? true,
org: options.org
});
const repoUrl = result.data.html_url + '.git';
console.log('✅ [ConnectToGitHub] Repository created:', repoUrl);
// 2. Set up git auth before any git operations
const projectId = ProjectModel.instance?.id || 'temp';
console.log('🔧 [ConnectToGitHub] Setting up git auth for project:', projectId);
LocalProjectsModel.instance.setCurrentGlobalGitAuth(projectId);
// 3. Initialize git if needed
const git = new Git(mergeProject);
if (gitState === 'no-git') {
console.log('🔧 [ConnectToGitHub] Initializing git repository...');
await git.initNewRepo(projectDirectory);
} else {
await git.openRepository(projectDirectory);
}
// 3. Add remote
console.log('🔧 [ConnectToGitHub] Adding remote origin:', repoUrl);
await git.setRemoteURL(repoUrl);
// 4. Make initial commit if there are changes
const status = await git.status();
if (status.length > 0 || gitState === 'no-git') {
console.log('🔧 [ConnectToGitHub] Creating initial commit...');
await git.commit('Initial commit');
}
// 5. Push to remote
console.log('🔧 [ConnectToGitHub] Pushing to remote...');
await git.push();
console.log('✅ [ConnectToGitHub] Successfully connected to GitHub!');
// Notify parent
setShowCreateModal(false);
onConnected();
} catch (err) {
console.error('❌ [ConnectToGitHub] Error:', err);
setError(err instanceof Error ? err.message : 'Failed to create repository');
} finally {
setIsConnecting(false);
}
},
[gitState, onConnected]
);
const handleSelectRepo = useCallback(
async (repoUrl: string) => {
setIsConnecting(true);
setError(null);
try {
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
if (!projectDirectory) {
throw new Error('No project directory found');
}
console.log('🔧 [ConnectToGitHub] Connecting to existing repo:', repoUrl);
// Set up git auth before any git operations
const projectId = ProjectModel.instance?.id || 'temp';
console.log('🔧 [ConnectToGitHub] Setting up git auth for project:', projectId);
LocalProjectsModel.instance.setCurrentGlobalGitAuth(projectId);
// Initialize git if needed
const git = new Git(mergeProject);
if (gitState === 'no-git') {
console.log('🔧 [ConnectToGitHub] Initializing git repository...');
await git.initNewRepo(projectDirectory);
} else {
await git.openRepository(projectDirectory);
}
// Add remote
console.log('🔧 [ConnectToGitHub] Setting remote URL:', repoUrl);
await git.setRemoteURL(repoUrl);
// Fetch from remote to see if there are existing commits
try {
console.log('🔧 [ConnectToGitHub] Fetching from remote...');
await git.fetch({ onProgress: () => {} });
// Check if remote has commits
const hasRemote = await git.hasRemoteCommits();
if (hasRemote) {
console.log('🔧 [ConnectToGitHub] Remote has commits, attempting to merge...');
// Pull changes
await git.mergeToCurrentBranch('origin/main', false);
}
} catch (fetchErr) {
// Remote might be empty, that's okay
console.log('⚠️ [ConnectToGitHub] Fetch warning (might be empty repo):', fetchErr);
}
console.log('✅ [ConnectToGitHub] Successfully connected to GitHub!');
setShowSelectModal(false);
onConnected();
} catch (err) {
console.error('❌ [ConnectToGitHub] Error:', err);
setError(err instanceof Error ? err.message : 'Failed to connect to repository');
} finally {
setIsConnecting(false);
}
},
[gitState, onConnected]
);
// If not connected to GitHub, show connect button
if (!isGitHubConnected) {
return (
<div className={styles.ConnectView}>
<div className={styles.Icon}>
<GitHubIcon />
</div>
<h3>Connect to GitHub</h3>
<p>Connect your GitHub account to create or link repositories.</p>
<button className={styles.PrimaryButton} onClick={handleConnectGitHub}>
Connect GitHub Account
</button>
</div>
);
}
// Show state-specific UI
return (
<div className={styles.ConnectView}>
{gitState === 'no-git' && (
<>
<div className={styles.Icon}>
<FolderIcon />
</div>
<h3>Initialize Git Repository</h3>
<p>This project is not under version control. Initialize git and connect to GitHub.</p>
</>
)}
{gitState === 'git-no-remote' && (
<>
<div className={styles.Icon}>
<GitIcon />
</div>
<h3>Connect to GitHub</h3>
<p>This project has git initialized but no remote. Connect it to a GitHub repository.</p>
</>
)}
{gitState === 'remote-not-github' && (
<>
<div className={styles.Icon}>
<CloudIcon />
</div>
<h3>Not a GitHub Repository</h3>
<p>
This project is connected to a different git provider:
<br />
<code className={styles.RemoteUrl}>{remoteUrl || provider}</code>
</p>
<p className={styles.HintText}>
To use GitHub features, you will need to change the remote or create a new GitHub repository.
</p>
</>
)}
{error && <div className={styles.ErrorMessage}>{error}</div>}
{gitState !== 'remote-not-github' && (
<div className={styles.Actions}>
<button className={styles.PrimaryButton} onClick={() => setShowCreateModal(true)} disabled={isConnecting}>
Create New Repository
</button>
<button className={styles.SecondaryButton} onClick={() => setShowSelectModal(true)} disabled={isConnecting}>
Connect Existing Repository
</button>
</div>
)}
{showCreateModal && (
<CreateRepoModal
onClose={() => setShowCreateModal(false)}
onCreate={handleCreateRepo}
isCreating={isConnecting}
/>
)}
{showSelectModal && (
<SelectRepoModal
onClose={() => setShowSelectModal(false)}
onSelect={handleSelectRepo}
isConnecting={isConnecting}
/>
)}
</div>
);
}
// Simple icon components
function GitHubIcon() {
return (
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}
function FolderIcon() {
return (
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
);
}
function GitIcon() {
return (
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.546 10.93L13.067.452c-.604-.603-1.582-.603-2.188 0L8.708 2.627l2.76 2.76c.645-.215 1.379-.07 1.889.441.516.515.658 1.258.438 1.9l2.658 2.66c.645-.223 1.387-.078 1.9.435.721.72.721 1.884 0 2.604-.719.719-1.881.719-2.6 0-.539-.541-.674-1.337-.404-1.996L12.86 8.955v6.525c.176.086.342.203.488.348.713.721.713 1.883 0 2.6-.719.721-1.889.721-2.609 0-.719-.719-.719-1.879 0-2.598.182-.18.387-.316.605-.406V8.835c-.217-.091-.424-.222-.6-.401-.545-.545-.676-1.342-.396-2.009L7.636 3.7.45 10.881c-.6.605-.6 1.584 0 2.189l10.48 10.477c.604.604 1.582.604 2.186 0l10.43-10.43c.605-.603.605-1.582 0-2.187" />
</svg>
);
}
function CloudIcon() {
return (
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" />
</svg>
);
}

View File

@@ -0,0 +1,174 @@
/**
* CreateRepoModal
*
* Modal for creating a new GitHub repository
*/
import React, { useState, useEffect } from 'react';
import { GitHubClient } from '../../../../../services/github';
import type { GitHubOrganization } from '../../../../../services/github/GitHubTypes';
import styles from './ConnectToGitHub.module.scss';
interface CreateRepoModalProps {
onClose: () => void;
onCreate: (options: { name: string; description?: string; private?: boolean; org?: string }) => void;
isCreating: boolean;
}
export function CreateRepoModal({ onClose, onCreate, isCreating }: CreateRepoModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isPrivate, setIsPrivate] = useState(true);
const [selectedOrg, setSelectedOrg] = useState<string>('');
const [orgs, setOrgs] = useState<GitHubOrganization[]>([]);
const [loadingOrgs, setLoadingOrgs] = useState(true);
const [error, setError] = useState<string | null>(null);
// Load organizations
useEffect(() => {
async function loadOrgs() {
try {
const client = GitHubClient.instance;
const result = await client.listOrganizations();
setOrgs(result.data);
} catch (err) {
console.error('Failed to load organizations:', err);
} finally {
setLoadingOrgs(false);
}
}
loadOrgs();
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setError('Repository name is required');
return;
}
// Validate repo name (GitHub rules)
const nameRegex = /^[a-zA-Z0-9._-]+$/;
if (!nameRegex.test(name)) {
setError('Repository name can only contain letters, numbers, hyphens, underscores, and dots');
return;
}
setError(null);
onCreate({
name: name.trim(),
description: description.trim() || undefined,
private: isPrivate,
org: selectedOrg || undefined
});
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && !isCreating) {
onClose();
}
};
return (
<div className={styles.ModalBackdrop} onClick={handleBackdropClick}>
<div className={styles.Modal}>
<div className={styles.ModalHeader}>
<h2>Create New Repository</h2>
<button className={styles.CloseButton} onClick={onClose} disabled={isCreating}>
&times;
</button>
</div>
<form onSubmit={handleSubmit}>
<div className={styles.ModalBody}>
<div className={styles.FormGroup}>
<label htmlFor="owner">Owner</label>
<select
id="owner"
value={selectedOrg}
onChange={(e) => setSelectedOrg(e.target.value)}
disabled={loadingOrgs || isCreating}
className={styles.Select}
>
<option value="">Personal Account</option>
{orgs.map((org) => (
<option key={org.id} value={org.login}>
{org.login}
</option>
))}
</select>
</div>
<div className={styles.FormGroup}>
<label htmlFor="name">Repository name *</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my-noodl-project"
disabled={isCreating}
className={styles.Input}
autoFocus
/>
</div>
<div className={styles.FormGroup}>
<label htmlFor="description">Description</label>
<input
id="description"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="A brief description of your project"
disabled={isCreating}
className={styles.Input}
/>
</div>
<div className={styles.FormGroup}>
<label>Visibility</label>
<div className={styles.RadioGroup}>
<label className={styles.RadioLabel}>
<input
type="radio"
name="visibility"
checked={isPrivate}
onChange={() => setIsPrivate(true)}
disabled={isCreating}
/>
<span className={styles.RadioIcon}>🔒</span>
<span>Private</span>
</label>
<label className={styles.RadioLabel}>
<input
type="radio"
name="visibility"
checked={!isPrivate}
onChange={() => setIsPrivate(false)}
disabled={isCreating}
/>
<span className={styles.RadioIcon}>🌐</span>
<span>Public</span>
</label>
</div>
</div>
{error && <div className={styles.ErrorMessage}>{error}</div>}
</div>
<div className={styles.ModalFooter}>
<button type="button" className={styles.SecondaryButton} onClick={onClose} disabled={isCreating}>
Cancel
</button>
<button type="submit" className={styles.PrimaryButton} disabled={isCreating || !name.trim()}>
{isCreating ? 'Creating...' : 'Create Repository'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,226 @@
/**
* SelectRepoModal
*
* Modal for selecting an existing GitHub repository to connect
*/
import React, { useState, useEffect, useMemo } from 'react';
import { GitHubClient } from '../../../../../services/github';
import type { GitHubRepository, GitHubOrganization } from '../../../../../services/github/GitHubTypes';
import styles from './ConnectToGitHub.module.scss';
interface SelectRepoModalProps {
onClose: () => void;
onSelect: (repoUrl: string) => void;
isConnecting: boolean;
}
interface RepoGroup {
name: string;
repos: GitHubRepository[];
}
export function SelectRepoModal({ onClose, onSelect, isConnecting }: SelectRepoModalProps) {
const [repos, setRepos] = useState<GitHubRepository[]>([]);
const [orgs, setOrgs] = useState<GitHubOrganization[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedRepo, setSelectedRepo] = useState<GitHubRepository | null>(null);
// Load repositories and organizations
useEffect(() => {
async function loadData() {
setLoading(true);
setError(null);
try {
const client = GitHubClient.instance;
// Load in parallel
const [reposResult, orgsResult] = await Promise.all([
client.listRepositories({ per_page: 100, sort: 'updated' }),
client.listOrganizations()
]);
setRepos(reposResult.data);
setOrgs(orgsResult.data);
// Also load org repos
const orgRepos = await Promise.all(
orgsResult.data.map((org) =>
client.listOrganizationRepositories(org.login, { per_page: 100, sort: 'updated' })
)
);
// Combine all repos
const allRepos = [...reposResult.data];
orgRepos.forEach((result) => {
result.data.forEach((repo) => {
if (!allRepos.find((r) => r.id === repo.id)) {
allRepos.push(repo);
}
});
});
setRepos(allRepos);
} catch (err) {
console.error('Failed to load repositories:', err);
setError('Failed to load repositories. Please try again.');
} finally {
setLoading(false);
}
}
loadData();
}, []);
// Group and filter repos
const groupedRepos = useMemo((): RepoGroup[] => {
// Filter by search query
const filtered = searchQuery
? repos.filter(
(repo) =>
repo.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
repo.full_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
repo.description?.toLowerCase().includes(searchQuery.toLowerCase())
)
: repos;
// Group by owner
const groups: Record<string, GitHubRepository[]> = {};
filtered.forEach((repo) => {
const ownerLogin = repo.owner.login;
if (!groups[ownerLogin]) {
groups[ownerLogin] = [];
}
groups[ownerLogin].push(repo);
});
// Sort groups: personal first, then orgs alphabetically
const sortedGroups: RepoGroup[] = [];
const personalRepos = Object.entries(groups).find(([name]) => !orgs.find((org) => org.login === name));
if (personalRepos) {
sortedGroups.push({ name: 'Personal', repos: personalRepos[1] });
}
Object.entries(groups)
.filter(([name]) => orgs.find((org) => org.login === name))
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([name, repoList]) => {
sortedGroups.push({ name, repos: repoList });
});
return sortedGroups;
}, [repos, orgs, searchQuery]);
const handleSelect = () => {
if (selectedRepo) {
onSelect(selectedRepo.html_url + '.git');
}
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && !isConnecting) {
onClose();
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'today';
if (diffDays === 1) return 'yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
return `${Math.floor(diffDays / 365)} years ago`;
};
return (
<div className={styles.ModalBackdrop} onClick={handleBackdropClick}>
<div className={`${styles.Modal} ${styles.ModalLarge}`}>
<div className={styles.ModalHeader}>
<h2>Connect to Existing Repository</h2>
<button className={styles.CloseButton} onClick={onClose} disabled={isConnecting}>
&times;
</button>
</div>
<div className={styles.ModalBody}>
<div className={styles.SearchContainer}>
<input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={styles.SearchInput}
autoFocus
/>
</div>
{loading && (
<div className={styles.LoadingState}>
<div className={styles.Spinner} />
<p>Loading repositories...</p>
</div>
)}
{error && <div className={styles.ErrorMessage}>{error}</div>}
{!loading && !error && (
<div className={styles.RepoList}>
{groupedRepos.length === 0 ? (
<div className={styles.EmptyState}>
<p>No repositories found</p>
</div>
) : (
groupedRepos.map((group) => (
<div key={group.name} className={styles.RepoGroup}>
<div className={styles.RepoGroupHeader}>{group.name}</div>
{group.repos.map((repo) => (
<div
key={repo.id}
className={`${styles.RepoItem} ${selectedRepo?.id === repo.id ? styles.RepoItemSelected : ''}`}
onClick={() => setSelectedRepo(repo)}
>
<div className={styles.RepoInfo}>
<div className={styles.RepoName}>
<span>{repo.name}</span>
{repo.private && <span className={styles.PrivateBadge}>Private</span>}
</div>
{repo.description && <div className={styles.RepoDescription}>{repo.description}</div>}
<div className={styles.RepoMeta}>Updated {formatDate(repo.updated_at)}</div>
</div>
</div>
))}
</div>
))
)}
</div>
)}
</div>
<div className={styles.ModalFooter}>
<button type="button" className={styles.SecondaryButton} onClick={onClose} disabled={isConnecting}>
Cancel
</button>
<button
type="button"
className={styles.PrimaryButton}
onClick={handleSelect}
disabled={isConnecting || !selectedRepo}
>
{isConnecting ? 'Connecting...' : 'Connect Repository'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { ConnectToGitHubView } from './ConnectToGitHubView';
export { CreateRepoModal } from './CreateRepoModal';
export { SelectRepoModal } from './SelectRepoModal';

View File

@@ -0,0 +1,161 @@
/**
* SyncToolbar styles
* Uses design tokens for theming
*/
.SyncToolbar {
display: flex;
flex-direction: column;
padding: 10px 12px;
background-color: var(--theme-color-bg-3);
border-bottom: 1px solid var(--theme-color-border-default);
}
.RepoInfo {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.RepoName {
font-size: 13px;
font-weight: 500;
color: var(--theme-color-fg-default);
}
.StatusText {
font-size: 12px;
color: var(--theme-color-fg-default-shy);
}
.ChangesIndicator {
color: var(--theme-color-warning);
}
.SyncedIndicator {
color: var(--theme-color-success);
}
.Actions {
display: flex;
gap: 8px;
}
.SyncButton {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background-color: var(--theme-color-bg-4);
border-color: var(--theme-color-border-strong);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.HasChanges {
background-color: rgba(var(--theme-color-primary-rgb, 59, 130, 246), 0.1);
border-color: var(--theme-color-primary);
color: var(--theme-color-primary);
&:hover:not(:disabled) {
background-color: rgba(var(--theme-color-primary-rgb, 59, 130, 246), 0.2);
}
}
svg {
flex-shrink: 0;
}
}
.Badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background-color: var(--theme-color-primary);
border-radius: 9px;
color: white;
font-size: 11px;
font-weight: 600;
}
.RefreshButton {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default-shy);
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-default);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.Spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.ErrorBar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
padding: 8px 10px;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 4px;
color: var(--theme-color-danger);
font-size: 12px;
button {
background: none;
border: none;
color: var(--theme-color-danger);
font-size: 16px;
cursor: pointer;
padding: 0 0 0 8px;
line-height: 1;
&:hover {
opacity: 0.7;
}
}
}

View File

@@ -0,0 +1,150 @@
/**
* SyncToolbar
*
* Toolbar with push/pull buttons and sync status display
*/
import React, { useState } from 'react';
import { useGitSyncStatus } from '../../hooks/useGitSyncStatus';
import styles from './SyncToolbar.module.scss';
interface SyncToolbarProps {
owner: string;
repo: string;
}
export function SyncToolbar({ owner, repo }: SyncToolbarProps) {
const { ahead, behind, hasUncommittedChanges, loading, error, isSyncing, push, pull, refresh } = useGitSyncStatus();
const [lastError, setLastError] = useState<string | null>(null);
const handlePush = async () => {
setLastError(null);
try {
await push();
} catch (err) {
setLastError(err instanceof Error ? err.message : 'Push failed');
}
};
const handlePull = async () => {
setLastError(null);
try {
await pull();
} catch (err) {
setLastError(err instanceof Error ? err.message : 'Pull failed');
}
};
const handleRefresh = () => {
setLastError(null);
refresh();
};
// Show error or status message
const displayError = lastError || error;
return (
<div className={styles.SyncToolbar}>
<div className={styles.RepoInfo}>
<span className={styles.RepoName}>
{owner}/{repo}
</span>
{loading && <span className={styles.StatusText}>Loading...</span>}
{!loading && !displayError && (
<span className={styles.StatusText}>
{hasUncommittedChanges && <span className={styles.ChangesIndicator}>Uncommitted changes</span>}
{!hasUncommittedChanges && ahead === 0 && behind === 0 && (
<span className={styles.SyncedIndicator}>Up to date</span>
)}
</span>
)}
</div>
<div className={styles.Actions}>
{/* Pull button */}
<button
className={`${styles.SyncButton} ${behind > 0 ? styles.HasChanges : ''}`}
onClick={handlePull}
disabled={isSyncing || loading}
title={behind > 0 ? `Pull ${behind} commit${behind > 1 ? 's' : ''} from remote` : 'Pull from remote'}
>
<DownloadIcon />
<span>Pull</span>
{behind > 0 && <span className={styles.Badge}>{behind}</span>}
</button>
{/* Push button */}
<button
className={`${styles.SyncButton} ${ahead > 0 || hasUncommittedChanges ? styles.HasChanges : ''}`}
onClick={handlePush}
disabled={isSyncing || loading || (ahead === 0 && !hasUncommittedChanges)}
title={
hasUncommittedChanges
? 'Commit and push changes'
: ahead > 0
? `Push ${ahead} commit${ahead > 1 ? 's' : ''} to remote`
: 'Nothing to push'
}
>
<UploadIcon />
<span>Push</span>
{(ahead > 0 || hasUncommittedChanges) && (
<span className={styles.Badge}>{hasUncommittedChanges ? '!' : ahead}</span>
)}
</button>
{/* Refresh button */}
<button
className={styles.RefreshButton}
onClick={handleRefresh}
disabled={isSyncing || loading}
title="Refresh sync status"
>
<RefreshIcon spinning={loading || isSyncing} />
</button>
</div>
{displayError && (
<div className={styles.ErrorBar}>
<span>{displayError}</span>
<button onClick={() => setLastError(null)}>&times;</button>
</div>
)}
</div>
);
}
// Icon components
function DownloadIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 12L3 7l1.4-1.4L7 8.2V1h2v7.2l2.6-2.6L13 7l-5 5z" />
<path d="M14 13v1H2v-1h12z" />
</svg>
);
}
function UploadIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1l5 5-1.4 1.4L9 4.8V12H7V4.8L4.4 7.4 3 6l5-5z" />
<path d="M14 13v1H2v-1h12z" />
</svg>
);
}
function RefreshIcon({ spinning }: { spinning?: boolean }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
className={spinning ? styles.Spinning : undefined}
>
<path d="M13.5 8c0-3-2.5-5.5-5.5-5.5S2.5 5 2.5 8H1C1 4.1 4.1 1 8 1s7 3.1 7 7h-1.5z" />
<path d="M2.5 8c0 3 2.5 5.5 5.5 5.5s5.5-2.5 5.5-5.5H15c0 3.9-3.1 7-7 7s-7-3.1-7-7h1.5z" />
</svg>
);
}

View File

@@ -0,0 +1 @@
export { SyncToolbar } from './SyncToolbar';

View File

@@ -2,20 +2,40 @@
* useGitHubRepository Hook
*
* Extracts GitHub repository information from the Git remote URL.
* Returns owner, repo name, and connection status.
* Returns owner, repo name, connection status, and detailed git state.
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Git } from '@noodl/git';
import { ProjectModel } from '@noodl-models/projectmodel';
import { mergeProject } from '@noodl-utils/projectmerger';
interface GitHubRepoInfo {
/**
* Possible states for a project's git connection
*/
export type ProjectGitState =
| 'loading' // Still determining state
| 'no-git' // No .git folder
| 'git-no-remote' // Has .git but no origin remote
| 'remote-not-github' // Has remote but not github.com
| 'github-connected'; // Connected to GitHub
export interface GitHubRepoInfo {
/** GitHub repository owner/organization */
owner: string | null;
/** GitHub repository name */
repo: string | null;
/** Whether the remote is GitHub */
isGitHub: boolean;
/** Whether we have all info needed (owner + repo) */
isReady: boolean;
/** Detailed state of the git connection */
gitState: ProjectGitState;
/** Remote URL if available */
remoteUrl: string | null;
/** Git provider (github, noodl, unknown, none) */
provider: string | null;
}
/**
@@ -54,77 +74,116 @@ function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
return null;
}
const initialState: GitHubRepoInfo = {
owner: null,
repo: null,
isGitHub: false,
isReady: false,
gitState: 'loading',
remoteUrl: null,
provider: null
};
/**
* Hook to get GitHub repository information from current project's Git remote
*/
export function useGitHubRepository(): GitHubRepoInfo {
const [repoInfo, setRepoInfo] = useState<GitHubRepoInfo>({
owner: null,
repo: null,
isGitHub: false,
isReady: false
});
export function useGitHubRepository(): GitHubRepoInfo & { refetch: () => void } {
const [repoInfo, setRepoInfo] = useState<GitHubRepoInfo>(initialState);
const fetchRepoInfo = useCallback(async () => {
try {
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
if (!projectDirectory) {
setRepoInfo({
...initialState,
gitState: 'no-git'
});
return;
}
// Create Git instance and try to open repository
const git = new Git(mergeProject);
useEffect(() => {
async function fetchRepoInfo() {
try {
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
if (!projectDirectory) {
setRepoInfo({
owner: null,
repo: null,
isGitHub: false,
isReady: false
});
return;
}
// Create Git instance and open repository
const git = new Git(mergeProject);
await git.openRepository(projectDirectory);
// Check if it's a GitHub repository
const provider = git.Provider;
if (provider !== 'github') {
setRepoInfo({
owner: null,
repo: null,
isGitHub: false,
isReady: false
});
return;
}
// Parse the remote URL
const remoteUrl = git.OriginUrl;
const parsed = parseGitHubUrl(remoteUrl);
if (parsed) {
setRepoInfo({
owner: parsed.owner,
repo: parsed.repo,
isGitHub: true,
isReady: true
});
} catch (gitError) {
// Not a git repository - this is expected for non-git projects
const errorMessage = gitError instanceof Error ? gitError.message : String(gitError);
if (errorMessage.includes('Not a git repository')) {
console.log('[useGitHubRepository] Project is not a git repository');
} else {
setRepoInfo({
owner: null,
repo: null,
isGitHub: true, // It's GitHub but couldn't parse
isReady: false
});
console.warn('[useGitHubRepository] Git error:', errorMessage);
}
} catch (error) {
console.error('Failed to fetch GitHub repository info:', error);
setRepoInfo({
...initialState,
gitState: 'no-git'
});
return;
}
// Check if we have a remote
const remoteName = await git.getRemoteName();
if (!remoteName) {
console.log('[useGitHubRepository] No remote configured');
setRepoInfo({
...initialState,
gitState: 'git-no-remote'
});
return;
}
// Get remote URL and provider
const remoteUrl = git.OriginUrl;
const provider = git.Provider;
// Check if it's a GitHub repository
if (provider !== 'github') {
setRepoInfo({
owner: null,
repo: null,
isGitHub: false,
isReady: false
isReady: false,
gitState: 'remote-not-github',
remoteUrl,
provider
});
return;
}
// Parse the remote URL
const parsed = parseGitHubUrl(remoteUrl);
if (parsed) {
setRepoInfo({
owner: parsed.owner,
repo: parsed.repo,
isGitHub: true,
isReady: true,
gitState: 'github-connected',
remoteUrl,
provider
});
} else {
setRepoInfo({
owner: null,
repo: null,
isGitHub: true, // It's GitHub but couldn't parse
isReady: false,
gitState: 'github-connected',
remoteUrl,
provider
});
}
} catch (error) {
console.error('[useGitHubRepository] Unexpected error:', error);
setRepoInfo({
...initialState,
gitState: 'no-git'
});
}
}, []);
useEffect(() => {
fetchRepoInfo();
// Refetch when project changes
@@ -138,7 +197,7 @@ export function useGitHubRepository(): GitHubRepoInfo {
return () => {
ProjectModel.instance?.off(handleProjectChange);
};
}, []);
}, [fetchRepoInfo]);
return repoInfo;
return { ...repoInfo, refetch: fetchRepoInfo };
}

View File

@@ -0,0 +1,247 @@
/**
* useGitSyncStatus Hook
*
* Monitors git sync status including ahead/behind counts and uncommitted changes.
* Provides push/pull functionality.
*/
import { useState, useEffect, useCallback } from 'react';
import { Git } from '@noodl/git';
import { ProjectModel } from '@noodl-models/projectmodel';
import { LocalProjectsModel } from '@noodl-utils/LocalProjectsModel';
import { mergeProject } from '@noodl-utils/projectmerger';
export interface GitSyncStatus {
/** Number of commits ahead of remote */
ahead: number;
/** Number of commits behind remote */
behind: number;
/** Whether there are uncommitted changes */
hasUncommittedChanges: boolean;
/** Whether we're currently loading status */
loading: boolean;
/** Any error that occurred */
error: string | null;
/** Whether a push/pull operation is in progress */
isSyncing: boolean;
}
interface UseGitSyncStatusResult extends GitSyncStatus {
/** Push local commits to remote */
push: () => Promise<void>;
/** Pull remote commits to local */
pull: () => Promise<void>;
/** Refresh the sync status */
refresh: () => Promise<void>;
}
export function useGitSyncStatus(): UseGitSyncStatusResult {
const [status, setStatus] = useState<GitSyncStatus>({
ahead: 0,
behind: 0,
hasUncommittedChanges: false,
loading: true,
error: null,
isSyncing: false
});
const fetchStatus = useCallback(async () => {
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
if (!projectDirectory) {
setStatus((prev) => ({
...prev,
loading: false,
error: 'No project directory'
}));
return;
}
try {
// Ensure git auth is set up
const projectId = ProjectModel.instance?.id || 'temp';
LocalProjectsModel.instance.setCurrentGlobalGitAuth(projectId);
const git = new Git(mergeProject);
await git.openRepository(projectDirectory);
// Check for remote
const remoteName = await git.getRemoteName();
if (!remoteName) {
setStatus((prev) => ({
...prev,
loading: false,
error: 'No remote configured'
}));
return;
}
// Fetch latest from remote (silently)
try {
await git.fetch({ onProgress: () => {} });
} catch (fetchError) {
console.warn('[useGitSyncStatus] Fetch warning:', fetchError);
// Continue anyway - might be offline
}
// Get ahead/behind counts
let ahead = 0;
let behind = 0;
try {
const aheadBehind = await git.currentAheadBehind();
ahead = aheadBehind.ahead;
behind = aheadBehind.behind;
} catch (abError) {
console.warn('[useGitSyncStatus] Could not get ahead/behind:', abError);
// Remote might not have any commits yet
}
// Check for uncommitted changes
const changes = await git.status();
const hasUncommittedChanges = changes.length > 0;
setStatus({
ahead,
behind,
hasUncommittedChanges,
loading: false,
error: null,
isSyncing: false
});
} catch (error) {
console.error('[useGitSyncStatus] Error:', error);
setStatus((prev) => ({
...prev,
loading: false,
error: error instanceof Error ? error.message : 'Failed to get sync status'
}));
}
}, []);
const push = useCallback(async () => {
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
if (!projectDirectory) {
throw new Error('No project directory');
}
setStatus((prev) => ({ ...prev, isSyncing: true, error: null }));
try {
// Ensure git auth is set up
const projectId = ProjectModel.instance?.id || 'temp';
LocalProjectsModel.instance.setCurrentGlobalGitAuth(projectId);
const git = new Git(mergeProject);
await git.openRepository(projectDirectory);
// Check for uncommitted changes first
const changes = await git.status();
if (changes.length > 0) {
// Auto-commit changes before pushing
console.log('[useGitSyncStatus] Committing changes before push...');
await git.commit('Auto-commit before push');
}
console.log('[useGitSyncStatus] Pushing to remote...');
await git.push();
console.log('[useGitSyncStatus] Push successful');
// Refresh status
await fetchStatus();
} catch (error) {
console.error('[useGitSyncStatus] Push error:', error);
setStatus((prev) => ({
...prev,
isSyncing: false,
error: error instanceof Error ? error.message : 'Push failed'
}));
throw error;
}
}, [fetchStatus]);
const pull = useCallback(async () => {
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
if (!projectDirectory) {
throw new Error('No project directory');
}
setStatus((prev) => ({ ...prev, isSyncing: true, error: null }));
try {
// Ensure git auth is set up
const projectId = ProjectModel.instance?.id || 'temp';
LocalProjectsModel.instance.setCurrentGlobalGitAuth(projectId);
const git = new Git(mergeProject);
await git.openRepository(projectDirectory);
// Stash any uncommitted changes
const changes = await git.status();
const needsStash = changes.length > 0;
if (needsStash) {
console.log('[useGitSyncStatus] Stashing local changes...');
await git.stashPushChanges();
}
// Fetch and merge
console.log('[useGitSyncStatus] Fetching from remote...');
await git.fetch({ onProgress: () => {} });
// Get current branch
const branchName = await git.getCurrentBranchName();
const remoteBranch = `origin/${branchName}`;
console.log('[useGitSyncStatus] Merging', remoteBranch, 'into', branchName);
await git.mergeToCurrentBranch(remoteBranch, false);
// Pop stash if we stashed
if (needsStash) {
console.log('[useGitSyncStatus] Restoring stashed changes...');
await git.stashPopChanges();
}
console.log('[useGitSyncStatus] Pull successful');
// Refresh status
await fetchStatus();
// Notify project to reload
ProjectModel.instance?.notifyListeners('projectMightNeedRefresh');
} catch (error) {
console.error('[useGitSyncStatus] Pull error:', error);
setStatus((prev) => ({
...prev,
isSyncing: false,
error: error instanceof Error ? error.message : 'Pull failed'
}));
throw error;
}
}, [fetchStatus]);
// Initial fetch
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Auto-refresh on project changes
useEffect(() => {
const handleProjectChange = () => {
fetchStatus();
};
ProjectModel.instance?.on('projectSaved', handleProjectChange);
ProjectModel.instance?.on('remoteChanged', handleProjectChange);
return () => {
ProjectModel.instance?.off(handleProjectChange);
};
}, [fetchStatus]);
return {
...status,
push,
pull,
refresh: fetchStatus
};
}

View File

@@ -6,7 +6,7 @@
*/
import { useEventListener } from '@noodl-hooks/useEventListener';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { GitHubClient } from '../../../../services/github';
import type { GitHubIssue, GitHubIssueFilters } from '../../../../services/github/GitHubTypes';
@@ -43,6 +43,10 @@ export function useIssues({ owner, repo, filters = {}, enabled = true }: UseIssu
const client = GitHubClient.instance;
// Use ref to store filters to avoid infinite loops
const filtersRef = useRef(filters);
filtersRef.current = filters;
const fetchIssues = useCallback(
async (pageNum: number = 1, append: boolean = false) => {
if (!owner || !repo || !enabled) {
@@ -59,7 +63,7 @@ export function useIssues({ owner, repo, filters = {}, enabled = true }: UseIssu
}
const response = await client.listIssues(owner, repo, {
...filters,
...filtersRef.current,
per_page: DEFAULT_PER_PAGE,
page: pageNum
});
@@ -84,7 +88,7 @@ export function useIssues({ owner, repo, filters = {}, enabled = true }: UseIssu
setLoadingMore(false);
}
},
[owner, repo, enabled, filters, client]
[owner, repo, enabled, client]
);
const refetch = useCallback(async () => {
@@ -99,10 +103,16 @@ export function useIssues({ owner, repo, filters = {}, enabled = true }: UseIssu
}
}, [fetchIssues, page, hasMore, loadingMore]);
// Initial fetch
// Serialize filters to avoid infinite loops from object reference changes
const filtersKey = JSON.stringify(filters);
// Initial fetch - use serialized filters key to avoid infinite loop
// Note: refetch is excluded from deps to prevent loops, we use filtersKey instead
useEffect(() => {
refetch();
}, [owner, repo, filters, enabled]);
if (owner && repo && enabled) {
refetch();
}
}, [owner, repo, filtersKey, enabled, refetch]);
// Listen for cache invalidation events
useEventListener(client, 'rate-limit-updated', () => {

View File

@@ -6,7 +6,7 @@
*/
import { useEventListener } from '@noodl-hooks/useEventListener';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { GitHubClient } from '../../../../services/github';
import type { GitHubPullRequest, GitHubIssueFilters } from '../../../../services/github/GitHubTypes';
@@ -48,6 +48,10 @@ export function usePullRequests({
const client = GitHubClient.instance;
// Use ref to store filters to avoid infinite loops
const filtersRef = useRef(filters);
filtersRef.current = filters;
const fetchPullRequests = useCallback(
async (pageNum: number = 1, append: boolean = false) => {
if (!owner || !repo || !enabled) {
@@ -64,7 +68,7 @@ export function usePullRequests({
}
const response = await client.listPullRequests(owner, repo, {
...filters,
...filtersRef.current,
per_page: DEFAULT_PER_PAGE,
page: pageNum
});
@@ -89,7 +93,7 @@ export function usePullRequests({
setLoadingMore(false);
}
},
[owner, repo, enabled, filters, client]
[owner, repo, enabled, client]
);
const refetch = useCallback(async () => {
@@ -104,10 +108,15 @@ export function usePullRequests({
}
}, [fetchPullRequests, page, hasMore, loadingMore]);
// Initial fetch
// Serialize filters to avoid infinite loops
const filtersKey = JSON.stringify(filters);
// Initial fetch - use serialized filters key
useEffect(() => {
refetch();
}, [owner, repo, filters, enabled]);
if (owner && repo && enabled) {
refetch();
}
}, [owner, repo, filtersKey, enabled, refetch]);
// Listen for cache invalidation events
useEventListener(client, 'rate-limit-updated', () => {

View File

@@ -1,3 +1,4 @@
import { useEventListener } from '@noodl-hooks/useEventListener';
import React, { useState, useEffect } from 'react';
import { GitProvider } from '@noodl/git';
@@ -7,7 +8,7 @@ import { TextInput, TextInputVariant } from '@noodl-core-ui/components/inputs/Te
import { Section, SectionVariant } from '@noodl-core-ui/components/sidebar/Section';
import { Text } from '@noodl-core-ui/components/typography/Text';
import { GitHubAuth, type GitHubAuthState } from '../../../../../../services/github';
import { GitHubOAuthService } from '../../../../../../services/github';
type CredentialsSectionProps = {
provider: GitProvider;
@@ -29,43 +30,75 @@ export function CredentialsSection({
const [hidePassword, setHidePassword] = useState(true);
// OAuth state management
const [authState, setAuthState] = useState<GitHubAuthState>(GitHubAuth.getAuthState());
// OAuth state management using GitHubOAuthService
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [authenticatedUsername, setAuthenticatedUsername] = useState<string | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [progressMessage, setProgressMessage] = useState<string>('');
const [error, setError] = useState<string | null>(null);
// Check auth state on mount
const oauthService = GitHubOAuthService.instance;
// Initialize OAuth service on mount
useEffect(() => {
if (provider === 'github') {
setAuthState(GitHubAuth.getAuthState());
console.log('🔧 [CredentialsSection] Initializing GitHubOAuthService...');
oauthService.initialize().then(() => {
setIsAuthenticated(oauthService.isAuthenticated());
const user = oauthService.getCurrentUser();
setAuthenticatedUsername(user?.login || null);
console.log('🔧 [CredentialsSection] Auth state:', oauthService.isAuthenticated(), user?.login);
});
}
}, [provider]);
}, [provider, oauthService]);
// Listen for auth state changes
useEventListener(oauthService, 'auth-state-changed', (event: { authenticated: boolean }) => {
console.log('🔔 [CredentialsSection] Auth state changed:', event.authenticated);
setIsAuthenticated(event.authenticated);
if (event.authenticated) {
const user = oauthService.getCurrentUser();
setAuthenticatedUsername(user?.login || null);
} else {
setAuthenticatedUsername(null);
}
});
const handleConnect = async () => {
console.log('🔘 [CredentialsSection] handleConnect called - button clicked!');
setIsConnecting(true);
setError(null);
setProgressMessage('Initiating GitHub authentication...');
setProgressMessage('Opening GitHub in your browser...');
try {
await GitHubAuth.startWebOAuthFlow((message) => {
setProgressMessage(message);
});
console.log('🔐 [CredentialsSection] Calling GitHubOAuthService.initiateOAuth...');
await oauthService.initiateOAuth();
// Update state after successful auth
setAuthState(GitHubAuth.getAuthState());
setProgressMessage('');
console.log('✅ [CredentialsSection] OAuth flow initiated');
// State will be updated via event listener when auth completes
setProgressMessage('Waiting for authorization...');
} catch (err) {
console.error('❌ [CredentialsSection] OAuth flow error:', err);
setError(err instanceof Error ? err.message : 'Authentication failed');
setProgressMessage('');
} finally {
setIsConnecting(false);
}
};
const handleDisconnect = () => {
GitHubAuth.disconnect();
setAuthState(GitHubAuth.getAuthState());
// Listen for auth success to clear connecting state
useEventListener(oauthService, 'oauth-success', () => {
setIsConnecting(false);
setProgressMessage('');
});
useEventListener(oauthService, 'oauth-error', (event: { error: string }) => {
setIsConnecting(false);
setError(event.error);
setProgressMessage('');
});
const handleDisconnect = async () => {
await oauthService.disconnect();
setError(null);
};
@@ -74,11 +107,11 @@ export function CredentialsSection({
{/* OAuth Section - GitHub Only */}
{provider === 'github' && (
<Section title="GitHub Account (Recommended)" variant={SectionVariant.InModal} hasGutter>
{authState.isAuthenticated ? (
{isAuthenticated ? (
// Connected state
<>
<Text hasBottomSpacing>
Connected as <strong>{authState.username}</strong>
Connected as <strong>{authenticatedUsername}</strong>
</Text>
<Text hasBottomSpacing>Your GitHub account is connected and will be used for all Git operations.</Text>
<TextButton label="Disconnect GitHub Account" onClick={handleDisconnect} />

View File

@@ -15,8 +15,8 @@ const { ipcMain, BrowserWindow } = require('electron');
* GitHub OAuth credentials
* Uses existing credentials from GitHubOAuthService
*/
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Iv23lib1WdrimUdyvZui';
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || '9bd56694d6d300bf86b1999bab523b32654ec375';
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Ov23li2n9u3dwAhwoifb';
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || 'c45276fa80b0618de06e5e2b09c1019ca150baef';
/**
* Custom protocol for OAuth callback
@@ -217,6 +217,7 @@ class GitHubOAuthCallbackHandler {
/**
* Send success to renderer process
* Broadcasts to ALL windows since the editor might not be windows[0]
*/
sendSuccessToRenderer(result) {
console.log('📤 [GitHub OAuth] ========================================');
@@ -227,8 +228,15 @@ class GitHubOAuthCallbackHandler {
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send('github-oauth-complete', result);
console.log('✅ [GitHub OAuth] IPC event sent to renderer');
// Broadcast to ALL windows - the one with the listener will handle it
windows.forEach((win, index) => {
try {
win.webContents.send('github-oauth-complete', result);
console.log(`✅ [GitHub OAuth] IPC event sent to window ${index}`);
} catch (err) {
console.error(`❌ [GitHub OAuth] Failed to send to window ${index}:`, err.message);
}
});
} else {
console.error('❌ [GitHub OAuth] No windows available to send IPC event!');
}
@@ -236,13 +244,20 @@ class GitHubOAuthCallbackHandler {
/**
* Send error to renderer process
* Broadcasts to ALL windows since the editor might not be windows[0]
*/
sendErrorToRenderer(error, description) {
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send('github-oauth-error', {
error,
message: description || error
windows.forEach((win) => {
try {
win.webContents.send('github-oauth-error', {
error,
message: description || error
});
} catch (err) {
// Ignore errors for windows that can't receive messages
}
});
}
}

View File

@@ -671,7 +671,13 @@ function launchApp() {
// Load GitHub token
ipcMain.handle('github-load-token', async (event) => {
try {
const stored = jsonstorage.getSync('github.token');
// Use Promise wrapper for callback-based jsonstorage.get
const stored = await new Promise((resolve) => {
jsonstorage.get('github.token', (data) => {
resolve(data);
});
});
if (!stored) return null;
if (safeStorage.isEncryptionAvailable()) {