Added new github integration tasks

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

View File

@@ -4,6 +4,87 @@ This document captures important discoveries and gotchas encountered during Open
---
## ✅ VersionControlPanel is Already React! (Jan 18, 2026)
### The Good News: No jQuery Rewrite Needed
**Context**: Phase 3 TASK-002B GitHub Advanced Integration - Concerned that extending the existing VersionControlPanel with GitHub features would require a jQuery-to-React rewrite.
**GREAT NEWS**: The entire VersionControlPanel is already **100% modern React**!
**What Was Verified**:
```
VersionControlPanel/
├── VersionControlPanel.tsx ✅ React (useState, useEffect, useRef)
├── context/ ✅ React Context API
│ └── index.tsx
├── components/
│ ├── LocalChanges.tsx ✅ React functional component
│ ├── History.tsx ✅ React functional component
│ ├── HistoryCommitDiff.tsx ✅ React functional component
│ ├── CommitChangesDiff.tsx ✅ React functional component
│ ├── DiffList.tsx ✅ React functional component
│ ├── BranchList.tsx ✅ React functional component
│ ├── BranchMerge.tsx ✅ React functional component
│ ├── MergeConflicts.tsx ✅ React functional component
│ └── Stashes.tsx ✅ React functional component
└── hooks/
└── useShowComponentDiffDocument.ts ✅ React hook
```
**Modern Patterns Already in Use**:
- React hooks (useState, useEffect, useRef)
- Context API (VersionControlContext)
- TypeScript throughout
- @noodl-core-ui design system components
- NO jQuery anywhere (except PopupLayer which is separate system)
**Visual Diff System Already Works**:
The "green nodes for additions, red for deletions" visual diff is already implemented:
1. Click any commit in History tab
2. `HistoryCommitDiff.tsx` shows the diff
3. `CommitChangesDiff.tsx` fetches project.json diff
4. `DiffList.tsx` renders component-level changes
5. `useShowComponentDiffDocument` opens visual node graph diff
**Integration Strategy**:
Instead of creating a separate GitHubPanel, extend VersionControlPanel:
```typescript
// Just add new tabs to existing panel
<Tabs
tabs={[
{ id: 'changes', content: <LocalChanges /> }, // Existing
{ id: 'history', content: <History /> }, // Existing
{ id: 'issues', content: <IssuesTab /> }, // NEW (11-16 hours)
{ id: 'prs', content: <PullRequestsTab /> } // NEW
]}
/>
```
**Why This Matters**:
- **No rewrite needed** - Just add React components to existing React panel
- **Shared context** - Can extend VersionControlContext with GitHub state
- **Visual diffs work** - The killer feature is already implemented
- **Familiar UX** - Users already know the Version Control panel
**Time Saved**: Estimated 50+ hours saved vs creating separate panel or rewriting jQuery.
**Documentation**: Created `GIT-INTEGRATION-STRATEGY.md` with full implementation plan.
**Location**:
- Panel: `packages/noodl-editor/src/editor/src/views/panels/VersionControlPanel/`
- Strategy: `dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002B-github-advanced-integration/GIT-INTEGRATION-STRATEGY.md`
**Keywords**: VersionControlPanel, React, GitHub integration, visual diff, DiffList, History, no jQuery, extend existing panel
---
## 🏗️ CRITICAL ARCHITECTURE PATTERNS
These fundamental patterns apply across ALL Noodl development. Understanding them prevents hours of debugging.

View File

@@ -1,6 +1,6 @@
# Phase 3: Editor UX Overhaul - Progress Tracker
**Last Updated:** 2026-01-14
**Last Updated:** 2026-01-18
**Overall Status:** 🟡 In Progress
---
@@ -9,27 +9,28 @@
| Metric | Value |
| ------------ | ------- |
| Total Tasks | 9 |
| Total Tasks | 10 |
| Completed | 3 |
| In Progress | 1 |
| In Progress | 2 |
| Not Started | 5 |
| **Progress** | **33%** |
| **Progress** | **30%** |
---
## Task Status
| Task | Name | Status | Notes |
| --------- | ----------------------- | -------------- | --------------------------------------------- |
| TASK-001 | Dashboard UX Foundation | 🟢 Complete | Tabbed navigation done |
| TASK-001B | Launcher Fixes | 🟢 Complete | All 4 subtasks implemented |
| TASK-002 | GitHub Integration | 🟢 Complete | OAuth + basic features done |
| TASK-002B | GitHub Advanced | 🟡 In Progress | GIT-004A complete, 5 subtasks remaining |
| TASK-003 | Shared Component System | 🔴 Not Started | Prefab system refactor |
| TASK-004 | AI Project Creation | 🔴 Not Started | AI scaffolding feature |
| TASK-005 | Deployment Automation | 🔴 Not Started | Planning docs only, no implementation |
| TASK-006 | Expressions Overhaul | 🔴 Not Started | Enhanced expression nodes |
| TASK-007 | App Config | 🟡 In Progress | Runtime ✅, UI mostly done (Monaco debugging) |
| Task | Name | Status | Notes |
| --------- | --------------------------- | -------------- | --------------------------------------------------------- |
| TASK-001 | Dashboard UX Foundation | 🟢 Complete | Tabbed navigation done |
| TASK-001B | Launcher Fixes | 🟢 Complete | All 4 subtasks implemented |
| TASK-002 | GitHub Integration | 🟢 Complete | OAuth + basic features done |
| TASK-002B | GitHub Advanced Integration | 🟡 In Progress | Part 1: GIT-004A-C done. Part 2: GIT-005-011 docs created |
| TASK-002C | GitHub Clone & Connect | 🟡 In Progress | Clone from launcher flow |
| TASK-003 | Shared Component System | 🔴 Not Started | Prefab system refactor |
| TASK-004 | AI Project Creation | 🔴 Not Started | AI scaffolding feature |
| TASK-005 | Deployment Automation | 🔴 Not Started | Planning docs only, no implementation |
| TASK-006 | Expressions Overhaul | 🔴 Not Started | Enhanced expression nodes |
| TASK-007 | App Config | 🟡 In Progress | Runtime ✅, UI mostly done (Monaco debugging) |
---
@@ -43,13 +44,15 @@
## Recent Updates
| Date | Update |
| ---------- | ------------------------------------------------------ |
| 2026-01-14 | TASK-002B GIT-004A complete (GitHub Client Foundation) |
| 2026-01-07 | Audit completed: corrected TASK-001B, TASK-005 status |
| 2026-01-07 | Added TASK-006 and TASK-007 to tracking |
| 2026-01-07 | TASK-008 moved to Phase 6 (UBA) |
| 2026-01-07 | TASK-000 moved to Phase 9 (Styles) |
| Date | Update |
| ---------- | ------------------------------------------------------------------------------- |
| 2026-01-18 | TASK-002B: Created GIT-005-011 task docs (Live Collaboration & Multi-Community) |
| 2026-01-18 | TASK-002B scope expanded to 501-662 hours total |
| 2026-01-14 | TASK-002B GIT-004A complete (GitHub Client Foundation) |
| 2026-01-07 | Audit completed: corrected TASK-001B, TASK-005 status |
| 2026-01-07 | Added TASK-006 and TASK-007 to tracking |
| 2026-01-07 | TASK-008 moved to Phase 6 (UBA) |
| 2026-01-07 | TASK-000 moved to Phase 9 (Styles) |
---

View File

