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:
Richard Osborne
2026-01-15 17:37:15 +01:00
parent dd73b1339b
commit ddcb9cd02e
86 changed files with 17408 additions and 1873 deletions

View File

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

View File

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

View File

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

View File

@@ -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 */

View File

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

View File

@@ -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',

View File

@@ -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({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { EditorBanner, type EditorBannerProps } from './EditorBanner';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff