Added new github integration tasks

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

View File

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

View File

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

View File

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

View File

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