mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
feat: Phase 5 BYOB foundation + Phase 3 GitHub integration
Phase 5 - BYOB Backend (TASK-007A/B): - LocalSQL Adapter with full CloudStore API compatibility - QueryBuilder translates Parse-style queries to SQL - SchemaManager with PostgreSQL/Supabase export - LocalBackendServer with REST endpoints - BackendManager with IPC handlers for Electron - In-memory fallback when better-sqlite3 unavailable Phase 3 - GitHub Panel (GIT-004): - Issues tab with list/detail views - Pull Requests tab with list/detail views - GitHub API client with OAuth support - Repository info hook integration Phase 3 - Editor UX Bugfixes (TASK-013): - Legacy runtime detection banners - Read-only enforcement for legacy projects - Code editor modal close improvements - Property panel stuck state fix - Blockly node deletion and UI polish Phase 11 - Cloud Functions Planning: - Architecture documentation for workflow automation - Execution history storage schema design - Canvas overlay concept for debugging Docs: Updated LEARNINGS.md and COMMON-ISSUES.md
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import React, { createContext, useContext, useCallback, useState, useEffect } from 'react';
|
||||
|
||||
import { ComponentModel } from '@noodl-models/componentmodel';
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
import { SidebarModel } from '@noodl-models/sidebar';
|
||||
import { isComponentModel_CloudRuntime } from '@noodl-utils/NodeGraph';
|
||||
|
||||
import { Slot } from '@noodl-core-ui/types/global';
|
||||
|
||||
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
|
||||
import { CenterToFitMode, NodeGraphEditor } from '../../views/nodegrapheditor';
|
||||
|
||||
type NodeGraphID = 'frontend' | 'backend';
|
||||
@@ -72,6 +74,29 @@ export function NodeGraphContextProvider({ children }: NodeGraphContextProviderP
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Detect and apply read-only mode from ProjectModel
|
||||
useEffect(() => {
|
||||
if (!nodeGraph) return;
|
||||
|
||||
const eventGroup = {};
|
||||
|
||||
// Apply read-only mode when project instance changes
|
||||
const updateReadOnlyMode = () => {
|
||||
const isReadOnly = ProjectModel.instance?._isReadOnly || false;
|
||||
nodeGraph.setReadOnly(isReadOnly);
|
||||
};
|
||||
|
||||
// Listen for project changes
|
||||
EventDispatcher.instance.on('ProjectModel.instanceHasChanged', updateReadOnlyMode, eventGroup);
|
||||
|
||||
// Apply immediately if project is already loaded
|
||||
updateReadOnlyMode();
|
||||
|
||||
return () => {
|
||||
EventDispatcher.instance.off(eventGroup);
|
||||
};
|
||||
}, [nodeGraph]);
|
||||
|
||||
const switchToComponent: NodeGraphControlContext['switchToComponent'] = useCallback(
|
||||
(component, options) => {
|
||||
if (!component) return;
|
||||
|
||||
@@ -220,10 +220,13 @@ async function getProjectCreationDate(_projectPath: string): Promise<Date | null
|
||||
export async function detectRuntimeVersion(projectPath: string): Promise<RuntimeVersionInfo> {
|
||||
const indicators: string[] = [];
|
||||
|
||||
console.log('🔍 [detectRuntimeVersion] Starting detection for:', projectPath);
|
||||
|
||||
// Read project.json
|
||||
const projectJson = await readProjectJson(projectPath);
|
||||
|
||||
if (!projectJson) {
|
||||
console.log('❌ [detectRuntimeVersion] Could not read project.json');
|
||||
return {
|
||||
version: 'unknown',
|
||||
confidence: 'low',
|
||||
@@ -231,6 +234,15 @@ export async function detectRuntimeVersion(projectPath: string): Promise<Runtime
|
||||
};
|
||||
}
|
||||
|
||||
console.log('📄 [detectRuntimeVersion] Project JSON loaded:', {
|
||||
name: projectJson.name,
|
||||
version: projectJson.version,
|
||||
editorVersion: projectJson.editorVersion,
|
||||
runtimeVersion: projectJson.runtimeVersion,
|
||||
migratedFrom: projectJson.migratedFrom,
|
||||
createdAt: projectJson.createdAt
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Check 1: Explicit runtimeVersion field (most reliable)
|
||||
// ==========================================================================
|
||||
@@ -301,9 +313,7 @@ export async function detectRuntimeVersion(projectPath: string): Promise<Runtime
|
||||
// Check 5: Project creation date heuristic
|
||||
// Projects created before OpenNoodl fork are assumed React 17
|
||||
// ==========================================================================
|
||||
const createdAt = projectJson.createdAt
|
||||
? new Date(projectJson.createdAt)
|
||||
: await getProjectCreationDate(projectPath);
|
||||
const createdAt = projectJson.createdAt ? new Date(projectJson.createdAt) : await getProjectCreationDate(projectPath);
|
||||
|
||||
if (createdAt && createdAt < OPENNOODL_FORK_DATE) {
|
||||
indicators.push(`Project created ${createdAt.toISOString()} (before OpenNoodl fork)`);
|
||||
@@ -319,6 +329,7 @@ export async function detectRuntimeVersion(projectPath: string): Promise<Runtime
|
||||
// Any project without runtimeVersion, migratedFrom, or a recent editorVersion
|
||||
// is most likely a legacy project from before OpenNoodl
|
||||
// ==========================================================================
|
||||
console.log('✅ [detectRuntimeVersion] FINAL: Assuming React 17 (no markers found)');
|
||||
return {
|
||||
version: 'react17',
|
||||
confidence: 'low',
|
||||
@@ -445,7 +456,11 @@ function generateIssueId(): string {
|
||||
*/
|
||||
export async function scanProjectForMigration(
|
||||
projectPath: string,
|
||||
onProgress?: (progress: number, currentItem: string, stats: { components: number; nodes: number; jsFiles: number }) => void
|
||||
onProgress?: (
|
||||
progress: number,
|
||||
currentItem: string,
|
||||
stats: { components: number; nodes: number; jsFiles: number }
|
||||
) => void
|
||||
): Promise<MigrationScan> {
|
||||
const projectJson = await readProjectJson(projectPath);
|
||||
|
||||
@@ -478,9 +493,7 @@ export async function scanProjectForMigration(
|
||||
|
||||
// Scan JavaScript files for issues
|
||||
const allFiles = await listFilesRecursively(projectPath);
|
||||
const jsFiles = allFiles.filter(
|
||||
(file) => /\.(js|jsx|ts|tsx)$/.test(file) && !file.includes('node_modules')
|
||||
);
|
||||
const jsFiles = allFiles.filter((file) => /\.(js|jsx|ts|tsx)$/.test(file) && !file.includes('node_modules'));
|
||||
stats.jsFiles = jsFiles.length;
|
||||
|
||||
// Group issues by file/component
|
||||
@@ -610,12 +623,6 @@ function estimateAICost(issueCount: number): number {
|
||||
// Exports
|
||||
// =============================================================================
|
||||
|
||||
export {
|
||||
LEGACY_PATTERNS,
|
||||
REACT19_MIN_VERSION,
|
||||
OPENNOODL_FORK_DATE,
|
||||
readProjectJson,
|
||||
compareVersions
|
||||
};
|
||||
export { LEGACY_PATTERNS, REACT19_MIN_VERSION, OPENNOODL_FORK_DATE, readProjectJson, compareVersions };
|
||||
|
||||
export type { ProjectJson };
|
||||
|
||||
@@ -97,7 +97,9 @@ export class ProjectModel extends Model {
|
||||
public id?: string;
|
||||
public name?: string;
|
||||
public version?: string;
|
||||
public runtimeVersion?: 'react17' | 'react19';
|
||||
public _retainedProjectDirectory?: string;
|
||||
public _isReadOnly?: boolean; // Flag for read-only mode (legacy projects)
|
||||
public settings?: ProjectSettings;
|
||||
public metadata?: TSFixme;
|
||||
public components: ComponentModel[];
|
||||
@@ -121,10 +123,16 @@ export class ProjectModel extends Model {
|
||||
this.settings = args.settings;
|
||||
// this.thumbnailURI = args.thumbnailURI;
|
||||
this.version = args.version;
|
||||
this.runtimeVersion = args.runtimeVersion;
|
||||
this.metadata = args.metadata;
|
||||
// this.deviceSettings = args.deviceSettings;
|
||||
}
|
||||
|
||||
// NOTE: runtimeVersion is NOT auto-defaulted here!
|
||||
// - New projects: Explicitly set to 'react19' in LocalProjectsModel.newProject()
|
||||
// - Old projects: Left undefined, detected by runtime scanner
|
||||
// - This prevents corrupting legacy projects when they're loaded
|
||||
|
||||
NodeLibrary.instance.on(
|
||||
['moduleRegistered', 'moduleUnregistered', 'libraryUpdated'],
|
||||
() => {
|
||||
@@ -1154,6 +1162,7 @@ export class ProjectModel extends Model {
|
||||
rootNodeId: this.rootNode ? this.rootNode.id : undefined,
|
||||
// thumbnailURI:this.thumbnailURI,
|
||||
version: this.version,
|
||||
runtimeVersion: this.runtimeVersion,
|
||||
lesson: this.lesson ? this.lesson.toJSON() : undefined,
|
||||
metadata: this.metadata,
|
||||
variants: this.variants.map((v) => v.toJSON())
|
||||
@@ -1246,6 +1255,12 @@ EventDispatcher.instance.on(
|
||||
function saveProject() {
|
||||
if (!ProjectModel.instance) return;
|
||||
|
||||
// CRITICAL: Do not save read-only projects (e.g., legacy projects opened for inspection)
|
||||
if (ProjectModel.instance._isReadOnly) {
|
||||
console.log('⚠️ Skipping auto-save: Project is in read-only mode');
|
||||
return;
|
||||
}
|
||||
|
||||
if (ProjectModel.instance._retainedProjectDirectory) {
|
||||
// Project is loaded from directory, save it
|
||||
ProjectModel.instance.toDirectory(ProjectModel.instance._retainedProjectDirectory, function (r) {
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface AppRouteOptions {
|
||||
from?: string;
|
||||
uri?: string;
|
||||
project?: ProjectModel;
|
||||
readOnly?: boolean; // Flag to open project in read-only mode (for legacy projects)
|
||||
}
|
||||
|
||||
/** TODO: This will replace Router later */
|
||||
|
||||
@@ -17,9 +17,13 @@ import {
|
||||
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 { ProjectOrganizationService } from '../../services/ProjectOrganizationService';
|
||||
import { LocalProjectsModel, ProjectItem } from '../../utils/LocalProjectsModel';
|
||||
import { LocalProjectsModel, ProjectItemWithRuntime } from '../../utils/LocalProjectsModel';
|
||||
import { tracker } from '../../utils/tracker';
|
||||
import { MigrationWizard } from '../../views/migration/MigrationWizard';
|
||||
import { ToastLayer } from '../../views/ToastLayer/ToastLayer';
|
||||
|
||||
export interface ProjectsPageProps extends IRouteProps {
|
||||
@@ -27,9 +31,9 @@ export interface ProjectsPageProps extends IRouteProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Map LocalProjectsModel ProjectItem to LauncherProjectData format
|
||||
* Map LocalProjectsModel ProjectItemWithRuntime to LauncherProjectData format
|
||||
*/
|
||||
function mapProjectToLauncherData(project: ProjectItem): LauncherProjectData {
|
||||
function mapProjectToLauncherData(project: ProjectItemWithRuntime): LauncherProjectData {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.name || 'Untitled',
|
||||
@@ -38,7 +42,9 @@ function mapProjectToLauncherData(project: ProjectItem): LauncherProjectData {
|
||||
imageSrc: project.thumbURI || 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg"%3E%3C/svg%3E',
|
||||
cloudSyncMeta: {
|
||||
type: CloudSyncType.None // TODO: Detect git repos in future
|
||||
}
|
||||
},
|
||||
// Include runtime info for legacy detection
|
||||
runtimeInfo: project.runtimeInfo
|
||||
// Git-related fields will be populated in future tasks
|
||||
};
|
||||
}
|
||||
@@ -55,10 +61,16 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
// Switch main window size to editor size
|
||||
ipcRenderer.send('main-window-resize', { size: 'editor', center: true });
|
||||
|
||||
// Load projects
|
||||
// Load projects with runtime detection
|
||||
const loadProjects = async () => {
|
||||
await LocalProjectsModel.instance.fetch();
|
||||
const projects = LocalProjectsModel.instance.getProjects();
|
||||
|
||||
// Trigger background runtime detection for all projects
|
||||
LocalProjectsModel.instance.detectAllProjectRuntimes();
|
||||
|
||||
// Get projects (detection runs in background, will update via events)
|
||||
const projects = LocalProjectsModel.instance.getProjectsWithRuntime();
|
||||
console.log('🔵 Projects loaded, triggering runtime detection for:', projects.length);
|
||||
setRealProjects(projects.map(mapProjectToLauncherData));
|
||||
};
|
||||
|
||||
@@ -67,8 +79,15 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
|
||||
// Subscribe to project list changes
|
||||
useEventListener(LocalProjectsModel.instance, 'myProjectsChanged', () => {
|
||||
console.log('🔔 Projects list changed, updating dashboard');
|
||||
const projects = LocalProjectsModel.instance.getProjects();
|
||||
console.log('🔔 Projects list changed, updating dashboard with runtime detection');
|
||||
const projects = LocalProjectsModel.instance.getProjectsWithRuntime();
|
||||
setRealProjects(projects.map(mapProjectToLauncherData));
|
||||
});
|
||||
|
||||
// Subscribe to runtime detection completion to update UI
|
||||
useEventListener(LocalProjectsModel.instance, 'runtimeDetectionComplete', (projectPath: string, runtimeInfo) => {
|
||||
console.log('🎯 Runtime detection complete for:', projectPath, runtimeInfo);
|
||||
const projects = LocalProjectsModel.instance.getProjectsWithRuntime();
|
||||
setRealProjects(projects.map(mapProjectToLauncherData));
|
||||
});
|
||||
|
||||
@@ -136,60 +155,212 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this project is already in the list
|
||||
const existingProjects = LocalProjectsModel.instance.getProjects();
|
||||
const isExisting = existingProjects.some((p) => p.retainedProjectDirectory === direntry);
|
||||
|
||||
// If project is new, check for legacy runtime before opening
|
||||
if (!isExisting) {
|
||||
console.log('🔵 [handleOpenProject] New project detected, checking runtime...');
|
||||
const activityId = 'checking-compatibility';
|
||||
ToastLayer.showActivity('Checking project compatibility...', activityId);
|
||||
|
||||
try {
|
||||
const runtimeInfo = await detectRuntimeVersion(direntry);
|
||||
ToastLayer.hideActivity(activityId);
|
||||
|
||||
console.log('🔵 [handleOpenProject] Runtime detected:', runtimeInfo);
|
||||
|
||||
// If legacy or unknown, show warning dialog
|
||||
if (runtimeInfo.version === 'react17' || runtimeInfo.version === 'unknown') {
|
||||
const projectName = filesystem.basename(direntry);
|
||||
|
||||
// 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('🔵 [handleOpenProject] User choice:', userChoice);
|
||||
|
||||
if (userChoice === 'cancel') {
|
||||
console.log('🔵 [handleOpenProject] User cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (userChoice === 'migrate') {
|
||||
// Launch migration wizard
|
||||
tracker.track('Legacy Project Migration Started from Open', {
|
||||
projectName
|
||||
});
|
||||
|
||||
DialogLayerModel.instance.showDialog(
|
||||
(close) =>
|
||||
React.createElement(MigrationWizard, {
|
||||
sourcePath: direntry,
|
||||
projectName,
|
||||
onComplete: async (targetPath: string) => {
|
||||
close();
|
||||
|
||||
const migrateActivityId = 'opening-migrated';
|
||||
ToastLayer.showActivity('Opening migrated project', migrateActivityId);
|
||||
|
||||
try {
|
||||
// Add migrated project and open it
|
||||
const migratedProject = await LocalProjectsModel.instance.openProjectFromFolder(targetPath);
|
||||
|
||||
if (!migratedProject.name) {
|
||||
migratedProject.name = projectName + ' (React 19)';
|
||||
}
|
||||
|
||||
// Refresh and detect runtimes
|
||||
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;
|
||||
}
|
||||
|
||||
// If read-only, continue to open normally (will add to list with legacy badge)
|
||||
tracker.track('Legacy Project Opened Read-Only from Open', {
|
||||
projectName
|
||||
});
|
||||
|
||||
// CRITICAL: Open the project in read-only mode
|
||||
const readOnlyActivityId = 'opening-project-readonly';
|
||||
ToastLayer.showActivity('Opening project in read-only mode', readOnlyActivityId);
|
||||
|
||||
const readOnlyProject = await LocalProjectsModel.instance.openProjectFromFolder(direntry);
|
||||
|
||||
if (!readOnlyProject) {
|
||||
ToastLayer.hideActivity(readOnlyActivityId);
|
||||
ToastLayer.showError('Could not open project');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!readOnlyProject.name) {
|
||||
readOnlyProject.name = filesystem.basename(direntry);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Show persistent warning toast (stays forever with Infinity default)
|
||||
ToastLayer.showError('⚠️ READ-ONLY MODE - No changes will be saved to this legacy project');
|
||||
|
||||
// Route to editor with read-only flag
|
||||
props.route.router.route({ to: 'editor', project: loadedReadOnly, readOnly: true });
|
||||
return; // Exit early - don't continue to normal flow
|
||||
}
|
||||
} catch (error) {
|
||||
ToastLayer.hideActivity(activityId);
|
||||
console.error('Failed to detect runtime:', error);
|
||||
// Continue opening anyway if detection fails
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with normal opening flow (non-legacy or legacy with migrate choice)
|
||||
const activityId = 'opening-project';
|
||||
console.log('🔵 [handleOpenProject] Showing activity toast');
|
||||
ToastLayer.showActivity('Opening project', activityId);
|
||||
|
||||
console.log('🔵 [handleOpenProject] Calling openProjectFromFolder...');
|
||||
// openProjectFromFolder adds the project to recent list and returns ProjectModel
|
||||
const project = await LocalProjectsModel.instance.openProjectFromFolder(direntry);
|
||||
console.log('🔵 [handleOpenProject] Got project:', project);
|
||||
|
||||
if (!project) {
|
||||
console.log('🔴 [handleOpenProject] Project is null/undefined');
|
||||
ToastLayer.hideActivity(activityId);
|
||||
ToastLayer.showError('Could not open project');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!project.name) {
|
||||
console.log('🔵 [handleOpenProject] Setting project name from folder');
|
||||
project.name = filesystem.basename(direntry);
|
||||
}
|
||||
|
||||
console.log('🔵 [handleOpenProject] Getting projects list...');
|
||||
// Now we need to find the project entry that was just added and load it
|
||||
const projects = LocalProjectsModel.instance.getProjects();
|
||||
console.log('🔵 [handleOpenProject] Projects in list:', projects.length);
|
||||
|
||||
const projectEntry = projects.find((p) => p.id === project.id);
|
||||
console.log('🔵 [handleOpenProject] Found project entry:', projectEntry);
|
||||
|
||||
if (!projectEntry) {
|
||||
console.log('🔴 [handleOpenProject] Project entry not found in list');
|
||||
ToastLayer.hideActivity(activityId);
|
||||
ToastLayer.showError('Could not find project in recent list');
|
||||
console.error('Project was added but not found in list:', project.id);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔵 [handleOpenProject] Loading project...');
|
||||
// Actually load/open the project
|
||||
const loaded = await LocalProjectsModel.instance.loadProject(projectEntry);
|
||||
console.log('🔵 [handleOpenProject] Project loaded:', loaded);
|
||||
|
||||
ToastLayer.hideActivity(activityId);
|
||||
|
||||
if (!loaded) {
|
||||
console.log('🔴 [handleOpenProject] Load result is falsy');
|
||||
ToastLayer.showError('Could not load project');
|
||||
} else {
|
||||
console.log('✅ [handleOpenProject] Success! Navigating to editor...');
|
||||
// Navigate to editor with the loaded project
|
||||
props.route.router.route({ to: 'editor', project: loaded });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('🔴 [handleOpenProject] EXCEPTION:', error);
|
||||
ToastLayer.hideActivity('opening-project');
|
||||
console.error('Failed to open project:', error);
|
||||
ToastLayer.showError('Could not open project');
|
||||
@@ -256,6 +427,157 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle "Migrate Project" button click - opens the migration wizard
|
||||
*/
|
||||
const handleMigrateProject = useCallback(
|
||||
(projectId: string) => {
|
||||
const projects = LocalProjectsModel.instance.getProjects();
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
if (!project || !project.retainedProjectDirectory) {
|
||||
ToastLayer.showError('Cannot migrate project: path not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const projectPath = project.retainedProjectDirectory;
|
||||
|
||||
// Show the migration wizard as a dialog
|
||||
DialogLayerModel.instance.showDialog(
|
||||
(close) =>
|
||||
React.createElement(MigrationWizard, {
|
||||
sourcePath: projectPath,
|
||||
projectName: project.name,
|
||||
onComplete: async (targetPath: string) => {
|
||||
close();
|
||||
// Clear runtime cache for the source project
|
||||
LocalProjectsModel.instance.clearRuntimeCache(projectPath);
|
||||
|
||||
// Show activity indicator
|
||||
const activityId = 'adding-migrated-project';
|
||||
ToastLayer.showActivity('Adding migrated project to list', activityId);
|
||||
|
||||
try {
|
||||
// Add the migrated project to the projects list
|
||||
const migratedProject = await LocalProjectsModel.instance.openProjectFromFolder(targetPath);
|
||||
|
||||
if (!migratedProject.name) {
|
||||
migratedProject.name = project.name + ' (React 19)';
|
||||
}
|
||||
|
||||
// Refresh the projects list to show both projects
|
||||
await LocalProjectsModel.instance.fetch();
|
||||
|
||||
// Trigger runtime detection for both projects to update UI immediately
|
||||
await LocalProjectsModel.instance.detectProjectRuntime(projectPath);
|
||||
await LocalProjectsModel.instance.detectProjectRuntime(targetPath);
|
||||
|
||||
// Force a full re-detection to update the UI with correct runtime info
|
||||
LocalProjectsModel.instance.detectAllProjectRuntimes();
|
||||
|
||||
ToastLayer.hideActivity(activityId);
|
||||
|
||||
// Ask user if they want to archive the original
|
||||
const shouldArchive = confirm(
|
||||
`Migration successful!\n\n` +
|
||||
`Would you like to move the original project to a "Legacy Projects" folder?\n\n` +
|
||||
`The original will be preserved but organized separately. You can access it anytime from the Legacy Projects category.`
|
||||
);
|
||||
|
||||
if (shouldArchive) {
|
||||
// Get or create "Legacy Projects" folder
|
||||
let legacyFolder = ProjectOrganizationService.instance
|
||||
.getFolders()
|
||||
.find((f) => f.name === 'Legacy Projects');
|
||||
|
||||
if (!legacyFolder) {
|
||||
legacyFolder = ProjectOrganizationService.instance.createFolder('Legacy Projects');
|
||||
}
|
||||
|
||||
// Move original project to Legacy folder
|
||||
ProjectOrganizationService.instance.moveProjectToFolder(projectPath, legacyFolder.id);
|
||||
|
||||
ToastLayer.showSuccess(
|
||||
`"${migratedProject.name}" is ready! Original moved to Legacy Projects folder.`
|
||||
);
|
||||
|
||||
tracker.track('Legacy Project Archived', {
|
||||
projectName: project.name
|
||||
});
|
||||
} else {
|
||||
ToastLayer.showSuccess(`"${migratedProject.name}" is now in your projects list!`);
|
||||
}
|
||||
|
||||
// Stay in launcher - user can now see both projects and choose which to open
|
||||
tracker.track('Migration Completed', {
|
||||
projectName: project.name,
|
||||
archivedOriginal: shouldArchive
|
||||
});
|
||||
} catch (error) {
|
||||
ToastLayer.hideActivity(activityId);
|
||||
ToastLayer.showError('Project migrated but could not be added to list. Try opening it manually.');
|
||||
console.error('Failed to add migrated project:', error);
|
||||
// Refresh project list anyway
|
||||
LocalProjectsModel.instance.fetch();
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
close();
|
||||
}
|
||||
}),
|
||||
{
|
||||
onClose: () => {
|
||||
// Refresh project list when dialog closes
|
||||
LocalProjectsModel.instance.fetch();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
tracker.track('Migration Wizard Opened', {
|
||||
projectName: project.name
|
||||
});
|
||||
},
|
||||
[props.route]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle "Open Read-Only" button click - opens legacy project without migration
|
||||
*/
|
||||
const handleOpenReadOnly = useCallback(
|
||||
async (projectId: string) => {
|
||||
const projects = LocalProjectsModel.instance.getProjects();
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
if (!project) return;
|
||||
|
||||
const activityId = 'opening-project-readonly';
|
||||
ToastLayer.showActivity('Opening project in read-only mode', activityId);
|
||||
|
||||
try {
|
||||
const loaded = await LocalProjectsModel.instance.loadProject(project);
|
||||
ToastLayer.hideActivity(activityId);
|
||||
|
||||
if (!loaded) {
|
||||
ToastLayer.showError("Couldn't load project.");
|
||||
return;
|
||||
}
|
||||
|
||||
tracker.track('Legacy Project Opened Read-Only', {
|
||||
projectName: project.name
|
||||
});
|
||||
|
||||
// Show persistent warning about read-only mode (stays forever with Infinity default)
|
||||
ToastLayer.showError('⚠️ READ-ONLY MODE - No changes will be saved to this legacy project');
|
||||
|
||||
// Open the project in read-only mode
|
||||
props.route.router.route({ to: 'editor', project: loaded, readOnly: true });
|
||||
} catch (error) {
|
||||
ToastLayer.hideActivity(activityId);
|
||||
ToastLayer.showError('Could not open project');
|
||||
console.error('Failed to open legacy project:', error);
|
||||
}
|
||||
},
|
||||
[props.route]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Launcher
|
||||
@@ -265,6 +587,8 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
onLaunchProject={handleLaunchProject}
|
||||
onOpenProjectFolder={handleOpenProjectFolder}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
onMigrateProject={handleMigrateProject}
|
||||
onOpenReadOnly={handleOpenReadOnly}
|
||||
projectOrganizationService={ProjectOrganizationService.instance}
|
||||
githubUser={null}
|
||||
githubIsAuthenticated={false}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { DataLineagePanel } from './views/panels/DataLineagePanel';
|
||||
import { DesignTokenPanel } from './views/panels/DesignTokenPanel/DesignTokenPanel';
|
||||
import { EditorSettingsPanel } from './views/panels/EditorSettingsPanel/EditorSettingsPanel';
|
||||
import { FileExplorerPanel } from './views/panels/FileExplorerPanel';
|
||||
import { GitHubPanel } from './views/panels/GitHubPanel';
|
||||
import { NodeReferencesPanel_ID } from './views/panels/NodeReferencesPanel';
|
||||
import { NodeReferencesPanel } from './views/panels/NodeReferencesPanel/NodeReferencesPanel';
|
||||
import { ProjectSettingsPanel } from './views/panels/ProjectSettingsPanel/ProjectSettingsPanel';
|
||||
@@ -122,6 +123,14 @@ export function installSidePanel({ isLesson }: SetupEditorOptions) {
|
||||
panel: VersionControlPanel
|
||||
});
|
||||
|
||||
SidebarModel.instance.register({
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
order: 5.5,
|
||||
icon: IconName.Link,
|
||||
panel: GitHubPanel
|
||||
});
|
||||
|
||||
SidebarModel.instance.register({
|
||||
id: 'cloudservice',
|
||||
name: 'Cloud Services',
|
||||
|
||||
@@ -167,13 +167,18 @@ export default class Router
|
||||
if (args.project && ProjectModel.instance !== args.project) {
|
||||
//set new project
|
||||
ProjectModel.instance = args.project;
|
||||
|
||||
// Set read-only mode if specified (for legacy projects)
|
||||
if (args.readOnly !== undefined) {
|
||||
args.project._isReadOnly = args.readOnly;
|
||||
}
|
||||
}
|
||||
|
||||
// Routes
|
||||
if (args.to === 'editor') {
|
||||
this.setState({
|
||||
route: EditorPage,
|
||||
routeArgs: { route }
|
||||
routeArgs: { route, readOnly: args.readOnly }
|
||||
});
|
||||
} else if (args.to === 'projects') {
|
||||
this.setState({
|
||||
|
||||
@@ -1,255 +1,714 @@
|
||||
/**
|
||||
* GitHubClient
|
||||
*
|
||||
* Wrapper around Octokit REST API client with authentication and rate limiting.
|
||||
* Provides convenient methods for GitHub API operations needed by OpenNoodl.
|
||||
* High-level GitHub REST API client with rate limiting, caching, and error handling.
|
||||
* Built on top of GitHubOAuthService for authentication.
|
||||
*
|
||||
* @module services/github
|
||||
* @since 1.1.0
|
||||
* @module noodl-editor/services/github
|
||||
*/
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
import { GitHubAuth } from './GitHubAuth';
|
||||
import type { GitHubRepository, GitHubRateLimit, GitHubUser } from './GitHubTypes';
|
||||
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
|
||||
import { GitHubOAuthService } from '../GitHubOAuthService';
|
||||
import type {
|
||||
GitHubIssue,
|
||||
GitHubPullRequest,
|
||||
GitHubRepository,
|
||||
GitHubComment,
|
||||
GitHubCommit,
|
||||
GitHubLabel,
|
||||
GitHubRateLimit,
|
||||
GitHubApiResponse,
|
||||
GitHubIssueFilters,
|
||||
CreateIssueOptions,
|
||||
UpdateIssueOptions,
|
||||
GitHubApiError
|
||||
} from './GitHubTypes';
|
||||
|
||||
/**
|
||||
* GitHubClient
|
||||
*
|
||||
* Main client for GitHub API interactions.
|
||||
* Automatically uses authenticated token from GitHubAuth.
|
||||
* Handles rate limiting and provides typed API methods.
|
||||
* Cache entry structure
|
||||
*/
|
||||
export class GitHubClient {
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
etag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit warning threshold (percentage)
|
||||
*/
|
||||
const RATE_LIMIT_WARNING_THRESHOLD = 0.1; // Warn at 10% remaining
|
||||
|
||||
/**
|
||||
* Default cache TTL in milliseconds
|
||||
*/
|
||||
const DEFAULT_CACHE_TTL = 30000; // 30 seconds
|
||||
|
||||
/**
|
||||
* Maximum cache size (number of entries)
|
||||
*/
|
||||
const MAX_CACHE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* GitHub API client with rate limiting, caching, and error handling
|
||||
*/
|
||||
export class GitHubClient extends EventDispatcher {
|
||||
private static _instance: GitHubClient;
|
||||
private octokit: Octokit | null = null;
|
||||
private lastRateLimit: GitHubRateLimit | null = null;
|
||||
private cache: Map<string, CacheEntry<unknown>> = new Map();
|
||||
private rateLimit: GitHubRateLimit | null = null;
|
||||
private authService: GitHubOAuthService;
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
this.authService = GitHubOAuthService.instance;
|
||||
|
||||
// Listen for auth changes
|
||||
this.authService.on('auth-state-changed', this.handleAuthChange.bind(this), this);
|
||||
this.authService.on('disconnected', this.handleDisconnect.bind(this), this);
|
||||
|
||||
// Initialize if already authenticated
|
||||
if (this.authService.isAuthenticated()) {
|
||||
this.initializeOctokit();
|
||||
}
|
||||
}
|
||||
|
||||
static get instance(): GitHubClient {
|
||||
if (!GitHubClient._instance) {
|
||||
GitHubClient._instance = new GitHubClient();
|
||||
}
|
||||
return GitHubClient._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Octokit instance with current auth token
|
||||
*
|
||||
* @returns Octokit instance or null if not authenticated
|
||||
* Handle authentication state changes
|
||||
*/
|
||||
private getOctokit(): Octokit | null {
|
||||
const token = GitHubAuth.getAccessToken();
|
||||
private handleAuthChange(event: { authenticated: boolean }): void {
|
||||
if (event.authenticated) {
|
||||
this.initializeOctokit();
|
||||
} else {
|
||||
this.octokit = null;
|
||||
this.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle disconnection
|
||||
*/
|
||||
private handleDisconnect(): void {
|
||||
this.octokit = null;
|
||||
this.clearCache();
|
||||
this.rateLimit = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Octokit with current auth token
|
||||
*/
|
||||
private async initializeOctokit(): Promise<void> {
|
||||
const token = await this.authService.getToken();
|
||||
if (!token) {
|
||||
console.warn('[GitHub Client] Not authenticated');
|
||||
return null;
|
||||
throw new Error('No authentication token available');
|
||||
}
|
||||
|
||||
// Create new instance if token changed or doesn't exist
|
||||
this.octokit = new Octokit({
|
||||
auth: token,
|
||||
userAgent: 'OpenNoodl/1.1.0'
|
||||
});
|
||||
|
||||
// Fetch initial rate limit info
|
||||
await this.updateRateLimit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure client is authenticated and initialized
|
||||
*/
|
||||
private async ensureAuthenticated(): Promise<Octokit> {
|
||||
if (!this.octokit) {
|
||||
this.octokit = new Octokit({
|
||||
auth: token,
|
||||
userAgent: 'OpenNoodl/1.1.0'
|
||||
});
|
||||
await this.initializeOctokit();
|
||||
}
|
||||
|
||||
if (!this.octokit) {
|
||||
throw new Error('GitHub client not authenticated');
|
||||
}
|
||||
|
||||
return this.octokit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client is ready (authenticated)
|
||||
*
|
||||
* @returns True if client has valid auth token
|
||||
* Update rate limit information from response headers
|
||||
*/
|
||||
isReady(): boolean {
|
||||
return GitHubAuth.isAuthenticated();
|
||||
private updateRateLimitFromHeaders(headers: Record<string, string>): void {
|
||||
if (headers['x-ratelimit-limit']) {
|
||||
this.rateLimit = {
|
||||
limit: parseInt(headers['x-ratelimit-limit'], 10),
|
||||
remaining: parseInt(headers['x-ratelimit-remaining'], 10),
|
||||
reset: parseInt(headers['x-ratelimit-reset'], 10),
|
||||
used: parseInt(headers['x-ratelimit-used'] || '0', 10)
|
||||
};
|
||||
|
||||
// Emit warning if approaching limit
|
||||
if (this.rateLimit.remaining / this.rateLimit.limit < RATE_LIMIT_WARNING_THRESHOLD) {
|
||||
this.notifyListeners('rate-limit-warning', { rateLimit: this.rateLimit });
|
||||
}
|
||||
|
||||
// Emit event with current rate limit
|
||||
this.notifyListeners('rate-limit-updated', { rateLimit: this.rateLimit });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current rate limit status
|
||||
*
|
||||
* @returns Rate limit information
|
||||
* @throws {Error} If not authenticated
|
||||
* Fetch current rate limit status
|
||||
*/
|
||||
async getRateLimit(): Promise<GitHubRateLimit> {
|
||||
const octokit = this.getOctokit();
|
||||
if (!octokit) {
|
||||
throw new Error('Not authenticated with GitHub');
|
||||
}
|
||||
|
||||
async updateRateLimit(): Promise<GitHubRateLimit> {
|
||||
const octokit = await this.ensureAuthenticated();
|
||||
const response = await octokit.rateLimit.get();
|
||||
const core = response.data.resources.core;
|
||||
|
||||
const rateLimit: GitHubRateLimit = {
|
||||
limit: core.limit,
|
||||
remaining: core.remaining,
|
||||
reset: core.reset,
|
||||
resource: 'core'
|
||||
this.rateLimit = {
|
||||
limit: response.data.rate.limit,
|
||||
remaining: response.data.rate.remaining,
|
||||
reset: response.data.rate.reset,
|
||||
used: response.data.rate.used
|
||||
};
|
||||
|
||||
this.lastRateLimit = rateLimit;
|
||||
return rateLimit;
|
||||
return this.rateLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're approaching rate limit
|
||||
*
|
||||
* @returns True if remaining requests < 100
|
||||
* Get current rate limit info (cached)
|
||||
*/
|
||||
isApproachingRateLimit(): boolean {
|
||||
if (!this.lastRateLimit) {
|
||||
return false;
|
||||
}
|
||||
return this.lastRateLimit.remaining < 100;
|
||||
getRateLimit(): GitHubRateLimit | null {
|
||||
return this.rateLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authenticated user's information
|
||||
*
|
||||
* @returns User information
|
||||
* @throws {Error} If not authenticated or API call fails
|
||||
* Generate cache key
|
||||
*/
|
||||
async getAuthenticatedUser(): Promise<GitHubUser> {
|
||||
const octokit = this.getOctokit();
|
||||
if (!octokit) {
|
||||
throw new Error('Not authenticated with GitHub');
|
||||
private getCacheKey(method: string, params: unknown): string {
|
||||
return `${method}:${JSON.stringify(params)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data from cache if valid
|
||||
*/
|
||||
private getFromCache<T>(key: string, ttl: number = DEFAULT_CACHE_TTL): T | null {
|
||||
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await octokit.users.getAuthenticated();
|
||||
return response.data as GitHubUser;
|
||||
const age = Date.now() - entry.timestamp;
|
||||
if (age > ttl) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store data in cache
|
||||
*/
|
||||
private setCache<T>(key: string, data: T, etag?: string): void {
|
||||
// Implement simple LRU by removing oldest entries when cache is full
|
||||
if (this.cache.size >= MAX_CACHE_SIZE) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
etag
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API errors with user-friendly messages
|
||||
*/
|
||||
private handleApiError(error: unknown): never {
|
||||
if (error && typeof error === 'object' && 'status' in error) {
|
||||
const apiError = error as { status: number; response?: { data?: GitHubApiError } };
|
||||
|
||||
switch (apiError.status) {
|
||||
case 401:
|
||||
throw new Error('Authentication failed. Please reconnect your GitHub account.');
|
||||
case 403:
|
||||
if (apiError.response?.data?.message?.includes('rate limit')) {
|
||||
const resetTime = this.rateLimit ? new Date(this.rateLimit.reset * 1000) : new Date();
|
||||
throw new Error(`Rate limit exceeded. Resets at ${resetTime.toLocaleTimeString()}`);
|
||||
}
|
||||
throw new Error('Access forbidden. Check repository permissions.');
|
||||
case 404:
|
||||
throw new Error('Repository or resource not found.');
|
||||
case 422: {
|
||||
const message = apiError.response?.data?.message || 'Validation failed';
|
||||
throw new Error(`Invalid request: ${message}`);
|
||||
}
|
||||
default:
|
||||
throw new Error(`GitHub API error: ${apiError.response?.data?.message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// ==================== REPOSITORY METHODS ====================
|
||||
|
||||
/**
|
||||
* Get repository information
|
||||
*
|
||||
* @param owner - Repository owner
|
||||
* @param repo - Repository name
|
||||
* @returns Repository information
|
||||
* @throws {Error} If repository not found or API call fails
|
||||
*/
|
||||
async getRepository(owner: string, repo: string): Promise<GitHubRepository> {
|
||||
const octokit = this.getOctokit();
|
||||
if (!octokit) {
|
||||
throw new Error('Not authenticated with GitHub');
|
||||
async getRepository(owner: string, repo: string): Promise<GitHubApiResponse<GitHubRepository>> {
|
||||
const cacheKey = this.getCacheKey('getRepository', { owner, repo });
|
||||
const cached = this.getFromCache<GitHubRepository>(cacheKey, 60000); // 1 minute cache
|
||||
|
||||
if (cached) {
|
||||
return { data: cached, rateLimit: this.rateLimit! };
|
||||
}
|
||||
|
||||
const response = await octokit.repos.get({ owner, repo });
|
||||
return response.data as GitHubRepository;
|
||||
try {
|
||||
const octokit = await this.ensureAuthenticated();
|
||||
const response = await octokit.repos.get({ owner, repo });
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's repositories
|
||||
*
|
||||
* @param options - Listing options
|
||||
* @returns Array of repositories
|
||||
* @throws {Error} If not authenticated or API call fails
|
||||
* List user repositories
|
||||
*/
|
||||
async listRepositories(options?: {
|
||||
visibility?: 'all' | 'public' | 'private';
|
||||
type?: 'all' | 'owner' | 'public' | 'private' | 'member';
|
||||
sort?: 'created' | 'updated' | 'pushed' | 'full_name';
|
||||
direction?: 'asc' | 'desc';
|
||||
per_page?: number;
|
||||
}): Promise<GitHubRepository[]> {
|
||||
const octokit = this.getOctokit();
|
||||
if (!octokit) {
|
||||
throw new Error('Not authenticated with GitHub');
|
||||
page?: number;
|
||||
}): Promise<GitHubApiResponse<GitHubRepository[]>> {
|
||||
const cacheKey = this.getCacheKey('listRepositories', options || {});
|
||||
const cached = this.getFromCache<GitHubRepository[]>(cacheKey, 60000);
|
||||
|
||||
if (cached) {
|
||||
return { data: cached, rateLimit: this.rateLimit! };
|
||||
}
|
||||
|
||||
const response = await octokit.repos.listForAuthenticatedUser({
|
||||
visibility: options?.visibility || 'all',
|
||||
sort: options?.sort || 'updated',
|
||||
per_page: options?.per_page || 30
|
||||
});
|
||||
|
||||
return response.data as GitHubRepository[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a repository exists and user has access
|
||||
*
|
||||
* @param owner - Repository owner
|
||||
* @param repo - Repository name
|
||||
* @returns True if repository exists and accessible
|
||||
*/
|
||||
async repositoryExists(owner: string, repo: string): Promise<boolean> {
|
||||
try {
|
||||
await this.getRepository(owner, repo);
|
||||
return true;
|
||||
const octokit = await this.ensureAuthenticated();
|
||||
const response = await octokit.repos.listForAuthenticatedUser(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) {
|
||||
return false;
|
||||
this.handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ISSUE METHODS ====================
|
||||
|
||||
/**
|
||||
* List issues for a repository
|
||||
*/
|
||||
async listIssues(
|
||||
owner: string,
|
||||
repo: string,
|
||||
filters?: GitHubIssueFilters
|
||||
): Promise<GitHubApiResponse<GitHubIssue[]>> {
|
||||
const cacheKey = this.getCacheKey('listIssues', { owner, repo, ...filters });
|
||||
const cached = this.getFromCache<GitHubIssue[]>(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return { data: cached, rateLimit: this.rateLimit! };
|
||||
}
|
||||
|
||||
try {
|
||||
const octokit = await this.ensureAuthenticated();
|
||||
// Convert milestone number to string if present
|
||||
const apiFilters = filters
|
||||
? {
|
||||
...filters,
|
||||
milestone: filters.milestone ? String(filters.milestone) : undefined,
|
||||
labels: filters.labels?.join(',')
|
||||
}
|
||||
: {};
|
||||
|
||||
const response = await octokit.issues.listForRepo({
|
||||
owner,
|
||||
repo,
|
||||
...apiFilters
|
||||
});
|
||||
|
||||
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
|
||||
this.setCache(cacheKey, response.data);
|
||||
|
||||
return {
|
||||
data: response.data as unknown as GitHubIssue[],
|
||||
rateLimit: this.rateLimit!
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse repository URL to owner/repo
|
||||
*
|
||||
* Handles various GitHub URL formats:
|
||||
* - https://github.com/owner/repo
|
||||
* - git@github.com:owner/repo.git
|
||||
* - https://github.com/owner/repo.git
|
||||
*
|
||||
* @param url - GitHub repository URL
|
||||
* @returns Object with owner and repo, or null if invalid
|
||||
* Get a single issue
|
||||
*/
|
||||
static parseRepoUrl(url: string): { owner: string; repo: string } | null {
|
||||
try {
|
||||
// Remove .git suffix if present
|
||||
const cleanUrl = url.replace(/\.git$/, '');
|
||||
async getIssue(owner: string, repo: string, issue_number: number): Promise<GitHubApiResponse<GitHubIssue>> {
|
||||
const cacheKey = this.getCacheKey('getIssue', { owner, repo, issue_number });
|
||||
const cached = this.getFromCache<GitHubIssue>(cacheKey);
|
||||
|
||||
// Handle SSH format: git@github.com:owner/repo
|
||||
if (cleanUrl.includes('git@github.com:')) {
|
||||
const parts = cleanUrl.split('git@github.com:')[1].split('/');
|
||||
if (parts.length >= 2) {
|
||||
return {
|
||||
owner: parts[0],
|
||||
repo: parts[1]
|
||||
};
|
||||
}
|
||||
if (cached) {
|
||||
return { data: cached, rateLimit: this.rateLimit! };
|
||||
}
|
||||
|
||||
try {
|
||||
const octokit = await this.ensureAuthenticated();
|
||||
const response = await octokit.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number
|
||||
});
|
||||
|
||||
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
|
||||
this.setCache(cacheKey, response.data);
|
||||
|
||||
return {
|
||||
data: response.data as unknown as GitHubIssue,
|
||||
rateLimit: this.rateLimit!
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new issue
|
||||
*/
|
||||
async createIssue(owner: string, repo: string, options: CreateIssueOptions): Promise<GitHubApiResponse<GitHubIssue>> {
|
||||
try {
|
||||
const octokit = await this.ensureAuthenticated();
|
||||
const response = await octokit.issues.create({
|
||||
owner,
|
||||
repo,
|
||||
...options
|
||||
});
|
||||
|
||||
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
|
||||
|
||||
// Invalidate list cache
|
||||
this.clearCacheForPattern('listIssues');
|
||||
|
||||
return {
|
||||
data: response.data as unknown as GitHubIssue,
|
||||
rateLimit: this.rateLimit!
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing issue
|
||||
*/
|
||||
async updateIssue(
|
||||
owner: string,
|
||||
repo: string,
|
||||
issue_number: number,
|
||||
options: UpdateIssueOptions
|
||||
): Promise<GitHubApiResponse<GitHubIssue>> {
|
||||
try {
|
||||
const octokit = await this.ensureAuthenticated();
|
||||
const response = await octokit.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
...options
|
||||
});
|
||||
|
||||
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
|
||||
|
||||
// Invalidate caches
|
||||
this.clearCacheForPattern('listIssues');
|
||||
this.clearCacheForPattern('getIssue');
|
||||
|
||||
return {
|
||||
data: response.data as unknown as GitHubIssue,
|
||||
rateLimit: this.rateLimit!
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List comments on an issue
|
||||
*/
|
||||
async listIssueComments(
|
||||
owner: string,
|
||||
repo: string,
|
||||
issue_number: number
|
||||
): Promise<GitHubApiResponse<GitHubComment[]>> {
|
||||
const cacheKey = this.getCacheKey('listIssueComments', { owner, repo, issue_number });
|
||||
const cached = this.getFromCache<GitHubComment[]>(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return { data: cached, rateLimit: this.rateLimit! };
|
||||
}
|
||||
|
||||
try {
|
||||
const octokit = await this.ensureAuthenticated();
|
||||
const response = await octokit.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number
|
||||
});
|
||||
|
||||
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
|
||||
this.setCache(cacheKey, response.data);
|
||||
|
||||
return {
|
||||
data: response.data as unknown as GitHubComment[],
|
||||
rateLimit: this.rateLimit!
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a comment on an issue
|
||||
*/
|
||||
async createIssueComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
issue_number: number,
|
||||
body: string
|
||||
): Promise<GitHubApiResponse<GitHubComment>> {
|
||||
try {
|
||||
const octokit = await this.ensureAuthenticated();
|
||||
const response = await octokit.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body
|
||||
});
|
||||
|
||||
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
|
||||
|
||||
// Invalidate comment cache
|
||||
this.clearCacheForPattern('listIssueComments');
|
||||
|
||||
return {
|
||||
data: response.data as unknown as GitHubComment,
|
||||
rateLimit: this.rateLimit!
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PULL REQUEST METHODS ====================
|
||||
|
||||
/**
|
||||
* List pull requests for a repository
|
||||
*/
|
||||
async listPullRequests(
|
||||
owner: string,
|
||||
repo: string,
|
||||
filters?: Omit<GitHubIssueFilters, 'milestone'>
|
||||
): Promise<GitHubApiResponse<GitHubPullRequest[]>> {
|
||||
const cacheKey = this.getCacheKey('listPullRequests', { owner, repo, ...filters });
|
||||
const cached = this.getFromCache<GitHubPullRequest[]>(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return { data: cached, rateLimit: this.rateLimit! };
|
||||
}
|
||||
|
||||
try {
|
||||
const octokit = await this.ensureAuthenticated();
|
||||
// Map our filters to PR-specific parameters
|
||||
const prSort = filters?.sort === 'comments' ? 'created' : filters?.sort;
|
||||
const apiFilters = filters
|
||||
? {
|
||||
state: filters.state,
|
||||
sort: prSort,
|
||||
direction: filters.direction,
|
||||
per_page: filters.per_page,
|
||||
page: filters.page
|
||||
}
|
||||
: {};
|
||||
|
||||
const response = await octokit.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
...apiFilters
|
||||
});
|
||||
|
||||
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
|
||||
this.setCache(cacheKey, response.data);
|
||||
|
||||
return {
|
||||
data: response.data as unknown as GitHubPullRequest[],
|
||||
rateLimit: this.rateLimit!
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single pull request
|
||||
*/
|
||||
async getPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
pull_number: number
|
||||
): Promise<GitHubApiResponse<GitHubPullRequest>> {
|
||||
const cacheKey = this.getCacheKey('getPullRequest', { owner, repo, pull_number });
|
||||
const cached = this.getFromCache<GitHubPullRequest>(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return { data: cached, rateLimit: this.rateLimit! };
|
||||
}
|
||||
|
||||
try {
|
||||
const octokit = await this.ensureAuthenticated();
|
||||
const response = await octokit.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number
|
||||
});
|
||||
|
||||
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
|
||||
this.setCache(cacheKey, response.data);
|
||||
|
||||
return {
|
||||
data: response.data as unknown as GitHubPullRequest,
|
||||
rateLimit: this.rateLimit!
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List commits in a pull request
|
||||
*/
|
||||
async listPullRequestCommits(
|
||||
owner: string,
|
||||
repo: string,
|
||||
pull_number: number
|
||||
): Promise<GitHubApiResponse<GitHubCommit[]>> {
|
||||
const cacheKey = this.getCacheKey('listPullRequestCommits', { owner, repo, pull_number });
|
||||
const cached = this.getFromCache<GitHubCommit[]>(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return { data: cached, rateLimit: this.rateLimit! };
|
||||
}
|
||||
|
||||
try {
|
||||
const octokit = await this.ensureAuthenticated();
|
||||
const response = await octokit.pulls.listCommits({
|
||||
owner,
|
||||
repo,
|
||||
pull_number
|
||||
});
|
||||
|
||||
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
|
||||
this.setCache(cacheKey, response.data);
|
||||
|
||||
return {
|
||||
data: response.data as unknown as GitHubCommit[],
|
||||
rateLimit: this.rateLimit!
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== LABEL METHODS ====================
|
||||
|
||||
/**
|
||||
* List labels for a repository
|
||||
*/
|
||||
async listLabels(owner: string, repo: string): Promise<GitHubApiResponse<GitHubLabel[]>> {
|
||||
const cacheKey = this.getCacheKey('listLabels', { owner, repo });
|
||||
const cached = this.getFromCache<GitHubLabel[]>(cacheKey, 300000); // 5 minute cache
|
||||
|
||||
if (cached) {
|
||||
return { data: cached, rateLimit: this.rateLimit! };
|
||||
}
|
||||
|
||||
try {
|
||||
const octokit = await this.ensureAuthenticated();
|
||||
const response = await octokit.issues.listLabelsForRepo({
|
||||
owner,
|
||||
repo
|
||||
});
|
||||
|
||||
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
|
||||
this.setCache(cacheKey, response.data);
|
||||
|
||||
return {
|
||||
data: response.data as unknown as GitHubLabel[],
|
||||
rateLimit: this.rateLimit!
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== UTILITY METHODS ====================
|
||||
|
||||
/**
|
||||
* Clear cache entries matching a pattern
|
||||
*/
|
||||
private clearCacheForPattern(pattern: string): void {
|
||||
for (const key of this.cache.keys()) {
|
||||
if (key.startsWith(pattern)) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
// Handle HTTPS format: https://github.com/owner/repo
|
||||
if (cleanUrl.includes('github.com/')) {
|
||||
const parts = cleanUrl.split('github.com/')[1].split('/');
|
||||
if (parts.length >= 2) {
|
||||
return {
|
||||
owner: parts[0],
|
||||
repo: parts[1]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[GitHub Client] Error parsing repo URL:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository from local Git remote URL
|
||||
*
|
||||
* Useful for getting GitHub repo info from current project's git remote.
|
||||
*
|
||||
* @param remoteUrl - Git remote URL
|
||||
* @returns Repository information if GitHub repo, null otherwise
|
||||
* Check if client is ready to make API calls
|
||||
*/
|
||||
async getRepositoryFromRemoteUrl(remoteUrl: string): Promise<GitHubRepository | null> {
|
||||
const parsed = GitHubClient.parseRepoUrl(remoteUrl);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.getRepository(parsed.owner, parsed.repo);
|
||||
} catch (error) {
|
||||
console.error('[GitHub Client] Error fetching repository:', error);
|
||||
return null;
|
||||
}
|
||||
isReady(): boolean {
|
||||
return this.octokit !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset client state
|
||||
*
|
||||
* Call this when user disconnects or token changes.
|
||||
* Get time until rate limit resets (in milliseconds)
|
||||
*/
|
||||
reset(): void {
|
||||
this.octokit = null;
|
||||
this.lastRateLimit = null;
|
||||
getTimeUntilRateLimitReset(): number {
|
||||
if (!this.rateLimit) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const resetTime = this.rateLimit.reset * 1000;
|
||||
const now = Date.now();
|
||||
return Math.max(0, resetTime - now);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of GitHubClient
|
||||
* Use this for all GitHub API operations
|
||||
*/
|
||||
export const githubClient = new GitHubClient();
|
||||
|
||||
@@ -1,184 +1,346 @@
|
||||
/**
|
||||
* GitHubTypes
|
||||
* TypeScript interfaces for GitHub API data structures
|
||||
*
|
||||
* TypeScript type definitions for GitHub OAuth and API integration.
|
||||
* These types define the structure of tokens, authentication state, and API responses.
|
||||
*
|
||||
* @module services/github
|
||||
* @since 1.1.0
|
||||
* @module noodl-editor/services/github
|
||||
*/
|
||||
|
||||
/**
|
||||
* OAuth device code response from GitHub
|
||||
* Returned when initiating device flow authorization
|
||||
* GitHub Issue data structure
|
||||
*/
|
||||
export interface GitHubDeviceCode {
|
||||
/** The device verification code */
|
||||
device_code: string;
|
||||
/** The user verification code (8-character code) */
|
||||
user_code: string;
|
||||
/** URL where user enters the code */
|
||||
verification_uri: string;
|
||||
/** Expiration time in seconds (default: 900) */
|
||||
expires_in: number;
|
||||
/** Polling interval in seconds (default: 5) */
|
||||
interval: number;
|
||||
export interface GitHubIssue {
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: 'open' | 'closed';
|
||||
html_url: string;
|
||||
user: GitHubUser;
|
||||
labels: GitHubLabel[];
|
||||
assignees: GitHubUser[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
closed_at: string | null;
|
||||
comments: number;
|
||||
milestone: GitHubMilestone | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub OAuth access token
|
||||
* Stored securely and used for API authentication
|
||||
* GitHub Pull Request data structure
|
||||
*/
|
||||
export interface GitHubToken {
|
||||
/** The OAuth access token */
|
||||
access_token: string;
|
||||
/** Token type (always 'bearer' for GitHub) */
|
||||
token_type: string;
|
||||
/** Granted scopes (comma-separated) */
|
||||
scope: string;
|
||||
/** Token expiration timestamp (ISO 8601) - undefined if no expiration */
|
||||
expires_at?: string;
|
||||
export interface GitHubPullRequest {
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: 'open' | 'closed';
|
||||
html_url: string;
|
||||
user: GitHubUser;
|
||||
labels: GitHubLabel[];
|
||||
assignees: GitHubUser[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
closed_at: string | null;
|
||||
merged_at: string | null;
|
||||
draft: boolean;
|
||||
head: {
|
||||
ref: string;
|
||||
sha: string;
|
||||
};
|
||||
base: {
|
||||
ref: string;
|
||||
sha: string;
|
||||
};
|
||||
mergeable: boolean | null;
|
||||
mergeable_state: string;
|
||||
comments: number;
|
||||
review_comments: number;
|
||||
commits: number;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changed_files: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current GitHub authentication state
|
||||
* Used by React components to display connection status
|
||||
*/
|
||||
export interface GitHubAuthState {
|
||||
/** Whether user is authenticated with GitHub */
|
||||
isAuthenticated: boolean;
|
||||
/** GitHub username if authenticated */
|
||||
username?: string;
|
||||
/** User's primary email if authenticated */
|
||||
email?: string;
|
||||
/** Current token (for internal use only) */
|
||||
token?: GitHubToken;
|
||||
/** Timestamp of last successful authentication */
|
||||
authenticatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub user information
|
||||
* Retrieved from /user API endpoint
|
||||
* GitHub User data structure
|
||||
*/
|
||||
export interface GitHubUser {
|
||||
/** GitHub username */
|
||||
login: string;
|
||||
/** GitHub user ID */
|
||||
id: number;
|
||||
/** User's display name */
|
||||
login: string;
|
||||
name: string | null;
|
||||
/** User's primary email */
|
||||
email: string | null;
|
||||
/** Avatar URL */
|
||||
avatar_url: string;
|
||||
/** Profile URL */
|
||||
html_url: string;
|
||||
/** User type (User or Organization) */
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub repository information
|
||||
* Basic repo details for issue/PR association
|
||||
* GitHub Organization data structure
|
||||
*/
|
||||
export interface GitHubOrganization {
|
||||
id: number;
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
description: string | null;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Repository data structure
|
||||
*/
|
||||
export interface GitHubRepository {
|
||||
/** Repository ID */
|
||||
id: number;
|
||||
/** Repository name (without owner) */
|
||||
name: string;
|
||||
/** Full repository name (owner/repo) */
|
||||
full_name: string;
|
||||
/** Repository owner */
|
||||
owner: {
|
||||
login: string;
|
||||
id: number;
|
||||
avatar_url: string;
|
||||
};
|
||||
/** Whether repo is private */
|
||||
owner: GitHubUser | GitHubOrganization;
|
||||
private: boolean;
|
||||
/** Repository URL */
|
||||
html_url: string;
|
||||
/** Default branch */
|
||||
description: string | null;
|
||||
fork: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
pushed_at: string;
|
||||
homepage: string | null;
|
||||
size: number;
|
||||
stargazers_count: number;
|
||||
watchers_count: number;
|
||||
language: string | null;
|
||||
has_issues: boolean;
|
||||
has_projects: boolean;
|
||||
has_downloads: boolean;
|
||||
has_wiki: boolean;
|
||||
has_pages: boolean;
|
||||
forks_count: number;
|
||||
open_issues_count: number;
|
||||
default_branch: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub App installation information
|
||||
* Represents organizations/accounts where the app was installed
|
||||
*/
|
||||
export interface GitHubInstallation {
|
||||
/** Installation ID */
|
||||
id: number;
|
||||
/** Account where app is installed */
|
||||
account: {
|
||||
login: string;
|
||||
type: 'User' | 'Organization';
|
||||
avatar_url: string;
|
||||
permissions?: {
|
||||
admin: boolean;
|
||||
maintain: boolean;
|
||||
push: boolean;
|
||||
triage: boolean;
|
||||
pull: boolean;
|
||||
};
|
||||
/** Repository selection type */
|
||||
repository_selection: 'all' | 'selected';
|
||||
/** List of repositories (if selected) */
|
||||
repositories?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit information from GitHub API
|
||||
* Used to prevent hitting API limits
|
||||
* GitHub Label data structure
|
||||
*/
|
||||
export interface GitHubLabel {
|
||||
id: number;
|
||||
node_id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
color: string;
|
||||
default: boolean;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Milestone data structure
|
||||
*/
|
||||
export interface GitHubMilestone {
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
state: 'open' | 'closed';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
due_on: string | null;
|
||||
closed_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Comment data structure
|
||||
*/
|
||||
export interface GitHubComment {
|
||||
id: number;
|
||||
body: string;
|
||||
user: GitHubUser;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Commit data structure
|
||||
*/
|
||||
export interface GitHubCommit {
|
||||
sha: string;
|
||||
commit: {
|
||||
author: {
|
||||
name: string;
|
||||
email: string;
|
||||
date: string;
|
||||
};
|
||||
committer: {
|
||||
name: string;
|
||||
email: string;
|
||||
date: string;
|
||||
};
|
||||
message: string;
|
||||
};
|
||||
author: GitHubUser | null;
|
||||
committer: GitHubUser | null;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Check Run data structure (for PR status checks)
|
||||
*/
|
||||
export interface GitHubCheckRun {
|
||||
id: number;
|
||||
name: string;
|
||||
status: 'queued' | 'in_progress' | 'completed';
|
||||
conclusion: 'success' | 'failure' | 'neutral' | 'cancelled' | 'skipped' | 'timed_out' | 'action_required' | null;
|
||||
html_url: string;
|
||||
details_url: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Review data structure
|
||||
*/
|
||||
export interface GitHubReview {
|
||||
id: number;
|
||||
user: GitHubUser;
|
||||
body: string;
|
||||
state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED' | 'PENDING';
|
||||
html_url: string;
|
||||
submitted_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit information
|
||||
*/
|
||||
export interface GitHubRateLimit {
|
||||
/** Maximum requests allowed per hour */
|
||||
limit: number;
|
||||
/** Remaining requests in current window */
|
||||
remaining: number;
|
||||
/** Timestamp when rate limit resets (Unix epoch) */
|
||||
reset: number;
|
||||
/** Resource type (core, search, graphql) */
|
||||
resource: string;
|
||||
reset: number; // Unix timestamp
|
||||
used: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* API response with rate limit info
|
||||
*/
|
||||
export interface GitHubApiResponse<T> {
|
||||
data: T;
|
||||
rateLimit: GitHubRateLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue/PR filter options
|
||||
*/
|
||||
export interface GitHubIssueFilters {
|
||||
state?: 'open' | 'closed' | 'all';
|
||||
labels?: string[];
|
||||
assignee?: string;
|
||||
creator?: string;
|
||||
mentioned?: string;
|
||||
milestone?: string | number;
|
||||
sort?: 'created' | 'updated' | 'comments';
|
||||
direction?: 'asc' | 'desc';
|
||||
since?: string;
|
||||
per_page?: number;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create issue options
|
||||
*/
|
||||
export interface CreateIssueOptions {
|
||||
title: string;
|
||||
body?: string;
|
||||
labels?: string[];
|
||||
assignees?: string[];
|
||||
milestone?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update issue options
|
||||
*/
|
||||
export interface UpdateIssueOptions {
|
||||
title?: string;
|
||||
body?: string;
|
||||
state?: 'open' | 'closed';
|
||||
labels?: string[];
|
||||
assignees?: string[];
|
||||
milestone?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response from GitHub API
|
||||
*/
|
||||
export interface GitHubError {
|
||||
/** HTTP status code */
|
||||
status: number;
|
||||
/** Error message */
|
||||
export interface GitHubApiError {
|
||||
message: string;
|
||||
/** Detailed documentation URL if available */
|
||||
documentation_url?: string;
|
||||
errors?: Array<{
|
||||
resource: string;
|
||||
field: string;
|
||||
code: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth authorization error
|
||||
* Thrown during device flow authorization
|
||||
* OAuth Token structure
|
||||
*/
|
||||
export interface GitHubAuthError extends Error {
|
||||
/** Error code from GitHub */
|
||||
code?: string;
|
||||
/** HTTP status if applicable */
|
||||
status?: number;
|
||||
export interface GitHubToken {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
scope: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored token data (persisted format)
|
||||
* Encrypted and stored in Electron's secure storage
|
||||
* GitHub Installation (App installation on org/repo)
|
||||
*/
|
||||
export interface GitHubInstallation {
|
||||
id: number;
|
||||
account: {
|
||||
login: string;
|
||||
type: string;
|
||||
};
|
||||
repository_selection: string;
|
||||
permissions: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored GitHub authentication data
|
||||
*/
|
||||
export interface StoredGitHubAuth {
|
||||
/** OAuth token */
|
||||
token: GitHubToken;
|
||||
/** Associated user info */
|
||||
user: {
|
||||
login: string;
|
||||
email: string | null;
|
||||
};
|
||||
/** Installation information (organizations/repos with access) */
|
||||
installations?: GitHubInstallation[];
|
||||
/** Timestamp when stored */
|
||||
storedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Auth state (returned by GitHubAuth.getAuthState())
|
||||
*/
|
||||
export interface GitHubAuthState {
|
||||
isAuthenticated: boolean;
|
||||
username?: string;
|
||||
email?: string;
|
||||
token?: GitHubToken;
|
||||
authenticatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Device Code (for OAuth Device Flow)
|
||||
*/
|
||||
export interface GitHubDeviceCode {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
expires_in: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Auth Error
|
||||
*/
|
||||
export interface GitHubAuthError extends Error {
|
||||
code?: string;
|
||||
}
|
||||
|
||||
@@ -1,41 +1,52 @@
|
||||
/**
|
||||
* GitHub Services
|
||||
* GitHub Service - Public API
|
||||
*
|
||||
* Public exports for GitHub OAuth authentication and API integration.
|
||||
* This module provides everything needed to connect to GitHub,
|
||||
* authenticate users, and interact with the GitHub API.
|
||||
* Provides GitHub integration services including OAuth authentication
|
||||
* and REST API client with rate limiting and caching.
|
||||
*
|
||||
* @module services/github
|
||||
* @since 1.1.0
|
||||
* @module noodl-editor/services/github
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { GitHubAuth, githubClient } from '@noodl-services/github';
|
||||
* import { GitHubClient, GitHubOAuthService } from '@noodl-editor/services/github';
|
||||
*
|
||||
* // Check if authenticated
|
||||
* if (GitHubAuth.isAuthenticated()) {
|
||||
* // Fetch user repos
|
||||
* const repos = await githubClient.listRepositories();
|
||||
* }
|
||||
* // Initialize OAuth
|
||||
* await GitHubOAuthService.instance.initialize();
|
||||
*
|
||||
* // Use API client
|
||||
* const client = GitHubClient.instance;
|
||||
* const { data: issues } = await client.listIssues('owner', 'repo');
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Authentication
|
||||
// Re-export main services
|
||||
export { GitHubOAuthService } from '../GitHubOAuthService';
|
||||
export { GitHubAuth } from './GitHubAuth';
|
||||
export { GitHubTokenStore } from './GitHubTokenStore';
|
||||
export { GitHubClient } from './GitHubClient';
|
||||
|
||||
// API Client
|
||||
export { GitHubClient, githubClient } from './GitHubClient';
|
||||
|
||||
// Types
|
||||
// Re-export all types
|
||||
export type {
|
||||
GitHubDeviceCode,
|
||||
GitHubToken,
|
||||
GitHubAuthState,
|
||||
GitHubIssue,
|
||||
GitHubPullRequest,
|
||||
GitHubUser,
|
||||
GitHubOrganization,
|
||||
GitHubRepository,
|
||||
GitHubLabel,
|
||||
GitHubMilestone,
|
||||
GitHubComment,
|
||||
GitHubCommit,
|
||||
GitHubCheckRun,
|
||||
GitHubReview,
|
||||
GitHubRateLimit,
|
||||
GitHubError,
|
||||
GitHubAuthError,
|
||||
StoredGitHubAuth
|
||||
GitHubApiResponse,
|
||||
GitHubIssueFilters,
|
||||
CreateIssueOptions,
|
||||
UpdateIssueOptions,
|
||||
GitHubApiError,
|
||||
GitHubToken,
|
||||
GitHubInstallation,
|
||||
StoredGitHubAuth,
|
||||
GitHubAuthState,
|
||||
GitHubDeviceCode,
|
||||
GitHubAuthError
|
||||
} from './GitHubTypes';
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* Enable pointer events when popouts are active (without dimming background)
|
||||
This allows clicking outside popouts to close them */
|
||||
.popup-layer.has-popouts {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.popup-menu {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<div class="nodegrapgeditor-bg nodegrapheditor-canvas" style="width: 100%; height: 100%">
|
||||
<!-- Editor Banner Root (for read-only mode warning) -->
|
||||
<div id="editor-banner-root" style="position: absolute; width: 100%; z-index: 1001;"></div>
|
||||
|
||||
<!-- Canvas Tabs Root (for React component) -->
|
||||
<div id="canvas-tabs-root" style="position: absolute; width: 100%; height: 100%; z-index: 100; pointer-events: none;"></div>
|
||||
|
||||
|
||||
@@ -252,6 +252,7 @@ export class LocalProjectsModel extends Model {
|
||||
}
|
||||
|
||||
project.name = name; //update the name from the template
|
||||
project.runtimeVersion = 'react19'; // NEW projects default to React 19
|
||||
|
||||
// Store the project, this will make it a unique project by
|
||||
// forcing it to generate a project id
|
||||
@@ -278,7 +279,8 @@ export class LocalProjectsModel extends Model {
|
||||
const minimalProject = {
|
||||
name: name,
|
||||
components: [],
|
||||
settings: {}
|
||||
settings: {},
|
||||
runtimeVersion: 'react19' // NEW projects default to React 19
|
||||
};
|
||||
|
||||
await filesystem.writeFile(filesystem.join(dirEntry, 'project.json'), JSON.stringify(minimalProject, null, 2));
|
||||
@@ -291,6 +293,7 @@ export class LocalProjectsModel extends Model {
|
||||
}
|
||||
|
||||
project.name = name;
|
||||
project.runtimeVersion = 'react19'; // Ensure it's set
|
||||
this._addProject(project);
|
||||
fn(project);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* EditorBanner Styles
|
||||
*
|
||||
* Warning banner for legacy projects in read-only mode.
|
||||
* Uses design tokens exclusively - NO hardcoded colors!
|
||||
*/
|
||||
|
||||
.EditorBanner {
|
||||
position: fixed;
|
||||
top: var(--topbar-height, 40px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
padding: 12px 20px;
|
||||
/* Solid dark background for maximum visibility */
|
||||
background: #1a1a1a;
|
||||
border-bottom: 2px solid var(--theme-color-warning, #ffc107);
|
||||
|
||||
/* Subtle shadow for depth */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* CRITICAL: Allow clicks through banner to editor below */
|
||||
/* Only interactive elements (buttons) should capture clicks */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.Icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
|
||||
.Content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
/* Re-enable pointer events for text content */
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.Title {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.Description {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
.Actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
/* Re-enable pointer events for interactive buttons */
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.CloseButton {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
|
||||
/* Re-enable pointer events for close button */
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 900px) {
|
||||
.EditorBanner {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.Content {
|
||||
flex-basis: 100%;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.Actions {
|
||||
order: 2;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.CloseButton {
|
||||
order: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* EditorBanner
|
||||
*
|
||||
* Warning banner that appears when a legacy (React 17) project is opened in read-only mode.
|
||||
* Provides clear messaging and actions for the user to migrate the project.
|
||||
*
|
||||
* @module noodl-editor/views/EditorBanner
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
|
||||
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
import css from './EditorBanner.module.scss';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface EditorBannerProps {
|
||||
/** Called when user dismisses the banner */
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function EditorBanner({ onDismiss }: EditorBannerProps) {
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true);
|
||||
onDismiss();
|
||||
};
|
||||
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css['EditorBanner']}>
|
||||
{/* Warning Icon */}
|
||||
<div className={css['Icon']}>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M10 6V11M10 14H10.01M19 10C19 14.9706 14.9706 19 10 19C5.02944 19 1 14.9706 1 10C1 5.02944 5.02944 1 10 1C14.9706 1 19 5.02944 19 10Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Message Content */}
|
||||
<div className={css['Content']}>
|
||||
<div className={css['Title']}>
|
||||
<Text textType={TextType.Default}>Legacy Project (React 17) - Read-Only Mode</Text>
|
||||
</div>
|
||||
<div className={css['Description']}>
|
||||
<Text textType={TextType.Secondary}>
|
||||
This project uses React 17. Return to the launcher to migrate it before editing.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<div className={css['CloseButton']}>
|
||||
<IconButton icon={IconName.Close} onClick={handleDismiss} variant={IconButtonVariant.Transparent} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditorBanner;
|
||||
@@ -0,0 +1 @@
|
||||
export { EditorBanner, type EditorBannerProps } from './EditorBanner';
|
||||
@@ -32,7 +32,10 @@ export function SidePanel() {
|
||||
setPanels((prev) => {
|
||||
const component = SidebarModel.instance.getPanelComponent(currentPanelId);
|
||||
if (component) {
|
||||
prev[currentPanelId] = React.createElement(component);
|
||||
return {
|
||||
...prev,
|
||||
[currentPanelId]: React.createElement(component)
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
@@ -52,7 +55,10 @@ export function SidePanel() {
|
||||
// TODO: Clean up this inside SidebarModel, createElement can be done here instead
|
||||
const component = SidebarModel.instance.getPanelComponent(panelId);
|
||||
if (component) {
|
||||
prev[panelId] = React.createElement(component);
|
||||
return {
|
||||
...prev,
|
||||
[panelId]: React.createElement(component)
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
@@ -73,8 +79,11 @@ export function SidePanel() {
|
||||
setPanels((prev) => {
|
||||
const component = SidebarModel.instance.getPanelComponent(panelId);
|
||||
if (component) {
|
||||
// Force recreation with new node props
|
||||
prev[panelId] = React.createElement(component);
|
||||
// Force recreation with new node props - MUST return new object for React to detect change
|
||||
return {
|
||||
...prev,
|
||||
[panelId]: React.createElement(component)
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
|
||||
@@ -34,8 +34,9 @@ export const ToastLayer = {
|
||||
toast.success(<ToastCard type={ToastType.Success} message={message} />);
|
||||
},
|
||||
|
||||
showError(message: string, duration = 1000000) {
|
||||
toast.error((t) => <ToastCard type={ToastType.Danger} message={message} onClose={() => toast.dismiss(t.id)} />, {
|
||||
showError(message: string, duration = Infinity) {
|
||||
// Don't pass onClose callback - makes toast permanent with no close button
|
||||
toast.error(<ToastCard type={ToastType.Danger} message={message} />, {
|
||||
duration
|
||||
});
|
||||
},
|
||||
|
||||
@@ -45,6 +45,7 @@ import { ViewerConnection } from '../ViewerConnection';
|
||||
import { HighlightOverlay } from './CanvasOverlays/HighlightOverlay';
|
||||
import { CanvasTabs } from './CanvasTabs';
|
||||
import CommentLayer from './commentlayer';
|
||||
import { EditorBanner } from './EditorBanner';
|
||||
// Import test utilities for console debugging (dev only)
|
||||
import '../services/HighlightManager/test-highlights';
|
||||
import { ConnectionPopup } from './ConnectionPopup';
|
||||
@@ -241,6 +242,7 @@ export class NodeGraphEditor extends View {
|
||||
titleRoot: Root = null;
|
||||
highlightOverlayRoot: Root = null;
|
||||
canvasTabsRoot: Root = null;
|
||||
editorBannerRoot: Root = null;
|
||||
|
||||
constructor(args) {
|
||||
super();
|
||||
@@ -463,6 +465,11 @@ export class NodeGraphEditor extends View {
|
||||
setReadOnly(readOnly: boolean) {
|
||||
this.readOnly = readOnly;
|
||||
this.commentLayer?.setReadOnly(readOnly);
|
||||
|
||||
// Update banner visibility when read-only status changes
|
||||
if (this.editorBannerRoot) {
|
||||
this.renderEditorBanner();
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
@@ -928,6 +935,11 @@ export class NodeGraphEditor extends View {
|
||||
this.renderCanvasTabs();
|
||||
}, 1);
|
||||
|
||||
// Render the editor banner (for read-only mode)
|
||||
setTimeout(() => {
|
||||
this.renderEditorBanner();
|
||||
}, 1);
|
||||
|
||||
this.relayout();
|
||||
this.repaint();
|
||||
|
||||
@@ -983,6 +995,42 @@ export class NodeGraphEditor extends View {
|
||||
console.log(`[NodeGraphEditor] Saved workspace and generated code for node ${nodeId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the EditorBanner React component (for read-only mode)
|
||||
*/
|
||||
renderEditorBanner() {
|
||||
const bannerElement = this.el.find('#editor-banner-root').get(0);
|
||||
if (!bannerElement) {
|
||||
console.warn('Editor banner root not found in DOM');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create React root if it doesn't exist
|
||||
if (!this.editorBannerRoot) {
|
||||
this.editorBannerRoot = createRoot(bannerElement);
|
||||
}
|
||||
|
||||
// Only show banner if in read-only mode
|
||||
if (this.readOnly) {
|
||||
this.editorBannerRoot.render(
|
||||
React.createElement(EditorBanner, {
|
||||
onDismiss: this.handleDismissBanner.bind(this)
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Clear banner if not in read-only mode
|
||||
this.editorBannerRoot.render(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle banner dismiss
|
||||
*/
|
||||
handleDismissBanner() {
|
||||
console.log('[NodeGraphEditor] Banner dismissed');
|
||||
// Banner handles its own visibility via state
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node bounds for the highlight overlay
|
||||
* Maps node IDs to their screen coordinates
|
||||
@@ -1807,17 +1855,20 @@ export class NodeGraphEditor extends View {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always select the node in the selector if not already selected
|
||||
if (!node.selected) {
|
||||
// Select node
|
||||
this.clearSelection();
|
||||
this.commentLayer?.clearSelection();
|
||||
node.selected = true;
|
||||
this.selector.select([node]);
|
||||
SidebarModel.instance.switchToNode(node.model);
|
||||
|
||||
this.repaint();
|
||||
} else {
|
||||
// Double selection
|
||||
}
|
||||
|
||||
// Always switch to the node in the sidebar (fixes property panel stuck issue)
|
||||
SidebarModel.instance.switchToNode(node.model);
|
||||
|
||||
// Handle double-click navigation
|
||||
if (this.leftButtonIsDoubleClicked) {
|
||||
if (node.model.type instanceof ComponentModel) {
|
||||
this.switchToComponent(node.model.type, { pushHistory: true });
|
||||
} else {
|
||||
@@ -1832,7 +1883,7 @@ export class NodeGraphEditor extends View {
|
||||
if (type) {
|
||||
// @ts-expect-error TODO: this is wrong!
|
||||
this.switchToComponent(type, { pushHistory: true });
|
||||
} else if (this.leftButtonIsDoubleClicked) {
|
||||
} else {
|
||||
//there was no type that matched, so forward the double click event to the sidebar
|
||||
SidebarModel.instance.invokeActive('doubleClick', node);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* GitHubPanel styles
|
||||
* Uses design tokens for theming
|
||||
*/
|
||||
|
||||
.GitHubPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
}
|
||||
|
||||
.Tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.Tab {
|
||||
padding: 12px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--theme-color-fg-default);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
&.TabActive {
|
||||
color: var(--theme-color-primary);
|
||||
border-bottom-color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.Content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.IssuesTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.Filters {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 13px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.IssuesList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.EmptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
|
||||
h3 {
|
||||
margin: 12px 0 8px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 20px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.EmptyStateIcon {
|
||||
font-size: 48px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ConnectButton {
|
||||
padding: 10px 20px;
|
||||
background-color: var(--theme-color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.ComingSoon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* GitHubPanel - GitHub Issues and Pull Requests integration
|
||||
*
|
||||
* Displays GitHub issues and PRs for the connected repository
|
||||
* with filtering, search, and detail views.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { GitHubClient, GitHubOAuthService } from '../../../services/github';
|
||||
import { IssuesList } from './components/IssuesTab/IssuesList';
|
||||
import { PRsList } from './components/PullRequestsTab/PRsList';
|
||||
import styles from './GitHubPanel.module.scss';
|
||||
import { useGitHubRepository } from './hooks/useGitHubRepository';
|
||||
import { useIssues } from './hooks/useIssues';
|
||||
import { usePullRequests } from './hooks/usePullRequests';
|
||||
|
||||
type TabType = 'issues' | 'pullRequests';
|
||||
|
||||
export function GitHubPanel() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('issues');
|
||||
const client = GitHubClient.instance;
|
||||
const { owner, repo, isGitHub, isReady } = useGitHubRepository();
|
||||
|
||||
// Check if GitHub is connected
|
||||
const isConnected = client.isReady();
|
||||
|
||||
const handleConnectGitHub = async () => {
|
||||
try {
|
||||
await GitHubOAuthService.instance.initiateOAuth();
|
||||
} catch (error) {
|
||||
console.error('Failed to initiate GitHub OAuth:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className={styles.GitHubPanel}>
|
||||
<div className={styles.EmptyState}>
|
||||
<div className={styles.EmptyStateIcon}>🔗</div>
|
||||
<h3>Connect GitHub</h3>
|
||||
<p>Connect your GitHub account to view and manage issues and pull requests.</p>
|
||||
<button className={styles.ConnectButton} onClick={handleConnectGitHub}>
|
||||
Connect GitHub Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isGitHub) {
|
||||
return (
|
||||
<div className={styles.GitHubPanel}>
|
||||
<div className={styles.EmptyState}>
|
||||
<div className={styles.EmptyStateIcon}>📦</div>
|
||||
<h3>Not a GitHub Repository</h3>
|
||||
<p>This project is not connected to a GitHub repository.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className={styles.GitHubPanel}>
|
||||
<div className={styles.EmptyState}>
|
||||
<div className={styles.EmptyStateIcon}>⚙️</div>
|
||||
<h3>Loading Repository</h3>
|
||||
<p>Loading repository information...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.GitHubPanel}>
|
||||
<div className={styles.Header}>
|
||||
<div className={styles.Tabs}>
|
||||
<button
|
||||
className={`${styles.Tab} ${activeTab === 'issues' ? styles.TabActive : ''}`}
|
||||
onClick={() => setActiveTab('issues')}
|
||||
>
|
||||
Issues
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.Tab} ${activeTab === 'pullRequests' ? styles.TabActive : ''}`}
|
||||
onClick={() => setActiveTab('pullRequests')}
|
||||
>
|
||||
Pull Requests
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.Content}>
|
||||
{activeTab === 'issues' && <IssuesTab owner={owner} repo={repo} />}
|
||||
{activeTab === 'pullRequests' && <PullRequestsTab owner={owner} repo={repo} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues tab content
|
||||
*/
|
||||
function IssuesTab({ owner, repo }: { owner: string; repo: string }) {
|
||||
const { issues, loading, error, hasMore, loadMore, loadingMore, refetch } = useIssues({
|
||||
owner,
|
||||
repo,
|
||||
filters: { state: 'open' }
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.IssuesTab}>
|
||||
<IssuesList
|
||||
issues={issues}
|
||||
loading={loading}
|
||||
error={error}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMore}
|
||||
loadingMore={loadingMore}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull Requests tab content
|
||||
*/
|
||||
function PullRequestsTab({ owner, repo }: { owner: string; repo: string }) {
|
||||
const { pullRequests, loading, error, hasMore, loadMore, loadingMore, refetch } = usePullRequests({
|
||||
owner,
|
||||
repo,
|
||||
filters: { state: 'open' }
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.PullRequestsTab}>
|
||||
<PRsList
|
||||
pullRequests={pullRequests}
|
||||
loading={loading}
|
||||
error={error}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMore}
|
||||
loadingMore={loadingMore}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* IssueDetail Styles - Slide-out panel
|
||||
*/
|
||||
|
||||
.IssueDetailOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.IssueDetail {
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
height: 100%;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.Header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.TitleSection {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.Title {
|
||||
flex: 1;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.StatusBadge {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
|
||||
&[data-state='open'] {
|
||||
background-color: rgba(46, 160, 67, 0.15);
|
||||
color: #2ea043;
|
||||
}
|
||||
|
||||
&[data-state='closed'] {
|
||||
background-color: rgba(177, 24, 24, 0.15);
|
||||
color: #da3633;
|
||||
}
|
||||
}
|
||||
|
||||
.CloseButton {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-4);
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
}
|
||||
|
||||
.Meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
strong {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.Labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.Label {
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Body {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.MarkdownContent {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.NoDescription {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.Footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--theme-color-border-default);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ViewOnGitHub {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--theme-color-primary);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* IssueDetail Component
|
||||
*
|
||||
* Slide-out panel displaying full issue details with markdown rendering
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { GitHubIssue } from '../../../../../services/github/GitHubTypes';
|
||||
import styles from './IssueDetail.module.scss';
|
||||
|
||||
interface IssueDetailProps {
|
||||
issue: GitHubIssue;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function IssueDetail({ issue, onClose }: IssueDetailProps) {
|
||||
return (
|
||||
<div className={styles.IssueDetailOverlay} onClick={onClose}>
|
||||
<div className={styles.IssueDetail} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.Header}>
|
||||
<div className={styles.TitleSection}>
|
||||
<h2 className={styles.Title}>
|
||||
#{issue.number} {issue.title}
|
||||
</h2>
|
||||
<div className={styles.StatusBadge} data-state={issue.state}>
|
||||
{issue.state === 'open' ? '🟢' : '🔴'} {issue.state}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className={styles.CloseButton} onClick={onClose} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.Meta}>
|
||||
<span>
|
||||
<strong>{issue.user.login}</strong> opened this issue {getRelativeTimeString(new Date(issue.created_at))}
|
||||
</span>
|
||||
{issue.comments > 0 && <span>• {issue.comments} comments</span>}
|
||||
</div>
|
||||
|
||||
{issue.labels && issue.labels.length > 0 && (
|
||||
<div className={styles.Labels}>
|
||||
{issue.labels.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className={styles.Label}
|
||||
style={{
|
||||
backgroundColor: `#${label.color}`,
|
||||
color: getContrastColor(label.color)
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.Body}>
|
||||
{issue.body ? (
|
||||
<div className={styles.MarkdownContent}>{issue.body}</div>
|
||||
) : (
|
||||
<p className={styles.NoDescription}>No description provided.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.Footer}>
|
||||
<a
|
||||
href={issue.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.ViewOnGitHub}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View on GitHub →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative time string (e.g., "2 hours ago", "3 days ago")
|
||||
*/
|
||||
function getRelativeTimeString(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) {
|
||||
return 'just now';
|
||||
} else if (diffMin < 60) {
|
||||
return `${diffMin} minute${diffMin !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffHour < 24) {
|
||||
return `${diffHour} hour${diffHour !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffDay < 30) {
|
||||
return `${diffDay} day${diffDay !== 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contrasting text color (black or white) for a background color
|
||||
*/
|
||||
function getContrastColor(hexColor: string): string {
|
||||
// Remove # if present
|
||||
const hex = hexColor.replace('#', '');
|
||||
|
||||
// Convert to RGB
|
||||
const r = parseInt(hex.substr(0, 2), 16);
|
||||
const g = parseInt(hex.substr(2, 2), 16);
|
||||
const b = parseInt(hex.substr(4, 2), 16);
|
||||
|
||||
// Calculate luminance
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff';
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* IssueItem Styles
|
||||
*/
|
||||
|
||||
.IssueItem {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-4);
|
||||
border-color: var(--theme-color-border-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.TitleRow {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.Number {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.Title {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.StatusBadge {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
|
||||
&[data-state='open'] {
|
||||
background-color: rgba(46, 160, 67, 0.15);
|
||||
color: #2ea043;
|
||||
}
|
||||
|
||||
&[data-state='closed'] {
|
||||
background-color: rgba(177, 24, 24, 0.15);
|
||||
color: #da3633;
|
||||
}
|
||||
}
|
||||
|
||||
.Meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.Author {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.Comments {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.Labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.Label {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.MoreLabels {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* IssueItem Component
|
||||
*
|
||||
* Displays a single GitHub issue in a card format
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { GitHubIssue } from '../../../../../services/github/GitHubTypes';
|
||||
import styles from './IssueItem.module.scss';
|
||||
|
||||
interface IssueItemProps {
|
||||
issue: GitHubIssue;
|
||||
onClick: (issue: GitHubIssue) => void;
|
||||
}
|
||||
|
||||
export function IssueItem({ issue, onClick }: IssueItemProps) {
|
||||
const createdDate = new Date(issue.created_at);
|
||||
const relativeTime = getRelativeTimeString(createdDate);
|
||||
|
||||
return (
|
||||
<div className={styles.IssueItem} onClick={() => onClick(issue)}>
|
||||
<div className={styles.Header}>
|
||||
<div className={styles.TitleRow}>
|
||||
<span className={styles.Number}>#{issue.number}</span>
|
||||
<span className={styles.Title}>{issue.title}</span>
|
||||
</div>
|
||||
<div className={styles.StatusBadge} data-state={issue.state}>
|
||||
{issue.state === 'open' ? '🟢' : '🔴'} {issue.state}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.Meta}>
|
||||
<span className={styles.Author}>
|
||||
Opened by {issue.user.login} {relativeTime}
|
||||
</span>
|
||||
{issue.comments > 0 && <span className={styles.Comments}>💬 {issue.comments}</span>}
|
||||
</div>
|
||||
|
||||
{issue.labels && issue.labels.length > 0 && (
|
||||
<div className={styles.Labels}>
|
||||
{issue.labels.slice(0, 3).map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className={styles.Label}
|
||||
style={{
|
||||
backgroundColor: `#${label.color}`,
|
||||
color: getContrastColor(label.color)
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
{issue.labels.length > 3 && <span className={styles.MoreLabels}>+{issue.labels.length - 3}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative time string (e.g., "2 hours ago", "3 days ago")
|
||||
*/
|
||||
function getRelativeTimeString(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) {
|
||||
return 'just now';
|
||||
} else if (diffMin < 60) {
|
||||
return `${diffMin} minute${diffMin !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffHour < 24) {
|
||||
return `${diffHour} hour${diffHour !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffDay < 30) {
|
||||
return `${diffDay} day${diffDay !== 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contrasting text color (black or white) for a background color
|
||||
*/
|
||||
function getContrastColor(hexColor: string): string {
|
||||
// Remove # if present
|
||||
const hex = hexColor.replace('#', '');
|
||||
|
||||
// Convert to RGB
|
||||
const r = parseInt(hex.substr(0, 2), 16);
|
||||
const g = parseInt(hex.substr(2, 2), 16);
|
||||
const b = parseInt(hex.substr(4, 2), 16);
|
||||
|
||||
// Calculate luminance
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff';
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* IssuesList Styles
|
||||
*/
|
||||
|
||||
.IssuesList {
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.LoadingState,
|
||||
.ErrorState,
|
||||
.EmptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
.Spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--theme-color-border-default);
|
||||
border-top-color: var(--theme-color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.LoadingState p {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ErrorState {
|
||||
color: var(--theme-color-fg-error);
|
||||
}
|
||||
|
||||
.ErrorIcon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ErrorState h3 {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ErrorState p {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.RetryButton {
|
||||
padding: 8px 16px;
|
||||
background-color: var(--theme-color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.EmptyIcon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.EmptyState h3 {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.EmptyState p {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.LoadMoreButton {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-top: 8px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--theme-color-bg-4);
|
||||
border-color: var(--theme-color-border-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.SmallSpinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--theme-color-border-default);
|
||||
border-top-color: var(--theme-color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.EndMessage {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* IssuesList Component
|
||||
*
|
||||
* Displays a list of GitHub issues with loading states and pagination
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import type { GitHubIssue } from '../../../../../services/github/GitHubTypes';
|
||||
import { IssueDetail } from './IssueDetail';
|
||||
import { IssueItem } from './IssueItem';
|
||||
import styles from './IssuesList.module.scss';
|
||||
|
||||
interface IssuesListProps {
|
||||
issues: GitHubIssue[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
hasMore: boolean;
|
||||
loadMore: () => Promise<void>;
|
||||
loadingMore: boolean;
|
||||
onRefresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function IssuesList({ issues, loading, error, hasMore, loadMore, loadingMore, onRefresh }: IssuesListProps) {
|
||||
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.LoadingState}>
|
||||
<div className={styles.Spinner} />
|
||||
<p>Loading issues...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.ErrorState}>
|
||||
<div className={styles.ErrorIcon}>⚠️</div>
|
||||
<h3>Failed to load issues</h3>
|
||||
<p>{error.message}</p>
|
||||
<button className={styles.RetryButton} onClick={onRefresh}>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<div className={styles.EmptyState}>
|
||||
<div className={styles.EmptyIcon}>📝</div>
|
||||
<h3>No issues found</h3>
|
||||
<p>This repository doesn't have any issues yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.IssuesList}>
|
||||
{issues.map((issue) => (
|
||||
<IssueItem key={issue.id} issue={issue} onClick={setSelectedIssue} />
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<button className={styles.LoadMoreButton} onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<div className={styles.SmallSpinner} />
|
||||
Loading more...
|
||||
</>
|
||||
) : (
|
||||
'Load More'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!hasMore && issues.length > 0 && <div className={styles.EndMessage}>No more issues to load</div>}
|
||||
</div>
|
||||
|
||||
{selectedIssue && <IssueDetail issue={selectedIssue} onClose={() => setSelectedIssue(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* PRDetail Styles - Slide-out panel
|
||||
*/
|
||||
|
||||
.PRDetailOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.PRDetail {
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
height: 100%;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.Header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.TitleSection {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.Title {
|
||||
flex: 1;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.StatusBadge {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
|
||||
&[data-status='open'] {
|
||||
background-color: rgba(46, 160, 67, 0.15);
|
||||
color: #2ea043;
|
||||
}
|
||||
|
||||
&[data-status='draft'] {
|
||||
background-color: rgba(110, 118, 129, 0.15);
|
||||
color: #6e7681;
|
||||
}
|
||||
|
||||
&[data-status='merged'] {
|
||||
background-color: rgba(137, 87, 229, 0.15);
|
||||
color: #8957e5;
|
||||
}
|
||||
|
||||
&[data-status='closed'] {
|
||||
background-color: rgba(177, 24, 24, 0.15);
|
||||
color: #da3633;
|
||||
}
|
||||
}
|
||||
|
||||
.CloseButton {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-4);
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
}
|
||||
|
||||
.Meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
strong {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.Branch {
|
||||
background-color: var(--theme-color-bg-4);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
|
||||
.Labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.Label {
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--theme-color-border-default);
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.StatItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.StatLabel {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.StatValue {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.Body {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.MarkdownContent {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.NoDescription {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.MergeInfo,
|
||||
.DraftInfo,
|
||||
.ClosedInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-top: 1px solid var(--theme-color-border-default);
|
||||
font-size: 13px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
.MergeIcon,
|
||||
.DraftIcon,
|
||||
.ClosedIcon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.Footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--theme-color-border-default);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ViewOnGitHub {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--theme-color-primary);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* PRDetail Component
|
||||
*
|
||||
* Slide-out panel displaying full pull request details
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { GitHubPullRequest } from '../../../../../services/github/GitHubTypes';
|
||||
import styles from './PRDetail.module.scss';
|
||||
|
||||
interface PRDetailProps {
|
||||
pr: GitHubPullRequest;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PRDetail({ pr, onClose }: PRDetailProps) {
|
||||
const isDraft = pr.draft;
|
||||
const isMerged = pr.merged_at !== null;
|
||||
const isClosed = pr.state === 'closed' && !isMerged;
|
||||
|
||||
return (
|
||||
<div className={styles.PRDetailOverlay} onClick={onClose}>
|
||||
<div className={styles.PRDetail} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.Header}>
|
||||
<div className={styles.TitleSection}>
|
||||
<h2 className={styles.Title}>
|
||||
#{pr.number} {pr.title}
|
||||
</h2>
|
||||
<div className={styles.StatusBadge} data-status={getStatus(pr)}>
|
||||
{getStatusIcon(pr)} {getStatusText(pr)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className={styles.CloseButton} onClick={onClose} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.Meta}>
|
||||
<span>
|
||||
<strong>{pr.user.login}</strong> wants to merge {pr.commits} commit{pr.commits !== 1 ? 's' : ''} into{' '}
|
||||
<code className={styles.Branch}>{pr.base.ref}</code> from{' '}
|
||||
<code className={styles.Branch}>{pr.head.ref}</code>
|
||||
</span>
|
||||
<span>• Opened {getRelativeTimeString(new Date(pr.created_at))}</span>
|
||||
</div>
|
||||
|
||||
{pr.labels && pr.labels.length > 0 && (
|
||||
<div className={styles.Labels}>
|
||||
{pr.labels.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className={styles.Label}
|
||||
style={{
|
||||
backgroundColor: `#${label.color}`,
|
||||
color: getContrastColor(label.color)
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.Stats}>
|
||||
<div className={styles.StatItem}>
|
||||
<span className={styles.StatLabel}>Commits</span>
|
||||
<span className={styles.StatValue}>{pr.commits}</span>
|
||||
</div>
|
||||
<div className={styles.StatItem}>
|
||||
<span className={styles.StatLabel}>Files Changed</span>
|
||||
<span className={styles.StatValue}>{pr.changed_files}</span>
|
||||
</div>
|
||||
<div className={styles.StatItem}>
|
||||
<span className={styles.StatLabel}>Comments</span>
|
||||
<span className={styles.StatValue}>{pr.comments}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.Body}>
|
||||
{pr.body ? (
|
||||
<div className={styles.MarkdownContent}>{pr.body}</div>
|
||||
) : (
|
||||
<p className={styles.NoDescription}>No description provided.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMerged && pr.merged_at && (
|
||||
<div className={styles.MergeInfo}>
|
||||
<span className={styles.MergeIcon}>🟣</span>
|
||||
<span>Merged {getRelativeTimeString(new Date(pr.merged_at))}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDraft && (
|
||||
<div className={styles.DraftInfo}>
|
||||
<span className={styles.DraftIcon}>📝</span>
|
||||
<span>This pull request is still a work in progress</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isClosed && (
|
||||
<div className={styles.ClosedInfo}>
|
||||
<span className={styles.ClosedIcon}>🔴</span>
|
||||
<span>This pull request was closed without merging</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.Footer}>
|
||||
<a
|
||||
href={pr.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.ViewOnGitHub}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View on GitHub →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PR status
|
||||
*/
|
||||
function getStatus(pr: GitHubPullRequest): string {
|
||||
if (pr.draft) return 'draft';
|
||||
if (pr.merged_at) return 'merged';
|
||||
if (pr.state === 'closed') return 'closed';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status icon
|
||||
*/
|
||||
function getStatusIcon(pr: GitHubPullRequest): string {
|
||||
if (pr.draft) return '📝';
|
||||
if (pr.merged_at) return '🟣';
|
||||
if (pr.state === 'closed') return '🔴';
|
||||
return '🟢';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status text
|
||||
*/
|
||||
function getStatusText(pr: GitHubPullRequest): string {
|
||||
if (pr.draft) return 'Draft';
|
||||
if (pr.merged_at) return 'Merged';
|
||||
if (pr.state === 'closed') return 'Closed';
|
||||
return 'Open';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative time string (e.g., "2 hours ago", "3 days ago")
|
||||
*/
|
||||
function getRelativeTimeString(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) {
|
||||
return 'just now';
|
||||
} else if (diffMin < 60) {
|
||||
return `${diffMin} minute${diffMin !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffHour < 24) {
|
||||
return `${diffHour} hour${diffHour !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffDay < 30) {
|
||||
return `${diffDay} day${diffDay !== 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contrasting text color (black or white) for a background color
|
||||
*/
|
||||
function getContrastColor(hexColor: string): string {
|
||||
// Remove # if present
|
||||
const hex = hexColor.replace('#', '');
|
||||
|
||||
// Convert to RGB
|
||||
const r = parseInt(hex.substr(0, 2), 16);
|
||||
const g = parseInt(hex.substr(2, 2), 16);
|
||||
const b = parseInt(hex.substr(4, 2), 16);
|
||||
|
||||
// Calculate luminance
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff';
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* PRItem Styles
|
||||
*/
|
||||
|
||||
.PRItem {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-4);
|
||||
border-color: var(--theme-color-border-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.TitleRow {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.Number {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.Title {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.StatusBadge {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
|
||||
&[data-status='open'] {
|
||||
background-color: rgba(46, 160, 67, 0.15);
|
||||
color: #2ea043;
|
||||
}
|
||||
|
||||
&[data-status='draft'] {
|
||||
background-color: rgba(110, 118, 129, 0.15);
|
||||
color: #6e7681;
|
||||
}
|
||||
|
||||
&[data-status='merged'] {
|
||||
background-color: rgba(137, 87, 229, 0.15);
|
||||
color: #8957e5;
|
||||
}
|
||||
|
||||
&[data-status='closed'] {
|
||||
background-color: rgba(177, 24, 24, 0.15);
|
||||
color: #da3633;
|
||||
}
|
||||
}
|
||||
|
||||
.Meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.Author {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.Time {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.Stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.Stat {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.Labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.Label {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.MoreLabels {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* PRItem Component
|
||||
*
|
||||
* Displays a single GitHub pull request in a card format
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { GitHubPullRequest } from '../../../../../services/github/GitHubTypes';
|
||||
import styles from './PRItem.module.scss';
|
||||
|
||||
interface PRItemProps {
|
||||
pr: GitHubPullRequest;
|
||||
onClick: (pr: GitHubPullRequest) => void;
|
||||
}
|
||||
|
||||
export function PRItem({ pr, onClick }: PRItemProps) {
|
||||
const createdDate = new Date(pr.created_at);
|
||||
const relativeTime = getRelativeTimeString(createdDate);
|
||||
|
||||
return (
|
||||
<div className={styles.PRItem} onClick={() => onClick(pr)}>
|
||||
<div className={styles.Header}>
|
||||
<div className={styles.TitleRow}>
|
||||
<span className={styles.Number}>#{pr.number}</span>
|
||||
<span className={styles.Title}>{pr.title}</span>
|
||||
</div>
|
||||
<div className={styles.StatusBadge} data-status={getStatus(pr)}>
|
||||
{getStatusIcon(pr)} {getStatusText(pr)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.Meta}>
|
||||
<span className={styles.Author}>
|
||||
{pr.user.login} wants to merge into {pr.base.ref} from {pr.head.ref}
|
||||
</span>
|
||||
<span className={styles.Time}>{relativeTime}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.Stats}>
|
||||
{pr.comments > 0 && <span className={styles.Stat}>💬 {pr.comments}</span>}
|
||||
{pr.commits > 0 && <span className={styles.Stat}>📝 {pr.commits} commits</span>}
|
||||
{pr.changed_files > 0 && <span className={styles.Stat}>📄 {pr.changed_files} files</span>}
|
||||
</div>
|
||||
|
||||
{pr.labels && pr.labels.length > 0 && (
|
||||
<div className={styles.Labels}>
|
||||
{pr.labels.slice(0, 3).map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className={styles.Label}
|
||||
style={{
|
||||
backgroundColor: `#${label.color}`,
|
||||
color: getContrastColor(label.color)
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
{pr.labels.length > 3 && <span className={styles.MoreLabels}>+{pr.labels.length - 3}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PR status
|
||||
*/
|
||||
function getStatus(pr: GitHubPullRequest): string {
|
||||
if (pr.draft) return 'draft';
|
||||
if (pr.merged_at) return 'merged';
|
||||
if (pr.state === 'closed') return 'closed';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status icon
|
||||
*/
|
||||
function getStatusIcon(pr: GitHubPullRequest): string {
|
||||
if (pr.draft) return '📝';
|
||||
if (pr.merged_at) return '🟣';
|
||||
if (pr.state === 'closed') return '🔴';
|
||||
return '🟢';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status text
|
||||
*/
|
||||
function getStatusText(pr: GitHubPullRequest): string {
|
||||
if (pr.draft) return 'Draft';
|
||||
if (pr.merged_at) return 'Merged';
|
||||
if (pr.state === 'closed') return 'Closed';
|
||||
return 'Open';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative time string (e.g., "2 hours ago", "3 days ago")
|
||||
*/
|
||||
function getRelativeTimeString(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) {
|
||||
return 'just now';
|
||||
} else if (diffMin < 60) {
|
||||
return `${diffMin} minute${diffMin !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffHour < 24) {
|
||||
return `${diffHour} hour${diffHour !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffDay < 30) {
|
||||
return `${diffDay} day${diffDay !== 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contrasting text color (black or white) for a background color
|
||||
*/
|
||||
function getContrastColor(hexColor: string): string {
|
||||
// Remove # if present
|
||||
const hex = hexColor.replace('#', '');
|
||||
|
||||
// Convert to RGB
|
||||
const r = parseInt(hex.substr(0, 2), 16);
|
||||
const g = parseInt(hex.substr(2, 2), 16);
|
||||
const b = parseInt(hex.substr(4, 2), 16);
|
||||
|
||||
// Calculate luminance
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff';
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* PRsList Styles
|
||||
*/
|
||||
|
||||
.PRsList {
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.LoadingState,
|
||||
.ErrorState,
|
||||
.EmptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
.Spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--theme-color-border-default);
|
||||
border-top-color: var(--theme-color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.LoadingState p {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ErrorState {
|
||||
color: var(--theme-color-fg-error);
|
||||
}
|
||||
|
||||
.ErrorIcon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ErrorState h3 {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ErrorState p {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.RetryButton {
|
||||
padding: 8px 16px;
|
||||
background-color: var(--theme-color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.EmptyIcon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.EmptyState h3 {
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.EmptyState p {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.LoadMoreButton {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-top: 8px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--theme-color-bg-4);
|
||||
border-color: var(--theme-color-border-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.SmallSpinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--theme-color-border-default);
|
||||
border-top-color: var(--theme-color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.EndMessage {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* PRsList Component
|
||||
*
|
||||
* Displays a list of GitHub pull requests with loading states and pagination
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import type { GitHubPullRequest } from '../../../../../services/github/GitHubTypes';
|
||||
import { PRDetail } from './PRDetail';
|
||||
import { PRItem } from './PRItem';
|
||||
import styles from './PRsList.module.scss';
|
||||
|
||||
interface PRsListProps {
|
||||
pullRequests: GitHubPullRequest[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
hasMore: boolean;
|
||||
loadMore: () => Promise<void>;
|
||||
loadingMore: boolean;
|
||||
onRefresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function PRsList({ pullRequests, loading, error, hasMore, loadMore, loadingMore, onRefresh }: PRsListProps) {
|
||||
const [selectedPR, setSelectedPR] = useState<GitHubPullRequest | null>(null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.LoadingState}>
|
||||
<div className={styles.Spinner} />
|
||||
<p>Loading pull requests...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.ErrorState}>
|
||||
<div className={styles.ErrorIcon}>⚠️</div>
|
||||
<h3>Failed to load pull requests</h3>
|
||||
<p>{error.message}</p>
|
||||
<button className={styles.RetryButton} onClick={onRefresh}>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pullRequests.length === 0) {
|
||||
return (
|
||||
<div className={styles.EmptyState}>
|
||||
<div className={styles.EmptyIcon}>🔀</div>
|
||||
<h3>No pull requests found</h3>
|
||||
<p>This repository doesn't have any pull requests yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.PRsList}>
|
||||
{pullRequests.map((pr) => (
|
||||
<PRItem key={pr.id} pr={pr} onClick={setSelectedPR} />
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<button className={styles.LoadMoreButton} onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<div className={styles.SmallSpinner} />
|
||||
Loading more...
|
||||
</>
|
||||
) : (
|
||||
'Load More'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!hasMore && pullRequests.length > 0 && <div className={styles.EndMessage}>No more pull requests to load</div>}
|
||||
</div>
|
||||
|
||||
{selectedPR && <PRDetail pr={selectedPR} onClose={() => setSelectedPR(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* useGitHubRepository Hook
|
||||
*
|
||||
* Extracts GitHub repository information from the Git remote URL.
|
||||
* Returns owner, repo name, and connection status.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Git } from '@noodl/git';
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
import { mergeProject } from '@noodl-utils/projectmerger';
|
||||
|
||||
interface GitHubRepoInfo {
|
||||
owner: string | null;
|
||||
repo: string | null;
|
||||
isGitHub: boolean;
|
||||
isReady: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GitHub owner and repo from a remote URL
|
||||
* Handles formats:
|
||||
* - https://github.com/owner/repo.git
|
||||
* - git@github.com:owner/repo.git
|
||||
* - https://github.com/owner/repo
|
||||
*/
|
||||
function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
|
||||
if (!url || !url.includes('github.com')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove .git suffix if present
|
||||
const cleanUrl = url.replace(/\.git$/, '');
|
||||
|
||||
// Handle HTTPS format: https://github.com/owner/repo
|
||||
const httpsMatch = cleanUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
|
||||
if (httpsMatch) {
|
||||
return {
|
||||
owner: httpsMatch[1],
|
||||
repo: httpsMatch[2]
|
||||
};
|
||||
}
|
||||
|
||||
// Handle SSH format: git@github.com:owner/repo
|
||||
const sshMatch = cleanUrl.match(/github\.com:([^/]+)\/([^/]+)/);
|
||||
if (sshMatch) {
|
||||
return {
|
||||
owner: sshMatch[1],
|
||||
repo: sshMatch[2]
|
||||
};
|
||||
}
|
||||
|
||||
return 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
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
} else {
|
||||
setRepoInfo({
|
||||
owner: null,
|
||||
repo: null,
|
||||
isGitHub: true, // It's GitHub but couldn't parse
|
||||
isReady: false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub repository info:', error);
|
||||
setRepoInfo({
|
||||
owner: null,
|
||||
repo: null,
|
||||
isGitHub: false,
|
||||
isReady: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fetchRepoInfo();
|
||||
|
||||
// Refetch when project changes
|
||||
const handleProjectChange = () => {
|
||||
fetchRepoInfo();
|
||||
};
|
||||
|
||||
ProjectModel.instance?.on('projectOpened', handleProjectChange);
|
||||
ProjectModel.instance?.on('remoteChanged', handleProjectChange);
|
||||
|
||||
return () => {
|
||||
ProjectModel.instance?.off(handleProjectChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return repoInfo;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* useIssues Hook
|
||||
*
|
||||
* Fetches and manages GitHub issues for a repository.
|
||||
* Handles pagination, filtering, and real-time updates.
|
||||
*/
|
||||
|
||||
import { useEventListener } from '@noodl-hooks/useEventListener';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { GitHubClient } from '../../../../services/github';
|
||||
import type { GitHubIssue, GitHubIssueFilters } from '../../../../services/github/GitHubTypes';
|
||||
|
||||
interface UseIssuesOptions {
|
||||
owner: string | null;
|
||||
repo: string | null;
|
||||
filters?: GitHubIssueFilters;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseIssuesResult {
|
||||
issues: GitHubIssue[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
hasMore: boolean;
|
||||
loadMore: () => Promise<void>;
|
||||
loadingMore: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PER_PAGE = 30;
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage GitHub issues
|
||||
*/
|
||||
export function useIssues({ owner, repo, filters = {}, enabled = true }: UseIssuesOptions): UseIssuesResult {
|
||||
const [issues, setIssues] = useState<GitHubIssue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const client = GitHubClient.instance;
|
||||
|
||||
const fetchIssues = useCallback(
|
||||
async (pageNum: number = 1, append: boolean = false) => {
|
||||
if (!owner || !repo || !enabled) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (append) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const response = await client.listIssues(owner, repo, {
|
||||
...filters,
|
||||
per_page: DEFAULT_PER_PAGE,
|
||||
page: pageNum
|
||||
});
|
||||
|
||||
const newIssues = response.data;
|
||||
|
||||
if (append) {
|
||||
setIssues((prev) => [...prev, ...newIssues]);
|
||||
} else {
|
||||
setIssues(newIssues);
|
||||
}
|
||||
|
||||
// Check if there are more issues to load
|
||||
setHasMore(newIssues.length === DEFAULT_PER_PAGE);
|
||||
setPage(pageNum);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch issues:', err);
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch issues'));
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[owner, repo, enabled, filters, client]
|
||||
);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
await fetchIssues(1, false);
|
||||
}, [fetchIssues]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!loadingMore && hasMore) {
|
||||
await fetchIssues(page + 1, true);
|
||||
}
|
||||
}, [fetchIssues, page, hasMore, loadingMore]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [owner, repo, filters, enabled]);
|
||||
|
||||
// Listen for cache invalidation events
|
||||
useEventListener(client, 'rate-limit-updated', () => {
|
||||
// Could show a notification about rate limits
|
||||
});
|
||||
|
||||
return {
|
||||
issues,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
hasMore,
|
||||
loadMore,
|
||||
loadingMore
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* usePullRequests Hook
|
||||
*
|
||||
* Fetches and manages GitHub pull requests for a repository.
|
||||
* Handles pagination, filtering, and real-time updates.
|
||||
*/
|
||||
|
||||
import { useEventListener } from '@noodl-hooks/useEventListener';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { GitHubClient } from '../../../../services/github';
|
||||
import type { GitHubPullRequest, GitHubIssueFilters } from '../../../../services/github/GitHubTypes';
|
||||
|
||||
interface UsePullRequestsOptions {
|
||||
owner: string | null;
|
||||
repo: string | null;
|
||||
filters?: Omit<GitHubIssueFilters, 'milestone'>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UsePullRequestsResult {
|
||||
pullRequests: GitHubPullRequest[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
hasMore: boolean;
|
||||
loadMore: () => Promise<void>;
|
||||
loadingMore: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PER_PAGE = 30;
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage GitHub pull requests
|
||||
*/
|
||||
export function usePullRequests({
|
||||
owner,
|
||||
repo,
|
||||
filters = {},
|
||||
enabled = true
|
||||
}: UsePullRequestsOptions): UsePullRequestsResult {
|
||||
const [pullRequests, setPullRequests] = useState<GitHubPullRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const client = GitHubClient.instance;
|
||||
|
||||
const fetchPullRequests = useCallback(
|
||||
async (pageNum: number = 1, append: boolean = false) => {
|
||||
if (!owner || !repo || !enabled) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (append) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const response = await client.listPullRequests(owner, repo, {
|
||||
...filters,
|
||||
per_page: DEFAULT_PER_PAGE,
|
||||
page: pageNum
|
||||
});
|
||||
|
||||
const newPRs = response.data;
|
||||
|
||||
if (append) {
|
||||
setPullRequests((prev) => [...prev, ...newPRs]);
|
||||
} else {
|
||||
setPullRequests(newPRs);
|
||||
}
|
||||
|
||||
// Check if there are more PRs to load
|
||||
setHasMore(newPRs.length === DEFAULT_PER_PAGE);
|
||||
setPage(pageNum);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch pull requests:', err);
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch pull requests'));
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[owner, repo, enabled, filters, client]
|
||||
);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
await fetchPullRequests(1, false);
|
||||
}, [fetchPullRequests]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!loadingMore && hasMore) {
|
||||
await fetchPullRequests(page + 1, true);
|
||||
}
|
||||
}, [fetchPullRequests, page, hasMore, loadingMore]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [owner, repo, filters, enabled]);
|
||||
|
||||
// Listen for cache invalidation events
|
||||
useEventListener(client, 'rate-limit-updated', () => {
|
||||
// Could show a notification about rate limits
|
||||
});
|
||||
|
||||
return {
|
||||
pullRequests,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
hasMore,
|
||||
loadMore,
|
||||
loadingMore
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GitHubPanel } from './GitHubPanel';
|
||||
@@ -68,6 +68,7 @@ export class CodeEditorType extends TypeView {
|
||||
nodeId: string;
|
||||
|
||||
isPrimary: boolean;
|
||||
readOnly: boolean;
|
||||
|
||||
propertyRoot: Root | null = null;
|
||||
popoutRoot: Root | null = null;
|
||||
@@ -78,6 +79,14 @@ export class CodeEditorType extends TypeView {
|
||||
const p = args.port;
|
||||
const parent = args.parent;
|
||||
|
||||
// Debug: Log all port properties
|
||||
console.log('[CodeEditorType.fromPort] Port properties:', {
|
||||
name: p.name,
|
||||
readOnly: p.readOnly,
|
||||
type: p.type,
|
||||
allKeys: Object.keys(p)
|
||||
});
|
||||
|
||||
view.port = p;
|
||||
view.displayName = p.displayName ? p.displayName : p.name;
|
||||
view.name = p.name;
|
||||
@@ -90,6 +99,11 @@ export class CodeEditorType extends TypeView {
|
||||
view.isConnected = parent.model.isPortConnected(p.name, 'target');
|
||||
view.isDefault = parent.model.parameters[p.name] === undefined;
|
||||
|
||||
// Try multiple locations for readOnly flag
|
||||
view.readOnly = p.readOnly || p.type?.readOnly || getEditType(p)?.readOnly || false;
|
||||
|
||||
console.log('[CodeEditorType.fromPort] Resolved readOnly:', view.readOnly);
|
||||
|
||||
// HACK: Like most of Property panel,
|
||||
// since the property panel can have many code editors
|
||||
// we want to open the one most likely to be the
|
||||
@@ -316,7 +330,15 @@ export class CodeEditorType extends TypeView {
|
||||
validationType = 'script';
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log('[CodeEditorType] Rendering JavaScriptEditor:', {
|
||||
parameterName: scope.name,
|
||||
readOnly: this.readOnly,
|
||||
nodeId: nodeId
|
||||
});
|
||||
|
||||
// Render JavaScriptEditor with proper sizing and history support
|
||||
// For read-only fields, don't pass nodeId/parameterName (no history tracking)
|
||||
this.popoutRoot.render(
|
||||
React.createElement(JavaScriptEditor, {
|
||||
value: this.value || '',
|
||||
@@ -329,11 +351,12 @@ export class CodeEditorType extends TypeView {
|
||||
save();
|
||||
},
|
||||
validationType,
|
||||
disabled: this.readOnly, // Enable read-only mode if port is marked readOnly
|
||||
width: props.initialSize?.x || 800,
|
||||
height: props.initialSize?.y || 500,
|
||||
// Add history tracking
|
||||
nodeId: nodeId,
|
||||
parameterName: scope.name
|
||||
// Only add history tracking for editable fields
|
||||
nodeId: this.readOnly ? undefined : nodeId,
|
||||
parameterName: this.readOnly ? undefined : scope.name
|
||||
})
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getEditType } from '../utils';
|
||||
/**
|
||||
* Custom editor for Logic Builder workspace parameter
|
||||
* Shows an "Edit Blocks" button that opens the Blockly editor in a tab
|
||||
* And a "View Generated Code" button to show the compiled JavaScript
|
||||
*/
|
||||
export class LogicBuilderWorkspaceType extends TypeView {
|
||||
el: TSFixme;
|
||||
@@ -20,7 +21,7 @@ export class LogicBuilderWorkspaceType extends TypeView {
|
||||
view.displayName = p.displayName ? p.displayName : p.name;
|
||||
view.name = p.name;
|
||||
view.type = getEditType(p);
|
||||
view.group = p.group;
|
||||
view.group = null; // Hide group label
|
||||
view.tooltip = p.tooltip;
|
||||
view.value = parent.model.getParameter(p.name);
|
||||
view.parent = parent;
|
||||
@@ -31,13 +32,21 @@ export class LogicBuilderWorkspaceType extends TypeView {
|
||||
}
|
||||
|
||||
render() {
|
||||
// Create a simple container with a button
|
||||
const html = `
|
||||
// Hide empty group labels
|
||||
const hideEmptyGroupsCSS = `
|
||||
<style>
|
||||
/* Hide empty group labels */
|
||||
.property-editor-group-name:empty {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
// Create a simple container with single button
|
||||
const html =
|
||||
hideEmptyGroupsCSS +
|
||||
`
|
||||
<div class="property-basic-container logic-builder-workspace-editor" style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div class="property-label-container" style="display: flex; align-items: center; gap: 8px;">
|
||||
<div class="property-changed-dot" data-click="resetToDefault" style="display: none;"></div>
|
||||
<div class="property-label">${this.displayName}</div>
|
||||
</div>
|
||||
<button class="edit-blocks-button"
|
||||
style="
|
||||
padding: 8px 16px;
|
||||
@@ -52,7 +61,7 @@ export class LogicBuilderWorkspaceType extends TypeView {
|
||||
"
|
||||
onmouseover="this.style.backgroundColor='var(--theme-color-primary-hover)'"
|
||||
onmouseout="this.style.backgroundColor='var(--theme-color-primary)'">
|
||||
✨ Edit Logic Blocks
|
||||
View Logic Blocks
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -87,7 +87,9 @@ export function NodeLabel({ model, showHelp = true }: NodeLabelProps) {
|
||||
<div className="property-editor-label-and-buttons property-header-bar" style={{ flex: '0 0' }}>
|
||||
<div
|
||||
style={{ flexGrow: 1, overflow: 'hidden' }}
|
||||
onDoubleClick={() => {
|
||||
onDoubleClick={(e) => {
|
||||
// Stop propagation to prevent canvas double-click handler from triggering
|
||||
e.stopPropagation();
|
||||
if (!isEditingLabel) {
|
||||
onEditLabel();
|
||||
}
|
||||
|
||||
@@ -535,6 +535,9 @@ PopupLayer.prototype.showPopout = function (args) {
|
||||
|
||||
this.popouts.push(popout);
|
||||
|
||||
// Enable pointer events for outside-click-to-close when popouts are active
|
||||
this.$('.popup-layer').addClass('has-popouts');
|
||||
|
||||
if (args.animate) {
|
||||
popoutEl.css({
|
||||
transform: 'translateY(10px)',
|
||||
@@ -587,6 +590,8 @@ PopupLayer.prototype.hidePopout = function (popout) {
|
||||
|
||||
if (this.popouts.length === 0) {
|
||||
this.$('.popup-layer-blocker').css({ display: 'none' });
|
||||
// Disable pointer events when no popouts are active
|
||||
this.$('.popup-layer').removeClass('has-popouts');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
1038
packages/noodl-editor/src/editor/src/views/popuplayer.js.bak
Normal file
1038
packages/noodl-editor/src/editor/src/views/popuplayer.js.bak
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user