mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user