@@ -329,6 +329,176 @@ Built complete GitHub Pull Requests panel following the same patterns as Issues
---
## [2026-01-16] - OAuth Flow Bug Fixes - Complete ✅
### Summary
Fixed critical bugs preventing GitHub OAuth from working. The OAuth flow was broken due to two separate OAuth implementations that weren't communicating properly.
### Root Causes Identified
1. **Git Path Null Error**: `git.openRepository()` didn't handle when `open()` returns null (not a git repo)
2. **Disconnected OAuth Flows**: Two separate OAuth implementations existed:
- `GitHubOAuthService.ts` (renderer) - Generated PKCE state, opened browser
- `github-oauth-handler.js` (main) - Had separate state, handled callback
- These didn't communicate - OAuth callback went to main process, but renderer never received it
### Files Modified
**Bug Fix 1 - Git Null Path:**
- `packages/noodl-git/src/git.ts` - Added null check in `openRepository()` to throw clear error instead of crashing
**Bug Fix 2 - OAuth Flow:**
- `packages/noodl-editor/src/editor/src/services/GitHubOAuthService.ts` - Complete rewrite to:
- Use IPC to get auth URL from main process (reuses main's state)
- Listen for `github-oauth-complete` and `github-oauth-error` IPC events
- Properly receive and store tokens after OAuth callback
**Bug Fix 3 - Error Handling:**
- `packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/hooks/useGitHubRepository.ts` - Added try/catch around `git.openRepository()` to gracefully handle non-git projects
**Bug Fix 4 - Initialization:**
- `packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/GitHubPanel.tsx` - Added:
- `GitHubOAuthService.initialize()` call on mount to restore saved tokens
- `useEventListener` to react to auth state changes
- Loading state while initializing
### Technical Notes
**OAuth Flow Now:**
1. User clicks "Connect GitHub Account"
2. GitHubOAuthService calls `ipcRenderer.invoke('github-oauth-start')` - main generates state
3. Main returns auth URL, renderer opens in browser
4. User authorizes, GitHub redirects to `noodl://github-callback`
5. Main process receives callback, validates state (its own), exchanges code for token
6. Main sends `github-oauth-complete` IPC event with token + user
7. GitHubOAuthService receives event, stores token, updates state
8. GitHubPanel re-renders as connected
**Token Storage:**
- Main process uses `jsonstorage` + Electron `safeStorage` for encryption
- Token persisted across sessions
- `initialize()` loads saved token on app start
### Existing Infrastructure Verified
The following was already correctly implemented in main.js:
- `initializeGitHubOAuthHandlers(app)` - Protocol handler registered
- `github-save-token` / `github-load-token` / `github-clear-token` IPC handlers
- Token encryption using Electron's safeStorage
### Error Messages Improved
- "Not a git repository: /path" instead of cryptic "path must be string, received null"
- Console logging for OAuth flow stages for debugging
### Testing Required
- [ ] Click "Connect GitHub Account" and complete OAuth flow
- [ ] Verify token is stored and user info displayed
- [ ] Close and reopen app, verify session is restored
- [ ] Test with non-git project (should show graceful error)
- [ ] Test with GitHub repo (should detect owner/repo)
---
## [2026-01-16] - Session 2: Critical Bug Fixes & Unification - Complete ✅
### Summary
Second bug-fix session addressing infinite loops, jsonstorage errors, IPC targeting issues, and unifying OAuth across launcher and editor.
### Bugs Fixed
**Bug 1: `jsonstorage.getSync` doesn't exist**
- **File:** `packages/noodl-editor/src/main/main.js` (line ~672)
- **Fix:** The jsonstorage module only has async `get(key, callback)`. Changed to Promise wrapper.
```javascript
const stored = await new Promise((resolve) => {
jsonstorage.get('github.token', (data) => resolve(data));
});
```
**Bug 2: IPC event not reaching renderer**
- **File:** `packages/noodl-editor/src/main/github-oauth-handler.js`
- **Fix:** Was sending to `windows[0]` which might be viewer/floating window, not editor. Now broadcasts to ALL windows.
```javascript
windows.forEach((win, index) => {
win.webContents.send('github-oauth-complete', result);
});
```
**Bug 3: Infinite loop in useIssues hook**
- **File:** `packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/hooks/useIssues.ts`
- **Root Cause:** `filters` object in useEffect deps creates new reference on every render
- **Fix:** Use `useRef` for filters and `JSON.stringify(filters)` for dependency comparison
```javascript
const filtersRef = useRef(filters);
filtersRef.current = filters;
const filtersKey = JSON.stringify(filters);
useEffect(() => { ... }, [owner, repo, filtersKey, enabled, refetch]);
```
**Bug 4: Infinite loop in usePullRequests hook**
- **File:** `packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/hooks/usePullRequests.ts`
- **Fix:** Same pattern as useIssues - ref for filters, JSON serialization for deps
### New Features
**Launcher OAuth Integration**
- **File:** `packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx`
- Added state: `githubIsAuthenticated`, `githubIsConnecting`, `githubUser`
- Added handlers: `handleGitHubConnect`, `handleGitHubDisconnect`
- Listens for auth state changes via `useEventListener`
- Launcher button now initiates real OAuth flow
**CredentialsSection OAuth Migration**
- **File:** `packages/noodl-editor/src/editor/src/views/panels/VersionControlPanel/components/GitProviderPopout/sections/CredentialsSection.tsx`
- Migrated from old `GitHubAuth` service to unified `GitHubOAuthService`
- Now shares token storage with GitHubPanel and Launcher
### Testing Results
| Test | Result |
| ----------------------------------- | ---------------------------- |
| OAuth from GitHubPanel in project | ✅ Works |
| OAuth from Launcher header button | ✅ Works |
| OAuth from CredentialsSection | ✅ Works (migrated) |
| Token persistence across restart | ✅ Works (without clean:all) |
| Issues/PRs single request (no loop) | ✅ Works |
| 404 error handling (no loop) | ✅ Works |
### Technical Notes
- All three OAuth entry points now use `GitHubOAuthService.instance`
- Token stored via IPC (`github-save-token`) in main process using Electron's safeStorage
- `clean:all` will wipe tokens (expected - wipes electron-store data)
### Future Task Created
Created `TASK-002C-github-clone-and-connect/README.md` documenting two new features:
- Subtask A: Clone Noodl projects from GitHub (launcher view)
- Subtask B: Connect unconnected project to GitHub (create/link repo)
---
## Progress Summary
| Sub-Task | Status | Started | Completed |
@@ -336,6 +506,7 @@ Built complete GitHub Pull Requests panel following the same patterns as Issues
| GIT-004A: OAuth & Client | ✅ Complete | 2026-01-14 | 2026-01-14 |
| GIT-004B: Issues Read | ✅ Complete | 2026-01-14 | 2026-01-14 |
| GIT-004C: PRs Read | ✅ Complete | 2026-01-15 | 2026-01-15 |
| OAuth Bug Fixes | ✅ Complete | 2026-01-16 | 2026-01-16 |
| GIT-004D: Issues CRUD | Not Started | - | - |
| GIT-004E: Component Linking | Not Started | - | - |
| GIT-004F: Dashboard | Not Started | - | - |
@@ -381,3 +552,94 @@ _Track user feedback during development/testing_
| Date | Feedback | Source | Action |
| ---- | -------- | ------ | ------ |
| - | - | - | - |
---
## [2026-01-18] - VersionControlPanel Discovery + GIT-005-011 Documentation ✅
### Summary
Major documentation effort creating GIT-005 through GIT-011 task specs (Live Collaboration & Multi-Community System), plus critical discovery that the existing VersionControlPanel is already 100% React with full visual diff support.
### Key Discovery: VersionControlPanel is React! 🎉
**Not jQuery** - The entire VersionControlPanel is modern React:
- React functional components with hooks (useState, useEffect, useRef)
- Context API (VersionControlContext)
- TypeScript throughout
- Uses @noodl-core-ui design system
**Visual diff system already works**:
- Click any commit in History tab
- See green nodes (additions), red nodes (deletions)
- Component-level diff visualization
- `DiffList.tsx`, `CommitChangesDiff.tsx` already implement this
### Integration Strategy Created
**Approach**: Extend VersionControlPanel with GitHub tabs instead of maintaining separate GitHubPanel.
**Benefits**:
- Single unified panel for all version control
- Existing visual diffs work unchanged
- No rewrite needed - just add tabs
- Estimated 11-16 hours vs 70-90 for separate panel
**Documentation**: `GIT-INTEGRATION-STRATEGY.md`
### Task Documentation Created (GIT-005-011)
**Part 2: Live Collaboration & Multi-Community System**
| Task | Name | Hours | Files Created |
| ------- | ------------------------ | ------- | -------------------------------------------- |
| GIT-005 | Community Infrastructure | 60-80 | `GIT-005-community-infrastructure/README.md` |
| GIT-006 | Server Infrastructure | 80-100 | `GIT-006-server-infrastructure/README.md` |
| GIT-007 | WebRTC Collaboration | 100-130 | `GIT-007-webrtc-collaboration/README.md` |
| GIT-008 | Notification System | 50-70 | `GIT-008-notification-system/README.md` |
| GIT-009 | Community Tab UI | 80-100 | `GIT-009-community-ui/README.md` |
| GIT-010 | Session Discovery | 50-70 | `GIT-010-session-discovery/README.md` |
| GIT-011 | Integration & Polish | 61-82 | `GIT-011-integration-polish/README.md` |
**Total Part 2 Effort**: 431-572 hours
### Files Created
- `GIT-005-community-infrastructure/README.md`
- `GIT-006-server-infrastructure/README.md`
- `GIT-007-webrtc-collaboration/README.md`
- `GIT-008-notification-system/README.md`
- `GIT-009-community-ui/README.md`
- `GIT-010-session-discovery/README.md`
- `GIT-011-integration-polish/README.md`
- `GIT-INTEGRATION-STRATEGY.md`
### Files Modified
- `README.md` - Added Part 2 section, integration strategy reference
- `CHECKLIST.md` - Added GIT-005-011 checklists
- `PROGRESS.md` (Phase 3) - Updated task list and recent updates
### VersionControlPanel Components Verified
All these are modern React:
- `LocalChanges.tsx` - Uncommitted changes
- `History.tsx` - Commit history
- `HistoryCommitDiff.tsx` - Visual commit diff
- `CommitChangesDiff.tsx` - Diff logic
- `DiffList.tsx` - Green/red node renderer
- `BranchList.tsx` - Branch management
- `BranchMerge.tsx` - Merge operations
- `MergeConflicts.tsx` - Conflict resolution
- `Stashes.tsx` - Git stash
### Next Steps
1. **Immediate**: Integrate Issues/PRs as tabs in VersionControlPanel
2. **Short-term**: Enhance GitProviderPopout with OAuth
3. **Medium-term**: Begin GIT-005 Community Infrastructure
4. **Long-term**: GIT-006-011 server and collaboration features

View File

@@ -1,9 +1,24 @@
# GIT-004: Implementation Checklist
> **⚠️ IMPORTANT: Integration Strategy Update (Jan 18, 2026)**
>
> GIT-004A-C were implemented with a separate `GitHubPanel`. However, we discovered that the existing `VersionControlPanel` is already 100% React with full visual diff support.
>
> **Going forward (GIT-004D-F):** We will integrate GitHub features into the existing VersionControlPanel as new tabs, rather than maintaining a separate panel.
>
> See **[GIT-INTEGRATION-STRATEGY.md](./GIT-INTEGRATION-STRATEGY.md)** for the full plan.
>
> **What this means for the checklist:**
>
> - GIT-004A-C: ✅ Complete (GitHubPanel code exists and works)
> - GIT-004D-F: Will move components from GitHubPanel → VersionControlPanel tabs
---
## Pre-Implementation
- [ ] Review existing `packages/noodl-git/` code
- [ ] Review `VersionControlPanel/` patterns
- [x] Review existing `packages/noodl-git/` code
- [x] Review `VersionControlPanel/` patterns
- [ ] Set up GitHub App in GitHub Developer Settings (for testing)
- [ ] Document GitHub App creation steps for users
@@ -381,17 +396,152 @@
---
## Progress Summary
## Progress Summary - Part 1: GitHub Project Management
| Sub-Task | Status | Started | Completed | Hours |
|----------|--------|---------|-----------|-------|
| GIT-004A: OAuth & Client | Not Started | - | - | - |
| GIT-004B: Issues Read | Not Started | - | - | - |
| GIT-004C: PRs Read | Not Started | - | - | - |
| GIT-004D: Issues CRUD | Not Started | - | - | - |
| GIT-004E: Component Linking | Not Started | - | - | - |
| GIT-004F: Dashboard | Not Started | - | - | - |
| Integration & Polish | Not Started | - | - | - |
| Sub-Task | Status | Started | Completed | Hours |
| --------------------------- | ----------- | ------- | --------- | ----- |
| GIT-004A: OAuth & Client | ✅ Complete | - | - | - |
| GIT-004B: Issues Read | ✅ Complete | - | - | - |
| GIT-004C: PRs Read | ✅ Complete | - | - | - |
| GIT-004D: Issues CRUD | Not Started | - | - | - |
| GIT-004E: Component Linking | Not Started | - | - | - |
| GIT-004F: Dashboard | Not Started | - | - | - |
| Integration & Polish | Not Started | - | - | - |
**Total Estimated:** 70-90 hours
**Part 1 Estimated:** 70-90 hours
**Part 1 Actual:** - hours
---
## Part 2: Live Collaboration & Multi-Community (GIT-005-011)
### GIT-005: Community Infrastructure (60-80 hours)
- [ ] Create `opennoodl-community` template repository
- [ ] Implement `community.json` schema and validation
- [ ] Create GitHub Actions workflows for validation
- [ ] Implement `CommunityManager` service
- [ ] Create community settings UI panel
- [ ] Add "Add Community" flow
- [ ] Add "Create Community" flow (fork template)
- [ ] Implement background sync
- [ ] Add server health monitoring
- [ ] Create community switcher UI
### GIT-006: Server Infrastructure (80-100 hours)
**Signaling Server:**
- [ ] Create repository
- [ ] Implement WebSocket room management
- [ ] Implement signal relay
- [ ] Add health check/metrics
- [ ] Create Dockerfile
- [ ] Deploy to production
**Sync Server:**
- [ ] Create repository
- [ ] Implement Yjs WebSocket provider
- [ ] Add optional LevelDB persistence
- [ ] Create Dockerfile
- [ ] Deploy to production
**Notification Server:**
- [ ] Create repository
- [ ] Implement WebSocket authentication
- [ ] Implement notification storage
- [ ] Implement TTL cleanup
- [ ] Create Dockerfile
- [ ] Deploy to production
### GIT-007: WebRTC Collaboration Client (100-130 hours)
- [ ] Create `CollaborationManager` service
- [ ] Implement session creation (host)
- [ ] Implement session joining (guest)
- [ ] Implement signaling server communication
- [ ] Implement WebRTC peer connections
- [ ] Set up Yjs document structure
- [ ] Implement WebRTC provider (y-webrtc)
- [ ] Implement WebSocket fallback (y-websocket)
- [ ] Sync project to Yjs
- [ ] Implement cursor broadcasting
- [ ] Implement selection broadcasting
- [ ] Implement local audio/video capture
- [ ] Display remote media streams
- [ ] Create collaboration toolbar UI
- [ ] Create participant list panel
### GIT-008: Notification System (50-70 hours)
- [ ] Create `NotificationManager` service
- [ ] Implement WebSocket connection
- [ ] Implement notification fetching/parsing
- [ ] Create `NotificationToast` component
- [ ] Create toast queue system
- [ ] Create `NotificationCenter` panel
- [ ] Add notification badge
- [ ] Implement Electron desktop notifications
- [ ] Add notification settings
### GIT-009: Community Tab UI (80-100 hours)
- [ ] Create `CommunityPanel` component
- [ ] Create tab navigation
- [ ] Implement Home view
- [ ] Implement Sessions view
- [ ] Implement Components view
- [ ] Implement Learn view
- [ ] Implement Discuss view (GitHub Discussions)
- [ ] Implement Jobs view
- [ ] Add loading states and skeletons
- [ ] Add error handling
### GIT-010: Session Discovery (50-70 hours)
- [ ] Create `DeepLinkHandler` service
- [ ] Register `opennoodl://` protocol in Electron
- [ ] Create `SessionPreview` dialog
- [ ] Create `QuickJoinWidget` component
- [ ] Create `SessionHistoryManager` service
- [ ] Implement favorites
- [ ] Add "Copy Link" to active sessions
- [ ] Test cross-platform deep links
### GIT-011: Integration & Polish (61-82 hours)
- [ ] Create comprehensive test plan
- [ ] End-to-end testing of all flows
- [ ] Performance profiling and optimization
- [ ] Write user documentation
- [ ] Write community setup guide
- [ ] Create demo video
- [ ] Deploy servers to production
- [ ] Set up monitoring
- [ ] Complete launch checklist
---
## Progress Summary - Part 2: Live Collaboration
| Sub-Task | Name | Status | Hours |
| -------- | ------------------------ | ----------- | ----- |
| GIT-005 | Community Infrastructure | Not Started | - |
| GIT-006 | Server Infrastructure | Not Started | - |
| GIT-007 | WebRTC Collaboration | Not Started | - |
| GIT-008 | Notification System | Not Started | - |
| GIT-009 | Community Tab UI | Not Started | - |
| GIT-010 | Session Discovery | Not Started | - |
| GIT-011 | Integration & Polish | Not Started | - |
**Part 2 Estimated:** 431-572 hours
**Part 2 Actual:** - hours
---
## Overall Progress
**Total Estimated:** 501-662 hours
**Total Actual:** - hours

View File

@@ -0,0 +1,279 @@
# GIT-005: Community Infrastructure & Template Repository
## Overview
**Priority:** High
**Estimated Hours:** 60-80
**Dependencies:** GIT-004A (GitHub OAuth)
**Status:** 🔴 Not Started
Create the foundational community infrastructure that enables users to fork and operate their own OpenNoodl communities. This includes the community template repository structure, server deployment templates, and community management system.
---
## Strategic Context
This task is part of the **Live Collaboration & Multi-Community System** (GIT-5 through GIT-11), which transforms OpenNoodl into a collaborative platform with:
- Multi-community management (join multiple communities simultaneously)
- Live collaboration sessions with audio/video chat
- Component library discovery across communities
- Community template repository (forkable)
- Self-hosted infrastructure options
---
## Technical Requirements
### 1. Community Template Repository
Create a new repository: `opennoodl-community` that serves as the template for all communities.
**Repository Structure:**
```
opennoodl-community/
├── .github/
│ ├── workflows/
│ │ ├── validate-community.yml # Validates community.json schema
│ │ ├── sync-discussions.yml # Syncs discussion metadata
│ │ ├── update-feeds.yml # Updates tutorial/showcase feeds
│ │ └── publish-sessions.yml # Updates public session list
│ └── ISSUE_TEMPLATE/
│ ├── component-submission.yml
│ ├── tutorial-submission.yml
│ └── session-request.yml
├── community.json # Community metadata (CRITICAL)
├── README.md # Community home page
├── CODE_OF_CONDUCT.md # Community guidelines
├── CONTRIBUTING.md # How to contribute
├── components/
│ ├── README.md # Component library guide
│ ├── featured.json # Curated components
│ └── registry.json # All registered components
├── tutorials/
│ ├── README.md # Learning resources hub
│ ├── beginner/
│ │ └── index.json # Beginner tutorials metadata
│ ├── intermediate/
│ │ └── index.json
│ └── advanced/
│ └── index.json
├── showcase/
│ ├── README.md # Featured projects
│ ├── projects.json # Project submissions
│ └── templates/
│ └── project-template.json
├── jobs/
│ ├── README.md # Job board guide
│ └── listings.json # Current listings (PRs to add)
├── collaboration/
│ ├── README.md # Collaboration guide
│ ├── public-sessions.json # Active public sessions
│ └── session-template.json # Template for session metadata
└── config/
├── servers.json # Server endpoints
├── features.json # Feature flags
└── notifications.json # Notification settings
```
### 2. Community Metadata Schema
**File: `community.json`**
This file is the single source of truth for community configuration.
```json
{
"$schema": "https://opennoodl.org/schemas/community.v1.json",
"version": "1.0.0",
"community": {
"id": "opennoodl-official",
"name": "OpenNoodl Official Community",
"description": "The official OpenNoodl visual programming community",
"type": "public",
"owner": {
"github": "The-Low-Code-Foundation",
"name": "Low Code Foundation",
"website": "https://opennoodl.org",
"contact": "community@opennoodl.org"
},
"repository": "https://github.com/The-Low-Code-Foundation/opennoodl-community",
"createdAt": "2026-01-18T00:00:00Z",
"updatedAt": "2026-01-18T00:00:00Z"
},
"servers": {
"signaling": {
"url": "wss://signal.opennoodl.org",
"healthCheck": "https://signal.opennoodl.org/health"
},
"sync": {
"url": "wss://sync.opennoodl.org",
"healthCheck": "https://sync.opennoodl.org/health"
},
"turn": {
"urls": ["turn:relay.opennoodl.org:3478"],
"username": "opennoodl",
"credentialType": "password"
},
"notifications": {
"url": "wss://notify.opennoodl.org",
"healthCheck": "https://notify.opennoodl.org/health"
}
},
"features": {
"discussions": true,
"components": true,
"tutorials": true,
"showcase": true,
"jobs": true,
"collaboration": {
"enabled": true,
"publicSessions": true,
"privateSessions": true,
"maxSessionSize": 10,
"audioEnabled": true,
"videoEnabled": false,
"screenShareEnabled": true,
"requireAuth": true
},
"notifications": {
"enabled": true,
"channels": ["discussions", "sessions", "components", "invites"],
"persistDays": 30
}
},
"branding": {
"primaryColor": "#FF6B6B",
"secondaryColor": "#4ECDC4",
"logo": "https://opennoodl.org/community-logo.png",
"favicon": "https://opennoodl.org/favicon.ico",
"customCSS": null
},
"moderation": {
"moderators": ["community-admin"],
"requireApproval": {
"components": false,
"tutorials": true,
"showcaseProjects": true,
"jobs": false
},
"autoModerationEnabled": true,
"bannedUsers": []
},
"integrations": {
"github": {
"org": "The-Low-Code-Foundation",
"discussionsRepo": "The-Low-Code-Foundation/opennoodl-community",
"issuesRepo": "The-Low-Code-Foundation/OpenNoodl"
},
"discord": {
"enabled": false,
"webhookUrl": null,
"serverId": null
},
"slack": {
"enabled": false,
"webhookUrl": null
}
},
"limits": {
"maxComponentsPerUser": 50,
"maxSessionsPerUser": 5,
"maxInvitesPerSession": 20,
"rateLimit": {
"sessionsPerHour": 10,
"invitesPerHour": 50
}
}
}
```
### 3. GitHub Actions Workflows
See `GIT-5-to-GIT-11-Live-Collaboration-Community-System.md` for complete workflow implementations.
### 4. Editor Integration - CommunityManager Service
**File: `packages/noodl-editor/src/editor/src/services/CommunityManager.ts`**
Key responsibilities:
- Load and validate community configurations from GitHub repos
- Manage multiple community memberships
- Background sync of community configs
- Server health monitoring
- Community switching
---
## Implementation Tasks
- [ ] Create `opennoodl-community` template repository with full structure
- [ ] Implement `community.json` schema and JSON Schema validation
- [ ] Create GitHub Actions workflows for validation and sync
- [ ] Implement `CommunityManager` service in editor
- [ ] Create community settings UI panel
- [ ] Add "Add Community" flow (paste GitHub URL)
- [ ] Add "Create Community" flow (fork template)
- [ ] Implement background sync for community configs
- [ ] Add server health monitoring
- [ ] Create community switcher UI component
---
## Verification Steps
- [ ] Can fork `opennoodl-community` template and customize `community.json`
- [ ] GitHub Actions validate configuration on push
- [ ] Editor can add community by pasting GitHub URL
- [ ] Editor validates community config and checks server health
- [ ] Can switch between multiple communities
- [ ] Background sync updates community configs
- [ ] Invalid communities show error states
- [ ] Can remove communities (except official)
---
## Files to Create
```
packages/noodl-editor/src/editor/src/services/
├── CommunityManager.ts # Community management service
├── CommunityTypes.ts # TypeScript interfaces
└── CommunityValidation.ts # Schema validation
packages/noodl-editor/src/editor/src/views/panels/CommunityPanel/
├── CommunitySettings.tsx # Settings UI
├── CommunitySettings.module.scss
├── components/
│ ├── CommunitySwitcher.tsx # Dropdown to switch communities
│ ├── AddCommunityDialog.tsx # Add by URL dialog
│ └── CommunityHealthIndicator.tsx # Server health display
└── hooks/
└── useCommunityManager.ts # React hook for manager
```
---
## Notes
- Official OpenNoodl community cannot be removed (hardcoded)
- Community configs are cached locally for offline access
- Server health checks run every sync cycle (15 minutes)
- Failed health checks show warning but don't remove community
---
## Related Tasks
- **GIT-006**: Server Infrastructure (signaling, sync, notifications servers)
- **GIT-007**: WebRTC Collaboration Client
- **GIT-009**: Community Tab UI/UX

View File

@@ -0,0 +1,330 @@
# GIT-006: Server Infrastructure (Signaling, Sync, Notifications)
## Overview
**Priority:** Critical
**Estimated Hours:** 80-100
**Dependencies:** GIT-005 (Community Infrastructure)
**Status:** 🔴 Not Started
Build the three core server components required for live collaboration and notifications:
1. **Signaling Server** - WebRTC peer discovery
2. **Sync Server** - WebSocket fallback for CRDT sync
3. **Notification Server** - Persistent cross-session notifications
---
## Strategic Context
These servers are the backbone of the collaboration system. They're designed to be:
- **Stateless** - Horizontally scalable
- **Self-hostable** - Community operators can run their own
- **Lightweight** - Minimal dependencies, easy to deploy
- **Observable** - Health checks, metrics, logging
---
## Technical Requirements
### 1. Signaling Server (WebRTC Peer Discovery)
Minimal WebSocket server that helps peers find each other for WebRTC connections. **No project data passes through this server.**
**Repository:** `opennoodl-signaling-server`
**Key Features:**
- Room-based peer management
- WebRTC signal relay (SDP, ICE candidates)
- Heartbeat for dead connection detection
- Invite forwarding for online users
- Prometheus-compatible metrics
**Protocol Messages:**
| Message Type | Direction | Description |
| ------------- | ---------------- | --------------------------- |
| `join` | Client → Server | Join a collaboration room |
| `joined` | Server → Client | Confirmation with peer list |
| `peer-joined` | Server → Clients | New peer joined room |
| `peer-left` | Server → Clients | Peer left room |
| `signal` | Bidirectional | WebRTC signaling data |
| `invite` | Client → Server | Invite user to session |
| `error` | Server → Client | Error notification |
**Environment Variables:**
| Variable | Default | Description |
| -------------------- | ------- | ----------------------- |
| `PORT` | 4444 | Server port |
| `MAX_ROOM_SIZE` | 20 | Maximum peers per room |
| `HEARTBEAT_INTERVAL` | 30000 | Heartbeat interval (ms) |
**Endpoints:**
| Endpoint | Method | Description |
| ---------- | --------- | --------------------------- |
| `/health` | GET | Health check (returns JSON) |
| `/metrics` | GET | Prometheus metrics |
| `/` | WebSocket | Signaling connection |
---
### 2. Sync Server (WebSocket Fallback)
Traditional WebSocket server using Yjs for CRDT synchronization. Used when WebRTC peer-to-peer fails.
**Repository:** `opennoodl-sync-server`
**Key Features:**
- Yjs WebSocket provider compatibility
- Optional LevelDB persistence
- Automatic garbage collection
- Document isolation by room
**Environment Variables:**
| Variable | Default | Description |
| -------------------- | -------- | -------------------------- |
| `PORT` | 4445 | Server port |
| `PERSIST_DIR` | `./data` | Persistence directory |
| `ENABLE_PERSISTENCE` | `true` | Enable LevelDB persistence |
**Dependencies:**
- `yjs` - CRDT library
- `y-websocket` - WebSocket provider
- `y-leveldb` - Optional persistence
---
### 3. Notification Server (Persistent Notifications)
Server for managing cross-session notifications (invites, community events, mentions).
**Repository:** `opennoodl-notification-server`
**Key Features:**
- WebSocket-based real-time delivery
- Persistent storage (survives server restart)
- Automatic TTL-based cleanup
- Multi-device support (same user on multiple devices)
- Notification types: invite, mention, thread, session, component, system
**Protocol Messages:**
| Message Type | Direction | Description |
| --------------------- | --------------- | ------------------------- |
| `authenticate` | Client → Server | Authenticate with user ID |
| `authenticated` | Server → Client | Auth confirmation |
| `get-notifications` | Client → Server | Request notification list |
| `notifications` | Server → Client | List of notifications |
| `notification` | Server → Client | New notification |
| `mark-read` | Client → Server | Mark notification as read |
| `delete-notification` | Client → Server | Delete a notification |
| `send-notification` | Client → Server | Send to another user |
**Environment Variables:**
| Variable | Default | Description |
| ---------- | ---------------------- | -------------------------- |
| `PORT` | 4446 | Server port |
| `DB_FILE` | `./notifications.json` | Database file path |
| `TTL_DAYS` | 30 | Notification expiry (days) |
---
## Deployment Options
### Docker Compose (Recommended for Self-Hosting)
```yaml
version: '3.8'
services:
signaling:
build: ./opennoodl-signaling-server
ports:
- '4444:4444'
environment:
- PORT=4444
- MAX_ROOM_SIZE=20
restart: unless-stopped
healthcheck:
test: ['CMD', 'wget', '--quiet', '--tries=1', '--spider', 'http://localhost:4444/health']
interval: 30s
timeout: 10s
retries: 3
sync:
build: ./opennoodl-sync-server
ports:
- '4445:4445'
environment:
- PORT=4445
- ENABLE_PERSISTENCE=true
- PERSIST_DIR=/data
volumes:
- sync-data:/data
restart: unless-stopped
notifications:
build: ./opennoodl-notification-server
ports:
- '4446:4446'
environment:
- PORT=4446
- DB_FILE=/data/notifications.json
- TTL_DAYS=30
volumes:
- notification-data:/data
restart: unless-stopped
volumes:
sync-data:
notification-data:
```
### One-Click Deploy Options
Each server includes configuration for:
- **Railway** (`railway.json`)
- **Render** (`render.yaml`)
- **Fly.io** (`fly.toml`)
---
## Implementation Tasks
### Signaling Server
- [ ] Create `opennoodl-signaling-server` repository
- [ ] Implement WebSocket server with room management
- [ ] Implement peer join/leave handling
- [ ] Implement signal relay
- [ ] Implement invite forwarding
- [ ] Add heartbeat for dead connection detection
- [ ] Add health check endpoint
- [ ] Add Prometheus metrics endpoint
- [ ] Create Dockerfile
- [ ] Create one-click deploy configs
- [ ] Write deployment documentation
### Sync Server
- [ ] Create `opennoodl-sync-server` repository
- [ ] Implement Yjs WebSocket provider integration
- [ ] Add optional LevelDB persistence
- [ ] Add health check endpoint
- [ ] Create Dockerfile
- [ ] Create one-click deploy configs
### Notification Server
- [ ] Create `opennoodl-notification-server` repository
- [ ] Implement WebSocket authentication
- [ ] Implement notification storage (LowDB initially)
- [ ] Implement notification delivery
- [ ] Implement mark-read/delete operations
- [ ] Implement TTL-based cleanup
- [ ] Add multi-device support
- [ ] Add health check endpoint
- [ ] Create Dockerfile
- [ ] Create one-click deploy configs
### Deployment
- [ ] Deploy official signaling server
- [ ] Deploy official sync server
- [ ] Deploy official notification server
- [ ] Configure SSL certificates
- [ ] Set up monitoring (Grafana/Prometheus)
- [ ] Set up alerting
- [ ] Load testing
---
## Verification Steps
- [ ] Signaling server helps peers find each other
- [ ] Sync server synchronizes Yjs documents
- [ ] Notification server stores and delivers notifications
- [ ] Health endpoints return 200 OK
- [ ] Metrics endpoints expose data
- [ ] Docker Compose brings up all services
- [ ] One-click deploy works on Railway/Render
- [ ] Servers handle connection failures gracefully
- [ ] Old notifications are cleaned up automatically
- [ ] Servers restart automatically after crash
---
## Security Considerations
1. **No sensitive data in signaling** - Only relay WebRTC signals, no content
2. **Rate limiting** - Prevent abuse of all servers
3. **Authentication** - Notification server requires user auth
4. **CORS** - Properly configured for cross-origin requests
5. **WSS only** - Require secure WebSocket connections in production
---
## Scaling Notes
| Server | Bottleneck | Scaling Strategy |
| ------------- | --------------------- | ----------------------------------- |
| Signaling | Memory (room state) | Horizontal with sticky sessions |
| Sync | Storage (persistence) | Shard by room prefix |
| Notifications | Storage | Replace LowDB with Redis/PostgreSQL |
---
## Files to Create
```
External repositories (separate from OpenNoodl):
opennoodl-signaling-server/
├── index.js
├── package.json
├── Dockerfile
├── railway.json
├── render.yaml
├── fly.toml
└── README.md
opennoodl-sync-server/
├── index.js
├── package.json
├── Dockerfile
└── README.md
opennoodl-notification-server/
├── index.js
├── package.json
├── Dockerfile
└── README.md
```
---
## Notes
- All servers designed to be stateless (horizontally scalable)
- Sync server can optionally persist data with LevelDB
- Notification server uses LowDB initially (can upgrade to Redis/PostgreSQL for scale)
- All servers include CORS headers for cross-origin requests
- WebSocket connections include heartbeat/ping-pong for dead connection detection
---
## Related Tasks
- **GIT-005**: Community Infrastructure (defines server URLs in community.json)
- **GIT-007**: WebRTC Collaboration Client (consumes these servers)
- **GIT-008**: Notification System (client for notification server)

View File

@@ -0,0 +1,342 @@
# GIT-007: WebRTC Collaboration Client
## Overview
**Priority:** Critical
**Estimated Hours:** 100-130
**Dependencies:** GIT-005, GIT-006
**Status:** 🔴 Not Started
Implement the client-side WebRTC collaboration system that enables real-time multi-user editing, cursor sharing, audio/video chat, and seamless fallback to WebSocket sync.
This is the **core collaboration feature** - enabling "Google Docs for visual programming."
---
## Strategic Context
This task delivers the most visible and impactful feature of the collaboration system:
- **Real-time multi-user editing** - See changes as they happen
- **Presence awareness** - Cursors, selections, viewports of all participants
- **Audio/video chat** - Built-in communication (no need for separate apps)
- **Peer-to-peer** - Low latency, no server relay for data
- **Automatic fallback** - Falls back to WebSocket when P2P fails
---
## Technical Requirements
### 1. CollaborationManager Service
**File:** `packages/noodl-editor/src/editor/src/services/CollaborationManager.ts`
Central service managing all collaboration functionality:
```typescript
interface CollaborationSession {
id: string;
roomId: string;
projectId: string;
isHost: boolean;
isPublic: boolean;
title: string;
description?: string;
maxParticipants: number;
participants: Map<string, Participant>;
createdAt: Date;
}
interface Participant {
peerId: string;
userId: string;
name: string;
avatar?: string;
color: string;
cursor?: { x: number; y: number };
selection?: { nodeId: string };
viewport?: { x: number; y: number; zoom: number };
audio: { enabled: boolean; stream?: MediaStream };
video: { enabled: boolean; stream?: MediaStream };
isHost: boolean;
joinedAt: Date;
}
```
**Key Methods:**
| Method | Description |
| ---------------------------- | ---------------------------------------- |
| `startSession(options)` | Start a new collaboration session (host) |
| `joinSession(roomId)` | Join an existing session |
| `leaveSession()` | Leave current session |
| `inviteUser(userId)` | Invite a user to current session |
| `updateCursor(x, y)` | Broadcast cursor position |
| `updateSelection(nodeId)` | Broadcast node selection |
| `updateViewport(x, y, zoom)` | Broadcast viewport state |
| `toggleAudio(enabled?)` | Toggle local audio |
| `toggleVideo(enabled?)` | Toggle local video |
### 2. WebRTC Connection Flow
```
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Peer A │ │ Signaling Server │ │ Peer B │
└──────┬───────┘ └────────┬─────────┘ └──────┬───────┘
│ │ │
│ 1. join(room) │ │
│─────────────────────>│ │
│ │ │
│ 2. joined(peers) │ │
│<─────────────────────│ │
│ │ │
│ │ 3. join(room) │
│ │<─────────────────────│
│ │ │
│ │ 4. peer-joined │
│<─────────────────────│─────────────────────>│
│ │ │
│ 5. signal(offer) │ │
│─────────────────────>│─────────────────────>│
│ │ │
│ │ 6. signal(answer) │
│<─────────────────────│<─────────────────────│
│ │ │
│ 7. P2P Connection Established │
│<═══════════════════════════════════════════>│
```
### 3. Yjs Integration (CRDT Sync)
Use Yjs for conflict-free replicated data types:
```typescript
// Document structure
const doc = new Y.Doc();
const yNodes = doc.getArray('nodes');
const yConnections = doc.getArray('connections');
const yProperties = doc.getMap('properties');
// Awareness (cursors, selections - not persisted)
const awareness = provider.awareness;
awareness.setLocalState({
user: { name, color, avatar },
cursor: { x, y },
selection: { nodeId }
});
```
### 4. Connection Fallback Strategy
```
1. Try WebRTC P2P (y-webrtc)
↓ (if fails after 5 seconds)
2. Try WebSocket via Sync Server (y-websocket)
↓ (if fails)
3. Show error, allow retry
```
### 5. Media Handling (Audio/Video)
```typescript
// Initialize local media
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true },
video: { width: 1280, height: 720 }
});
// Attach to peer connection
peer.addStream(stream);
// Handle remote streams
peer.on('stream', (remoteStream) => {
// Display in participant's video element
});
```
---
## User Flows
### Starting a Session (Host)
1. Click "Start Collaboration" in editor toolbar
2. Fill in session details (title, description, public/private)
3. Configure options (audio, video, max participants)
4. Session starts, room ID generated
5. Copy link or invite users directly
### Joining a Session (Guest)
1. Receive invitation notification OR click session link
2. Preview session details
3. Configure join options (audio, video)
4. Click "Join"
5. WebRTC connection established
6. Project state synced
### During Session
- See other participants' cursors (colored)
- See other participants' selections (highlighted)
- See avatar thumbnails of participants
- Optionally see video feeds
- Changes to nodes sync in real-time
- Can toggle audio/video anytime
### Leaving a Session
1. Click "Leave Session"
2. Confirm if host (session continues or ends)
3. Cleanup: stop media, close connections
4. Return to solo editing mode
---
## Implementation Tasks
### Phase 1: Core Service (20-25 hours)
- [ ] Create `CollaborationManager.ts` service
- [ ] Implement session creation (host)
- [ ] Implement session joining (guest)
- [ ] Implement signaling server communication
- [ ] Implement WebRTC peer connections
- [ ] Implement graceful disconnection
### Phase 2: Yjs Integration (20-25 hours)
- [ ] Set up Yjs document structure
- [ ] Implement WebRTC provider (y-webrtc)
- [ ] Implement WebSocket fallback (y-websocket)
- [ ] Sync project nodes to Yjs
- [ ] Sync project connections to Yjs
- [ ] Handle remote changes in ProjectModel
- [ ] Implement conflict resolution UI
### Phase 3: Awareness (15-20 hours)
- [ ] Implement cursor position broadcasting
- [ ] Implement selection broadcasting
- [ ] Implement viewport broadcasting
- [ ] Create remote cursor renderer
- [ ] Create remote selection highlighter
- [ ] Add participant list UI
### Phase 4: Media (20-25 hours)
- [ ] Implement local audio capture
- [ ] Implement local video capture
- [ ] Implement audio/video toggle
- [ ] Display remote audio (spatial if possible)
- [ ] Display remote video feeds
- [ ] Handle media permission errors
### Phase 5: UI Components (15-20 hours)
- [ ] Create collaboration toolbar
- [ ] Create start session dialog
- [ ] Create join session dialog
- [ ] Create participant list panel
- [ ] Create video grid component
- [ ] Create session info panel
### Phase 6: Integration (10-15 hours)
- [ ] Integrate with existing editor
- [ ] Add collaboration indicators to canvas
- [ ] Handle undo/redo in collaboration mode
- [ ] Test with multiple participants
- [ ] Performance optimization
---
## Verification Steps
- [ ] Can start a collaboration session
- [ ] Can join a session via room ID
- [ ] WebRTC peers connect automatically
- [ ] Cursor positions sync in real-time
- [ ] Node changes sync across all peers
- [ ] Selections visible to all participants
- [ ] Audio chat works between peers
- [ ] Video feeds display correctly
- [ ] Falls back to WebSocket when WebRTC fails
- [ ] Can toggle audio/video during session
- [ ] Disconnections handled gracefully
- [ ] Session persists if host leaves temporarily
- [ ] Can invite users to session
---
## Files to Create
```
packages/noodl-editor/src/editor/src/services/
├── CollaborationManager.ts # Main collaboration service
├── CollaborationTypes.ts # TypeScript interfaces
├── CollaborationYjsAdapter.ts # Yjs <-> ProjectModel adapter
└── CollaborationMediaManager.ts # Audio/video handling
packages/noodl-editor/src/editor/src/views/
├── CollaborationToolbar/
│ ├── CollaborationToolbar.tsx
│ ├── CollaborationToolbar.module.scss
│ ├── StartSessionDialog.tsx
│ ├── JoinSessionDialog.tsx
│ └── SessionInfoPanel.tsx
├── CollaborationOverlay/
│ ├── RemoteCursors.tsx # Render remote cursors on canvas
│ ├── RemoteSelections.tsx # Render remote selections
│ └── ParticipantAvatars.tsx # Show participant list
└── CollaborationVideo/
├── VideoGrid.tsx # Video feed layout
└── LocalVideo.tsx # Self-view
packages/noodl-editor/src/editor/src/hooks/
├── useCollaboration.ts # Hook for collaboration state
├── useRemoteCursors.ts # Hook for cursor rendering
└── useMediaStream.ts # Hook for audio/video
```
---
## Dependencies to Add
```json
{
"yjs": "^13.6.0",
"y-webrtc": "^10.2.0",
"y-websocket": "^1.5.0",
"simple-peer": "^9.11.0",
"lib0": "^0.2.85"
}
```
---
## Performance Considerations
1. **Cursor throttling** - Don't broadcast every mouse move (throttle to 50ms)
2. **Viewport culling** - Don't render cursors outside visible area
3. **Yjs updates** - Batch updates where possible
4. **Media quality** - Adaptive bitrate based on network conditions
5. **Connection limits** - Max 10-20 participants for performance
---
## Security Notes
1. **Room IDs** - Use UUIDs, hard to guess
2. **Signaling** - Only relay WebRTC signals, no content
3. **Media** - Encrypted via SRTP (built into WebRTC)
4. **Project data** - Synced P2P, not through server (except fallback)
---
## Related Tasks
- **GIT-005**: Community Infrastructure (provides server URLs)
- **GIT-006**: Server Infrastructure (signaling, sync, notifications)
- **GIT-008**: Notification System (for session invites)
- **GIT-010**: Session Discovery (for finding public sessions)

View File

@@ -0,0 +1,326 @@
# GIT-008: Notification System
## Overview
**Priority:** High
**Estimated Hours:** 50-70
**Dependencies:** GIT-006 (Notification Server)
**Status:** 🔴 Not Started
Implement persistent cross-session notification system for collaboration invites, community events, mentions, and updates.
---
## Strategic Context
Notifications bridge sessions - they ensure users don't miss:
- **Collaboration invites** - Someone wants to work with you
- **Mentions** - Someone referenced you in a discussion
- **Session updates** - Public session started by someone you follow
- **Component updates** - New component published in your community
- **System messages** - Important announcements
---
## Technical Requirements
### 1. NotificationManager Service
**File:** `packages/noodl-editor/src/editor/src/services/NotificationManager.ts`
```typescript
interface Notification {
id: string;
userId: string;
fromUserId?: string;
type: 'invite' | 'mention' | 'thread' | 'session' | 'component' | 'system';
title: string;
message: string;
data?: Record<string, unknown>;
read: boolean;
createdAt: Date;
expiresAt: Date;
readAt?: Date;
actions?: NotificationAction[];
}
interface NotificationAction {
label: string;
action: string;
primary?: boolean;
}
```
**Key Methods:**
| Method | Description |
| -------------------------------------- | ------------------------------ |
| `initialize(userId)` | Connect to notification server |
| `getNotifications()` | Get all notifications |
| `getUnreadCount()` | Get unread count |
| `markAsRead(id)` | Mark notification as read |
| `deleteNotification(id)` | Delete a notification |
| `sendNotification(userId, type, data)` | Send to another user |
| `destroy()` | Disconnect and cleanup |
### 2. Notification Types
| Type | Trigger | Actions |
| ----------- | ---------------------------------- | -------------- |
| `invite` | Someone invites you to collaborate | Join, Decline |
| `mention` | Someone mentions you in discussion | View Thread |
| `thread` | New reply in discussion you follow | View Thread |
| `session` | Public session started | Join Session |
| `component` | New component in your community | View Component |
| `system` | System announcement | Dismiss |
### 3. Toast Notifications
Real-time toasts for new notifications:
```typescript
// Toast component structure
interface ToastProps {
notification: Notification;
onAction: (action: string) => void;
onDismiss: () => void;
}
// Toast behavior
- Appears bottom-right of editor
- Auto-dismisses after 10 seconds
- Can be clicked for quick action
- Can be swiped to dismiss
- Queue system for multiple notifications
```
### 4. Notification Center
Panel/popover for viewing all notifications:
```
┌─────────────────────────────────┐
│ Notifications ✕ │
├─────────────────────────────────┤
│ ┌─────────────────────────────┐ │
│ │ 👥 Collaboration Invite │ │
│ │ Alice invited you to │ │
│ │ "Building a Dashboard" │ │
│ │ [Join] [Decline] 2m ago│ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ 📦 New Component │ │
│ │ Bob published "DataGrid" │ │
│ │ [View] 1h ago│ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ 💬 Discussion Reply │ │
│ │ Charlie replied to your │ │
│ │ question about routing │ │
│ │ [View Thread] 3h ago│ │
│ └─────────────────────────────┘ │
├─────────────────────────────────┤
│ [Mark All Read] [Clear All] │
└─────────────────────────────────┘
```
### 5. Desktop Notifications
Leverage Electron's native notification API:
```typescript
// Show native notification
new Notification('Collaboration Invite', {
body: 'Alice invited you to collaborate',
icon: '/path/to/icon.png',
tag: notification.id, // Prevents duplicates
requireInteraction: notification.type === 'invite' // Stay until clicked
});
notification.onclick = () => {
// Focus app and handle action
};
```
---
## User Flows
### Receiving Notification (Online)
1. Notification arrives via WebSocket
2. Toast appears in editor
3. Badge updates on notification bell
4. Desktop notification shows (if enabled)
5. User can act on toast or dismiss
### Receiving Notification (Offline)
1. Notification stored on server
2. User opens app
3. Notifications fetched on connect
4. Multiple notifications batch-displayed
5. Badge shows unread count
### Handling Notification Actions
**Invite:**
1. Click "Join" on invite notification
2. Session preview dialog opens
3. Confirm join options
4. Connect to collaboration session
**Mention/Thread:**
1. Click "View Thread"
2. Opens discussion in browser (GitHub Discussions)
**Component:**
1. Click "View Component"
2. Opens component in community panel
---
## Implementation Tasks
### Phase 1: Core Service (15-20 hours)
- [ ] Create `NotificationManager.ts` service
- [ ] Implement WebSocket connection to notification server
- [ ] Implement authentication flow
- [ ] Implement notification fetching
- [ ] Implement notification parsing/typing
- [ ] Implement mark as read
- [ ] Implement delete notification
- [ ] Implement send notification
- [ ] Add reconnection logic
- [ ] Add offline support (local storage cache)
### Phase 2: Toast UI (10-15 hours)
- [ ] Create `NotificationToast.tsx` component
- [ ] Create toast container/queue
- [ ] Implement auto-dismiss
- [ ] Implement action buttons
- [ ] Implement swipe-to-dismiss
- [ ] Add animations
- [ ] Style according to notification type
### Phase 3: Notification Center (15-20 hours)
- [ ] Create `NotificationCenter.tsx` component
- [ ] Create `NotificationItem.tsx` component
- [ ] Implement notification list with grouping
- [ ] Implement "Mark All Read"
- [ ] Implement "Clear All"
- [ ] Add notification badge to header
- [ ] Implement search/filter (optional)
### Phase 4: Desktop Notifications (5-10 hours)
- [ ] Implement Electron notification API
- [ ] Add notification settings (enable/disable)
- [ ] Handle notification click → focus app
- [ ] Add notification sound (optional)
- [ ] Test on Windows, Mac, Linux
### Phase 5: Integration (5-10 hours)
- [ ] Connect to existing authentication
- [ ] Integrate with collaboration system
- [ ] Add to editor layout
- [ ] Test end-to-end flows
- [ ] Performance optimization
---
## Verification Steps
- [ ] Notifications persist across sessions
- [ ] Toast appears when notification received
- [ ] Can mark notifications as read
- [ ] Can delete notifications
- [ ] Unread count displays correctly
- [ ] Actions trigger correct behavior
- [ ] Desktop notifications work (Electron)
- [ ] Reconnects after connection loss
- [ ] Offline notifications cached and shown on reconnect
- [ ] Expired notifications cleaned up
---
## Files to Create
```
packages/noodl-editor/src/editor/src/services/
├── NotificationManager.ts # Main notification service
├── NotificationTypes.ts # TypeScript interfaces
packages/noodl-editor/src/editor/src/views/
├── NotificationCenter/
│ ├── NotificationCenter.tsx # Main panel/popover
│ ├── NotificationCenter.module.scss
│ ├── NotificationItem.tsx # Individual notification
│ ├── NotificationBadge.tsx # Unread count badge
│ └── NotificationEmpty.tsx # Empty state
├── NotificationToast/
│ ├── NotificationToast.tsx # Toast component
│ ├── NotificationToast.module.scss
│ └── ToastContainer.tsx # Toast queue manager
packages/noodl-editor/src/editor/src/hooks/
├── useNotifications.ts # Hook for notification state
└── useNotificationActions.ts # Hook for notification actions
```
---
## Notification Message Format
```typescript
// Incoming notification from server
interface ServerNotification {
id: string;
userId: string;
fromUserId?: string;
type: 'invite' | 'mention' | 'thread' | 'session' | 'component' | 'system';
data: {
// Type-specific data
sessionId?: string;
sessionTitle?: string;
threadUrl?: string;
componentId?: string;
componentName?: string;
message?: string;
[key: string]: unknown;
};
read: boolean;
createdAt: string; // ISO date
expiresAt: string; // ISO date
}
```
---
## Settings
| Setting | Default | Description |
| -------------------------- | ------- | ---------------------------- |
| `notifications.enabled` | `true` | Enable notifications |
| `notifications.desktop` | `true` | Enable desktop notifications |
| `notifications.sound` | `true` | Play sound on notification |
| `notifications.invites` | `true` | Show collaboration invites |
| `notifications.mentions` | `true` | Show mentions |
| `notifications.sessions` | `true` | Show public sessions |
| `notifications.components` | `true` | Show component updates |
---
## Related Tasks
- **GIT-006**: Server Infrastructure (notification server)
- **GIT-007**: WebRTC Collaboration Client (sends invites)
- **GIT-009**: Community Tab UI (displays community notifications)

View File

@@ -0,0 +1,303 @@
# GIT-009: Community Tab UI/UX
## Overview
**Priority:** High
**Estimated Hours:** 80-100
**Dependencies:** GIT-005, GIT-007, GIT-008
**Status:** 🔴 Not Started
Create the Community tab UI that serves as the central hub for all community features: browsing communities, discovering sessions, exploring components, viewing tutorials, and accessing discussions.
---
## Strategic Context
The Community tab is the social layer of OpenNoodl. It surfaces:
- **Live collaboration sessions** - Join others in real-time
- **Shared components** - Discover and install components
- **Learning resources** - Tutorials organized by skill level
- **Discussions** - Community Q&A via GitHub Discussions
- **Job board** - Opportunities in the ecosystem
---
## UI Layout
```
┌─────────────────────────────────────────────────────────────┐
│ Community Switcher: [OpenNoodl Official ▾] [+ Add] │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────┐│
│ │ 🏠 Home │ 👥 Sessions │ 📦 Components │ 🎓 Learn │ 💬 Discuss │ 💼 Jobs │ │
│ └─────────────────────────────────────────────────────────┘│
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ [Tab Content] ││
│ │ ││
│ │ ││
│ │ ││
│ │ ││
│ │ ││
│ │ ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
```
---
## Tab Views
### 1. Home View
Featured content dashboard with quick actions:
- **Live Now** - Active public sessions
- **Featured This Week** - Curated components
- **New Tutorials** - Recently published
- **Hot Discussions** - Trending threads
- **Quick Actions** - Start session, share component, ask question
### 2. Sessions View
Browse and join collaboration sessions:
- **Filters**: Live now, scheduled, all
- **Session cards** with:
- Host avatar and name
- Session title and description
- Participant count / max
- Audio/video indicators
- Duration
- **Create Session** button
- **Join via Link** button
### 3. Components View
Browse shared component library:
- **Search bar** with filters
- **Category filter** dropdown
- **Sort by**: Popular, recent, name
- **Component cards** with:
- Preview image or icon
- Name and description
- Author
- Stars and downloads
- Tags
- "Add to Library" button
### 4. Learn View
Tutorial browser organized by skill level:
- **Learning paths** - Curated sequences
- **Level filter**: Beginner, intermediate, advanced
- **Format filter**: Video, article, interactive
- **Tutorial cards** with:
- Thumbnail
- Title and description
- Author
- Duration
- Level badge
### 5. Discuss View
GitHub Discussions integration:
- **Category tabs**: All, Q&A, Show and Tell, Ideas, General
- **Sort**: Recent, top, unanswered
- **Discussion items** with:
- Title and preview
- Author
- Category
- Comment and reaction counts
- Time ago
- **Start Discussion** button (opens GitHub)
### 6. Jobs View
Community job board:
- **Filters**: Type (full-time, contract, freelance), remote, experience
- **Job cards** with:
- Company logo
- Job title and company
- Description
- Tags (type, location, experience)
- Salary (if provided)
- Posted date
- "Apply" button
---
## Implementation Tasks
### Phase 1: Panel Structure (15-20 hours)
- [ ] Create `CommunityPanel/CommunityPanel.tsx`
- [ ] Create tab navigation component
- [ ] Create community switcher component
- [ ] Integrate with CommunityManager
- [ ] Add to editor sidebar
- [ ] Create panel layout and styles
### Phase 2: Home View (15-20 hours)
- [ ] Create `HomeView.tsx`
- [ ] Implement welcome banner with community branding
- [ ] Implement "Live Now" section
- [ ] Implement "Featured" section
- [ ] Implement "New Tutorials" section
- [ ] Implement "Hot Discussions" section
- [ ] Create quick action buttons
- [ ] Fetch featured content from community repo
### Phase 3: Sessions View (15-20 hours)
- [ ] Create `SessionsView.tsx`
- [ ] Implement session list with cards
- [ ] Implement session filters
- [ ] Create "Create Session" dialog
- [ ] Create "Join via Link" dialog
- [ ] Integrate with CollaborationManager
- [ ] Implement real-time session count updates
### Phase 4: Components View (15-20 hours)
- [ ] Create `ComponentsView.tsx`
- [ ] Implement search with debounce
- [ ] Implement category filter
- [ ] Implement sort options
- [ ] Create component cards
- [ ] Implement "Add to Library" action
- [ ] Create component preview modal
- [ ] Fetch from community components registry
### Phase 5: Learn View (10-15 hours)
- [ ] Create `LearnView.tsx`
- [ ] Implement learning paths section
- [ ] Implement level filter
- [ ] Implement format filter
- [ ] Create tutorial cards
- [ ] Fetch from community tutorials directory
### Phase 6: Discuss View (8-12 hours)
- [ ] Create `DiscussView.tsx`
- [ ] Implement category tabs
- [ ] Implement sort options
- [ ] Create discussion item component
- [ ] Integrate GitHub Discussions API via Octokit
- [ ] Implement "Start Discussion" action
### Phase 7: Jobs View (8-12 hours)
- [ ] Create `JobsView.tsx`
- [ ] Implement job filters
- [ ] Create job cards
- [ ] Implement "Apply" action
- [ ] Fetch from community jobs listings
### Phase 8: Polish (10-15 hours)
- [ ] Add loading states and skeletons
- [ ] Add error handling and retry
- [ ] Implement infinite scroll
- [ ] Add keyboard navigation
- [ ] Add animations and transitions
- [ ] Responsive layout adjustments
---
## Verification Steps
- [ ] Community tab loads without errors
- [ ] Can switch between communities
- [ ] Home view shows featured content
- [ ] Can browse and join sessions
- [ ] Can search and install components
- [ ] Tutorials load and display correctly
- [ ] Discussions integrate with GitHub
- [ ] Job board displays listings
- [ ] All filters work correctly
- [ ] UI is responsive and performant
- [ ] Offline/error states handled gracefully
---
## Files to Create
```
packages/noodl-editor/src/editor/src/views/panels/CommunityPanel/
├── CommunityPanel.tsx
├── CommunityPanel.module.scss
├── components/
│ ├── CommunityHeader.tsx
│ ├── CommunitySwitcher.tsx
│ ├── TabNavigation.tsx
│ ├── EmptyState.tsx
│ ├── SessionCard.tsx
│ ├── ComponentCard.tsx
│ ├── TutorialCard.tsx
│ ├── DiscussionItem.tsx
│ ├── JobCard.tsx
│ └── LoadingSkeletons.tsx
├── views/
│ ├── HomeView.tsx
│ ├── SessionsView.tsx
│ ├── ComponentsView.tsx
│ ├── LearnView.tsx
│ ├── DiscussView.tsx
│ └── JobsView.tsx
├── dialogs/
│ ├── CreateSessionDialog.tsx
│ ├── JoinSessionDialog.tsx
│ ├── ComponentPreviewModal.tsx
│ └── ShareComponentDialog.tsx
└── hooks/
├── useCommunityContent.ts
├── useSessions.ts
├── useComponents.ts
├── useTutorials.ts
├── useDiscussions.ts
└── useJobs.ts
```
---
## Data Fetching
All content is fetched from the community's GitHub repository:
| Content | Source |
| ----------- | ------------------------------------ |
| Featured | `featured.json` |
| Sessions | `collaboration/public-sessions.json` |
| Components | `components/registry.json` |
| Tutorials | `tutorials/{level}/index.json` |
| Discussions | GitHub Discussions API |
| Jobs | `jobs/listings.json` |
---
## Styling Notes
- Use CSS variables from theme for colors
- Follow Panel UI Style Guide
- Dark theme first
- Consistent card styling across views
- Smooth animations for transitions
- Loading skeletons match card dimensions
---
## Related Tasks
- **GIT-005**: Community Infrastructure (provides data sources)
- **GIT-007**: WebRTC Collaboration (sessions integration)
- **GIT-008**: Notification System (community notifications)
- **GIT-010**: Session Discovery (extends sessions view)

View File

@@ -0,0 +1,357 @@
# GIT-010: Session Discovery & Joining
## Overview
**Priority:** High
**Estimated Hours:** 50-70
**Dependencies:** GIT-007, GIT-009
**Status:** 🔴 Not Started
Streamline the session discovery and joining experience with deep linking, quick join flows, session previews, and favorites.
---
## Strategic Context
This task focuses on reducing friction for joining collaboration sessions:
- **Deep links** - Share `opennoodl://` links that open directly in the app
- **Quick join** - One-click joining from invites
- **Session preview** - See what you're joining before committing
- **History** - Remember past sessions for easy rejoin
- **Favorites** - Bookmark sessions you join frequently
---
## Technical Requirements
### 1. Deep Link Protocol
Register `opennoodl://` protocol handler for session joining:
```
Examples:
opennoodl://session/abc123
opennoodl://session/abc123?audio=true&video=false
opennoodl://community/add?repo=user/my-community
opennoodl://component/install?repo=user/repo&component=DataGrid
```
**Protocol Handlers:**
| Path | Description |
| ------------------- | --------------------------- |
| `session/{roomId}` | Join collaboration session |
| `community/add` | Add community by repo URL |
| `component/install` | Install component from repo |
### 2. DeepLinkHandler Service
**File:** `packages/noodl-editor/src/editor/src/utils/DeepLinkHandler.ts`
```typescript
class DeepLinkHandler {
// Register handlers for different paths
register(path: string, handler: (params: any) => void): void;
// Parse and handle incoming URL
handleUrl(url: string): void;
// Generate shareable link
generateLink(path: string, params?: Record<string, string>): string;
}
```
**Electron Integration:**
- Register protocol on app startup
- Handle `app.on('open-url')` for links when app is running
- Handle `process.argv` for links that launch the app
### 3. Session Preview
Before joining, show session details:
```
┌─────────────────────────────────────────┐
│ Join Collaboration Session ✕ │
├─────────────────────────────────────────┤
│ │
│ [Avatar] Alice is hosting │
│ │
│ 📌 Building a User Dashboard │
│ Working on the main dashboard layout │
│ and navigation components. │
│ │
│ ┌─────────┬─────────┬─────────┐ │
│ │ 👥 4/10 │ 📁 Proj │ 🕐 45m │ │
│ └─────────┴─────────┴─────────┘ │
│ │
│ Features: │
│ 🎤 Audio Chat 🖥️ Screen Share │
│ │
│ Participants: │
│ [A] [B] [C] [D] │
│ │
│ ┌────────────────────────────────┐ │
│ │ ☑ Join with audio enabled │ │
│ │ ☐ Join with video enabled │ │
│ └────────────────────────────────┘ │
│ │
│ [Cancel] [Join Session] │
└─────────────────────────────────────────┘
```
### 4. Quick Join Widget
Floating widget for incoming invites:
```
┌─────────────────────────────────────┐
│ 👥 Collaboration Invite [] [✕]│
├─────────────────────────────────────┤
│ [Avatar] Alice invited you │
│ │
│ "Building a User Dashboard" │
│ │
│ 👥 4 participants 🎤 Audio │
│ │
│ [Decline] [Join Now] │
└─────────────────────────────────────┘
```
### 5. Session History Manager
**File:** `packages/noodl-editor/src/editor/src/services/SessionHistoryManager.ts`
```typescript
interface SessionHistoryEntry {
sessionId: string;
roomId: string;
title: string;
host: {
userId: string;
name: string;
avatar?: string;
};
joinedAt: Date;
leftAt?: Date;
duration: number;
isFavorite: boolean;
}
class SessionHistoryManager {
// Add entry when joining session
addEntry(session: any): void;
// Update entry when leaving
updateEntry(sessionId: string, updates: Partial<SessionHistoryEntry>): void;
// Toggle favorite
toggleFavorite(sessionId: string): void;
// Get all history
getHistory(): SessionHistoryEntry[];
// Get favorites only
getFavorites(): SessionHistoryEntry[];
}
```
---
## User Flows
### Joining via Deep Link
1. User clicks `opennoodl://session/abc123` link
2. App opens (or focuses if already open)
3. Session preview dialog appears
4. User configures join options
5. Click "Join Session"
6. WebRTC connection established
### Joining via Invitation
1. Notification appears (toast + notification center)
2. Click "Join Now" on toast OR
3. Open notification center, click invitation
4. Session preview dialog appears
5. Configure and join
### Joining from History
1. Open "Recent Sessions" panel
2. See list of past sessions
3. Click session to preview
4. If session still active, join option shown
5. Join or view details
### Managing Favorites
1. During/after session, click star icon
2. Session added to favorites
3. Favorites show in dedicated section
4. Quick access for frequently joined sessions
---
## Implementation Tasks
### Phase 1: Deep Link Handler (10-15 hours)
- [ ] Create `DeepLinkHandler.ts` service
- [ ] Implement protocol registration in Electron
- [ ] Handle `open-url` event
- [ ] Handle startup with URL argument
- [ ] Implement session handler
- [ ] Implement community handler
- [ ] Implement component handler
- [ ] Generate shareable links
### Phase 2: Session Preview (10-15 hours)
- [ ] Create `SessionPreview.tsx` dialog
- [ ] Fetch session info from signaling server
- [ ] Display host, participants, features
- [ ] Implement join options checkboxes
- [ ] Handle session not found
- [ ] Handle session full
- [ ] Implement "Join" action
### Phase 3: Quick Join Widget (8-12 hours)
- [ ] Create `QuickJoinWidget.tsx` component
- [ ] Implement minimize/close functionality
- [ ] Connect to notification manager
- [ ] Implement "Join Now" action
- [ ] Implement "Decline" action
- [ ] Add animations
### Phase 4: Session History (12-16 hours)
- [ ] Create `SessionHistoryManager.ts` service
- [ ] Implement local storage persistence
- [ ] Create `SessionHistory.tsx` panel
- [ ] Display recent sessions list
- [ ] Implement favorites toggle
- [ ] Create `SessionHistoryItem.tsx` component
- [ ] Implement rejoin from history
- [ ] Add "Clear History" option
### Phase 5: Integration (10-12 hours)
- [ ] Add session info API to signaling server
- [ ] Add "Copy Link" button to active sessions
- [ ] Add session history to Community panel
- [ ] Add favorites section
- [ ] Test cross-platform deep links
- [ ] Test with various link scenarios
---
## Verification Steps
- [ ] `opennoodl://` links open the app
- [ ] Session preview loads correctly
- [ ] Can join session from deep link
- [ ] Quick join widget appears for invites
- [ ] Can minimize/dismiss widget
- [ ] Session history saves correctly
- [ ] Can favorite sessions
- [ ] Can rejoin from history
- [ ] Copy link generates correct URL
- [ ] Works on Windows, Mac, Linux
---
## Files to Create
```
packages/noodl-editor/src/editor/src/utils/
├── DeepLinkHandler.ts # Protocol handler
packages/noodl-editor/src/editor/src/services/
├── SessionHistoryManager.ts # History persistence
packages/noodl-editor/src/editor/src/views/
├── SessionPreview/
│ ├── SessionPreview.tsx # Preview dialog
│ └── SessionPreview.module.scss
├── QuickJoinWidget/
│ ├── QuickJoinWidget.tsx # Floating invite widget
│ └── QuickJoinWidget.module.scss
├── SessionHistory/
│ ├── SessionHistory.tsx # History panel
│ ├── SessionHistory.module.scss
│ └── SessionHistoryItem.tsx
packages/noodl-editor/src/main/
├── protocol-handler.js # Electron protocol registration
```
---
## Signaling Server API Extension
Add endpoint for session info (without joining):
```
GET /session/{roomId}/info
Response:
{
"roomId": "abc123",
"title": "Building a User Dashboard",
"description": "Working on dashboard layout",
"host": {
"userId": "alice",
"name": "Alice Smith",
"avatar": "https://..."
},
"participants": [
{ "userId": "bob", "name": "Bob", "avatar": "..." },
...
],
"participantCount": 4,
"maxParticipants": 10,
"features": {
"audioEnabled": true,
"videoEnabled": false,
"screenShareEnabled": true
},
"duration": 2700, // seconds
"createdAt": "2026-01-18T10:00:00Z"
}
404 if session not found
```
---
## Platform-Specific Notes
### macOS
- Protocol registered via `app.setAsDefaultProtocolClient()`
- Already running app receives `open-url` event
### Windows
- Protocol registered in registry via `app.setAsDefaultProtocolClient()`
- Deep links passed via `process.argv`
- May require admin for first registration
### Linux
- Desktop file registration
- Varies by distribution
- Consider XDG protocol handler
---
## Related Tasks
- **GIT-007**: WebRTC Collaboration (provides joining mechanics)
- **GIT-008**: Notification System (delivers invites)
- **GIT-009**: Community UI (displays sessions)

View File

@@ -0,0 +1,344 @@
# GIT-011: Integration & Polish
## Overview
**Priority:** High
**Estimated Hours:** 61-82
**Dependencies:** All previous tasks (GIT-005 through GIT-010)
**Status:** 🔴 Not Started
Final integration, testing, documentation, and marketing preparation for the live collaboration and multi-community system.
---
## Strategic Context
This is the "putting it all together" phase. Before launch:
- **Everything must work end-to-end** - No broken flows
- **Performance must be acceptable** - <3s connection, smooth sync
- **Documentation must be complete** - Users need to know how to use it
- **Servers must be deployed** - Infrastructure ready for production
- **Marketing must be ready** - Demo videos, blog posts, press kit
---
## Implementation Tasks
### 1. End-to-End Testing (20-25 hours)
#### Test Plan Creation
- [ ] Document all user flows
- [ ] Create test scenarios for each flow
- [ ] Define success criteria
- [ ] Set up test environment
#### Collaboration Testing
- [ ] Test session creation (host)
- [ ] Test session joining (guest)
- [ ] Test WebRTC connection establishment
- [ ] Test WebSocket fallback scenarios
- [ ] Test with 2, 5, 10+ participants
- [ ] Test audio/video functionality
- [ ] Test screen share (if enabled)
#### Sync Testing
- [ ] Test cursor position sync
- [ ] Test selection sync
- [ ] Test viewport sync
- [ ] Test node creation sync
- [ ] Test node deletion sync
- [ ] Test connection creation sync
- [ ] Test property editing sync
- [ ] Test undo/redo in collaboration
#### Edge Cases
- [ ] Test disconnection and reconnection
- [ ] Test host leaving (does session persist?)
- [ ] Test network quality degradation
- [ ] Test firewall scenarios
- [ ] Test notification delivery
- [ ] Test deep link handling
#### Community Testing
- [ ] Test adding custom community
- [ ] Test community switching
- [ ] Test component installation
- [ ] Test GitHub Discussions integration
### 2. Performance Optimization (15-20 hours)
#### Profiling
- [ ] Profile WebRTC data channel usage
- [ ] Profile Yjs document size
- [ ] Profile render performance during sync
- [ ] Profile memory usage over time
#### Optimizations
- [ ] Implement cursor position throttling (50ms)
- [ ] Add viewport culling for remote cursors
- [ ] Batch Yjs updates where possible
- [ ] Implement lazy loading for community content
- [ ] Add virtual scrolling for long lists
- [ ] Optimize GitHub API calls (caching)
#### Benchmarks
- [ ] Measure time to establish connection (target: <3s)
- [ ] Measure time for sync to stabilize (target: <5s)
- [ ] Measure cursor latency (target: <100ms)
- [ ] Measure CPU usage during collaboration
### 3. Documentation (12-15 hours)
#### User Documentation
- [ ] Write "Getting Started with Collaboration" guide
- [ ] Document session creation and joining
- [ ] Document audio/video controls
- [ ] Write troubleshooting guide
- [ ] Create FAQ section
#### Community Documentation
- [ ] Write "Creating Your Own Community" guide
- [ ] Document community.json schema
- [ ] Document server deployment
- [ ] Create self-hosting guide
#### Developer Documentation
- [ ] Document CollaborationManager API
- [ ] Document NotificationManager API
- [ ] Document CommunityManager API
- [ ] Write architecture overview
#### Video Tutorials
- [ ] Record "Starting a Collaboration Session" (2-3 min)
- [ ] Record "Joining a Session" (1-2 min)
- [ ] Record "Setting Up Your Community" (5-7 min)
### 4. Server Deployment (6-10 hours)
#### Official Servers
- [ ] Deploy signaling server to production
- [ ] Deploy sync server to production
- [ ] Deploy notification server to production
- [ ] Configure TURN server (Coturn or service)
- [ ] Configure SSL certificates
- [ ] Set up domain names
#### Monitoring & Observability
- [ ] Set up Prometheus metrics collection
- [ ] Configure Grafana dashboards
- [ ] Set up alerting (PagerDuty/Discord/email)
- [ ] Configure log aggregation
- [ ] Set up uptime monitoring
#### Operations
- [ ] Create deployment runbooks
- [ ] Document scaling procedures
- [ ] Set up backup systems (for notification server)
- [ ] Perform load testing
- [ ] Document incident response
### 5. Marketing Preparation (8-12 hours)
#### Demo Materials
- [ ] Create 3-5 minute demo video
- [ ] Create GIF demonstrations
- [ ] Prepare live demo environment
#### Content
- [ ] Write announcement blog post
- [ ] Write technical deep-dive blog post
- [ ] Create social media graphics
- [ ] Write Twitter/X thread
- [ ] Prepare Product Hunt submission
#### Press Kit
- [ ] Create feature comparison matrix
- [ ] Compile screenshots
- [ ] Write press release
- [ ] Prepare case studies
---
## Verification Steps
### Functional Verification
- [ ] All automated tests pass
- [ ] All manual test scenarios complete
- [ ] No critical bugs remaining
- [ ] Performance benchmarks met
### Documentation Verification
- [ ] Documentation is complete
- [ ] Documentation is accurate
- [ ] All links work
- [ ] Code examples run
### Infrastructure Verification
- [ ] Servers deployed and healthy
- [ ] Monitoring active
- [ ] Alerts configured
- [ ] Backup systems tested
### Security Verification
- [ ] Security audit passed
- [ ] Privacy compliance verified
- [ ] Rate limiting configured
- [ ] Authentication working
---
## Launch Checklist
### Pre-Launch (T-7 days)
- [ ] Feature freeze
- [ ] Final testing round
- [ ] Documentation review
- [ ] Marketing materials approved
### Launch Day (T-0)
- [ ] Feature flag enabled (if using)
- [ ] Blog post published
- [ ] Social media posts scheduled
- [ ] Team available for support
### Post-Launch (T+1 to T+7)
- [ ] Monitor server health
- [ ] Track error rates
- [ ] Respond to user feedback
- [ ] Fix critical issues immediately
- [ ] Document lessons learned
---
## Success Metrics
### Adoption Metrics
| Metric | Target | Measurement |
| ------------------------------ | ----------- | ----------- |
| Communities created | 10+ | First month |
| Collaboration sessions started | 100+ | First month |
| Average session duration | 15+ minutes | - |
| Components shared | 50+ | First month |
### Technical Metrics
| Metric | Target |
| ------------------------------ | ---------- |
| WebRTC connection success rate | >95% |
| Average connection time | <3 seconds |
| WebSocket fallback rate | <10% |
| Notification delivery rate | >99% |
| Server uptime | 99.9% |
### User Satisfaction
| Metric | Target |
| ------------------------------------- | ------- |
| Session completion rate | >80% |
| Feature usage (audio, cursor sharing) | Tracked |
| Net Promoter Score | >50 |
---
## Risk Mitigation
### Technical Risks
| Risk | Mitigation |
| ---------------------------------- | ----------------------------------------- |
| WebRTC fails in corporate networks | Automatic WebSocket fallback, TURN server |
| Server overload at launch | Load testing, auto-scaling, rate limiting |
| Data loss during sync | Yjs CRDT guarantees, periodic snapshots |
| Security vulnerabilities | Security audit, penetration testing |
### Business Risks
| Risk | Mitigation |
| ----------------------- | ------------------------------------------- |
| Low adoption | Marketing push, tutorial content |
| Community fragmentation | Make official community compelling |
| Server costs | P2P-first architecture, self-hosting option |
| Support overwhelm | Good documentation, FAQ, community forums |
---
## Files to Create/Update
```
Documentation:
docs/
├── collaboration/
│ ├── getting-started.md
│ ├── sessions.md
│ ├── troubleshooting.md
│ └── faq.md
├── community/
│ ├── creating-community.md
│ ├── community-json-schema.md
│ └── self-hosting.md
└── api/
├── collaboration-manager.md
├── notification-manager.md
└── community-manager.md
Marketing:
marketing/
├── demo-video-script.md
├── blog-post-announcement.md
├── blog-post-technical.md
├── social-media-kit/
│ ├── twitter-thread.md
│ └── graphics/
└── press-kit/
├── press-release.md
├── screenshots/
└── feature-comparison.md
```
---
## Definition of Done
This task (and the entire GIT-5 through GIT-11 series) is complete when:
1. ✅ All features implemented and tested
2. ✅ Performance benchmarks met
3. ✅ Documentation complete
4. ✅ Servers deployed and monitored
5. ✅ Marketing materials ready
6. ✅ Launch checklist complete
7. ✅ No critical bugs outstanding
---
## Related Tasks
- **GIT-005 through GIT-010**: All prior tasks in this series
- **Future**: Advanced features (screen sharing, session recording, AI integration)

View File

@@ -0,0 +1,292 @@
# VersionControlPanel + GitHub Integration Strategy
## Overview
This document outlines the strategy for integrating the new GitHub features (Issues, PRs, OAuth) with the existing VersionControlPanel. The goal is to **unify** rather than duplicate, leveraging the excellent existing git functionality.
---
## Key Finding: VersionControlPanel is Already React ✅
The existing VersionControlPanel is **100% React** with modern patterns:
- React functional components with hooks
- Context API (`VersionControlContext`)
- TypeScript throughout
- Uses `@noodl-core-ui` design system
- No jQuery (except PopupLayer which is a separate system)
**This means integration is straightforward - no rewrite needed!**
---
## What Already Exists
### VersionControlPanel (`packages/noodl-editor/src/editor/src/views/panels/VersionControlPanel/`)
| Component | Description | Status |
| ----------------------- | -------------------------------------- | ---------- |
| `LocalChanges.tsx` | View uncommitted changes | ✅ Working |
| `History.tsx` | Browse commits | ✅ Working |
| `HistoryCommitDiff.tsx` | Click commit → see visual diff | ✅ Working |
| `CommitChangesDiff.tsx` | Diff logic for commits | ✅ Working |
| `DiffList.tsx` | Visual diff renderer (green/red nodes) | ✅ Working |
| `BranchList.tsx` | Manage branches | ✅ Working |
| `BranchMerge.tsx` | Merge branches | ✅ Working |
| `MergeConflicts.tsx` | Resolve conflicts | ✅ Working |
| `Stashes.tsx` | Git stash support | ✅ Working |
| `GitProviderPopout.tsx` | GitHub/Git credentials | ✅ Working |
| `GitStatusButton.tsx` | Push/pull status | ✅ Working |
### VersionControlContext
Provides git state to all components:
```typescript
const {
git, // Git client instance
activeTabId, // Current tab
setActiveTabId, // Tab switcher
localChangesCount, // Number of uncommitted changes
branchStatus, // Current branch state
fetch, // Fetch operations
updateLocalDiff // Refresh diff
} = useVersionControlContext();
```
### GitHubPanel (New - from GIT-004 work)
| Component | Description | Status |
| -------------------- | ---------------------- | ---------- |
| `GitHubPanel.tsx` | Container panel | ✅ Working |
| `useIssues.ts` | Fetch GitHub issues | ✅ Working |
| `usePullRequests.ts` | Fetch GitHub PRs | ✅ Working |
| `ConnectToGitHub/` | GitHub connection flow | ✅ Working |
| `SyncToolbar.tsx` | Push/pull/sync status | ✅ Working |
| `GitHubClient.ts` | GitHub API wrapper | ✅ Working |
---
## Integration Strategy
### Option A: Extend VersionControlPanel (Recommended) ✅
Add GitHub-specific tabs to the existing panel:
```
┌──────────────────────────────────────────────────┐
│ Version Control [⚙️] [🔗] │
├──────────────────────────────────────────────────┤
│ [GitStatusButton - push/pull status] │
│ [BranchStatusButton - current branch] │
├──────────────────────────────────────────────────┤
│ [Changes] [History] [Issues] [PRs] ← NEW TABS │
├──────────────────────────────────────────────────┤
│ (Tab content - all existing components work) │
└──────────────────────────────────────────────────┘
```
**Pros:**
- Leverages ALL existing functionality
- Single unified panel
- Visual diff system already works
- Less code to maintain
- Familiar UX for existing users
---
## Implementation Plan
### Phase 1: Add GitHub Context (2-3 hours)
Create a GitHub-specific context that can be used alongside VersionControlContext:
```typescript
// New: GitHubContext.tsx in VersionControlPanel/context/
interface GitHubContextValue {
isGitHubConnected: boolean;
issues: Issue[];
pullRequests: PullRequest[];
issuesLoading: boolean;
prsLoading: boolean;
refreshIssues: () => void;
refreshPRs: () => void;
}
export function GitHubProvider({ children, git }) {
// Use existing hooks from GitHubPanel
const { issues, loading: issuesLoading, refetch: refreshIssues } = useIssues(repoOwner, repoName);
const { pullRequests, loading: prsLoading, refetch: refreshPRs } = usePullRequests(repoOwner, repoName);
return (
<GitHubContext.Provider value={{...}}>
{children}
</GitHubContext.Provider>
);
}
```
### Phase 2: Add Issues Tab (3-4 hours)
Move/refactor components from GitHubPanel:
```
VersionControlPanel/
├── components/
│ ├── IssuesTab/ ← NEW (from GitHubPanel)
│ │ ├── IssuesList.tsx
│ │ ├── IssueItem.tsx
│ │ └── IssueDetail.tsx
```
Wire into tabs:
```typescript
// In VersionControlPanel.tsx
<Tabs
tabs={[
{ id: 'changes', label: 'Local Changes', content: <LocalChanges /> },
{ id: 'history', label: 'History', content: <History /> },
// NEW:
{ id: 'issues', label: `Issues (${issues.length})`, content: <IssuesTab />, disabled: !isGitHubConnected },
{ id: 'prs', label: `PRs (${prs.length})`, content: <PullRequestsTab />, disabled: !isGitHubConnected }
]}
/>
```
### Phase 3: Add PRs Tab (3-4 hours)
Similar to Issues tab:
```
VersionControlPanel/
├── components/
│ ├── PullRequestsTab/ ← NEW (from GitHubPanel)
│ │ ├── PRsList.tsx
│ │ ├── PRItem.tsx
│ │ └── PRDetail.tsx
```
### Phase 4: Enhance GitProviderPopout (2-3 hours)
Upgrade existing popout with OAuth flow:
```typescript
// GitProviderPopout.tsx - enhance with OAuth
import { GitHubOAuthService } from '@noodl-services/GitHubOAuthService';
// Add "Connect with GitHub" button that triggers OAuth
// Show connected status
// Show authenticated user info
```
### Phase 5: Deprecate Separate GitHubPanel (1-2 hours)
- Remove GitHubPanel from sidebar registration
- Keep components for reuse in VersionControlPanel
- Update any references
---
## Files to Modify
### VersionControlPanel (Main Changes)
```
packages/noodl-editor/src/editor/src/views/panels/VersionControlPanel/
├── VersionControlPanel.tsx # Add new tabs
├── context/
│ ├── index.tsx # Add GitHubProvider
│ └── GitHubContext.tsx # NEW
├── components/
│ ├── IssuesTab/ # NEW (move from GitHubPanel)
│ ├── PullRequestsTab/ # NEW (move from GitHubPanel)
│ └── GitProviderPopout/
│ └── GitProviderPopout.tsx # Enhance with OAuth
```
### Move from GitHubPanel
```
FROM: packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
components/IssuesTab/ → VersionControlPanel/components/IssuesTab/
components/PullRequestsTab/ → VersionControlPanel/components/PullRequestsTab/
hooks/useIssues.ts → VersionControlPanel/hooks/useIssues.ts
hooks/usePullRequests.ts → VersionControlPanel/hooks/usePullRequests.ts
```
### Services (Keep as-is)
```
packages/noodl-editor/src/editor/src/services/github/
├── GitHubClient.ts # Keep - used by hooks
├── GitHubTypes.ts # Keep - type definitions
├── GitHubOAuthService.ts # Keep - OAuth flow
```
---
## What Stays Separate
### GitHubPanel Features That Don't Move
Some features make more sense in their current location:
- **Launcher "Clone from GitHub"** → Stay in Launcher (TASK-002C)
- **Connect/Create Repo flows** → Can be triggered from VersionControlPanel but may open modals
---
## Visual Diff System - Already Working!
The visual diff magic you mentioned already works perfectly. When clicking a commit in History:
1. `History.tsx` renders list of commits
2. Click → `HistoryCommitDiff.tsx` renders
3. `CommitChangesDiff.tsx` fetches the project.json diff
4. `DiffList.tsx` renders:
- "Changed Components" section (click to see visual diff)
- "Changed Files" section
- "Changed Settings" section
- "Changed Styles" section
5. `useShowComponentDiffDocument` opens the visual node graph diff
**This all continues to work unchanged!**
---
## Benefits of This Approach
1. **Single panel** for all version control + GitHub
2. **Existing visual diffs** work unchanged
3. **No rewrite** - just adding tabs
4. **Shared context** between git and GitHub features
5. **Familiar UX** - users already know VersionControlPanel
6. **Less code** to maintain than two separate panels
---
## Estimated Effort
| Phase | Task | Hours |
| --------- | ------------------------- | --------------- |
| 1 | Add GitHub Context | 2-3 |
| 2 | Add Issues Tab | 3-4 |
| 3 | Add PRs Tab | 3-4 |
| 4 | Enhance GitProviderPopout | 2-3 |
| 5 | Cleanup & Deprecate | 1-2 |
| **Total** | | **11-16 hours** |
This is significantly less than maintaining two separate panels!
---
## Future: Community Tab
For the GIT-005-011 collaboration features, we might add a third panel:
- **VersionControlPanel** → Git + GitHub (Issues, PRs)
- **CommunityPanel** → Communities, Sessions, Components, Notifications
Or integrate Community features as more tabs if it makes sense UX-wise.

View File

@@ -1,15 +1,39 @@
# GIT-004: GitHub Project Management Integration
# TASK-002B: GitHub Advanced Integration
## Overview
Transform Nodegex from a "coding tool with Git" into a **collaborative development hub** where teams can manage their entire project workflow without leaving the editor. This feature enables viewing, creating, and managing GitHub Issues, Pull Requests, and Discussions directly from both the Nodegex Editor and Dashboard.
This task encompasses **two major feature sets**:
### Part 1: GitHub Project Management (GIT-004A-F)
Transform OpenNoodl from a "coding tool with Git" into a **collaborative development hub** where teams can manage their entire project workflow without leaving the editor.
**The killer feature**: Link GitHub issues directly to visual components, enabling unprecedented traceability between project management and implementation.
**Effort:** 70-90 hours across 6 sub-tasks
### Part 2: Live Collaboration & Multi-Community (GIT-005-011)
Transform OpenNoodl into a **collaborative platform** with real-time multi-user editing (Google Docs for visual programming), multi-community support, and persistent notifications.
**Effort:** 431-572 hours across 7 sub-tasks
---
**Phase:** 3 (Dashboard UX & Collaboration)
**Priority:** HIGH (key differentiator)
**Total Effort:** 70-90 hours across 6 sub-tasks
**Risk:** Medium (OAuth complexity, GitHub API rate limits)
**Total Effort:** 501-662 hours across 13 sub-tasks
**Risk:** Medium-High (OAuth complexity, WebRTC, server infrastructure)
---
## Integration Strategy
**Important:** We're extending the existing `VersionControlPanel` rather than creating a separate panel. The existing panel is already 100% React with full visual diff support (showing green nodes for additions, red for deletions).
See **[GIT-INTEGRATION-STRATEGY.md](./GIT-INTEGRATION-STRATEGY.md)** for details.
**Key insight:** The visual commit diff system already works perfectly - we just need to add GitHub-specific tabs (Issues, PRs) to the existing panel.
---
@@ -19,13 +43,13 @@ Transform Nodegex from a "coding tool with Git" into a **collaborative developme
No major low-code platform offers this level of GitHub integration:
| Platform | Git Support | Issues/PRs | Component Linking |
|----------|-------------|------------|-------------------|
| Retool | ❌ | ❌ | ❌ |
| Bubble | ❌ | ❌ | ❌ |
| Webflow | Basic | ❌ | ❌ |
| FlutterFlow | Basic | ❌ | ❌ |
| **Nodegex** | ✅ Full | ✅ Full | ✅ **Unique** |
| Platform | Git Support | Issues/PRs | Component Linking |
| ----------- | ----------- | ---------- | ----------------- |
| Retool | ❌ | ❌ | ❌ |
| Bubble | ❌ | ❌ | ❌ |
| Webflow | Basic | ❌ | ❌ |
| FlutterFlow | Basic | ❌ | ❌ |
| **Nodegex** | ✅ Full | ✅ Full | ✅ **Unique** |
### Target Users
@@ -101,6 +125,7 @@ Overview widgets for project health and team awareness.
Upgrade from PAT-based authentication to full GitHub App OAuth flow, and create a reusable GitHub API client.
**Scope:**
- GitHub App registration guidance/documentation
- OAuth authorization flow in Electron
- Secure token storage (upgrade from current GitStore pattern)
@@ -108,6 +133,7 @@ Upgrade from PAT-based authentication to full GitHub App OAuth flow, and create
- Token refresh handling
**Files to Create:**
```
packages/noodl-editor/src/editor/src/services/github/
├── GitHubAuth.ts # OAuth flow handler
@@ -118,6 +144,7 @@ packages/noodl-editor/src/editor/src/services/github/
```
**Files to Modify:**
```
packages/noodl-editor/src/editor/src/views/panels/VersionControlPanel/
components/GitProviderPopout/GitProviderPopout.tsx
@@ -129,6 +156,7 @@ packages/noodl-git/src/git.ts
```
**Success Criteria:**
- [ ] User can authenticate via GitHub OAuth from editor
- [ ] Token stored securely and persists across sessions
- [ ] Token refresh works automatically
@@ -142,6 +170,7 @@ packages/noodl-git/src/git.ts
View GitHub issues for the connected repository.
**Scope:**
- New sidebar panel: GitHubPanel with tabbed interface
- Issues list with filtering (open/closed, labels, assignees)
- Issue detail view (body, comments, labels, assignees)
@@ -150,6 +179,7 @@ View GitHub issues for the connected repository.
- Pagination for large issue lists
**Files to Create:**
```
packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
├── GitHubPanel.tsx
@@ -169,6 +199,7 @@ packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
```
**Files to Modify:**
```
packages/noodl-editor/src/editor/src/router.setup.ts
- Register GitHubPanel with SidebarModel
@@ -178,10 +209,12 @@ packages/noodl-editor/src/editor/src/models/sidebar/sidebarmodel.tsx
```
**Dependencies:**
- `react-markdown` for rendering issue bodies
- `@octokit/rest` (from GIT-004A)
**Success Criteria:**
- [ ] GitHub panel appears in sidebar when repo is connected
- [ ] Issues list loads and displays correctly
- [ ] Filters work (open/closed, labels, assignees, search)
@@ -197,6 +230,7 @@ packages/noodl-editor/src/editor/src/models/sidebar/sidebarmodel.tsx
View GitHub pull requests for the connected repository.
**Scope:**
- PRs tab in GitHubPanel
- PR list with status indicators (draft, review requested, approved, changes requested)
- PR detail view (description, commits, checks status)
@@ -205,6 +239,7 @@ View GitHub pull requests for the connected repository.
- Merge conflict indicators
**Files to Create:**
```
packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
├── components/
@@ -220,6 +255,7 @@ packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
```
**Success Criteria:**
- [ ] PRs tab displays all open PRs
- [ ] Status badges show draft/review/approved/changes requested
- [ ] PR detail shows description, commits list, checks status
@@ -234,6 +270,7 @@ packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
Full CRUD operations for GitHub issues from within Nodegex.
**Scope:**
- Create Issue dialog with title, body (markdown editor), labels, assignees
- Edit existing issues (title, body, status)
- Add comments to issues
@@ -242,6 +279,7 @@ Full CRUD operations for GitHub issues from within Nodegex.
- Issue templates support (load from repo's .github/ISSUE_TEMPLATE/)
**Files to Create:**
```
packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
├── components/
@@ -256,12 +294,14 @@ packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
```
**Files to Modify:**
```
packages/noodl-editor/src/editor/src/views/nodegrapheditor/
- Add context menu items for component-linked issue creation
```
**Success Criteria:**
- [ ] Create Issue dialog opens from panel header
- [ ] Can set title, body, labels, assignees
- [ ] Markdown preview works in body editor
@@ -279,6 +319,7 @@ packages/noodl-editor/src/editor/src/views/nodegrapheditor/
**THE KILLER FEATURE**: Link GitHub issues to visual components for unprecedented traceability.
**Scope:**
- Component metadata extension for issue links
- "Link Issue" dialog from component context menu
- "Create Issue from Component" with auto-populated context
@@ -295,21 +336,22 @@ interface ComponentMetadata {
name: string;
// ... existing fields
github?: {
linkedIssues: number[]; // Issue numbers
linkedPRs: number[]; // PR numbers
lastModifiedBy?: string; // GitHub username
linkedIssues: number[]; // Issue numbers
linkedPRs: number[]; // PR numbers
lastModifiedBy?: string; // GitHub username
};
}
// Issue display shows linked components
interface LinkedComponent {
componentName: string;
componentPath: string; // For navigation
componentPath: string; // For navigation
linkType: 'mentioned' | 'implements' | 'fixes';
}
```
**Files to Create:**
```
packages/noodl-editor/src/editor/src/services/github/
├── ComponentLinking.ts # Link management logic
@@ -323,6 +365,7 @@ packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
```
**Files to Modify:**
```
packages/noodl-editor/src/editor/src/models/projectmodel.ts
- Add methods for component GitHub metadata
@@ -338,17 +381,20 @@ packages/noodl-editor/src/editor/src/views/panels/PropertyPanel/
**User Flows:**
1. **Link Existing Issue to Component:**
- Right-click component → "Link to Issue"
- Search/select from open issues
- Choose link type (mentions, implements, fixes)
- Component shows badge, issue shows component link
2. **Create Issue from Component:**
- Right-click component → "Create Issue"
- Dialog pre-fills: component name, path, screenshot option
- Creates issue and links automatically
3. **Navigate from Issue to Component:**
- In issue detail, "Linked Components" section
- Click component name → navigates to component in editor
@@ -358,6 +404,7 @@ packages/noodl-editor/src/editor/src/views/panels/PropertyPanel/
- Auto-labels as "bug"
**Success Criteria:**
- [ ] Can link issues to components via context menu
- [ ] Can create issue from component with pre-filled context
- [ ] Components show visual indicator when issues linked
@@ -374,6 +421,7 @@ packages/noodl-editor/src/editor/src/views/panels/PropertyPanel/
Project health overview and activity feed in the Dashboard.
**Scope:**
- Project card enhancements: issue/PR counts, health indicators
- Activity feed widget: recent issue/PR activity across projects
- Notification badges for items needing attention
@@ -381,6 +429,7 @@ Project health overview and activity feed in the Dashboard.
- Click-through to editor with correct panel open
**Files to Create:**
```
packages/noodl-editor/src/editor/src/views/Dashboard/
├── components/
@@ -393,6 +442,7 @@ packages/noodl-editor/src/editor/src/views/Dashboard/
```
**Files to Modify:**
```
packages/noodl-editor/src/editor/src/views/Dashboard/ProjectCard/
- Add GitHub stats display
@@ -405,6 +455,7 @@ packages/noodl-editor/src/editor/src/views/Dashboard/Dashboard.tsx
**Dashboard Features:**
1. **Project Card Stats:**
```
┌─────────────────────────┐
│ My Project │
@@ -418,6 +469,7 @@ packages/noodl-editor/src/editor/src/views/Dashboard/Dashboard.tsx
```
2. **Activity Feed:**
- Shows recent activity across all connected projects
- Filterable by type (issues, PRs, discussions)
- "Assigned to me" filter
@@ -428,6 +480,7 @@ packages/noodl-editor/src/editor/src/views/Dashboard/Dashboard.tsx
- Attention triggers: assigned issues, review requests, mentions
**Success Criteria:**
- [ ] Project cards show GitHub stats when connected
- [ ] Activity feed displays recent cross-project activity
- [ ] Notification badges appear for items needing attention
@@ -466,7 +519,8 @@ function useIssues(repoOwner: string, repoName: string) {
const client = useGitHubClient();
useEffect(() => {
client.issues.list({ owner: repoOwner, repo: repoName })
client.issues
.list({ owner: repoOwner, repo: repoName })
.then(setIssues)
.catch(setError)
.finally(() => setLoading(false));
@@ -507,10 +561,10 @@ GitHub API has limits (5000 requests/hour for authenticated users). Strategy:
```json
{
"@octokit/rest": "^20.0.0", // GitHub API client
"@octokit/rest": "^20.0.0", // GitHub API client
"@octokit/auth-oauth-app": "^7.0.0", // OAuth flow
"react-markdown": "^9.0.0", // Markdown rendering
"remark-gfm": "^4.0.0" // GitHub Flavored Markdown
"react-markdown": "^9.0.0", // Markdown rendering
"remark-gfm": "^4.0.0" // GitHub Flavored Markdown
}
```
@@ -543,6 +597,7 @@ GIT-004A: OAuth & Client Foundation
```
**Recommended sequence:**
1. **GIT-004A** (Foundation) - Required first
2. **GIT-004B** (Issues Read) - Core value, quick win
3. **GIT-004D** (Issues Write) - Enables productivity
@@ -581,13 +636,13 @@ GIT-004A: OAuth & Client Foundation
## Risks & Mitigations
| Risk | Impact | Probability | Mitigation |
|------|--------|-------------|------------|
| GitHub API rate limits | Medium | Medium | Caching, conditional requests, user feedback |
| OAuth complexity in Electron | High | Medium | Follow GitHub Desktop patterns, thorough testing |
| Token security concerns | High | Low | Use OS keychain via electron-store encryption |
| Large repos performance | Medium | Medium | Pagination, virtual lists, lazy loading |
| GitHub API changes | Low | Low | Pin @octokit version, integration tests |
| Risk | Impact | Probability | Mitigation |
| ---------------------------- | ------ | ----------- | ------------------------------------------------ |
| GitHub API rate limits | Medium | Medium | Caching, conditional requests, user feedback |
| OAuth complexity in Electron | High | Medium | Follow GitHub Desktop patterns, thorough testing |
| Token security concerns | High | Low | Use OS keychain via electron-store encryption |
| Large repos performance | Medium | Medium | Pagination, virtual lists, lazy loading |
| GitHub API changes | Low | Low | Pin @octokit version, integration tests |
---
@@ -619,3 +674,124 @@ GIT-004A: OAuth & Client Foundation
- [GitHub App Permissions](https://docs.github.com/en/rest/overview/permissions-required-for-github-apps)
- Existing: `packages/noodl-git/` - Current Git integration
- Existing: `VersionControlPanel/` - Current VCS UI patterns
---
# Part 2: Live Collaboration & Multi-Community System (GIT-005-011)
## Overview
This series transforms OpenNoodl into a collaborative platform with multi-community support, live real-time collaboration (Google Docs for visual programming), and persistent notification system.
**Total Estimated Hours:** 431-572 hours
---
## Task Summary
| Task | Name | Estimated Hours | Status |
| ------- | --------------------------- | --------------- | -------------- |
| GIT-005 | Community Infrastructure | 60-80 | 🔴 Not Started |
| GIT-006 | Server Infrastructure | 80-100 | 🔴 Not Started |
| GIT-007 | WebRTC Collaboration Client | 100-130 | 🔴 Not Started |
| GIT-008 | Notification System | 50-70 | 🔴 Not Started |
| GIT-009 | Community Tab UI/UX | 80-100 | 🔴 Not Started |
| GIT-010 | Session Discovery & Joining | 50-70 | 🔴 Not Started |
| GIT-011 | Integration & Polish | 61-82 | 🔴 Not Started |
---
## Key Features
### Multi-Community System
- Join multiple communities simultaneously
- Fork and operate your own communities
- Self-hosted infrastructure option (BYOB philosophy)
- Community template repository for forking
### Live Collaboration
- Real-time multi-user editing via WebRTC
- Cursor and selection sharing
- Audio/video chat built-in
- Automatic WebSocket fallback when P2P fails
### Persistent Notifications
- Cross-session notification system
- Collaboration invites
- Community events and mentions
- Desktop notification support
### Community Features
- Component library discovery
- Tutorial and showcase feeds
- GitHub Discussions integration
- Job board
---
## Task Details
Each task has a detailed README in its subdirectory:
- **[GIT-005: Community Infrastructure](./GIT-005-community-infrastructure/README.md)** - Template repository, community.json schema, CommunityManager service
- **[GIT-006: Server Infrastructure](./GIT-006-server-infrastructure/README.md)** - Signaling, Sync, and Notification servers
- **[GIT-007: WebRTC Collaboration](./GIT-007-webrtc-collaboration/README.md)** - P2P connections, Yjs CRDT sync, media handling
- **[GIT-008: Notification System](./GIT-008-notification-system/README.md)** - NotificationManager, toasts, notification center
- **[GIT-009: Community UI](./GIT-009-community-ui/README.md)** - Community tab with sessions, components, tutorials
- **[GIT-010: Session Discovery](./GIT-010-session-discovery/README.md)** - Deep links, quick join, session history
- **[GIT-011: Integration & Polish](./GIT-011-integration-polish/README.md)** - Testing, documentation, deployment
---
## Implementation Order
```
GIT-005: Community Infrastructure
└──► GIT-006: Server Infrastructure
├──► GIT-007: WebRTC Collaboration Client
│ │
│ └──► GIT-010: Session Discovery
└──► GIT-008: Notification System
└──► GIT-009: Community Tab UI
└──► GIT-011: Integration & Polish
```
---
## Dependencies
### NPM Packages
```json
{
"yjs": "^13.6.0",
"y-webrtc": "^10.2.0",
"y-websocket": "^1.5.0",
"simple-peer": "^9.11.0"
}
```
### External Infrastructure
- Signaling server (WebSocket)
- Sync server (Yjs WebSocket)
- Notification server (WebSocket + persistence)
- TURN server (for WebRTC fallback)
---
## Source Documentation
Full detailed specifications are in:
- `GIT-5-to-GIT-11-Live-Collaboration-Community-System.md`
- `GIT-5-to-GIT-11-Part-2-Community-UI-Integration.md`

View File

@@ -0,0 +1,246 @@
# TASK-002C: GitHub Clone & Connect - Changelog
## 2025-01-17 - Subtask A: Clone from GitHub ✅ COMPLETE
### Implemented Features
#### 1. GitHub Tab in Launcher
- New "GitHub" tab added to the launcher navigation
- Shows user's GitHub avatar when connected
- Only available when authenticated
#### 2. Noodl Project Detection
- Scans all repos for `project.json` at root
- Shows scanning progress: "Scanning X repositories for project.json..."
- Only displays repos that are confirmed Noodl projects
- Caches detection results for 1 minute
#### 3. Repository Organization
- Groups repos by source: Personal vs Organization
- Collapsible organization sections with project counts
- Shows repo details: name, privacy status, description, last updated
#### 4. Clone Functionality
- Clone button on each Noodl project card
- User selects destination folder via native dialog
- Progress indicator during clone
- **Legacy project detection**: After cloning, detects React 17 projects and shows:
- **Migrate** - Opens migration wizard
- **Read-Only** - Opens in read-only mode
- **Cancel** - Adds to projects list without opening
#### 5. OAuth Improvements
- Fixed cache bug: null values now properly handled (undefined vs null)
- Fixed scope: Token now has proper permissions for private repo access
- Connection status persists across sessions
### Files Modified
**Editor (noodl-editor):**
- `src/editor/src/pages/ProjectsPage/ProjectsPage.tsx` - Added handleCloneRepo with legacy detection
- `src/editor/src/services/github/GitHubClient.ts` - Fixed cache, simplified isNoodlProject
- `src/editor/src/services/GitHubOAuthService.ts` - Fixed token persistence
**Core UI (noodl-core-ui):**
- `src/preview/launcher/Launcher/views/GitHubRepos.tsx` - New GitHub repos view
- `src/preview/launcher/Launcher/hooks/useGitHubRepos.ts` - Hook for fetching/detecting repos
- `src/preview/launcher/Launcher/Launcher.tsx` - Added GitHub tab
- `src/preview/launcher/Launcher/LauncherContext.tsx` - Added GitHub context props
### Bug Fixes
- Fixed cache returning stale null values (404 results)
- Removed premature `nodegx.project.json` check
- Fixed OAuth flow to properly complete on macOS
### Known Limitations
- Only checks default branch for project.json
- API rate limit: ~5000 requests/hour (sufficient for most users)
- Large organizations may take time to scan all repos
---
## 2025-01-17 - Subtask B: Connect Project to GitHub ✅ COMPLETE
### Implemented Features
#### 1. Enhanced Git State Detection
- Extended `useGitHubRepository` hook to return detailed git state:
- `no-git` - No .git folder, project not under version control
- `git-no-remote` - Has .git but no remote configured
- `remote-not-github` - Has remote but not github.com
- `github-connected` - Connected to GitHub
#### 2. Connect to GitHub Flow
- **ConnectToGitHubView** - Main view showing state-appropriate options
- State-specific messaging:
- No git: "Initialize Git Repository"
- No remote: "Connect to GitHub"
- Not GitHub: Shows current remote URL with explanation
#### 3. Create New Repository
- **CreateRepoModal** - Full modal for creating new GitHub repos
- Features:
- Repository name with validation (GitHub naming rules)
- Optional description
- Visibility toggle (Private/Public, defaults to Private)
- Organization picker (lists user's organizations)
- After creation:
- Initializes git if needed
- Adds remote origin
- Creates initial commit
- Pushes to remote
#### 4. Connect to Existing Repository
- **SelectRepoModal** - Browse and select from user's repos
- Features:
- Lists all user repos + organization repos
- Search/filter by name or description
- Grouped by owner (Personal vs Organization)
- Shows privacy status and last updated
- After connecting:
- Initializes git if needed
- Sets remote URL
- Fetches from remote if repo has commits
- Attempts to merge remote changes
### Files Added
**ConnectToGitHub Components:**
- `components/ConnectToGitHub/ConnectToGitHubView.tsx` - Main view component
- `components/ConnectToGitHub/CreateRepoModal.tsx` - Create repo modal
- `components/ConnectToGitHub/SelectRepoModal.tsx` - Select existing repo modal
- `components/ConnectToGitHub/ConnectToGitHub.module.scss` - Styles
- `components/ConnectToGitHub/index.ts` - Exports
### Files Modified
- `hooks/useGitHubRepository.ts` - Enhanced with `ProjectGitState` type and `refetch` function
- `services/github/GitHubTypes.ts` - Added `CreateRepositoryOptions` interface
- `services/github/GitHubClient.ts` - Added `createRepository` method
- `GitHubPanel.tsx` - Integrated ConnectToGitHubView for unconnected states
---
## 2025-01-17 - Subtask C: Push/Pull from GitHub Panel ✅ COMPLETE
### Implemented Features
#### 1. Git Sync Status Hook
- **useGitSyncStatus** - Monitors git sync status in real-time
- Tracks:
- `ahead` - Number of commits ahead of remote
- `behind` - Number of commits behind remote
- `hasUncommittedChanges` - Whether there are local changes
- Provides:
- `push()` - Push to remote (auto-commits if uncommitted changes)
- `pull()` - Pull from remote (stashes changes, merges, pops stash)
- `refresh()` - Manually refresh sync status
- Auto-refreshes on project save and remote changes
#### 2. Sync Toolbar
- **SyncToolbar** - Toolbar displayed at top of GitHubPanel when connected
- Shows:
- Repository name (owner/repo)
- Sync status (uncommitted changes, up to date)
- Buttons:
- **Pull** - Pull from remote, shows badge with behind count
- **Push** - Push to remote, shows badge with ahead count or "!" for uncommitted
- **Refresh** - Manually refresh sync status (spinning animation while loading)
- Visual feedback:
- Highlighted buttons when there are changes to push/pull
- Error bar for operation failures
#### 3. Push Operation
- Checks for uncommitted changes
- Auto-commits with "Auto-commit before push" message
- Pushes to remote
- Refreshes sync status
#### 4. Pull Operation
- Stashes local changes if present
- Fetches from remote
- Merges remote branch into current branch
- Pops stash to restore local changes
- Notifies project to refresh
### Files Added
**SyncToolbar Component:**
- `components/SyncToolbar/SyncToolbar.tsx` - Toolbar with push/pull buttons
- `components/SyncToolbar/SyncToolbar.module.scss` - Styles
- `components/SyncToolbar/index.ts` - Exports
**Hooks:**
- `hooks/useGitSyncStatus.ts` - Git sync status monitoring
### Files Modified
- `GitHubPanel.tsx` - Added SyncToolbar to connected state view
---
## Summary
### All Subtasks Complete ✅
| Subtask | Status | Description |
| ------------------------------ | -------- | ----------------------------------------------- |
| A: Clone from GitHub | Complete | Clone Noodl projects from GitHub in launcher |
| B: Connect Project to GitHub | Complete | Initialize git, create/connect repo from editor |
| C: Push/Pull from GitHub Panel | Complete | Push/pull with sync status in GitHubPanel |
### Files Created (New)
```
packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
├── components/
│ ├── ConnectToGitHub/
│ │ ├── ConnectToGitHub.module.scss
│ │ ├── ConnectToGitHubView.tsx
│ │ ├── CreateRepoModal.tsx
│ │ ├── SelectRepoModal.tsx
│ │ └── index.ts
│ └── SyncToolbar/
│ ├── SyncToolbar.module.scss
│ ├── SyncToolbar.tsx
│ └── index.ts
└── hooks/
└── useGitSyncStatus.ts
```
### Files Modified
```
packages/noodl-editor/src/editor/src/
├── services/github/
│ ├── GitHubClient.ts (added createRepository)
│ └── GitHubTypes.ts (added CreateRepositoryOptions)
└── views/panels/GitHubPanel/
├── GitHubPanel.tsx (integrated new components)
└── hooks/
└── useGitHubRepository.ts (enhanced state detection)
```
---
_Completed: January 2025_

View File

@@ -0,0 +1,197 @@
# TASK-002C: GitHub Clone & Connect UX
## Overview
Add two critical GitHub UX flows that are currently missing:
1. **Clone Noodl Projects from GitHub** - Browse and clone existing Noodl projects from user's orgs/repos
2. **Connect Project to GitHub** - Initialize git and create/connect remote for unconnected projects
## Prerequisites
- [x] GitHub OAuth working (TASK-002B)
- [x] Token persistence working
- [x] GitHubOAuthService unified across launcher/editor
## Subtask A: Clone from GitHub (Launcher)
### User Story
> As a user, I want to browse my GitHub orgs/repos in the Launcher and clone existing Noodl projects with one click.
### Requirements
1. **UI: GitHub Repositories Tab/Section in Launcher**
- Show when authenticated with GitHub
- List user's organizations (from OAuth app installations)
- List user's personal repositories
- Search/filter by name
- Visual indicator for Noodl projects (detected via `project.json`)
2. **Noodl Project Detection**
- Query GitHub API for `project.json` in repo root
- Only show repos that contain `project.json` (optional: user toggle to show all)
- Show warning badge for repos without `project.json`
3. **Clone Flow**
- User clicks "Clone" on a repo
- Select local destination folder
- Clone with progress indicator
- Automatically add to projects list
- Option to open immediately after clone
### API Requirements
```typescript
// GitHubClient additions needed:
listUserRepositories(): Promise<GitHubRepository[]>
listOrganizationRepositories(org: string): Promise<GitHubRepository[]>
getFileContent(owner: string, repo: string, path: string): Promise<string | null>
```
### Files to Create/Modify
- `packages/noodl-core-ui/src/preview/launcher/Launcher/views/GitHubRepos/` (new)
- `GitHubReposView.tsx`
- `GitHubReposList.tsx`
- `GitHubRepoCard.tsx`
- `hooks/useGitHubRepos.ts`
- `packages/noodl-editor/src/editor/src/services/github/GitHubClient.ts` (extend)
- `packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx` (add clone handler)
---
## Subtask B: Connect Project to GitHub (In-Editor)
### User Story
> As a user, when my project isn't connected to GitHub, I want to either create a new repo or connect to an existing one.
### Requirements
1. **Detection State**
- Project has no `.git` folder → "Initialize Git"
- Project has `.git` but no remote → "Add Remote"
- Project has remote but not GitHub → "Not a GitHub repo" (read-only info)
- Project connected to GitHub → Show Issues/PRs as normal
2. **UI: "Not Connected" State in GitHubPanel**
- Replace 404 errors with friendly message
- Show two primary actions:
- **"Create New Repository"** → Creates on GitHub, adds as remote, pushes
- **"Connect Existing Repository"** → Browse user's repos, set as remote
3. **Create New Repo Flow**
- Modal: Enter repo name, description, visibility (public/private)
- Select organization (from OAuth installations) or personal
- Create via GitHub API
- Initialize git if needed
- Add remote origin
- Initial commit & push
4. **Connect Existing Repo Flow**
- Browse user's orgs/repos (reuse from Subtask A)
- Select repo
- Add as remote origin
- Offer to fetch/pull if repo has commits
### API Requirements
```typescript
// GitHubClient additions needed:
createRepository(options: {
name: string;
description?: string;
private?: boolean;
org?: string; // If creating in org
}): Promise<GitHubRepository>
```
### Files to Create/Modify
- `packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/GitHubPanel.tsx` (modify)
- `packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/ConnectToGitHub/` (new)
- `ConnectToGitHubView.tsx`
- `CreateRepoModal.tsx`
- `SelectRepoModal.tsx`
- `packages/noodl-editor/src/editor/src/services/github/GitHubClient.ts` (extend)
---
## Design Considerations
### Noodl Project Detection
Option 1: **Check for `project.json` via API**
```typescript
async isNoodlProject(owner: string, repo: string): Promise<boolean> {
const content = await this.getFileContent(owner, repo, 'project.json');
return content !== null;
}
```
Option 2: **Topics/Labels** (future)
- Allow users to add `noodl-project` topic to repos
- Search by topic
### Error Handling
- Rate limit warnings (GitHub API limits)
- Private repo access errors (missing OAuth scope)
- Clone failures (disk space, permissions)
- Push failures (authentication, branch protection)
### Performance
- Cache repo lists with short TTL (30 seconds)
- Lazy load Noodl detection (don't check all repos immediately)
- Pagination for orgs with many repos
---
## Acceptance Criteria
### Subtask A: Clone
- [ ] Authenticated user can see "GitHub Repos" section in launcher
- [ ] Can browse personal repos and org repos
- [ ] Noodl projects are visually distinguished
- [ ] Can clone repo to selected location
- [ ] Cloned project appears in projects list
- [ ] Progress indicator during clone
### Subtask B: Connect
- [ ] Non-git project shows "Initialize & Connect" option
- [ ] Git project without remote shows "Connect" option
- [ ] Can create new repo (personal or org)
- [ ] Can connect to existing repo
- [ ] After connecting, Issues/PRs tabs work
---
## Related Tasks
- TASK-002B: GitHub OAuth Integration (prerequisite - DONE)
- TASK-002D: GitHub Sync Status (future - show sync status in project cards)
---
## Estimated Effort
| Subtask | Effort | Priority |
| -------------------- | --------- | -------- |
| A: Clone from GitHub | 4-6 hours | High |
| B: Connect to GitHub | 3-4 hours | High |
---
_Created: January 2026_
_Status: DRAFT_

View File

@@ -17,8 +17,10 @@ import {
CloudSyncType,
LauncherProjectData
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
import { NoodlGitHubRepo, UseGitHubReposReturn } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useGitHubRepos';
import { usePersistentTab } from '@noodl-core-ui/preview/launcher/Launcher/hooks/usePersistentTab';
import { GitHubUser, LauncherPageId, LauncherProvider } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
import { GitHubRepos } from '@noodl-core-ui/preview/launcher/Launcher/views/GitHubRepos';
import { LearningCenter } from '@noodl-core-ui/preview/launcher/Launcher/views/LearningCenter';
import { Projects } from '@noodl-core-ui/preview/launcher/Launcher/views/Projects';
import { Templates } from '@noodl-core-ui/preview/launcher/Launcher/views/Templates';
@@ -53,15 +55,24 @@ export interface LauncherProps {
githubIsConnecting?: boolean;
onGitHubConnect?: () => void;
onGitHubDisconnect?: () => void;
// GitHub repos for clone feature (optional - for Storybook compatibility)
githubRepos?: UseGitHubReposReturn | null;
onCloneRepo?: (repo: NoodlGitHubRepo) => Promise<void>;
}
// Tab configuration
// Tab configuration (GitHub tab added dynamically based on auth state)
const LAUNCHER_TABS: TabBarItem[] = [
{
id: 'projects',
label: 'Projects',
icon: IconName.FolderOpen
},
{
id: 'github',
label: 'GitHub',
icon: IconName.CloudFunction
},
{
id: 'learn',
label: 'Learn',
@@ -187,7 +198,9 @@ export function Launcher({
githubIsAuthenticated,
githubIsConnecting,
onGitHubConnect,
onGitHubDisconnect
onGitHubDisconnect,
githubRepos,
onCloneRepo
}: LauncherProps) {
// Determine initial tab: props > deep link > persisted > default
const deepLinkTab = parseDeepLink();
@@ -263,6 +276,8 @@ export function Launcher({
switch (activePageId) {
case 'projects':
return <Projects />;
case 'github':
return <GitHubRepos />;
case 'learn':
return <LearningCenter />;
case 'templates':
@@ -295,7 +310,9 @@ export function Launcher({
githubIsAuthenticated,
githubIsConnecting,
onGitHubConnect,
onGitHubDisconnect
onGitHubDisconnect,
githubRepos,
onCloneRepo
}}
>
<div className={css['Root']}>

View File

@@ -9,8 +9,9 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { LauncherProjectData } from './components/LauncherProjectCard';
import { NoodlGitHubRepo, UseGitHubReposReturn } from './hooks/useGitHubRepos';
export type LauncherPageId = 'projects' | 'learn' | 'templates';
export type LauncherPageId = 'projects' | 'learn' | 'templates' | 'github';
// GitHub user info (matches GitHubOAuthService interface)
export interface GitHubUser {
@@ -52,6 +53,10 @@ export interface LauncherContextValue {
githubIsConnecting?: boolean;
onGitHubConnect?: () => void;
onGitHubDisconnect?: () => void;
// GitHub repos for clone feature (optional - for Storybook compatibility)
githubRepos?: UseGitHubReposReturn | null;
onCloneRepo?: (repo: NoodlGitHubRepo) => Promise<void>;
}
const LauncherContext = createContext<LauncherContextValue | null>(null);

View File

@@ -0,0 +1,337 @@
/**
* useGitHubRepos Hook
*
* Fetches and manages GitHub repositories for the authenticated user,
* including personal repos and organization repos.
* Detects Noodl projects by checking for project.json or nodegx.project.json.
*
* @module noodl-core-ui/preview/launcher/Launcher/hooks
*/
import { useState, useEffect, useCallback, useRef } from 'react';
// ==================== LOCAL TYPE DEFINITIONS ====================
// These mirror the GitHub types but are defined locally to avoid circular dependencies
/**
* GitHub User/Owner
*/
export interface GitHubOwner {
id: number;
login: string;
avatar_url: string;
html_url: string;
}
/**
* GitHub Repository (minimal fields needed for clone UI)
*/
export interface GitHubRepo {
id: number;
name: string;
full_name: string;
owner: GitHubOwner;
private: boolean;
html_url: string;
description: string | null;
clone_url?: string;
ssh_url?: string;
updated_at: string;
pushed_at: string;
default_branch: string;
stargazers_count: number;
language: string | null;
}
/**
* GitHub Organization
*/
export interface GitHubOrg {
id: number;
login: string;
avatar_url: string;
description: string | null;
}
// ==================== HOOK TYPES ====================
/**
* Extended repo info with Noodl detection status
*/
export interface NoodlGitHubRepo extends GitHubRepo {
/** Whether this repo is a Noodl project */
isNoodlProject: boolean | null; // null = not yet checked
/** Whether Noodl detection is in progress */
isCheckingNoodl: boolean;
/** Source: 'personal' or org name */
source: string;
}
/**
* Organization with its repos
*/
export interface GitHubOrgWithRepos extends GitHubOrg {
repos: NoodlGitHubRepo[];
isLoading: boolean;
error: string | null;
}
/**
* Hook return type
*/
export interface UseGitHubReposReturn {
/** All Noodl project repos (filtered) */
noodlProjects: NoodlGitHubRepo[];
/** All repos (unfiltered) */
allRepos: NoodlGitHubRepo[];
/** User's organizations */
organizations: GitHubOrgWithRepos[];
/** Personal repos */
personalRepos: NoodlGitHubRepo[];
/** Loading state */
isLoading: boolean;
/** Error message */
error: string | null;
/** Refresh all data */
refresh: () => Promise<void>;
/** Check if a specific repo is a Noodl project */
checkIfNoodlProject: (owner: string, repo: string) => Promise<boolean>;
}
/**
* GitHub API client interface (injected to avoid circular dependencies)
*/
export interface GitHubClientInterface {
listRepositories: (options?: { per_page?: number; sort?: string }) => Promise<{ data: GitHubRepo[] }>;
listOrganizations: () => Promise<{ data: GitHubOrg[] }>;
listOrganizationRepositories: (org: string, options?: { per_page?: number }) => Promise<{ data: GitHubRepo[] }>;
isNoodlProject: (owner: string, repo: string) => Promise<boolean>;
}
/**
* Hook to fetch GitHub repositories and detect Noodl projects
*
* @param client - GitHub client instance
* @param isAuthenticated - Whether user is authenticated with GitHub
*/
export function useGitHubRepos(client: GitHubClientInterface | null, isAuthenticated: boolean): UseGitHubReposReturn {
const [personalRepos, setPersonalRepos] = useState<NoodlGitHubRepo[]>([]);
const [organizations, setOrganizations] = useState<GitHubOrgWithRepos[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Track ongoing Noodl checks to avoid duplicates
const noodlCheckQueue = useRef<Map<string, Promise<boolean>>>(new Map());
/**
* Check if a repo is a Noodl project (with deduplication)
*/
const checkIfNoodlProject = useCallback(
async (owner: string, repo: string): Promise<boolean> => {
if (!client) return false;
const key = `${owner}/${repo}`;
// Return existing promise if already checking
if (noodlCheckQueue.current.has(key)) {
return noodlCheckQueue.current.get(key)!;
}
// Start new check
const checkPromise = client.isNoodlProject(owner, repo);
noodlCheckQueue.current.set(key, checkPromise);
try {
const result = await checkPromise;
return result;
} finally {
noodlCheckQueue.current.delete(key);
}
},
[client]
);
/**
* Map GitHubRepo to NoodlGitHubRepo
*/
const mapRepo = useCallback((repo: GitHubRepo, source: string): NoodlGitHubRepo => {
return {
...repo,
isNoodlProject: null,
isCheckingNoodl: false,
source
};
}, []);
/**
* Check Noodl status for a batch of repos (rate-limit friendly)
*/
const checkNoodlStatusForRepos = useCallback(
async (repos: NoodlGitHubRepo[], updateFn: (repoId: number, isNoodl: boolean) => void) => {
console.log('🔍 [checkNoodlStatusForRepos] Starting check for', repos.length, 'repos');
// Check repos sequentially to avoid rate limits
for (const repo of repos) {
if (repo.isNoodlProject !== null) continue; // Already checked
try {
const isNoodl = await checkIfNoodlProject(repo.owner.login, repo.name);
console.log('🔍 [checkNoodlStatusForRepos]', repo.full_name, '- isNoodl:', isNoodl);
updateFn(repo.id, isNoodl);
// Small delay between checks to be rate-limit friendly
await new Promise((resolve) => setTimeout(resolve, 100));
} catch (err) {
// On error, mark as not a Noodl project
console.error('❌ [checkNoodlStatusForRepos] Error checking', repo.full_name, err);
updateFn(repo.id, false);
}
}
console.log('✅ [checkNoodlStatusForRepos] Finished checking repos');
},
[checkIfNoodlProject]
);
/**
* Fetch all repos (personal + org)
*/
const fetchRepos = useCallback(async () => {
console.log('🔍 [useGitHubRepos] fetchRepos called', {
hasClient: !!client,
isAuthenticated
});
if (!client || !isAuthenticated) {
console.log('🔍 [useGitHubRepos] Skipping fetch - not authenticated or no client');
setPersonalRepos([]);
setOrganizations([]);
setError(null);
return;
}
setIsLoading(true);
setError(null);
try {
console.log('🔍 [useGitHubRepos] Fetching personal repos and orgs...');
// Fetch personal repos and orgs in parallel
const [reposResponse, orgsResponse] = await Promise.all([
client.listRepositories({ per_page: 100, sort: 'updated' }),
client.listOrganizations()
]);
console.log('🔍 [useGitHubRepos] API responses:', {
personalRepoCount: reposResponse?.data?.length || 0,
orgCount: orgsResponse?.data?.length || 0
});
// Map personal repos
const mappedPersonalRepos = reposResponse.data.map((r) => mapRepo(r, 'personal'));
setPersonalRepos(mappedPersonalRepos);
// Initialize orgs (repos will be loaded on-demand or in parallel)
const mappedOrgs: GitHubOrgWithRepos[] = orgsResponse.data.map((org) => ({
...org,
repos: [],
isLoading: true,
error: null
}));
setOrganizations(mappedOrgs);
// Fetch org repos in parallel
const orgRepoPromises = orgsResponse.data.map(async (org) => {
try {
const orgRepos = await client.listOrganizationRepositories(org.login, { per_page: 100 });
return {
orgLogin: org.login,
repos: orgRepos.data.map((r) => mapRepo(r, org.login)),
error: null
};
} catch (err) {
return {
orgLogin: org.login,
repos: [],
error: err instanceof Error ? err.message : 'Failed to load repos'
};
}
});
const orgResults = await Promise.all(orgRepoPromises);
// Update orgs with their repos
setOrganizations((prev) =>
prev.map((org) => {
const result = orgResults.find((r) => r.orgLogin === org.login);
if (result) {
return {
...org,
repos: result.repos,
isLoading: false,
error: result.error
};
}
return { ...org, isLoading: false };
})
);
// Start checking Noodl status for personal repos
const updatePersonalRepo = (repoId: number, isNoodl: boolean) => {
setPersonalRepos((prev) =>
prev.map((r) => (r.id === repoId ? { ...r, isNoodlProject: isNoodl, isCheckingNoodl: false } : r))
);
};
// Check personal repos
checkNoodlStatusForRepos(mappedPersonalRepos, updatePersonalRepo);
// Check org repos
for (const result of orgResults) {
const updateOrgRepo = (repoId: number, isNoodl: boolean) => {
setOrganizations((prev) =>
prev.map((org) => {
if (org.login === result.orgLogin) {
return {
...org,
repos: org.repos.map((r) =>
r.id === repoId ? { ...r, isNoodlProject: isNoodl, isCheckingNoodl: false } : r
)
};
}
return org;
})
);
};
checkNoodlStatusForRepos(result.repos, updateOrgRepo);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch repositories');
} finally {
setIsLoading(false);
}
}, [client, isAuthenticated, mapRepo, checkNoodlStatusForRepos]);
// Fetch on mount and when auth changes
useEffect(() => {
fetchRepos();
}, [fetchRepos]);
// Compute derived values
const allRepos: NoodlGitHubRepo[] = [...personalRepos, ...organizations.flatMap((org) => org.repos)];
const noodlProjects = allRepos.filter((repo) => repo.isNoodlProject === true);
return {
noodlProjects,
allRepos,
organizations,
personalRepos,
isLoading,
error,
refresh: fetchRepos,
checkIfNoodlProject
};
}

View File

@@ -0,0 +1,450 @@
/**
* GitHubRepos View
*
* Browse and clone Noodl projects from GitHub repositories.
* Only shows repos that contain project.json or nodegx.project.json.
*
* @module noodl-core-ui/preview/launcher/Launcher/views
*/
import React, { useState, useMemo } from 'react';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { TextInput, TextInputVariant } from '@noodl-core-ui/components/inputs/TextInput';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
import { LauncherPage } from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherPage';
import { NoodlGitHubRepo, GitHubOrgWithRepos } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useGitHubRepos';
import { useLauncherContext } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
/**
* GitHub repo card for display in clone list
*/
interface GitHubRepoCardProps {
repo: NoodlGitHubRepo;
onClone: (repo: NoodlGitHubRepo) => void;
isCloning: boolean;
}
function GitHubRepoCard({ repo, onClone, isCloning }: GitHubRepoCardProps) {
const updatedAt = new Date(repo.updated_at).toLocaleDateString();
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--spacing-4)',
padding: 'var(--spacing-3) var(--spacing-4)',
backgroundColor: 'var(--theme-color-bg-3)',
borderRadius: 'var(--radius-default)',
border: '1px solid var(--theme-color-border-default)'
}}
>
{/* Avatar */}
<img
src={repo.owner.avatar_url}
alt={repo.owner.login}
style={{
width: 40,
height: 40,
borderRadius: 'var(--radius-default)'
}}
/>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontWeight: 600, color: 'var(--theme-color-fg-default)' }}>{repo.name}</span>
{repo.private && (
<span
style={{
fontSize: '10px',
padding: '2px 6px',
backgroundColor: 'var(--theme-color-secondary-highlight)',
borderRadius: 'var(--radius-small)',
color: 'var(--theme-color-fg-default)'
}}
>
Private
</span>
)}
{repo.isNoodlProject && (
<span
style={{
fontSize: '10px',
padding: '2px 6px',
backgroundColor: 'var(--theme-color-primary)',
borderRadius: 'var(--radius-small)',
color: 'white'
}}
>
Noodl Project
</span>
)}
</div>
<div style={{ fontSize: '12px', color: 'var(--theme-color-fg-default-shy)' }}>
{repo.full_name} Updated {updatedAt}
</div>
{repo.description && (
<div
style={{
fontSize: '12px',
color: 'var(--theme-color-fg-default-shy)',
marginTop: '4px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{repo.description}
</div>
)}
</div>
{/* Clone button */}
<PrimaryButton
label={isCloning ? 'Cloning...' : 'Clone'}
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
isDisabled={isCloning || repo.isCheckingNoodl}
onClick={() => onClone(repo)}
/>
</div>
);
}
/**
* Section header for organization
*/
function OrgSection({ org, children }: { org: GitHubOrgWithRepos; children: React.ReactNode }) {
const [isExpanded, setIsExpanded] = useState(true);
const noodlProjectCount = org.repos.filter((r) => r.isNoodlProject === true).length;
return (
<Box hasBottomSpacing={4}>
<button
onClick={() => setIsExpanded(!isExpanded)}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
padding: '8px 12px',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
color: 'var(--theme-color-fg-default)'
}}
>
<img src={org.avatar_url} alt={org.login} style={{ width: 24, height: 24, borderRadius: '4px' }} />
<span style={{ fontWeight: 600, flex: 1, textAlign: 'left' }}>{org.login}</span>
<span style={{ fontSize: '12px', color: 'var(--theme-color-fg-default-shy)' }}>
{noodlProjectCount} {noodlProjectCount === 1 ? 'project' : 'projects'}
</span>
<Icon icon={isExpanded ? IconName.CaretDown : IconName.CaretRight} />
</button>
{isExpanded && <div style={{ paddingLeft: '16px' }}>{children}</div>}
</Box>
);
}
/**
* Empty state when no Noodl projects found
*/
function EmptyState() {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '64px',
textAlign: 'center'
}}
>
<div style={{ fontSize: '48px' }}>
<Icon icon={IconName.FolderOpen} />
</div>
<h3 style={{ fontSize: '18px', fontWeight: 600, color: 'var(--theme-color-fg-default)', marginTop: '16px' }}>
No Noodl Projects Found
</h3>
<p
style={{
color: 'var(--theme-color-fg-default-shy)',
marginTop: '8px',
maxWidth: '400px',
lineHeight: 1.5
}}
>
No repositories with project.json were found in your GitHub account. Noodl projects contain a project.json file
in the root directory.
</p>
</div>
);
}
/**
* Connect GitHub prompt when not authenticated
*/
function ConnectGitHubPrompt({ onConnect }: { onConnect: () => void }) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '64px',
textAlign: 'center'
}}
>
<div style={{ fontSize: '48px' }}>
<Icon icon={IconName.CloudFunction} />
</div>
<h3 style={{ fontSize: '18px', fontWeight: 600, color: 'var(--theme-color-fg-default)', marginTop: '16px' }}>
Connect GitHub
</h3>
<p
style={{
color: 'var(--theme-color-fg-default-shy)',
marginTop: '8px',
maxWidth: '400px',
lineHeight: 1.5
}}
>
Connect your GitHub account to browse and clone your Noodl projects.
</p>
<Box hasTopSpacing={4}>
<PrimaryButton label="Connect GitHub" onClick={onConnect} />
</Box>
</div>
);
}
/**
* Loading spinner
*/
function LoadingState() {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '64px',
gap: '12px'
}}
>
<div
style={{
width: 24,
height: 24,
border: '2px solid var(--theme-color-border-default)',
borderTopColor: 'var(--theme-color-primary)',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}}
/>
<span style={{ color: 'var(--theme-color-fg-default-shy)' }}>Loading repositories...</span>
</div>
);
}
export interface GitHubReposProps {}
export function GitHubRepos({}: GitHubReposProps) {
const { githubIsAuthenticated, onGitHubConnect, githubRepos, onCloneRepo } = useLauncherContext();
const [searchTerm, setSearchTerm] = useState('');
const [cloningRepoId, setCloningRepoId] = useState<number | null>(null);
// Get repos from context
const { noodlProjects, allRepos, organizations, isLoading, error, refresh } = githubRepos || {
noodlProjects: [],
allRepos: [],
organizations: [],
personalRepos: [],
isLoading: false,
error: null,
refresh: async () => {}
};
// Count how many repos are still being checked
const checkingCount = allRepos.filter((r) => r.isNoodlProject === null).length;
const isChecking = checkingCount > 0;
// Only show Noodl projects
const reposToShow = noodlProjects;
// Filter by search term
const filteredRepos = useMemo(() => {
if (!searchTerm) return reposToShow;
const term = searchTerm.toLowerCase();
return reposToShow.filter(
(repo) =>
repo.name.toLowerCase().includes(term) ||
repo.full_name.toLowerCase().includes(term) ||
repo.description?.toLowerCase().includes(term)
);
}, [reposToShow, searchTerm]);
// Group by source (personal vs org)
const personalFilteredRepos = filteredRepos.filter((r) => r.source === 'personal');
const orgFilteredRepos = filteredRepos.filter((r) => r.source !== 'personal');
// Group org projects by org
const projectsByOrg = useMemo(() => {
const grouped = new Map<string, NoodlGitHubRepo[]>();
for (const repo of orgFilteredRepos) {
const existing = grouped.get(repo.source) || [];
grouped.set(repo.source, [...existing, repo]);
}
return grouped;
}, [orgFilteredRepos]);
const handleClone = async (repo: NoodlGitHubRepo) => {
if (!onCloneRepo) return;
setCloningRepoId(repo.id);
try {
await onCloneRepo(repo);
} finally {
setCloningRepoId(null);
}
};
// Not authenticated
if (!githubIsAuthenticated) {
return (
<LauncherPage title="GitHub">
<ConnectGitHubPrompt onConnect={() => onGitHubConnect?.()} />
</LauncherPage>
);
}
return (
<LauncherPage
title="GitHub Projects"
headerSlot={
<HStack hasSpacing>
<PrimaryButton
label="Refresh"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={refresh}
isDisabled={isLoading}
/>
</HStack>
}
>
{/* Search bar */}
<Box hasBottomSpacing={4}>
<TextInput
variant={TextInputVariant.InModal}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search Noodl projects..."
/>
</Box>
{/* Error state */}
{error && (
<Box hasBottomSpacing={4}>
<div
style={{
padding: '12px',
backgroundColor: 'var(--theme-color-danger-10)',
borderRadius: 'var(--radius-default)',
color: 'var(--theme-color-danger)'
}}
>
{error}
</div>
</Box>
)}
{/* Loading state */}
{isLoading && <LoadingState />}
{/* Checking progress indicator */}
{!isLoading && isChecking && (
<Box hasBottomSpacing={4}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
backgroundColor: 'var(--theme-color-bg-2)',
borderRadius: 'var(--radius-default)',
fontSize: '12px',
color: 'var(--theme-color-fg-default-shy)'
}}
>
<div
style={{
width: 12,
height: 12,
border: '2px solid var(--theme-color-border-default)',
borderTopColor: 'var(--theme-color-primary)',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}}
/>
Scanning {checkingCount} repositories for project.json...
</div>
</Box>
)}
{/* Content */}
{!isLoading && !isChecking && filteredRepos.length === 0 && <EmptyState />}
{!isLoading && filteredRepos.length > 0 && (
<VStack hasSpacing>
{/* Personal repos */}
{personalFilteredRepos.length > 0 && (
<Box hasBottomSpacing={4}>
<Label size={LabelSize.Default}>Personal Repositories ({personalFilteredRepos.length})</Label>
<div style={{ marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
{personalFilteredRepos.map((repo) => (
<GitHubRepoCard
key={repo.id}
repo={repo}
onClone={handleClone}
isCloning={cloningRepoId === repo.id}
/>
))}
</div>
</Box>
)}
{/* Organization repos */}
{Array.from(projectsByOrg.entries()).map(([orgLogin, repos]) => {
const org = organizations.find((o) => o.login === orgLogin);
if (!org) return null;
return (
<OrgSection key={orgLogin} org={org}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{repos.map((repo) => (
<GitHubRepoCard
key={repo.id}
repo={repo}
onClone={handleClone}
isCloning={cloningRepoId === repo.id}
/>
))}
</div>
</OrgSection>
);
})}
</VStack>
)}
</LauncherPage>
);
}

