mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
Added new github integration tasks
This commit is contained in:
@@ -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
|
## 🏗️ CRITICAL ARCHITECTURE PATTERNS
|
||||||
|
|
||||||
These fundamental patterns apply across ALL Noodl development. Understanding them prevents hours of debugging.
|
These fundamental patterns apply across ALL Noodl development. Understanding them prevents hours of debugging.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 3: Editor UX Overhaul - Progress Tracker
|
# Phase 3: Editor UX Overhaul - Progress Tracker
|
||||||
|
|
||||||
**Last Updated:** 2026-01-14
|
**Last Updated:** 2026-01-18
|
||||||
**Overall Status:** 🟡 In Progress
|
**Overall Status:** 🟡 In Progress
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -9,22 +9,23 @@
|
|||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
| ------------ | ------- |
|
| ------------ | ------- |
|
||||||
| Total Tasks | 9 |
|
| Total Tasks | 10 |
|
||||||
| Completed | 3 |
|
| Completed | 3 |
|
||||||
| In Progress | 1 |
|
| In Progress | 2 |
|
||||||
| Not Started | 5 |
|
| Not Started | 5 |
|
||||||
| **Progress** | **33%** |
|
| **Progress** | **30%** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task Status
|
## Task Status
|
||||||
|
|
||||||
| Task | Name | Status | Notes |
|
| Task | Name | Status | Notes |
|
||||||
| --------- | ----------------------- | -------------- | --------------------------------------------- |
|
| --------- | --------------------------- | -------------- | --------------------------------------------------------- |
|
||||||
| TASK-001 | Dashboard UX Foundation | 🟢 Complete | Tabbed navigation done |
|
| TASK-001 | Dashboard UX Foundation | 🟢 Complete | Tabbed navigation done |
|
||||||
| TASK-001B | Launcher Fixes | 🟢 Complete | All 4 subtasks implemented |
|
| TASK-001B | Launcher Fixes | 🟢 Complete | All 4 subtasks implemented |
|
||||||
| TASK-002 | GitHub Integration | 🟢 Complete | OAuth + basic features done |
|
| TASK-002 | GitHub Integration | 🟢 Complete | OAuth + basic features done |
|
||||||
| TASK-002B | GitHub Advanced | 🟡 In Progress | GIT-004A complete, 5 subtasks remaining |
|
| 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-003 | Shared Component System | 🔴 Not Started | Prefab system refactor |
|
||||||
| TASK-004 | AI Project Creation | 🔴 Not Started | AI scaffolding feature |
|
| TASK-004 | AI Project Creation | 🔴 Not Started | AI scaffolding feature |
|
||||||
| TASK-005 | Deployment Automation | 🔴 Not Started | Planning docs only, no implementation |
|
| TASK-005 | Deployment Automation | 🔴 Not Started | Planning docs only, no implementation |
|
||||||
@@ -44,7 +45,9 @@
|
|||||||
## Recent Updates
|
## Recent Updates
|
||||||
|
|
||||||
| Date | Update |
|
| 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-14 | TASK-002B GIT-004A complete (GitHub Client Foundation) |
|
||||||
| 2026-01-07 | Audit completed: corrected TASK-001B, TASK-005 status |
|
| 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 | Added TASK-006 and TASK-007 to tracking |
|
||||||
|
|||||||
@@ -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
|
## Progress Summary
|
||||||
|
|
||||||
| Sub-Task | Status | Started | Completed |
|
| 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-004A: OAuth & Client | ✅ Complete | 2026-01-14 | 2026-01-14 |
|
||||||
| GIT-004B: Issues Read | ✅ 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 |
|
| 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-004D: Issues CRUD | Not Started | - | - |
|
||||||
| GIT-004E: Component Linking | Not Started | - | - |
|
| GIT-004E: Component Linking | Not Started | - | - |
|
||||||
| GIT-004F: Dashboard | Not Started | - | - |
|
| GIT-004F: Dashboard | Not Started | - | - |
|
||||||
@@ -381,3 +552,94 @@ _Track user feedback during development/testing_
|
|||||||
| Date | Feedback | Source | Action |
|
| 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
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
# GIT-004: Implementation Checklist
|
# 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
|
## Pre-Implementation
|
||||||
|
|
||||||
- [ ] Review existing `packages/noodl-git/` code
|
- [x] Review existing `packages/noodl-git/` code
|
||||||
- [ ] Review `VersionControlPanel/` patterns
|
- [x] Review `VersionControlPanel/` patterns
|
||||||
- [ ] Set up GitHub App in GitHub Developer Settings (for testing)
|
- [ ] Set up GitHub App in GitHub Developer Settings (for testing)
|
||||||
- [ ] Document GitHub App creation steps for users
|
- [ ] 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 |
|
| Sub-Task | Status | Started | Completed | Hours |
|
||||||
|----------|--------|---------|-----------|-------|
|
| --------------------------- | ----------- | ------- | --------- | ----- |
|
||||||
| GIT-004A: OAuth & Client | Not Started | - | - | - |
|
| GIT-004A: OAuth & Client | ✅ Complete | - | - | - |
|
||||||
| GIT-004B: Issues Read | Not Started | - | - | - |
|
| GIT-004B: Issues Read | ✅ Complete | - | - | - |
|
||||||
| GIT-004C: PRs Read | Not Started | - | - | - |
|
| GIT-004C: PRs Read | ✅ Complete | - | - | - |
|
||||||
| GIT-004D: Issues CRUD | Not Started | - | - | - |
|
| GIT-004D: Issues CRUD | Not Started | - | - | - |
|
||||||
| GIT-004E: Component Linking | Not Started | - | - | - |
|
| GIT-004E: Component Linking | Not Started | - | - | - |
|
||||||
| GIT-004F: Dashboard | Not Started | - | - | - |
|
| GIT-004F: Dashboard | Not Started | - | - | - |
|
||||||
| Integration & Polish | 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
|
**Total Actual:** - hours
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
@@ -1,15 +1,39 @@
|
|||||||
# GIT-004: GitHub Project Management Integration
|
# TASK-002B: GitHub Advanced Integration
|
||||||
|
|
||||||
## Overview
|
## 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.
|
**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)
|
**Phase:** 3 (Dashboard UX & Collaboration)
|
||||||
**Priority:** HIGH (key differentiator)
|
**Priority:** HIGH (key differentiator)
|
||||||
**Total Effort:** 70-90 hours across 6 sub-tasks
|
**Total Effort:** 501-662 hours across 13 sub-tasks
|
||||||
**Risk:** Medium (OAuth complexity, GitHub API rate limits)
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,7 +44,7 @@ Transform Nodegex from a "coding tool with Git" into a **collaborative developme
|
|||||||
No major low-code platform offers this level of GitHub integration:
|
No major low-code platform offers this level of GitHub integration:
|
||||||
|
|
||||||
| Platform | Git Support | Issues/PRs | Component Linking |
|
| Platform | Git Support | Issues/PRs | Component Linking |
|
||||||
|----------|-------------|------------|-------------------|
|
| ----------- | ----------- | ---------- | ----------------- |
|
||||||
| Retool | ❌ | ❌ | ❌ |
|
| Retool | ❌ | ❌ | ❌ |
|
||||||
| Bubble | ❌ | ❌ | ❌ |
|
| Bubble | ❌ | ❌ | ❌ |
|
||||||
| Webflow | Basic | ❌ | ❌ |
|
| Webflow | Basic | ❌ | ❌ |
|
||||||
@@ -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.
|
Upgrade from PAT-based authentication to full GitHub App OAuth flow, and create a reusable GitHub API client.
|
||||||
|
|
||||||
**Scope:**
|
**Scope:**
|
||||||
|
|
||||||
- GitHub App registration guidance/documentation
|
- GitHub App registration guidance/documentation
|
||||||
- OAuth authorization flow in Electron
|
- OAuth authorization flow in Electron
|
||||||
- Secure token storage (upgrade from current GitStore pattern)
|
- 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
|
- Token refresh handling
|
||||||
|
|
||||||
**Files to Create:**
|
**Files to Create:**
|
||||||
|
|
||||||
```
|
```
|
||||||
packages/noodl-editor/src/editor/src/services/github/
|
packages/noodl-editor/src/editor/src/services/github/
|
||||||
├── GitHubAuth.ts # OAuth flow handler
|
├── GitHubAuth.ts # OAuth flow handler
|
||||||
@@ -118,6 +144,7 @@ packages/noodl-editor/src/editor/src/services/github/
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Files to Modify:**
|
**Files to Modify:**
|
||||||
|
|
||||||
```
|
```
|
||||||
packages/noodl-editor/src/editor/src/views/panels/VersionControlPanel/
|
packages/noodl-editor/src/editor/src/views/panels/VersionControlPanel/
|
||||||
components/GitProviderPopout/GitProviderPopout.tsx
|
components/GitProviderPopout/GitProviderPopout.tsx
|
||||||
@@ -129,6 +156,7 @@ packages/noodl-git/src/git.ts
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Success Criteria:**
|
**Success Criteria:**
|
||||||
|
|
||||||
- [ ] User can authenticate via GitHub OAuth from editor
|
- [ ] User can authenticate via GitHub OAuth from editor
|
||||||
- [ ] Token stored securely and persists across sessions
|
- [ ] Token stored securely and persists across sessions
|
||||||
- [ ] Token refresh works automatically
|
- [ ] Token refresh works automatically
|
||||||
@@ -142,6 +170,7 @@ packages/noodl-git/src/git.ts
|
|||||||
View GitHub issues for the connected repository.
|
View GitHub issues for the connected repository.
|
||||||
|
|
||||||
**Scope:**
|
**Scope:**
|
||||||
|
|
||||||
- New sidebar panel: GitHubPanel with tabbed interface
|
- New sidebar panel: GitHubPanel with tabbed interface
|
||||||
- Issues list with filtering (open/closed, labels, assignees)
|
- Issues list with filtering (open/closed, labels, assignees)
|
||||||
- Issue detail view (body, comments, 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
|
- Pagination for large issue lists
|
||||||
|
|
||||||
**Files to Create:**
|
**Files to Create:**
|
||||||
|
|
||||||
```
|
```
|
||||||
packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
|
packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
|
||||||
├── GitHubPanel.tsx
|
├── GitHubPanel.tsx
|
||||||
@@ -169,6 +199,7 @@ packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Files to Modify:**
|
**Files to Modify:**
|
||||||
|
|
||||||
```
|
```
|
||||||
packages/noodl-editor/src/editor/src/router.setup.ts
|
packages/noodl-editor/src/editor/src/router.setup.ts
|
||||||
- Register GitHubPanel with SidebarModel
|
- Register GitHubPanel with SidebarModel
|
||||||
@@ -178,10 +209,12 @@ packages/noodl-editor/src/editor/src/models/sidebar/sidebarmodel.tsx
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Dependencies:**
|
**Dependencies:**
|
||||||
|
|
||||||
- `react-markdown` for rendering issue bodies
|
- `react-markdown` for rendering issue bodies
|
||||||
- `@octokit/rest` (from GIT-004A)
|
- `@octokit/rest` (from GIT-004A)
|
||||||
|
|
||||||
**Success Criteria:**
|
**Success Criteria:**
|
||||||
|
|
||||||
- [ ] GitHub panel appears in sidebar when repo is connected
|
- [ ] GitHub panel appears in sidebar when repo is connected
|
||||||
- [ ] Issues list loads and displays correctly
|
- [ ] Issues list loads and displays correctly
|
||||||
- [ ] Filters work (open/closed, labels, assignees, search)
|
- [ ] 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.
|
View GitHub pull requests for the connected repository.
|
||||||
|
|
||||||
**Scope:**
|
**Scope:**
|
||||||
|
|
||||||
- PRs tab in GitHubPanel
|
- PRs tab in GitHubPanel
|
||||||
- PR list with status indicators (draft, review requested, approved, changes requested)
|
- PR list with status indicators (draft, review requested, approved, changes requested)
|
||||||
- PR detail view (description, commits, checks status)
|
- PR detail view (description, commits, checks status)
|
||||||
@@ -205,6 +239,7 @@ View GitHub pull requests for the connected repository.
|
|||||||
- Merge conflict indicators
|
- Merge conflict indicators
|
||||||
|
|
||||||
**Files to Create:**
|
**Files to Create:**
|
||||||
|
|
||||||
```
|
```
|
||||||
packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
|
packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
|
||||||
├── components/
|
├── components/
|
||||||
@@ -220,6 +255,7 @@ packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Success Criteria:**
|
**Success Criteria:**
|
||||||
|
|
||||||
- [ ] PRs tab displays all open PRs
|
- [ ] PRs tab displays all open PRs
|
||||||
- [ ] Status badges show draft/review/approved/changes requested
|
- [ ] Status badges show draft/review/approved/changes requested
|
||||||
- [ ] PR detail shows description, commits list, checks status
|
- [ ] 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.
|
Full CRUD operations for GitHub issues from within Nodegex.
|
||||||
|
|
||||||
**Scope:**
|
**Scope:**
|
||||||
|
|
||||||
- Create Issue dialog with title, body (markdown editor), labels, assignees
|
- Create Issue dialog with title, body (markdown editor), labels, assignees
|
||||||
- Edit existing issues (title, body, status)
|
- Edit existing issues (title, body, status)
|
||||||
- Add comments to issues
|
- 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/)
|
- Issue templates support (load from repo's .github/ISSUE_TEMPLATE/)
|
||||||
|
|
||||||
**Files to Create:**
|
**Files to Create:**
|
||||||
|
|
||||||
```
|
```
|
||||||
packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
|
packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
|
||||||
├── components/
|
├── components/
|
||||||
@@ -256,12 +294,14 @@ packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Files to Modify:**
|
**Files to Modify:**
|
||||||
|
|
||||||
```
|
```
|
||||||
packages/noodl-editor/src/editor/src/views/nodegrapheditor/
|
packages/noodl-editor/src/editor/src/views/nodegrapheditor/
|
||||||
- Add context menu items for component-linked issue creation
|
- Add context menu items for component-linked issue creation
|
||||||
```
|
```
|
||||||
|
|
||||||
**Success Criteria:**
|
**Success Criteria:**
|
||||||
|
|
||||||
- [ ] Create Issue dialog opens from panel header
|
- [ ] Create Issue dialog opens from panel header
|
||||||
- [ ] Can set title, body, labels, assignees
|
- [ ] Can set title, body, labels, assignees
|
||||||
- [ ] Markdown preview works in body editor
|
- [ ] 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.
|
**THE KILLER FEATURE**: Link GitHub issues to visual components for unprecedented traceability.
|
||||||
|
|
||||||
**Scope:**
|
**Scope:**
|
||||||
|
|
||||||
- Component metadata extension for issue links
|
- Component metadata extension for issue links
|
||||||
- "Link Issue" dialog from component context menu
|
- "Link Issue" dialog from component context menu
|
||||||
- "Create Issue from Component" with auto-populated context
|
- "Create Issue from Component" with auto-populated context
|
||||||
@@ -310,6 +351,7 @@ interface LinkedComponent {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Files to Create:**
|
**Files to Create:**
|
||||||
|
|
||||||
```
|
```
|
||||||
packages/noodl-editor/src/editor/src/services/github/
|
packages/noodl-editor/src/editor/src/services/github/
|
||||||
├── ComponentLinking.ts # Link management logic
|
├── ComponentLinking.ts # Link management logic
|
||||||
@@ -323,6 +365,7 @@ packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Files to Modify:**
|
**Files to Modify:**
|
||||||
|
|
||||||
```
|
```
|
||||||
packages/noodl-editor/src/editor/src/models/projectmodel.ts
|
packages/noodl-editor/src/editor/src/models/projectmodel.ts
|
||||||
- Add methods for component GitHub metadata
|
- Add methods for component GitHub metadata
|
||||||
@@ -338,17 +381,20 @@ packages/noodl-editor/src/editor/src/views/panels/PropertyPanel/
|
|||||||
**User Flows:**
|
**User Flows:**
|
||||||
|
|
||||||
1. **Link Existing Issue to Component:**
|
1. **Link Existing Issue to Component:**
|
||||||
|
|
||||||
- Right-click component → "Link to Issue"
|
- Right-click component → "Link to Issue"
|
||||||
- Search/select from open issues
|
- Search/select from open issues
|
||||||
- Choose link type (mentions, implements, fixes)
|
- Choose link type (mentions, implements, fixes)
|
||||||
- Component shows badge, issue shows component link
|
- Component shows badge, issue shows component link
|
||||||
|
|
||||||
2. **Create Issue from Component:**
|
2. **Create Issue from Component:**
|
||||||
|
|
||||||
- Right-click component → "Create Issue"
|
- Right-click component → "Create Issue"
|
||||||
- Dialog pre-fills: component name, path, screenshot option
|
- Dialog pre-fills: component name, path, screenshot option
|
||||||
- Creates issue and links automatically
|
- Creates issue and links automatically
|
||||||
|
|
||||||
3. **Navigate from Issue to Component:**
|
3. **Navigate from Issue to Component:**
|
||||||
|
|
||||||
- In issue detail, "Linked Components" section
|
- In issue detail, "Linked Components" section
|
||||||
- Click component name → navigates to component in editor
|
- 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"
|
- Auto-labels as "bug"
|
||||||
|
|
||||||
**Success Criteria:**
|
**Success Criteria:**
|
||||||
|
|
||||||
- [ ] Can link issues to components via context menu
|
- [ ] Can link issues to components via context menu
|
||||||
- [ ] Can create issue from component with pre-filled context
|
- [ ] Can create issue from component with pre-filled context
|
||||||
- [ ] Components show visual indicator when issues linked
|
- [ ] 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.
|
Project health overview and activity feed in the Dashboard.
|
||||||
|
|
||||||
**Scope:**
|
**Scope:**
|
||||||
|
|
||||||
- Project card enhancements: issue/PR counts, health indicators
|
- Project card enhancements: issue/PR counts, health indicators
|
||||||
- Activity feed widget: recent issue/PR activity across projects
|
- Activity feed widget: recent issue/PR activity across projects
|
||||||
- Notification badges for items needing attention
|
- 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
|
- Click-through to editor with correct panel open
|
||||||
|
|
||||||
**Files to Create:**
|
**Files to Create:**
|
||||||
|
|
||||||
```
|
```
|
||||||
packages/noodl-editor/src/editor/src/views/Dashboard/
|
packages/noodl-editor/src/editor/src/views/Dashboard/
|
||||||
├── components/
|
├── components/
|
||||||
@@ -393,6 +442,7 @@ packages/noodl-editor/src/editor/src/views/Dashboard/
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Files to Modify:**
|
**Files to Modify:**
|
||||||
|
|
||||||
```
|
```
|
||||||
packages/noodl-editor/src/editor/src/views/Dashboard/ProjectCard/
|
packages/noodl-editor/src/editor/src/views/Dashboard/ProjectCard/
|
||||||
- Add GitHub stats display
|
- Add GitHub stats display
|
||||||
@@ -405,6 +455,7 @@ packages/noodl-editor/src/editor/src/views/Dashboard/Dashboard.tsx
|
|||||||
**Dashboard Features:**
|
**Dashboard Features:**
|
||||||
|
|
||||||
1. **Project Card Stats:**
|
1. **Project Card Stats:**
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────┐
|
┌─────────────────────────┐
|
||||||
│ My Project │
|
│ My Project │
|
||||||
@@ -418,6 +469,7 @@ packages/noodl-editor/src/editor/src/views/Dashboard/Dashboard.tsx
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Activity Feed:**
|
2. **Activity Feed:**
|
||||||
|
|
||||||
- Shows recent activity across all connected projects
|
- Shows recent activity across all connected projects
|
||||||
- Filterable by type (issues, PRs, discussions)
|
- Filterable by type (issues, PRs, discussions)
|
||||||
- "Assigned to me" filter
|
- "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
|
- Attention triggers: assigned issues, review requests, mentions
|
||||||
|
|
||||||
**Success Criteria:**
|
**Success Criteria:**
|
||||||
|
|
||||||
- [ ] Project cards show GitHub stats when connected
|
- [ ] Project cards show GitHub stats when connected
|
||||||
- [ ] Activity feed displays recent cross-project activity
|
- [ ] Activity feed displays recent cross-project activity
|
||||||
- [ ] Notification badges appear for items needing attention
|
- [ ] Notification badges appear for items needing attention
|
||||||
@@ -466,7 +519,8 @@ function useIssues(repoOwner: string, repoName: string) {
|
|||||||
const client = useGitHubClient();
|
const client = useGitHubClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
client.issues.list({ owner: repoOwner, repo: repoName })
|
client.issues
|
||||||
|
.list({ owner: repoOwner, repo: repoName })
|
||||||
.then(setIssues)
|
.then(setIssues)
|
||||||
.catch(setError)
|
.catch(setError)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -543,6 +597,7 @@ GIT-004A: OAuth & Client Foundation
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Recommended sequence:**
|
**Recommended sequence:**
|
||||||
|
|
||||||
1. **GIT-004A** (Foundation) - Required first
|
1. **GIT-004A** (Foundation) - Required first
|
||||||
2. **GIT-004B** (Issues Read) - Core value, quick win
|
2. **GIT-004B** (Issues Read) - Core value, quick win
|
||||||
3. **GIT-004D** (Issues Write) - Enables productivity
|
3. **GIT-004D** (Issues Write) - Enables productivity
|
||||||
@@ -582,7 +637,7 @@ GIT-004A: OAuth & Client Foundation
|
|||||||
## Risks & Mitigations
|
## Risks & Mitigations
|
||||||
|
|
||||||
| Risk | Impact | Probability | Mitigation |
|
| Risk | Impact | Probability | Mitigation |
|
||||||
|------|--------|-------------|------------|
|
| ---------------------------- | ------ | ----------- | ------------------------------------------------ |
|
||||||
| GitHub API rate limits | Medium | Medium | Caching, conditional requests, user feedback |
|
| GitHub API rate limits | Medium | Medium | Caching, conditional requests, user feedback |
|
||||||
| OAuth complexity in Electron | High | Medium | Follow GitHub Desktop patterns, thorough testing |
|
| OAuth complexity in Electron | High | Medium | Follow GitHub Desktop patterns, thorough testing |
|
||||||
| Token security concerns | High | Low | Use OS keychain via electron-store encryption |
|
| Token security concerns | High | Low | Use OS keychain via electron-store encryption |
|
||||||
@@ -619,3 +674,124 @@ GIT-004A: OAuth & Client Foundation
|
|||||||
- [GitHub App Permissions](https://docs.github.com/en/rest/overview/permissions-required-for-github-apps)
|
- [GitHub App Permissions](https://docs.github.com/en/rest/overview/permissions-required-for-github-apps)
|
||||||
- Existing: `packages/noodl-git/` - Current Git integration
|
- Existing: `packages/noodl-git/` - Current Git integration
|
||||||
- Existing: `VersionControlPanel/` - Current VCS UI patterns
|
- 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`
|
||||||
|
|||||||
@@ -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_
|
||||||
@@ -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_
|
||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
CloudSyncType,
|
CloudSyncType,
|
||||||
LauncherProjectData
|
LauncherProjectData
|
||||||
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
|
} 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 { usePersistentTab } from '@noodl-core-ui/preview/launcher/Launcher/hooks/usePersistentTab';
|
||||||
import { GitHubUser, LauncherPageId, LauncherProvider } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
|
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 { LearningCenter } from '@noodl-core-ui/preview/launcher/Launcher/views/LearningCenter';
|
||||||
import { Projects } from '@noodl-core-ui/preview/launcher/Launcher/views/Projects';
|
import { Projects } from '@noodl-core-ui/preview/launcher/Launcher/views/Projects';
|
||||||
import { Templates } from '@noodl-core-ui/preview/launcher/Launcher/views/Templates';
|
import { Templates } from '@noodl-core-ui/preview/launcher/Launcher/views/Templates';
|
||||||
@@ -53,15 +55,24 @@ export interface LauncherProps {
|
|||||||
githubIsConnecting?: boolean;
|
githubIsConnecting?: boolean;
|
||||||
onGitHubConnect?: () => void;
|
onGitHubConnect?: () => void;
|
||||||
onGitHubDisconnect?: () => 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[] = [
|
const LAUNCHER_TABS: TabBarItem[] = [
|
||||||
{
|
{
|
||||||
id: 'projects',
|
id: 'projects',
|
||||||
label: 'Projects',
|
label: 'Projects',
|
||||||
icon: IconName.FolderOpen
|
icon: IconName.FolderOpen
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'github',
|
||||||
|
label: 'GitHub',
|
||||||
|
icon: IconName.CloudFunction
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'learn',
|
id: 'learn',
|
||||||
label: 'Learn',
|
label: 'Learn',
|
||||||
@@ -187,7 +198,9 @@ export function Launcher({
|
|||||||
githubIsAuthenticated,
|
githubIsAuthenticated,
|
||||||
githubIsConnecting,
|
githubIsConnecting,
|
||||||
onGitHubConnect,
|
onGitHubConnect,
|
||||||
onGitHubDisconnect
|
onGitHubDisconnect,
|
||||||
|
githubRepos,
|
||||||
|
onCloneRepo
|
||||||
}: LauncherProps) {
|
}: LauncherProps) {
|
||||||
// Determine initial tab: props > deep link > persisted > default
|
// Determine initial tab: props > deep link > persisted > default
|
||||||
const deepLinkTab = parseDeepLink();
|
const deepLinkTab = parseDeepLink();
|
||||||
@@ -263,6 +276,8 @@ export function Launcher({
|
|||||||
switch (activePageId) {
|
switch (activePageId) {
|
||||||
case 'projects':
|
case 'projects':
|
||||||
return <Projects />;
|
return <Projects />;
|
||||||
|
case 'github':
|
||||||
|
return <GitHubRepos />;
|
||||||
case 'learn':
|
case 'learn':
|
||||||
return <LearningCenter />;
|
return <LearningCenter />;
|
||||||
case 'templates':
|
case 'templates':
|
||||||
@@ -295,7 +310,9 @@ export function Launcher({
|
|||||||
githubIsAuthenticated,
|
githubIsAuthenticated,
|
||||||
githubIsConnecting,
|
githubIsConnecting,
|
||||||
onGitHubConnect,
|
onGitHubConnect,
|
||||||
onGitHubDisconnect
|
onGitHubDisconnect,
|
||||||
|
githubRepos,
|
||||||
|
onCloneRepo
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={css['Root']}>
|
<div className={css['Root']}>
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
import React, { createContext, useContext, ReactNode } from 'react';
|
import React, { createContext, useContext, ReactNode } from 'react';
|
||||||
|
|
||||||
import { LauncherProjectData } from './components/LauncherProjectCard';
|
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)
|
// GitHub user info (matches GitHubOAuthService interface)
|
||||||
export interface GitHubUser {
|
export interface GitHubUser {
|
||||||
@@ -52,6 +53,10 @@ export interface LauncherContextValue {
|
|||||||
githubIsConnecting?: boolean;
|
githubIsConnecting?: boolean;
|
||||||
onGitHubConnect?: () => void;
|
onGitHubConnect?: () => void;
|
||||||
onGitHubDisconnect?: () => 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);
|
const LauncherContext = createContext<LauncherContextValue | null>(null);
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* useGitHubRepos Hook
|
||||||
|
*
|
||||||
|
* Fetches and manages GitHub repositories for the authenticated user,
|
||||||
|
* including personal repos and organization repos.
|
||||||
|
* Detects Noodl projects by checking for project.json or nodegx.project.json.
|
||||||
|
*
|
||||||
|
* @module noodl-core-ui/preview/launcher/Launcher/hooks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
// ==================== LOCAL TYPE DEFINITIONS ====================
|
||||||
|
// These mirror the GitHub types but are defined locally to avoid circular dependencies
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub User/Owner
|
||||||
|
*/
|
||||||
|
export interface GitHubOwner {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
avatar_url: string;
|
||||||
|
html_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub Repository (minimal fields needed for clone UI)
|
||||||
|
*/
|
||||||
|
export interface GitHubRepo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
full_name: string;
|
||||||
|
owner: GitHubOwner;
|
||||||
|
private: boolean;
|
||||||
|
html_url: string;
|
||||||
|
description: string | null;
|
||||||
|
clone_url?: string;
|
||||||
|
ssh_url?: string;
|
||||||
|
updated_at: string;
|
||||||
|
pushed_at: string;
|
||||||
|
default_branch: string;
|
||||||
|
stargazers_count: number;
|
||||||
|
language: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub Organization
|
||||||
|
*/
|
||||||
|
export interface GitHubOrg {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
avatar_url: string;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== HOOK TYPES ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended repo info with Noodl detection status
|
||||||
|
*/
|
||||||
|
export interface NoodlGitHubRepo extends GitHubRepo {
|
||||||
|
/** Whether this repo is a Noodl project */
|
||||||
|
isNoodlProject: boolean | null; // null = not yet checked
|
||||||
|
/** Whether Noodl detection is in progress */
|
||||||
|
isCheckingNoodl: boolean;
|
||||||
|
/** Source: 'personal' or org name */
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organization with its repos
|
||||||
|
*/
|
||||||
|
export interface GitHubOrgWithRepos extends GitHubOrg {
|
||||||
|
repos: NoodlGitHubRepo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook return type
|
||||||
|
*/
|
||||||
|
export interface UseGitHubReposReturn {
|
||||||
|
/** All Noodl project repos (filtered) */
|
||||||
|
noodlProjects: NoodlGitHubRepo[];
|
||||||
|
/** All repos (unfiltered) */
|
||||||
|
allRepos: NoodlGitHubRepo[];
|
||||||
|
/** User's organizations */
|
||||||
|
organizations: GitHubOrgWithRepos[];
|
||||||
|
/** Personal repos */
|
||||||
|
personalRepos: NoodlGitHubRepo[];
|
||||||
|
/** Loading state */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Error message */
|
||||||
|
error: string | null;
|
||||||
|
/** Refresh all data */
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
/** Check if a specific repo is a Noodl project */
|
||||||
|
checkIfNoodlProject: (owner: string, repo: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub API client interface (injected to avoid circular dependencies)
|
||||||
|
*/
|
||||||
|
export interface GitHubClientInterface {
|
||||||
|
listRepositories: (options?: { per_page?: number; sort?: string }) => Promise<{ data: GitHubRepo[] }>;
|
||||||
|
listOrganizations: () => Promise<{ data: GitHubOrg[] }>;
|
||||||
|
listOrganizationRepositories: (org: string, options?: { per_page?: number }) => Promise<{ data: GitHubRepo[] }>;
|
||||||
|
isNoodlProject: (owner: string, repo: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch GitHub repositories and detect Noodl projects
|
||||||
|
*
|
||||||
|
* @param client - GitHub client instance
|
||||||
|
* @param isAuthenticated - Whether user is authenticated with GitHub
|
||||||
|
*/
|
||||||
|
export function useGitHubRepos(client: GitHubClientInterface | null, isAuthenticated: boolean): UseGitHubReposReturn {
|
||||||
|
const [personalRepos, setPersonalRepos] = useState<NoodlGitHubRepo[]>([]);
|
||||||
|
const [organizations, setOrganizations] = useState<GitHubOrgWithRepos[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Track ongoing Noodl checks to avoid duplicates
|
||||||
|
const noodlCheckQueue = useRef<Map<string, Promise<boolean>>>(new Map());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a repo is a Noodl project (with deduplication)
|
||||||
|
*/
|
||||||
|
const checkIfNoodlProject = useCallback(
|
||||||
|
async (owner: string, repo: string): Promise<boolean> => {
|
||||||
|
if (!client) return false;
|
||||||
|
|
||||||
|
const key = `${owner}/${repo}`;
|
||||||
|
|
||||||
|
// Return existing promise if already checking
|
||||||
|
if (noodlCheckQueue.current.has(key)) {
|
||||||
|
return noodlCheckQueue.current.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new check
|
||||||
|
const checkPromise = client.isNoodlProject(owner, repo);
|
||||||
|
noodlCheckQueue.current.set(key, checkPromise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await checkPromise;
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
noodlCheckQueue.current.delete(key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map GitHubRepo to NoodlGitHubRepo
|
||||||
|
*/
|
||||||
|
const mapRepo = useCallback((repo: GitHubRepo, source: string): NoodlGitHubRepo => {
|
||||||
|
return {
|
||||||
|
...repo,
|
||||||
|
isNoodlProject: null,
|
||||||
|
isCheckingNoodl: false,
|
||||||
|
source
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Noodl status for a batch of repos (rate-limit friendly)
|
||||||
|
*/
|
||||||
|
const checkNoodlStatusForRepos = useCallback(
|
||||||
|
async (repos: NoodlGitHubRepo[], updateFn: (repoId: number, isNoodl: boolean) => void) => {
|
||||||
|
console.log('🔍 [checkNoodlStatusForRepos] Starting check for', repos.length, 'repos');
|
||||||
|
|
||||||
|
// Check repos sequentially to avoid rate limits
|
||||||
|
for (const repo of repos) {
|
||||||
|
if (repo.isNoodlProject !== null) continue; // Already checked
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isNoodl = await checkIfNoodlProject(repo.owner.login, repo.name);
|
||||||
|
|
||||||
|
console.log('🔍 [checkNoodlStatusForRepos]', repo.full_name, '- isNoodl:', isNoodl);
|
||||||
|
|
||||||
|
updateFn(repo.id, isNoodl);
|
||||||
|
|
||||||
|
// Small delay between checks to be rate-limit friendly
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
} catch (err) {
|
||||||
|
// On error, mark as not a Noodl project
|
||||||
|
console.error('❌ [checkNoodlStatusForRepos] Error checking', repo.full_name, err);
|
||||||
|
updateFn(repo.id, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ [checkNoodlStatusForRepos] Finished checking repos');
|
||||||
|
},
|
||||||
|
[checkIfNoodlProject]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all repos (personal + org)
|
||||||
|
*/
|
||||||
|
const fetchRepos = useCallback(async () => {
|
||||||
|
console.log('🔍 [useGitHubRepos] fetchRepos called', {
|
||||||
|
hasClient: !!client,
|
||||||
|
isAuthenticated
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!client || !isAuthenticated) {
|
||||||
|
console.log('🔍 [useGitHubRepos] Skipping fetch - not authenticated or no client');
|
||||||
|
setPersonalRepos([]);
|
||||||
|
setOrganizations([]);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔍 [useGitHubRepos] Fetching personal repos and orgs...');
|
||||||
|
|
||||||
|
// Fetch personal repos and orgs in parallel
|
||||||
|
const [reposResponse, orgsResponse] = await Promise.all([
|
||||||
|
client.listRepositories({ per_page: 100, sort: 'updated' }),
|
||||||
|
client.listOrganizations()
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('🔍 [useGitHubRepos] API responses:', {
|
||||||
|
personalRepoCount: reposResponse?.data?.length || 0,
|
||||||
|
orgCount: orgsResponse?.data?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map personal repos
|
||||||
|
const mappedPersonalRepos = reposResponse.data.map((r) => mapRepo(r, 'personal'));
|
||||||
|
setPersonalRepos(mappedPersonalRepos);
|
||||||
|
|
||||||
|
// Initialize orgs (repos will be loaded on-demand or in parallel)
|
||||||
|
const mappedOrgs: GitHubOrgWithRepos[] = orgsResponse.data.map((org) => ({
|
||||||
|
...org,
|
||||||
|
repos: [],
|
||||||
|
isLoading: true,
|
||||||
|
error: null
|
||||||
|
}));
|
||||||
|
setOrganizations(mappedOrgs);
|
||||||
|
|
||||||
|
// Fetch org repos in parallel
|
||||||
|
const orgRepoPromises = orgsResponse.data.map(async (org) => {
|
||||||
|
try {
|
||||||
|
const orgRepos = await client.listOrganizationRepositories(org.login, { per_page: 100 });
|
||||||
|
return {
|
||||||
|
orgLogin: org.login,
|
||||||
|
repos: orgRepos.data.map((r) => mapRepo(r, org.login)),
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
orgLogin: org.login,
|
||||||
|
repos: [],
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to load repos'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const orgResults = await Promise.all(orgRepoPromises);
|
||||||
|
|
||||||
|
// Update orgs with their repos
|
||||||
|
setOrganizations((prev) =>
|
||||||
|
prev.map((org) => {
|
||||||
|
const result = orgResults.find((r) => r.orgLogin === org.login);
|
||||||
|
if (result) {
|
||||||
|
return {
|
||||||
|
...org,
|
||||||
|
repos: result.repos,
|
||||||
|
isLoading: false,
|
||||||
|
error: result.error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...org, isLoading: false };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start checking Noodl status for personal repos
|
||||||
|
const updatePersonalRepo = (repoId: number, isNoodl: boolean) => {
|
||||||
|
setPersonalRepos((prev) =>
|
||||||
|
prev.map((r) => (r.id === repoId ? { ...r, isNoodlProject: isNoodl, isCheckingNoodl: false } : r))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check personal repos
|
||||||
|
checkNoodlStatusForRepos(mappedPersonalRepos, updatePersonalRepo);
|
||||||
|
|
||||||
|
// Check org repos
|
||||||
|
for (const result of orgResults) {
|
||||||
|
const updateOrgRepo = (repoId: number, isNoodl: boolean) => {
|
||||||
|
setOrganizations((prev) =>
|
||||||
|
prev.map((org) => {
|
||||||
|
if (org.login === result.orgLogin) {
|
||||||
|
return {
|
||||||
|
...org,
|
||||||
|
repos: org.repos.map((r) =>
|
||||||
|
r.id === repoId ? { ...r, isNoodlProject: isNoodl, isCheckingNoodl: false } : r
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return org;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
checkNoodlStatusForRepos(result.repos, updateOrgRepo);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch repositories');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [client, isAuthenticated, mapRepo, checkNoodlStatusForRepos]);
|
||||||
|
|
||||||
|
// Fetch on mount and when auth changes
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRepos();
|
||||||
|
}, [fetchRepos]);
|
||||||
|
|
||||||
|
// Compute derived values
|
||||||
|
const allRepos: NoodlGitHubRepo[] = [...personalRepos, ...organizations.flatMap((org) => org.repos)];
|
||||||
|
|
||||||
|
const noodlProjects = allRepos.filter((repo) => repo.isNoodlProject === true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
noodlProjects,
|
||||||
|
allRepos,
|
||||||
|
organizations,
|
||||||
|
personalRepos,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchRepos,
|
||||||
|
checkIfNoodlProject
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* GitHubRepos View
|
||||||
|
*
|
||||||
|
* Browse and clone Noodl projects from GitHub repositories.
|
||||||
|
* Only shows repos that contain project.json or nodegx.project.json.
|
||||||
|
*
|
||||||
|
* @module noodl-core-ui/preview/launcher/Launcher/views
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
|
||||||
|
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||||
|
import { TextInput, TextInputVariant } from '@noodl-core-ui/components/inputs/TextInput';
|
||||||
|
import { Box } from '@noodl-core-ui/components/layout/Box';
|
||||||
|
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||||
|
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
|
||||||
|
import { LauncherPage } from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherPage';
|
||||||
|
import { NoodlGitHubRepo, GitHubOrgWithRepos } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useGitHubRepos';
|
||||||
|
import { useLauncherContext } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub repo card for display in clone list
|
||||||
|
*/
|
||||||
|
interface GitHubRepoCardProps {
|
||||||
|
repo: NoodlGitHubRepo;
|
||||||
|
onClone: (repo: NoodlGitHubRepo) => void;
|
||||||
|
isCloning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GitHubRepoCard({ repo, onClone, isCloning }: GitHubRepoCardProps) {
|
||||||
|
const updatedAt = new Date(repo.updated_at).toLocaleDateString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--spacing-4)',
|
||||||
|
padding: 'var(--spacing-3) var(--spacing-4)',
|
||||||
|
backgroundColor: 'var(--theme-color-bg-3)',
|
||||||
|
borderRadius: 'var(--radius-default)',
|
||||||
|
border: '1px solid var(--theme-color-border-default)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<img
|
||||||
|
src={repo.owner.avatar_url}
|
||||||
|
alt={repo.owner.login}
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 'var(--radius-default)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ fontWeight: 600, color: 'var(--theme-color-fg-default)' }}>{repo.name}</span>
|
||||||
|
{repo.private && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
backgroundColor: 'var(--theme-color-secondary-highlight)',
|
||||||
|
borderRadius: 'var(--radius-small)',
|
||||||
|
color: 'var(--theme-color-fg-default)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Private
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{repo.isNoodlProject && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
backgroundColor: 'var(--theme-color-primary)',
|
||||||
|
borderRadius: 'var(--radius-small)',
|
||||||
|
color: 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Noodl Project
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--theme-color-fg-default-shy)' }}>
|
||||||
|
{repo.full_name} • Updated {updatedAt}
|
||||||
|
</div>
|
||||||
|
{repo.description && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--theme-color-fg-default-shy)',
|
||||||
|
marginTop: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{repo.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clone button */}
|
||||||
|
<PrimaryButton
|
||||||
|
label={isCloning ? 'Cloning...' : 'Clone'}
|
||||||
|
size={PrimaryButtonSize.Small}
|
||||||
|
variant={PrimaryButtonVariant.Muted}
|
||||||
|
isDisabled={isCloning || repo.isCheckingNoodl}
|
||||||
|
onClick={() => onClone(repo)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Section header for organization
|
||||||
|
*/
|
||||||
|
function OrgSection({ org, children }: { org: GitHubOrgWithRepos; children: React.ReactNode }) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
|
const noodlProjectCount = org.repos.filter((r) => r.isNoodlProject === true).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box hasBottomSpacing={4}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--theme-color-fg-default)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={org.avatar_url} alt={org.login} style={{ width: 24, height: 24, borderRadius: '4px' }} />
|
||||||
|
<span style={{ fontWeight: 600, flex: 1, textAlign: 'left' }}>{org.login}</span>
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--theme-color-fg-default-shy)' }}>
|
||||||
|
{noodlProjectCount} {noodlProjectCount === 1 ? 'project' : 'projects'}
|
||||||
|
</span>
|
||||||
|
<Icon icon={isExpanded ? IconName.CaretDown : IconName.CaretRight} />
|
||||||
|
</button>
|
||||||
|
{isExpanded && <div style={{ paddingLeft: '16px' }}>{children}</div>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state when no Noodl projects found
|
||||||
|
*/
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '64px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '48px' }}>
|
||||||
|
<Icon icon={IconName.FolderOpen} />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ fontSize: '18px', fontWeight: 600, color: 'var(--theme-color-fg-default)', marginTop: '16px' }}>
|
||||||
|
No Noodl Projects Found
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: 'var(--theme-color-fg-default-shy)',
|
||||||
|
marginTop: '8px',
|
||||||
|
maxWidth: '400px',
|
||||||
|
lineHeight: 1.5
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No repositories with project.json were found in your GitHub account. Noodl projects contain a project.json file
|
||||||
|
in the root directory.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect GitHub prompt when not authenticated
|
||||||
|
*/
|
||||||
|
function ConnectGitHubPrompt({ onConnect }: { onConnect: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '64px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '48px' }}>
|
||||||
|
<Icon icon={IconName.CloudFunction} />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ fontSize: '18px', fontWeight: 600, color: 'var(--theme-color-fg-default)', marginTop: '16px' }}>
|
||||||
|
Connect GitHub
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: 'var(--theme-color-fg-default-shy)',
|
||||||
|
marginTop: '8px',
|
||||||
|
maxWidth: '400px',
|
||||||
|
lineHeight: 1.5
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connect your GitHub account to browse and clone your Noodl projects.
|
||||||
|
</p>
|
||||||
|
<Box hasTopSpacing={4}>
|
||||||
|
<PrimaryButton label="Connect GitHub" onClick={onConnect} />
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading spinner
|
||||||
|
*/
|
||||||
|
function LoadingState() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '64px',
|
||||||
|
gap: '12px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
border: '2px solid var(--theme-color-border-default)',
|
||||||
|
borderTopColor: 'var(--theme-color-primary)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: 'var(--theme-color-fg-default-shy)' }}>Loading repositories...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitHubReposProps {}
|
||||||
|
|
||||||
|
export function GitHubRepos({}: GitHubReposProps) {
|
||||||
|
const { githubIsAuthenticated, onGitHubConnect, githubRepos, onCloneRepo } = useLauncherContext();
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [cloningRepoId, setCloningRepoId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Get repos from context
|
||||||
|
const { noodlProjects, allRepos, organizations, isLoading, error, refresh } = githubRepos || {
|
||||||
|
noodlProjects: [],
|
||||||
|
allRepos: [],
|
||||||
|
organizations: [],
|
||||||
|
personalRepos: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: async () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count how many repos are still being checked
|
||||||
|
const checkingCount = allRepos.filter((r) => r.isNoodlProject === null).length;
|
||||||
|
const isChecking = checkingCount > 0;
|
||||||
|
|
||||||
|
// Only show Noodl projects
|
||||||
|
const reposToShow = noodlProjects;
|
||||||
|
|
||||||
|
// Filter by search term
|
||||||
|
const filteredRepos = useMemo(() => {
|
||||||
|
if (!searchTerm) return reposToShow;
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return reposToShow.filter(
|
||||||
|
(repo) =>
|
||||||
|
repo.name.toLowerCase().includes(term) ||
|
||||||
|
repo.full_name.toLowerCase().includes(term) ||
|
||||||
|
repo.description?.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}, [reposToShow, searchTerm]);
|
||||||
|
|
||||||
|
// Group by source (personal vs org)
|
||||||
|
const personalFilteredRepos = filteredRepos.filter((r) => r.source === 'personal');
|
||||||
|
const orgFilteredRepos = filteredRepos.filter((r) => r.source !== 'personal');
|
||||||
|
|
||||||
|
// Group org projects by org
|
||||||
|
const projectsByOrg = useMemo(() => {
|
||||||
|
const grouped = new Map<string, NoodlGitHubRepo[]>();
|
||||||
|
for (const repo of orgFilteredRepos) {
|
||||||
|
const existing = grouped.get(repo.source) || [];
|
||||||
|
grouped.set(repo.source, [...existing, repo]);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}, [orgFilteredRepos]);
|
||||||
|
|
||||||
|
const handleClone = async (repo: NoodlGitHubRepo) => {
|
||||||
|
if (!onCloneRepo) return;
|
||||||
|
|
||||||
|
setCloningRepoId(repo.id);
|
||||||
|
try {
|
||||||
|
await onCloneRepo(repo);
|
||||||
|
} finally {
|
||||||
|
setCloningRepoId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Not authenticated
|
||||||
|
if (!githubIsAuthenticated) {
|
||||||
|
return (
|
||||||
|
<LauncherPage title="GitHub">
|
||||||
|
<ConnectGitHubPrompt onConnect={() => onGitHubConnect?.()} />
|
||||||
|
</LauncherPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LauncherPage
|
||||||
|
title="GitHub Projects"
|
||||||
|
headerSlot={
|
||||||
|
<HStack hasSpacing>
|
||||||
|
<PrimaryButton
|
||||||
|
label="Refresh"
|
||||||
|
size={PrimaryButtonSize.Small}
|
||||||
|
variant={PrimaryButtonVariant.Muted}
|
||||||
|
onClick={refresh}
|
||||||
|
isDisabled={isLoading}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Search bar */}
|
||||||
|
<Box hasBottomSpacing={4}>
|
||||||
|
<TextInput
|
||||||
|
variant={TextInputVariant.InModal}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Search Noodl projects..."
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<Box hasBottomSpacing={4}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: 'var(--theme-color-danger-10)',
|
||||||
|
borderRadius: 'var(--radius-default)',
|
||||||
|
color: 'var(--theme-color-danger)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && <LoadingState />}
|
||||||
|
|
||||||
|
{/* Checking progress indicator */}
|
||||||
|
{!isLoading && isChecking && (
|
||||||
|
<Box hasBottomSpacing={4}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: 'var(--theme-color-bg-2)',
|
||||||
|
borderRadius: 'var(--radius-default)',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--theme-color-fg-default-shy)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
border: '2px solid var(--theme-color-border-default)',
|
||||||
|
borderTopColor: 'var(--theme-color-primary)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Scanning {checkingCount} repositories for project.json...
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{!isLoading && !isChecking && filteredRepos.length === 0 && <EmptyState />}
|
||||||
|
|
||||||
|
{!isLoading && filteredRepos.length > 0 && (
|
||||||
|
<VStack hasSpacing>
|
||||||
|
{/* Personal repos */}
|
||||||
|
{personalFilteredRepos.length > 0 && (
|
||||||
|
<Box hasBottomSpacing={4}>
|
||||||
|
<Label size={LabelSize.Default}>Personal Repositories ({personalFilteredRepos.length})</Label>
|
||||||
|
<div style={{ marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{personalFilteredRepos.map((repo) => (
|
||||||
|
<GitHubRepoCard
|
||||||
|
key={repo.id}
|
||||||
|
repo={repo}
|
||||||
|
onClone={handleClone}
|
||||||
|
isCloning={cloningRepoId === repo.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Organization repos */}
|
||||||
|
{Array.from(projectsByOrg.entries()).map(([orgLogin, repos]) => {
|
||||||
|
const org = organizations.find((o) => o.login === orgLogin);
|
||||||
|
if (!org) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OrgSection key={orgLogin} org={org}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{repos.map((repo) => (
|
||||||
|
<GitHubRepoCard
|
||||||
|
key={repo.id}
|
||||||
|
repo={repo}
|
||||||
|
onClone={handleClone}
|
||||||
|
isCloning={cloningRepoId === repo.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</OrgSection>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</LauncherPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ipcRenderer, shell } from 'electron';
|
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 { filesystem } from '@noodl/platform';
|
||||||
|
|
||||||
import { CreateProjectModal } from '@noodl-core-ui/preview/launcher/Launcher/components/CreateProjectModal';
|
import { CreateProjectModal } from '@noodl-core-ui/preview/launcher/Launcher/components/CreateProjectModal';
|
||||||
@@ -14,12 +15,18 @@ import {
|
|||||||
CloudSyncType,
|
CloudSyncType,
|
||||||
LauncherProjectData
|
LauncherProjectData
|
||||||
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
|
} 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 { Launcher } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
|
||||||
|
|
||||||
import { useEventListener } from '../../hooks/useEventListener';
|
import { useEventListener } from '../../hooks/useEventListener';
|
||||||
import { DialogLayerModel } from '../../models/DialogLayerModel';
|
import { DialogLayerModel } from '../../models/DialogLayerModel';
|
||||||
import { detectRuntimeVersion } from '../../models/migration/ProjectScanner';
|
import { detectRuntimeVersion } from '../../models/migration/ProjectScanner';
|
||||||
import { IRouteProps } from '../../pages/AppRoute';
|
import { IRouteProps } from '../../pages/AppRoute';
|
||||||
|
import { GitHubOAuthService, GitHubClient } from '../../services/github';
|
||||||
import { ProjectOrganizationService } from '../../services/ProjectOrganizationService';
|
import { ProjectOrganizationService } from '../../services/ProjectOrganizationService';
|
||||||
import { LocalProjectsModel, ProjectItemWithRuntime } from '../../utils/LocalProjectsModel';
|
import { LocalProjectsModel, ProjectItemWithRuntime } from '../../utils/LocalProjectsModel';
|
||||||
import { tracker } from '../../utils/tracker';
|
import { tracker } from '../../utils/tracker';
|
||||||
@@ -56,6 +63,332 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
|||||||
// Create project modal state
|
// Create project modal state
|
||||||
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
|
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
|
// Initialize and fetch projects on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Switch main window size to editor size
|
// Switch main window size to editor size
|
||||||
@@ -590,11 +923,13 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
|||||||
onMigrateProject={handleMigrateProject}
|
onMigrateProject={handleMigrateProject}
|
||||||
onOpenReadOnly={handleOpenReadOnly}
|
onOpenReadOnly={handleOpenReadOnly}
|
||||||
projectOrganizationService={ProjectOrganizationService.instance}
|
projectOrganizationService={ProjectOrganizationService.instance}
|
||||||
githubUser={null}
|
githubUser={githubUser}
|
||||||
githubIsAuthenticated={false}
|
githubIsAuthenticated={githubIsAuthenticated}
|
||||||
githubIsConnecting={false}
|
githubIsConnecting={githubIsConnecting}
|
||||||
onGitHubConnect={() => {}}
|
onGitHubConnect={handleGitHubConnect}
|
||||||
onGitHubDisconnect={() => {}}
|
onGitHubDisconnect={handleGitHubDisconnect}
|
||||||
|
githubRepos={githubRepos}
|
||||||
|
onCloneRepo={handleCloneRepo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateProjectModal
|
<CreateProjectModal
|
||||||
|
|||||||
@@ -1,48 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* GitHubOAuthService
|
* GitHubOAuthService
|
||||||
*
|
*
|
||||||
* Manages GitHub OAuth authentication using PKCE flow.
|
* Manages GitHub OAuth authentication via IPC with the main process.
|
||||||
* Provides token management and user information retrieval.
|
* 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
|
* @module noodl-editor/services
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import crypto from 'crypto';
|
import { shell, ipcRenderer } from 'electron';
|
||||||
import { shell } from 'electron';
|
|
||||||
|
|
||||||
import { EventDispatcher } from '../../../shared/utils/EventDispatcher';
|
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 {
|
export interface GitHubUser {
|
||||||
id: number;
|
id: number;
|
||||||
login: string;
|
login: string;
|
||||||
@@ -65,23 +34,41 @@ interface GitHubToken {
|
|||||||
scope: string;
|
scope: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PKCEChallenge {
|
interface OAuthCompleteResult {
|
||||||
verifier: string;
|
token: GitHubToken;
|
||||||
challenge: string;
|
user: GitHubUser;
|
||||||
state: string;
|
installations: unknown[];
|
||||||
|
authMethod: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OAuthErrorResult {
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for managing GitHub OAuth authentication
|
* 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 {
|
export class GitHubOAuthService extends EventDispatcher {
|
||||||
private static _instance: GitHubOAuthService;
|
private static _instance: GitHubOAuthService;
|
||||||
private currentUser: GitHubUser | null = null;
|
private currentUser: GitHubUser | null = null;
|
||||||
private accessToken: string | null = null;
|
private accessToken: string | null = null;
|
||||||
private pendingPKCE: PKCEChallenge | null = null;
|
private isAuthenticating: boolean = false;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
super();
|
super();
|
||||||
|
console.log('🔧 [GitHubOAuthService] Constructor called - setting up IPC listeners');
|
||||||
|
this.setupIPCListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
static get instance(): GitHubOAuthService {
|
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 {
|
private setupIPCListeners(): void {
|
||||||
// Generate code verifier (random string)
|
console.log('🔌 [GitHubOAuthService] Setting up IPC listeners for github-oauth-complete and github-oauth-error');
|
||||||
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
||||||
|
|
||||||
// Generate code challenge (SHA256 hash of verifier)
|
// Listen for successful OAuth completion
|
||||||
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
ipcRenderer.on('github-oauth-complete', (_event, result: OAuthCompleteResult) => {
|
||||||
|
console.log('✅ [GitHubOAuthService] IPC RECEIVED: github-oauth-complete');
|
||||||
// Generate state for CSRF protection
|
console.log('✅ [GitHubOAuthService] Result:', result);
|
||||||
const state = crypto.randomBytes(16).toString('hex');
|
this.handleOAuthComplete(result);
|
||||||
|
|
||||||
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'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
console.log('✅ [GitHubOAuthService] IPC listeners registered');
|
||||||
|
|
||||||
// Open in system browser
|
|
||||||
await shell.openExternal(authUrl);
|
|
||||||
|
|
||||||
// Notify listeners that OAuth flow started
|
|
||||||
this.notifyListeners('oauth-started');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle OAuth callback with authorization code
|
* Handle successful OAuth completion from main process
|
||||||
*/
|
*/
|
||||||
async handleCallback(code: string, state: string): Promise<void> {
|
private async handleOAuthComplete(result: OAuthCompleteResult): Promise<void> {
|
||||||
console.log('🔄 Handling OAuth callback');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify state to prevent CSRF
|
console.log('🔄 [GitHub OAuth] Processing OAuth result for user:', result.user.login);
|
||||||
if (!this.pendingPKCE || state !== this.pendingPKCE.state) {
|
|
||||||
throw new Error('Invalid OAuth state - possible CSRF attack');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exchange code for token
|
// Store the token
|
||||||
const token = await this.exchangeCodeForToken(code, this.pendingPKCE.verifier);
|
this.accessToken = result.token.access_token;
|
||||||
|
this.currentUser = result.user;
|
||||||
// Store token
|
|
||||||
this.accessToken = token.access_token;
|
|
||||||
|
|
||||||
// Clear pending PKCE
|
|
||||||
this.pendingPKCE = null;
|
|
||||||
|
|
||||||
// Fetch user information
|
|
||||||
await this.fetchCurrentUser();
|
|
||||||
|
|
||||||
// Persist token securely
|
// 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
|
// Notify listeners
|
||||||
|
this.isAuthenticating = false;
|
||||||
this.notifyListeners('oauth-success', { user: this.currentUser });
|
this.notifyListeners('oauth-success', { user: this.currentUser });
|
||||||
this.notifyListeners('auth-state-changed', { authenticated: true });
|
this.notifyListeners('auth-state-changed', { authenticated: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ OAuth callback error:', error);
|
console.error('❌ [GitHub OAuth] Failed to process OAuth result:', error);
|
||||||
this.pendingPKCE = null;
|
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 });
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange authorization code for access token
|
* Cancel any pending OAuth flow
|
||||||
*/
|
*/
|
||||||
private async exchangeCodeForToken(code: string, verifier: string): Promise<GitHubToken> {
|
async cancelOAuth(): Promise<void> {
|
||||||
console.log('🔄 Exchanging code for access token');
|
if (this.isAuthenticating) {
|
||||||
|
console.log('🚫 [GitHub OAuth] Cancelling OAuth flow');
|
||||||
// Exchange authorization code for access token using PKCE + client secret
|
await ipcRenderer.invoke('github-oauth-stop');
|
||||||
const response = await fetch('https://github.com/login/oauth/access_token', {
|
this.isAuthenticating = false;
|
||||||
method: 'POST',
|
this.notifyListeners('oauth-cancelled');
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
return this.accessToken !== null && this.currentUser !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if OAuth flow is in progress
|
||||||
|
*/
|
||||||
|
isOAuthInProgress(): boolean {
|
||||||
|
return this.isAuthenticating;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revoke token and disconnect
|
* Revoke token and disconnect
|
||||||
*/
|
*/
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
console.log('🔌 Disconnecting GitHub account');
|
console.log('🔌 [GitHub OAuth] Disconnecting GitHub account');
|
||||||
|
|
||||||
this.accessToken = null;
|
this.accessToken = null;
|
||||||
this.currentUser = 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> {
|
private async saveToken(token: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { ipcRenderer } = window.require('electron');
|
|
||||||
await ipcRenderer.invoke('github-save-token', token);
|
await ipcRenderer.invoke('github-save-token', token);
|
||||||
|
console.log('💾 [GitHub OAuth] Token saved');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save token:', error);
|
console.error('❌ [GitHub OAuth] Failed to save token:', error);
|
||||||
// Fallback: keep in memory only
|
// Token is still in memory, just not persisted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,32 +283,56 @@ export class GitHubOAuthService extends EventDispatcher {
|
|||||||
*/
|
*/
|
||||||
private async loadToken(): Promise<void> {
|
private async loadToken(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { ipcRenderer } = window.require('electron');
|
|
||||||
const token = await ipcRenderer.invoke('github-load-token');
|
const token = await ipcRenderer.invoke('github-load-token');
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
|
console.log('🔑 [GitHub OAuth] Token loaded from storage, verifying...');
|
||||||
this.accessToken = token;
|
this.accessToken = token;
|
||||||
|
|
||||||
// Fetch user info to verify token is still valid
|
// Fetch user info to verify token is still valid
|
||||||
await this.fetchCurrentUser();
|
await this.fetchCurrentUser();
|
||||||
this.notifyListeners('auth-state-changed', { authenticated: true });
|
this.notifyListeners('auth-state-changed', { authenticated: true });
|
||||||
|
console.log('✅ [GitHub OAuth] Token verified, user:', this.currentUser?.login);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
// Token may be invalid, clear it
|
||||||
this.accessToken = null;
|
this.accessToken = null;
|
||||||
this.currentUser = 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
|
* Clear stored token
|
||||||
*/
|
*/
|
||||||
private async clearToken(): Promise<void> {
|
private async clearToken(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { ipcRenderer } = window.require('electron');
|
|
||||||
await ipcRenderer.invoke('github-clear-token');
|
await ipcRenderer.invoke('github-clear-token');
|
||||||
} catch (error) {
|
} 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
|
* Initialize service and restore session if available
|
||||||
*/
|
*/
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
console.log('🔧 Initializing GitHubOAuthService');
|
console.log('🔧 [GitHub OAuth] Initializing GitHubOAuthService');
|
||||||
await this.loadToken();
|
await this.loadToken();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
GitHubIssue,
|
GitHubIssue,
|
||||||
GitHubPullRequest,
|
GitHubPullRequest,
|
||||||
GitHubRepository,
|
GitHubRepository,
|
||||||
|
GitHubOrganization,
|
||||||
GitHubComment,
|
GitHubComment,
|
||||||
GitHubCommit,
|
GitHubCommit,
|
||||||
GitHubLabel,
|
GitHubLabel,
|
||||||
@@ -23,6 +24,7 @@ import type {
|
|||||||
GitHubIssueFilters,
|
GitHubIssueFilters,
|
||||||
CreateIssueOptions,
|
CreateIssueOptions,
|
||||||
UpdateIssueOptions,
|
UpdateIssueOptions,
|
||||||
|
CreateRepositoryOptions,
|
||||||
GitHubApiError
|
GitHubApiError
|
||||||
} from './GitHubTypes';
|
} from './GitHubTypes';
|
||||||
|
|
||||||
@@ -190,21 +192,22 @@ export class GitHubClient extends EventDispatcher {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get data from cache if valid
|
* 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;
|
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return null;
|
return undefined; // Not in cache
|
||||||
}
|
}
|
||||||
|
|
||||||
const age = Date.now() - entry.timestamp;
|
const age = Date.now() - entry.timestamp;
|
||||||
if (age > ttl) {
|
if (age > ttl) {
|
||||||
this.cache.delete(key);
|
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;
|
per_page?: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
}): Promise<GitHubApiResponse<GitHubRepository[]>> {
|
}): Promise<GitHubApiResponse<GitHubRepository[]>> {
|
||||||
|
console.log('🔍 [GitHubClient] listRepositories called with:', options);
|
||||||
|
|
||||||
const cacheKey = this.getCacheKey('listRepositories', options || {});
|
const cacheKey = this.getCacheKey('listRepositories', options || {});
|
||||||
const cached = this.getFromCache<GitHubRepository[]>(cacheKey, 60000);
|
const cached = this.getFromCache<GitHubRepository[]>(cacheKey, 60000);
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
|
console.log('🔍 [GitHubClient] Returning cached repos:', cached.length);
|
||||||
return { data: cached, rateLimit: this.rateLimit! };
|
return { data: cached, rateLimit: this.rateLimit! };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('🔍 [GitHubClient] Calling octokit.repos.listForAuthenticatedUser...');
|
||||||
const octokit = await this.ensureAuthenticated();
|
const octokit = await this.ensureAuthenticated();
|
||||||
const response = await octokit.repos.listForAuthenticatedUser(options);
|
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.updateRateLimitFromHeaders(response.headers as Record<string, string>);
|
||||||
this.setCache(cacheKey, response.data);
|
this.setCache(cacheKey, response.data);
|
||||||
|
|
||||||
@@ -321,6 +330,7 @@ export class GitHubClient extends EventDispatcher {
|
|||||||
rateLimit: this.rateLimit!
|
rateLimit: this.rateLimit!
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('❌ [GitHubClient] listRepositories error:', error);
|
||||||
this.handleApiError(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 ====================
|
// ==================== UTILITY METHODS ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -267,6 +267,26 @@ export interface UpdateIssueOptions {
|
|||||||
milestone?: number | null;
|
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
|
* Error response from GitHub API
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import Model from '../../../shared/model';
|
|||||||
import { detectRuntimeVersion } from '../models/migration/ProjectScanner';
|
import { detectRuntimeVersion } from '../models/migration/ProjectScanner';
|
||||||
import { RuntimeVersionInfo } from '../models/migration/types';
|
import { RuntimeVersionInfo } from '../models/migration/types';
|
||||||
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
|
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
|
||||||
import { GitHubAuth } from '../services/github';
|
import { GitHubOAuthService } from '../services/GitHubOAuthService';
|
||||||
import FileSystem from './filesystem';
|
import FileSystem from './filesystem';
|
||||||
import { tracker } from './tracker';
|
import { tracker } from './tracker';
|
||||||
import { guid } from './utils';
|
import { guid } from './utils';
|
||||||
@@ -336,15 +336,20 @@ export class LocalProjectsModel extends Model {
|
|||||||
setCurrentGlobalGitAuth(projectId: string) {
|
setCurrentGlobalGitAuth(projectId: string) {
|
||||||
const func = async (endpoint: string) => {
|
const func = async (endpoint: string) => {
|
||||||
if (endpoint.includes('github.com')) {
|
if (endpoint.includes('github.com')) {
|
||||||
// Priority 1: Check for global OAuth token
|
// Priority 1: Check for global OAuth token from GitHubOAuthService
|
||||||
const authState = GitHubAuth.getAuthState();
|
try {
|
||||||
if (authState.isAuthenticated && authState.token) {
|
const token = await GitHubOAuthService.instance.getToken();
|
||||||
console.log('[Git Auth] Using GitHub OAuth token for:', endpoint);
|
const user = GitHubOAuthService.instance.getCurrentUser();
|
||||||
|
if (token) {
|
||||||
|
console.log('[Git Auth] Using GitHub OAuth token for:', endpoint, 'user:', user?.login);
|
||||||
return {
|
return {
|
||||||
username: authState.username || 'oauth',
|
username: user?.login || 'oauth',
|
||||||
password: authState.token.access_token // Extract actual access token string
|
password: token
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Git Auth] Failed to get OAuth token:', err);
|
||||||
|
}
|
||||||
|
|
||||||
// Priority 2: Fall back to project-specific PAT
|
// Priority 2: Fall back to project-specific PAT
|
||||||
const config = await GitStore.get('github', projectId);
|
const config = await GitStore.get('github', projectId);
|
||||||
|
|||||||
@@ -5,11 +5,14 @@
|
|||||||
* with filtering, search, and detail views.
|
* 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 { GitHubClient, GitHubOAuthService } from '../../../services/github';
|
||||||
|
import { ConnectToGitHubView } from './components/ConnectToGitHub';
|
||||||
import { IssuesList } from './components/IssuesTab/IssuesList';
|
import { IssuesList } from './components/IssuesTab/IssuesList';
|
||||||
import { PRsList } from './components/PullRequestsTab/PRsList';
|
import { PRsList } from './components/PullRequestsTab/PRsList';
|
||||||
|
import { SyncToolbar } from './components/SyncToolbar';
|
||||||
import styles from './GitHubPanel.module.scss';
|
import styles from './GitHubPanel.module.scss';
|
||||||
import { useGitHubRepository } from './hooks/useGitHubRepository';
|
import { useGitHubRepository } from './hooks/useGitHubRepository';
|
||||||
import { useIssues } from './hooks/useIssues';
|
import { useIssues } from './hooks/useIssues';
|
||||||
@@ -19,11 +22,37 @@ type TabType = 'issues' | 'pullRequests';
|
|||||||
|
|
||||||
export function GitHubPanel() {
|
export function GitHubPanel() {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('issues');
|
const [activeTab, setActiveTab] = useState<TabType>('issues');
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const client = GitHubClient.instance;
|
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
|
// Initialize GitHubOAuthService on mount
|
||||||
const isConnected = client.isReady();
|
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 () => {
|
const handleConnectGitHub = async () => {
|
||||||
try {
|
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) {
|
if (!isConnected) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.GitHubPanel}>
|
<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) {
|
if (!isGitHub) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.GitHubPanel}>
|
<div className={styles.GitHubPanel}>
|
||||||
@@ -74,6 +149,8 @@ export function GitHubPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.GitHubPanel}>
|
<div className={styles.GitHubPanel}>
|
||||||
|
<SyncToolbar owner={owner} repo={repo} />
|
||||||
|
|
||||||
<div className={styles.Header}>
|
<div className={styles.Header}>
|
||||||
<div className={styles.Tabs}>
|
<div className={styles.Tabs}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { ConnectToGitHubView } from './ConnectToGitHubView';
|
||||||
|
export { CreateRepoModal } from './CreateRepoModal';
|
||||||
|
export { SelectRepoModal } from './SelectRepoModal';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}>×</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SyncToolbar } from './SyncToolbar';
|
||||||
@@ -2,20 +2,40 @@
|
|||||||
* useGitHubRepository Hook
|
* useGitHubRepository Hook
|
||||||
*
|
*
|
||||||
* Extracts GitHub repository information from the Git remote URL.
|
* 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 { Git } from '@noodl/git';
|
||||||
|
|
||||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||||
import { mergeProject } from '@noodl-utils/projectmerger';
|
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;
|
owner: string | null;
|
||||||
|
/** GitHub repository name */
|
||||||
repo: string | null;
|
repo: string | null;
|
||||||
|
/** Whether the remote is GitHub */
|
||||||
isGitHub: boolean;
|
isGitHub: boolean;
|
||||||
|
/** Whether we have all info needed (owner + repo) */
|
||||||
isReady: boolean;
|
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,49 +74,83 @@ function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const initialState: GitHubRepoInfo = {
|
||||||
* Hook to get GitHub repository information from current project's Git remote
|
|
||||||
*/
|
|
||||||
export function useGitHubRepository(): GitHubRepoInfo {
|
|
||||||
const [repoInfo, setRepoInfo] = useState<GitHubRepoInfo>({
|
|
||||||
owner: null,
|
owner: null,
|
||||||
repo: null,
|
repo: null,
|
||||||
isGitHub: false,
|
isGitHub: false,
|
||||||
isReady: false
|
isReady: false,
|
||||||
});
|
gitState: 'loading',
|
||||||
|
remoteUrl: null,
|
||||||
|
provider: null
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
/**
|
||||||
async function fetchRepoInfo() {
|
* Hook to get GitHub repository information from current project's Git remote
|
||||||
|
*/
|
||||||
|
export function useGitHubRepository(): GitHubRepoInfo & { refetch: () => void } {
|
||||||
|
const [repoInfo, setRepoInfo] = useState<GitHubRepoInfo>(initialState);
|
||||||
|
|
||||||
|
const fetchRepoInfo = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
|
const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
|
||||||
if (!projectDirectory) {
|
if (!projectDirectory) {
|
||||||
setRepoInfo({
|
setRepoInfo({
|
||||||
owner: null,
|
...initialState,
|
||||||
repo: null,
|
gitState: 'no-git'
|
||||||
isGitHub: false,
|
|
||||||
isReady: false
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Git instance and open repository
|
// Create Git instance and try to open repository
|
||||||
const git = new Git(mergeProject);
|
const git = new Git(mergeProject);
|
||||||
|
|
||||||
|
try {
|
||||||
await git.openRepository(projectDirectory);
|
await git.openRepository(projectDirectory);
|
||||||
|
} 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 {
|
||||||
|
console.warn('[useGitHubRepository] Git error:', errorMessage);
|
||||||
|
}
|
||||||
|
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
|
// Check if it's a GitHub repository
|
||||||
const provider = git.Provider;
|
|
||||||
if (provider !== 'github') {
|
if (provider !== 'github') {
|
||||||
setRepoInfo({
|
setRepoInfo({
|
||||||
owner: null,
|
owner: null,
|
||||||
repo: null,
|
repo: null,
|
||||||
isGitHub: false,
|
isGitHub: false,
|
||||||
isReady: false
|
isReady: false,
|
||||||
|
gitState: 'remote-not-github',
|
||||||
|
remoteUrl,
|
||||||
|
provider
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the remote URL
|
// Parse the remote URL
|
||||||
const remoteUrl = git.OriginUrl;
|
|
||||||
const parsed = parseGitHubUrl(remoteUrl);
|
const parsed = parseGitHubUrl(remoteUrl);
|
||||||
|
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
@@ -104,27 +158,32 @@ export function useGitHubRepository(): GitHubRepoInfo {
|
|||||||
owner: parsed.owner,
|
owner: parsed.owner,
|
||||||
repo: parsed.repo,
|
repo: parsed.repo,
|
||||||
isGitHub: true,
|
isGitHub: true,
|
||||||
isReady: true
|
isReady: true,
|
||||||
|
gitState: 'github-connected',
|
||||||
|
remoteUrl,
|
||||||
|
provider
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setRepoInfo({
|
setRepoInfo({
|
||||||
owner: null,
|
owner: null,
|
||||||
repo: null,
|
repo: null,
|
||||||
isGitHub: true, // It's GitHub but couldn't parse
|
isGitHub: true, // It's GitHub but couldn't parse
|
||||||
isReady: false
|
isReady: false,
|
||||||
|
gitState: 'github-connected',
|
||||||
|
remoteUrl,
|
||||||
|
provider
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch GitHub repository info:', error);
|
console.error('[useGitHubRepository] Unexpected error:', error);
|
||||||
setRepoInfo({
|
setRepoInfo({
|
||||||
owner: null,
|
...initialState,
|
||||||
repo: null,
|
gitState: 'no-git'
|
||||||
isGitHub: false,
|
|
||||||
isReady: false
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
fetchRepoInfo();
|
fetchRepoInfo();
|
||||||
|
|
||||||
// Refetch when project changes
|
// Refetch when project changes
|
||||||
@@ -138,7 +197,7 @@ export function useGitHubRepository(): GitHubRepoInfo {
|
|||||||
return () => {
|
return () => {
|
||||||
ProjectModel.instance?.off(handleProjectChange);
|
ProjectModel.instance?.off(handleProjectChange);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [fetchRepoInfo]);
|
||||||
|
|
||||||
return repoInfo;
|
return { ...repoInfo, refetch: fetchRepoInfo };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEventListener } from '@noodl-hooks/useEventListener';
|
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 { GitHubClient } from '../../../../services/github';
|
||||||
import type { GitHubIssue, GitHubIssueFilters } from '../../../../services/github/GitHubTypes';
|
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;
|
const client = GitHubClient.instance;
|
||||||
|
|
||||||
|
// Use ref to store filters to avoid infinite loops
|
||||||
|
const filtersRef = useRef(filters);
|
||||||
|
filtersRef.current = filters;
|
||||||
|
|
||||||
const fetchIssues = useCallback(
|
const fetchIssues = useCallback(
|
||||||
async (pageNum: number = 1, append: boolean = false) => {
|
async (pageNum: number = 1, append: boolean = false) => {
|
||||||
if (!owner || !repo || !enabled) {
|
if (!owner || !repo || !enabled) {
|
||||||
@@ -59,7 +63,7 @@ export function useIssues({ owner, repo, filters = {}, enabled = true }: UseIssu
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await client.listIssues(owner, repo, {
|
const response = await client.listIssues(owner, repo, {
|
||||||
...filters,
|
...filtersRef.current,
|
||||||
per_page: DEFAULT_PER_PAGE,
|
per_page: DEFAULT_PER_PAGE,
|
||||||
page: pageNum
|
page: pageNum
|
||||||
});
|
});
|
||||||
@@ -84,7 +88,7 @@ export function useIssues({ owner, repo, filters = {}, enabled = true }: UseIssu
|
|||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[owner, repo, enabled, filters, client]
|
[owner, repo, enabled, client]
|
||||||
);
|
);
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
@@ -99,10 +103,16 @@ export function useIssues({ owner, repo, filters = {}, enabled = true }: UseIssu
|
|||||||
}
|
}
|
||||||
}, [fetchIssues, page, hasMore, loadingMore]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
|
if (owner && repo && enabled) {
|
||||||
refetch();
|
refetch();
|
||||||
}, [owner, repo, filters, enabled]);
|
}
|
||||||
|
}, [owner, repo, filtersKey, enabled, refetch]);
|
||||||
|
|
||||||
// Listen for cache invalidation events
|
// Listen for cache invalidation events
|
||||||
useEventListener(client, 'rate-limit-updated', () => {
|
useEventListener(client, 'rate-limit-updated', () => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEventListener } from '@noodl-hooks/useEventListener';
|
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 { GitHubClient } from '../../../../services/github';
|
||||||
import type { GitHubPullRequest, GitHubIssueFilters } from '../../../../services/github/GitHubTypes';
|
import type { GitHubPullRequest, GitHubIssueFilters } from '../../../../services/github/GitHubTypes';
|
||||||
@@ -48,6 +48,10 @@ export function usePullRequests({
|
|||||||
|
|
||||||
const client = GitHubClient.instance;
|
const client = GitHubClient.instance;
|
||||||
|
|
||||||
|
// Use ref to store filters to avoid infinite loops
|
||||||
|
const filtersRef = useRef(filters);
|
||||||
|
filtersRef.current = filters;
|
||||||
|
|
||||||
const fetchPullRequests = useCallback(
|
const fetchPullRequests = useCallback(
|
||||||
async (pageNum: number = 1, append: boolean = false) => {
|
async (pageNum: number = 1, append: boolean = false) => {
|
||||||
if (!owner || !repo || !enabled) {
|
if (!owner || !repo || !enabled) {
|
||||||
@@ -64,7 +68,7 @@ export function usePullRequests({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await client.listPullRequests(owner, repo, {
|
const response = await client.listPullRequests(owner, repo, {
|
||||||
...filters,
|
...filtersRef.current,
|
||||||
per_page: DEFAULT_PER_PAGE,
|
per_page: DEFAULT_PER_PAGE,
|
||||||
page: pageNum
|
page: pageNum
|
||||||
});
|
});
|
||||||
@@ -89,7 +93,7 @@ export function usePullRequests({
|
|||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[owner, repo, enabled, filters, client]
|
[owner, repo, enabled, client]
|
||||||
);
|
);
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
@@ -104,10 +108,15 @@ export function usePullRequests({
|
|||||||
}
|
}
|
||||||
}, [fetchPullRequests, page, hasMore, loadingMore]);
|
}, [fetchPullRequests, page, hasMore, loadingMore]);
|
||||||
|
|
||||||
// Initial fetch
|
// Serialize filters to avoid infinite loops
|
||||||
|
const filtersKey = JSON.stringify(filters);
|
||||||
|
|
||||||
|
// Initial fetch - use serialized filters key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (owner && repo && enabled) {
|
||||||
refetch();
|
refetch();
|
||||||
}, [owner, repo, filters, enabled]);
|
}
|
||||||
|
}, [owner, repo, filtersKey, enabled, refetch]);
|
||||||
|
|
||||||
// Listen for cache invalidation events
|
// Listen for cache invalidation events
|
||||||
useEventListener(client, 'rate-limit-updated', () => {
|
useEventListener(client, 'rate-limit-updated', () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEventListener } from '@noodl-hooks/useEventListener';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { GitProvider } from '@noodl/git';
|
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 { Section, SectionVariant } from '@noodl-core-ui/components/sidebar/Section';
|
||||||
import { Text } from '@noodl-core-ui/components/typography/Text';
|
import { Text } from '@noodl-core-ui/components/typography/Text';
|
||||||
|
|
||||||
import { GitHubAuth, type GitHubAuthState } from '../../../../../../services/github';
|
import { GitHubOAuthService } from '../../../../../../services/github';
|
||||||
|
|
||||||
type CredentialsSectionProps = {
|
type CredentialsSectionProps = {
|
||||||
provider: GitProvider;
|
provider: GitProvider;
|
||||||
@@ -29,43 +30,75 @@ export function CredentialsSection({
|
|||||||
|
|
||||||
const [hidePassword, setHidePassword] = useState(true);
|
const [hidePassword, setHidePassword] = useState(true);
|
||||||
|
|
||||||
// OAuth state management
|
// OAuth state management using GitHubOAuthService
|
||||||
const [authState, setAuthState] = useState<GitHubAuthState>(GitHubAuth.getAuthState());
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [authenticatedUsername, setAuthenticatedUsername] = useState<string | null>(null);
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
const [progressMessage, setProgressMessage] = useState<string>('');
|
const [progressMessage, setProgressMessage] = useState<string>('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Check auth state on mount
|
const oauthService = GitHubOAuthService.instance;
|
||||||
|
|
||||||
|
// Initialize OAuth service on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (provider === 'github') {
|
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]);
|
||||||
|
|
||||||
const handleConnect = async () => {
|
// Listen for auth state changes
|
||||||
setIsConnecting(true);
|
useEventListener(oauthService, 'auth-state-changed', (event: { authenticated: boolean }) => {
|
||||||
setError(null);
|
console.log('🔔 [CredentialsSection] Auth state changed:', event.authenticated);
|
||||||
setProgressMessage('Initiating GitHub authentication...');
|
setIsAuthenticated(event.authenticated);
|
||||||
|
if (event.authenticated) {
|
||||||
try {
|
const user = oauthService.getCurrentUser();
|
||||||
await GitHubAuth.startWebOAuthFlow((message) => {
|
setAuthenticatedUsername(user?.login || null);
|
||||||
setProgressMessage(message);
|
} else {
|
||||||
|
setAuthenticatedUsername(null);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update state after successful auth
|
const handleConnect = async () => {
|
||||||
setAuthState(GitHubAuth.getAuthState());
|
console.log('🔘 [CredentialsSection] handleConnect called - button clicked!');
|
||||||
setProgressMessage('');
|
setIsConnecting(true);
|
||||||
|
setError(null);
|
||||||
|
setProgressMessage('Opening GitHub in your browser...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔐 [CredentialsSection] Calling GitHubOAuthService.initiateOAuth...');
|
||||||
|
await oauthService.initiateOAuth();
|
||||||
|
|
||||||
|
console.log('✅ [CredentialsSection] OAuth flow initiated');
|
||||||
|
// State will be updated via event listener when auth completes
|
||||||
|
setProgressMessage('Waiting for authorization...');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('❌ [CredentialsSection] OAuth flow error:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Authentication failed');
|
setError(err instanceof Error ? err.message : 'Authentication failed');
|
||||||
setProgressMessage('');
|
setProgressMessage('');
|
||||||
} finally {
|
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
// Listen for auth success to clear connecting state
|
||||||
GitHubAuth.disconnect();
|
useEventListener(oauthService, 'oauth-success', () => {
|
||||||
setAuthState(GitHubAuth.getAuthState());
|
setIsConnecting(false);
|
||||||
|
setProgressMessage('');
|
||||||
|
});
|
||||||
|
|
||||||
|
useEventListener(oauthService, 'oauth-error', (event: { error: string }) => {
|
||||||
|
setIsConnecting(false);
|
||||||
|
setError(event.error);
|
||||||
|
setProgressMessage('');
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDisconnect = async () => {
|
||||||
|
await oauthService.disconnect();
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,11 +107,11 @@ export function CredentialsSection({
|
|||||||
{/* OAuth Section - GitHub Only */}
|
{/* OAuth Section - GitHub Only */}
|
||||||
{provider === 'github' && (
|
{provider === 'github' && (
|
||||||
<Section title="GitHub Account (Recommended)" variant={SectionVariant.InModal} hasGutter>
|
<Section title="GitHub Account (Recommended)" variant={SectionVariant.InModal} hasGutter>
|
||||||
{authState.isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
// Connected state
|
// Connected state
|
||||||
<>
|
<>
|
||||||
<Text hasBottomSpacing>
|
<Text hasBottomSpacing>
|
||||||
✓ Connected as <strong>{authState.username}</strong>
|
✓ Connected as <strong>{authenticatedUsername}</strong>
|
||||||
</Text>
|
</Text>
|
||||||
<Text hasBottomSpacing>Your GitHub account is connected and will be used for all Git operations.</Text>
|
<Text hasBottomSpacing>Your GitHub account is connected and will be used for all Git operations.</Text>
|
||||||
<TextButton label="Disconnect GitHub Account" onClick={handleDisconnect} />
|
<TextButton label="Disconnect GitHub Account" onClick={handleDisconnect} />
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const { ipcMain, BrowserWindow } = require('electron');
|
|||||||
* GitHub OAuth credentials
|
* GitHub OAuth credentials
|
||||||
* Uses existing credentials from GitHubOAuthService
|
* Uses existing credentials from GitHubOAuthService
|
||||||
*/
|
*/
|
||||||
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Iv23lib1WdrimUdyvZui';
|
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Ov23li2n9u3dwAhwoifb';
|
||||||
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || '9bd56694d6d300bf86b1999bab523b32654ec375';
|
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || 'c45276fa80b0618de06e5e2b09c1019ca150baef';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom protocol for OAuth callback
|
* Custom protocol for OAuth callback
|
||||||
@@ -217,6 +217,7 @@ class GitHubOAuthCallbackHandler {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send success to renderer process
|
* Send success to renderer process
|
||||||
|
* Broadcasts to ALL windows since the editor might not be windows[0]
|
||||||
*/
|
*/
|
||||||
sendSuccessToRenderer(result) {
|
sendSuccessToRenderer(result) {
|
||||||
console.log('📤 [GitHub OAuth] ========================================');
|
console.log('📤 [GitHub OAuth] ========================================');
|
||||||
@@ -227,8 +228,15 @@ class GitHubOAuthCallbackHandler {
|
|||||||
|
|
||||||
const windows = BrowserWindow.getAllWindows();
|
const windows = BrowserWindow.getAllWindows();
|
||||||
if (windows.length > 0) {
|
if (windows.length > 0) {
|
||||||
windows[0].webContents.send('github-oauth-complete', result);
|
// Broadcast to ALL windows - the one with the listener will handle it
|
||||||
console.log('✅ [GitHub OAuth] IPC event sent to renderer');
|
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 {
|
} else {
|
||||||
console.error('❌ [GitHub OAuth] No windows available to send IPC event!');
|
console.error('❌ [GitHub OAuth] No windows available to send IPC event!');
|
||||||
}
|
}
|
||||||
@@ -236,14 +244,21 @@ class GitHubOAuthCallbackHandler {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send error to renderer process
|
* Send error to renderer process
|
||||||
|
* Broadcasts to ALL windows since the editor might not be windows[0]
|
||||||
*/
|
*/
|
||||||
sendErrorToRenderer(error, description) {
|
sendErrorToRenderer(error, description) {
|
||||||
const windows = BrowserWindow.getAllWindows();
|
const windows = BrowserWindow.getAllWindows();
|
||||||
if (windows.length > 0) {
|
if (windows.length > 0) {
|
||||||
windows[0].webContents.send('github-oauth-error', {
|
windows.forEach((win) => {
|
||||||
|
try {
|
||||||
|
win.webContents.send('github-oauth-error', {
|
||||||
error,
|
error,
|
||||||
message: description || error
|
message: description || error
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore errors for windows that can't receive messages
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -671,7 +671,13 @@ function launchApp() {
|
|||||||
// Load GitHub token
|
// Load GitHub token
|
||||||
ipcMain.handle('github-load-token', async (event) => {
|
ipcMain.handle('github-load-token', async (event) => {
|
||||||
try {
|
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 (!stored) return null;
|
||||||
|
|
||||||
if (safeStorage.isEncryptionAvailable()) {
|
if (safeStorage.isEncryptionAvailable()) {
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import { GitError as DugiteError } from "dugite";
|
import { GitError as DugiteError } from 'dugite';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the SHA of the passed in IGitResult
|
* 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 {
|
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) {
|
} else if (result.output.length) {
|
||||||
message = result.error.toString();
|
message = result.error.toString();
|
||||||
} else {
|
} else {
|
||||||
message = "Unknown error";
|
message = 'Unknown error';
|
||||||
rawMessage = false;
|
rawMessage = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
super(message);
|
super(message);
|
||||||
|
|
||||||
this.name = "GitError";
|
this.name = 'GitError';
|
||||||
this.result = result;
|
this.result = result;
|
||||||
this.args = args;
|
this.args = args;
|
||||||
this.isRawMessage = rawMessage;
|
this.isRawMessage = rawMessage;
|
||||||
@@ -111,94 +131,94 @@ export function getDescriptionForError(error: DugiteError): string | null {
|
|||||||
|
|
||||||
switch (error) {
|
switch (error) {
|
||||||
case DugiteError.SSHKeyAuditUnverified:
|
case DugiteError.SSHKeyAuditUnverified:
|
||||||
return "The SSH key is unverified.";
|
return 'The SSH key is unverified.';
|
||||||
case DugiteError.RemoteDisconnection:
|
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:
|
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:
|
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:
|
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.HTTPSRepositoryNotFound:
|
||||||
case DugiteError.SSHRepositoryNotFound:
|
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:
|
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:
|
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:
|
case DugiteError.DefaultBranchDeletionFailed:
|
||||||
return `The branch is the repository's default branch and cannot be deleted.`;
|
return `The branch is the repository's default branch and cannot be deleted.`;
|
||||||
case DugiteError.RevertConflicts:
|
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:
|
case DugiteError.EmptyRebasePatch:
|
||||||
return "There aren’t any changes left to apply.";
|
return 'There aren’t any changes left to apply.';
|
||||||
case DugiteError.NoMatchingRemoteBranch:
|
case DugiteError.NoMatchingRemoteBranch:
|
||||||
return "There aren’t any remote branches that match the current branch.";
|
return 'There aren’t any remote branches that match the current branch.';
|
||||||
case DugiteError.NothingToCommit:
|
case DugiteError.NothingToCommit:
|
||||||
return "There are no changes to commit.";
|
return 'There are no changes to commit.';
|
||||||
case DugiteError.NoSubmoduleMapping:
|
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:
|
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:
|
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:
|
case DugiteError.LocalPermissionDenied:
|
||||||
return "Permission denied.";
|
return 'Permission denied.';
|
||||||
case DugiteError.InvalidMerge:
|
case DugiteError.InvalidMerge:
|
||||||
return "This is not something we can merge.";
|
return 'This is not something we can merge.';
|
||||||
case DugiteError.InvalidRebase:
|
case DugiteError.InvalidRebase:
|
||||||
return "This is not something we can rebase.";
|
return 'This is not something we can rebase.';
|
||||||
case DugiteError.NonFastForwardMergeIntoEmptyHead:
|
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:
|
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:
|
case DugiteError.BranchAlreadyExists:
|
||||||
return "A branch with that name already exists.";
|
return 'A branch with that name already exists.';
|
||||||
case DugiteError.BadRevision:
|
case DugiteError.BadRevision:
|
||||||
return "Bad revision.";
|
return 'Bad revision.';
|
||||||
case DugiteError.NotAGitRepository:
|
case DugiteError.NotAGitRepository:
|
||||||
return "This is not a git repository.";
|
return 'This is not a git repository.';
|
||||||
case DugiteError.ProtectedBranchForcePush:
|
case DugiteError.ProtectedBranchForcePush:
|
||||||
return "This branch is protected from force-push operations.";
|
return 'This branch is protected from force-push operations.';
|
||||||
case DugiteError.ProtectedBranchRequiresReview:
|
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:
|
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.";
|
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:
|
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:
|
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:
|
case DugiteError.InvalidRefLength:
|
||||||
return "A ref cannot be longer than 255 characters.";
|
return 'A ref cannot be longer than 255 characters.';
|
||||||
case DugiteError.CannotMergeUnrelatedHistories:
|
case DugiteError.CannotMergeUnrelatedHistories:
|
||||||
return "Unable to merge unrelated histories in this repository.";
|
return 'Unable to merge unrelated histories in this repository.';
|
||||||
case DugiteError.PushWithPrivateEmail:
|
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.';
|
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:
|
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:
|
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:
|
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:
|
case DugiteError.BranchRenameFailed:
|
||||||
return "The branch could not be renamed.";
|
return 'The branch could not be renamed.';
|
||||||
case DugiteError.PathDoesNotExist:
|
case DugiteError.PathDoesNotExist:
|
||||||
return "The path does not exist on disk.";
|
return 'The path does not exist on disk.';
|
||||||
case DugiteError.InvalidObjectName:
|
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:
|
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:
|
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:
|
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:
|
case DugiteError.NoExistingRemoteBranch:
|
||||||
return "The remote branch does not exist.";
|
return 'The remote branch does not exist.';
|
||||||
case DugiteError.LocalChangesOverwritten:
|
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:
|
case DugiteError.UnresolvedConflicts:
|
||||||
return "There are unresolved conflicts in the working directory.";
|
return 'There are unresolved conflicts in the working directory.';
|
||||||
case DugiteError.ConfigLockFileAlreadyExists:
|
case DugiteError.ConfigLockFileAlreadyExists:
|
||||||
// Added in dugite 1.88.0 (https://github.com/desktop/dugite/pull/386)
|
// 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
|
// 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:
|
case DugiteError.RemoteAlreadyExists:
|
||||||
return null;
|
return null;
|
||||||
case DugiteError.TagAlreadyExists:
|
case DugiteError.TagAlreadyExists:
|
||||||
return "A tag with that name already exists";
|
return 'A tag with that name already exists';
|
||||||
case DugiteError.MergeWithLocalChanges:
|
case DugiteError.MergeWithLocalChanges:
|
||||||
case DugiteError.RebaseWithLocalChanges:
|
case DugiteError.RebaseWithLocalChanges:
|
||||||
case DugiteError.GPGFailedToSignData:
|
case DugiteError.GPGFailedToSignData:
|
||||||
|
|||||||
@@ -106,11 +106,17 @@ export class Git {
|
|||||||
* Open a git repository in the given path.
|
* Open a git repository in the given path.
|
||||||
*
|
*
|
||||||
* @param baseDir
|
* @param baseDir
|
||||||
|
* @throws Error if the path is not a git repository
|
||||||
*/
|
*/
|
||||||
async openRepository(baseDir: string): Promise<void> {
|
async openRepository(baseDir: string): Promise<void> {
|
||||||
if (this.baseDir) return;
|
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();
|
await this._setupRepository();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user