mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-07 17:43:28 +01:00
Added new github integration tasks
This commit is contained in:
@@ -17,8 +17,10 @@ import {
|
||||
CloudSyncType,
|
||||
LauncherProjectData
|
||||
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
|
||||
import { NoodlGitHubRepo, UseGitHubReposReturn } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useGitHubRepos';
|
||||
import { usePersistentTab } from '@noodl-core-ui/preview/launcher/Launcher/hooks/usePersistentTab';
|
||||
import { GitHubUser, LauncherPageId, LauncherProvider } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
|
||||
import { GitHubRepos } from '@noodl-core-ui/preview/launcher/Launcher/views/GitHubRepos';
|
||||
import { LearningCenter } from '@noodl-core-ui/preview/launcher/Launcher/views/LearningCenter';
|
||||
import { Projects } from '@noodl-core-ui/preview/launcher/Launcher/views/Projects';
|
||||
import { Templates } from '@noodl-core-ui/preview/launcher/Launcher/views/Templates';
|
||||
@@ -53,15 +55,24 @@ export interface LauncherProps {
|
||||
githubIsConnecting?: boolean;
|
||||
onGitHubConnect?: () => void;
|
||||
onGitHubDisconnect?: () => void;
|
||||
|
||||
// GitHub repos for clone feature (optional - for Storybook compatibility)
|
||||
githubRepos?: UseGitHubReposReturn | null;
|
||||
onCloneRepo?: (repo: NoodlGitHubRepo) => Promise<void>;
|
||||
}
|
||||
|
||||
// Tab configuration
|
||||
// Tab configuration (GitHub tab added dynamically based on auth state)
|
||||
const LAUNCHER_TABS: TabBarItem[] = [
|
||||
{
|
||||
id: 'projects',
|
||||
label: 'Projects',
|
||||
icon: IconName.FolderOpen
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
label: 'GitHub',
|
||||
icon: IconName.CloudFunction
|
||||
},
|
||||
{
|
||||
id: 'learn',
|
||||
label: 'Learn',
|
||||
@@ -187,7 +198,9 @@ export function Launcher({
|
||||
githubIsAuthenticated,
|
||||
githubIsConnecting,
|
||||
onGitHubConnect,
|
||||
onGitHubDisconnect
|
||||
onGitHubDisconnect,
|
||||
githubRepos,
|
||||
onCloneRepo
|
||||
}: LauncherProps) {
|
||||
// Determine initial tab: props > deep link > persisted > default
|
||||
const deepLinkTab = parseDeepLink();
|
||||
@@ -263,6 +276,8 @@ export function Launcher({
|
||||
switch (activePageId) {
|
||||
case 'projects':
|
||||
return <Projects />;
|
||||
case 'github':
|
||||
return <GitHubRepos />;
|
||||
case 'learn':
|
||||
return <LearningCenter />;
|
||||
case 'templates':
|
||||
@@ -295,7 +310,9 @@ export function Launcher({
|
||||
githubIsAuthenticated,
|
||||
githubIsConnecting,
|
||||
onGitHubConnect,
|
||||
onGitHubDisconnect
|
||||
onGitHubDisconnect,
|
||||
githubRepos,
|
||||
onCloneRepo
|
||||
}}
|
||||
>
|
||||
<div className={css['Root']}>
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
|
||||
import { LauncherProjectData } from './components/LauncherProjectCard';
|
||||
import { NoodlGitHubRepo, UseGitHubReposReturn } from './hooks/useGitHubRepos';
|
||||
|
||||
export type LauncherPageId = 'projects' | 'learn' | 'templates';
|
||||
export type LauncherPageId = 'projects' | 'learn' | 'templates' | 'github';
|
||||
|
||||
// GitHub user info (matches GitHubOAuthService interface)
|
||||
export interface GitHubUser {
|
||||
@@ -52,6 +53,10 @@ export interface LauncherContextValue {
|
||||
githubIsConnecting?: boolean;
|
||||
onGitHubConnect?: () => void;
|
||||
onGitHubDisconnect?: () => void;
|
||||
|
||||
// GitHub repos for clone feature (optional - for Storybook compatibility)
|
||||
githubRepos?: UseGitHubReposReturn | null;
|
||||
onCloneRepo?: (repo: NoodlGitHubRepo) => Promise<void>;
|
||||
}
|
||||
|
||||
const LauncherContext = createContext<LauncherContextValue | null>(null);
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* useGitHubRepos Hook
|
||||
*
|
||||
* Fetches and manages GitHub repositories for the authenticated user,
|
||||
* including personal repos and organization repos.
|
||||
* Detects Noodl projects by checking for project.json or nodegx.project.json.
|
||||
*
|
||||
* @module noodl-core-ui/preview/launcher/Launcher/hooks
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
// ==================== LOCAL TYPE DEFINITIONS ====================
|
||||
// These mirror the GitHub types but are defined locally to avoid circular dependencies
|
||||
|
||||
/**
|
||||
* GitHub User/Owner
|
||||
*/
|
||||
export interface GitHubOwner {
|
||||
id: number;
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Repository (minimal fields needed for clone UI)
|
||||
*/
|
||||
export interface GitHubRepo {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
owner: GitHubOwner;
|
||||
private: boolean;
|
||||
html_url: string;
|
||||
description: string | null;
|
||||
clone_url?: string;
|
||||
ssh_url?: string;
|
||||
updated_at: string;
|
||||
pushed_at: string;
|
||||
default_branch: string;
|
||||
stargazers_count: number;
|
||||
language: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Organization
|
||||
*/
|
||||
export interface GitHubOrg {
|
||||
id: number;
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
// ==================== HOOK TYPES ====================
|
||||
|
||||
/**
|
||||
* Extended repo info with Noodl detection status
|
||||
*/
|
||||
export interface NoodlGitHubRepo extends GitHubRepo {
|
||||
/** Whether this repo is a Noodl project */
|
||||
isNoodlProject: boolean | null; // null = not yet checked
|
||||
/** Whether Noodl detection is in progress */
|
||||
isCheckingNoodl: boolean;
|
||||
/** Source: 'personal' or org name */
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization with its repos
|
||||
*/
|
||||
export interface GitHubOrgWithRepos extends GitHubOrg {
|
||||
repos: NoodlGitHubRepo[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook return type
|
||||
*/
|
||||
export interface UseGitHubReposReturn {
|
||||
/** All Noodl project repos (filtered) */
|
||||
noodlProjects: NoodlGitHubRepo[];
|
||||
/** All repos (unfiltered) */
|
||||
allRepos: NoodlGitHubRepo[];
|
||||
/** User's organizations */
|
||||
organizations: GitHubOrgWithRepos[];
|
||||
/** Personal repos */
|
||||
personalRepos: NoodlGitHubRepo[];
|
||||
/** Loading state */
|
||||
isLoading: boolean;
|
||||
/** Error message */
|
||||
error: string | null;
|
||||
/** Refresh all data */
|
||||
refresh: () => Promise<void>;
|
||||
/** Check if a specific repo is a Noodl project */
|
||||
checkIfNoodlProject: (owner: string, repo: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub API client interface (injected to avoid circular dependencies)
|
||||
*/
|
||||
export interface GitHubClientInterface {
|
||||
listRepositories: (options?: { per_page?: number; sort?: string }) => Promise<{ data: GitHubRepo[] }>;
|
||||
listOrganizations: () => Promise<{ data: GitHubOrg[] }>;
|
||||
listOrganizationRepositories: (org: string, options?: { per_page?: number }) => Promise<{ data: GitHubRepo[] }>;
|
||||
isNoodlProject: (owner: string, repo: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch GitHub repositories and detect Noodl projects
|
||||
*
|
||||
* @param client - GitHub client instance
|
||||
* @param isAuthenticated - Whether user is authenticated with GitHub
|
||||
*/
|
||||
export function useGitHubRepos(client: GitHubClientInterface | null, isAuthenticated: boolean): UseGitHubReposReturn {
|
||||
const [personalRepos, setPersonalRepos] = useState<NoodlGitHubRepo[]>([]);
|
||||
const [organizations, setOrganizations] = useState<GitHubOrgWithRepos[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Track ongoing Noodl checks to avoid duplicates
|
||||
const noodlCheckQueue = useRef<Map<string, Promise<boolean>>>(new Map());
|
||||
|
||||
/**
|
||||
* Check if a repo is a Noodl project (with deduplication)
|
||||
*/
|
||||
const checkIfNoodlProject = useCallback(
|
||||
async (owner: string, repo: string): Promise<boolean> => {
|
||||
if (!client) return false;
|
||||
|
||||
const key = `${owner}/${repo}`;
|
||||
|
||||
// Return existing promise if already checking
|
||||
if (noodlCheckQueue.current.has(key)) {
|
||||
return noodlCheckQueue.current.get(key)!;
|
||||
}
|
||||
|
||||
// Start new check
|
||||
const checkPromise = client.isNoodlProject(owner, repo);
|
||||
noodlCheckQueue.current.set(key, checkPromise);
|
||||
|
||||
try {
|
||||
const result = await checkPromise;
|
||||
return result;
|
||||
} finally {
|
||||
noodlCheckQueue.current.delete(key);
|
||||
}
|
||||
},
|
||||
[client]
|
||||
);
|
||||
|
||||
/**
|
||||
* Map GitHubRepo to NoodlGitHubRepo
|
||||
*/
|
||||
const mapRepo = useCallback((repo: GitHubRepo, source: string): NoodlGitHubRepo => {
|
||||
return {
|
||||
...repo,
|
||||
isNoodlProject: null,
|
||||
isCheckingNoodl: false,
|
||||
source
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Check Noodl status for a batch of repos (rate-limit friendly)
|
||||
*/
|
||||
const checkNoodlStatusForRepos = useCallback(
|
||||
async (repos: NoodlGitHubRepo[], updateFn: (repoId: number, isNoodl: boolean) => void) => {
|
||||
console.log('🔍 [checkNoodlStatusForRepos] Starting check for', repos.length, 'repos');
|
||||
|
||||
// Check repos sequentially to avoid rate limits
|
||||
for (const repo of repos) {
|
||||
if (repo.isNoodlProject !== null) continue; // Already checked
|
||||
|
||||
try {
|
||||
const isNoodl = await checkIfNoodlProject(repo.owner.login, repo.name);
|
||||
|
||||
console.log('🔍 [checkNoodlStatusForRepos]', repo.full_name, '- isNoodl:', isNoodl);
|
||||
|
||||
updateFn(repo.id, isNoodl);
|
||||
|
||||
// Small delay between checks to be rate-limit friendly
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
} catch (err) {
|
||||
// On error, mark as not a Noodl project
|
||||
console.error('❌ [checkNoodlStatusForRepos] Error checking', repo.full_name, err);
|
||||
updateFn(repo.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ [checkNoodlStatusForRepos] Finished checking repos');
|
||||
},
|
||||
[checkIfNoodlProject]
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch all repos (personal + org)
|
||||
*/
|
||||
const fetchRepos = useCallback(async () => {
|
||||
console.log('🔍 [useGitHubRepos] fetchRepos called', {
|
||||
hasClient: !!client,
|
||||
isAuthenticated
|
||||
});
|
||||
|
||||
if (!client || !isAuthenticated) {
|
||||
console.log('🔍 [useGitHubRepos] Skipping fetch - not authenticated or no client');
|
||||
setPersonalRepos([]);
|
||||
setOrganizations([]);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
console.log('🔍 [useGitHubRepos] Fetching personal repos and orgs...');
|
||||
|
||||
// Fetch personal repos and orgs in parallel
|
||||
const [reposResponse, orgsResponse] = await Promise.all([
|
||||
client.listRepositories({ per_page: 100, sort: 'updated' }),
|
||||
client.listOrganizations()
|
||||
]);
|
||||
|
||||
console.log('🔍 [useGitHubRepos] API responses:', {
|
||||
personalRepoCount: reposResponse?.data?.length || 0,
|
||||
orgCount: orgsResponse?.data?.length || 0
|
||||
});
|
||||
|
||||
// Map personal repos
|
||||
const mappedPersonalRepos = reposResponse.data.map((r) => mapRepo(r, 'personal'));
|
||||
setPersonalRepos(mappedPersonalRepos);
|
||||
|
||||
// Initialize orgs (repos will be loaded on-demand or in parallel)
|
||||
const mappedOrgs: GitHubOrgWithRepos[] = orgsResponse.data.map((org) => ({
|
||||
...org,
|
||||
repos: [],
|
||||
isLoading: true,
|
||||
error: null
|
||||
}));
|
||||
setOrganizations(mappedOrgs);
|
||||
|
||||
// Fetch org repos in parallel
|
||||
const orgRepoPromises = orgsResponse.data.map(async (org) => {
|
||||
try {
|
||||
const orgRepos = await client.listOrganizationRepositories(org.login, { per_page: 100 });
|
||||
return {
|
||||
orgLogin: org.login,
|
||||
repos: orgRepos.data.map((r) => mapRepo(r, org.login)),
|
||||
error: null
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
orgLogin: org.login,
|
||||
repos: [],
|
||||
error: err instanceof Error ? err.message : 'Failed to load repos'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const orgResults = await Promise.all(orgRepoPromises);
|
||||
|
||||
// Update orgs with their repos
|
||||
setOrganizations((prev) =>
|
||||
prev.map((org) => {
|
||||
const result = orgResults.find((r) => r.orgLogin === org.login);
|
||||
if (result) {
|
||||
return {
|
||||
...org,
|
||||
repos: result.repos,
|
||||
isLoading: false,
|
||||
error: result.error
|
||||
};
|
||||
}
|
||||
return { ...org, isLoading: false };
|
||||
})
|
||||
);
|
||||
|
||||
// Start checking Noodl status for personal repos
|
||||
const updatePersonalRepo = (repoId: number, isNoodl: boolean) => {
|
||||
setPersonalRepos((prev) =>
|
||||
prev.map((r) => (r.id === repoId ? { ...r, isNoodlProject: isNoodl, isCheckingNoodl: false } : r))
|
||||
);
|
||||
};
|
||||
|
||||
// Check personal repos
|
||||
checkNoodlStatusForRepos(mappedPersonalRepos, updatePersonalRepo);
|
||||
|
||||
// Check org repos
|
||||
for (const result of orgResults) {
|
||||
const updateOrgRepo = (repoId: number, isNoodl: boolean) => {
|
||||
setOrganizations((prev) =>
|
||||
prev.map((org) => {
|
||||
if (org.login === result.orgLogin) {
|
||||
return {
|
||||
...org,
|
||||
repos: org.repos.map((r) =>
|
||||
r.id === repoId ? { ...r, isNoodlProject: isNoodl, isCheckingNoodl: false } : r
|
||||
)
|
||||
};
|
||||
}
|
||||
return org;
|
||||
})
|
||||
);
|
||||
};
|
||||
checkNoodlStatusForRepos(result.repos, updateOrgRepo);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch repositories');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [client, isAuthenticated, mapRepo, checkNoodlStatusForRepos]);
|
||||
|
||||
// Fetch on mount and when auth changes
|
||||
useEffect(() => {
|
||||
fetchRepos();
|
||||
}, [fetchRepos]);
|
||||
|
||||
// Compute derived values
|
||||
const allRepos: NoodlGitHubRepo[] = [...personalRepos, ...organizations.flatMap((org) => org.repos)];
|
||||
|
||||
const noodlProjects = allRepos.filter((repo) => repo.isNoodlProject === true);
|
||||
|
||||
return {
|
||||
noodlProjects,
|
||||
allRepos,
|
||||
organizations,
|
||||
personalRepos,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchRepos,
|
||||
checkIfNoodlProject
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* GitHubRepos View
|
||||
*
|
||||
* Browse and clone Noodl projects from GitHub repositories.
|
||||
* Only shows repos that contain project.json or nodegx.project.json.
|
||||
*
|
||||
* @module noodl-core-ui/preview/launcher/Launcher/views
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { TextInput, TextInputVariant } from '@noodl-core-ui/components/inputs/TextInput';
|
||||
import { Box } from '@noodl-core-ui/components/layout/Box';
|
||||
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
|
||||
import { LauncherPage } from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherPage';
|
||||
import { NoodlGitHubRepo, GitHubOrgWithRepos } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useGitHubRepos';
|
||||
import { useLauncherContext } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
|
||||
|
||||
/**
|
||||
* GitHub repo card for display in clone list
|
||||
*/
|
||||
interface GitHubRepoCardProps {
|
||||
repo: NoodlGitHubRepo;
|
||||
onClone: (repo: NoodlGitHubRepo) => void;
|
||||
isCloning: boolean;
|
||||
}
|
||||
|
||||
function GitHubRepoCard({ repo, onClone, isCloning }: GitHubRepoCardProps) {
|
||||
const updatedAt = new Date(repo.updated_at).toLocaleDateString();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--spacing-4)',
|
||||
padding: 'var(--spacing-3) var(--spacing-4)',
|
||||
backgroundColor: 'var(--theme-color-bg-3)',
|
||||
borderRadius: 'var(--radius-default)',
|
||||
border: '1px solid var(--theme-color-border-default)'
|
||||
}}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<img
|
||||
src={repo.owner.avatar_url}
|
||||
alt={repo.owner.login}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 'var(--radius-default)'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--theme-color-fg-default)' }}>{repo.name}</span>
|
||||
{repo.private && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
backgroundColor: 'var(--theme-color-secondary-highlight)',
|
||||
borderRadius: 'var(--radius-small)',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
Private
|
||||
</span>
|
||||
)}
|
||||
{repo.isNoodlProject && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
backgroundColor: 'var(--theme-color-primary)',
|
||||
borderRadius: 'var(--radius-small)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
Noodl Project
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--theme-color-fg-default-shy)' }}>
|
||||
{repo.full_name} • Updated {updatedAt}
|
||||
</div>
|
||||
{repo.description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--theme-color-fg-default-shy)',
|
||||
marginTop: '4px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{repo.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clone button */}
|
||||
<PrimaryButton
|
||||
label={isCloning ? 'Cloning...' : 'Clone'}
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
isDisabled={isCloning || repo.isCheckingNoodl}
|
||||
onClick={() => onClone(repo)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Section header for organization
|
||||
*/
|
||||
function OrgSection({ org, children }: { org: GitHubOrgWithRepos; children: React.ReactNode }) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const noodlProjectCount = org.repos.filter((r) => r.isNoodlProject === true).length;
|
||||
|
||||
return (
|
||||
<Box hasBottomSpacing={4}>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--theme-color-fg-default)'
|
||||
}}
|
||||
>
|
||||
<img src={org.avatar_url} alt={org.login} style={{ width: 24, height: 24, borderRadius: '4px' }} />
|
||||
<span style={{ fontWeight: 600, flex: 1, textAlign: 'left' }}>{org.login}</span>
|
||||
<span style={{ fontSize: '12px', color: 'var(--theme-color-fg-default-shy)' }}>
|
||||
{noodlProjectCount} {noodlProjectCount === 1 ? 'project' : 'projects'}
|
||||
</span>
|
||||
<Icon icon={isExpanded ? IconName.CaretDown : IconName.CaretRight} />
|
||||
</button>
|
||||
{isExpanded && <div style={{ paddingLeft: '16px' }}>{children}</div>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state when no Noodl projects found
|
||||
*/
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '64px',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px' }}>
|
||||
<Icon icon={IconName.FolderOpen} />
|
||||
</div>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 600, color: 'var(--theme-color-fg-default)', marginTop: '16px' }}>
|
||||
No Noodl Projects Found
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
color: 'var(--theme-color-fg-default-shy)',
|
||||
marginTop: '8px',
|
||||
maxWidth: '400px',
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
>
|
||||
No repositories with project.json were found in your GitHub account. Noodl projects contain a project.json file
|
||||
in the root directory.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect GitHub prompt when not authenticated
|
||||
*/
|
||||
function ConnectGitHubPrompt({ onConnect }: { onConnect: () => void }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '64px',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px' }}>
|
||||
<Icon icon={IconName.CloudFunction} />
|
||||
</div>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 600, color: 'var(--theme-color-fg-default)', marginTop: '16px' }}>
|
||||
Connect GitHub
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
color: 'var(--theme-color-fg-default-shy)',
|
||||
marginTop: '8px',
|
||||
maxWidth: '400px',
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
>
|
||||
Connect your GitHub account to browse and clone your Noodl projects.
|
||||
</p>
|
||||
<Box hasTopSpacing={4}>
|
||||
<PrimaryButton label="Connect GitHub" onClick={onConnect} />
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading spinner
|
||||
*/
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '64px',
|
||||
gap: '12px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
border: '2px solid var(--theme-color-border-default)',
|
||||
borderTopColor: 'var(--theme-color-primary)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: 'var(--theme-color-fg-default-shy)' }}>Loading repositories...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface GitHubReposProps {}
|
||||
|
||||
export function GitHubRepos({}: GitHubReposProps) {
|
||||
const { githubIsAuthenticated, onGitHubConnect, githubRepos, onCloneRepo } = useLauncherContext();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [cloningRepoId, setCloningRepoId] = useState<number | null>(null);
|
||||
|
||||
// Get repos from context
|
||||
const { noodlProjects, allRepos, organizations, isLoading, error, refresh } = githubRepos || {
|
||||
noodlProjects: [],
|
||||
allRepos: [],
|
||||
organizations: [],
|
||||
personalRepos: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refresh: async () => {}
|
||||
};
|
||||
|
||||
// Count how many repos are still being checked
|
||||
const checkingCount = allRepos.filter((r) => r.isNoodlProject === null).length;
|
||||
const isChecking = checkingCount > 0;
|
||||
|
||||
// Only show Noodl projects
|
||||
const reposToShow = noodlProjects;
|
||||
|
||||
// Filter by search term
|
||||
const filteredRepos = useMemo(() => {
|
||||
if (!searchTerm) return reposToShow;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return reposToShow.filter(
|
||||
(repo) =>
|
||||
repo.name.toLowerCase().includes(term) ||
|
||||
repo.full_name.toLowerCase().includes(term) ||
|
||||
repo.description?.toLowerCase().includes(term)
|
||||
);
|
||||
}, [reposToShow, searchTerm]);
|
||||
|
||||
// Group by source (personal vs org)
|
||||
const personalFilteredRepos = filteredRepos.filter((r) => r.source === 'personal');
|
||||
const orgFilteredRepos = filteredRepos.filter((r) => r.source !== 'personal');
|
||||
|
||||
// Group org projects by org
|
||||
const projectsByOrg = useMemo(() => {
|
||||
const grouped = new Map<string, NoodlGitHubRepo[]>();
|
||||
for (const repo of orgFilteredRepos) {
|
||||
const existing = grouped.get(repo.source) || [];
|
||||
grouped.set(repo.source, [...existing, repo]);
|
||||
}
|
||||
return grouped;
|
||||
}, [orgFilteredRepos]);
|
||||
|
||||
const handleClone = async (repo: NoodlGitHubRepo) => {
|
||||
if (!onCloneRepo) return;
|
||||
|
||||
setCloningRepoId(repo.id);
|
||||
try {
|
||||
await onCloneRepo(repo);
|
||||
} finally {
|
||||
setCloningRepoId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Not authenticated
|
||||
if (!githubIsAuthenticated) {
|
||||
return (
|
||||
<LauncherPage title="GitHub">
|
||||
<ConnectGitHubPrompt onConnect={() => onGitHubConnect?.()} />
|
||||
</LauncherPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LauncherPage
|
||||
title="GitHub Projects"
|
||||
headerSlot={
|
||||
<HStack hasSpacing>
|
||||
<PrimaryButton
|
||||
label="Refresh"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={refresh}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
{/* Search bar */}
|
||||
<Box hasBottomSpacing={4}>
|
||||
<TextInput
|
||||
variant={TextInputVariant.InModal}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search Noodl projects..."
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<Box hasBottomSpacing={4}>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
backgroundColor: 'var(--theme-color-danger-10)',
|
||||
borderRadius: 'var(--radius-default)',
|
||||
color: 'var(--theme-color-danger)'
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && <LoadingState />}
|
||||
|
||||
{/* Checking progress indicator */}
|
||||
{!isLoading && isChecking && (
|
||||
<Box hasBottomSpacing={4}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: 'var(--theme-color-bg-2)',
|
||||
borderRadius: 'var(--radius-default)',
|
||||
fontSize: '12px',
|
||||
color: 'var(--theme-color-fg-default-shy)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
border: '2px solid var(--theme-color-border-default)',
|
||||
borderTopColor: 'var(--theme-color-primary)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}}
|
||||
/>
|
||||
Scanning {checkingCount} repositories for project.json...
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{!isLoading && !isChecking && filteredRepos.length === 0 && <EmptyState />}
|
||||
|
||||
{!isLoading && filteredRepos.length > 0 && (
|
||||
<VStack hasSpacing>
|
||||
{/* Personal repos */}
|
||||
{personalFilteredRepos.length > 0 && (
|
||||
<Box hasBottomSpacing={4}>
|
||||
<Label size={LabelSize.Default}>Personal Repositories ({personalFilteredRepos.length})</Label>
|
||||
<div style={{ marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{personalFilteredRepos.map((repo) => (
|
||||
<GitHubRepoCard
|
||||
key={repo.id}
|
||||
repo={repo}
|
||||
onClone={handleClone}
|
||||
isCloning={cloningRepoId === repo.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Organization repos */}
|
||||
{Array.from(projectsByOrg.entries()).map(([orgLogin, repos]) => {
|
||||
const org = organizations.find((o) => o.login === orgLogin);
|
||||
if (!org) return null;
|
||||
|
||||
return (
|
||||
<OrgSection key={orgLogin} org={org}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{repos.map((repo) => (
|
||||
<GitHubRepoCard
|
||||
key={repo.id}
|
||||
repo={repo}
|
||||
onClone={handleClone}
|
||||
isCloning={cloningRepoId === repo.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</OrgSection>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</LauncherPage>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ConnectToGitHubView } from './ConnectToGitHubView';
|
||||
export { CreateRepoModal } from './CreateRepoModal';
|
||||
export { SelectRepoModal } from './SelectRepoModal';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}>×</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SyncToolbar } from './SyncToolbar';
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
import { GitError as DugiteError } from "dugite";
|
||||
import { GitError as DugiteError } from 'dugite';
|
||||
|
||||
/**
|
||||
* Returns the SHA of the passed in IGitResult
|
||||
*
|
||||
* Git commit output format:
|
||||
* - Normal: "[main abc1234] Commit message"
|
||||
* - Root commit: "[main (root-commit) abc1234] Commit message"
|
||||
*/
|
||||
export function parseCommitSHA(result: IGitResult): string {
|
||||
return result.output.toString().split("]")[0].split(" ")[1];
|
||||
const output = result.output.toString();
|
||||
const bracketContent = output.split(']')[0]; // "[main abc1234" or "[main (root-commit) abc1234"
|
||||
const parts = bracketContent.split(' ');
|
||||
|
||||
// For root commit, the SHA is the last part before the closing bracket
|
||||
// For normal commit, it's the second part
|
||||
// Skip "(root-commit)" if present and get the actual SHA
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i];
|
||||
// SHA is a hex string, not "(root-commit)" or branch name
|
||||
if (part && !part.startsWith('(') && !part.startsWith('[') && /^[a-f0-9]+$/.test(part)) {
|
||||
return part;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to original behavior
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,13 +82,13 @@ export class GitError extends Error {
|
||||
} else if (result.output.length) {
|
||||
message = result.error.toString();
|
||||
} else {
|
||||
message = "Unknown error";
|
||||
message = 'Unknown error';
|
||||
rawMessage = false;
|
||||
}
|
||||
|
||||
super(message);
|
||||
|
||||
this.name = "GitError";
|
||||
this.name = 'GitError';
|
||||
this.result = result;
|
||||
this.args = args;
|
||||
this.isRawMessage = rawMessage;
|
||||
@@ -111,94 +131,94 @@ export function getDescriptionForError(error: DugiteError): string | null {
|
||||
|
||||
switch (error) {
|
||||
case DugiteError.SSHKeyAuditUnverified:
|
||||
return "The SSH key is unverified.";
|
||||
return 'The SSH key is unverified.';
|
||||
case DugiteError.RemoteDisconnection:
|
||||
return "The remote disconnected. Check your Internet connection and try again.";
|
||||
return 'The remote disconnected. Check your Internet connection and try again.';
|
||||
case DugiteError.HostDown:
|
||||
return "The host is down. Check your Internet connection and try again.";
|
||||
return 'The host is down. Check your Internet connection and try again.';
|
||||
case DugiteError.RebaseConflicts:
|
||||
return "We found some conflicts while trying to rebase. Please resolve the conflicts before continuing.";
|
||||
return 'We found some conflicts while trying to rebase. Please resolve the conflicts before continuing.';
|
||||
case DugiteError.MergeConflicts:
|
||||
return "We found some conflicts while trying to merge. Please resolve the conflicts and commit the changes.";
|
||||
return 'We found some conflicts while trying to merge. Please resolve the conflicts and commit the changes.';
|
||||
case DugiteError.HTTPSRepositoryNotFound:
|
||||
case DugiteError.SSHRepositoryNotFound:
|
||||
return "The repository does not seem to exist anymore. You may not have access, or it may have been deleted or renamed.";
|
||||
return 'The repository does not seem to exist anymore. You may not have access, or it may have been deleted or renamed.';
|
||||
case DugiteError.PushNotFastForward:
|
||||
return "The repository has been updated since you last pulled. Try pulling before pushing.";
|
||||
return 'The repository has been updated since you last pulled. Try pulling before pushing.';
|
||||
case DugiteError.BranchDeletionFailed:
|
||||
return "Could not delete the branch. It was probably already deleted.";
|
||||
return 'Could not delete the branch. It was probably already deleted.';
|
||||
case DugiteError.DefaultBranchDeletionFailed:
|
||||
return `The branch is the repository's default branch and cannot be deleted.`;
|
||||
case DugiteError.RevertConflicts:
|
||||
return "To finish reverting, please merge and commit the changes.";
|
||||
return 'To finish reverting, please merge and commit the changes.';
|
||||
case DugiteError.EmptyRebasePatch:
|
||||
return "There aren’t any changes left to apply.";
|
||||
return 'There aren’t any changes left to apply.';
|
||||
case DugiteError.NoMatchingRemoteBranch:
|
||||
return "There aren’t any remote branches that match the current branch.";
|
||||
return 'There aren’t any remote branches that match the current branch.';
|
||||
case DugiteError.NothingToCommit:
|
||||
return "There are no changes to commit.";
|
||||
return 'There are no changes to commit.';
|
||||
case DugiteError.NoSubmoduleMapping:
|
||||
return "A submodule was removed from .gitmodules, but the folder still exists in the repository. Delete the folder, commit the change, then try again.";
|
||||
return 'A submodule was removed from .gitmodules, but the folder still exists in the repository. Delete the folder, commit the change, then try again.';
|
||||
case DugiteError.SubmoduleRepositoryDoesNotExist:
|
||||
return "A submodule points to a location which does not exist.";
|
||||
return 'A submodule points to a location which does not exist.';
|
||||
case DugiteError.InvalidSubmoduleSHA:
|
||||
return "A submodule points to a commit which does not exist.";
|
||||
return 'A submodule points to a commit which does not exist.';
|
||||
case DugiteError.LocalPermissionDenied:
|
||||
return "Permission denied.";
|
||||
return 'Permission denied.';
|
||||
case DugiteError.InvalidMerge:
|
||||
return "This is not something we can merge.";
|
||||
return 'This is not something we can merge.';
|
||||
case DugiteError.InvalidRebase:
|
||||
return "This is not something we can rebase.";
|
||||
return 'This is not something we can rebase.';
|
||||
case DugiteError.NonFastForwardMergeIntoEmptyHead:
|
||||
return "The merge you attempted is not a fast-forward, so it cannot be performed on an empty branch.";
|
||||
return 'The merge you attempted is not a fast-forward, so it cannot be performed on an empty branch.';
|
||||
case DugiteError.PatchDoesNotApply:
|
||||
return "The requested changes conflict with one or more files in the repository.";
|
||||
return 'The requested changes conflict with one or more files in the repository.';
|
||||
case DugiteError.BranchAlreadyExists:
|
||||
return "A branch with that name already exists.";
|
||||
return 'A branch with that name already exists.';
|
||||
case DugiteError.BadRevision:
|
||||
return "Bad revision.";
|
||||
return 'Bad revision.';
|
||||
case DugiteError.NotAGitRepository:
|
||||
return "This is not a git repository.";
|
||||
return 'This is not a git repository.';
|
||||
case DugiteError.ProtectedBranchForcePush:
|
||||
return "This branch is protected from force-push operations.";
|
||||
return 'This branch is protected from force-push operations.';
|
||||
case DugiteError.ProtectedBranchRequiresReview:
|
||||
return "This branch is protected and any changes requires an approved review. Open a pull request with changes targeting this branch instead.";
|
||||
return 'This branch is protected and any changes requires an approved review. Open a pull request with changes targeting this branch instead.';
|
||||
case DugiteError.PushWithFileSizeExceedingLimit:
|
||||
return "The push operation includes a file which exceeds GitHub's file size restriction of 100MB. Please remove the file from history and try again.";
|
||||
case DugiteError.HexBranchNameRejected:
|
||||
return "The branch name cannot be a 40-character string of hexadecimal characters, as this is the format that Git uses for representing objects.";
|
||||
return 'The branch name cannot be a 40-character string of hexadecimal characters, as this is the format that Git uses for representing objects.';
|
||||
case DugiteError.ForcePushRejected:
|
||||
return "The force push has been rejected for the current branch.";
|
||||
return 'The force push has been rejected for the current branch.';
|
||||
case DugiteError.InvalidRefLength:
|
||||
return "A ref cannot be longer than 255 characters.";
|
||||
return 'A ref cannot be longer than 255 characters.';
|
||||
case DugiteError.CannotMergeUnrelatedHistories:
|
||||
return "Unable to merge unrelated histories in this repository.";
|
||||
return 'Unable to merge unrelated histories in this repository.';
|
||||
case DugiteError.PushWithPrivateEmail:
|
||||
return 'Cannot push these commits as they contain an email address marked as private on GitHub. To push anyway, visit https://github.com/settings/emails, uncheck "Keep my email address private", then switch back to GitHub Desktop to push your commits. You can then enable the setting again.';
|
||||
case DugiteError.LFSAttributeDoesNotMatch:
|
||||
return "Git LFS attribute found in global Git configuration does not match expected value.";
|
||||
return 'Git LFS attribute found in global Git configuration does not match expected value.';
|
||||
case DugiteError.ProtectedBranchDeleteRejected:
|
||||
return "This branch cannot be deleted from the remote repository because it is marked as protected.";
|
||||
return 'This branch cannot be deleted from the remote repository because it is marked as protected.';
|
||||
case DugiteError.ProtectedBranchRequiredStatus:
|
||||
return "The push was rejected by the remote server because a required status check has not been satisfied.";
|
||||
return 'The push was rejected by the remote server because a required status check has not been satisfied.';
|
||||
case DugiteError.BranchRenameFailed:
|
||||
return "The branch could not be renamed.";
|
||||
return 'The branch could not be renamed.';
|
||||
case DugiteError.PathDoesNotExist:
|
||||
return "The path does not exist on disk.";
|
||||
return 'The path does not exist on disk.';
|
||||
case DugiteError.InvalidObjectName:
|
||||
return "The object was not found in the Git repository.";
|
||||
return 'The object was not found in the Git repository.';
|
||||
case DugiteError.OutsideRepository:
|
||||
return "This path is not a valid path inside the repository.";
|
||||
return 'This path is not a valid path inside the repository.';
|
||||
case DugiteError.LockFileAlreadyExists:
|
||||
return "A lock file already exists in the repository, which blocks this operation from completing.";
|
||||
return 'A lock file already exists in the repository, which blocks this operation from completing.';
|
||||
case DugiteError.NoMergeToAbort:
|
||||
return "There is no merge in progress, so there is nothing to abort.";
|
||||
return 'There is no merge in progress, so there is nothing to abort.';
|
||||
case DugiteError.NoExistingRemoteBranch:
|
||||
return "The remote branch does not exist.";
|
||||
return 'The remote branch does not exist.';
|
||||
case DugiteError.LocalChangesOverwritten:
|
||||
return "Unable to switch branches as there are working directory changes which would be overwritten. Please commit or stash your changes.";
|
||||
return 'Unable to switch branches as there are working directory changes which would be overwritten. Please commit or stash your changes.';
|
||||
case DugiteError.UnresolvedConflicts:
|
||||
return "There are unresolved conflicts in the working directory.";
|
||||
return 'There are unresolved conflicts in the working directory.';
|
||||
case DugiteError.ConfigLockFileAlreadyExists:
|
||||
// Added in dugite 1.88.0 (https://github.com/desktop/dugite/pull/386)
|
||||
// in support of https://github.com/desktop/desktop/issues/8675 but we're
|
||||
@@ -209,7 +229,7 @@ export function getDescriptionForError(error: DugiteError): string | null {
|
||||
case DugiteError.RemoteAlreadyExists:
|
||||
return null;
|
||||
case DugiteError.TagAlreadyExists:
|
||||
return "A tag with that name already exists";
|
||||
return 'A tag with that name already exists';
|
||||
case DugiteError.MergeWithLocalChanges:
|
||||
case DugiteError.RebaseWithLocalChanges:
|
||||
case DugiteError.GPGFailedToSignData:
|
||||
|
||||
@@ -106,11 +106,17 @@ export class Git {
|
||||
* Open a git repository in the given path.
|
||||
*
|
||||
* @param baseDir
|
||||
* @throws Error if the path is not a git repository
|
||||
*/
|
||||
async openRepository(baseDir: string): Promise<void> {
|
||||
if (this.baseDir) return;
|
||||
|
||||
this.baseDir = await open(baseDir);
|
||||
const repositoryPath = await open(baseDir);
|
||||
if (!repositoryPath) {
|
||||
throw new Error(`Not a git repository: ${baseDir}`);
|
||||
}
|
||||
|
||||
this.baseDir = repositoryPath;
|
||||
await this._setupRepository();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user