View File

@@ -6,7 +6,8 @@
*/
import { ipcRenderer, shell } from 'electron';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { clone } from '@noodl/git/src/core/clone';
import { filesystem } from '@noodl/platform';
import { CreateProjectModal } from '@noodl-core-ui/preview/launcher/Launcher/components/CreateProjectModal';
@@ -14,12 +15,18 @@ import {
CloudSyncType,
LauncherProjectData
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
import {
useGitHubRepos,
NoodlGitHubRepo,
GitHubClientInterface
} from '@noodl-core-ui/preview/launcher/Launcher/hooks/useGitHubRepos';
import { Launcher } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
import { useEventListener } from '../../hooks/useEventListener';
import { DialogLayerModel } from '../../models/DialogLayerModel';
import { detectRuntimeVersion } from '../../models/migration/ProjectScanner';
import { IRouteProps } from '../../pages/AppRoute';
import { GitHubOAuthService, GitHubClient } from '../../services/github';
import { ProjectOrganizationService } from '../../services/ProjectOrganizationService';
import { LocalProjectsModel, ProjectItemWithRuntime } from '../../utils/LocalProjectsModel';
import { tracker } from '../../utils/tracker';
@@ -56,6 +63,332 @@ export function ProjectsPage(props: ProjectsPageProps) {
// Create project modal state
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
// GitHub OAuth state
const [githubIsAuthenticated, setGithubIsAuthenticated] = useState(false);
const [githubIsConnecting, setGithubIsConnecting] = useState(false);
const [githubUser, setGithubUser] = useState<ReturnType<typeof GitHubOAuthService.instance.getCurrentUser>>(null);
const oauthService = GitHubOAuthService.instance;
// Initialize GitHub OAuth state on mount
useEffect(() => {
console.log('🔧 [ProjectsPage] Initializing GitHub OAuth...');
oauthService.initialize().then(() => {
const isAuth = oauthService.isAuthenticated();
const user = oauthService.getCurrentUser();
console.log('🔧 [ProjectsPage] GitHub auth state:', isAuth, user?.login);
setGithubIsAuthenticated(isAuth);
setGithubUser(user);
});
}, [oauthService]);
// Listen for GitHub auth state changes
useEventListener(oauthService, 'auth-state-changed', (event: { authenticated: boolean }) => {
console.log('🔔 [ProjectsPage] GitHub auth state changed:', event.authenticated);
setGithubIsAuthenticated(event.authenticated);
if (event.authenticated) {
setGithubUser(oauthService.getCurrentUser());
} else {
setGithubUser(null);
}
});
// Listen for OAuth success
useEventListener(oauthService, 'oauth-success', () => {
setGithubIsConnecting(false);
});
useEventListener(oauthService, 'oauth-error', () => {
setGithubIsConnecting(false);
ToastLayer.showError('GitHub authentication failed');
});
// GitHub OAuth handlers
const handleGitHubConnect = useCallback(async () => {
console.log('🔘 [ProjectsPage] handleGitHubConnect called');
setGithubIsConnecting(true);
try {
await oauthService.initiateOAuth();
console.log('✅ [ProjectsPage] OAuth initiated');
} catch (error) {
console.error('❌ [ProjectsPage] OAuth error:', error);
setGithubIsConnecting(false);
ToastLayer.showError('Failed to connect GitHub');
}
}, [oauthService]);
const handleGitHubDisconnect = useCallback(async () => {
console.log('🔘 [ProjectsPage] handleGitHubDisconnect called');
await oauthService.disconnect();
ToastLayer.showSuccess('GitHub account disconnected');
}, [oauthService]);
// Create GitHubClient adapter for useGitHubRepos hook
const githubClient = useMemo((): GitHubClientInterface | null => {
if (!githubIsAuthenticated) return null;
const client = GitHubClient.instance;
return {
listRepositories: async (options?: { per_page?: number; sort?: string }) => {
const result = await client.listRepositories(options as TSFixme);
return result;
},
listOrganizations: async () => {
const result = await client.listOrganizations();
return result;
},
listOrganizationRepositories: async (org, options) => {
const result = await client.listOrganizationRepositories(org, options);
return result;
},
isNoodlProject: async (owner, repo) => {
return client.isNoodlProject(owner, repo);
}
};
}, [githubIsAuthenticated]);
// Use the GitHub repos hook
const githubRepos = useGitHubRepos(githubClient, githubIsAuthenticated);
/**
* Handle cloning a GitHub repository
* Follows the same legacy detection flow as handleOpenProject
*/
const handleCloneRepo = useCallback(
async (repo: NoodlGitHubRepo) => {
console.log('🔵 [handleCloneRepo] Starting clone for:', repo.full_name);
// Ask user where to clone
try {
const targetDir = await filesystem.openDialog({
allowCreateDirectory: true
});
if (!targetDir) {
console.log('🔵 [handleCloneRepo] User cancelled');
return;
}
// Create path with repo name
const clonePath = filesystem.join(targetDir, repo.name);
// Check if directory already exists
if (await filesystem.exists(clonePath)) {
ToastLayer.showError(`A folder named "${repo.name}" already exists at that location`);
return;
}
const activityId = 'cloning-repo';
ToastLayer.showActivity(`Cloning ${repo.name}...`, activityId);
// Get clone URL (prefer HTTPS with token for authenticated access)
const token = await oauthService.getToken();
const cloneUrl = repo.html_url.replace('https://', `https://x-access-token:${token}@`) + '.git';
await clone(cloneUrl, clonePath, {
singleBranch: false,
defaultBranch: repo.default_branch
});
ToastLayer.hideActivity(activityId);
ToastLayer.showSuccess(`Cloned "${repo.name}" successfully!`);
tracker.track('GitHub Repository Cloned', {
repoName: repo.name,
isPrivate: repo.private
});
// Now detect runtime and follow the same flow as handleOpenProject
const runtimeActivityId = 'checking-compatibility';
ToastLayer.showActivity('Checking project compatibility...', runtimeActivityId);
try {
const runtimeInfo = await detectRuntimeVersion(clonePath);
ToastLayer.hideActivity(runtimeActivityId);
console.log('🔵 [handleCloneRepo] Runtime detected:', runtimeInfo);
// If legacy or unknown, show warning dialog
if (runtimeInfo.version === 'react17' || runtimeInfo.version === 'unknown') {
const projectName = repo.name;
// Show legacy project warning dialog
const userChoice = await new Promise<'migrate' | 'readonly' | 'cancel'>((resolve) => {
const confirmed = confirm(
`⚠️ Legacy Project Detected\n\n` +
`This project "${projectName}" was created with an earlier version of Noodl (React 17).\n\n` +
`OpenNoodl uses React 19, which requires migrating your project to ensure compatibility.\n\n` +
`What would you like to do?\n\n` +
`OK - Migrate Project (Recommended)\n` +
`Cancel - View options`
);
if (confirmed) {
resolve('migrate');
} else {
// Show second dialog for Read-Only or Cancel
const openReadOnly = confirm(
`Would you like to open this project in Read-Only mode?\n\n` +
`You can inspect the project safely without making changes.\n\n` +
`OK - Open Read-Only\n` +
`Cancel - Return to launcher`
);
if (openReadOnly) {
resolve('readonly');
} else {
resolve('cancel');
}
}
});
console.log('🔵 [handleCloneRepo] User choice:', userChoice);
if (userChoice === 'cancel') {
// Add to projects list but don't open
await LocalProjectsModel.instance.openProjectFromFolder(clonePath);
await LocalProjectsModel.instance.fetch();
LocalProjectsModel.instance.detectAllProjectRuntimes();
ToastLayer.showSuccess(`Project "${repo.name}" added to your projects.`);
return;
}
if (userChoice === 'migrate') {
// Launch migration wizard
tracker.track('Legacy Project Migration Started from Clone', { projectName });
DialogLayerModel.instance.showDialog(
(close) =>
React.createElement(MigrationWizard, {
sourcePath: clonePath,
projectName,
onComplete: async (targetPath: string) => {
close();
const migrateActivityId = 'opening-migrated';
ToastLayer.showActivity('Opening migrated project', migrateActivityId);
try {
const migratedProject = await LocalProjectsModel.instance.openProjectFromFolder(targetPath);
if (!migratedProject.name) {
migratedProject.name = projectName + ' (React 19)';
}
await LocalProjectsModel.instance.fetch();
await LocalProjectsModel.instance.detectProjectRuntime(targetPath);
LocalProjectsModel.instance.detectAllProjectRuntimes();
const projects = LocalProjectsModel.instance.getProjects();
const projectEntry = projects.find((p) => p.id === migratedProject.id);
if (projectEntry) {
const loaded = await LocalProjectsModel.instance.loadProject(projectEntry);
ToastLayer.hideActivity(migrateActivityId);
if (loaded) {
ToastLayer.showSuccess('Project migrated and opened successfully!');
props.route.router.route({ to: 'editor', project: loaded });
}
}
} catch (error) {
ToastLayer.hideActivity(migrateActivityId);
ToastLayer.showError('Could not open migrated project');
console.error(error);
}
},
onCancel: () => {
close();
}
}),
{
onClose: () => {
LocalProjectsModel.instance.fetch();
}
}
);
return;
}
// Read-only mode
tracker.track('Legacy Project Opened Read-Only from Clone', { projectName });
const readOnlyActivityId = 'opening-project-readonly';
ToastLayer.showActivity('Opening project in read-only mode', readOnlyActivityId);
const readOnlyProject = await LocalProjectsModel.instance.openProjectFromFolder(clonePath);
if (!readOnlyProject) {
ToastLayer.hideActivity(readOnlyActivityId);
ToastLayer.showError('Could not open project');
return;
}
if (!readOnlyProject.name) {
readOnlyProject.name = repo.name;
}
const readOnlyProjects = LocalProjectsModel.instance.getProjects();
const readOnlyProjectEntry = readOnlyProjects.find((p) => p.id === readOnlyProject.id);
if (!readOnlyProjectEntry) {
ToastLayer.hideActivity(readOnlyActivityId);
ToastLayer.showError('Could not find project in recent list');
return;
}
const loadedReadOnly = await LocalProjectsModel.instance.loadProject(readOnlyProjectEntry);
ToastLayer.hideActivity(readOnlyActivityId);
if (!loadedReadOnly) {
ToastLayer.showError('Could not load project');
return;
}
ToastLayer.showError('⚠️ READ-ONLY MODE - No changes will be saved to this legacy project');
props.route.router.route({ to: 'editor', project: loadedReadOnly, readOnly: true });
return;
}
} catch (error) {
ToastLayer.hideActivity(runtimeActivityId);
console.error('Failed to detect runtime:', error);
// Continue with normal flow if detection fails
}
// Modern project - add to list and ask to open
const project = await LocalProjectsModel.instance.openProjectFromFolder(clonePath);
if (project) {
if (!project.name) {
project.name = repo.name;
}
await LocalProjectsModel.instance.fetch();
LocalProjectsModel.instance.detectAllProjectRuntimes();
const shouldOpen = confirm(`Project "${repo.name}" cloned successfully!\n\nWould you like to open it now?`);
if (shouldOpen) {
const projects = LocalProjectsModel.instance.getProjects();
const projectEntry = projects.find((p) => p.id === project.id);
if (projectEntry) {
const loaded = await LocalProjectsModel.instance.loadProject(projectEntry);
if (loaded) {
props.route.router.route({ to: 'editor', project: loaded });
}
}
}
}
} catch (error) {
ToastLayer.hideActivity('cloning-repo');
console.error('Failed to clone repository:', error);
ToastLayer.showError(`Failed to clone repository: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
},
[oauthService, props.route]
);
// Initialize and fetch projects on mount
useEffect(() => {
// Switch main window size to editor size
@@ -590,11 +923,13 @@ export function ProjectsPage(props: ProjectsPageProps) {
onMigrateProject={handleMigrateProject}
onOpenReadOnly={handleOpenReadOnly}
projectOrganizationService={ProjectOrganizationService.instance}
githubUser={null}
githubIsAuthenticated={false}
githubIsConnecting={false}
onGitHubConnect={() => {}}
onGitHubDisconnect={() => {}}
githubUser={githubUser}
githubIsAuthenticated={githubIsAuthenticated}
githubIsConnecting={githubIsConnecting}
onGitHubConnect={handleGitHubConnect}
onGitHubDisconnect={handleGitHubDisconnect}
githubRepos={githubRepos}
onCloneRepo={handleCloneRepo}
/>
<CreateProjectModal

View File

@@ -1,48 +1,17 @@
/**
* GitHubOAuthService
*
* Manages GitHub OAuth authentication using PKCE flow.
* Provides token management and user information retrieval.
* Manages GitHub OAuth authentication via IPC with the main process.
* The main process handles the OAuth flow and protocol callbacks,
* this service coordinates with it and manages state in the renderer.
*
* @module noodl-editor/services
*/
import crypto from 'crypto';
import { shell } from 'electron';
import { shell, ipcRenderer } from 'electron';
import { EventDispatcher } from '../../../shared/utils/EventDispatcher';
/**
* IMPORTANT: GitHub App Setup Instructions
*
* This service uses PKCE (Proof Key for Code Exchange) combined with a client secret.
*
* To set up:
* 1. Go to https://github.com/settings/apps/new
* 2. Fill in:
* - GitHub App name: "OpenNoodl" (or your choice)
* - Homepage URL: https://github.com/The-Low-Code-Foundation/OpenNoodl
* - Callback URL: noodl://github-callback
* - Check "Request user authorization (OAuth) during installation"
* - Uncheck "Webhook > Active"
* - Permissions:
* * Repository permissions → Contents: Read and write
* * Account permissions → Email addresses: Read-only
* 3. Click "Create GitHub App"
* 4. Copy the Client ID
* 5. Generate a Client Secret and copy it
* 6. Update GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET below
*
* Security Note:
* While storing client secrets in desktop apps is not ideal (they can be extracted),
* this is GitHub's requirement for token exchange. The PKCE flow still adds security
* by preventing authorization code interception attacks.
*/
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Iv23lib1WdrimUdyvZui'; // Replace with your GitHub App Client ID
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || '9bd56694d6d300bf86b1999bab523b32654ec375'; // Replace with your GitHub App Client Secret
const GITHUB_REDIRECT_URI = 'noodl://github-callback';
const GITHUB_SCOPES = ['repo', 'read:org', 'read:user'];
export interface GitHubUser {
id: number;
login: string;
@@ -65,23 +34,41 @@ interface GitHubToken {
scope: string;
}
interface PKCEChallenge {
verifier: string;
challenge: string;
state: string;
interface OAuthCompleteResult {
token: GitHubToken;
user: GitHubUser;
installations: unknown[];
authMethod: string;
}
interface OAuthErrorResult {
error: string;
message: string;
}
/**
* Service for managing GitHub OAuth authentication
*
* This service coordinates with the main process which handles:
* - State generation and validation
* - Protocol callback handling (noodl://github-callback)
* - Token exchange with GitHub
*
* The renderer process handles:
* - Opening the auth URL in the browser
* - Storing tokens securely
* - Managing user state
*/
export class GitHubOAuthService extends EventDispatcher {
private static _instance: GitHubOAuthService;
private currentUser: GitHubUser | null = null;
private accessToken: string | null = null;
private pendingPKCE: PKCEChallenge | null = null;
private isAuthenticating: boolean = false;
private constructor() {
super();
console.log('🔧 [GitHubOAuthService] Constructor called - setting up IPC listeners');
this.setupIPCListeners();
}
static get instance(): GitHubOAuthService {
@@ -92,147 +79,119 @@ export class GitHubOAuthService extends EventDispatcher {
}
/**
* Generate PKCE challenge for secure OAuth flow
* Set up IPC listeners for OAuth callbacks from main process
*/
private generatePKCE(): PKCEChallenge {
// Generate code verifier (random string)
const verifier = crypto.randomBytes(32).toString('base64url');
private setupIPCListeners(): void {
console.log('🔌 [GitHubOAuthService] Setting up IPC listeners for github-oauth-complete and github-oauth-error');
// Generate code challenge (SHA256 hash of verifier)
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
// Generate state for CSRF protection
const state = crypto.randomBytes(16).toString('hex');
return { verifier, challenge, state };
}
/**
* Initiate OAuth flow by opening GitHub authorization in browser
*/
async initiateOAuth(): Promise<void> {
console.log('🔐 Initiating GitHub OAuth flow');
// Generate PKCE challenge
this.pendingPKCE = this.generatePKCE();
// Build authorization URL
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: GITHUB_REDIRECT_URI,
scope: GITHUB_SCOPES.join(' '),
state: this.pendingPKCE.state,
code_challenge: this.pendingPKCE.challenge,
code_challenge_method: 'S256'
// Listen for successful OAuth completion
ipcRenderer.on('github-oauth-complete', (_event, result: OAuthCompleteResult) => {
console.log('✅ [GitHubOAuthService] IPC RECEIVED: github-oauth-complete');
console.log('✅ [GitHubOAuthService] Result:', result);
this.handleOAuthComplete(result);
});
const authUrl = `https://github.com/login/oauth/authorize?${params.toString()}`;
// Listen for OAuth errors
ipcRenderer.on('github-oauth-error', (_event, error: OAuthErrorResult) => {
console.error('❌ [GitHubOAuthService] IPC RECEIVED: github-oauth-error');
console.error('❌ [GitHubOAuthService] Error:', error);
this.handleOAuthError(error);
});
console.log('🌐 Opening GitHub authorization URL:', authUrl);
// Open in system browser
await shell.openExternal(authUrl);
// Notify listeners that OAuth flow started
this.notifyListeners('oauth-started');
console.log('✅ [GitHubOAuthService] IPC listeners registered');
}
/**
* Handle OAuth callback with authorization code
* Handle successful OAuth completion from main process
*/
async handleCallback(code: string, state: string): Promise<void> {
console.log('🔄 Handling OAuth callback');
private async handleOAuthComplete(result: OAuthCompleteResult): Promise<void> {
try {
// Verify state to prevent CSRF
if (!this.pendingPKCE || state !== this.pendingPKCE.state) {
throw new Error('Invalid OAuth state - possible CSRF attack');
}
console.log('🔄 [GitHub OAuth] Processing OAuth result for user:', result.user.login);
// Exchange code for token
const token = await this.exchangeCodeForToken(code, this.pendingPKCE.verifier);
// Store token
this.accessToken = token.access_token;
// Clear pending PKCE
this.pendingPKCE = null;
// Fetch user information
await this.fetchCurrentUser();
// Store the token
this.accessToken = result.token.access_token;
this.currentUser = result.user;
// Persist token securely
await this.saveToken(token.access_token);
await this.saveToken(result.token.access_token);
console.log('✅ GitHub OAuth successful, user:', this.currentUser?.login);
console.log('✅ [GitHub OAuth] Authentication successful');
// Notify listeners
this.isAuthenticating = false;
this.notifyListeners('oauth-success', { user: this.currentUser });
this.notifyListeners('auth-state-changed', { authenticated: true });
} catch (error) {
console.error('❌ OAuth callback error:', error);
this.pendingPKCE = null;
this.notifyListeners('oauth-error', { error: error.message });
console.error('❌ [GitHub OAuth] Failed to process OAuth result:', error);
this.handleOAuthError({
error: 'processing_failed',
message: error instanceof Error ? error.message : 'Failed to process OAuth result'
});
}
}
/**
* Handle OAuth error from main process
*/
private handleOAuthError(error: OAuthErrorResult): void {
console.error('❌ [GitHub OAuth] OAuth error:', error.error, error.message);
this.isAuthenticating = false;
this.notifyListeners('oauth-error', { error: error.message });
}
/**
* Initiate OAuth flow by requesting auth URL from main process
* and opening it in the system browser
*/
async initiateOAuth(): Promise<void> {
if (this.isAuthenticating) {
console.warn('[GitHub OAuth] OAuth flow already in progress');
return;
}
console.log('🔐 [GitHub OAuth] Initiating OAuth flow');
this.isAuthenticating = true;
try {
// Request auth URL from main process
// Main process generates the state and stores it for validation
const result = await ipcRenderer.invoke('github-oauth-start');
if (!result.success) {
throw new Error(result.error || 'Failed to start OAuth flow');
}
console.log('🌐 [GitHub OAuth] Opening auth URL in browser');
// Open the auth URL in the system browser
await shell.openExternal(result.authUrl);
// Notify listeners that OAuth flow started
this.notifyListeners('oauth-started');
// The main process will handle the callback and send us the result
// via 'github-oauth-complete' or 'github-oauth-error' IPC events
} catch (error) {
console.error('❌ [GitHub OAuth] Failed to initiate OAuth:', error);
this.isAuthenticating = false;
this.notifyListeners('oauth-error', {
error: error instanceof Error ? error.message : 'Failed to start OAuth'
});
throw error;
}
}
/**
* Exchange authorization code for access token
* Cancel any pending OAuth flow
*/
private async exchangeCodeForToken(code: string, verifier: string): Promise<GitHubToken> {
console.log('🔄 Exchanging code for access token');
// Exchange authorization code for access token using PKCE + client secret
const response = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
code_verifier: verifier,
redirect_uri: GITHUB_REDIRECT_URI
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to exchange code for token: ${response.status} ${errorText}`);
async cancelOAuth(): Promise<void> {
if (this.isAuthenticating) {
console.log('🚫 [GitHub OAuth] Cancelling OAuth flow');
await ipcRenderer.invoke('github-oauth-stop');
this.isAuthenticating = false;
this.notifyListeners('oauth-cancelled');
}
const data = await response.json();
if (data.error) {
throw new Error(`GitHub OAuth error: ${data.error_description || data.error}`);
}
return data;
}
/**
* Fetch current user information from GitHub API
*/
private async fetchCurrentUser(): Promise<void> {
if (!this.accessToken) {
throw new Error('No access token available');
}
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
Accept: 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.status}`);
}
this.currentUser = await response.json();
}
/**
@@ -282,11 +241,18 @@ export class GitHubOAuthService extends EventDispatcher {
return this.accessToken !== null && this.currentUser !== null;
}
/**
* Check if OAuth flow is in progress
*/
isOAuthInProgress(): boolean {
return this.isAuthenticating;
}
/**
* Revoke token and disconnect
*/
async disconnect(): Promise<void> {
console.log('🔌 Disconnecting GitHub account');
console.log('🔌 [GitHub OAuth] Disconnecting GitHub account');
this.accessToken = null;
this.currentUser = null;
@@ -300,15 +266,15 @@ export class GitHubOAuthService extends EventDispatcher {
}
/**
* Save token securely using Electron's safeStorage
* Save token securely using Electron's safeStorage via IPC
*/
private async saveToken(token: string): Promise<void> {
try {
const { ipcRenderer } = window.require('electron');
await ipcRenderer.invoke('github-save-token', token);
console.log('💾 [GitHub OAuth] Token saved');
} catch (error) {
console.error('Failed to save token:', error);
// Fallback: keep in memory only
console.error('❌ [GitHub OAuth] Failed to save token:', error);
// Token is still in memory, just not persisted
}
}
@@ -317,32 +283,56 @@ export class GitHubOAuthService extends EventDispatcher {
*/
private async loadToken(): Promise<void> {
try {
const { ipcRenderer } = window.require('electron');
const token = await ipcRenderer.invoke('github-load-token');
if (token) {
console.log('🔑 [GitHub OAuth] Token loaded from storage, verifying...');
this.accessToken = token;
// Fetch user info to verify token is still valid
await this.fetchCurrentUser();
this.notifyListeners('auth-state-changed', { authenticated: true });
console.log('✅ [GitHub OAuth] Token verified, user:', this.currentUser?.login);
}
} catch (error) {
console.error('Failed to load token:', error);
console.error('❌ [GitHub OAuth] Failed to load/verify token:', error);
// Token may be invalid, clear it
this.accessToken = null;
this.currentUser = null;
await this.clearToken();
}
}
/**
* Fetch current user information from GitHub API
*/
private async fetchCurrentUser(): Promise<void> {
if (!this.accessToken) {
throw new Error('No access token available');
}
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
Accept: 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.status}`);
}
this.currentUser = await response.json();
}
/**
* Clear stored token
*/
private async clearToken(): Promise<void> {
try {
const { ipcRenderer } = window.require('electron');
await ipcRenderer.invoke('github-clear-token');
} catch (error) {
console.error('Failed to clear token:', error);
console.error('❌ [GitHub OAuth] Failed to clear token:', error);
}
}
@@ -350,7 +340,7 @@ export class GitHubOAuthService extends EventDispatcher {
* Initialize service and restore session if available
*/
async initialize(): Promise<void> {
console.log('🔧 Initializing GitHubOAuthService');
console.log('🔧 [GitHub OAuth] Initializing GitHubOAuthService');
await this.loadToken();
}
}

View File

@@ -15,6 +15,7 @@ import type {
GitHubIssue,
GitHubPullRequest,
GitHubRepository,
GitHubOrganization,
GitHubComment,
GitHubCommit,
GitHubLabel,
@@ -23,6 +24,7 @@ import type {
GitHubIssueFilters,
CreateIssueOptions,
UpdateIssueOptions,
CreateRepositoryOptions,
GitHubApiError
} from './GitHubTypes';
@@ -190,21 +192,22 @@ export class GitHubClient extends EventDispatcher {
/**
* Get data from cache if valid
* Returns undefined if not in cache, the cached value (which could be null) if present
*/
private getFromCache<T>(key: string, ttl: number = DEFAULT_CACHE_TTL): T | null {
private getFromCache<T>(key: string, ttl: number = DEFAULT_CACHE_TTL): T | undefined {
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
if (!entry) {
return null;
return undefined; // Not in cache
}
const age = Date.now() - entry.timestamp;
if (age > ttl) {
this.cache.delete(key);
return null;
return undefined; // Cache expired
}
return entry.data;
return entry.data; // Return cached value (could be null)
}
/**
@@ -302,17 +305,23 @@ export class GitHubClient extends EventDispatcher {
per_page?: number;
page?: number;
}): Promise<GitHubApiResponse<GitHubRepository[]>> {
console.log('🔍 [GitHubClient] listRepositories called with:', options);
const cacheKey = this.getCacheKey('listRepositories', options || {});
const cached = this.getFromCache<GitHubRepository[]>(cacheKey, 60000);
if (cached) {
console.log('🔍 [GitHubClient] Returning cached repos:', cached.length);
return { data: cached, rateLimit: this.rateLimit! };
}
try {
console.log('🔍 [GitHubClient] Calling octokit.repos.listForAuthenticatedUser...');
const octokit = await this.ensureAuthenticated();
const response = await octokit.repos.listForAuthenticatedUser(options);
console.log('🔍 [GitHubClient] Got repos from API:', response.data?.length || 0);
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
this.setCache(cacheKey, response.data);
@@ -321,6 +330,7 @@ export class GitHubClient extends EventDispatcher {
rateLimit: this.rateLimit!
};
} catch (error) {
console.error('❌ [GitHubClient] listRepositories error:', error);
this.handleApiError(error);
}
}
@@ -679,6 +689,222 @@ export class GitHubClient extends EventDispatcher {
}
}
// ==================== ORGANIZATION METHODS ====================
/**
* List organizations for the authenticated user
*/
async listOrganizations(): Promise<GitHubApiResponse<GitHubOrganization[]>> {
console.log('🔍 [GitHubClient] listOrganizations called');
const cacheKey = this.getCacheKey('listOrganizations', {});
const cached = this.getFromCache<GitHubOrganization[]>(cacheKey, 60000); // 1 minute cache
if (cached) {
console.log('🔍 [GitHubClient] Returning cached orgs:', cached.length);
return { data: cached, rateLimit: this.rateLimit! };
}
try {
console.log('🔍 [GitHubClient] Calling octokit.orgs.listForAuthenticatedUser...');
const octokit = await this.ensureAuthenticated();
const response = await octokit.orgs.listForAuthenticatedUser({
per_page: 100
});
console.log('🔍 [GitHubClient] Got orgs from API:', response.data?.length || 0);
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
this.setCache(cacheKey, response.data);
return {
data: response.data as unknown as GitHubOrganization[],
rateLimit: this.rateLimit!
};
} catch (error) {
console.error('❌ [GitHubClient] listOrganizations error:', error);
this.handleApiError(error);
}
}
/**
* List repositories for an organization
*/
async listOrganizationRepositories(
org: string,
options?: {
type?: 'all' | 'public' | 'private' | 'forks' | 'sources' | 'member';
sort?: 'created' | 'updated' | 'pushed' | 'full_name';
direction?: 'asc' | 'desc';
per_page?: number;
page?: number;
}
): Promise<GitHubApiResponse<GitHubRepository[]>> {
const cacheKey = this.getCacheKey('listOrganizationRepositories', { org, ...options });
const cached = this.getFromCache<GitHubRepository[]>(cacheKey, 60000); // 1 minute cache
if (cached) {
return { data: cached, rateLimit: this.rateLimit! };
}
try {
const octokit = await this.ensureAuthenticated();
const response = await octokit.repos.listForOrg({
org,
...options
});
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
this.setCache(cacheKey, response.data);
return {
data: response.data as unknown as GitHubRepository[],
rateLimit: this.rateLimit!
};
} catch (error) {
this.handleApiError(error);
}
}
/**
* Create a new repository
*
* @param options - Repository creation options
* @returns The created repository
*/
async createRepository(options: CreateRepositoryOptions): Promise<GitHubApiResponse<GitHubRepository>> {
console.log('🔧 [GitHubClient] createRepository called with:', options);
try {
const octokit = await this.ensureAuthenticated();
let response;
if (options.org) {
// Create repository in organization
console.log('🔧 [GitHubClient] Creating repo in org:', options.org);
response = await octokit.repos.createInOrg({
org: options.org,
name: options.name,
description: options.description,
private: options.private ?? true,
auto_init: options.auto_init ?? false,
gitignore_template: options.gitignore_template,
license_template: options.license_template
});
} else {
// Create repository in user account
console.log('🔧 [GitHubClient] Creating repo in user account');
response = await octokit.repos.createForAuthenticatedUser({
name: options.name,
description: options.description,
private: options.private ?? true,
auto_init: options.auto_init ?? false,
gitignore_template: options.gitignore_template,
license_template: options.license_template
});
}
console.log('✅ [GitHubClient] Repository created:', response.data.full_name);
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
// Invalidate repo list caches
this.clearCacheForPattern('listRepositories');
this.clearCacheForPattern('listOrganizationRepositories');
return {
data: response.data as unknown as GitHubRepository,
rateLimit: this.rateLimit!
};
} catch (error) {
console.error('❌ [GitHubClient] createRepository error:', error);
this.handleApiError(error);
}
}
// ==================== FILE CONTENT METHODS ====================
/**
* Get file content from a repository
* @returns File content as string, or null if file doesn't exist
*/
async getFileContent(owner: string, repo: string, path: string): Promise<string | null> {
const cacheKey = this.getCacheKey('getFileContent', { owner, repo, path });
const cached = this.getFromCache<string | null>(cacheKey, 60000); // 1 minute cache
if (cached !== undefined) {
console.log('📦 [getFileContent] Cache hit for', `${owner}/${repo}/${path}`);
return cached;
}
console.log('🔍 [getFileContent] Fetching', `${owner}/${repo}/${path}`);
try {
const octokit = await this.ensureAuthenticated();
const response = await octokit.repos.getContent({
owner,
repo,
path
});
const responseType = !Array.isArray(response.data) && 'type' in response.data ? response.data.type : 'unknown';
console.log('✅ [getFileContent] Got response for', `${owner}/${repo}/${path}`, responseType);
this.updateRateLimitFromHeaders(response.headers as Record<string, string>);
// Handle file content (not directory)
if (!Array.isArray(response.data) && 'content' in response.data && response.data.type === 'file') {
const content = Buffer.from(response.data.content, 'base64').toString('utf-8');
this.setCache(cacheKey, content);
console.log('✅ [getFileContent] Found file', `${owner}/${repo}/${path}`, content.substring(0, 50) + '...');
return content;
}
// It's a directory or something else
console.log('⚠️ [getFileContent] Not a file:', `${owner}/${repo}/${path}`, responseType);
this.setCache(cacheKey, null);
return null;
} catch (error) {
const errorStatus =
error && typeof error === 'object' && 'status' in error ? (error as { status: number }).status : 'unknown';
console.log('❌ [getFileContent] Error for', `${owner}/${repo}/${path}`, 'status:', errorStatus);
// 404 means file doesn't exist - cache that result
if (errorStatus === 404) {
this.setCache(cacheKey, null);
return null;
}
// Log the full error for non-404 errors
console.error('❌ [getFileContent] Full error:', error);
// For other errors, don't cache and rethrow
throw error;
}
}
/**
* Check if a repository is a Noodl project
* Checks for project.json at the root of the repo
*/
async isNoodlProject(owner: string, repo: string): Promise<boolean> {
console.log('🔍 [GitHubClient] isNoodlProject checking:', `${owner}/${repo}`);
try {
const projectJson = await this.getFileContent(owner, repo, 'project.json');
if (projectJson !== null) {
console.log('✅ [GitHubClient] Found project.json in', `${owner}/${repo}`);
return true;
}
console.log('❌ [GitHubClient] No project.json found in', `${owner}/${repo}`);
return false;
} catch (error) {
console.error('❌ [GitHubClient] Error checking isNoodlProject for', `${owner}/${repo}`, error);
return false;
}
}
// ==================== UTILITY METHODS ====================
/**

View File

@@ -267,6 +267,26 @@ export interface UpdateIssueOptions {
milestone?: number | null;
}
/**
* Create repository options
*/
export interface CreateRepositoryOptions {
/** Repository name */
name: string;
/** Repository description */
description?: string;
/** Whether the repo is private (default: true) */
private?: boolean;
/** Organization name (if creating in an org, otherwise creates in user account) */
org?: string;
/** Initialize with README */
auto_init?: boolean;
/** .gitignore template */
gitignore_template?: string;
/** License template */
license_template?: string;
}
/**
* Error response from GitHub API
*/

View File

@@ -13,7 +13,7 @@ import Model from '../../../shared/model';
import { detectRuntimeVersion } from '../models/migration/ProjectScanner';
import { RuntimeVersionInfo } from '../models/migration/types';
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
import { GitHubAuth } from '../services/github';
import { GitHubOAuthService } from '../services/GitHubOAuthService';
import FileSystem from './filesystem';
import { tracker } from './tracker';
import { guid } from './utils';
@@ -336,14 +336,19 @@ export class LocalProjectsModel extends Model {
setCurrentGlobalGitAuth(projectId: string) {
const func = async (endpoint: string) => {
if (endpoint.includes('github.com')) {
// Priority 1: Check for global OAuth token
const authState = GitHubAuth.getAuthState();
if (authState.isAuthenticated && authState.token) {
console.log('[Git Auth] Using GitHub OAuth token for:', endpoint);
return {
username: authState.username || 'oauth',
password: authState.token.access_token // Extract actual access token string
};
// Priority 1: Check for global OAuth token from GitHubOAuthService
try {
const token = await GitHubOAuthService.instance.getToken();
const user = GitHubOAuthService.instance.getCurrentUser();
if (token) {
console.log('[Git Auth] Using GitHub OAuth token for:', endpoint, 'user:', user?.login);
return {
username: user?.login || 'oauth',
password: token
};
}
} catch (err) {
console.warn('[Git Auth] Failed to get OAuth token:', err);
}
// Priority 2: Fall back to project-specific PAT

View File

@@ -5,11 +5,14 @@
* with filtering, search, and detail views.
*/
import React, { useState } from 'react';
import { useEventListener } from '@noodl-hooks/useEventListener';
import React, { useState, useEffect } from 'react';
import { GitHubClient, GitHubOAuthService } from '../../../services/github';
import { ConnectToGitHubView } from './components/ConnectToGitHub';
import { IssuesList } from './components/IssuesTab/IssuesList';
import { PRsList } from './components/PullRequestsTab/PRsList';
import { SyncToolbar } from './components/SyncToolbar';
import styles from './GitHubPanel.module.scss';
import { useGitHubRepository } from './hooks/useGitHubRepository';
import { useIssues } from './hooks/useIssues';
@@ -19,11 +22,37 @@ type TabType = 'issues' | 'pullRequests';
export function GitHubPanel() {
const [activeTab, setActiveTab] = useState<TabType>('issues');
const [isConnected, setIsConnected] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const client = GitHubClient.instance;
const { owner, repo, isGitHub, isReady } = useGitHubRepository();
const { owner, repo, isGitHub, isReady, gitState, remoteUrl, provider, refetch } = useGitHubRepository();
// Check if GitHub is connected
const isConnected = client.isReady();
// Initialize GitHubOAuthService on mount
useEffect(() => {
console.log('🔧 [GitHubPanel] useEffect running - initializing OAuth service');
const initAuth = async () => {
try {
console.log('🔧 [GitHubPanel] Calling GitHubOAuthService.instance.initialize()...');
await GitHubOAuthService.instance.initialize();
const ready = client.isReady();
console.log('🔧 [GitHubPanel] After initialize - client.isReady():', ready);
setIsConnected(ready);
} catch (error) {
console.error('[GitHubPanel] Failed to initialize OAuth service:', error);
} finally {
setIsInitialized(true);
console.log('🔧 [GitHubPanel] Initialization complete');
}
};
initAuth();
}, [client]);
// Listen for auth state changes
console.log('🎧 [GitHubPanel] Setting up useEventListener for auth-state-changed');
useEventListener(GitHubOAuthService.instance, 'auth-state-changed', (event: { authenticated: boolean }) => {
console.log('🔔 [GitHubPanel] AUTH STATE CHANGED EVENT RECEIVED:', event.authenticated);
setIsConnected(event.authenticated);
});
const handleConnectGitHub = async () => {
try {
@@ -33,6 +62,38 @@ export function GitHubPanel() {
}
};
const handleConnected = () => {
// Refetch git state after connecting
refetch();
};
// Show loading while initializing
if (!isInitialized) {
return (
<div className={styles.GitHubPanel}>
<div className={styles.EmptyState}>
<div className={styles.EmptyStateIcon}></div>
<h3>Initializing</h3>
<p>Checking GitHub connection...</p>
</div>
</div>
);
}
// Loading state while determining git state
if (gitState === 'loading') {
return (
<div className={styles.GitHubPanel}>
<div className={styles.EmptyState}>
<div className={styles.EmptyStateIcon}></div>
<h3>Loading</h3>
<p>Checking repository status...</p>
</div>
</div>
);
}
// Not connected to GitHub account
if (!isConnected) {
return (
<div className={styles.GitHubPanel}>
@@ -48,6 +109,20 @@ export function GitHubPanel() {
);
}
// Project not connected to GitHub - show connect options
if (gitState === 'no-git' || gitState === 'git-no-remote' || gitState === 'remote-not-github') {
return (
<div className={styles.GitHubPanel}>
<ConnectToGitHubView
gitState={gitState}
remoteUrl={remoteUrl}
provider={provider}
onConnected={handleConnected}
/>
</div>
);
}
if (!isGitHub) {
return (
<div className={styles.GitHubPanel}>
@@ -74,6 +149,8 @@ export function GitHubPanel() {
return (
<div className={styles.GitHubPanel}>
<SyncToolbar owner={owner} repo={repo} />
<div className={styles.Header}>
<div className={styles.Tabs}>
<button

View File

@@ -0,0 +1,459 @@
/**
* ConnectToGitHub styles
* Uses design tokens for theming
*/
// Main view
.ConnectView {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
color: var(--theme-color-fg-default-shy);
min-height: 300px;
h3 {
margin: 16px 0 8px;
color: var(--theme-color-fg-default);
font-size: 18px;
font-weight: 600;
}
p {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
max-width: 400px;
}
}
.Icon {
color: var(--theme-color-fg-default-shy);
opacity: 0.7;
margin-bottom: 8px;
svg {
display: block;
}
}
.Actions {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 20px;
width: 100%;
max-width: 280px;
}
.PrimaryButton {
padding: 12px 24px;
background-color: var(--theme-color-primary);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
&:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
&:active:not(:disabled) {
opacity: 0.8;
transform: translateY(0);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.SecondaryButton {
padding: 12px 24px;
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background-color: var(--theme-color-bg-4);
border-color: var(--theme-color-border-strong);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.RemoteUrl {
display: inline-block;
padding: 4px 8px;
margin-top: 8px;
background-color: var(--theme-color-bg-3);
border-radius: 4px;
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
.HintText {
font-size: 12px;
color: var(--theme-color-fg-default-shy);
opacity: 0.8;
}
.ErrorMessage {
padding: 12px 16px;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 6px;
color: var(--theme-color-danger);
font-size: 13px;
margin-top: 16px;
width: 100%;
max-width: 400px;
text-align: left;
}
// Modal styles
.ModalBackdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.Modal {
background-color: var(--theme-color-bg-2);
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 420px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ModalLarge {
max-width: 560px;
}
.ModalHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--theme-color-border-default);
h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
}
.CloseButton {
background: none;
border: none;
font-size: 24px;
color: var(--theme-color-fg-default-shy);
cursor: pointer;
padding: 0;
line-height: 1;
transition: color 0.2s ease;
&:hover:not(:disabled) {
color: var(--theme-color-fg-default);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.ModalBody {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.ModalFooter {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid var(--theme-color-border-default);
background-color: var(--theme-color-bg-2);
.PrimaryButton,
.SecondaryButton {
width: auto;
padding: 10px 20px;
}
}
// Form styles
.FormGroup {
margin-bottom: 20px;
label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--theme-color-fg-default);
}
}
.Input,
.Select {
width: 100%;
padding: 10px 12px;
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
color: var(--theme-color-fg-default);
font-size: 14px;
transition: border-color 0.2s ease;
&::placeholder {
color: var(--theme-color-fg-default-shy);
}
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.Select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M3 4.5L6 7.5L9 4.5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.RadioGroup {
display: flex;
gap: 16px;
}
.RadioLabel {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
color: var(--theme-color-fg-default);
input[type='radio'] {
appearance: none;
width: 18px;
height: 18px;
border: 2px solid var(--theme-color-border-strong);
border-radius: 50%;
cursor: pointer;
position: relative;
&:checked {
border-color: var(--theme-color-primary);
&::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 8px;
height: 8px;
background-color: var(--theme-color-primary);
border-radius: 50%;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.RadioIcon {
font-size: 14px;
}
// Repository list styles
.SearchContainer {
margin-bottom: 16px;
}
.SearchInput {
width: 100%;
padding: 10px 12px;
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
color: var(--theme-color-fg-default);
font-size: 14px;
&::placeholder {
color: var(--theme-color-fg-default-shy);
}
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
}
.RepoList {
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--theme-color-border-default);
border-radius: 8px;
}
.RepoGroup {
&:not(:last-child) {
border-bottom: 1px solid var(--theme-color-border-default);
}
}
.RepoGroupHeader {
padding: 10px 14px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--theme-color-fg-default-shy);
background-color: var(--theme-color-bg-3);
position: sticky;
top: 0;
}
.RepoItem {
padding: 12px 14px;
cursor: pointer;
transition: background-color 0.15s ease;
border-bottom: 1px solid var(--theme-color-border-default);
&:last-child {
border-bottom: none;
}
&:hover {
background-color: var(--theme-color-bg-3);
}
&.RepoItemSelected {
background-color: rgba(var(--theme-color-primary-rgb, 59, 130, 246), 0.1);
border-left: 3px solid var(--theme-color-primary);
padding-left: 11px;
}
}
.RepoInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.RepoName {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: var(--theme-color-fg-default);
}
.PrivateBadge {
padding: 2px 6px;
background-color: var(--theme-color-bg-4);
border-radius: 4px;
font-size: 11px;
font-weight: 400;
color: var(--theme-color-fg-default-shy);
}
.RepoDescription {
font-size: 13px;
color: var(--theme-color-fg-default-shy);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.RepoMeta {
font-size: 12px;
color: var(--theme-color-fg-default-shy);
opacity: 0.8;
}
// Loading state
.LoadingState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: var(--theme-color-fg-default-shy);
p {
margin-top: 16px;
font-size: 13px;
}
}
.Spinner {
width: 32px;
height: 32px;
border: 3px solid var(--theme-color-bg-4);
border-top-color: var(--theme-color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// Empty state
.EmptyState {
padding: 32px 24px;
text-align: center;
color: var(--theme-color-fg-default-shy);
p {
margin: 0;
font-size: 13px;
}
}

View File

@@ -0,0 +1,299 @@
/**
* ConnectToGitHubView
*
* Displays appropriate UI based on project's git state:
* - no-git: Offer to initialize git and create/connect repo
* - git-no-remote: Offer to create or connect to existing repo
* - remote-not-github: Show info that it's not a GitHub repo
*/
import React, { useState, useCallback } from 'react';
import { Git } from '@noodl/git';
import { ProjectModel } from '@noodl-models/projectmodel';
import { LocalProjectsModel } from '@noodl-utils/LocalProjectsModel';
import { mergeProject } from '@noodl-utils/projectmerger';
import { GitHubClient, GitHubOAuthService } from '../../../../../services/github';
import type { ProjectGitState } from '../../hooks/useGitHubRepository';
import styles from './ConnectToGitHub.module.scss';
import { CreateRepoModal } from './CreateRepoModal';
import { SelectRepoModal } from './SelectRepoModal';
interface ConnectToGitHubViewProps {
gitState: ProjectGitState;
remoteUrl?: string | null;
provider?: string | null;
onConnected: () => void;
}
export function ConnectToGitHubView({ gitState, remoteUrl, provider, onConnected }: ConnectToGitHubViewProps) {
const [showCreateModal, setShowCreateModal] = useState(false);
const [showSelectModal, setShowSelectModal] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const isGitHubConnected = GitHubOAuthService.instance.isAuthenticated();
const handleConnectGitHub = async () => {
try {
await GitHubOAuthService.instance.initiateOAuth();
} catch (err) {
console.error('Failed to initiate GitHub OAuth:', err);
setError('Failed to connect to GitHub. Please try again.');
}
};
const handleCreateRepo = useCallback(
async (options: { name: string; description?: string; private?: boolean; org?: string }) => {
setIsConnecting(true);
setError(null);
try {
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
if (!projectDirectory) {
throw new Error('No project directory found');
}
console.log('🔧 [ConnectToGitHub] Creating repository:', options);
// 1. Create repo on GitHub
const client = GitHubClient.instance;
const result = await client.createRepository({
name: options.name,
description: options.description,
private: options.private ?? true,
org: options.org
});
const repoUrl = result.data.html_url + '.git';
console.log('✅ [ConnectToGitHub] Repository created:', repoUrl);
// 2. Set up git auth before any git operations
const projectId = ProjectModel.instance?.id || 'temp';
console.log('🔧 [ConnectToGitHub] Setting up git auth for project:', projectId);
LocalProjectsModel.instance.setCurrentGlobalGitAuth(projectId);
// 3. Initialize git if needed
const git = new Git(mergeProject);
if (gitState === 'no-git') {
console.log('🔧 [ConnectToGitHub] Initializing git repository...');
await git.initNewRepo(projectDirectory);
} else {
await git.openRepository(projectDirectory);
}
// 3. Add remote
console.log('🔧 [ConnectToGitHub] Adding remote origin:', repoUrl);
await git.setRemoteURL(repoUrl);
// 4. Make initial commit if there are changes
const status = await git.status();
if (status.length > 0 || gitState === 'no-git') {
console.log('🔧 [ConnectToGitHub] Creating initial commit...');
await git.commit('Initial commit');
}
// 5. Push to remote
console.log('🔧 [ConnectToGitHub] Pushing to remote...');
await git.push();
console.log('✅ [ConnectToGitHub] Successfully connected to GitHub!');
// Notify parent
setShowCreateModal(false);
onConnected();
} catch (err) {
console.error('❌ [ConnectToGitHub] Error:', err);
setError(err instanceof Error ? err.message : 'Failed to create repository');
} finally {
setIsConnecting(false);
}
},
[gitState, onConnected]
);
const handleSelectRepo = useCallback(
async (repoUrl: string) => {
setIsConnecting(true);
setError(null);
try {
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
if (!projectDirectory) {
throw new Error('No project directory found');
}
console.log('🔧 [ConnectToGitHub] Connecting to existing repo:', repoUrl);
// Set up git auth before any git operations
const projectId = ProjectModel.instance?.id || 'temp';
console.log('🔧 [ConnectToGitHub] Setting up git auth for project:', projectId);
LocalProjectsModel.instance.setCurrentGlobalGitAuth(projectId);
// Initialize git if needed
const git = new Git(mergeProject);
if (gitState === 'no-git') {
console.log('🔧 [ConnectToGitHub] Initializing git repository...');
await git.initNewRepo(projectDirectory);
} else {
await git.openRepository(projectDirectory);
}
// Add remote
console.log('🔧 [ConnectToGitHub] Setting remote URL:', repoUrl);
await git.setRemoteURL(repoUrl);
// Fetch from remote to see if there are existing commits
try {
console.log('🔧 [ConnectToGitHub] Fetching from remote...');
await git.fetch({ onProgress: () => {} });
// Check if remote has commits
const hasRemote = await git.hasRemoteCommits();
if (hasRemote) {
console.log('🔧 [ConnectToGitHub] Remote has commits, attempting to merge...');
// Pull changes
await git.mergeToCurrentBranch('origin/main', false);
}
} catch (fetchErr) {
// Remote might be empty, that's okay
console.log('⚠️ [ConnectToGitHub] Fetch warning (might be empty repo):', fetchErr);
}
console.log('✅ [ConnectToGitHub] Successfully connected to GitHub!');
setShowSelectModal(false);
onConnected();
} catch (err) {
console.error('❌ [ConnectToGitHub] Error:', err);
setError(err instanceof Error ? err.message : 'Failed to connect to repository');
} finally {
setIsConnecting(false);
}
},
[gitState, onConnected]
);
// If not connected to GitHub, show connect button
if (!isGitHubConnected) {
return (
<div className={styles.ConnectView}>
<div className={styles.Icon}>
<GitHubIcon />
</div>
<h3>Connect to GitHub</h3>
<p>Connect your GitHub account to create or link repositories.</p>
<button className={styles.PrimaryButton} onClick={handleConnectGitHub}>
Connect GitHub Account
</button>
</div>
);
}
// Show state-specific UI
return (
<div className={styles.ConnectView}>
{gitState === 'no-git' && (
<>
<div className={styles.Icon}>
<FolderIcon />
</div>
<h3>Initialize Git Repository</h3>
<p>This project is not under version control. Initialize git and connect to GitHub.</p>
</>
)}
{gitState === 'git-no-remote' && (
<>
<div className={styles.Icon}>
<GitIcon />
</div>
<h3>Connect to GitHub</h3>
<p>This project has git initialized but no remote. Connect it to a GitHub repository.</p>
</>
)}
{gitState === 'remote-not-github' && (
<>
<div className={styles.Icon}>
<CloudIcon />
</div>
<h3>Not a GitHub Repository</h3>
<p>
This project is connected to a different git provider:
<br />
<code className={styles.RemoteUrl}>{remoteUrl || provider}</code>
</p>
<p className={styles.HintText}>
To use GitHub features, you will need to change the remote or create a new GitHub repository.
</p>
</>
)}
{error && <div className={styles.ErrorMessage}>{error}</div>}
{gitState !== 'remote-not-github' && (
<div className={styles.Actions}>
<button className={styles.PrimaryButton} onClick={() => setShowCreateModal(true)} disabled={isConnecting}>
Create New Repository
</button>
<button className={styles.SecondaryButton} onClick={() => setShowSelectModal(true)} disabled={isConnecting}>
Connect Existing Repository
</button>
</div>
)}
{showCreateModal && (
<CreateRepoModal
onClose={() => setShowCreateModal(false)}
onCreate={handleCreateRepo}
isCreating={isConnecting}
/>
)}
{showSelectModal && (
<SelectRepoModal
onClose={() => setShowSelectModal(false)}
onSelect={handleSelectRepo}
isConnecting={isConnecting}
/>
)}
</div>
);
}
// Simple icon components
function GitHubIcon() {
return (
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}
function FolderIcon() {
return (
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
);
}
function GitIcon() {
return (
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.546 10.93L13.067.452c-.604-.603-1.582-.603-2.188 0L8.708 2.627l2.76 2.76c.645-.215 1.379-.07 1.889.441.516.515.658 1.258.438 1.9l2.658 2.66c.645-.223 1.387-.078 1.9.435.721.72.721 1.884 0 2.604-.719.719-1.881.719-2.6 0-.539-.541-.674-1.337-.404-1.996L12.86 8.955v6.525c.176.086.342.203.488.348.713.721.713 1.883 0 2.6-.719.721-1.889.721-2.609 0-.719-.719-.719-1.879 0-2.598.182-.18.387-.316.605-.406V8.835c-.217-.091-.424-.222-.6-.401-.545-.545-.676-1.342-.396-2.009L7.636 3.7.45 10.881c-.6.605-.6 1.584 0 2.189l10.48 10.477c.604.604 1.582.604 2.186 0l10.43-10.43c.605-.603.605-1.582 0-2.187" />
</svg>
);
}
function CloudIcon() {
return (
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" />
</svg>
);
}

View File

@@ -0,0 +1,174 @@
/**
* CreateRepoModal
*
* Modal for creating a new GitHub repository
*/
import React, { useState, useEffect } from 'react';
import { GitHubClient } from '../../../../../services/github';
import type { GitHubOrganization } from '../../../../../services/github/GitHubTypes';
import styles from './ConnectToGitHub.module.scss';
interface CreateRepoModalProps {
onClose: () => void;
onCreate: (options: { name: string; description?: string; private?: boolean; org?: string }) => void;
isCreating: boolean;
}
export function CreateRepoModal({ onClose, onCreate, isCreating }: CreateRepoModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isPrivate, setIsPrivate] = useState(true);
const [selectedOrg, setSelectedOrg] = useState<string>('');
const [orgs, setOrgs] = useState<GitHubOrganization[]>([]);
const [loadingOrgs, setLoadingOrgs] = useState(true);
const [error, setError] = useState<string | null>(null);
// Load organizations
useEffect(() => {
async function loadOrgs() {
try {
const client = GitHubClient.instance;
const result = await client.listOrganizations();
setOrgs(result.data);
} catch (err) {
console.error('Failed to load organizations:', err);
} finally {
setLoadingOrgs(false);
}
}
loadOrgs();
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setError('Repository name is required');
return;
}
// Validate repo name (GitHub rules)
const nameRegex = /^[a-zA-Z0-9._-]+$/;
if (!nameRegex.test(name)) {
setError('Repository name can only contain letters, numbers, hyphens, underscores, and dots');
return;
}
setError(null);
onCreate({
name: name.trim(),
description: description.trim() || undefined,
private: isPrivate,
org: selectedOrg || undefined
});
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && !isCreating) {
onClose();
}
};
return (
<div className={styles.ModalBackdrop} onClick={handleBackdropClick}>
<div className={styles.Modal}>
<div className={styles.ModalHeader}>
<h2>Create New Repository</h2>
<button className={styles.CloseButton} onClick={onClose} disabled={isCreating}>
&times;
</button>
</div>
<form onSubmit={handleSubmit}>
<div className={styles.ModalBody}>
<div className={styles.FormGroup}>
<label htmlFor="owner">Owner</label>
<select
id="owner"
value={selectedOrg}
onChange={(e) => setSelectedOrg(e.target.value)}
disabled={loadingOrgs || isCreating}
className={styles.Select}
>
<option value="">Personal Account</option>
{orgs.map((org) => (
<option key={org.id} value={org.login}>
{org.login}
</option>
))}
</select>
</div>
<div className={styles.FormGroup}>
<label htmlFor="name">Repository name *</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my-noodl-project"
disabled={isCreating}
className={styles.Input}
autoFocus
/>
</div>
<div className={styles.FormGroup}>
<label htmlFor="description">Description</label>
<input
id="description"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="A brief description of your project"
disabled={isCreating}
className={styles.Input}
/>
</div>
<div className={styles.FormGroup}>
<label>Visibility</label>
<div className={styles.RadioGroup}>
<label className={styles.RadioLabel}>
<input
type="radio"
name="visibility"
checked={isPrivate}
onChange={() => setIsPrivate(true)}
disabled={isCreating}
/>
<span className={styles.RadioIcon}>🔒</span>
<span>Private</span>
</label>
<label className={styles.RadioLabel}>
<input
type="radio"
name="visibility"
checked={!isPrivate}
onChange={() => setIsPrivate(false)}
disabled={isCreating}
/>
<span className={styles.RadioIcon}>🌐</span>
<span>Public</span>
</label>
</div>
</div>
{error && <div className={styles.ErrorMessage}>{error}</div>}
</div>
<div className={styles.ModalFooter}>
<button type="button" className={styles.SecondaryButton} onClick={onClose} disabled={isCreating}>
Cancel
</button>
<button type="submit" className={styles.PrimaryButton} disabled={isCreating || !name.trim()}>
{isCreating ? 'Creating...' : 'Create Repository'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,226 @@
/**
* SelectRepoModal
*
* Modal for selecting an existing GitHub repository to connect
*/
import React, { useState, useEffect, useMemo } from 'react';
import { GitHubClient } from '../../../../../services/github';
import type { GitHubRepository, GitHubOrganization } from '../../../../../services/github/GitHubTypes';
import styles from './ConnectToGitHub.module.scss';
interface SelectRepoModalProps {
onClose: () => void;
onSelect: (repoUrl: string) => void;
isConnecting: boolean;
}
interface RepoGroup {
name: string;
repos: GitHubRepository[];
}
export function SelectRepoModal({ onClose, onSelect, isConnecting }: SelectRepoModalProps) {
const [repos, setRepos] = useState<GitHubRepository[]>([]);
const [orgs, setOrgs] = useState<GitHubOrganization[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedRepo, setSelectedRepo] = useState<GitHubRepository | null>(null);
// Load repositories and organizations
useEffect(() => {
async function loadData() {
setLoading(true);
setError(null);
try {
const client = GitHubClient.instance;
// Load in parallel
const [reposResult, orgsResult] = await Promise.all([
client.listRepositories({ per_page: 100, sort: 'updated' }),
client.listOrganizations()
]);
setRepos(reposResult.data);
setOrgs(orgsResult.data);
// Also load org repos
const orgRepos = await Promise.all(
orgsResult.data.map((org) =>
client.listOrganizationRepositories(org.login, { per_page: 100, sort: 'updated' })
)
);
// Combine all repos
const allRepos = [...reposResult.data];
orgRepos.forEach((result) => {
result.data.forEach((repo) => {
if (!allRepos.find((r) => r.id === repo.id)) {
allRepos.push(repo);
}
});
});
setRepos(allRepos);
} catch (err) {
console.error('Failed to load repositories:', err);
setError('Failed to load repositories. Please try again.');
} finally {
setLoading(false);
}
}
loadData();
}, []);
// Group and filter repos
const groupedRepos = useMemo((): RepoGroup[] => {
// Filter by search query
const filtered = searchQuery
? repos.filter(
(repo) =>
repo.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
repo.full_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
repo.description?.toLowerCase().includes(searchQuery.toLowerCase())
)
: repos;
// Group by owner
const groups: Record<string, GitHubRepository[]> = {};
filtered.forEach((repo) => {
const ownerLogin = repo.owner.login;
if (!groups[ownerLogin]) {
groups[ownerLogin] = [];
}
groups[ownerLogin].push(repo);
});
// Sort groups: personal first, then orgs alphabetically
const sortedGroups: RepoGroup[] = [];
const personalRepos = Object.entries(groups).find(([name]) => !orgs.find((org) => org.login === name));
if (personalRepos) {
sortedGroups.push({ name: 'Personal', repos: personalRepos[1] });
}
Object.entries(groups)
.filter(([name]) => orgs.find((org) => org.login === name))
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([name, repoList]) => {
sortedGroups.push({ name, repos: repoList });
});
return sortedGroups;
}, [repos, orgs, searchQuery]);
const handleSelect = () => {
if (selectedRepo) {
onSelect(selectedRepo.html_url + '.git');
}
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && !isConnecting) {
onClose();
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'today';
if (diffDays === 1) return 'yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
return `${Math.floor(diffDays / 365)} years ago`;
};
return (
<div className={styles.ModalBackdrop} onClick={handleBackdropClick}>
<div className={`${styles.Modal} ${styles.ModalLarge}`}>
<div className={styles.ModalHeader}>
<h2>Connect to Existing Repository</h2>
<button className={styles.CloseButton} onClick={onClose} disabled={isConnecting}>
&times;
</button>
</div>
<div className={styles.ModalBody}>
<div className={styles.SearchContainer}>
<input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={styles.SearchInput}
autoFocus
/>
</div>
{loading && (
<div className={styles.LoadingState}>
<div className={styles.Spinner} />
<p>Loading repositories...</p>
</div>
)}
{error && <div className={styles.ErrorMessage}>{error}</div>}
{!loading && !error && (
<div className={styles.RepoList}>
{groupedRepos.length === 0 ? (
<div className={styles.EmptyState}>
<p>No repositories found</p>
</div>
) : (
groupedRepos.map((group) => (
<div key={group.name} className={styles.RepoGroup}>
<div className={styles.RepoGroupHeader}>{group.name}</div>
{group.repos.map((repo) => (
<div
key={repo.id}
className={`${styles.RepoItem} ${selectedRepo?.id === repo.id ? styles.RepoItemSelected : ''}`}
onClick={() => setSelectedRepo(repo)}
>
<div className={styles.RepoInfo}>
<div className={styles.RepoName}>
<span>{repo.name}</span>
{repo.private && <span className={styles.PrivateBadge}>Private</span>}
</div>
{repo.description && <div className={styles.RepoDescription}>{repo.description}</div>}
<div className={styles.RepoMeta}>Updated {formatDate(repo.updated_at)}</div>
</div>
</div>
))}
</div>
))
)}
</div>
)}
</div>
<div className={styles.ModalFooter}>
<button type="button" className={styles.SecondaryButton} onClick={onClose} disabled={isConnecting}>
Cancel
</button>
<button
type="button"
className={styles.PrimaryButton}
onClick={handleSelect}
disabled={isConnecting || !selectedRepo}
>
{isConnecting ? 'Connecting...' : 'Connect Repository'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { ConnectToGitHubView } from './ConnectToGitHubView';
export { CreateRepoModal } from './CreateRepoModal';
export { SelectRepoModal } from './SelectRepoModal';

View File

@@ -0,0 +1,161 @@
/**
* SyncToolbar styles
* Uses design tokens for theming
*/
.SyncToolbar {
display: flex;
flex-direction: column;
padding: 10px 12px;
background-color: var(--theme-color-bg-3);
border-bottom: 1px solid var(--theme-color-border-default);
}
.RepoInfo {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.RepoName {
font-size: 13px;
font-weight: 500;
color: var(--theme-color-fg-default);
}
.StatusText {
font-size: 12px;
color: var(--theme-color-fg-default-shy);
}
.ChangesIndicator {
color: var(--theme-color-warning);
}
.SyncedIndicator {
color: var(--theme-color-success);
}
.Actions {
display: flex;
gap: 8px;
}
.SyncButton {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background-color: var(--theme-color-bg-4);
border-color: var(--theme-color-border-strong);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.HasChanges {
background-color: rgba(var(--theme-color-primary-rgb, 59, 130, 246), 0.1);
border-color: var(--theme-color-primary);
color: var(--theme-color-primary);
&:hover:not(:disabled) {
background-color: rgba(var(--theme-color-primary-rgb, 59, 130, 246), 0.2);
}
}
svg {
flex-shrink: 0;
}
}
.Badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background-color: var(--theme-color-primary);
border-radius: 9px;
color: white;
font-size: 11px;
font-weight: 600;
}
.RefreshButton {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default-shy);
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-default);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.Spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.ErrorBar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
padding: 8px 10px;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 4px;
color: var(--theme-color-danger);
font-size: 12px;
button {
background: none;
border: none;
color: var(--theme-color-danger);
font-size: 16px;
cursor: pointer;
padding: 0 0 0 8px;
line-height: 1;
&:hover {
opacity: 0.7;
}
}
}

View File

@@ -0,0 +1,150 @@
/**
* SyncToolbar
*
* Toolbar with push/pull buttons and sync status display
*/
import React, { useState } from 'react';
import { useGitSyncStatus } from '../../hooks/useGitSyncStatus';
import styles from './SyncToolbar.module.scss';
interface SyncToolbarProps {
owner: string;
repo: string;
}
export function SyncToolbar({ owner, repo }: SyncToolbarProps) {
const { ahead, behind, hasUncommittedChanges, loading, error, isSyncing, push, pull, refresh } = useGitSyncStatus();
const [lastError, setLastError] = useState<string | null>(null);
const handlePush = async () => {
setLastError(null);
try {
await push();
} catch (err) {
setLastError(err instanceof Error ? err.message : 'Push failed');
}
};
const handlePull = async () => {
setLastError(null);
try {
await pull();
} catch (err) {
setLastError(err instanceof Error ? err.message : 'Pull failed');
}
};
const handleRefresh = () => {
setLastError(null);
refresh();
};
// Show error or status message
const displayError = lastError || error;
return (
<div className={styles.SyncToolbar}>
<div className={styles.RepoInfo}>
<span className={styles.RepoName}>
{owner}/{repo}
</span>
{loading && <span className={styles.StatusText}>Loading...</span>}
{!loading && !displayError && (
<span className={styles.StatusText}>
{hasUncommittedChanges && <span className={styles.ChangesIndicator}>Uncommitted changes</span>}
{!hasUncommittedChanges && ahead === 0 && behind === 0 && (
<span className={styles.SyncedIndicator}>Up to date</span>
)}
</span>
)}
</div>
<div className={styles.Actions}>
{/* Pull button */}
<button
className={`${styles.SyncButton} ${behind > 0 ? styles.HasChanges : ''}`}
onClick={handlePull}
disabled={isSyncing || loading}
title={behind > 0 ? `Pull ${behind} commit${behind > 1 ? 's' : ''} from remote` : 'Pull from remote'}
>
<DownloadIcon />
<span>Pull</span>
{behind > 0 && <span className={styles.Badge}>{behind}</span>}
</button>
{/* Push button */}
<button
className={`${styles.SyncButton} ${ahead > 0 || hasUncommittedChanges ? styles.HasChanges : ''}`}
onClick={handlePush}
disabled={isSyncing || loading || (ahead === 0 && !hasUncommittedChanges)}
title={
hasUncommittedChanges
? 'Commit and push changes'
: ahead > 0
? `Push ${ahead} commit${ahead > 1 ? 's' : ''} to remote`
: 'Nothing to push'
}
>
<UploadIcon />
<span>Push</span>
{(ahead > 0 || hasUncommittedChanges) && (
<span className={styles.Badge}>{hasUncommittedChanges ? '!' : ahead}</span>
)}
</button>
{/* Refresh button */}
<button
className={styles.RefreshButton}
onClick={handleRefresh}
disabled={isSyncing || loading}
title="Refresh sync status"
>
<RefreshIcon spinning={loading || isSyncing} />
</button>
</div>
{displayError && (
<div className={styles.ErrorBar}>
<span>{displayError}</span>
<button onClick={() => setLastError(null)}>&times;</button>
</div>
)}
</div>
);
}
// Icon components
function DownloadIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 12L3 7l1.4-1.4L7 8.2V1h2v7.2l2.6-2.6L13 7l-5 5z" />
<path d="M14 13v1H2v-1h12z" />
</svg>
);
}
function UploadIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1l5 5-1.4 1.4L9 4.8V12H7V4.8L4.4 7.4 3 6l5-5z" />
<path d="M14 13v1H2v-1h12z" />
</svg>
);
}
function RefreshIcon({ spinning }: { spinning?: boolean }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
className={spinning ? styles.Spinning : undefined}
>
<path d="M13.5 8c0-3-2.5-5.5-5.5-5.5S2.5 5 2.5 8H1C1 4.1 4.1 1 8 1s7 3.1 7 7h-1.5z" />
<path d="M2.5 8c0 3 2.5 5.5 5.5 5.5s5.5-2.5 5.5-5.5H15c0 3.9-3.1 7-7 7s-7-3.1-7-7h1.5z" />
</svg>
);
}

View File

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

View File

@@ -2,20 +2,40 @@
* useGitHubRepository Hook
*
* Extracts GitHub repository information from the Git remote URL.
* Returns owner, repo name, and connection status.
* Returns owner, repo name, connection status, and detailed git state.
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Git } from '@noodl/git';
import { ProjectModel } from '@noodl-models/projectmodel';
import { mergeProject } from '@noodl-utils/projectmerger';
interface GitHubRepoInfo {
/**
* Possible states for a project's git connection
*/
export type ProjectGitState =
| 'loading' // Still determining state
| 'no-git' // No .git folder
| 'git-no-remote' // Has .git but no origin remote
| 'remote-not-github' // Has remote but not github.com
| 'github-connected'; // Connected to GitHub
export interface GitHubRepoInfo {
/** GitHub repository owner/organization */
owner: string | null;
/** GitHub repository name */
repo: string | null;
/** Whether the remote is GitHub */
isGitHub: boolean;
/** Whether we have all info needed (owner + repo) */
isReady: boolean;
/** Detailed state of the git connection */
gitState: ProjectGitState;
/** Remote URL if available */
remoteUrl: string | null;
/** Git provider (github, noodl, unknown, none) */
provider: string | null;
}
/**
@@ -54,77 +74,116 @@ function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
return null;
}
const initialState: GitHubRepoInfo = {
owner: null,
repo: null,
isGitHub: false,
isReady: false,
gitState: 'loading',
remoteUrl: null,
provider: null
};
/**
* Hook to get GitHub repository information from current project's Git remote
*/
export function useGitHubRepository(): GitHubRepoInfo {
const [repoInfo, setRepoInfo] = useState<GitHubRepoInfo>({
owner: null,
repo: null,
isGitHub: false,
isReady: false
});
export function useGitHubRepository(): GitHubRepoInfo & { refetch: () => void } {
const [repoInfo, setRepoInfo] = useState<GitHubRepoInfo>(initialState);
const fetchRepoInfo = useCallback(async () => {
try {
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
if (!projectDirectory) {
setRepoInfo({
...initialState,
gitState: 'no-git'
});
return;
}
// Create Git instance and try to open repository
const git = new Git(mergeProject);
useEffect(() => {
async function fetchRepoInfo() {
try {
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
if (!projectDirectory) {
setRepoInfo({
owner: null,
repo: null,
isGitHub: false,
isReady: false
});
return;
}
// Create Git instance and open repository
const git = new Git(mergeProject);
await git.openRepository(projectDirectory);
// Check if it's a GitHub repository
const provider = git.Provider;
if (provider !== 'github') {
setRepoInfo({
owner: null,
repo: null,
isGitHub: false,
isReady: false
});
return;
}
// Parse the remote URL
const remoteUrl = git.OriginUrl;
const parsed = parseGitHubUrl(remoteUrl);
if (parsed) {
setRepoInfo({
owner: parsed.owner,
repo: parsed.repo,
isGitHub: true,
isReady: true
});
} catch (gitError) {
// Not a git repository - this is expected for non-git projects
const errorMessage = gitError instanceof Error ? gitError.message : String(gitError);
if (errorMessage.includes('Not a git repository')) {
console.log('[useGitHubRepository] Project is not a git repository');
} else {
setRepoInfo({
owner: null,
repo: null,
isGitHub: true, // It's GitHub but couldn't parse
isReady: false
});
console.warn('[useGitHubRepository] Git error:', errorMessage);
}
} catch (error) {
console.error('Failed to fetch GitHub repository info:', error);
setRepoInfo({
...initialState,
gitState: 'no-git'
});
return;
}
// Check if we have a remote
const remoteName = await git.getRemoteName();
if (!remoteName) {
console.log('[useGitHubRepository] No remote configured');
setRepoInfo({
...initialState,
gitState: 'git-no-remote'
});
return;
}
// Get remote URL and provider
const remoteUrl = git.OriginUrl;
const provider = git.Provider;
// Check if it's a GitHub repository
if (provider !== 'github') {
setRepoInfo({
owner: null,
repo: null,
isGitHub: false,
isReady: false
isReady: false,
gitState: 'remote-not-github',
remoteUrl,
provider
});
return;
}
// Parse the remote URL
const parsed = parseGitHubUrl(remoteUrl);
if (parsed) {
setRepoInfo({
owner: parsed.owner,
repo: parsed.repo,
isGitHub: true,
isReady: true,
gitState: 'github-connected',
remoteUrl,
provider
});
} else {
setRepoInfo({
owner: null,
repo: null,
isGitHub: true, // It's GitHub but couldn't parse
isReady: false,
gitState: 'github-connected',
remoteUrl,
provider
});
}
} catch (error) {
console.error('[useGitHubRepository] Unexpected error:', error);
setRepoInfo({
...initialState,
gitState: 'no-git'
});
}
}, []);
useEffect(() => {
fetchRepoInfo();
// Refetch when project changes
@@ -138,7 +197,7 @@ export function useGitHubRepository(): GitHubRepoInfo {
return () => {
ProjectModel.instance?.off(handleProjectChange);
};
}, []);
}, [fetchRepoInfo]);
return repoInfo;
return { ...repoInfo, refetch: fetchRepoInfo };
}

View File

@@ -0,0 +1,247 @@
/**
* useGitSyncStatus Hook
*
* Monitors git sync status including ahead/behind counts and uncommitted changes.
* Provides push/pull functionality.
*/
import { useState, useEffect, useCallback } from 'react';
import { Git } from '@noodl/git';
import { ProjectModel } from '@noodl-models/projectmodel';
import { LocalProjectsModel } from '@noodl-utils/LocalProjectsModel';
import { mergeProject } from '@noodl-utils/projectmerger';
export interface GitSyncStatus {
/** Number of commits ahead of remote */
ahead: number;
/** Number of commits behind remote */
behind: number;
/** Whether there are uncommitted changes */
hasUncommittedChanges: boolean;
/** Whether we're currently loading status */
loading: boolean;
/** Any error that occurred */
error: string | null;
/** Whether a push/pull operation is in progress */
isSyncing: boolean;
}
interface UseGitSyncStatusResult extends GitSyncStatus {
/** Push local commits to remote */
push: () => Promise<void>;
/** Pull remote commits to local */
pull: () => Promise<void>;
/** Refresh the sync status */
refresh: () => Promise<void>;
}
export function useGitSyncStatus(): UseGitSyncStatusResult {
const [status, setStatus] = useState<GitSyncStatus>({
ahead: 0,
behind: 0,
hasUncommittedChanges: false,
loading: true,
error: null,
isSyncing: false
});
const fetchStatus = useCallback(async () => {
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
if (!projectDirectory) {
setStatus((prev) => ({
...prev,
loading: false,
error: 'No project directory'
}));
return;
}
try {
// Ensure git auth is set up
const projectId = ProjectModel.instance?.id || 'temp';
LocalProjectsModel.instance.setCurrentGlobalGitAuth(projectId);
const git = new Git(mergeProject);
await git.openRepository(projectDirectory);
// Check for remote
const remoteName = await git.getRemoteName();
if (!remoteName) {
setStatus((prev) => ({
...prev,
loading: false,
error: 'No remote configured'
}));
return;
}
// Fetch latest from remote (silently)
try {
await git.fetch({ onProgress: () => {} });
} catch (fetchError) {
console.warn('[useGitSyncStatus] Fetch warning:', fetchError);
// Continue anyway - might be offline
}
// Get ahead/behind counts
let ahead = 0;
let behind = 0;
try {
const aheadBehind = await git.currentAheadBehind();
ahead = aheadBehind.ahead;
behind = aheadBehind.behind;
} catch (abError) {
console.warn('[useGitSyncStatus] Could not get ahead/behind:', abError);
// Remote might not have any commits yet
}
// Check for uncommitted changes
const changes = await git.status();
const hasUncommittedChanges = changes.length > 0;
setStatus({
ahead,
behind,
hasUncommittedChanges,
loading: false,
error: null,
isSyncing: false
});
} catch (error) {
console.error('[useGitSyncStatus] Error:', error);
setStatus((prev) => ({
...prev,
loading: false,
error: error instanceof Error ? error.message : 'Failed to get sync status'
}));
}
}, []);
const push = useCallback(async () => {
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
if (!projectDirectory) {
throw new Error('No project directory');
}
setStatus((prev) => ({ ...prev, isSyncing: true, error: null }));
try {
// Ensure git auth is set up
const projectId = ProjectModel.instance?.id || 'temp';
LocalProjectsModel.instance.setCurrentGlobalGitAuth(projectId);
const git = new Git(mergeProject);
await git.openRepository(projectDirectory);
// Check for uncommitted changes first
const changes = await git.status();
if (changes.length > 0) {
// Auto-commit changes before pushing
console.log('[useGitSyncStatus] Committing changes before push...');
await git.commit('Auto-commit before push');
}
console.log('[useGitSyncStatus] Pushing to remote...');
await git.push();
console.log('[useGitSyncStatus] Push successful');
// Refresh status
await fetchStatus();
} catch (error) {
console.error('[useGitSyncStatus] Push error:', error);
setStatus((prev) => ({
...prev,
isSyncing: false,
error: error instanceof Error ? error.message : 'Push failed'
}));
throw error;
}
}, [fetchStatus]);
const pull = useCallback(async () => {
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
if (!projectDirectory) {
throw new Error('No project directory');
}
setStatus((prev) => ({ ...prev, isSyncing: true, error: null }));
try {
// Ensure git auth is set up
const projectId = ProjectModel.instance?.id || 'temp';
LocalProjectsModel.instance.setCurrentGlobalGitAuth(projectId);
const git = new Git(mergeProject);
await git.openRepository(projectDirectory);
// Stash any uncommitted changes
const changes = await git.status();
const needsStash = changes.length > 0;
if (needsStash) {
console.log('[useGitSyncStatus] Stashing local changes...');
await git.stashPushChanges();
}
// Fetch and merge
console.log('[useGitSyncStatus] Fetching from remote...');
await git.fetch({ onProgress: () => {} });
// Get current branch
const branchName = await git.getCurrentBranchName();
const remoteBranch = `origin/${branchName}`;
console.log('[useGitSyncStatus] Merging', remoteBranch, 'into', branchName);
await git.mergeToCurrentBranch(remoteBranch, false);
// Pop stash if we stashed
if (needsStash) {
console.log('[useGitSyncStatus] Restoring stashed changes...');
await git.stashPopChanges();
}
console.log('[useGitSyncStatus] Pull successful');
// Refresh status
await fetchStatus();
// Notify project to reload
ProjectModel.instance?.notifyListeners('projectMightNeedRefresh');
} catch (error) {
console.error('[useGitSyncStatus] Pull error:', error);
setStatus((prev) => ({
...prev,
isSyncing: false,
error: error instanceof Error ? error.message : 'Pull failed'
}));
throw error;
}
}, [fetchStatus]);
// Initial fetch
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Auto-refresh on project changes
useEffect(() => {
const handleProjectChange = () => {
fetchStatus();
};
ProjectModel.instance?.on('projectSaved', handleProjectChange);
ProjectModel.instance?.on('remoteChanged', handleProjectChange);
return () => {
ProjectModel.instance?.off(handleProjectChange);
};
}, [fetchStatus]);
return {
...status,
push,
pull,
refresh: fetchStatus
};
}

View File

@@ -6,7 +6,7 @@
*/
import { useEventListener } from '@noodl-hooks/useEventListener';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { GitHubClient } from '../../../../services/github';
import type { GitHubIssue, GitHubIssueFilters } from '../../../../services/github/GitHubTypes';
@@ -43,6 +43,10 @@ export function useIssues({ owner, repo, filters = {}, enabled = true }: UseIssu
const client = GitHubClient.instance;
// Use ref to store filters to avoid infinite loops
const filtersRef = useRef(filters);
filtersRef.current = filters;
const fetchIssues = useCallback(
async (pageNum: number = 1, append: boolean = false) => {
if (!owner || !repo || !enabled) {
@@ -59,7 +63,7 @@ export function useIssues({ owner, repo, filters = {}, enabled = true }: UseIssu
}
const response = await client.listIssues(owner, repo, {
...filters,
...filtersRef.current,
per_page: DEFAULT_PER_PAGE,
page: pageNum
});
@@ -84,7 +88,7 @@ export function useIssues({ owner, repo, filters = {}, enabled = true }: UseIssu
setLoadingMore(false);
}
},
[owner, repo, enabled, filters, client]
[owner, repo, enabled, client]
);
const refetch = useCallback(async () => {
@@ -99,10 +103,16 @@ export function useIssues({ owner, repo, filters = {}, enabled = true }: UseIssu
}
}, [fetchIssues, page, hasMore, loadingMore]);
// Initial fetch
// Serialize filters to avoid infinite loops from object reference changes
const filtersKey = JSON.stringify(filters);
// Initial fetch - use serialized filters key to avoid infinite loop
// Note: refetch is excluded from deps to prevent loops, we use filtersKey instead
useEffect(() => {
refetch();
}, [owner, repo, filters, enabled]);
if (owner && repo && enabled) {
refetch();
}
}, [owner, repo, filtersKey, enabled, refetch]);
// Listen for cache invalidation events
useEventListener(client, 'rate-limit-updated', () => {

View File

@@ -6,7 +6,7 @@
*/
import { useEventListener } from '@noodl-hooks/useEventListener';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { GitHubClient } from '../../../../services/github';
import type { GitHubPullRequest, GitHubIssueFilters } from '../../../../services/github/GitHubTypes';
@@ -48,6 +48,10 @@ export function usePullRequests({
const client = GitHubClient.instance;
// Use ref to store filters to avoid infinite loops
const filtersRef = useRef(filters);
filtersRef.current = filters;
const fetchPullRequests = useCallback(
async (pageNum: number = 1, append: boolean = false) => {
if (!owner || !repo || !enabled) {
@@ -64,7 +68,7 @@ export function usePullRequests({
}
const response = await client.listPullRequests(owner, repo, {
...filters,
...filtersRef.current,
per_page: DEFAULT_PER_PAGE,
page: pageNum
});
@@ -89,7 +93,7 @@ export function usePullRequests({
setLoadingMore(false);
}
},
[owner, repo, enabled, filters, client]
[owner, repo, enabled, client]
);
const refetch = useCallback(async () => {
@@ -104,10 +108,15 @@ export function usePullRequests({
}
}, [fetchPullRequests, page, hasMore, loadingMore]);
// Initial fetch
// Serialize filters to avoid infinite loops
const filtersKey = JSON.stringify(filters);
// Initial fetch - use serialized filters key
useEffect(() => {
refetch();
}, [owner, repo, filters, enabled]);
if (owner && repo && enabled) {
refetch();
}
}, [owner, repo, filtersKey, enabled, refetch]);
// Listen for cache invalidation events
useEventListener(client, 'rate-limit-updated', () => {

View File

@@ -1,3 +1,4 @@
import { useEventListener } from '@noodl-hooks/useEventListener';
import React, { useState, useEffect } from 'react';
import { GitProvider } from '@noodl/git';
@@ -7,7 +8,7 @@ import { TextInput, TextInputVariant } from '@noodl-core-ui/components/inputs/Te
import { Section, SectionVariant } from '@noodl-core-ui/components/sidebar/Section';
import { Text } from '@noodl-core-ui/components/typography/Text';
import { GitHubAuth, type GitHubAuthState } from '../../../../../../services/github';
import { GitHubOAuthService } from '../../../../../../services/github';
type CredentialsSectionProps = {
provider: GitProvider;
@@ -29,43 +30,75 @@ export function CredentialsSection({
const [hidePassword, setHidePassword] = useState(true);
// OAuth state management
const [authState, setAuthState] = useState<GitHubAuthState>(GitHubAuth.getAuthState());
// OAuth state management using GitHubOAuthService
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [authenticatedUsername, setAuthenticatedUsername] = useState<string | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [progressMessage, setProgressMessage] = useState<string>('');
const [error, setError] = useState<string | null>(null);
// Check auth state on mount
const oauthService = GitHubOAuthService.instance;
// Initialize OAuth service on mount
useEffect(() => {
if (provider === 'github') {
setAuthState(GitHubAuth.getAuthState());
console.log('🔧 [CredentialsSection] Initializing GitHubOAuthService...');
oauthService.initialize().then(() => {
setIsAuthenticated(oauthService.isAuthenticated());
const user = oauthService.getCurrentUser();
setAuthenticatedUsername(user?.login || null);
console.log('🔧 [CredentialsSection] Auth state:', oauthService.isAuthenticated(), user?.login);
});
}
}, [provider]);
}, [provider, oauthService]);
// Listen for auth state changes
useEventListener(oauthService, 'auth-state-changed', (event: { authenticated: boolean }) => {
console.log('🔔 [CredentialsSection] Auth state changed:', event.authenticated);
setIsAuthenticated(event.authenticated);
if (event.authenticated) {
const user = oauthService.getCurrentUser();
setAuthenticatedUsername(user?.login || null);
} else {
setAuthenticatedUsername(null);
}
});
const handleConnect = async () => {
console.log('🔘 [CredentialsSection] handleConnect called - button clicked!');
setIsConnecting(true);
setError(null);
setProgressMessage('Initiating GitHub authentication...');
setProgressMessage('Opening GitHub in your browser...');
try {
await GitHubAuth.startWebOAuthFlow((message) => {
setProgressMessage(message);
});
console.log('🔐 [CredentialsSection] Calling GitHubOAuthService.initiateOAuth...');
await oauthService.initiateOAuth();
// Update state after successful auth
setAuthState(GitHubAuth.getAuthState());
setProgressMessage('');
console.log('✅ [CredentialsSection] OAuth flow initiated');
// State will be updated via event listener when auth completes
setProgressMessage('Waiting for authorization...');
} catch (err) {
console.error('❌ [CredentialsSection] OAuth flow error:', err);
setError(err instanceof Error ? err.message : 'Authentication failed');
setProgressMessage('');
} finally {
setIsConnecting(false);
}
};
const handleDisconnect = () => {
GitHubAuth.disconnect();
setAuthState(GitHubAuth.getAuthState());
// Listen for auth success to clear connecting state
useEventListener(oauthService, 'oauth-success', () => {
setIsConnecting(false);
setProgressMessage('');
});
useEventListener(oauthService, 'oauth-error', (event: { error: string }) => {
setIsConnecting(false);
setError(event.error);
setProgressMessage('');
});
const handleDisconnect = async () => {
await oauthService.disconnect();
setError(null);
};
@@ -74,11 +107,11 @@ export function CredentialsSection({
{/* OAuth Section - GitHub Only */}
{provider === 'github' && (
<Section title="GitHub Account (Recommended)" variant={SectionVariant.InModal} hasGutter>
{authState.isAuthenticated ? (
{isAuthenticated ? (
// Connected state
<>
<Text hasBottomSpacing>
Connected as <strong>{authState.username}</strong>
Connected as <strong>{authenticatedUsername}</strong>
</Text>
<Text hasBottomSpacing>Your GitHub account is connected and will be used for all Git operations.</Text>
<TextButton label="Disconnect GitHub Account" onClick={handleDisconnect} />

View File

@@ -15,8 +15,8 @@ const { ipcMain, BrowserWindow } = require('electron');
* GitHub OAuth credentials
* Uses existing credentials from GitHubOAuthService
*/
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Iv23lib1WdrimUdyvZui';
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || '9bd56694d6d300bf86b1999bab523b32654ec375';
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Ov23li2n9u3dwAhwoifb';
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || 'c45276fa80b0618de06e5e2b09c1019ca150baef';
/**
* Custom protocol for OAuth callback
@@ -217,6 +217,7 @@ class GitHubOAuthCallbackHandler {
/**
* Send success to renderer process
* Broadcasts to ALL windows since the editor might not be windows[0]
*/
sendSuccessToRenderer(result) {
console.log('📤 [GitHub OAuth] ========================================');
@@ -227,8 +228,15 @@ class GitHubOAuthCallbackHandler {
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send('github-oauth-complete', result);
console.log('✅ [GitHub OAuth] IPC event sent to renderer');
// Broadcast to ALL windows - the one with the listener will handle it
windows.forEach((win, index) => {
try {
win.webContents.send('github-oauth-complete', result);
console.log(`✅ [GitHub OAuth] IPC event sent to window ${index}`);
} catch (err) {
console.error(`❌ [GitHub OAuth] Failed to send to window ${index}:`, err.message);
}
});
} else {
console.error('❌ [GitHub OAuth] No windows available to send IPC event!');
}
@@ -236,13 +244,20 @@ class GitHubOAuthCallbackHandler {
/**
* Send error to renderer process
* Broadcasts to ALL windows since the editor might not be windows[0]
*/
sendErrorToRenderer(error, description) {
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send('github-oauth-error', {
error,
message: description || error
windows.forEach((win) => {
try {
win.webContents.send('github-oauth-error', {
error,
message: description || error
});
} catch (err) {
// Ignore errors for windows that can't receive messages
}
});
}
}

View File

@@ -671,7 +671,13 @@ function launchApp() {
// Load GitHub token
ipcMain.handle('github-load-token', async (event) => {
try {
const stored = jsonstorage.getSync('github.token');
// Use Promise wrapper for callback-based jsonstorage.get
const stored = await new Promise((resolve) => {
jsonstorage.get('github.token', (data) => {
resolve(data);
});
});
if (!stored) return null;
if (safeStorage.isEncryptionAvailable()) {

View File

@@ -1,10 +1,30 @@
import { GitError as DugiteError } from "dugite";
import { GitError as DugiteError } from 'dugite';
/**
* Returns the SHA of the passed in IGitResult
*
* Git commit output format:
* - Normal: "[main abc1234] Commit message"
* - Root commit: "[main (root-commit) abc1234] Commit message"
*/
export function parseCommitSHA(result: IGitResult): string {
return result.output.toString().split("]")[0].split(" ")[1];
const output = result.output.toString();
const bracketContent = output.split(']')[0]; // "[main abc1234" or "[main (root-commit) abc1234"
const parts = bracketContent.split(' ');
// For root commit, the SHA is the last part before the closing bracket
// For normal commit, it's the second part
// Skip "(root-commit)" if present and get the actual SHA
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i];
// SHA is a hex string, not "(root-commit)" or branch name
if (part && !part.startsWith('(') && !part.startsWith('[') && /^[a-f0-9]+$/.test(part)) {
return part;
}
}
// Fallback to original behavior
return parts[1];
}
/**
@@ -62,13 +82,13 @@ export class GitError extends Error {
} else if (result.output.length) {
message = result.error.toString();
} else {
message = "Unknown error";
message = 'Unknown error';
rawMessage = false;
}
super(message);
this.name = "GitError";
this.name = 'GitError';
this.result = result;
this.args = args;
this.isRawMessage = rawMessage;
@@ -111,94 +131,94 @@ export function getDescriptionForError(error: DugiteError): string | null {
switch (error) {
case DugiteError.SSHKeyAuditUnverified:
return "The SSH key is unverified.";
return 'The SSH key is unverified.';
case DugiteError.RemoteDisconnection:
return "The remote disconnected. Check your Internet connection and try again.";
return 'The remote disconnected. Check your Internet connection and try again.';
case DugiteError.HostDown:
return "The host is down. Check your Internet connection and try again.";
return 'The host is down. Check your Internet connection and try again.';
case DugiteError.RebaseConflicts:
return "We found some conflicts while trying to rebase. Please resolve the conflicts before continuing.";
return 'We found some conflicts while trying to rebase. Please resolve the conflicts before continuing.';
case DugiteError.MergeConflicts:
return "We found some conflicts while trying to merge. Please resolve the conflicts and commit the changes.";
return 'We found some conflicts while trying to merge. Please resolve the conflicts and commit the changes.';
case DugiteError.HTTPSRepositoryNotFound:
case DugiteError.SSHRepositoryNotFound:
return "The repository does not seem to exist anymore. You may not have access, or it may have been deleted or renamed.";
return 'The repository does not seem to exist anymore. You may not have access, or it may have been deleted or renamed.';
case DugiteError.PushNotFastForward:
return "The repository has been updated since you last pulled. Try pulling before pushing.";
return 'The repository has been updated since you last pulled. Try pulling before pushing.';
case DugiteError.BranchDeletionFailed:
return "Could not delete the branch. It was probably already deleted.";
return 'Could not delete the branch. It was probably already deleted.';
case DugiteError.DefaultBranchDeletionFailed:
return `The branch is the repository's default branch and cannot be deleted.`;
case DugiteError.RevertConflicts:
return "To finish reverting, please merge and commit the changes.";
return 'To finish reverting, please merge and commit the changes.';
case DugiteError.EmptyRebasePatch:
return "There arent any changes left to apply.";
return 'There arent any changes left to apply.';
case DugiteError.NoMatchingRemoteBranch:
return "There arent any remote branches that match the current branch.";
return 'There arent any remote branches that match the current branch.';
case DugiteError.NothingToCommit:
return "There are no changes to commit.";
return 'There are no changes to commit.';
case DugiteError.NoSubmoduleMapping:
return "A submodule was removed from .gitmodules, but the folder still exists in the repository. Delete the folder, commit the change, then try again.";
return 'A submodule was removed from .gitmodules, but the folder still exists in the repository. Delete the folder, commit the change, then try again.';
case DugiteError.SubmoduleRepositoryDoesNotExist:
return "A submodule points to a location which does not exist.";
return 'A submodule points to a location which does not exist.';
case DugiteError.InvalidSubmoduleSHA:
return "A submodule points to a commit which does not exist.";
return 'A submodule points to a commit which does not exist.';
case DugiteError.LocalPermissionDenied:
return "Permission denied.";
return 'Permission denied.';
case DugiteError.InvalidMerge:
return "This is not something we can merge.";
return 'This is not something we can merge.';
case DugiteError.InvalidRebase:
return "This is not something we can rebase.";
return 'This is not something we can rebase.';
case DugiteError.NonFastForwardMergeIntoEmptyHead:
return "The merge you attempted is not a fast-forward, so it cannot be performed on an empty branch.";
return 'The merge you attempted is not a fast-forward, so it cannot be performed on an empty branch.';
case DugiteError.PatchDoesNotApply:
return "The requested changes conflict with one or more files in the repository.";
return 'The requested changes conflict with one or more files in the repository.';
case DugiteError.BranchAlreadyExists:
return "A branch with that name already exists.";
return 'A branch with that name already exists.';
case DugiteError.BadRevision:
return "Bad revision.";
return 'Bad revision.';
case DugiteError.NotAGitRepository:
return "This is not a git repository.";
return 'This is not a git repository.';
case DugiteError.ProtectedBranchForcePush:
return "This branch is protected from force-push operations.";
return 'This branch is protected from force-push operations.';
case DugiteError.ProtectedBranchRequiresReview:
return "This branch is protected and any changes requires an approved review. Open a pull request with changes targeting this branch instead.";
return 'This branch is protected and any changes requires an approved review. Open a pull request with changes targeting this branch instead.';
case DugiteError.PushWithFileSizeExceedingLimit:
return "The push operation includes a file which exceeds GitHub's file size restriction of 100MB. Please remove the file from history and try again.";
case DugiteError.HexBranchNameRejected:
return "The branch name cannot be a 40-character string of hexadecimal characters, as this is the format that Git uses for representing objects.";
return 'The branch name cannot be a 40-character string of hexadecimal characters, as this is the format that Git uses for representing objects.';
case DugiteError.ForcePushRejected:
return "The force push has been rejected for the current branch.";
return 'The force push has been rejected for the current branch.';
case DugiteError.InvalidRefLength:
return "A ref cannot be longer than 255 characters.";
return 'A ref cannot be longer than 255 characters.';
case DugiteError.CannotMergeUnrelatedHistories:
return "Unable to merge unrelated histories in this repository.";
return 'Unable to merge unrelated histories in this repository.';
case DugiteError.PushWithPrivateEmail:
return 'Cannot push these commits as they contain an email address marked as private on GitHub. To push anyway, visit https://github.com/settings/emails, uncheck "Keep my email address private", then switch back to GitHub Desktop to push your commits. You can then enable the setting again.';
case DugiteError.LFSAttributeDoesNotMatch:
return "Git LFS attribute found in global Git configuration does not match expected value.";
return 'Git LFS attribute found in global Git configuration does not match expected value.';
case DugiteError.ProtectedBranchDeleteRejected:
return "This branch cannot be deleted from the remote repository because it is marked as protected.";
return 'This branch cannot be deleted from the remote repository because it is marked as protected.';
case DugiteError.ProtectedBranchRequiredStatus:
return "The push was rejected by the remote server because a required status check has not been satisfied.";
return 'The push was rejected by the remote server because a required status check has not been satisfied.';
case DugiteError.BranchRenameFailed:
return "The branch could not be renamed.";
return 'The branch could not be renamed.';
case DugiteError.PathDoesNotExist:
return "The path does not exist on disk.";
return 'The path does not exist on disk.';
case DugiteError.InvalidObjectName:
return "The object was not found in the Git repository.";
return 'The object was not found in the Git repository.';
case DugiteError.OutsideRepository:
return "This path is not a valid path inside the repository.";
return 'This path is not a valid path inside the repository.';
case DugiteError.LockFileAlreadyExists:
return "A lock file already exists in the repository, which blocks this operation from completing.";
return 'A lock file already exists in the repository, which blocks this operation from completing.';
case DugiteError.NoMergeToAbort:
return "There is no merge in progress, so there is nothing to abort.";
return 'There is no merge in progress, so there is nothing to abort.';
case DugiteError.NoExistingRemoteBranch:
return "The remote branch does not exist.";
return 'The remote branch does not exist.';
case DugiteError.LocalChangesOverwritten:
return "Unable to switch branches as there are working directory changes which would be overwritten. Please commit or stash your changes.";
return 'Unable to switch branches as there are working directory changes which would be overwritten. Please commit or stash your changes.';
case DugiteError.UnresolvedConflicts:
return "There are unresolved conflicts in the working directory.";
return 'There are unresolved conflicts in the working directory.';
case DugiteError.ConfigLockFileAlreadyExists:
// Added in dugite 1.88.0 (https://github.com/desktop/dugite/pull/386)
// in support of https://github.com/desktop/desktop/issues/8675 but we're
@@ -209,7 +229,7 @@ export function getDescriptionForError(error: DugiteError): string | null {
case DugiteError.RemoteAlreadyExists:
return null;
case DugiteError.TagAlreadyExists:
return "A tag with that name already exists";
return 'A tag with that name already exists';
case DugiteError.MergeWithLocalChanges:
case DugiteError.RebaseWithLocalChanges:
case DugiteError.GPGFailedToSignData:

View File

@@ -106,11 +106,17 @@ export class Git {
* Open a git repository in the given path.
*
* @param baseDir
* @throws Error if the path is not a git repository
*/
async openRepository(baseDir: string): Promise<void> {
if (this.baseDir) return;
this.baseDir = await open(baseDir);
const repositoryPath = await open(baseDir);
if (!repositoryPath) {
throw new Error(`Not a git repository: ${baseDir}`);
}
this.baseDir = repositoryPath;
await this._setupRepository();
}