+// Should NOT have onClick that calls event.stopPropagation()
+```
+
+### 2. Check Popout Configuration
+
+Current call in `CodeEditorType.ts`:
+
+```typescript
+this.parent.showPopout({
+ content: { el: [this.popoutDiv] },
+ attachTo: $(el),
+ position: 'right',
+ disableDynamicPositioning: true,
+ // manualClose is NOT set, so should close on outside click
+ onClose: function () {
+ save(); // Auto-saves
+ // ... cleanup
+ }
+});
+```
+
+### 3. Compare with Monaco Editor
+
+The old Monaco CodeEditor works correctly - compare popout setup.
+
+### 4. Test Overlay Click Handler
+
+Check if PopupLayer's overlay click handler is working:
+
+```javascript
+// In browser console when modal is open:
+document.querySelector('.popout-overlay')?.addEventListener('click', (e) => {
+ console.log('Overlay clicked', e);
+});
+```
+
+---
+
+## Solution Options
+
+### Option A: Fix Event Propagation (Preferred)
+
+If JavaScriptEditor is stopping events, remove/fix that:
+
+```typescript
+// JavaScriptEditor.tsx - ensure no stopPropagation on root
+
+```
+
+### Option B: Add Explicit Close Button
+
+If outside-click proves unreliable, add a close button:
+
+```typescript
+
+
+
+
+
+```
+
+But this is less elegant - prefer fixing the root cause.
+
+### Option C: Set manualClose Flag
+
+Force manual close behavior and add close button:
+
+```typescript
+this.parent.showPopout({
+ // ...
+ manualClose: true, // Require explicit close
+ onClose: function () {
+ save(); // Still auto-save
+ // ...
+ }
+});
+```
+
+---
+
+## Implementation Plan
+
+1. **Investigate** - Determine exact cause (event propagation vs overlay)
+2. **Fix Root Cause** - Prefer making outside-click work
+3. **Test** - Verify click-outside, Escape key, and Save all work
+4. **Fallback** - If outside-click unreliable, add close button
+
+---
+
+## Design Decision: Auto-Save Behavior
+
+**Chosen: Option A - Auto-save on close**
+
+- Clicking outside closes modal and auto-saves
+- No "unsaved changes" warning needed
+- Consistent with existing Monaco editor behavior
+- Simpler UX - less friction
+
+**Rejected alternatives:**
+
+- Option B: Require explicit save (adds friction)
+- Option C: Add visual feedback (over-engineering for this use case)
+
+---
+
+## Files to Modify
+
+**Investigation:**
+
+- `packages/noodl-core-ui/src/components/code-editor/JavaScriptEditor.tsx` - Check event handlers
+- `packages/noodl-editor/src/editor/src/views/popuplayer.js` - Check overlay click handling
+
+**Fix (likely):**
+
+- `packages/noodl-core-ui/src/components/code-editor/JavaScriptEditor.tsx` - Remove stopPropagation if present
+- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/CodeEditor/CodeEditorType.ts` - Verify popout config
+
+**Fallback:**
+
+- Add close button to JavaScriptEditor if outside-click proves unreliable
+
+---
+
+## Testing Checklist
+
+- [ ] Click outside modal closes it
+- [ ] Changes are auto-saved on close
+- [ ] Escape key closes modal (if PopupLayer supports it)
+- [ ] Save button works (saves but doesn't close)
+- [ ] Works for both editable and read-only editors
+- [ ] No console errors on close
+- [ ] Cursor position preserved if re-opening same editor
+
+---
+
+## Related Issues
+
+- Related to Task 11 (Advanced Code Editor implementation)
+- Similar pattern needed for Blockly editor modals
+
+---
+
+## Notes
+
+- This is a quick fix - should be resolved before continuing with other bugs
+- Auto-save behavior matches existing patterns in OpenNoodl
+- If outside-click proves buggy across different contexts, consider standardizing on explicit close buttons
+
+---
+
+_Last Updated: January 13, 2026_
diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-013-integration-bugfixes/CHANGELOG.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-013-integration-bugfixes/CHANGELOG.md
new file mode 100644
index 0000000..d1c8883
--- /dev/null
+++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-013-integration-bugfixes/CHANGELOG.md
@@ -0,0 +1,514 @@
+# TASK-013 Integration Bug Fixes - CHANGELOG
+
+This document tracks progress on fixing bugs introduced during Phase 2 Task 8 and Phase 3 Task 12.
+
+---
+
+## 2026-01-13 - Task Created
+
+### Documentation Complete
+
+**Created task structure:**
+
+- ✅ Main README with overview and implementation phases
+- ✅ BUG-1: Property Panel Stuck (detailed investigation doc)
+- ✅ BUG-2: Blockly Node Deletion (intermittent data loss)
+- ✅ BUG-2.1: Blockly UI Polish (quick wins)
+- ✅ BUG-3: Comment UX Overhaul (design doc)
+- ✅ BUG-4: Label Double-Click (opens wrong modal)
+- ✅ CHANGELOG (this file)
+
+**Status:**
+
+- **Phase A:** Research & Investigation (IN PROGRESS)
+- **Phase B:** Quick Wins (PENDING)
+- **Phase C:** Core Fixes (IN PROGRESS)
+- **Phase D:** Complex Debugging (PENDING)
+- **Phase E:** Testing & Documentation (PENDING)
+
+---
+
+## 2026-01-13 - BUG-1 FIXED: Property Panel Stuck
+
+### Root Cause Identified
+
+Found in `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` line 1149:
+
+The `selectNode()` method had conditional logic:
+
+- **First click** (when `!node.selected`): Called `SidebarModel.instance.switchToNode()` ✅
+- **Subsequent clicks** (when `node.selected === true`): Only handled double-click navigation, **never called switchToNode()** ❌
+
+This meant clicking a node that was already "selected" wouldn't update the property panel.
+
+### Solution Applied
+
+**Implemented Option A:** Always switch to node regardless of selection state
+
+Changed logic to:
+
+1. Update selector state only if node not selected (unchanged behavior)
+2. **ALWAYS call `SidebarModel.instance.switchToNode()`** (KEY FIX)
+3. Handle double-click navigation separately when `leftButtonIsDoubleClicked` is true
+
+### Changes Made
+
+- **File:** `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts`
+- **Method:** `selectNode()`
+- **Lines:** ~1149-1183
+- **Type:** Logic refactoring to separate concerns
+
+### Testing Needed
+
+- [ ] Click node A → panel shows A
+- [ ] Click node B → panel shows B (not A)
+- [ ] Click node C → panel shows C
+- [ ] Rapid clicking between nodes works correctly
+- [ ] Double-click navigation still works
+- [ ] No regressions in multiselect behavior
+
+**Next Steps:**
+
+1. Manual testing with `npm run dev`
+2. If confirmed working, mark as complete
+3. Move to BUG-4 (likely same root cause - event handling)
+
+---
+
+## Future Entries
+
+Template for future updates:
+
+```markdown
+## YYYY-MM-DD - [Milestone/Phase Name]
+
+### What Changed
+
+- Item 1
+- Item 2
+
+### Bugs Fixed
+
+- BUG-X: Brief description
+
+### Discoveries
+
+- Important finding 1
+- Important finding 2
+
+### Next Steps
+
+- Next action 1
+- Next action 2
+```
+
+---
+
+## [2026-01-13 16:00] - BUG-1 ACTUALLY FIXED: React State Mutation
+
+### Investigation Update
+
+The first fix attempt failed. Node visual selection worked, but property panel stayed stuck. This revealed the real problem was deeper in the React component layer.
+
+### Root Cause Identified (ACTUAL)
+
+Found in `packages/noodl-editor/src/editor/src/views/SidePanel/SidePanel.tsx`:
+
+**The `nodeSelected` event listener (lines 73-84) was MUTATING React state:**
+
+```typescript
+setPanels((prev) => {
+ const component = SidebarModel.instance.getPanelComponent(panelId);
+ if (component) {
+ prev[panelId] = React.createElement(component); // ❌ MUTATION!
+ }
+ return prev; // ❌ Returns SAME object reference
+});
+```
+
+React uses reference equality to detect changes. When you mutate an object and return the same reference, **React doesn't detect any change** and skips re-rendering. This is why the panel stayed stuck showing the old node!
+
+### Solution Applied
+
+**Fixed ALL three state mutations in SidePanel.tsx:**
+
+1. **Initial panel load** (lines 30-40)
+2. **activeChanged listener** (lines 48-66)
+3. **nodeSelected listener** (lines 73-84) ← **THE CRITICAL BUG**
+
+Changed ALL setState calls to return NEW objects:
+
+```typescript
+setPanels((prev) => {
+ const component = SidebarModel.instance.getPanelComponent(panelId);
+ if (component) {
+ return {
+ ...prev, // ✅ Spread creates NEW object
+ [panelId]: React.createElement(component)
+ };
+ }
+ return prev;
+});
+```
+
+### Changes Made
+
+- **File:** `packages/noodl-editor/src/editor/src/views/SidePanel/SidePanel.tsx`
+- **Lines:** 30-40, 48-66, 73-84
+- **Type:** React state management bug fix
+- **Severity:** Critical (broke all node property panel updates)
+
+### Why This Happened
+
+This was introduced during Phase 2 Task 8 when the side panel was migrated to React. The original code likely worked because it was using a different state management approach. The React migration introduced this classic state mutation anti-pattern.
+
+### Testing Needed
+
+- [x] Visual selection works (confirmed earlier)
+- [x] Click node A → panel shows A ✅
+- [x] Click node B → panel shows B (not stuck on A) ✅
+- [x] Click node C → panel shows C ✅
+- [x] Rapid clicking between nodes updates correctly ✅
+- [x] No performance regressions ✅
+
+**STATUS: ✅ VERIFIED AND WORKING - BUG-1 COMPLETE**
+
+### Learnings
+
+**Added to COMMON-ISSUES.md:**
+
+- React setState MUST return new objects for React to detect changes
+- State mutation is silent and hard to debug (no errors, just wrong behavior)
+- Always use spread operator or Object.assign for state updates
+
+---
+
+---
+
+## [2026-01-13 17:00] - BUG-2.1 COMPLETE: Blockly UI Polish
+
+### Changes Implemented
+
+**Goal:** Clean up Blockly Logic Builder UI by:
+
+1. Removing redundant "View Generated Code" button
+2. Showing "Generated code" field in property panel (read-only)
+3. Changing "Edit Logic Blocks" to "View Logic Blocks"
+4. Using new CodeMirror editor in read-only mode for generated code
+
+### Root Cause
+
+The generatedCode parameter was being hidden via CSS and had a separate button to view it. This was redundant since we can just show the parameter directly with the new code editor in read-only mode.
+
+### Solution Applied
+
+**1. Node Definition (`logic-builder.js`)**
+
+- Changed `generatedCode` parameter:
+ - `editorType: 'code-editor'` (use new JavaScriptEditor)
+ - `displayName: 'Generated code'` (lowercase 'c')
+ - `group: 'Advanced'` (show in Advanced group)
+ - `readOnly: true` (mark as read-only)
+- Removed hiding logic (empty group, high index)
+
+**2. LogicBuilderWorkspaceType Component**
+
+- Removed "View Generated Code" button completely
+- Removed CSS that was hiding generatedCode parameter
+- Changed button text: "✨ Edit Logic Blocks" → "View Logic Blocks"
+- Removed `onViewCodeClicked()` method (no longer needed)
+- Kept CSS to hide empty group labels
+
+**3. CodeEditorType Component**
+
+- Added support for `readOnly` port flag
+- Pass `disabled={this.port?.readOnly || false}` to JavaScriptEditor
+- This makes the editor truly read-only (can't edit, can copy/paste)
+
+### Files Modified
+
+1. `packages/noodl-runtime/src/nodes/std-library/logic-builder.js`
+ - Updated `generatedCode` parameter configuration
+2. `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/LogicBuilderWorkspaceType.ts`
+ - Removed second button
+ - Updated button label
+ - Removed CSS hiding logic
+3. `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/CodeEditor/CodeEditorType.ts`
+ - Added readOnly support for JavaScriptEditor
+
+### Testing Needed
+
+- [ ] Logic Builder node shows only "View Logic Blocks" button
+- [ ] "Generated code" field appears in Advanced group
+- [ ] Clicking "Generated code" opens new CodeMirror editor
+- [ ] Editor is read-only (can't type, can select/copy)
+- [ ] No empty group labels visible
+
+**Next Steps:**
+
+1. Test with `npm run clean:all && npm run dev`
+2. Add a Logic Builder node and add some blocks
+3. Close Blockly tab and verify generated code field appears
+4. Click it and verify read-only CodeMirror editor opens
+
+**STATUS: ✅ IMPLEMENTED - AWAITING USER TESTING**
+
+---
+
+## [2026-01-13 22:48] - BUG-2.1 FINAL FIX: Read-Only Flag Location
+
+### Investigation Complete
+
+After clean rebuild and testing, discovered `readOnly: false` in logs. Root cause: the `readOnly` flag wasn't being passed through to the property panel.
+
+### Root Cause (ACTUAL)
+
+The port object only contains these properties:
+
+```javascript
+allKeys: ['name', 'type', 'plug', 'group', 'displayName', 'index'];
+```
+
+`readOnly` was NOT in the list because it was at the wrong location in the node definition.
+
+**Wrong Location (not passed through):**
+
+```javascript
+generatedCode: {
+ type: { ... },
+ readOnly: true // ❌ Not passed to port object
+}
+```
+
+**Correct Location (passed through):**
+
+```javascript
+generatedCode: {
+ type: {
+ readOnly: true; // ✅ Passed as port.type.readOnly
+ }
+}
+```
+
+### Solution Applied
+
+**Moved `readOnly` flag inside `type` object in `logic-builder.js`:**
+
+```javascript
+generatedCode: {
+ type: {
+ name: 'string',
+ allowEditOnly: true,
+ codeeditor: 'javascript',
+ readOnly: true // ✅ Correct location
+ },
+ displayName: 'Generated code',
+ group: 'Advanced',
+ set: function (value) { ... }
+}
+```
+
+**CodeEditorType already checks `p.type?.readOnly`** so no changes needed there!
+
+### Files Modified
+
+1. `packages/noodl-runtime/src/nodes/std-library/logic-builder.js`
+ - Moved `readOnly: true` inside `type` object (line 237)
+2. `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/CodeEditor/CodeEditorType.ts`
+ - Added debug logging to identify the issue
+ - Added fallback to check multiple locations for readOnly flag
+ - Disabled history tracking for read-only fields (prevents crash)
+
+### Testing Checklist
+
+After `npm run clean:all && npm run dev`:
+
+- [x] Console shows `[CodeEditorType.fromPort] Resolved readOnly: true` ✅
+- [x] Console shows `[CodeEditorType] Rendering JavaScriptEditor: {readOnly: true}` ✅
+- [x] Generated code editor is completely read-only (can't type) ✅
+- [x] Can still select and copy text ✅
+- [x] Format and Save buttons are disabled ✅
+- [x] No CodeHistoryManager crash on close ✅
+
+**STATUS: ✅ COMPLETE AND VERIFIED WORKING**
+
+### Key Learning
+
+**Added to LEARNINGS-NODE-CREATION.md:**
+
+- Port-level properties (like `readOnly`) are NOT automatically passed to the property panel
+- To make a property accessible, it must be inside the `type` object
+- The property panel accesses it as `port.type.propertyName`
+- Always check `allKeys` in debug logs to see what properties are actually available
+
+---
+
+_Last Updated: January 13, 2026 22:48_
+
+---
+
+## [2026-01-13 23:00] - BUG-5 DOCUMENTED: Code Editor Modal Close Behavior
+
+### Bug Report Created
+
+**Issue:** New JavaScriptEditor (CodeMirror 6) modal doesn't close when clicking outside of it. Users feel "trapped" and unclear how to dismiss the editor.
+
+**Expected behavior:**
+
+- Click outside modal → Auto-saves and closes
+- Press Escape → Auto-saves and closes
+- Click Save button → Saves and stays open
+
+**Current behavior:**
+
+- Click outside modal → Nothing happens (modal stays open)
+- Only way to interact is through Save button
+
+### Design Decision Made
+
+**Chose Option A: Auto-save on close**
+
+- Keep it simple - clicking outside auto-saves and closes
+- No "unsaved changes" warning needed (nothing is lost)
+- Consistent with existing Monaco editor behavior
+- Less friction for users
+
+Rejected alternatives:
+
+- Option B: Require explicit save (adds friction)
+- Option C: Add visual feedback indicators (over-engineering)
+
+### Investigation Plan
+
+**Likely causes to investigate:**
+
+1. **Event propagation** - JavaScriptEditor stopping click events
+2. **Z-index/pointer events** - Overlay not capturing clicks
+3. **React event handling** - Synthetic events interfering with jQuery popout system
+
+**Next steps:**
+
+1. Check if JavaScriptEditor root has onClick that calls stopPropagation
+2. Compare with Monaco editor (which works correctly)
+3. Test overlay click handler in browser console
+4. Fix root cause (prefer making outside-click work)
+5. Fallback: Add explicit close button if outside-click proves unreliable
+
+### Files to Investigate
+
+- `packages/noodl-core-ui/src/components/code-editor/JavaScriptEditor.tsx`
+- `packages/noodl-editor/src/editor/src/views/popuplayer.js`
+- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/CodeEditor/CodeEditorType.ts`
+
+### Priority
+
+**P1 - Significant UX Issue**
+
+This is a quick fix that should be resolved early in Phase B (Quick Wins), likely before or alongside BUG-2.1.
+
+**STATUS: 🔴 DOCUMENTED - AWAITING INVESTIGATION**
+
+---
+
+## [2026-01-14 21:57] - BUG-5 FIXED: Code Editor Modal Close Behavior
+
+### Root Cause Identified
+
+The `.popup-layer` element has `pointer-events: none` by default, which means clicks pass through it. The CSS class `.dim` adds `pointer-events: all` for modals with dark overlays, but popouts (like the code editor) don't use the dim class.
+
+**The problem:**
+
+- `.popup-layer-popout` itself has `pointer-events: all` → clicks on editor work ✅
+- `.popup-layer` has `pointer-events: none` → clicks OUTSIDE pass through ❌
+- The popuplayer.js click handlers never receive the events → popout doesn't close
+
+### Solution Implemented
+
+**Added new CSS class `.has-popouts` to enable click detection:**
+
+**1. CSS Changes (`popuplayer.css`):**
+
+```css
+/* Enable pointer events when popouts are active (without dimming background)
+ This allows clicking outside popouts to close them */
+.popup-layer.has-popouts {
+ pointer-events: all;
+}
+```
+
+**2. JavaScript Changes (`popuplayer.js`):**
+
+**In `showPopout()` method (after line 536):**
+
+```javascript
+this.popouts.push(popout);
+
+// Enable pointer events for outside-click-to-close when popouts are active
+this.$('.popup-layer').addClass('has-popouts');
+```
+
+**In `hidePopout()` method (inside close function):**
+
+```javascript
+if (this.popouts.length === 0) {
+ this.$('.popup-layer-blocker').css({ display: 'none' });
+ // Disable pointer events when no popouts are active
+ this.$('.popup-layer').removeClass('has-popouts');
+}
+```
+
+### How It Works
+
+1. When a popout opens, add `has-popouts` class → enables `pointer-events: all`
+2. Click detection now works → outside clicks trigger `hidePopouts()`
+3. When last popout closes, remove `has-popouts` class → restores `pointer-events: none`
+4. This ensures clicks only work when popouts are actually open
+
+### Files Modified
+
+1. `packages/noodl-editor/src/editor/src/styles/popuplayer.css`
+ - Added `.popup-layer.has-popouts` CSS rule (lines 23-26)
+2. `packages/noodl-editor/src/editor/src/views/popuplayer.js`
+ - Added `addClass('has-popouts')` after pushing popout (lines 538-540)
+ - Added `removeClass('has-popouts')` when popouts array becomes empty (line 593)
+
+### Testing Checklist
+
+- [ ] Open code editor by clicking a code property
+- [ ] Click outside modal → Editor closes and auto-saves
+- [ ] Changes are preserved after close
+- [ ] Press Escape → Editor closes (existing functionality)
+- [ ] Save button still works (saves but doesn't close)
+- [ ] Works for both editable and read-only editors
+- [ ] Multiple popouts can be open (all close when clicking outside)
+- [ ] No console errors on close
+
+### Design Notes
+
+**Auto-save behavior maintained:**
+
+- Clicking outside triggers `onClose` callback
+- `onClose` calls `save()` which auto-saves changes
+- No "unsaved changes" warning needed
+- Consistent with existing Monaco editor behavior
+
+**No visual changes:**
+
+- No close button added (outside-click is intuitive enough)
+- Keeps UI clean and minimal
+- Escape key also works as an alternative
+
+### Testing Complete
+
+User verification confirmed:
+
+- ✅ Click outside modal closes editor
+- ✅ Changes auto-save on close
+- ✅ No console errors
+- ✅ Clean, intuitive UX
+
+**STATUS: ✅ COMPLETE - VERIFIED WORKING**
+
+---
+
+_Last Updated: January 14, 2026 22:01_
diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-013-integration-bugfixes/README.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-013-integration-bugfixes/README.md
new file mode 100644
index 0000000..b2813d7
--- /dev/null
+++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-013-integration-bugfixes/README.md
@@ -0,0 +1,159 @@
+# TASK-013: Phase 3/4 Integration Bug Fixes
+
+**Status:** 🔴 RESEARCH PHASE
+**Priority:** P0 - Critical UX Issues
+**Created:** January 13, 2026
+**Last Updated:** January 13, 2026
+
+---
+
+## Overview
+
+Critical UX bugs introduced during Phase 2 (Task 8 - ComponentsPanel changes) and Phase 3 (Task 12 - Blockly Integration) that significantly impact core editing workflows.
+
+These bugs affect basic node selection, property panel interactions, Blockly editor stability, and comment system usability.
+
+---
+
+## Bugs
+
+### 🐛 [BUG-1: Property Panel "Stuck" on Previous Node](./BUG-1-property-panel-stuck.md)
+
+**Priority:** P0 - Blocks basic workflow
+**Status:** Research needed
+
+When clicking different nodes, property panel shows previous node's properties until you click blank canvas.
+
+### 🐛 [BUG-2: Blockly Node Randomly Deleted on Tab Close](./BUG-2-blockly-node-deletion.md)
+
+**Priority:** P0 - Data loss risk
+**Status:** Research needed
+
+Sometimes when closing Blockly editor tab, the node vanishes from canvas.
+
+### 🎨 [BUG-2.1: Blockly UI Polish](./BUG-2.1-blockly-ui-polish.md)
+
+**Priority:** P2 - UX improvement
+**Status:** Ready to implement
+
+Simple UI improvements to Blockly property panel (remove redundant label, add code viewer button).
+
+### 💬 [BUG-3: Comment System UX Overhaul](./BUG-3-comment-ux-overhaul.md)
+
+**Priority:** P1 - Significant UX annoyance
+**Status:** Design phase
+
+Comment button too easy to click accidentally, inconsistent positioning. Move to property panel.
+
+### 🏷️ [BUG-4: Double-Click Label Opens Comment Modal](./BUG-4-label-double-click.md)
+
+**Priority:** P1 - Breaks expected behavior
+**Status:** Research needed
+
+Double-clicking node name in property panel opens comment modal instead of inline rename.
+
+### 🪟 [BUG-5: Code Editor Modal Won't Close on Outside Click](./BUG-5-code-editor-modal-close.md)
+
+**Priority:** P1 - Significant UX issue
+**Status:** Research needed
+
+New JavaScriptEditor modal stays on screen when clicking outside. Should auto-save and close.
+
+---
+
+## Implementation Phases
+
+### Phase A: Research & Investigation (Current)
+
+- [ ] Investigate Bug 1: Property panel state synchronization
+- [ ] Investigate Bug 2: Blockly node deletion race condition
+- [ ] Investigate Bug 3: Comment UX design and implementation path
+- [ ] Investigate Bug 4: Label interaction event flow
+- [ ] Investigate Bug 5: Code editor modal close behavior
+
+### Phase B: Quick Wins
+
+- [ ] Fix Bug 5: Code editor modal close (likely event propagation)
+- [ ] Fix Bug 2.1: Blockly UI polish (straightforward)
+- [ ] Fix Bug 4: Label double-click (likely related to Bug 1)
+
+### Phase C: Core Fixes
+
+- [ ] Fix Bug 1: Property panel selection sync
+- [ ] Fix Bug 3: Implement new comment UX
+
+### Phase D: Complex Debugging
+
+- [ ] Fix Bug 2: Blockly node deletion
+
+### Phase E: Testing & Documentation
+
+- [ ] Comprehensive testing of all fixes
+- [ ] Update LEARNINGS.md with discoveries
+- [ ] Close out task
+
+---
+
+## Success Criteria
+
+- [ ] Can click different nodes without canvas clear workaround
+- [ ] Blockly tabs close without ever deleting nodes
+- [ ] Blockly UI is polished and intuitive
+- [ ] Comment system feels intentional, no accidental triggers
+- [ ] Comment preview on hover is useful
+- [ ] Double-click label renames inline, not opening comment modal
+- [ ] Code editor modal closes on outside click with auto-save
+- [ ] All existing functionality still works
+- [ ] No regressions introduced
+
+---
+
+## Files Modified (Expected)
+
+**Bug 1 & 4:**
+
+- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts`
+- `packages/noodl-editor/src/editor/src/models/sidebar/sidebarmodel.ts`
+- Property panel files
+
+**Bug 2:**
+
+- `packages/noodl-editor/src/editor/src/views/CanvasTabs/CanvasTabs.tsx`
+- `packages/noodl-editor/src/editor/src/contexts/CanvasTabsContext.tsx`
+
+**Bug 2.1:**
+
+- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/LogicBuilderWorkspaceType.ts`
+
+**Bug 3:**
+
+- `packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorNode.ts`
+- Property panel header components
+- New hover preview component
+
+**Bug 5:**
+
+- `packages/noodl-core-ui/src/components/code-editor/JavaScriptEditor.tsx`
+- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/CodeEditor/CodeEditorType.ts`
+
+---
+
+## Related Tasks
+
+- **Phase 2 Task 8:** ComponentsPanel Menu & Sheets (introduced Bug 1, 4)
+- **Phase 3 Task 12:** Blockly Integration (introduced Bug 2, 2.1)
+- **LEARNINGS.md:** Will document all discoveries
+
+---
+
+## Notes
+
+- All bugs are separate and should be researched independently
+- Bug 2 is intermittent - need to reproduce consistently first
+- Bug 3 requires UX design before implementation
+- Bug 1 and 4 likely share root cause in property panel event handling
+- Bug 5 is a quick fix - should be resolved early
+
+---
+
+_Last Updated: January 13, 2026_
diff --git a/package-lock.json b/package-lock.json
index c44feb5..bbfa59e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7610,7 +7610,6 @@
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*"
@@ -7655,9 +7654,17 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
- "dev": true,
"license": "MIT"
},
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
"node_modules/@types/express": {
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
@@ -7741,6 +7748,15 @@
"@types/node": "*"
}
},
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
+ "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
"node_modules/@types/hogan.js": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/hogan.js/-/hogan.js-3.0.5.tgz",
@@ -7889,6 +7905,15 @@
"integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==",
"license": "MIT"
},
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
"node_modules/@types/mdx": {
"version": "2.0.13",
"resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz",
@@ -7921,7 +7946,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
- "dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
@@ -7992,7 +8016,6 @@
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -8147,6 +8170,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
"node_modules/@types/uuid": {
"version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
@@ -8442,7 +8471,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
- "dev": true,
"license": "ISC"
},
"node_modules/@vercel/oidc": {
@@ -10010,6 +10038,16 @@
"@babel/core": "^7.0.0"
}
},
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -10748,6 +10786,16 @@
"node": ">=4"
}
},
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
@@ -10792,6 +10840,46 @@
"node": ">=10"
}
},
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/chardet": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz",
@@ -11243,6 +11331,16 @@
"node": ">= 0.8"
}
},
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -12449,6 +12547,19 @@
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
+ "node_modules/decode-named-character-reference": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
+ "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/decompress-response": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz",
@@ -12719,7 +12830,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -12788,6 +12898,19 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
@@ -14123,6 +14246,16 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -14320,6 +14453,12 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -15696,6 +15835,46 @@
"node": ">= 0.4"
}
},
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
@@ -16064,6 +16243,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/html-webpack-plugin": {
"version": "5.6.5",
"resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.5.tgz",
@@ -16585,6 +16774,12 @@
"node": ">=10"
}
},
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "license": "MIT"
+ },
"node_modules/inquirer": {
"version": "8.2.7",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz",
@@ -16708,6 +16903,30 @@
"node": ">= 10"
}
},
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
@@ -16892,6 +17111,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
@@ -16984,6 +17213,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/is-interactive": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz",
@@ -19496,6 +19735,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -19668,6 +19917,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
+ "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/markdown-to-jsx": {
"version": "7.7.17",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.17.tgz",
@@ -19731,6 +19990,288 @@
"node": ">=10.13.0"
}
},
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
+ "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
+ "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
+ "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
+ "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
+ "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
+ "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
+ "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-expression": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
+ "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -20005,6 +20546,569 @@
"node": ">= 0.6"
}
},
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
+ "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
+ "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
+ "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
+ "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
+ "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-table": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
+ "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
+ "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
+ "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
+ "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -22477,6 +23581,31 @@
"node": ">=6"
}
},
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
+ "license": "MIT"
+ },
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@@ -23437,6 +24566,16 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
+ "node_modules/property-information": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
+ "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/protocols": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz",
@@ -23817,6 +24956,33 @@
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"license": "MIT"
},
+ "node_modules/react-markdown": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
+ "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
"node_modules/react-rnd": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/react-rnd/-/react-rnd-10.5.2.tgz",
@@ -24598,6 +25764,72 @@
"node": ">= 0.10"
}
},
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+ "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-stringify": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
+ "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remarkable": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/remarkable/-/remarkable-2.0.1.tgz",
@@ -26264,6 +27496,16 @@
"source-map": "^0.6.0"
}
},
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/spawn-command": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
@@ -26653,6 +27895,20 @@
"node": ">=4.0.0"
}
},
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -26766,6 +28022,24 @@
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
+ "node_modules/style-to-js": {
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
+ "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.14"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
"node_modules/sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
@@ -27254,6 +28528,16 @@
"tree-kill": "cli.js"
}
},
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/trim-newlines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz",
@@ -27264,6 +28548,16 @@
"node": ">=8"
}
},
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/truncate-utf8-bytes": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
@@ -28062,6 +29356,37 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unified/node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/unique-filename": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz",
@@ -28088,6 +29413,74 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz",
+ "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/universal-user-agent": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz",
@@ -28460,6 +29853,34 @@
"license": "MIT",
"optional": true
},
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
@@ -29509,6 +30930,16 @@
"zod": "^3.24.1"
}
},
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"packages/noodl-core-ui": {
"name": "@noodl/noodl-core-ui",
"version": "2.7.0",
@@ -29609,7 +31040,9 @@
"react-dom": "19.0.0",
"react-hot-toast": "^2.6.0",
"react-instantsearch": "^7.16.2",
+ "react-markdown": "^9.1.0",
"react-rnd": "^10.5.2",
+ "remark-gfm": "^4.0.1",
"remarkable": "^2.0.1",
"s3": "github:noodlapp/node-s3-client",
"string.prototype.matchall": "^4.0.12",
diff --git a/packages/noodl-core-ui/src/components/inputs/TextInput/TextInput.module.scss b/packages/noodl-core-ui/src/components/inputs/TextInput/TextInput.module.scss
index e806765..90be63e 100644
--- a/packages/noodl-core-ui/src/components/inputs/TextInput/TextInput.module.scss
+++ b/packages/noodl-core-ui/src/components/inputs/TextInput/TextInput.module.scss
@@ -109,6 +109,7 @@
.InputWrapper {
overflow-x: hidden;
+ overflow-y: hidden; // Prevent tiny vertical scrollbar on single-line inputs
flex-grow: 1;
padding-top: 1px;
}
diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/Launcher.tsx b/packages/noodl-core-ui/src/preview/launcher/Launcher/Launcher.tsx
index 94e0c64..02cbc54 100644
--- a/packages/noodl-core-ui/src/preview/launcher/Launcher/Launcher.tsx
+++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/Launcher.tsx
@@ -41,6 +41,8 @@ export interface LauncherProps {
onLaunchProject?: (projectId: string) => void;
onOpenProjectFolder?: (projectId: string) => void;
onDeleteProject?: (projectId: string) => void;
+ onMigrateProject?: (projectId: string) => void;
+ onOpenReadOnly?: (projectId: string) => void;
// Project organization service (optional - for Storybook compatibility)
projectOrganizationService?: any;
@@ -178,6 +180,8 @@ export function Launcher({
onLaunchProject,
onOpenProjectFolder,
onDeleteProject,
+ onMigrateProject,
+ onOpenReadOnly,
projectOrganizationService,
githubUser,
githubIsAuthenticated,
@@ -285,6 +289,8 @@ export function Launcher({
onLaunchProject,
onOpenProjectFolder,
onDeleteProject,
+ onMigrateProject,
+ onOpenReadOnly,
githubUser,
githubIsAuthenticated,
githubIsConnecting,
diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/LauncherContext.tsx b/packages/noodl-core-ui/src/preview/launcher/Launcher/LauncherContext.tsx
index 3612708..616ac27 100644
--- a/packages/noodl-core-ui/src/preview/launcher/Launcher/LauncherContext.tsx
+++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/LauncherContext.tsx
@@ -43,6 +43,8 @@ export interface LauncherContextValue {
onLaunchProject?: (projectId: string) => void;
onOpenProjectFolder?: (projectId: string) => void;
onDeleteProject?: (projectId: string) => void;
+ onMigrateProject?: (projectId: string) => void;
+ onOpenReadOnly?: (projectId: string) => void;
// GitHub OAuth integration (optional - for Storybook compatibility)
githubUser?: GitHubUser | null;
diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/LauncherProjectCard/LauncherProjectCard.module.scss b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/LauncherProjectCard/LauncherProjectCard.module.scss
index f429b1c..5fbdc3a 100644
--- a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/LauncherProjectCard/LauncherProjectCard.module.scss
+++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/LauncherProjectCard/LauncherProjectCard.module.scss
@@ -17,3 +17,32 @@
.VersionControlTooltip {
cursor: default;
}
+
+// Legacy project styles
+.LegacyCard {
+ border-color: var(--theme-color-border-danger) !important;
+
+ &:hover {
+ border-color: var(--theme-color-border-danger) !important;
+ }
+}
+
+.LegacyBanner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-2);
+ margin-top: var(--spacing-3);
+ padding: var(--spacing-2) var(--spacing-3);
+ background-color: var(--theme-color-bg-danger-subtle);
+ border: 1px solid var(--theme-color-border-danger);
+ border-radius: var(--border-radius-medium);
+}
+
+.LegacyDetails {
+ margin-top: var(--spacing-2);
+ padding: var(--spacing-3);
+ background-color: var(--theme-color-bg-2);
+ border-radius: var(--border-radius-medium);
+ border: 1px solid var(--theme-color-border-default);
+}
diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/LauncherProjectCard/LauncherProjectCard.tsx b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/LauncherProjectCard/LauncherProjectCard.tsx
index 9e2dbb7..24c0ff8 100644
--- a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/LauncherProjectCard/LauncherProjectCard.tsx
+++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/LauncherProjectCard/LauncherProjectCard.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
import { FeedbackType } from '@noodl-constants/FeedbackType';
@@ -23,6 +23,13 @@ import { useProjectOrganization } from '../../hooks/useProjectOrganization';
import { TagPill, TagPillSize } from '../TagPill';
import css from './LauncherProjectCard.module.scss';
+// Runtime version detection types
+export interface RuntimeVersionInfo {
+ version: 'react17' | 'react19' | 'unknown';
+ confidence: 'high' | 'medium' | 'low';
+ indicators: string[];
+}
+
// FIXME: Use the timeSince function from the editor package when this is moved there
function timeSince(date: Date | number) {
const date_unix = typeof date === 'number' ? date : date.getTime();
@@ -71,11 +78,15 @@ export interface LauncherProjectData {
uncommittedChangesAmount?: number;
imageSrc: string;
contributors?: UserBadgeProps[];
+ runtimeInfo?: RuntimeVersionInfo;
}
export interface LauncherProjectCardProps extends LauncherProjectData {
contextMenuItems: ContextMenuProps[];
onClick?: () => void;
+ runtimeInfo?: RuntimeVersionInfo;
+ onMigrateProject?: () => void;
+ onOpenReadOnly?: () => void;
}
export function LauncherProjectCard({
@@ -90,25 +101,54 @@ export function LauncherProjectCard({
imageSrc,
contextMenuItems,
contributors,
- onClick
+ onClick,
+ runtimeInfo,
+ onMigrateProject,
+ onOpenReadOnly
}: LauncherProjectCardProps) {
const { tags, getProjectMeta } = useProjectOrganization();
+ const [showLegacyDetails, setShowLegacyDetails] = useState(false);
// Get project tags
const projectMeta = getProjectMeta(localPath);
const projectTags = projectMeta ? tags.filter((tag) => projectMeta.tagIds.includes(tag.id)) : [];
+ // Determine if this is a legacy project
+ const isLegacy = runtimeInfo?.version === 'react17';
+ const isDetecting = runtimeInfo === undefined;
+
return (
-
+ {
+ // Auto-expand details when user clicks legacy project
+ setShowLegacyDetails(true);
+ }
+ : onClick
+ }
+ UNSAFE_className={isLegacy ? css.LegacyCard : undefined}
+ >
-
- {title}
-
+
+
+ {title}
+
+
+ {/* Legacy warning icon */}
+ {isLegacy && (
+
+
+
+ )}
+
{/* Tags */}
{projectTags.length > 0 && (
@@ -219,6 +259,66 @@ export function LauncherProjectCard({
)}
+
+ {/* Legacy warning banner */}
+ {isLegacy && (
+
+
+
+ React 17 (Legacy Runtime)
+
+
+ {
+ e.stopPropagation();
+ setShowLegacyDetails(!showLegacyDetails);
+ }}
+ />
+
+ )}
+
+ {/* Expanded legacy details */}
+ {isLegacy && showLegacyDetails && (
+
+
+
+
+ {
+ e.stopPropagation();
+ onMigrateProject?.();
+ }}
+ />
+
+ {
+ e.stopPropagation();
+ onOpenReadOnly?.();
+ }}
+ />
+
+ {
+ e.stopPropagation();
+ // TODO: Open documentation
+ window.open('https://docs.opennoodl.com/migration', '_blank');
+ }}
+ />
+
+
+ )}
diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/views/Projects.tsx b/packages/noodl-core-ui/src/preview/launcher/Launcher/views/Projects.tsx
index db9c625..4c5737b 100644
--- a/packages/noodl-core-ui/src/preview/launcher/Launcher/views/Projects.tsx
+++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/views/Projects.tsx
@@ -36,7 +36,9 @@ export function Projects({}: ProjectsViewProps) {
onOpenProject,
onLaunchProject,
onOpenProjectFolder,
- onDeleteProject
+ onDeleteProject,
+ onMigrateProject,
+ onOpenReadOnly
} = useLauncherContext();
const { getProjectMeta, getProjectsInFolder, folders, moveProjectToFolder } = useProjectOrganization();
@@ -189,6 +191,8 @@ export function Projects({}: ProjectsViewProps) {
key={project.id}
{...project}
onClick={() => onLaunchProject?.(project.id)}
+ onMigrateProject={() => onMigrateProject?.(project.id)}
+ onOpenReadOnly={() => onOpenReadOnly?.(project.id)}
contextMenuItems={[
{
label: 'Launch project',
diff --git a/packages/noodl-editor/package.json b/packages/noodl-editor/package.json
index 7f4f12b..21e848a 100644
--- a/packages/noodl-editor/package.json
+++ b/packages/noodl-editor/package.json
@@ -94,7 +94,9 @@
"react-dom": "19.0.0",
"react-hot-toast": "^2.6.0",
"react-instantsearch": "^7.16.2",
+ "react-markdown": "^9.1.0",
"react-rnd": "^10.5.2",
+ "remark-gfm": "^4.0.1",
"remarkable": "^2.0.1",
"s3": "github:noodlapp/node-s3-client",
"string.prototype.matchall": "^4.0.12",
diff --git a/packages/noodl-editor/src/editor/src/contexts/NodeGraphContext/NodeGraphContext.tsx b/packages/noodl-editor/src/editor/src/contexts/NodeGraphContext/NodeGraphContext.tsx
index add9451..d1bcd05 100644
--- a/packages/noodl-editor/src/editor/src/contexts/NodeGraphContext/NodeGraphContext.tsx
+++ b/packages/noodl-editor/src/editor/src/contexts/NodeGraphContext/NodeGraphContext.tsx
@@ -1,11 +1,13 @@
import React, { createContext, useContext, useCallback, useState, useEffect } from 'react';
import { ComponentModel } from '@noodl-models/componentmodel';
+import { ProjectModel } from '@noodl-models/projectmodel';
import { SidebarModel } from '@noodl-models/sidebar';
import { isComponentModel_CloudRuntime } from '@noodl-utils/NodeGraph';
import { Slot } from '@noodl-core-ui/types/global';
+import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
import { CenterToFitMode, NodeGraphEditor } from '../../views/nodegrapheditor';
type NodeGraphID = 'frontend' | 'backend';
@@ -72,6 +74,29 @@ export function NodeGraphContextProvider({ children }: NodeGraphContextProviderP
};
}, []);
+ // Detect and apply read-only mode from ProjectModel
+ useEffect(() => {
+ if (!nodeGraph) return;
+
+ const eventGroup = {};
+
+ // Apply read-only mode when project instance changes
+ const updateReadOnlyMode = () => {
+ const isReadOnly = ProjectModel.instance?._isReadOnly || false;
+ nodeGraph.setReadOnly(isReadOnly);
+ };
+
+ // Listen for project changes
+ EventDispatcher.instance.on('ProjectModel.instanceHasChanged', updateReadOnlyMode, eventGroup);
+
+ // Apply immediately if project is already loaded
+ updateReadOnlyMode();
+
+ return () => {
+ EventDispatcher.instance.off(eventGroup);
+ };
+ }, [nodeGraph]);
+
const switchToComponent: NodeGraphControlContext['switchToComponent'] = useCallback(
(component, options) => {
if (!component) return;
diff --git a/packages/noodl-editor/src/editor/src/models/migration/ProjectScanner.ts b/packages/noodl-editor/src/editor/src/models/migration/ProjectScanner.ts
index aa625bb..97a7900 100644
--- a/packages/noodl-editor/src/editor/src/models/migration/ProjectScanner.ts
+++ b/packages/noodl-editor/src/editor/src/models/migration/ProjectScanner.ts
@@ -220,10 +220,13 @@ async function getProjectCreationDate(_projectPath: string): Promise {
const indicators: string[] = [];
+ console.log('🔍 [detectRuntimeVersion] Starting detection for:', projectPath);
+
// Read project.json
const projectJson = await readProjectJson(projectPath);
if (!projectJson) {
+ console.log('❌ [detectRuntimeVersion] Could not read project.json');
return {
version: 'unknown',
confidence: 'low',
@@ -231,6 +234,15 @@ export async function detectRuntimeVersion(projectPath: string): Promise void
+ onProgress?: (
+ progress: number,
+ currentItem: string,
+ stats: { components: number; nodes: number; jsFiles: number }
+ ) => void
): Promise {
const projectJson = await readProjectJson(projectPath);
@@ -478,9 +493,7 @@ export async function scanProjectForMigration(
// Scan JavaScript files for issues
const allFiles = await listFilesRecursively(projectPath);
- const jsFiles = allFiles.filter(
- (file) => /\.(js|jsx|ts|tsx)$/.test(file) && !file.includes('node_modules')
- );
+ const jsFiles = allFiles.filter((file) => /\.(js|jsx|ts|tsx)$/.test(file) && !file.includes('node_modules'));
stats.jsFiles = jsFiles.length;
// Group issues by file/component
@@ -610,12 +623,6 @@ function estimateAICost(issueCount: number): number {
// Exports
// =============================================================================
-export {
- LEGACY_PATTERNS,
- REACT19_MIN_VERSION,
- OPENNOODL_FORK_DATE,
- readProjectJson,
- compareVersions
-};
+export { LEGACY_PATTERNS, REACT19_MIN_VERSION, OPENNOODL_FORK_DATE, readProjectJson, compareVersions };
export type { ProjectJson };
diff --git a/packages/noodl-editor/src/editor/src/models/projectmodel.ts b/packages/noodl-editor/src/editor/src/models/projectmodel.ts
index 63e06db..98bdb61 100644
--- a/packages/noodl-editor/src/editor/src/models/projectmodel.ts
+++ b/packages/noodl-editor/src/editor/src/models/projectmodel.ts
@@ -97,7 +97,9 @@ export class ProjectModel extends Model {
public id?: string;
public name?: string;
public version?: string;
+ public runtimeVersion?: 'react17' | 'react19';
public _retainedProjectDirectory?: string;
+ public _isReadOnly?: boolean; // Flag for read-only mode (legacy projects)
public settings?: ProjectSettings;
public metadata?: TSFixme;
public components: ComponentModel[];
@@ -121,10 +123,16 @@ export class ProjectModel extends Model {
this.settings = args.settings;
// this.thumbnailURI = args.thumbnailURI;
this.version = args.version;
+ this.runtimeVersion = args.runtimeVersion;
this.metadata = args.metadata;
// this.deviceSettings = args.deviceSettings;
}
+ // NOTE: runtimeVersion is NOT auto-defaulted here!
+ // - New projects: Explicitly set to 'react19' in LocalProjectsModel.newProject()
+ // - Old projects: Left undefined, detected by runtime scanner
+ // - This prevents corrupting legacy projects when they're loaded
+
NodeLibrary.instance.on(
['moduleRegistered', 'moduleUnregistered', 'libraryUpdated'],
() => {
@@ -1154,6 +1162,7 @@ export class ProjectModel extends Model {
rootNodeId: this.rootNode ? this.rootNode.id : undefined,
// thumbnailURI:this.thumbnailURI,
version: this.version,
+ runtimeVersion: this.runtimeVersion,
lesson: this.lesson ? this.lesson.toJSON() : undefined,
metadata: this.metadata,
variants: this.variants.map((v) => v.toJSON())
@@ -1246,6 +1255,12 @@ EventDispatcher.instance.on(
function saveProject() {
if (!ProjectModel.instance) return;
+ // CRITICAL: Do not save read-only projects (e.g., legacy projects opened for inspection)
+ if (ProjectModel.instance._isReadOnly) {
+ console.log('⚠️ Skipping auto-save: Project is in read-only mode');
+ return;
+ }
+
if (ProjectModel.instance._retainedProjectDirectory) {
// Project is loaded from directory, save it
ProjectModel.instance.toDirectory(ProjectModel.instance._retainedProjectDirectory, function (r) {
diff --git a/packages/noodl-editor/src/editor/src/pages/AppRouter.ts b/packages/noodl-editor/src/editor/src/pages/AppRouter.ts
index 456ef63..2330e77 100644
--- a/packages/noodl-editor/src/editor/src/pages/AppRouter.ts
+++ b/packages/noodl-editor/src/editor/src/pages/AppRouter.ts
@@ -5,6 +5,7 @@ export interface AppRouteOptions {
from?: string;
uri?: string;
project?: ProjectModel;
+ readOnly?: boolean; // Flag to open project in read-only mode (for legacy projects)
}
/** TODO: This will replace Router later */
diff --git a/packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx b/packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx
index 8108277..31d290a 100644
--- a/packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx
+++ b/packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx
@@ -17,9 +17,13 @@ import {
import { Launcher } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
import { useEventListener } from '../../hooks/useEventListener';
+import { DialogLayerModel } from '../../models/DialogLayerModel';
+import { detectRuntimeVersion } from '../../models/migration/ProjectScanner';
import { IRouteProps } from '../../pages/AppRoute';
import { ProjectOrganizationService } from '../../services/ProjectOrganizationService';
-import { LocalProjectsModel, ProjectItem } from '../../utils/LocalProjectsModel';
+import { LocalProjectsModel, ProjectItemWithRuntime } from '../../utils/LocalProjectsModel';
+import { tracker } from '../../utils/tracker';
+import { MigrationWizard } from '../../views/migration/MigrationWizard';
import { ToastLayer } from '../../views/ToastLayer/ToastLayer';
export interface ProjectsPageProps extends IRouteProps {
@@ -27,9 +31,9 @@ export interface ProjectsPageProps extends IRouteProps {
}
/**
- * Map LocalProjectsModel ProjectItem to LauncherProjectData format
+ * Map LocalProjectsModel ProjectItemWithRuntime to LauncherProjectData format
*/
-function mapProjectToLauncherData(project: ProjectItem): LauncherProjectData {
+function mapProjectToLauncherData(project: ProjectItemWithRuntime): LauncherProjectData {
return {
id: project.id,
title: project.name || 'Untitled',
@@ -38,7 +42,9 @@ function mapProjectToLauncherData(project: ProjectItem): LauncherProjectData {
imageSrc: project.thumbURI || 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg"%3E%3C/svg%3E',
cloudSyncMeta: {
type: CloudSyncType.None // TODO: Detect git repos in future
- }
+ },
+ // Include runtime info for legacy detection
+ runtimeInfo: project.runtimeInfo
// Git-related fields will be populated in future tasks
};
}
@@ -55,10 +61,16 @@ export function ProjectsPage(props: ProjectsPageProps) {
// Switch main window size to editor size
ipcRenderer.send('main-window-resize', { size: 'editor', center: true });
- // Load projects
+ // Load projects with runtime detection
const loadProjects = async () => {
await LocalProjectsModel.instance.fetch();
- const projects = LocalProjectsModel.instance.getProjects();
+
+ // Trigger background runtime detection for all projects
+ LocalProjectsModel.instance.detectAllProjectRuntimes();
+
+ // Get projects (detection runs in background, will update via events)
+ const projects = LocalProjectsModel.instance.getProjectsWithRuntime();
+ console.log('🔵 Projects loaded, triggering runtime detection for:', projects.length);
setRealProjects(projects.map(mapProjectToLauncherData));
};
@@ -67,8 +79,15 @@ export function ProjectsPage(props: ProjectsPageProps) {
// Subscribe to project list changes
useEventListener(LocalProjectsModel.instance, 'myProjectsChanged', () => {
- console.log('🔔 Projects list changed, updating dashboard');
- const projects = LocalProjectsModel.instance.getProjects();
+ console.log('🔔 Projects list changed, updating dashboard with runtime detection');
+ const projects = LocalProjectsModel.instance.getProjectsWithRuntime();
+ setRealProjects(projects.map(mapProjectToLauncherData));
+ });
+
+ // Subscribe to runtime detection completion to update UI
+ useEventListener(LocalProjectsModel.instance, 'runtimeDetectionComplete', (projectPath: string, runtimeInfo) => {
+ console.log('🎯 Runtime detection complete for:', projectPath, runtimeInfo);
+ const projects = LocalProjectsModel.instance.getProjectsWithRuntime();
setRealProjects(projects.map(mapProjectToLauncherData));
});
@@ -136,60 +155,212 @@ export function ProjectsPage(props: ProjectsPageProps) {
return;
}
+ // Check if this project is already in the list
+ const existingProjects = LocalProjectsModel.instance.getProjects();
+ const isExisting = existingProjects.some((p) => p.retainedProjectDirectory === direntry);
+
+ // If project is new, check for legacy runtime before opening
+ if (!isExisting) {
+ console.log('🔵 [handleOpenProject] New project detected, checking runtime...');
+ const activityId = 'checking-compatibility';
+ ToastLayer.showActivity('Checking project compatibility...', activityId);
+
+ try {
+ const runtimeInfo = await detectRuntimeVersion(direntry);
+ ToastLayer.hideActivity(activityId);
+
+ console.log('🔵 [handleOpenProject] Runtime detected:', runtimeInfo);
+
+ // If legacy or unknown, show warning dialog
+ if (runtimeInfo.version === 'react17' || runtimeInfo.version === 'unknown') {
+ const projectName = filesystem.basename(direntry);
+
+ // Show legacy project warning dialog
+ const userChoice = await new Promise<'migrate' | 'readonly' | 'cancel'>((resolve) => {
+ const confirmed = confirm(
+ `⚠️ Legacy Project Detected\n\n` +
+ `This project "${projectName}" was created with an earlier version of Noodl (React 17).\n\n` +
+ `OpenNoodl uses React 19, which requires migrating your project to ensure compatibility.\n\n` +
+ `What would you like to do?\n\n` +
+ `OK - Migrate Project (Recommended)\n` +
+ `Cancel - View options`
+ );
+
+ if (confirmed) {
+ resolve('migrate');
+ } else {
+ // Show second dialog for Read-Only or Cancel
+ const openReadOnly = confirm(
+ `Would you like to open this project in Read-Only mode?\n\n` +
+ `You can inspect the project safely without making changes.\n\n` +
+ `OK - Open Read-Only\n` +
+ `Cancel - Return to launcher`
+ );
+
+ if (openReadOnly) {
+ resolve('readonly');
+ } else {
+ resolve('cancel');
+ }
+ }
+ });
+
+ console.log('🔵 [handleOpenProject] User choice:', userChoice);
+
+ if (userChoice === 'cancel') {
+ console.log('🔵 [handleOpenProject] User cancelled');
+ return;
+ }
+
+ if (userChoice === 'migrate') {
+ // Launch migration wizard
+ tracker.track('Legacy Project Migration Started from Open', {
+ projectName
+ });
+
+ DialogLayerModel.instance.showDialog(
+ (close) =>
+ React.createElement(MigrationWizard, {
+ sourcePath: direntry,
+ projectName,
+ onComplete: async (targetPath: string) => {
+ close();
+
+ const migrateActivityId = 'opening-migrated';
+ ToastLayer.showActivity('Opening migrated project', migrateActivityId);
+
+ try {
+ // Add migrated project and open it
+ const migratedProject = await LocalProjectsModel.instance.openProjectFromFolder(targetPath);
+
+ if (!migratedProject.name) {
+ migratedProject.name = projectName + ' (React 19)';
+ }
+
+ // Refresh and detect runtimes
+ await LocalProjectsModel.instance.fetch();
+ await LocalProjectsModel.instance.detectProjectRuntime(targetPath);
+ LocalProjectsModel.instance.detectAllProjectRuntimes();
+
+ const projects = LocalProjectsModel.instance.getProjects();
+ const projectEntry = projects.find((p) => p.id === migratedProject.id);
+
+ if (projectEntry) {
+ const loaded = await LocalProjectsModel.instance.loadProject(projectEntry);
+ ToastLayer.hideActivity(migrateActivityId);
+
+ if (loaded) {
+ ToastLayer.showSuccess('Project migrated and opened successfully!');
+ props.route.router.route({ to: 'editor', project: loaded });
+ }
+ }
+ } catch (error) {
+ ToastLayer.hideActivity(migrateActivityId);
+ ToastLayer.showError('Could not open migrated project');
+ console.error(error);
+ }
+ },
+ onCancel: () => {
+ close();
+ }
+ }),
+ {
+ onClose: () => {
+ LocalProjectsModel.instance.fetch();
+ }
+ }
+ );
+
+ return;
+ }
+
+ // If read-only, continue to open normally (will add to list with legacy badge)
+ tracker.track('Legacy Project Opened Read-Only from Open', {
+ projectName
+ });
+
+ // CRITICAL: Open the project in read-only mode
+ const readOnlyActivityId = 'opening-project-readonly';
+ ToastLayer.showActivity('Opening project in read-only mode', readOnlyActivityId);
+
+ const readOnlyProject = await LocalProjectsModel.instance.openProjectFromFolder(direntry);
+
+ if (!readOnlyProject) {
+ ToastLayer.hideActivity(readOnlyActivityId);
+ ToastLayer.showError('Could not open project');
+ return;
+ }
+
+ if (!readOnlyProject.name) {
+ readOnlyProject.name = filesystem.basename(direntry);
+ }
+
+ const readOnlyProjects = LocalProjectsModel.instance.getProjects();
+ const readOnlyProjectEntry = readOnlyProjects.find((p) => p.id === readOnlyProject.id);
+
+ if (!readOnlyProjectEntry) {
+ ToastLayer.hideActivity(readOnlyActivityId);
+ ToastLayer.showError('Could not find project in recent list');
+ return;
+ }
+
+ const loadedReadOnly = await LocalProjectsModel.instance.loadProject(readOnlyProjectEntry);
+ ToastLayer.hideActivity(readOnlyActivityId);
+
+ if (!loadedReadOnly) {
+ ToastLayer.showError('Could not load project');
+ return;
+ }
+
+ // Show persistent warning toast (stays forever with Infinity default)
+ ToastLayer.showError('⚠️ READ-ONLY MODE - No changes will be saved to this legacy project');
+
+ // Route to editor with read-only flag
+ props.route.router.route({ to: 'editor', project: loadedReadOnly, readOnly: true });
+ return; // Exit early - don't continue to normal flow
+ }
+ } catch (error) {
+ ToastLayer.hideActivity(activityId);
+ console.error('Failed to detect runtime:', error);
+ // Continue opening anyway if detection fails
+ }
+ }
+
+ // Proceed with normal opening flow (non-legacy or legacy with migrate choice)
const activityId = 'opening-project';
- console.log('🔵 [handleOpenProject] Showing activity toast');
ToastLayer.showActivity('Opening project', activityId);
- console.log('🔵 [handleOpenProject] Calling openProjectFromFolder...');
- // openProjectFromFolder adds the project to recent list and returns ProjectModel
const project = await LocalProjectsModel.instance.openProjectFromFolder(direntry);
- console.log('🔵 [handleOpenProject] Got project:', project);
if (!project) {
- console.log('🔴 [handleOpenProject] Project is null/undefined');
ToastLayer.hideActivity(activityId);
ToastLayer.showError('Could not open project');
return;
}
if (!project.name) {
- console.log('🔵 [handleOpenProject] Setting project name from folder');
project.name = filesystem.basename(direntry);
}
- console.log('🔵 [handleOpenProject] Getting projects list...');
- // Now we need to find the project entry that was just added and load it
const projects = LocalProjectsModel.instance.getProjects();
- console.log('🔵 [handleOpenProject] Projects in list:', projects.length);
-
const projectEntry = projects.find((p) => p.id === project.id);
- console.log('🔵 [handleOpenProject] Found project entry:', projectEntry);
if (!projectEntry) {
- console.log('🔴 [handleOpenProject] Project entry not found in list');
ToastLayer.hideActivity(activityId);
ToastLayer.showError('Could not find project in recent list');
console.error('Project was added but not found in list:', project.id);
return;
}
- console.log('🔵 [handleOpenProject] Loading project...');
- // Actually load/open the project
const loaded = await LocalProjectsModel.instance.loadProject(projectEntry);
- console.log('🔵 [handleOpenProject] Project loaded:', loaded);
-
ToastLayer.hideActivity(activityId);
if (!loaded) {
- console.log('🔴 [handleOpenProject] Load result is falsy');
ToastLayer.showError('Could not load project');
} else {
- console.log('✅ [handleOpenProject] Success! Navigating to editor...');
- // Navigate to editor with the loaded project
props.route.router.route({ to: 'editor', project: loaded });
}
} catch (error) {
- console.error('🔴 [handleOpenProject] EXCEPTION:', error);
ToastLayer.hideActivity('opening-project');
console.error('Failed to open project:', error);
ToastLayer.showError('Could not open project');
@@ -256,6 +427,157 @@ export function ProjectsPage(props: ProjectsPageProps) {
}
}, []);
+ /**
+ * Handle "Migrate Project" button click - opens the migration wizard
+ */
+ const handleMigrateProject = useCallback(
+ (projectId: string) => {
+ const projects = LocalProjectsModel.instance.getProjects();
+ const project = projects.find((p) => p.id === projectId);
+ if (!project || !project.retainedProjectDirectory) {
+ ToastLayer.showError('Cannot migrate project: path not found');
+ return;
+ }
+
+ const projectPath = project.retainedProjectDirectory;
+
+ // Show the migration wizard as a dialog
+ DialogLayerModel.instance.showDialog(
+ (close) =>
+ React.createElement(MigrationWizard, {
+ sourcePath: projectPath,
+ projectName: project.name,
+ onComplete: async (targetPath: string) => {
+ close();
+ // Clear runtime cache for the source project
+ LocalProjectsModel.instance.clearRuntimeCache(projectPath);
+
+ // Show activity indicator
+ const activityId = 'adding-migrated-project';
+ ToastLayer.showActivity('Adding migrated project to list', activityId);
+
+ try {
+ // Add the migrated project to the projects list
+ const migratedProject = await LocalProjectsModel.instance.openProjectFromFolder(targetPath);
+
+ if (!migratedProject.name) {
+ migratedProject.name = project.name + ' (React 19)';
+ }
+
+ // Refresh the projects list to show both projects
+ await LocalProjectsModel.instance.fetch();
+
+ // Trigger runtime detection for both projects to update UI immediately
+ await LocalProjectsModel.instance.detectProjectRuntime(projectPath);
+ await LocalProjectsModel.instance.detectProjectRuntime(targetPath);
+
+ // Force a full re-detection to update the UI with correct runtime info
+ LocalProjectsModel.instance.detectAllProjectRuntimes();
+
+ ToastLayer.hideActivity(activityId);
+
+ // Ask user if they want to archive the original
+ const shouldArchive = confirm(
+ `Migration successful!\n\n` +
+ `Would you like to move the original project to a "Legacy Projects" folder?\n\n` +
+ `The original will be preserved but organized separately. You can access it anytime from the Legacy Projects category.`
+ );
+
+ if (shouldArchive) {
+ // Get or create "Legacy Projects" folder
+ let legacyFolder = ProjectOrganizationService.instance
+ .getFolders()
+ .find((f) => f.name === 'Legacy Projects');
+
+ if (!legacyFolder) {
+ legacyFolder = ProjectOrganizationService.instance.createFolder('Legacy Projects');
+ }
+
+ // Move original project to Legacy folder
+ ProjectOrganizationService.instance.moveProjectToFolder(projectPath, legacyFolder.id);
+
+ ToastLayer.showSuccess(
+ `"${migratedProject.name}" is ready! Original moved to Legacy Projects folder.`
+ );
+
+ tracker.track('Legacy Project Archived', {
+ projectName: project.name
+ });
+ } else {
+ ToastLayer.showSuccess(`"${migratedProject.name}" is now in your projects list!`);
+ }
+
+ // Stay in launcher - user can now see both projects and choose which to open
+ tracker.track('Migration Completed', {
+ projectName: project.name,
+ archivedOriginal: shouldArchive
+ });
+ } catch (error) {
+ ToastLayer.hideActivity(activityId);
+ ToastLayer.showError('Project migrated but could not be added to list. Try opening it manually.');
+ console.error('Failed to add migrated project:', error);
+ // Refresh project list anyway
+ LocalProjectsModel.instance.fetch();
+ }
+ },
+ onCancel: () => {
+ close();
+ }
+ }),
+ {
+ onClose: () => {
+ // Refresh project list when dialog closes
+ LocalProjectsModel.instance.fetch();
+ }
+ }
+ );
+
+ tracker.track('Migration Wizard Opened', {
+ projectName: project.name
+ });
+ },
+ [props.route]
+ );
+
+ /**
+ * Handle "Open Read-Only" button click - opens legacy project without migration
+ */
+ const handleOpenReadOnly = useCallback(
+ async (projectId: string) => {
+ const projects = LocalProjectsModel.instance.getProjects();
+ const project = projects.find((p) => p.id === projectId);
+ if (!project) return;
+
+ const activityId = 'opening-project-readonly';
+ ToastLayer.showActivity('Opening project in read-only mode', activityId);
+
+ try {
+ const loaded = await LocalProjectsModel.instance.loadProject(project);
+ ToastLayer.hideActivity(activityId);
+
+ if (!loaded) {
+ ToastLayer.showError("Couldn't load project.");
+ return;
+ }
+
+ tracker.track('Legacy Project Opened Read-Only', {
+ projectName: project.name
+ });
+
+ // Show persistent warning about read-only mode (stays forever with Infinity default)
+ ToastLayer.showError('⚠️ READ-ONLY MODE - No changes will be saved to this legacy project');
+
+ // Open the project in read-only mode
+ props.route.router.route({ to: 'editor', project: loaded, readOnly: true });
+ } catch (error) {
+ ToastLayer.hideActivity(activityId);
+ ToastLayer.showError('Could not open project');
+ console.error('Failed to open legacy project:', error);
+ }
+ },
+ [props.route]
+ );
+
return (
<>
{
+ data: T;
+ timestamp: number;
+ etag?: string;
+}
+
+/**
+ * Rate limit warning threshold (percentage)
+ */
+const RATE_LIMIT_WARNING_THRESHOLD = 0.1; // Warn at 10% remaining
+
+/**
+ * Default cache TTL in milliseconds
+ */
+const DEFAULT_CACHE_TTL = 30000; // 30 seconds
+
+/**
+ * Maximum cache size (number of entries)
+ */
+const MAX_CACHE_SIZE = 100;
+
+/**
+ * GitHub API client with rate limiting, caching, and error handling
+ */
+export class GitHubClient extends EventDispatcher {
+ private static _instance: GitHubClient;
private octokit: Octokit | null = null;
- private lastRateLimit: GitHubRateLimit | null = null;
+ private cache: Map> = new Map();
+ private rateLimit: GitHubRateLimit | null = null;
+ private authService: GitHubOAuthService;
+
+ private constructor() {
+ super();
+ this.authService = GitHubOAuthService.instance;
+
+ // Listen for auth changes
+ this.authService.on('auth-state-changed', this.handleAuthChange.bind(this), this);
+ this.authService.on('disconnected', this.handleDisconnect.bind(this), this);
+
+ // Initialize if already authenticated
+ if (this.authService.isAuthenticated()) {
+ this.initializeOctokit();
+ }
+ }
+
+ static get instance(): GitHubClient {
+ if (!GitHubClient._instance) {
+ GitHubClient._instance = new GitHubClient();
+ }
+ return GitHubClient._instance;
+ }
/**
- * Initialize Octokit instance with current auth token
- *
- * @returns Octokit instance or null if not authenticated
+ * Handle authentication state changes
*/
- private getOctokit(): Octokit | null {
- const token = GitHubAuth.getAccessToken();
+ private handleAuthChange(event: { authenticated: boolean }): void {
+ if (event.authenticated) {
+ this.initializeOctokit();
+ } else {
+ this.octokit = null;
+ this.clearCache();
+ }
+ }
+
+ /**
+ * Handle disconnection
+ */
+ private handleDisconnect(): void {
+ this.octokit = null;
+ this.clearCache();
+ this.rateLimit = null;
+ }
+
+ /**
+ * Initialize Octokit with current auth token
+ */
+ private async initializeOctokit(): Promise {
+ const token = await this.authService.getToken();
if (!token) {
- console.warn('[GitHub Client] Not authenticated');
- return null;
+ throw new Error('No authentication token available');
}
- // Create new instance if token changed or doesn't exist
+ this.octokit = new Octokit({
+ auth: token,
+ userAgent: 'OpenNoodl/1.1.0'
+ });
+
+ // Fetch initial rate limit info
+ await this.updateRateLimit();
+ }
+
+ /**
+ * Ensure client is authenticated and initialized
+ */
+ private async ensureAuthenticated(): Promise {
if (!this.octokit) {
- this.octokit = new Octokit({
- auth: token,
- userAgent: 'OpenNoodl/1.1.0'
- });
+ await this.initializeOctokit();
+ }
+
+ if (!this.octokit) {
+ throw new Error('GitHub client not authenticated');
}
return this.octokit;
}
/**
- * Check if client is ready (authenticated)
- *
- * @returns True if client has valid auth token
+ * Update rate limit information from response headers
*/
- isReady(): boolean {
- return GitHubAuth.isAuthenticated();
+ private updateRateLimitFromHeaders(headers: Record): void {
+ if (headers['x-ratelimit-limit']) {
+ this.rateLimit = {
+ limit: parseInt(headers['x-ratelimit-limit'], 10),
+ remaining: parseInt(headers['x-ratelimit-remaining'], 10),
+ reset: parseInt(headers['x-ratelimit-reset'], 10),
+ used: parseInt(headers['x-ratelimit-used'] || '0', 10)
+ };
+
+ // Emit warning if approaching limit
+ if (this.rateLimit.remaining / this.rateLimit.limit < RATE_LIMIT_WARNING_THRESHOLD) {
+ this.notifyListeners('rate-limit-warning', { rateLimit: this.rateLimit });
+ }
+
+ // Emit event with current rate limit
+ this.notifyListeners('rate-limit-updated', { rateLimit: this.rateLimit });
+ }
}
/**
- * Get current rate limit status
- *
- * @returns Rate limit information
- * @throws {Error} If not authenticated
+ * Fetch current rate limit status
*/
- async getRateLimit(): Promise {
- const octokit = this.getOctokit();
- if (!octokit) {
- throw new Error('Not authenticated with GitHub');
- }
-
+ async updateRateLimit(): Promise {
+ const octokit = await this.ensureAuthenticated();
const response = await octokit.rateLimit.get();
- const core = response.data.resources.core;
- const rateLimit: GitHubRateLimit = {
- limit: core.limit,
- remaining: core.remaining,
- reset: core.reset,
- resource: 'core'
+ this.rateLimit = {
+ limit: response.data.rate.limit,
+ remaining: response.data.rate.remaining,
+ reset: response.data.rate.reset,
+ used: response.data.rate.used
};
- this.lastRateLimit = rateLimit;
- return rateLimit;
+ return this.rateLimit;
}
/**
- * Check if we're approaching rate limit
- *
- * @returns True if remaining requests < 100
+ * Get current rate limit info (cached)
*/
- isApproachingRateLimit(): boolean {
- if (!this.lastRateLimit) {
- return false;
- }
- return this.lastRateLimit.remaining < 100;
+ getRateLimit(): GitHubRateLimit | null {
+ return this.rateLimit;
}
/**
- * Get authenticated user's information
- *
- * @returns User information
- * @throws {Error} If not authenticated or API call fails
+ * Generate cache key
*/
- async getAuthenticatedUser(): Promise {
- const octokit = this.getOctokit();
- if (!octokit) {
- throw new Error('Not authenticated with GitHub');
+ private getCacheKey(method: string, params: unknown): string {
+ return `${method}:${JSON.stringify(params)}`;
+ }
+
+ /**
+ * Get data from cache if valid
+ */
+ private getFromCache(key: string, ttl: number = DEFAULT_CACHE_TTL): T | null {
+ const entry = this.cache.get(key) as CacheEntry | undefined;
+
+ if (!entry) {
+ return null;
}
- const response = await octokit.users.getAuthenticated();
- return response.data as GitHubUser;
+ const age = Date.now() - entry.timestamp;
+ if (age > ttl) {
+ this.cache.delete(key);
+ return null;
+ }
+
+ return entry.data;
}
+ /**
+ * Store data in cache
+ */
+ private setCache(key: string, data: T, etag?: string): void {
+ // Implement simple LRU by removing oldest entries when cache is full
+ if (this.cache.size >= MAX_CACHE_SIZE) {
+ const firstKey = this.cache.keys().next().value;
+ if (firstKey) {
+ this.cache.delete(firstKey);
+ }
+ }
+
+ this.cache.set(key, {
+ data,
+ timestamp: Date.now(),
+ etag
+ });
+ }
+
+ /**
+ * Clear all cached data
+ */
+ clearCache(): void {
+ this.cache.clear();
+ }
+
+ /**
+ * Handle API errors with user-friendly messages
+ */
+ private handleApiError(error: unknown): never {
+ if (error && typeof error === 'object' && 'status' in error) {
+ const apiError = error as { status: number; response?: { data?: GitHubApiError } };
+
+ switch (apiError.status) {
+ case 401:
+ throw new Error('Authentication failed. Please reconnect your GitHub account.');
+ case 403:
+ if (apiError.response?.data?.message?.includes('rate limit')) {
+ const resetTime = this.rateLimit ? new Date(this.rateLimit.reset * 1000) : new Date();
+ throw new Error(`Rate limit exceeded. Resets at ${resetTime.toLocaleTimeString()}`);
+ }
+ throw new Error('Access forbidden. Check repository permissions.');
+ case 404:
+ throw new Error('Repository or resource not found.');
+ case 422: {
+ const message = apiError.response?.data?.message || 'Validation failed';
+ throw new Error(`Invalid request: ${message}`);
+ }
+ default:
+ throw new Error(`GitHub API error: ${apiError.response?.data?.message || 'Unknown error'}`);
+ }
+ }
+
+ throw error;
+ }
+
+ // ==================== REPOSITORY METHODS ====================
+
/**
* Get repository information
- *
- * @param owner - Repository owner
- * @param repo - Repository name
- * @returns Repository information
- * @throws {Error} If repository not found or API call fails
*/
- async getRepository(owner: string, repo: string): Promise {
- const octokit = this.getOctokit();
- if (!octokit) {
- throw new Error('Not authenticated with GitHub');
+ async getRepository(owner: string, repo: string): Promise> {
+ const cacheKey = this.getCacheKey('getRepository', { owner, repo });
+ const cached = this.getFromCache(cacheKey, 60000); // 1 minute cache
+
+ if (cached) {
+ return { data: cached, rateLimit: this.rateLimit! };
}
- const response = await octokit.repos.get({ owner, repo });
- return response.data as GitHubRepository;
+ try {
+ const octokit = await this.ensureAuthenticated();
+ const response = await octokit.repos.get({ owner, repo });
+
+ this.updateRateLimitFromHeaders(response.headers as Record);
+ this.setCache(cacheKey, response.data);
+
+ return {
+ data: response.data as unknown as GitHubRepository,
+ rateLimit: this.rateLimit!
+ };
+ } catch (error) {
+ this.handleApiError(error);
+ }
}
/**
- * List user's repositories
- *
- * @param options - Listing options
- * @returns Array of repositories
- * @throws {Error} If not authenticated or API call fails
+ * List user repositories
*/
async listRepositories(options?: {
- visibility?: 'all' | 'public' | 'private';
+ type?: 'all' | 'owner' | 'public' | 'private' | 'member';
sort?: 'created' | 'updated' | 'pushed' | 'full_name';
+ direction?: 'asc' | 'desc';
per_page?: number;
- }): Promise {
- const octokit = this.getOctokit();
- if (!octokit) {
- throw new Error('Not authenticated with GitHub');
+ page?: number;
+ }): Promise> {
+ const cacheKey = this.getCacheKey('listRepositories', options || {});
+ const cached = this.getFromCache(cacheKey, 60000);
+
+ if (cached) {
+ return { data: cached, rateLimit: this.rateLimit! };
}
- const response = await octokit.repos.listForAuthenticatedUser({
- visibility: options?.visibility || 'all',
- sort: options?.sort || 'updated',
- per_page: options?.per_page || 30
- });
-
- return response.data as GitHubRepository[];
- }
-
- /**
- * Check if a repository exists and user has access
- *
- * @param owner - Repository owner
- * @param repo - Repository name
- * @returns True if repository exists and accessible
- */
- async repositoryExists(owner: string, repo: string): Promise {
try {
- await this.getRepository(owner, repo);
- return true;
+ const octokit = await this.ensureAuthenticated();
+ const response = await octokit.repos.listForAuthenticatedUser(options);
+
+ this.updateRateLimitFromHeaders(response.headers as Record);
+ this.setCache(cacheKey, response.data);
+
+ return {
+ data: response.data as unknown as GitHubRepository[],
+ rateLimit: this.rateLimit!
+ };
} catch (error) {
- return false;
+ this.handleApiError(error);
+ }
+ }
+
+ // ==================== ISSUE METHODS ====================
+
+ /**
+ * List issues for a repository
+ */
+ async listIssues(
+ owner: string,
+ repo: string,
+ filters?: GitHubIssueFilters
+ ): Promise> {
+ const cacheKey = this.getCacheKey('listIssues', { owner, repo, ...filters });
+ const cached = this.getFromCache(cacheKey);
+
+ if (cached) {
+ return { data: cached, rateLimit: this.rateLimit! };
+ }
+
+ try {
+ const octokit = await this.ensureAuthenticated();
+ // Convert milestone number to string if present
+ const apiFilters = filters
+ ? {
+ ...filters,
+ milestone: filters.milestone ? String(filters.milestone) : undefined,
+ labels: filters.labels?.join(',')
+ }
+ : {};
+
+ const response = await octokit.issues.listForRepo({
+ owner,
+ repo,
+ ...apiFilters
+ });
+
+ this.updateRateLimitFromHeaders(response.headers as Record);
+ this.setCache(cacheKey, response.data);
+
+ return {
+ data: response.data as unknown as GitHubIssue[],
+ rateLimit: this.rateLimit!
+ };
+ } catch (error) {
+ this.handleApiError(error);
}
}
/**
- * Parse repository URL to owner/repo
- *
- * Handles various GitHub URL formats:
- * - https://github.com/owner/repo
- * - git@github.com:owner/repo.git
- * - https://github.com/owner/repo.git
- *
- * @param url - GitHub repository URL
- * @returns Object with owner and repo, or null if invalid
+ * Get a single issue
*/
- static parseRepoUrl(url: string): { owner: string; repo: string } | null {
- try {
- // Remove .git suffix if present
- const cleanUrl = url.replace(/\.git$/, '');
+ async getIssue(owner: string, repo: string, issue_number: number): Promise> {
+ const cacheKey = this.getCacheKey('getIssue', { owner, repo, issue_number });
+ const cached = this.getFromCache(cacheKey);
- // Handle SSH format: git@github.com:owner/repo
- if (cleanUrl.includes('git@github.com:')) {
- const parts = cleanUrl.split('git@github.com:')[1].split('/');
- if (parts.length >= 2) {
- return {
- owner: parts[0],
- repo: parts[1]
- };
- }
+ if (cached) {
+ return { data: cached, rateLimit: this.rateLimit! };
+ }
+
+ try {
+ const octokit = await this.ensureAuthenticated();
+ const response = await octokit.issues.get({
+ owner,
+ repo,
+ issue_number
+ });
+
+ this.updateRateLimitFromHeaders(response.headers as Record);
+ this.setCache(cacheKey, response.data);
+
+ return {
+ data: response.data as unknown as GitHubIssue,
+ rateLimit: this.rateLimit!
+ };
+ } catch (error) {
+ this.handleApiError(error);
+ }
+ }
+
+ /**
+ * Create a new issue
+ */
+ async createIssue(owner: string, repo: string, options: CreateIssueOptions): Promise> {
+ try {
+ const octokit = await this.ensureAuthenticated();
+ const response = await octokit.issues.create({
+ owner,
+ repo,
+ ...options
+ });
+
+ this.updateRateLimitFromHeaders(response.headers as Record);
+
+ // Invalidate list cache
+ this.clearCacheForPattern('listIssues');
+
+ return {
+ data: response.data as unknown as GitHubIssue,
+ rateLimit: this.rateLimit!
+ };
+ } catch (error) {
+ this.handleApiError(error);
+ }
+ }
+
+ /**
+ * Update an existing issue
+ */
+ async updateIssue(
+ owner: string,
+ repo: string,
+ issue_number: number,
+ options: UpdateIssueOptions
+ ): Promise> {
+ try {
+ const octokit = await this.ensureAuthenticated();
+ const response = await octokit.issues.update({
+ owner,
+ repo,
+ issue_number,
+ ...options
+ });
+
+ this.updateRateLimitFromHeaders(response.headers as Record);
+
+ // Invalidate caches
+ this.clearCacheForPattern('listIssues');
+ this.clearCacheForPattern('getIssue');
+
+ return {
+ data: response.data as unknown as GitHubIssue,
+ rateLimit: this.rateLimit!
+ };
+ } catch (error) {
+ this.handleApiError(error);
+ }
+ }
+
+ /**
+ * List comments on an issue
+ */
+ async listIssueComments(
+ owner: string,
+ repo: string,
+ issue_number: number
+ ): Promise> {
+ const cacheKey = this.getCacheKey('listIssueComments', { owner, repo, issue_number });
+ const cached = this.getFromCache(cacheKey);
+
+ if (cached) {
+ return { data: cached, rateLimit: this.rateLimit! };
+ }
+
+ try {
+ const octokit = await this.ensureAuthenticated();
+ const response = await octokit.issues.listComments({
+ owner,
+ repo,
+ issue_number
+ });
+
+ this.updateRateLimitFromHeaders(response.headers as Record);
+ this.setCache(cacheKey, response.data);
+
+ return {
+ data: response.data as unknown as GitHubComment[],
+ rateLimit: this.rateLimit!
+ };
+ } catch (error) {
+ this.handleApiError(error);
+ }
+ }
+
+ /**
+ * Create a comment on an issue
+ */
+ async createIssueComment(
+ owner: string,
+ repo: string,
+ issue_number: number,
+ body: string
+ ): Promise> {
+ try {
+ const octokit = await this.ensureAuthenticated();
+ const response = await octokit.issues.createComment({
+ owner,
+ repo,
+ issue_number,
+ body
+ });
+
+ this.updateRateLimitFromHeaders(response.headers as Record);
+
+ // Invalidate comment cache
+ this.clearCacheForPattern('listIssueComments');
+
+ return {
+ data: response.data as unknown as GitHubComment,
+ rateLimit: this.rateLimit!
+ };
+ } catch (error) {
+ this.handleApiError(error);
+ }
+ }
+
+ // ==================== PULL REQUEST METHODS ====================
+
+ /**
+ * List pull requests for a repository
+ */
+ async listPullRequests(
+ owner: string,
+ repo: string,
+ filters?: Omit
+ ): Promise> {
+ const cacheKey = this.getCacheKey('listPullRequests', { owner, repo, ...filters });
+ const cached = this.getFromCache(cacheKey);
+
+ if (cached) {
+ return { data: cached, rateLimit: this.rateLimit! };
+ }
+
+ try {
+ const octokit = await this.ensureAuthenticated();
+ // Map our filters to PR-specific parameters
+ const prSort = filters?.sort === 'comments' ? 'created' : filters?.sort;
+ const apiFilters = filters
+ ? {
+ state: filters.state,
+ sort: prSort,
+ direction: filters.direction,
+ per_page: filters.per_page,
+ page: filters.page
+ }
+ : {};
+
+ const response = await octokit.pulls.list({
+ owner,
+ repo,
+ ...apiFilters
+ });
+
+ this.updateRateLimitFromHeaders(response.headers as Record);
+ this.setCache(cacheKey, response.data);
+
+ return {
+ data: response.data as unknown as GitHubPullRequest[],
+ rateLimit: this.rateLimit!
+ };
+ } catch (error) {
+ this.handleApiError(error);
+ }
+ }
+
+ /**
+ * Get a single pull request
+ */
+ async getPullRequest(
+ owner: string,
+ repo: string,
+ pull_number: number
+ ): Promise> {
+ const cacheKey = this.getCacheKey('getPullRequest', { owner, repo, pull_number });
+ const cached = this.getFromCache(cacheKey);
+
+ if (cached) {
+ return { data: cached, rateLimit: this.rateLimit! };
+ }
+
+ try {
+ const octokit = await this.ensureAuthenticated();
+ const response = await octokit.pulls.get({
+ owner,
+ repo,
+ pull_number
+ });
+
+ this.updateRateLimitFromHeaders(response.headers as Record);
+ this.setCache(cacheKey, response.data);
+
+ return {
+ data: response.data as unknown as GitHubPullRequest,
+ rateLimit: this.rateLimit!
+ };
+ } catch (error) {
+ this.handleApiError(error);
+ }
+ }
+
+ /**
+ * List commits in a pull request
+ */
+ async listPullRequestCommits(
+ owner: string,
+ repo: string,
+ pull_number: number
+ ): Promise> {
+ const cacheKey = this.getCacheKey('listPullRequestCommits', { owner, repo, pull_number });
+ const cached = this.getFromCache(cacheKey);
+
+ if (cached) {
+ return { data: cached, rateLimit: this.rateLimit! };
+ }
+
+ try {
+ const octokit = await this.ensureAuthenticated();
+ const response = await octokit.pulls.listCommits({
+ owner,
+ repo,
+ pull_number
+ });
+
+ this.updateRateLimitFromHeaders(response.headers as Record);
+ this.setCache(cacheKey, response.data);
+
+ return {
+ data: response.data as unknown as GitHubCommit[],
+ rateLimit: this.rateLimit!
+ };
+ } catch (error) {
+ this.handleApiError(error);
+ }
+ }
+
+ // ==================== LABEL METHODS ====================
+
+ /**
+ * List labels for a repository
+ */
+ async listLabels(owner: string, repo: string): Promise> {
+ const cacheKey = this.getCacheKey('listLabels', { owner, repo });
+ const cached = this.getFromCache(cacheKey, 300000); // 5 minute cache
+
+ if (cached) {
+ return { data: cached, rateLimit: this.rateLimit! };
+ }
+
+ try {
+ const octokit = await this.ensureAuthenticated();
+ const response = await octokit.issues.listLabelsForRepo({
+ owner,
+ repo
+ });
+
+ this.updateRateLimitFromHeaders(response.headers as Record);
+ this.setCache(cacheKey, response.data);
+
+ return {
+ data: response.data as unknown as GitHubLabel[],
+ rateLimit: this.rateLimit!
+ };
+ } catch (error) {
+ this.handleApiError(error);
+ }
+ }
+
+ // ==================== UTILITY METHODS ====================
+
+ /**
+ * Clear cache entries matching a pattern
+ */
+ private clearCacheForPattern(pattern: string): void {
+ for (const key of this.cache.keys()) {
+ if (key.startsWith(pattern)) {
+ this.cache.delete(key);
}
-
- // Handle HTTPS format: https://github.com/owner/repo
- if (cleanUrl.includes('github.com/')) {
- const parts = cleanUrl.split('github.com/')[1].split('/');
- if (parts.length >= 2) {
- return {
- owner: parts[0],
- repo: parts[1]
- };
- }
- }
-
- return null;
- } catch (error) {
- console.error('[GitHub Client] Error parsing repo URL:', error);
- return null;
}
}
/**
- * Get repository from local Git remote URL
- *
- * Useful for getting GitHub repo info from current project's git remote.
- *
- * @param remoteUrl - Git remote URL
- * @returns Repository information if GitHub repo, null otherwise
+ * Check if client is ready to make API calls
*/
- async getRepositoryFromRemoteUrl(remoteUrl: string): Promise {
- const parsed = GitHubClient.parseRepoUrl(remoteUrl);
- if (!parsed) {
- return null;
- }
-
- try {
- return await this.getRepository(parsed.owner, parsed.repo);
- } catch (error) {
- console.error('[GitHub Client] Error fetching repository:', error);
- return null;
- }
+ isReady(): boolean {
+ return this.octokit !== null;
}
/**
- * Reset client state
- *
- * Call this when user disconnects or token changes.
+ * Get time until rate limit resets (in milliseconds)
*/
- reset(): void {
- this.octokit = null;
- this.lastRateLimit = null;
+ getTimeUntilRateLimitReset(): number {
+ if (!this.rateLimit) {
+ return 0;
+ }
+
+ const resetTime = this.rateLimit.reset * 1000;
+ const now = Date.now();
+ return Math.max(0, resetTime - now);
}
}
-
-/**
- * Singleton instance of GitHubClient
- * Use this for all GitHub API operations
- */
-export const githubClient = new GitHubClient();
diff --git a/packages/noodl-editor/src/editor/src/services/github/GitHubTypes.ts b/packages/noodl-editor/src/editor/src/services/github/GitHubTypes.ts
index e7b9072..87d435b 100644
--- a/packages/noodl-editor/src/editor/src/services/github/GitHubTypes.ts
+++ b/packages/noodl-editor/src/editor/src/services/github/GitHubTypes.ts
@@ -1,184 +1,346 @@
/**
- * GitHubTypes
+ * TypeScript interfaces for GitHub API data structures
*
- * TypeScript type definitions for GitHub OAuth and API integration.
- * These types define the structure of tokens, authentication state, and API responses.
- *
- * @module services/github
- * @since 1.1.0
+ * @module noodl-editor/services/github
*/
/**
- * OAuth device code response from GitHub
- * Returned when initiating device flow authorization
+ * GitHub Issue data structure
*/
-export interface GitHubDeviceCode {
- /** The device verification code */
- device_code: string;
- /** The user verification code (8-character code) */
- user_code: string;
- /** URL where user enters the code */
- verification_uri: string;
- /** Expiration time in seconds (default: 900) */
- expires_in: number;
- /** Polling interval in seconds (default: 5) */
- interval: number;
+export interface GitHubIssue {
+ id: number;
+ number: number;
+ title: string;
+ body: string | null;
+ state: 'open' | 'closed';
+ html_url: string;
+ user: GitHubUser;
+ labels: GitHubLabel[];
+ assignees: GitHubUser[];
+ created_at: string;
+ updated_at: string;
+ closed_at: string | null;
+ comments: number;
+ milestone: GitHubMilestone | null;
}
/**
- * GitHub OAuth access token
- * Stored securely and used for API authentication
+ * GitHub Pull Request data structure
*/
-export interface GitHubToken {
- /** The OAuth access token */
- access_token: string;
- /** Token type (always 'bearer' for GitHub) */
- token_type: string;
- /** Granted scopes (comma-separated) */
- scope: string;
- /** Token expiration timestamp (ISO 8601) - undefined if no expiration */
- expires_at?: string;
+export interface GitHubPullRequest {
+ id: number;
+ number: number;
+ title: string;
+ body: string | null;
+ state: 'open' | 'closed';
+ html_url: string;
+ user: GitHubUser;
+ labels: GitHubLabel[];
+ assignees: GitHubUser[];
+ created_at: string;
+ updated_at: string;
+ closed_at: string | null;
+ merged_at: string | null;
+ draft: boolean;
+ head: {
+ ref: string;
+ sha: string;
+ };
+ base: {
+ ref: string;
+ sha: string;
+ };
+ mergeable: boolean | null;
+ mergeable_state: string;
+ comments: number;
+ review_comments: number;
+ commits: number;
+ additions: number;
+ deletions: number;
+ changed_files: number;
}
/**
- * Current GitHub authentication state
- * Used by React components to display connection status
- */
-export interface GitHubAuthState {
- /** Whether user is authenticated with GitHub */
- isAuthenticated: boolean;
- /** GitHub username if authenticated */
- username?: string;
- /** User's primary email if authenticated */
- email?: string;
- /** Current token (for internal use only) */
- token?: GitHubToken;
- /** Timestamp of last successful authentication */
- authenticatedAt?: string;
-}
-
-/**
- * GitHub user information
- * Retrieved from /user API endpoint
+ * GitHub User data structure
*/
export interface GitHubUser {
- /** GitHub username */
- login: string;
- /** GitHub user ID */
id: number;
- /** User's display name */
+ login: string;
name: string | null;
- /** User's primary email */
email: string | null;
- /** Avatar URL */
avatar_url: string;
- /** Profile URL */
html_url: string;
- /** User type (User or Organization) */
- type: string;
}
/**
- * GitHub repository information
- * Basic repo details for issue/PR association
+ * GitHub Organization data structure
+ */
+export interface GitHubOrganization {
+ id: number;
+ login: string;
+ avatar_url: string;
+ description: string | null;
+ html_url: string;
+}
+
+/**
+ * GitHub Repository data structure
*/
export interface GitHubRepository {
- /** Repository ID */
id: number;
- /** Repository name (without owner) */
name: string;
- /** Full repository name (owner/repo) */
full_name: string;
- /** Repository owner */
- owner: {
- login: string;
- id: number;
- avatar_url: string;
- };
- /** Whether repo is private */
+ owner: GitHubUser | GitHubOrganization;
private: boolean;
- /** Repository URL */
html_url: string;
- /** Default branch */
+ description: string | null;
+ fork: boolean;
+ created_at: string;
+ updated_at: string;
+ pushed_at: string;
+ homepage: string | null;
+ size: number;
+ stargazers_count: number;
+ watchers_count: number;
+ language: string | null;
+ has_issues: boolean;
+ has_projects: boolean;
+ has_downloads: boolean;
+ has_wiki: boolean;
+ has_pages: boolean;
+ forks_count: number;
+ open_issues_count: number;
default_branch: string;
-}
-
-/**
- * GitHub App installation information
- * Represents organizations/accounts where the app was installed
- */
-export interface GitHubInstallation {
- /** Installation ID */
- id: number;
- /** Account where app is installed */
- account: {
- login: string;
- type: 'User' | 'Organization';
- avatar_url: string;
+ permissions?: {
+ admin: boolean;
+ maintain: boolean;
+ push: boolean;
+ triage: boolean;
+ pull: boolean;
};
- /** Repository selection type */
- repository_selection: 'all' | 'selected';
- /** List of repositories (if selected) */
- repositories?: Array<{
- id: number;
- name: string;
- full_name: string;
- private: boolean;
- }>;
}
/**
- * Rate limit information from GitHub API
- * Used to prevent hitting API limits
+ * GitHub Label data structure
+ */
+export interface GitHubLabel {
+ id: number;
+ node_id: string;
+ url: string;
+ name: string;
+ color: string;
+ default: boolean;
+ description: string | null;
+}
+
+/**
+ * GitHub Milestone data structure
+ */
+export interface GitHubMilestone {
+ id: number;
+ number: number;
+ title: string;
+ description: string | null;
+ state: 'open' | 'closed';
+ created_at: string;
+ updated_at: string;
+ due_on: string | null;
+ closed_at: string | null;
+}
+
+/**
+ * GitHub Comment data structure
+ */
+export interface GitHubComment {
+ id: number;
+ body: string;
+ user: GitHubUser;
+ created_at: string;
+ updated_at: string;
+ html_url: string;
+}
+
+/**
+ * GitHub Commit data structure
+ */
+export interface GitHubCommit {
+ sha: string;
+ commit: {
+ author: {
+ name: string;
+ email: string;
+ date: string;
+ };
+ committer: {
+ name: string;
+ email: string;
+ date: string;
+ };
+ message: string;
+ };
+ author: GitHubUser | null;
+ committer: GitHubUser | null;
+ html_url: string;
+}
+
+/**
+ * GitHub Check Run data structure (for PR status checks)
+ */
+export interface GitHubCheckRun {
+ id: number;
+ name: string;
+ status: 'queued' | 'in_progress' | 'completed';
+ conclusion: 'success' | 'failure' | 'neutral' | 'cancelled' | 'skipped' | 'timed_out' | 'action_required' | null;
+ html_url: string;
+ details_url: string;
+ started_at: string | null;
+ completed_at: string | null;
+}
+
+/**
+ * GitHub Review data structure
+ */
+export interface GitHubReview {
+ id: number;
+ user: GitHubUser;
+ body: string;
+ state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED' | 'PENDING';
+ html_url: string;
+ submitted_at: string;
+}
+
+/**
+ * Rate limit information
*/
export interface GitHubRateLimit {
- /** Maximum requests allowed per hour */
limit: number;
- /** Remaining requests in current window */
remaining: number;
- /** Timestamp when rate limit resets (Unix epoch) */
- reset: number;
- /** Resource type (core, search, graphql) */
- resource: string;
+ reset: number; // Unix timestamp
+ used: number;
+}
+
+/**
+ * API response with rate limit info
+ */
+export interface GitHubApiResponse {
+ data: T;
+ rateLimit: GitHubRateLimit;
+}
+
+/**
+ * Issue/PR filter options
+ */
+export interface GitHubIssueFilters {
+ state?: 'open' | 'closed' | 'all';
+ labels?: string[];
+ assignee?: string;
+ creator?: string;
+ mentioned?: string;
+ milestone?: string | number;
+ sort?: 'created' | 'updated' | 'comments';
+ direction?: 'asc' | 'desc';
+ since?: string;
+ per_page?: number;
+ page?: number;
+}
+
+/**
+ * Create issue options
+ */
+export interface CreateIssueOptions {
+ title: string;
+ body?: string;
+ labels?: string[];
+ assignees?: string[];
+ milestone?: number;
+}
+
+/**
+ * Update issue options
+ */
+export interface UpdateIssueOptions {
+ title?: string;
+ body?: string;
+ state?: 'open' | 'closed';
+ labels?: string[];
+ assignees?: string[];
+ milestone?: number | null;
}
/**
* Error response from GitHub API
*/
-export interface GitHubError {
- /** HTTP status code */
- status: number;
- /** Error message */
+export interface GitHubApiError {
message: string;
- /** Detailed documentation URL if available */
documentation_url?: string;
+ errors?: Array<{
+ resource: string;
+ field: string;
+ code: string;
+ }>;
}
/**
- * OAuth authorization error
- * Thrown during device flow authorization
+ * OAuth Token structure
*/
-export interface GitHubAuthError extends Error {
- /** Error code from GitHub */
- code?: string;
- /** HTTP status if applicable */
- status?: number;
+export interface GitHubToken {
+ access_token: string;
+ token_type: string;
+ scope: string;
+ expires_at?: string;
}
/**
- * Stored token data (persisted format)
- * Encrypted and stored in Electron's secure storage
+ * GitHub Installation (App installation on org/repo)
+ */
+export interface GitHubInstallation {
+ id: number;
+ account: {
+ login: string;
+ type: string;
+ };
+ repository_selection: string;
+ permissions: Record;
+}
+
+/**
+ * Stored GitHub authentication data
*/
export interface StoredGitHubAuth {
- /** OAuth token */
token: GitHubToken;
- /** Associated user info */
user: {
login: string;
email: string | null;
};
- /** Installation information (organizations/repos with access) */
installations?: GitHubInstallation[];
- /** Timestamp when stored */
storedAt: string;
}
+
+/**
+ * GitHub Auth state (returned by GitHubAuth.getAuthState())
+ */
+export interface GitHubAuthState {
+ isAuthenticated: boolean;
+ username?: string;
+ email?: string;
+ token?: GitHubToken;
+ authenticatedAt?: string;
+}
+
+/**
+ * GitHub Device Code (for OAuth Device Flow)
+ */
+export interface GitHubDeviceCode {
+ device_code: string;
+ user_code: string;
+ verification_uri: string;
+ expires_in: number;
+ interval: number;
+}
+
+/**
+ * GitHub Auth Error
+ */
+export interface GitHubAuthError extends Error {
+ code?: string;
+}
diff --git a/packages/noodl-editor/src/editor/src/services/github/index.ts b/packages/noodl-editor/src/editor/src/services/github/index.ts
index 3cd0c8e..cc26b08 100644
--- a/packages/noodl-editor/src/editor/src/services/github/index.ts
+++ b/packages/noodl-editor/src/editor/src/services/github/index.ts
@@ -1,41 +1,52 @@
/**
- * GitHub Services
+ * GitHub Service - Public API
*
- * Public exports for GitHub OAuth authentication and API integration.
- * This module provides everything needed to connect to GitHub,
- * authenticate users, and interact with the GitHub API.
+ * Provides GitHub integration services including OAuth authentication
+ * and REST API client with rate limiting and caching.
*
- * @module services/github
- * @since 1.1.0
+ * @module noodl-editor/services/github
*
* @example
* ```typescript
- * import { GitHubAuth, githubClient } from '@noodl-services/github';
+ * import { GitHubClient, GitHubOAuthService } from '@noodl-editor/services/github';
*
- * // Check if authenticated
- * if (GitHubAuth.isAuthenticated()) {
- * // Fetch user repos
- * const repos = await githubClient.listRepositories();
- * }
+ * // Initialize OAuth
+ * await GitHubOAuthService.instance.initialize();
+ *
+ * // Use API client
+ * const client = GitHubClient.instance;
+ * const { data: issues } = await client.listIssues('owner', 'repo');
* ```
*/
-// Authentication
+// Re-export main services
+export { GitHubOAuthService } from '../GitHubOAuthService';
export { GitHubAuth } from './GitHubAuth';
-export { GitHubTokenStore } from './GitHubTokenStore';
+export { GitHubClient } from './GitHubClient';
-// API Client
-export { GitHubClient, githubClient } from './GitHubClient';
-
-// Types
+// Re-export all types
export type {
- GitHubDeviceCode,
- GitHubToken,
- GitHubAuthState,
+ GitHubIssue,
+ GitHubPullRequest,
GitHubUser,
+ GitHubOrganization,
GitHubRepository,
+ GitHubLabel,
+ GitHubMilestone,
+ GitHubComment,
+ GitHubCommit,
+ GitHubCheckRun,
+ GitHubReview,
GitHubRateLimit,
- GitHubError,
- GitHubAuthError,
- StoredGitHubAuth
+ GitHubApiResponse,
+ GitHubIssueFilters,
+ CreateIssueOptions,
+ UpdateIssueOptions,
+ GitHubApiError,
+ GitHubToken,
+ GitHubInstallation,
+ StoredGitHubAuth,
+ GitHubAuthState,
+ GitHubDeviceCode,
+ GitHubAuthError
} from './GitHubTypes';
diff --git a/packages/noodl-editor/src/editor/src/styles/popuplayer.css b/packages/noodl-editor/src/editor/src/styles/popuplayer.css
index 01cd3c8..d3ee9b4 100644
--- a/packages/noodl-editor/src/editor/src/styles/popuplayer.css
+++ b/packages/noodl-editor/src/editor/src/styles/popuplayer.css
@@ -20,6 +20,12 @@
pointer-events: all;
}
+/* Enable pointer events when popouts are active (without dimming background)
+ This allows clicking outside popouts to close them */
+.popup-layer.has-popouts {
+ pointer-events: all;
+}
+
.popup-menu {
background-color: var(--theme-color-bg-3);
}
diff --git a/packages/noodl-editor/src/editor/src/templates/nodegrapheditor.html b/packages/noodl-editor/src/editor/src/templates/nodegrapheditor.html
index adada85..8d09f58 100644
--- a/packages/noodl-editor/src/editor/src/templates/nodegrapheditor.html
+++ b/packages/noodl-editor/src/editor/src/templates/nodegrapheditor.html
@@ -1,4 +1,7 @@
+
+
+
diff --git a/packages/noodl-editor/src/editor/src/utils/LocalProjectsModel.ts b/packages/noodl-editor/src/editor/src/utils/LocalProjectsModel.ts
index f8b379d..ea43d07 100644
--- a/packages/noodl-editor/src/editor/src/utils/LocalProjectsModel.ts
+++ b/packages/noodl-editor/src/editor/src/utils/LocalProjectsModel.ts
@@ -252,6 +252,7 @@ export class LocalProjectsModel extends Model {
}
project.name = name; //update the name from the template
+ project.runtimeVersion = 'react19'; // NEW projects default to React 19
// Store the project, this will make it a unique project by
// forcing it to generate a project id
@@ -278,7 +279,8 @@ export class LocalProjectsModel extends Model {
const minimalProject = {
name: name,
components: [],
- settings: {}
+ settings: {},
+ runtimeVersion: 'react19' // NEW projects default to React 19
};
await filesystem.writeFile(filesystem.join(dirEntry, 'project.json'), JSON.stringify(minimalProject, null, 2));
@@ -291,6 +293,7 @@ export class LocalProjectsModel extends Model {
}
project.name = name;
+ project.runtimeVersion = 'react19'; // Ensure it's set
this._addProject(project);
fn(project);
});
diff --git a/packages/noodl-editor/src/editor/src/views/EditorBanner/EditorBanner.module.scss b/packages/noodl-editor/src/editor/src/views/EditorBanner/EditorBanner.module.scss
new file mode 100644
index 0000000..c5b1a39
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/EditorBanner/EditorBanner.module.scss
@@ -0,0 +1,98 @@
+/**
+ * EditorBanner Styles
+ *
+ * Warning banner for legacy projects in read-only mode.
+ * Uses design tokens exclusively - NO hardcoded colors!
+ */
+
+.EditorBanner {
+ position: fixed;
+ top: var(--topbar-height, 40px);
+ left: 0;
+ right: 0;
+ z-index: 1000;
+
+ display: flex;
+ align-items: center;
+ gap: 16px;
+
+ padding: 12px 20px;
+ /* Solid dark background for maximum visibility */
+ background: #1a1a1a;
+ border-bottom: 2px solid var(--theme-color-warning, #ffc107);
+
+ /* Subtle shadow for depth */
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+
+ /* CRITICAL: Allow clicks through banner to editor below */
+ /* Only interactive elements (buttons) should capture clicks */
+ pointer-events: none;
+}
+
+.Icon {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ color: var(--theme-color-warning);
+}
+
+.Content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ /* Re-enable pointer events for text content */
+ pointer-events: all;
+}
+
+.Title {
+ color: var(--theme-color-fg-default);
+ font-weight: 600;
+}
+
+.Description {
+ color: var(--theme-color-fg-default-shy);
+}
+
+.Actions {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ /* Re-enable pointer events for interactive buttons */
+ pointer-events: all;
+}
+
+.CloseButton {
+ flex-shrink: 0;
+ margin-left: 8px;
+
+ /* Re-enable pointer events for close button */
+ pointer-events: all;
+}
+
+/* Responsive adjustments */
+@media (max-width: 900px) {
+ .EditorBanner {
+ flex-wrap: wrap;
+ }
+
+ .Content {
+ flex-basis: 100%;
+ order: 1;
+ }
+
+ .Actions {
+ order: 2;
+ margin-top: 8px;
+ }
+
+ .CloseButton {
+ order: 0;
+ margin-left: auto;
+ }
+}
diff --git a/packages/noodl-editor/src/editor/src/views/EditorBanner/EditorBanner.tsx b/packages/noodl-editor/src/editor/src/views/EditorBanner/EditorBanner.tsx
new file mode 100644
index 0000000..4949f70
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/EditorBanner/EditorBanner.tsx
@@ -0,0 +1,79 @@
+/**
+ * EditorBanner
+ *
+ * Warning banner that appears when a legacy (React 17) project is opened in read-only mode.
+ * Provides clear messaging and actions for the user to migrate the project.
+ *
+ * @module noodl-editor/views/EditorBanner
+ * @since 1.2.0
+ */
+
+import React, { useState } from 'react';
+
+import { IconName } from '@noodl-core-ui/components/common/Icon';
+import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
+import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
+
+import css from './EditorBanner.module.scss';
+
+// =============================================================================
+// Types
+// =============================================================================
+
+export interface EditorBannerProps {
+ /** Called when user dismisses the banner */
+ onDismiss: () => void;
+}
+
+// =============================================================================
+// Component
+// =============================================================================
+
+export function EditorBanner({ onDismiss }: EditorBannerProps) {
+ const [isDismissed, setIsDismissed] = useState(false);
+
+ const handleDismiss = () => {
+ setIsDismissed(true);
+ onDismiss();
+ };
+
+ if (isDismissed) {
+ return null;
+ }
+
+ return (
+
+ {/* Warning Icon */}
+
+
+ {/* Message Content */}
+
+
+ Legacy Project (React 17) - Read-Only Mode
+
+
+
+ This project uses React 17. Return to the launcher to migrate it before editing.
+
+
+
+
+ {/* Close Button */}
+
+
+
+
+ );
+}
+
+export default EditorBanner;
diff --git a/packages/noodl-editor/src/editor/src/views/EditorBanner/index.ts b/packages/noodl-editor/src/editor/src/views/EditorBanner/index.ts
new file mode 100644
index 0000000..1c056ea
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/EditorBanner/index.ts
@@ -0,0 +1 @@
+export { EditorBanner, type EditorBannerProps } from './EditorBanner';
diff --git a/packages/noodl-editor/src/editor/src/views/SidePanel/SidePanel.tsx b/packages/noodl-editor/src/editor/src/views/SidePanel/SidePanel.tsx
index 55d9da1..a28e9fd 100644
--- a/packages/noodl-editor/src/editor/src/views/SidePanel/SidePanel.tsx
+++ b/packages/noodl-editor/src/editor/src/views/SidePanel/SidePanel.tsx
@@ -32,7 +32,10 @@ export function SidePanel() {
setPanels((prev) => {
const component = SidebarModel.instance.getPanelComponent(currentPanelId);
if (component) {
- prev[currentPanelId] = React.createElement(component);
+ return {
+ ...prev,
+ [currentPanelId]: React.createElement(component)
+ };
}
return prev;
});
@@ -52,7 +55,10 @@ export function SidePanel() {
// TODO: Clean up this inside SidebarModel, createElement can be done here instead
const component = SidebarModel.instance.getPanelComponent(panelId);
if (component) {
- prev[panelId] = React.createElement(component);
+ return {
+ ...prev,
+ [panelId]: React.createElement(component)
+ };
}
return prev;
});
@@ -73,8 +79,11 @@ export function SidePanel() {
setPanels((prev) => {
const component = SidebarModel.instance.getPanelComponent(panelId);
if (component) {
- // Force recreation with new node props
- prev[panelId] = React.createElement(component);
+ // Force recreation with new node props - MUST return new object for React to detect change
+ return {
+ ...prev,
+ [panelId]: React.createElement(component)
+ };
}
return prev;
});
diff --git a/packages/noodl-editor/src/editor/src/views/ToastLayer/ToastLayer.tsx b/packages/noodl-editor/src/editor/src/views/ToastLayer/ToastLayer.tsx
index c88e6c8..bf6f6ca 100644
--- a/packages/noodl-editor/src/editor/src/views/ToastLayer/ToastLayer.tsx
+++ b/packages/noodl-editor/src/editor/src/views/ToastLayer/ToastLayer.tsx
@@ -34,8 +34,9 @@ export const ToastLayer = {
toast.success(
);
},
- showError(message: string, duration = 1000000) {
- toast.error((t) =>
toast.dismiss(t.id)} />, {
+ showError(message: string, duration = Infinity) {
+ // Don't pass onClose callback - makes toast permanent with no close button
+ toast.error(, {
duration
});
},
diff --git a/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts b/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
index f051c2b..3c6266c 100644
--- a/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
+++ b/packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
@@ -45,6 +45,7 @@ import { ViewerConnection } from '../ViewerConnection';
import { HighlightOverlay } from './CanvasOverlays/HighlightOverlay';
import { CanvasTabs } from './CanvasTabs';
import CommentLayer from './commentlayer';
+import { EditorBanner } from './EditorBanner';
// Import test utilities for console debugging (dev only)
import '../services/HighlightManager/test-highlights';
import { ConnectionPopup } from './ConnectionPopup';
@@ -241,6 +242,7 @@ export class NodeGraphEditor extends View {
titleRoot: Root = null;
highlightOverlayRoot: Root = null;
canvasTabsRoot: Root = null;
+ editorBannerRoot: Root = null;
constructor(args) {
super();
@@ -463,6 +465,11 @@ export class NodeGraphEditor extends View {
setReadOnly(readOnly: boolean) {
this.readOnly = readOnly;
this.commentLayer?.setReadOnly(readOnly);
+
+ // Update banner visibility when read-only status changes
+ if (this.editorBannerRoot) {
+ this.renderEditorBanner();
+ }
}
reset() {
@@ -928,6 +935,11 @@ export class NodeGraphEditor extends View {
this.renderCanvasTabs();
}, 1);
+ // Render the editor banner (for read-only mode)
+ setTimeout(() => {
+ this.renderEditorBanner();
+ }, 1);
+
this.relayout();
this.repaint();
@@ -983,6 +995,42 @@ export class NodeGraphEditor extends View {
console.log(`[NodeGraphEditor] Saved workspace and generated code for node ${nodeId}`);
}
+ /**
+ * Render the EditorBanner React component (for read-only mode)
+ */
+ renderEditorBanner() {
+ const bannerElement = this.el.find('#editor-banner-root').get(0);
+ if (!bannerElement) {
+ console.warn('Editor banner root not found in DOM');
+ return;
+ }
+
+ // Create React root if it doesn't exist
+ if (!this.editorBannerRoot) {
+ this.editorBannerRoot = createRoot(bannerElement);
+ }
+
+ // Only show banner if in read-only mode
+ if (this.readOnly) {
+ this.editorBannerRoot.render(
+ React.createElement(EditorBanner, {
+ onDismiss: this.handleDismissBanner.bind(this)
+ })
+ );
+ } else {
+ // Clear banner if not in read-only mode
+ this.editorBannerRoot.render(null);
+ }
+ }
+
+ /**
+ * Handle banner dismiss
+ */
+ handleDismissBanner() {
+ console.log('[NodeGraphEditor] Banner dismissed');
+ // Banner handles its own visibility via state
+ }
+
/**
* Get node bounds for the highlight overlay
* Maps node IDs to their screen coordinates
@@ -1807,17 +1855,20 @@ export class NodeGraphEditor extends View {
return;
}
+ // Always select the node in the selector if not already selected
if (!node.selected) {
- // Select node
this.clearSelection();
this.commentLayer?.clearSelection();
node.selected = true;
this.selector.select([node]);
- SidebarModel.instance.switchToNode(node.model);
-
this.repaint();
- } else {
- // Double selection
+ }
+
+ // Always switch to the node in the sidebar (fixes property panel stuck issue)
+ SidebarModel.instance.switchToNode(node.model);
+
+ // Handle double-click navigation
+ if (this.leftButtonIsDoubleClicked) {
if (node.model.type instanceof ComponentModel) {
this.switchToComponent(node.model.type, { pushHistory: true });
} else {
@@ -1832,7 +1883,7 @@ export class NodeGraphEditor extends View {
if (type) {
// @ts-expect-error TODO: this is wrong!
this.switchToComponent(type, { pushHistory: true });
- } else if (this.leftButtonIsDoubleClicked) {
+ } else {
//there was no type that matched, so forward the double click event to the sidebar
SidebarModel.instance.invokeActive('doubleClick', node);
}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/GitHubPanel.module.scss b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/GitHubPanel.module.scss
new file mode 100644
index 0000000..d8352b1
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/GitHubPanel.module.scss
@@ -0,0 +1,147 @@
+/**
+ * GitHubPanel styles
+ * Uses design tokens for theming
+ */
+
+.GitHubPanel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background-color: var(--theme-color-bg-2);
+}
+
+.Header {
+ display: flex;
+ flex-direction: column;
+ border-bottom: 1px solid var(--theme-color-border-default);
+ background-color: var(--theme-color-bg-2);
+}
+
+.Tabs {
+ display: flex;
+ gap: 0;
+ padding: 0 12px;
+}
+
+.Tab {
+ padding: 12px 16px;
+ background: none;
+ border: none;
+ color: var(--theme-color-fg-default-shy);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: all 0.2s ease;
+
+ &:hover {
+ color: var(--theme-color-fg-default);
+ background-color: var(--theme-color-bg-3);
+ }
+
+ &.TabActive {
+ color: var(--theme-color-primary);
+ border-bottom-color: var(--theme-color-primary);
+ }
+}
+
+.Content {
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.IssuesTab {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.Filters {
+ padding: 12px;
+ border-bottom: 1px solid var(--theme-color-border-default);
+ background-color: var(--theme-color-bg-2);
+}
+
+.SearchInput {
+ width: 100%;
+ padding: 8px 12px;
+ background-color: var(--theme-color-bg-3);
+ border: 1px solid var(--theme-color-border-default);
+ border-radius: 4px;
+ color: var(--theme-color-fg-default);
+ font-size: 13px;
+
+ &::placeholder {
+ color: var(--theme-color-fg-default-shy);
+ }
+
+ &:focus {
+ outline: none;
+ border-color: var(--theme-color-primary);
+ }
+}
+
+.IssuesList {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px;
+}
+
+.EmptyState {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 48px 24px;
+ text-align: center;
+ color: var(--theme-color-fg-default-shy);
+
+ h3 {
+ margin: 12px 0 8px;
+ color: var(--theme-color-fg-default);
+ font-size: 16px;
+ font-weight: 600;
+ }
+
+ p {
+ margin: 0 0 20px;
+ font-size: 13px;
+ line-height: 1.5;
+ }
+}
+
+.EmptyStateIcon {
+ font-size: 48px;
+ opacity: 0.5;
+}
+
+.ConnectButton {
+ padding: 10px 20px;
+ background-color: var(--theme-color-primary);
+ color: white;
+ border: none;
+ border-radius: 4px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: opacity 0.2s ease;
+
+ &:hover {
+ opacity: 0.9;
+ }
+
+ &:active {
+ opacity: 0.8;
+ }
+}
+
+.ComingSoon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 48px 24px;
+ color: var(--theme-color-fg-default-shy);
+ font-size: 13px;
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/GitHubPanel.tsx b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/GitHubPanel.tsx
new file mode 100644
index 0000000..c9b0bd1
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/GitHubPanel.tsx
@@ -0,0 +1,150 @@
+/**
+ * GitHubPanel - GitHub Issues and Pull Requests integration
+ *
+ * Displays GitHub issues and PRs for the connected repository
+ * with filtering, search, and detail views.
+ */
+
+import React, { useState } from 'react';
+
+import { GitHubClient, GitHubOAuthService } from '../../../services/github';
+import { IssuesList } from './components/IssuesTab/IssuesList';
+import { PRsList } from './components/PullRequestsTab/PRsList';
+import styles from './GitHubPanel.module.scss';
+import { useGitHubRepository } from './hooks/useGitHubRepository';
+import { useIssues } from './hooks/useIssues';
+import { usePullRequests } from './hooks/usePullRequests';
+
+type TabType = 'issues' | 'pullRequests';
+
+export function GitHubPanel() {
+ const [activeTab, setActiveTab] = useState('issues');
+ const client = GitHubClient.instance;
+ const { owner, repo, isGitHub, isReady } = useGitHubRepository();
+
+ // Check if GitHub is connected
+ const isConnected = client.isReady();
+
+ const handleConnectGitHub = async () => {
+ try {
+ await GitHubOAuthService.instance.initiateOAuth();
+ } catch (error) {
+ console.error('Failed to initiate GitHub OAuth:', error);
+ }
+ };
+
+ if (!isConnected) {
+ return (
+
+
+
🔗
+
Connect GitHub
+
Connect your GitHub account to view and manage issues and pull requests.
+
+
+
+ );
+ }
+
+ if (!isGitHub) {
+ return (
+
+
+
📦
+
Not a GitHub Repository
+
This project is not connected to a GitHub repository.
+
+
+ );
+ }
+
+ if (!isReady) {
+ return (
+
+
+
⚙️
+
Loading Repository
+
Loading repository information...
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {activeTab === 'issues' &&
}
+ {activeTab === 'pullRequests' &&
}
+
+
+ );
+}
+
+/**
+ * Issues tab content
+ */
+function IssuesTab({ owner, repo }: { owner: string; repo: string }) {
+ const { issues, loading, error, hasMore, loadMore, loadingMore, refetch } = useIssues({
+ owner,
+ repo,
+ filters: { state: 'open' }
+ });
+
+ return (
+
+
+
+ );
+}
+
+/**
+ * Pull Requests tab content
+ */
+function PullRequestsTab({ owner, repo }: { owner: string; repo: string }) {
+ const { pullRequests, loading, error, hasMore, loadMore, loadingMore, refetch } = usePullRequests({
+ owner,
+ repo,
+ filters: { state: 'open' }
+ });
+
+ return (
+
+ );
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssueDetail.module.scss b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssueDetail.module.scss
new file mode 100644
index 0000000..8f764a7
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssueDetail.module.scss
@@ -0,0 +1,185 @@
+/**
+ * IssueDetail Styles - Slide-out panel
+ */
+
+.IssueDetailOverlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 1000;
+ display: flex;
+ justify-content: flex-end;
+ animation: fadeIn 0.2s ease;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.IssueDetail {
+ width: 600px;
+ max-width: 90vw;
+ height: 100%;
+ background-color: var(--theme-color-bg-2);
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2);
+ display: flex;
+ flex-direction: column;
+ animation: slideIn 0.3s ease;
+ overflow: hidden;
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ }
+ to {
+ transform: translateX(0);
+ }
+}
+
+.Header {
+ padding: 20px;
+ border-bottom: 1px solid var(--theme-color-border-default);
+ flex-shrink: 0;
+}
+
+.TitleSection {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.Title {
+ flex: 1;
+ color: var(--theme-color-fg-default);
+ font-size: 18px;
+ font-weight: 600;
+ margin: 0;
+ line-height: 1.4;
+}
+
+.StatusBadge {
+ flex-shrink: 0;
+ padding: 4px 12px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: capitalize;
+
+ &[data-state='open'] {
+ background-color: rgba(46, 160, 67, 0.15);
+ color: #2ea043;
+ }
+
+ &[data-state='closed'] {
+ background-color: rgba(177, 24, 24, 0.15);
+ color: #da3633;
+ }
+}
+
+.CloseButton {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: transparent;
+ border: none;
+ border-radius: 4px;
+ color: var(--theme-color-fg-default-shy);
+ font-size: 20px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover {
+ background-color: var(--theme-color-bg-4);
+ color: var(--theme-color-fg-default);
+ }
+}
+
+.Meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--theme-color-fg-default-shy);
+ font-size: 13px;
+ margin-bottom: 12px;
+
+ strong {
+ color: var(--theme-color-fg-default);
+ font-weight: 600;
+ }
+}
+
+.Labels {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.Label {
+ padding: 3px 10px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: 600;
+ display: inline-block;
+}
+
+.Body {
+ flex: 1;
+ padding: 20px;
+ overflow-y: auto;
+ color: var(--theme-color-fg-default);
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.MarkdownContent {
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.NoDescription {
+ color: var(--theme-color-fg-default-shy);
+ font-style: italic;
+}
+
+.Footer {
+ padding: 16px 20px;
+ border-top: 1px solid var(--theme-color-border-default);
+ flex-shrink: 0;
+}
+
+.ViewOnGitHub {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 8px 16px;
+ background-color: var(--theme-color-primary);
+ color: white;
+ text-decoration: none;
+ border-radius: 4px;
+ font-size: 13px;
+ font-weight: 500;
+ transition: opacity 0.15s ease;
+
+ &:hover {
+ opacity: 0.9;
+ }
+
+ &:active {
+ opacity: 0.8;
+ }
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssueDetail.tsx b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssueDetail.tsx
new file mode 100644
index 0000000..0ab06f5
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssueDetail.tsx
@@ -0,0 +1,124 @@
+/**
+ * IssueDetail Component
+ *
+ * Slide-out panel displaying full issue details with markdown rendering
+ */
+
+import React from 'react';
+
+import type { GitHubIssue } from '../../../../../services/github/GitHubTypes';
+import styles from './IssueDetail.module.scss';
+
+interface IssueDetailProps {
+ issue: GitHubIssue;
+ onClose: () => void;
+}
+
+export function IssueDetail({ issue, onClose }: IssueDetailProps) {
+ return (
+
+
e.stopPropagation()}>
+
+
+
+ #{issue.number} {issue.title}
+
+
+ {issue.state === 'open' ? '🟢' : '🔴'} {issue.state}
+
+
+
+
+
+
+
+
+ {issue.user.login} opened this issue {getRelativeTimeString(new Date(issue.created_at))}
+
+ {issue.comments > 0 && • {issue.comments} comments}
+
+
+ {issue.labels && issue.labels.length > 0 && (
+
+ {issue.labels.map((label) => (
+
+ {label.name}
+
+ ))}
+
+ )}
+
+
+ {issue.body ? (
+
{issue.body}
+ ) : (
+
No description provided.
+ )}
+
+
+
+
+
+ );
+}
+
+/**
+ * Get relative time string (e.g., "2 hours ago", "3 days ago")
+ */
+function getRelativeTimeString(date: Date): string {
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffSec = Math.floor(diffMs / 1000);
+ const diffMin = Math.floor(diffSec / 60);
+ const diffHour = Math.floor(diffMin / 60);
+ const diffDay = Math.floor(diffHour / 24);
+
+ if (diffSec < 60) {
+ return 'just now';
+ } else if (diffMin < 60) {
+ return `${diffMin} minute${diffMin !== 1 ? 's' : ''} ago`;
+ } else if (diffHour < 24) {
+ return `${diffHour} hour${diffHour !== 1 ? 's' : ''} ago`;
+ } else if (diffDay < 30) {
+ return `${diffDay} day${diffDay !== 1 ? 's' : ''} ago`;
+ } else {
+ return date.toLocaleDateString();
+ }
+}
+
+/**
+ * Get contrasting text color (black or white) for a background color
+ */
+function getContrastColor(hexColor: string): string {
+ // Remove # if present
+ const hex = hexColor.replace('#', '');
+
+ // Convert to RGB
+ const r = parseInt(hex.substr(0, 2), 16);
+ const g = parseInt(hex.substr(2, 2), 16);
+ const b = parseInt(hex.substr(4, 2), 16);
+
+ // Calculate luminance
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+
+ return luminance > 0.5 ? '#000000' : '#ffffff';
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssueItem.module.scss b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssueItem.module.scss
new file mode 100644
index 0000000..f9bfaa3
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssueItem.module.scss
@@ -0,0 +1,113 @@
+/**
+ * IssueItem Styles
+ */
+
+.IssueItem {
+ background-color: var(--theme-color-bg-3);
+ border: 1px solid var(--theme-color-border-default);
+ border-radius: 4px;
+ padding: 12px;
+ margin-bottom: 8px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover {
+ background-color: var(--theme-color-bg-4);
+ border-color: var(--theme-color-border-hover);
+ }
+
+ &:active {
+ transform: scale(0.99);
+ }
+}
+
+.Header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.TitleRow {
+ flex: 1;
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ min-width: 0;
+}
+
+.Number {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 12px;
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+.Title {
+ color: var(--theme-color-fg-default);
+ font-size: 13px;
+ font-weight: 500;
+ flex: 1;
+ word-break: break-word;
+}
+
+.StatusBadge {
+ flex-shrink: 0;
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: capitalize;
+
+ &[data-state='open'] {
+ background-color: rgba(46, 160, 67, 0.15);
+ color: #2ea043;
+ }
+
+ &[data-state='closed'] {
+ background-color: rgba(177, 24, 24, 0.15);
+ color: #da3633;
+ }
+}
+
+.Meta {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+
+.Author {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 12px;
+}
+
+.Comments {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 12px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.Labels {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+}
+
+.Label {
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 600;
+ display: inline-block;
+}
+
+.MoreLabels {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 11px;
+ font-weight: 600;
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssueItem.tsx b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssueItem.tsx
new file mode 100644
index 0000000..64305f1
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssueItem.tsx
@@ -0,0 +1,101 @@
+/**
+ * IssueItem Component
+ *
+ * Displays a single GitHub issue in a card format
+ */
+
+import React from 'react';
+
+import type { GitHubIssue } from '../../../../../services/github/GitHubTypes';
+import styles from './IssueItem.module.scss';
+
+interface IssueItemProps {
+ issue: GitHubIssue;
+ onClick: (issue: GitHubIssue) => void;
+}
+
+export function IssueItem({ issue, onClick }: IssueItemProps) {
+ const createdDate = new Date(issue.created_at);
+ const relativeTime = getRelativeTimeString(createdDate);
+
+ return (
+ onClick(issue)}>
+
+
+ #{issue.number}
+ {issue.title}
+
+
+ {issue.state === 'open' ? '🟢' : '🔴'} {issue.state}
+
+
+
+
+
+ Opened by {issue.user.login} {relativeTime}
+
+ {issue.comments > 0 && 💬 {issue.comments}}
+
+
+ {issue.labels && issue.labels.length > 0 && (
+
+ {issue.labels.slice(0, 3).map((label) => (
+
+ {label.name}
+
+ ))}
+ {issue.labels.length > 3 && +{issue.labels.length - 3}}
+
+ )}
+
+ );
+}
+
+/**
+ * Get relative time string (e.g., "2 hours ago", "3 days ago")
+ */
+function getRelativeTimeString(date: Date): string {
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffSec = Math.floor(diffMs / 1000);
+ const diffMin = Math.floor(diffSec / 60);
+ const diffHour = Math.floor(diffMin / 60);
+ const diffDay = Math.floor(diffHour / 24);
+
+ if (diffSec < 60) {
+ return 'just now';
+ } else if (diffMin < 60) {
+ return `${diffMin} minute${diffMin !== 1 ? 's' : ''} ago`;
+ } else if (diffHour < 24) {
+ return `${diffHour} hour${diffHour !== 1 ? 's' : ''} ago`;
+ } else if (diffDay < 30) {
+ return `${diffDay} day${diffDay !== 1 ? 's' : ''} ago`;
+ } else {
+ return date.toLocaleDateString();
+ }
+}
+
+/**
+ * Get contrasting text color (black or white) for a background color
+ */
+function getContrastColor(hexColor: string): string {
+ // Remove # if present
+ const hex = hexColor.replace('#', '');
+
+ // Convert to RGB
+ const r = parseInt(hex.substr(0, 2), 16);
+ const g = parseInt(hex.substr(2, 2), 16);
+ const b = parseInt(hex.substr(4, 2), 16);
+
+ // Calculate luminance
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+
+ return luminance > 0.5 ? '#000000' : '#ffffff';
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssuesList.module.scss b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssuesList.module.scss
new file mode 100644
index 0000000..ab99495
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssuesList.module.scss
@@ -0,0 +1,145 @@
+/**
+ * IssuesList Styles
+ */
+
+.IssuesList {
+ padding: 8px;
+ overflow-y: auto;
+ flex: 1;
+}
+
+.LoadingState,
+.ErrorState,
+.EmptyState {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 40px 20px;
+ text-align: center;
+ color: var(--theme-color-fg-default-shy);
+}
+
+.Spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid var(--theme-color-border-default);
+ border-top-color: var(--theme-color-primary);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin-bottom: 16px;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.LoadingState p {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 13px;
+}
+
+.ErrorState {
+ color: var(--theme-color-fg-error);
+}
+
+.ErrorIcon {
+ font-size: 48px;
+ margin-bottom: 16px;
+}
+
+.ErrorState h3 {
+ color: var(--theme-color-fg-default);
+ font-size: 15px;
+ font-weight: 600;
+ margin-bottom: 8px;
+}
+
+.ErrorState p {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 13px;
+ margin-bottom: 16px;
+}
+
+.RetryButton {
+ padding: 8px 16px;
+ background-color: var(--theme-color-primary);
+ color: white;
+ border: none;
+ border-radius: 4px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: opacity 0.15s ease;
+
+ &:hover {
+ opacity: 0.9;
+ }
+
+ &:active {
+ opacity: 0.8;
+ }
+}
+
+.EmptyIcon {
+ font-size: 48px;
+ margin-bottom: 16px;
+}
+
+.EmptyState h3 {
+ color: var(--theme-color-fg-default);
+ font-size: 15px;
+ font-weight: 600;
+ margin-bottom: 8px;
+}
+
+.EmptyState p {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 13px;
+}
+
+.LoadMoreButton {
+ width: 100%;
+ padding: 10px;
+ margin-top: 8px;
+ background-color: var(--theme-color-bg-3);
+ border: 1px solid var(--theme-color-border-default);
+ border-radius: 4px;
+ color: var(--theme-color-fg-default);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+
+ &:hover:not(:disabled) {
+ background-color: var(--theme-color-bg-4);
+ border-color: var(--theme-color-border-hover);
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+}
+
+.SmallSpinner {
+ width: 14px;
+ height: 14px;
+ border: 2px solid var(--theme-color-border-default);
+ border-top-color: var(--theme-color-primary);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+.EndMessage {
+ text-align: center;
+ padding: 16px;
+ color: var(--theme-color-fg-default-shy);
+ font-size: 12px;
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssuesList.tsx b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssuesList.tsx
new file mode 100644
index 0000000..2da07f1
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/IssuesTab/IssuesList.tsx
@@ -0,0 +1,85 @@
+/**
+ * IssuesList Component
+ *
+ * Displays a list of GitHub issues with loading states and pagination
+ */
+
+import React, { useState } from 'react';
+
+import type { GitHubIssue } from '../../../../../services/github/GitHubTypes';
+import { IssueDetail } from './IssueDetail';
+import { IssueItem } from './IssueItem';
+import styles from './IssuesList.module.scss';
+
+interface IssuesListProps {
+ issues: GitHubIssue[];
+ loading: boolean;
+ error: Error | null;
+ hasMore: boolean;
+ loadMore: () => Promise;
+ loadingMore: boolean;
+ onRefresh: () => Promise;
+}
+
+export function IssuesList({ issues, loading, error, hasMore, loadMore, loadingMore, onRefresh }: IssuesListProps) {
+ const [selectedIssue, setSelectedIssue] = useState(null);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
⚠️
+
Failed to load issues
+
{error.message}
+
+
+ );
+ }
+
+ if (issues.length === 0) {
+ return (
+
+
📝
+
No issues found
+
This repository doesn't have any issues yet.
+
+ );
+ }
+
+ return (
+ <>
+
+ {issues.map((issue) => (
+
+ ))}
+
+ {hasMore && (
+
+ )}
+
+ {!hasMore && issues.length > 0 &&
No more issues to load
}
+
+
+ {selectedIssue && setSelectedIssue(null)} />}
+ >
+ );
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRDetail.module.scss b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRDetail.module.scss
new file mode 100644
index 0000000..e29476d
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRDetail.module.scss
@@ -0,0 +1,252 @@
+/**
+ * PRDetail Styles - Slide-out panel
+ */
+
+.PRDetailOverlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 1000;
+ display: flex;
+ justify-content: flex-end;
+ animation: fadeIn 0.2s ease;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.PRDetail {
+ width: 600px;
+ max-width: 90vw;
+ height: 100%;
+ background-color: var(--theme-color-bg-2);
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2);
+ display: flex;
+ flex-direction: column;
+ animation: slideIn 0.3s ease;
+ overflow: hidden;
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ }
+ to {
+ transform: translateX(0);
+ }
+}
+
+.Header {
+ padding: 20px;
+ border-bottom: 1px solid var(--theme-color-border-default);
+ flex-shrink: 0;
+}
+
+.TitleSection {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.Title {
+ flex: 1;
+ color: var(--theme-color-fg-default);
+ font-size: 18px;
+ font-weight: 600;
+ margin: 0;
+ line-height: 1.4;
+}
+
+.StatusBadge {
+ flex-shrink: 0;
+ padding: 4px 12px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: capitalize;
+
+ &[data-status='open'] {
+ background-color: rgba(46, 160, 67, 0.15);
+ color: #2ea043;
+ }
+
+ &[data-status='draft'] {
+ background-color: rgba(110, 118, 129, 0.15);
+ color: #6e7681;
+ }
+
+ &[data-status='merged'] {
+ background-color: rgba(137, 87, 229, 0.15);
+ color: #8957e5;
+ }
+
+ &[data-status='closed'] {
+ background-color: rgba(177, 24, 24, 0.15);
+ color: #da3633;
+ }
+}
+
+.CloseButton {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: transparent;
+ border: none;
+ border-radius: 4px;
+ color: var(--theme-color-fg-default-shy);
+ font-size: 20px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover {
+ background-color: var(--theme-color-bg-4);
+ color: var(--theme-color-fg-default);
+ }
+}
+
+.Meta {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ color: var(--theme-color-fg-default-shy);
+ font-size: 13px;
+ margin-bottom: 12px;
+
+ strong {
+ color: var(--theme-color-fg-default);
+ font-weight: 600;
+ }
+}
+
+.Branch {
+ background-color: var(--theme-color-bg-4);
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-family: monospace;
+ font-size: 12px;
+ color: var(--theme-color-fg-default);
+}
+
+.Labels {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 12px;
+}
+
+.Label {
+ padding: 3px 10px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: 600;
+ display: inline-block;
+}
+
+.Stats {
+ display: flex;
+ gap: 20px;
+ padding: 12px 0;
+ border-top: 1px solid var(--theme-color-border-default);
+ border-bottom: 1px solid var(--theme-color-border-default);
+ margin-bottom: 12px;
+}
+
+.StatItem {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.StatLabel {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.StatValue {
+ color: var(--theme-color-fg-default);
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.Body {
+ flex: 1;
+ padding: 20px;
+ overflow-y: auto;
+ color: var(--theme-color-fg-default);
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.MarkdownContent {
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.NoDescription {
+ color: var(--theme-color-fg-default-shy);
+ font-style: italic;
+}
+
+.MergeInfo,
+.DraftInfo,
+.ClosedInfo {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 20px;
+ background-color: var(--theme-color-bg-3);
+ border-top: 1px solid var(--theme-color-border-default);
+ font-size: 13px;
+ color: var(--theme-color-fg-default-shy);
+}
+
+.MergeIcon,
+.DraftIcon,
+.ClosedIcon {
+ font-size: 16px;
+}
+
+.Footer {
+ padding: 16px 20px;
+ border-top: 1px solid var(--theme-color-border-default);
+ flex-shrink: 0;
+}
+
+.ViewOnGitHub {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 8px 16px;
+ background-color: var(--theme-color-primary);
+ color: white;
+ text-decoration: none;
+ border-radius: 4px;
+ font-size: 13px;
+ font-weight: 500;
+ transition: opacity 0.15s ease;
+
+ &:hover {
+ opacity: 0.9;
+ }
+
+ &:active {
+ opacity: 0.8;
+ }
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRDetail.tsx b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRDetail.tsx
new file mode 100644
index 0000000..a1f5629
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRDetail.tsx
@@ -0,0 +1,196 @@
+/**
+ * PRDetail Component
+ *
+ * Slide-out panel displaying full pull request details
+ */
+
+import React from 'react';
+
+import type { GitHubPullRequest } from '../../../../../services/github/GitHubTypes';
+import styles from './PRDetail.module.scss';
+
+interface PRDetailProps {
+ pr: GitHubPullRequest;
+ onClose: () => void;
+}
+
+export function PRDetail({ pr, onClose }: PRDetailProps) {
+ const isDraft = pr.draft;
+ const isMerged = pr.merged_at !== null;
+ const isClosed = pr.state === 'closed' && !isMerged;
+
+ return (
+
+
e.stopPropagation()}>
+
+
+
+ #{pr.number} {pr.title}
+
+
+ {getStatusIcon(pr)} {getStatusText(pr)}
+
+
+
+
+
+
+
+
+ {pr.user.login} wants to merge {pr.commits} commit{pr.commits !== 1 ? 's' : ''} into{' '}
+ {pr.base.ref} from{' '}
+ {pr.head.ref}
+
+ • Opened {getRelativeTimeString(new Date(pr.created_at))}
+
+
+ {pr.labels && pr.labels.length > 0 && (
+
+ {pr.labels.map((label) => (
+
+ {label.name}
+
+ ))}
+
+ )}
+
+
+
+ Commits
+ {pr.commits}
+
+
+ Files Changed
+ {pr.changed_files}
+
+
+ Comments
+ {pr.comments}
+
+
+
+
+ {pr.body ? (
+
{pr.body}
+ ) : (
+
No description provided.
+ )}
+
+
+ {isMerged && pr.merged_at && (
+
+ 🟣
+ Merged {getRelativeTimeString(new Date(pr.merged_at))}
+
+ )}
+
+ {isDraft && (
+
+ 📝
+ This pull request is still a work in progress
+
+ )}
+
+ {isClosed && (
+
+ 🔴
+ This pull request was closed without merging
+
+ )}
+
+
+
+
+ );
+}
+
+/**
+ * Get PR status
+ */
+function getStatus(pr: GitHubPullRequest): string {
+ if (pr.draft) return 'draft';
+ if (pr.merged_at) return 'merged';
+ if (pr.state === 'closed') return 'closed';
+ return 'open';
+}
+
+/**
+ * Get status icon
+ */
+function getStatusIcon(pr: GitHubPullRequest): string {
+ if (pr.draft) return '📝';
+ if (pr.merged_at) return '🟣';
+ if (pr.state === 'closed') return '🔴';
+ return '🟢';
+}
+
+/**
+ * Get status text
+ */
+function getStatusText(pr: GitHubPullRequest): string {
+ if (pr.draft) return 'Draft';
+ if (pr.merged_at) return 'Merged';
+ if (pr.state === 'closed') return 'Closed';
+ return 'Open';
+}
+
+/**
+ * Get relative time string (e.g., "2 hours ago", "3 days ago")
+ */
+function getRelativeTimeString(date: Date): string {
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffSec = Math.floor(diffMs / 1000);
+ const diffMin = Math.floor(diffSec / 60);
+ const diffHour = Math.floor(diffMin / 60);
+ const diffDay = Math.floor(diffHour / 24);
+
+ if (diffSec < 60) {
+ return 'just now';
+ } else if (diffMin < 60) {
+ return `${diffMin} minute${diffMin !== 1 ? 's' : ''} ago`;
+ } else if (diffHour < 24) {
+ return `${diffHour} hour${diffHour !== 1 ? 's' : ''} ago`;
+ } else if (diffDay < 30) {
+ return `${diffDay} day${diffDay !== 1 ? 's' : ''} ago`;
+ } else {
+ return date.toLocaleDateString();
+ }
+}
+
+/**
+ * Get contrasting text color (black or white) for a background color
+ */
+function getContrastColor(hexColor: string): string {
+ // Remove # if present
+ const hex = hexColor.replace('#', '');
+
+ // Convert to RGB
+ const r = parseInt(hex.substr(0, 2), 16);
+ const g = parseInt(hex.substr(2, 2), 16);
+ const b = parseInt(hex.substr(4, 2), 16);
+
+ // Calculate luminance
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+
+ return luminance > 0.5 ? '#000000' : '#ffffff';
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRItem.module.scss b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRItem.module.scss
new file mode 100644
index 0000000..62ccfcc
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRItem.module.scss
@@ -0,0 +1,135 @@
+/**
+ * PRItem Styles
+ */
+
+.PRItem {
+ background-color: var(--theme-color-bg-3);
+ border: 1px solid var(--theme-color-border-default);
+ border-radius: 4px;
+ padding: 12px;
+ margin-bottom: 8px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover {
+ background-color: var(--theme-color-bg-4);
+ border-color: var(--theme-color-border-hover);
+ }
+
+ &:active {
+ transform: scale(0.99);
+ }
+}
+
+.Header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.TitleRow {
+ flex: 1;
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ min-width: 0;
+}
+
+.Number {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 12px;
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+.Title {
+ color: var(--theme-color-fg-default);
+ font-size: 13px;
+ font-weight: 500;
+ flex: 1;
+ word-break: break-word;
+}
+
+.StatusBadge {
+ flex-shrink: 0;
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: capitalize;
+
+ &[data-status='open'] {
+ background-color: rgba(46, 160, 67, 0.15);
+ color: #2ea043;
+ }
+
+ &[data-status='draft'] {
+ background-color: rgba(110, 118, 129, 0.15);
+ color: #6e7681;
+ }
+
+ &[data-status='merged'] {
+ background-color: rgba(137, 87, 229, 0.15);
+ color: #8957e5;
+ }
+
+ &[data-status='closed'] {
+ background-color: rgba(177, 24, 24, 0.15);
+ color: #da3633;
+ }
+}
+
+.Meta {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-bottom: 8px;
+}
+
+.Author {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 12px;
+}
+
+.Time {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 11px;
+}
+
+.Stats {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+
+.Stat {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 12px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.Labels {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+}
+
+.Label {
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 600;
+ display: inline-block;
+}
+
+.MoreLabels {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 11px;
+ font-weight: 600;
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRItem.tsx b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRItem.tsx
new file mode 100644
index 0000000..e25f657
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRItem.tsx
@@ -0,0 +1,137 @@
+/**
+ * PRItem Component
+ *
+ * Displays a single GitHub pull request in a card format
+ */
+
+import React from 'react';
+
+import type { GitHubPullRequest } from '../../../../../services/github/GitHubTypes';
+import styles from './PRItem.module.scss';
+
+interface PRItemProps {
+ pr: GitHubPullRequest;
+ onClick: (pr: GitHubPullRequest) => void;
+}
+
+export function PRItem({ pr, onClick }: PRItemProps) {
+ const createdDate = new Date(pr.created_at);
+ const relativeTime = getRelativeTimeString(createdDate);
+
+ return (
+ onClick(pr)}>
+
+
+ #{pr.number}
+ {pr.title}
+
+
+ {getStatusIcon(pr)} {getStatusText(pr)}
+
+
+
+
+
+ {pr.user.login} wants to merge into {pr.base.ref} from {pr.head.ref}
+
+ {relativeTime}
+
+
+
+ {pr.comments > 0 && 💬 {pr.comments}}
+ {pr.commits > 0 && 📝 {pr.commits} commits}
+ {pr.changed_files > 0 && 📄 {pr.changed_files} files}
+
+
+ {pr.labels && pr.labels.length > 0 && (
+
+ {pr.labels.slice(0, 3).map((label) => (
+
+ {label.name}
+
+ ))}
+ {pr.labels.length > 3 && +{pr.labels.length - 3}}
+
+ )}
+
+ );
+}
+
+/**
+ * Get PR status
+ */
+function getStatus(pr: GitHubPullRequest): string {
+ if (pr.draft) return 'draft';
+ if (pr.merged_at) return 'merged';
+ if (pr.state === 'closed') return 'closed';
+ return 'open';
+}
+
+/**
+ * Get status icon
+ */
+function getStatusIcon(pr: GitHubPullRequest): string {
+ if (pr.draft) return '📝';
+ if (pr.merged_at) return '🟣';
+ if (pr.state === 'closed') return '🔴';
+ return '🟢';
+}
+
+/**
+ * Get status text
+ */
+function getStatusText(pr: GitHubPullRequest): string {
+ if (pr.draft) return 'Draft';
+ if (pr.merged_at) return 'Merged';
+ if (pr.state === 'closed') return 'Closed';
+ return 'Open';
+}
+
+/**
+ * Get relative time string (e.g., "2 hours ago", "3 days ago")
+ */
+function getRelativeTimeString(date: Date): string {
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffSec = Math.floor(diffMs / 1000);
+ const diffMin = Math.floor(diffSec / 60);
+ const diffHour = Math.floor(diffMin / 60);
+ const diffDay = Math.floor(diffHour / 24);
+
+ if (diffSec < 60) {
+ return 'just now';
+ } else if (diffMin < 60) {
+ return `${diffMin} minute${diffMin !== 1 ? 's' : ''} ago`;
+ } else if (diffHour < 24) {
+ return `${diffHour} hour${diffHour !== 1 ? 's' : ''} ago`;
+ } else if (diffDay < 30) {
+ return `${diffDay} day${diffDay !== 1 ? 's' : ''} ago`;
+ } else {
+ return date.toLocaleDateString();
+ }
+}
+
+/**
+ * Get contrasting text color (black or white) for a background color
+ */
+function getContrastColor(hexColor: string): string {
+ // Remove # if present
+ const hex = hexColor.replace('#', '');
+
+ // Convert to RGB
+ const r = parseInt(hex.substr(0, 2), 16);
+ const g = parseInt(hex.substr(2, 2), 16);
+ const b = parseInt(hex.substr(4, 2), 16);
+
+ // Calculate luminance
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+
+ return luminance > 0.5 ? '#000000' : '#ffffff';
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRsList.module.scss b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRsList.module.scss
new file mode 100644
index 0000000..41864b9
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRsList.module.scss
@@ -0,0 +1,145 @@
+/**
+ * PRsList Styles
+ */
+
+.PRsList {
+ padding: 8px;
+ overflow-y: auto;
+ flex: 1;
+}
+
+.LoadingState,
+.ErrorState,
+.EmptyState {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 40px 20px;
+ text-align: center;
+ color: var(--theme-color-fg-default-shy);
+}
+
+.Spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid var(--theme-color-border-default);
+ border-top-color: var(--theme-color-primary);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin-bottom: 16px;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.LoadingState p {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 13px;
+}
+
+.ErrorState {
+ color: var(--theme-color-fg-error);
+}
+
+.ErrorIcon {
+ font-size: 48px;
+ margin-bottom: 16px;
+}
+
+.ErrorState h3 {
+ color: var(--theme-color-fg-default);
+ font-size: 15px;
+ font-weight: 600;
+ margin-bottom: 8px;
+}
+
+.ErrorState p {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 13px;
+ margin-bottom: 16px;
+}
+
+.RetryButton {
+ padding: 8px 16px;
+ background-color: var(--theme-color-primary);
+ color: white;
+ border: none;
+ border-radius: 4px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: opacity 0.15s ease;
+
+ &:hover {
+ opacity: 0.9;
+ }
+
+ &:active {
+ opacity: 0.8;
+ }
+}
+
+.EmptyIcon {
+ font-size: 48px;
+ margin-bottom: 16px;
+}
+
+.EmptyState h3 {
+ color: var(--theme-color-fg-default);
+ font-size: 15px;
+ font-weight: 600;
+ margin-bottom: 8px;
+}
+
+.EmptyState p {
+ color: var(--theme-color-fg-default-shy);
+ font-size: 13px;
+}
+
+.LoadMoreButton {
+ width: 100%;
+ padding: 10px;
+ margin-top: 8px;
+ background-color: var(--theme-color-bg-3);
+ border: 1px solid var(--theme-color-border-default);
+ border-radius: 4px;
+ color: var(--theme-color-fg-default);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+
+ &:hover:not(:disabled) {
+ background-color: var(--theme-color-bg-4);
+ border-color: var(--theme-color-border-hover);
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+}
+
+.SmallSpinner {
+ width: 14px;
+ height: 14px;
+ border: 2px solid var(--theme-color-border-default);
+ border-top-color: var(--theme-color-primary);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+.EndMessage {
+ text-align: center;
+ padding: 16px;
+ color: var(--theme-color-fg-default-shy);
+ font-size: 12px;
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRsList.tsx b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRsList.tsx
new file mode 100644
index 0000000..9a46867
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/components/PullRequestsTab/PRsList.tsx
@@ -0,0 +1,85 @@
+/**
+ * PRsList Component
+ *
+ * Displays a list of GitHub pull requests with loading states and pagination
+ */
+
+import React, { useState } from 'react';
+
+import type { GitHubPullRequest } from '../../../../../services/github/GitHubTypes';
+import { PRDetail } from './PRDetail';
+import { PRItem } from './PRItem';
+import styles from './PRsList.module.scss';
+
+interface PRsListProps {
+ pullRequests: GitHubPullRequest[];
+ loading: boolean;
+ error: Error | null;
+ hasMore: boolean;
+ loadMore: () => Promise;
+ loadingMore: boolean;
+ onRefresh: () => Promise;
+}
+
+export function PRsList({ pullRequests, loading, error, hasMore, loadMore, loadingMore, onRefresh }: PRsListProps) {
+ const [selectedPR, setSelectedPR] = useState(null);
+
+ if (loading) {
+ return (
+
+
+
Loading pull requests...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
⚠️
+
Failed to load pull requests
+
{error.message}
+
+
+ );
+ }
+
+ if (pullRequests.length === 0) {
+ return (
+
+
🔀
+
No pull requests found
+
This repository doesn't have any pull requests yet.
+
+ );
+ }
+
+ return (
+ <>
+
+ {pullRequests.map((pr) => (
+
+ ))}
+
+ {hasMore && (
+
+ )}
+
+ {!hasMore && pullRequests.length > 0 &&
No more pull requests to load
}
+
+
+ {selectedPR && setSelectedPR(null)} />}
+ >
+ );
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/hooks/useGitHubRepository.ts b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/hooks/useGitHubRepository.ts
new file mode 100644
index 0000000..5d04b93
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/hooks/useGitHubRepository.ts
@@ -0,0 +1,144 @@
+/**
+ * useGitHubRepository Hook
+ *
+ * Extracts GitHub repository information from the Git remote URL.
+ * Returns owner, repo name, and connection status.
+ */
+
+import { useState, useEffect } from 'react';
+import { Git } from '@noodl/git';
+
+import { ProjectModel } from '@noodl-models/projectmodel';
+import { mergeProject } from '@noodl-utils/projectmerger';
+
+interface GitHubRepoInfo {
+ owner: string | null;
+ repo: string | null;
+ isGitHub: boolean;
+ isReady: boolean;
+}
+
+/**
+ * Parse GitHub owner and repo from a remote URL
+ * Handles formats:
+ * - https://github.com/owner/repo.git
+ * - git@github.com:owner/repo.git
+ * - https://github.com/owner/repo
+ */
+function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
+ if (!url || !url.includes('github.com')) {
+ return null;
+ }
+
+ // Remove .git suffix if present
+ const cleanUrl = url.replace(/\.git$/, '');
+
+ // Handle HTTPS format: https://github.com/owner/repo
+ const httpsMatch = cleanUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
+ if (httpsMatch) {
+ return {
+ owner: httpsMatch[1],
+ repo: httpsMatch[2]
+ };
+ }
+
+ // Handle SSH format: git@github.com:owner/repo
+ const sshMatch = cleanUrl.match(/github\.com:([^/]+)\/([^/]+)/);
+ if (sshMatch) {
+ return {
+ owner: sshMatch[1],
+ repo: sshMatch[2]
+ };
+ }
+
+ return null;
+}
+
+/**
+ * Hook to get GitHub repository information from current project's Git remote
+ */
+export function useGitHubRepository(): GitHubRepoInfo {
+ const [repoInfo, setRepoInfo] = useState({
+ owner: null,
+ repo: null,
+ isGitHub: false,
+ isReady: false
+ });
+
+ useEffect(() => {
+ async function fetchRepoInfo() {
+ try {
+ const projectDirectory = ProjectModel.instance?._retainedProjectDirectory;
+ if (!projectDirectory) {
+ setRepoInfo({
+ owner: null,
+ repo: null,
+ isGitHub: false,
+ isReady: false
+ });
+ return;
+ }
+
+ // Create Git instance and open repository
+ const git = new Git(mergeProject);
+ await git.openRepository(projectDirectory);
+
+ // Check if it's a GitHub repository
+ const provider = git.Provider;
+ if (provider !== 'github') {
+ setRepoInfo({
+ owner: null,
+ repo: null,
+ isGitHub: false,
+ isReady: false
+ });
+ return;
+ }
+
+ // Parse the remote URL
+ const remoteUrl = git.OriginUrl;
+ const parsed = parseGitHubUrl(remoteUrl);
+
+ if (parsed) {
+ setRepoInfo({
+ owner: parsed.owner,
+ repo: parsed.repo,
+ isGitHub: true,
+ isReady: true
+ });
+ } else {
+ setRepoInfo({
+ owner: null,
+ repo: null,
+ isGitHub: true, // It's GitHub but couldn't parse
+ isReady: false
+ });
+ }
+ } catch (error) {
+ console.error('Failed to fetch GitHub repository info:', error);
+ setRepoInfo({
+ owner: null,
+ repo: null,
+ isGitHub: false,
+ isReady: false
+ });
+ }
+ }
+
+ fetchRepoInfo();
+
+ // Refetch when project changes
+ const handleProjectChange = () => {
+ fetchRepoInfo();
+ };
+
+ ProjectModel.instance?.on('projectOpened', handleProjectChange);
+ ProjectModel.instance?.on('remoteChanged', handleProjectChange);
+
+ return () => {
+ ProjectModel.instance?.off(handleProjectChange);
+ };
+ }, []);
+
+ return repoInfo;
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/hooks/useIssues.ts b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/hooks/useIssues.ts
new file mode 100644
index 0000000..4103463
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/hooks/useIssues.ts
@@ -0,0 +1,121 @@
+/**
+ * useIssues Hook
+ *
+ * Fetches and manages GitHub issues for a repository.
+ * Handles pagination, filtering, and real-time updates.
+ */
+
+import { useEventListener } from '@noodl-hooks/useEventListener';
+import { useState, useEffect, useCallback } from 'react';
+
+import { GitHubClient } from '../../../../services/github';
+import type { GitHubIssue, GitHubIssueFilters } from '../../../../services/github/GitHubTypes';
+
+interface UseIssuesOptions {
+ owner: string | null;
+ repo: string | null;
+ filters?: GitHubIssueFilters;
+ enabled?: boolean;
+}
+
+interface UseIssuesResult {
+ issues: GitHubIssue[];
+ loading: boolean;
+ error: Error | null;
+ refetch: () => Promise;
+ hasMore: boolean;
+ loadMore: () => Promise;
+ loadingMore: boolean;
+}
+
+const DEFAULT_PER_PAGE = 30;
+
+/**
+ * Hook to fetch and manage GitHub issues
+ */
+export function useIssues({ owner, repo, filters = {}, enabled = true }: UseIssuesOptions): UseIssuesResult {
+ const [issues, setIssues] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [error, setError] = useState(null);
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(true);
+
+ const client = GitHubClient.instance;
+
+ const fetchIssues = useCallback(
+ async (pageNum: number = 1, append: boolean = false) => {
+ if (!owner || !repo || !enabled) {
+ setLoading(false);
+ return;
+ }
+
+ try {
+ if (append) {
+ setLoadingMore(true);
+ } else {
+ setLoading(true);
+ setError(null);
+ }
+
+ const response = await client.listIssues(owner, repo, {
+ ...filters,
+ per_page: DEFAULT_PER_PAGE,
+ page: pageNum
+ });
+
+ const newIssues = response.data;
+
+ if (append) {
+ setIssues((prev) => [...prev, ...newIssues]);
+ } else {
+ setIssues(newIssues);
+ }
+
+ // Check if there are more issues to load
+ setHasMore(newIssues.length === DEFAULT_PER_PAGE);
+ setPage(pageNum);
+ } catch (err) {
+ console.error('Failed to fetch issues:', err);
+ setError(err instanceof Error ? err : new Error('Failed to fetch issues'));
+ setHasMore(false);
+ } finally {
+ setLoading(false);
+ setLoadingMore(false);
+ }
+ },
+ [owner, repo, enabled, filters, client]
+ );
+
+ const refetch = useCallback(async () => {
+ setPage(1);
+ setHasMore(true);
+ await fetchIssues(1, false);
+ }, [fetchIssues]);
+
+ const loadMore = useCallback(async () => {
+ if (!loadingMore && hasMore) {
+ await fetchIssues(page + 1, true);
+ }
+ }, [fetchIssues, page, hasMore, loadingMore]);
+
+ // Initial fetch
+ useEffect(() => {
+ refetch();
+ }, [owner, repo, filters, enabled]);
+
+ // Listen for cache invalidation events
+ useEventListener(client, 'rate-limit-updated', () => {
+ // Could show a notification about rate limits
+ });
+
+ return {
+ issues,
+ loading,
+ error,
+ refetch,
+ hasMore,
+ loadMore,
+ loadingMore
+ };
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/hooks/usePullRequests.ts b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/hooks/usePullRequests.ts
new file mode 100644
index 0000000..f08a802
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/hooks/usePullRequests.ts
@@ -0,0 +1,126 @@
+/**
+ * usePullRequests Hook
+ *
+ * Fetches and manages GitHub pull requests for a repository.
+ * Handles pagination, filtering, and real-time updates.
+ */
+
+import { useEventListener } from '@noodl-hooks/useEventListener';
+import { useState, useEffect, useCallback } from 'react';
+
+import { GitHubClient } from '../../../../services/github';
+import type { GitHubPullRequest, GitHubIssueFilters } from '../../../../services/github/GitHubTypes';
+
+interface UsePullRequestsOptions {
+ owner: string | null;
+ repo: string | null;
+ filters?: Omit;
+ enabled?: boolean;
+}
+
+interface UsePullRequestsResult {
+ pullRequests: GitHubPullRequest[];
+ loading: boolean;
+ error: Error | null;
+ refetch: () => Promise;
+ hasMore: boolean;
+ loadMore: () => Promise;
+ loadingMore: boolean;
+}
+
+const DEFAULT_PER_PAGE = 30;
+
+/**
+ * Hook to fetch and manage GitHub pull requests
+ */
+export function usePullRequests({
+ owner,
+ repo,
+ filters = {},
+ enabled = true
+}: UsePullRequestsOptions): UsePullRequestsResult {
+ const [pullRequests, setPullRequests] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [error, setError] = useState(null);
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(true);
+
+ const client = GitHubClient.instance;
+
+ const fetchPullRequests = useCallback(
+ async (pageNum: number = 1, append: boolean = false) => {
+ if (!owner || !repo || !enabled) {
+ setLoading(false);
+ return;
+ }
+
+ try {
+ if (append) {
+ setLoadingMore(true);
+ } else {
+ setLoading(true);
+ setError(null);
+ }
+
+ const response = await client.listPullRequests(owner, repo, {
+ ...filters,
+ per_page: DEFAULT_PER_PAGE,
+ page: pageNum
+ });
+
+ const newPRs = response.data;
+
+ if (append) {
+ setPullRequests((prev) => [...prev, ...newPRs]);
+ } else {
+ setPullRequests(newPRs);
+ }
+
+ // Check if there are more PRs to load
+ setHasMore(newPRs.length === DEFAULT_PER_PAGE);
+ setPage(pageNum);
+ } catch (err) {
+ console.error('Failed to fetch pull requests:', err);
+ setError(err instanceof Error ? err : new Error('Failed to fetch pull requests'));
+ setHasMore(false);
+ } finally {
+ setLoading(false);
+ setLoadingMore(false);
+ }
+ },
+ [owner, repo, enabled, filters, client]
+ );
+
+ const refetch = useCallback(async () => {
+ setPage(1);
+ setHasMore(true);
+ await fetchPullRequests(1, false);
+ }, [fetchPullRequests]);
+
+ const loadMore = useCallback(async () => {
+ if (!loadingMore && hasMore) {
+ await fetchPullRequests(page + 1, true);
+ }
+ }, [fetchPullRequests, page, hasMore, loadingMore]);
+
+ // Initial fetch
+ useEffect(() => {
+ refetch();
+ }, [owner, repo, filters, enabled]);
+
+ // Listen for cache invalidation events
+ useEventListener(client, 'rate-limit-updated', () => {
+ // Could show a notification about rate limits
+ });
+
+ return {
+ pullRequests,
+ loading,
+ error,
+ refetch,
+ hasMore,
+ loadMore,
+ loadingMore
+ };
+}
diff --git a/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/index.ts b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/index.ts
new file mode 100644
index 0000000..9645a45
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/index.ts
@@ -0,0 +1 @@
+export { GitHubPanel } from './GitHubPanel';
diff --git a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/CodeEditor/CodeEditorType.ts b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/CodeEditor/CodeEditorType.ts
index 6f46278..bdd169c 100644
--- a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/CodeEditor/CodeEditorType.ts
+++ b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/CodeEditor/CodeEditorType.ts
@@ -68,6 +68,7 @@ export class CodeEditorType extends TypeView {
nodeId: string;
isPrimary: boolean;
+ readOnly: boolean;
propertyRoot: Root | null = null;
popoutRoot: Root | null = null;
@@ -78,6 +79,14 @@ export class CodeEditorType extends TypeView {
const p = args.port;
const parent = args.parent;
+ // Debug: Log all port properties
+ console.log('[CodeEditorType.fromPort] Port properties:', {
+ name: p.name,
+ readOnly: p.readOnly,
+ type: p.type,
+ allKeys: Object.keys(p)
+ });
+
view.port = p;
view.displayName = p.displayName ? p.displayName : p.name;
view.name = p.name;
@@ -90,6 +99,11 @@ export class CodeEditorType extends TypeView {
view.isConnected = parent.model.isPortConnected(p.name, 'target');
view.isDefault = parent.model.parameters[p.name] === undefined;
+ // Try multiple locations for readOnly flag
+ view.readOnly = p.readOnly || p.type?.readOnly || getEditType(p)?.readOnly || false;
+
+ console.log('[CodeEditorType.fromPort] Resolved readOnly:', view.readOnly);
+
// HACK: Like most of Property panel,
// since the property panel can have many code editors
// we want to open the one most likely to be the
@@ -316,7 +330,15 @@ export class CodeEditorType extends TypeView {
validationType = 'script';
}
+ // Debug logging
+ console.log('[CodeEditorType] Rendering JavaScriptEditor:', {
+ parameterName: scope.name,
+ readOnly: this.readOnly,
+ nodeId: nodeId
+ });
+
// Render JavaScriptEditor with proper sizing and history support
+ // For read-only fields, don't pass nodeId/parameterName (no history tracking)
this.popoutRoot.render(
React.createElement(JavaScriptEditor, {
value: this.value || '',
@@ -329,11 +351,12 @@ export class CodeEditorType extends TypeView {
save();
},
validationType,
+ disabled: this.readOnly, // Enable read-only mode if port is marked readOnly
width: props.initialSize?.x || 800,
height: props.initialSize?.y || 500,
- // Add history tracking
- nodeId: nodeId,
- parameterName: scope.name
+ // Only add history tracking for editable fields
+ nodeId: this.readOnly ? undefined : nodeId,
+ parameterName: this.readOnly ? undefined : scope.name
})
);
} else {
diff --git a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/LogicBuilderWorkspaceType.ts b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/LogicBuilderWorkspaceType.ts
index 57a0327..a22bfbe 100644
--- a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/LogicBuilderWorkspaceType.ts
+++ b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/LogicBuilderWorkspaceType.ts
@@ -5,6 +5,7 @@ import { getEditType } from '../utils';
/**
* Custom editor for Logic Builder workspace parameter
* Shows an "Edit Blocks" button that opens the Blockly editor in a tab
+ * And a "View Generated Code" button to show the compiled JavaScript
*/
export class LogicBuilderWorkspaceType extends TypeView {
el: TSFixme;
@@ -20,7 +21,7 @@ export class LogicBuilderWorkspaceType extends TypeView {
view.displayName = p.displayName ? p.displayName : p.name;
view.name = p.name;
view.type = getEditType(p);
- view.group = p.group;
+ view.group = null; // Hide group label
view.tooltip = p.tooltip;
view.value = parent.model.getParameter(p.name);
view.parent = parent;
@@ -31,13 +32,21 @@ export class LogicBuilderWorkspaceType extends TypeView {
}
render() {
- // Create a simple container with a button
- const html = `
+ // Hide empty group labels
+ const hideEmptyGroupsCSS = `
+
+ `;
+
+ // Create a simple container with single button
+ const html =
+ hideEmptyGroupsCSS +
+ `
-
-
-
${this.displayName}
-
`;
diff --git a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/NodeLabel/NodeLabel.tsx b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/NodeLabel/NodeLabel.tsx
index 25e3f8e..74d36bf 100644
--- a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/NodeLabel/NodeLabel.tsx
+++ b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/NodeLabel/NodeLabel.tsx
@@ -87,7 +87,9 @@ export function NodeLabel({ model, showHelp = true }: NodeLabelProps) {
{
+ onDoubleClick={(e) => {
+ // Stop propagation to prevent canvas double-click handler from triggering
+ e.stopPropagation();
if (!isEditingLabel) {
onEditLabel();
}
diff --git a/packages/noodl-editor/src/editor/src/views/popuplayer.js b/packages/noodl-editor/src/editor/src/views/popuplayer.js
index d16c3e1..7836b64 100644
--- a/packages/noodl-editor/src/editor/src/views/popuplayer.js
+++ b/packages/noodl-editor/src/editor/src/views/popuplayer.js
@@ -535,6 +535,9 @@ PopupLayer.prototype.showPopout = function (args) {
this.popouts.push(popout);
+ // Enable pointer events for outside-click-to-close when popouts are active
+ this.$('.popup-layer').addClass('has-popouts');
+
if (args.animate) {
popoutEl.css({
transform: 'translateY(10px)',
@@ -587,6 +590,8 @@ PopupLayer.prototype.hidePopout = function (popout) {
if (this.popouts.length === 0) {
this.$('.popup-layer-blocker').css({ display: 'none' });
+ // Disable pointer events when no popouts are active
+ this.$('.popup-layer').removeClass('has-popouts');
}
};
diff --git a/packages/noodl-editor/src/editor/src/views/popuplayer.js.bak b/packages/noodl-editor/src/editor/src/views/popuplayer.js.bak
new file mode 100644
index 0000000..d16c3e1
--- /dev/null
+++ b/packages/noodl-editor/src/editor/src/views/popuplayer.js.bak
@@ -0,0 +1,1038 @@
+const KeyboardHandler = require('@noodl-utils/keyboardhandler').default;
+const { KeyCode } = require('@noodl-utils/keyboard/KeyCode');
+const View = require('../../../shared/view');
+const PopupLayerTemplate = require('../templates/popuplayer.html');
+const StringInputPopupTemplate = require('../templates/stringinputpopup.html');
+const YesNoPopupTemplate = require('../templates/yesnopopup.html');
+const FileSystem = require('../utils/filesystem');
+const ConfirmModal = require('./confirmmodal');
+const ErrorModal = require('./errormodal');
+const { ToastLayer } = require('./ToastLayer/ToastLayer');
+
+const utils = require('../utils/utils');
+const { platform, PlatformOS } = require('@noodl/platform');
+
+// Styles
+require('../styles/popuplayer.css');
+
+// ---------------------------------------------------------------------
+// PopupLayer
+// ---------------------------------------------------------------------
+function PopupLayer() {
+ View.call(this);
+
+ this.isShowingPopup = false;
+ this.ignoreContextMenuEvent = false;
+
+ this.isLocked = false; //locked means that a body click event won't close the popup
+ this.contentId = '';
+ this.popouts = [];
+ this._dimLayerCount = 0;
+ this.modals = [];
+
+ KeyboardHandler.instance.registerCommands([
+ {
+ handler: () => {
+ if (this.popup && this.isShowingPopup) {
+ this.hidePopup();
+ } else if (this.modals.length) {
+ this.hideModal();
+ } else if (this.popouts.length) {
+ this.hidePopouts();
+ }
+ },
+ keybinding: KeyCode.Escape
+ }
+ ]);
+}
+
+PopupLayer.prototype = Object.create(View.prototype);
+
+PopupLayer.prototype.resize = function () {
+ this.width = $(window).width();
+ this.height = $(window).height();
+};
+
+PopupLayer.prototype.render = function () {
+ var _this = this;
+
+ var el = this.bindView($(PopupLayerTemplate), this);
+ if (this.el) this.el.append(el);
+ else this.el = el;
+
+ this.resize();
+ $(window).on('resize', () => this.resize());
+
+ // Detect if you click outside of a popup, then it should be closed
+ var shouldClosePopup = false;
+ $('body')
+ .on('click', function (e) {
+ if (!$(e.target).parents().is(_this.$('.popup-layer-popup')) && shouldClosePopup && !_this.modals.length) {
+ _this.hidePopup();
+ _this.hideTooltip();
+ }
+ })
+ .on('mousedown', function (e) {
+ shouldClosePopup = !$(e.target).parents().is(_this.$('.popup-layer-popup')) && !_this.isLocked;
+ });
+
+ // Detect if you click outside of a popout and popup, then all popouts should be closed
+ var shouldClosePopout = false;
+
+ function onClick(e) {
+ if (
+ !(
+ $(e.target).parents().is(_this.$('.popup-layer-popup')) ||
+ $(e.target).parents().is(_this.$('.popup-layer-popouts'))
+ ) &&
+ shouldClosePopout &&
+ !_this.modals.length
+ ) {
+ _this.hidePopouts();
+ }
+ }
+
+ $('body')
+ .on('click', onClick)
+ .on('contextmenu', (e) => {
+ if (!this.ignoreContextMenuEvent) {
+ onClick(e);
+ }
+ })
+ .on('mousedown', (e) => {
+ shouldClosePopout =
+ !(
+ $(e.target).parents().is(this.$('.popup-layer-popup')) ||
+ $(e.target).parents().is(this.$('.popup-layer-popouts'))
+ ) && !this.isLocked;
+
+ //On Windows contextmenu is sent after mousedown. This can cause popups that are opened through mousedown to close immediately.
+ //So ignore the contextmenu event for 0.1 seconds to remedy this
+ if (platform.os === PlatformOS.Windows) {
+ this.ignoreContextMenuEvent = true;
+ setTimeout(() => {
+ this.ignoreContextMenuEvent = false;
+ }, 100);
+ }
+ });
+
+ // Check if should close modal
+ _this.shouldCloseModal = false;
+ _this.allowShouldCloseModal = false;
+
+ $('body')
+ .on('click', () => {
+ if (_this.shouldCloseModal) {
+ _this.hideModal();
+ _this.shouldCloseModal = false;
+ _this.allowShouldCloseModal = false;
+ }
+ })
+ .on('mousedown', (e) => {
+ if (_this.allowShouldCloseModal) {
+ _this.shouldCloseModal = !$(e.target).parents().is(_this.$('.popup-layer-modal'));
+ }
+ });
+
+ // Drop files on body to copy to project folder
+ var isValid = function (dataTransfer) {
+ return dataTransfer !== undefined && dataTransfer.types && dataTransfer.types.indexOf('Files') >= 0;
+ };
+
+ const { ProjectModel } = require('../models/projectmodel'); //include here to fix circular dependency
+
+ $('body')[0].addEventListener('dragover', function (evt) {
+ // Indicate drop is OK
+ if (ProjectModel.instance && isValid(evt.dataTransfer)) {
+ _this.showFileDrop();
+
+ evt.dataTransfer.dropEffect = 'copy';
+ }
+
+ evt.stopPropagation();
+ evt.preventDefault();
+ });
+
+ $('body')[0].addEventListener('dragleave', function (evt) {
+ _this.hideFileDrop();
+
+ evt.stopPropagation();
+ evt.preventDefault();
+ });
+
+ $('body')[0].addEventListener('drop', function (evt) {
+ if (ProjectModel.instance && isValid(evt.dataTransfer)) {
+ var files = evt.dataTransfer.files;
+
+ var _files = [];
+ function collectFiles(file, basedir) {
+ if (FileSystem.instance.isPathDirectory(file.fullPath)) {
+ var subfiles = FileSystem.instance.readDirectorySync(file.fullPath);
+ subfiles.forEach((f) => {
+ collectFiles({ fullPath: f.fullPath, name: f.fullPath.substring(basedir.length + 1) });
+ });
+ } else _files.push(file);
+ }
+
+ const toastActivityId = 'toast-drop-files-progress-id';
+ try {
+ for (var i = 0; i < files.length; i++) {
+ collectFiles(
+ { fullPath: files[i].path, name: files[i].name },
+ FileSystem.instance.getFileDirectoryName(files[i].path)
+ );
+ }
+
+ _files.forEach((f, index) => {
+ ProjectModel.instance.copyFileToProjectDirectory(f);
+
+ const progress = index / _files.length;
+ ToastLayer.showProgress('Copying files to project folder.', progress, toastActivityId);
+ });
+
+ if (_files.length === 1) {
+ ToastLayer.showSuccess('Successfully copied file to the project folder.');
+ } else {
+ ToastLayer.showSuccess(`Successfully copied ${_files.length} files to the project folder.`);
+ }
+ } catch (e) {
+ console.error(e);
+ ToastLayer.showError(
+ 'Failed to drop file. This is most likely caused by a temporary file, place the file in a normal folder and try again.'
+ );
+ } finally {
+ ToastLayer.hideActivity(toastActivityId);
+ }
+ }
+
+ _this.hideFileDrop();
+
+ evt.stopPropagation();
+ evt.preventDefault();
+ });
+
+ View.showTooltip = this.showTooltip.bind(this);
+ View.hideTooltip = this.hideTooltip.bind(this);
+
+ return this.el;
+};
+
+PopupLayer.prototype.getContentId = function () {
+ return this.contentId;
+};
+
+PopupLayer.prototype._dimBakckground = function () {
+ this._dimLayerCount++;
+ this.$('.popup-layer').addClass('dim');
+};
+
+PopupLayer.prototype._undimBackground = function () {
+ this._dimLayerCount--;
+
+ if (this._dimLayerCount <= 0) {
+ this.$('.popup-layer').removeClass('dim');
+ this._dimLayerCount = 0;
+ }
+};
+
+// Popups
+PopupLayer.prototype.hidePopup = function (args) {
+ if (this.popup && this.isShowingPopup) {
+ this._undimBackground();
+ this.popup && this.popup.content.el.detach();
+ this.$('.popup-layer-popup').css({ visibility: 'hidden' });
+ this.popup.onClose && this.popup.onClose();
+ this.popup.content.onClose && this.popup.content.onClose();
+ this.isShowingPopup = false;
+ this.contentId = '';
+ this._disablePopupAutoheight();
+ }
+ this.$('.popup-layer-blocker').css({ display: 'none' });
+};
+
+PopupLayer.prototype._enablePopupAutoheight = function () {
+ this.$('.popup-layer-popup').css({ height: 'auto' });
+ this.$('.popup-layer-popup-content').css({ position: 'relative' });
+ this.$('.popup-layer-popup-content > *').css({ display: 'inline-block', verticalAlign: 'bottom' });
+};
+
+PopupLayer.prototype._disablePopupAutoheight = function () {
+ this.$('.popup-layer-popup').css({ height: '' });
+ this.$('.popup-layer-popup-content').css({ position: '' });
+ this.$('.popup-layer-popup-content > *').css({ display: '', verticalAlign: '' });
+};
+
+PopupLayer.prototype.setContentSize = function (contentWidth, contentHeight) {
+ this.$('.popup-layer-popup').css({
+ width: contentWidth,
+ height: contentHeight,
+ transition: 'none'
+ });
+};
+
+// Popup
+PopupLayer.prototype.showPopup = function (args) {
+ var arrowSize = 10;
+
+ this.hidePopup();
+ this.$('.popup-layer-blocker').css({ display: '' });
+
+ var content = args.content.el;
+ args.content.owner = this;
+
+ this.$('.popup-layer-popup-content').append(content);
+
+ // Force a reflow to ensure the element is measurable
+ void this.$('.popup-layer-popup-content')[0].offsetHeight;
+
+ // Query the actual appended element to measure dimensions
+ var popupContent = this.$('.popup-layer-popup-content');
+ var contentWidth = popupContent.children().first().outerWidth(true);
+ var contentHeight = popupContent.children().first().outerHeight(true);
+
+ if (args.position === 'screen-center') {
+ if (args.isBackgroundDimmed) {
+ this._dimBakckground();
+
+ this.$('.popup-layer-popup').css({
+ transition: '',
+ transform: 'translateY(20px)',
+ opacity: 0
+ });
+
+ setTimeout(() => {
+ this.$('.popup-layer-popup').css({
+ transition: 'all 200ms ease',
+ transform: 'translateY(0)',
+ opacity: 1
+ });
+ }, 100);
+ }
+
+ var x = this.width / 2 - contentWidth / 2,
+ y = this.height / 2 - contentHeight / 2;
+
+ this.$('.popup-layer-popup').css({
+ position: 'absolute',
+ left: x,
+ top: y,
+ width: contentWidth,
+ height: contentHeight
+ });
+ this.$('.popup-layer-popup-arrow').css({ display: 'none' });
+ this.$('.popup-layer-popup').css({ visibility: 'visible' });
+ } else {
+ var attachToLeft = args.attachTo ? args.attachTo.offset().left : args.attachToPoint.x;
+ var attachToTop = args.attachTo ? args.attachTo.offset().top : args.attachToPoint.y;
+ var attachToWidth = args.attachTo ? args.attachTo.outerWidth(true) : 0;
+ var attachToHeight = args.attachTo ? args.attachTo.outerHeight(true) : 0;
+
+ // Figure out the position of the popup
+ var x, y;
+ this.$('.popup-layer-popup-arrow')
+ .removeClass('left')
+ .removeClass('right')
+ .removeClass('bottom')
+ .removeClass('top');
+ if (args.position === 'bottom') {
+ x = attachToLeft + attachToWidth / 2 - contentWidth / 2;
+ y = attachToHeight + attachToTop + arrowSize;
+ this.$('.popup-layer-popup-arrow').addClass('top');
+ } else if (args.position === 'top') {
+ x = attachToLeft + attachToWidth / 2 - contentWidth / 2;
+ y = attachToTop - contentHeight - arrowSize;
+ this.$('.popup-layer-popup-arrow').addClass('bottom');
+ } else if (args.position === 'left') {
+ x = attachToLeft - contentWidth - arrowSize;
+ y = attachToTop + attachToHeight / 2 - contentHeight / 2;
+ this.$('.popup-layer-popup-arrow').addClass('right');
+ } else if (args.position === 'right') {
+ x = attachToWidth + attachToLeft + arrowSize;
+ y = attachToTop + attachToHeight / 2 - contentHeight / 2;
+ this.$('.popup-layer-popup-arrow').addClass('left');
+ }
+
+ // Make sure the popup is not outside of the screen
+ var margin = 2;
+ if (x + contentWidth > this.width - margin) x = this.width - margin - contentWidth;
+ if (y + contentHeight > this.height - margin) y = this.height - margin - contentHeight;
+ if (x < margin) x = margin;
+ if (y < margin) y = margin;
+
+ // Cannot cover to bar as that is used for moving window
+ const topBarHeight = utils.windowTitleBarHeight();
+
+ if (y < topBarHeight) y = topBarHeight;
+
+ // Position the popup
+ this.$('.popup-layer-popup').css({
+ position: 'absolute',
+ left: x,
+ top: y,
+ transition: 'none'
+ });
+
+ this.setContentSize(contentWidth, contentHeight);
+
+ // Set the position of the arrow
+ this.$('.popup-layer-popup-arrow').css({
+ left:
+ args.position === 'top' || args.position === 'bottom'
+ ? Math.round(Math.abs(attachToLeft + attachToWidth / 2 - x)) + 'px'
+ : '',
+ top:
+ args.position === 'left' || args.position === 'right'
+ ? Math.round(Math.abs(attachToTop + attachToHeight / 2 - y)) + 'px'
+ : ''
+ });
+
+ this.$('.popup-layer-popup-arrow').css({ display: 'initial' });
+ this.$('.popup-layer-popup').css({ visibility: 'visible' });
+ }
+
+ if (args.hasDynamicHeight) {
+ this._enablePopupAutoheight();
+ }
+
+ this.popup = args;
+ this.popup.onOpen && this.popup.onOpen();
+ this.popup.content.onOpen && this.popup.content.onOpen();
+ this.isShowingPopup = true;
+ this.contentId = args.contentId;
+};
+
+PopupLayer.prototype._resizePopout = function (popout, args) {
+ const popoutEl = popout.el;
+ const content = popoutEl.find('.popup-layer-popout-content');
+
+ const contentWidth = content.outerWidth(true);
+ const contentHeight = content.outerHeight(true);
+
+ popoutEl.css({
+ width: contentWidth,
+ height: contentHeight,
+ transition: 'none'
+ });
+};
+
+PopupLayer.prototype._positionPopout = function (popout, args) {
+ const popoutEl = popout.el;
+ const position = popout.position;
+ const attachRect = popout.attachToRect;
+
+ const content = popoutEl.find('.popup-layer-popout-content');
+
+ const arrowSize = 10;
+
+ const contentWidth = content.outerWidth(true);
+ const contentHeight = content.outerHeight(true);
+
+ // Figure out the position of the popup
+ let x, y;
+
+ if (!args.disableCentering)
+ popoutEl
+ .find('.popup-layer-popout-arrow')
+ .removeClass('left')
+ .removeClass('right')
+ .removeClass('bottom')
+ .removeClass('top');
+ if (position === 'bottom') {
+ x = attachRect.left + attachRect.width / 2 - contentWidth / 2;
+ y = attachRect.height + attachRect.top + arrowSize;
+ popoutEl.find('.popup-layer-popout-arrow').addClass('top');
+ } else if (position === 'top') {
+ x = attachRect.left + attachRect.width / 2 - contentWidth / 2;
+ y = attachRect.top - contentHeight - arrowSize;
+ popoutEl.find('.popup-layer-popout-arrow').addClass('bottom');
+ } else if (position === 'left') {
+ x = attachRect.left - contentWidth - arrowSize;
+ y = attachRect.top + attachRect.height / 2 - contentHeight / 2;
+ popoutEl.find('.popup-layer-popout-arrow').addClass('right');
+ } else if (position === 'right') {
+ x = attachRect.width + attachRect.left + arrowSize;
+ y = attachRect.top + attachRect.height / 2 - contentHeight / 2;
+ popoutEl.find('.popup-layer-popout-arrow').addClass('left');
+ }
+
+ // Make sure the popup is not outside of the screen
+ const margin = 10;
+ if (args.offsetX) x += args.offsetX;
+ if (args.offsetY) y += args.offsetY;
+
+ if (x + contentWidth > this.width - margin) x = this.width - margin - contentWidth;
+ if (y + contentHeight > this.height - margin) y = this.height - margin - contentHeight;
+ if (x < margin) x = margin;
+ if (y < margin) y = margin;
+
+ // Cannot cover to bar as that is used for moving window
+ const topBarHeight = utils.windowTitleBarHeight();
+
+ if (y < topBarHeight) y = topBarHeight;
+
+ // Position the popup
+ popoutEl.css({
+ position: 'absolute',
+ left: x,
+ top: y,
+ transition: 'none'
+ });
+
+ // Set the position of the arrow
+ popoutEl.find('.popup-layer-popout-arrow').css({
+ left:
+ position === 'top' || position === 'bottom'
+ ? Math.round(Math.abs(attachRect.left + attachRect.width / 2 - x)) + 'px'
+ : '',
+ top:
+ position === 'left' || position === 'right'
+ ? Math.round(Math.abs(attachRect.top + attachRect.height / 2 - y)) + 'px'
+ : ''
+ });
+};
+
+// Popout
+PopupLayer.prototype.showPopout = function (args) {
+ this.$('.popup-layer-blocker').css({ display: '' });
+
+ var content = args.content.el;
+ args.content.owner = this;
+
+ var popoutEl = this.cloneTemplate('popout');
+ this.$('.popup-layer-popouts').append(popoutEl);
+
+ popoutEl.find('.popup-layer-popout-content').append(content);
+ popoutEl.find('.popup-layer-popout').css({ visibility: 'visible' });
+
+ const resizeObserver = new ResizeObserver((entries) => {
+ this._resizePopout(popout, args);
+ if (!args.disableDynamicPositioning) {
+ this._positionPopout(popout, args);
+ }
+ });
+
+ //note: the dom element in attachTo can become invalid while the popout is open (when the property panel re-renders)
+ //so we need to save the position now and hope the attach point doesn't move
+ const popout = {
+ el: popoutEl,
+ onClose: args.onClose,
+ position: args.position,
+ animate: args.animate,
+ manualClose: args.manualClose,
+ attachToRect: {
+ left: args.attachTo ? args.attachTo.offset().left : args.attachToPoint.x,
+ top: args.attachTo ? args.attachTo.offset().top : args.attachToPoint.y,
+ width: args.attachTo ? args.attachTo.outerWidth(true) : 0,
+ height: args.attachTo ? args.attachTo.outerHeight(true) : 0
+ },
+ resizeObserver
+ };
+ this.setPopoutArrowColor(popout, args.arrowColor || '313131');
+
+ this._resizePopout(popout, args);
+ this._positionPopout(popout, args);
+ resizeObserver.observe(content[0]);
+
+ this.popouts.push(popout);
+
+ if (args.animate) {
+ popoutEl.css({
+ transform: 'translateY(10px)',
+ opacity: 0
+ });
+
+ setTimeout(() => {
+ popoutEl.css({ transition: 'all 200ms ease-out', transform: 'translateY(0px)', opacity: 1 });
+ }, 50);
+ }
+
+ return popout;
+};
+
+PopupLayer.prototype.setPopoutArrowColor = function (popout, color) {
+ // Set the position of the arrow
+ const _arrowColorCssAttr = {
+ bottom: 'borderBottomColor',
+ top: 'borderTopColor',
+ left: 'borderLeftColor',
+ right: 'borderRightColor'
+ };
+ const arrowColorCss = {};
+ arrowColorCss[_arrowColorCssAttr[popout.position]] = color;
+ popout.el.find('.popup-layer-popout-arrow').css(arrowColorCss);
+};
+
+PopupLayer.prototype.hidePopouts = function (manual) {
+ const popouts = [...this.popouts]; //shallow copy since we'll modify the array in the loop
+ popouts.forEach((p) => {
+ if (!p.manualClose || manual === true) {
+ this.hidePopout(p);
+ }
+ });
+};
+
+PopupLayer.prototype.hidePopout = function (popout) {
+ if (!popout) return;
+
+ const i = this.popouts.indexOf(popout);
+ if (i !== -1) {
+ this.popouts.splice(i, 1);
+ }
+
+ popout.resizeObserver.disconnect();
+ popout.onClose && popout.onClose();
+
+ const close = () => {
+ popout.el.detach();
+
+ if (this.popouts.length === 0) {
+ this.$('.popup-layer-blocker').css({ display: 'none' });
+ }
+ };
+
+ if (popout.animate) {
+ popout.el.css({ transition: 'all 200ms ease-out', transform: 'translateY(10px)', opacity: 0 });
+ setTimeout(() => {
+ close();
+ }, 250);
+ } else {
+ close();
+ }
+};
+
+// Modals
+PopupLayer.prototype.showModal = function (args) {
+ const content = args.content.el;
+ args.content.owner = this;
+
+ this.$('.popup-layer-modal-content').html(content);
+
+ //If the previous popup is being hidden, cancel that timer
+ this._hideTimeoutId && clearTimeout(this._hideTimeoutId);
+
+ // Position the popup
+ this.$('.popup-layer-modal').css({
+ transform: 'translate(-50%, calc(-50% + -20px))',
+ transition: 'none',
+ opacity: 0
+ });
+ this.$('.popup-layer-modal').css({ visibility: 'visible' });
+
+ this._dimBakckground();
+
+ setTimeout(() => {
+ this.$('.popup-layer-modal').css({ transition: 'all 200ms ease', transform: 'translate(-50%, -50%)', opacity: 1 });
+ }, 100);
+
+ const modal = args;
+
+ modal.onOpen && modal.onOpen();
+ modal.content.onOpen && modal.content.onOpen();
+ this.modals.push(modal);
+
+ return modal;
+};
+
+PopupLayer.prototype.hideModal = function (modal) {
+ if (!modal) {
+ modal = this.modals.pop();
+ } else {
+ const index = this.modals.indexOf(modal);
+ if (index !== -1) {
+ this.modals.splice(index, 1);
+ }
+ }
+
+ if (modal) {
+ this.$('.popup-layer-modal').css({ transform: 'translate(-50%, calc(-50% + -20px))', opacity: 0 });
+ this._undimBackground();
+ this._hideTimeoutId = setTimeout(() => {
+ this.$('.popup-layer-modal').css({ visibility: 'hidden' });
+ this.$('.popup-layer-modal-content').empty();
+ }, 200);
+ modal.onClose && modal.onClose();
+ }
+};
+
+PopupLayer.prototype.hideAllModalsAndPopups = function () {
+ const modals = this.modals.slice();
+ modals.forEach((modal) => this.hideModal(modal));
+
+ this.hidePopup();
+ this.hidePopouts();
+ this.hideTooltip();
+};
+
+PopupLayer.prototype.showConfirmModal = function ({ message, title, confirmLabel, cancelLabel, onConfirm, onCancel }) {
+ var popup = new ConfirmModal({
+ message: message,
+ title: title,
+ confirmLabel: confirmLabel,
+ cancelLabel: cancelLabel,
+ onCancel: () => {
+ this.hideModal();
+ onCancel && onCancel();
+ },
+ onConfirm: () => {
+ this.hideModal();
+ onConfirm && onConfirm();
+ }
+ });
+
+ popup.render();
+
+ this.showModal({
+ content: popup,
+ onClose: function () {},
+ onOpen: function () {}
+ });
+};
+
+PopupLayer.prototype.showErrorModal = function ({ message, title, onOk }) {
+ //print error so it is logged to the debug log
+ console.log('Showing error modal: ');
+ console.log(` Title: ${title} Message: ${message}`);
+
+ var popup = new ErrorModal({
+ message: message,
+ title: title,
+ onOk: () => {
+ this.hideModal();
+ onOk && onOk();
+ }
+ });
+
+ popup.render();
+
+ this.showModal({
+ content: popup,
+ onClose: function () {},
+ onOpen: function () {}
+ });
+};
+
+// ------------------ Drag and drop ---------------------
+PopupLayer.prototype.startDragging = function (item) {
+ var _this = this;
+
+ this.$('.popup-layer-dragger-label').text(item.label);
+
+ this.dragItem = item;
+
+ function placeDragItem(x, y) {
+ _this
+ .$('.popup-layer-dragger')
+ .css({ opacity: '1', '-webkit-transition': 'none', transform: 'translate3d(' + x + 'px,' + y + 'px,0px)' });
+ }
+
+ $('body').on('mousemove', function (e) {
+ placeDragItem(e.pageX, e.pageY);
+ e.preventDefault();
+ });
+ $('body').on('mouseup', function (e) {
+ _this.dragCompleted();
+
+ $('body').off('mousemove').off('mouseup');
+ e.preventDefault();
+ });
+};
+
+PopupLayer.prototype.isDragging = function () {
+ return !!this.dragItem;
+};
+
+PopupLayer.prototype.indicateDropType = function (type) {
+ var dropTypeClasses = {
+ move: 'fa-share',
+ add: 'fa-plus'
+ };
+ for (var i in dropTypeClasses) {
+ this.$('.popup-layer-drop-type-indicator').removeClass(dropTypeClasses[i]);
+ }
+
+ if (type) this.$('.popup-layer-drop-type-indicator').addClass(dropTypeClasses[type]);
+};
+
+PopupLayer.prototype.setDragMessage = function (message) {
+ if (message && message !== '') {
+ this.$('.popup-layer-drag-message-text').text(message);
+ this.$('.popup-layer-drag-message').show();
+ } else {
+ this.$('.popup-layer-drag-message').hide();
+ }
+};
+
+PopupLayer.prototype.dragCompleted = function () {
+ this.$('.popup-layer-dragger').css({ opacity: '0' });
+ this.dragItem = undefined;
+};
+
+PopupLayer.prototype._setTooltipPosition = function (args) {
+ if (args.offset && args.offset.x) {
+ this.$('.popup-layer-tooltip-arrow').css({ transform: `translateX(-${args.offset.x}px)` });
+ } else {
+ this.$('.popup-layer-tooltip-arrow').css({ transform: '' });
+ }
+
+ this.$('.popup-layer-tooltip').css({ left: args.x, top: args.y, opacity: 1 });
+
+ // Set arrow position
+ this.$('.popup-layer-tooltip-arrow')
+ .removeClass('left')
+ .removeClass('right')
+ .removeClass('top')
+ .removeClass('bottom')
+ .addClass(args.position);
+};
+
+PopupLayer.prototype._getTooltipPosition = function (args) {
+ var contentWidth = this.$('.popup-layer-tooltip').outerWidth();
+ var contentHeight = this.$('.popup-layer-tooltip').outerHeight();
+
+ var attachToLeft = args.attachTo ? args.attachTo.offset().left : args.x;
+ var attachToTop = args.attachTo ? args.attachTo.offset().top : args.y;
+ var attachToWidth = args.attachTo ? args.attachTo[0].getBoundingClientRect().width : 0;
+ var attachToHeight = args.attachTo ? args.attachTo[0].getBoundingClientRect().height : 0;
+
+ if (args.offset && args.offset.x) {
+ attachToLeft += args.offset.x;
+ }
+
+ var x, y;
+ var arrowSize = 5;
+ if (args.position === undefined || args.position === 'bottom') {
+ x = attachToLeft + attachToWidth / 2 - contentWidth / 2;
+ y = attachToHeight + attachToTop + arrowSize;
+ } else if (args.position === 'top') {
+ x = attachToLeft + attachToWidth / 2 - contentWidth / 2;
+ y = attachToTop - contentHeight - arrowSize;
+ } else if (args.position === 'left') {
+ x = attachToLeft - contentWidth - arrowSize;
+ y = attachToTop + attachToHeight / 2 - contentHeight / 2;
+ } else if (args.position === 'right') {
+ x = attachToWidth + attachToLeft + arrowSize;
+ y = attachToTop + attachToHeight / 2 - contentHeight / 2;
+ }
+
+ return { x, y, contentWidth, contentHeight };
+};
+
+// -------------------------------- Tooltip ----------------------------------
+PopupLayer.prototype.showTooltip = function (args) {
+ if (this.isDragging()) return; // Don't show tooltip if a drag is in progress
+
+ // Set text
+ this.$('.popup-layer-tooltip-content').html(args.content);
+
+ args.position = args.position || 'bottom'; //default to bottom
+
+ //calculate tooltip position
+ let rect = this._getTooltipPosition(args);
+
+ //if the tooltip is attached to the bottom of an element, and gets placed outside
+ //the screen, change position to top
+ if (args.position === 'bottom' && rect.y + rect.contentHeight > window.innerHeight) {
+ args.position = 'top';
+ rect = this._getTooltipPosition(args);
+ }
+
+ //make sure the tooltip isn't rendered outside the screen, and that there's
+ //a small amount of margin to the edge
+ rect.x = Math.max(16, rect.x);
+
+ this._setTooltipPosition({
+ offset: args.offset,
+ position: args.position,
+ x: rect.x,
+ y: rect.y
+ });
+
+ return rect;
+};
+
+PopupLayer.prototype.hideTooltip = function () {
+ this.$('.popup-layer-tooltip').css({ opacity: 0 });
+};
+
+// ------------------ Toast ---------------------
+PopupLayer.prototype.showToast = function (text) {
+ var _this = this;
+
+ this.$('.popup-layer-toast').text(text);
+ var x = (this.width - this.$('.popup-layer-toast').width()) / 2;
+ var y = (this.height - this.$('.popup-layer-toast').height()) / 2;
+
+ this.$('.popup-layer-toast').css({ opacity: '1', transform: 'translate3d(' + x + 'px,' + y + 'px,0px)' });
+
+ clearTimeout(this.toastHideTimeout);
+ this.toastHideTimeout = setTimeout(function () {
+ _this.$('.popup-layer-toast').css({ opacity: '0' });
+ }, 2000);
+
+ console.error(
+ 'showToast is deprecated. Use ToastLayer.showSuccess(), ToastLayer.showError() or ToastLayer.showInteraction() instead.'
+ );
+};
+
+// ------------------ Toast ---------------------
+PopupLayer.prototype.showActivity = function (text) {
+ var _this = this;
+
+ this.$('.popup-layer-activity-text').html(text);
+ var x = (this.width - this.$('.popup-layer-activity').width()) / 2;
+ var y = (this.height - this.$('.popup-layer-activity').height()) / 2;
+
+ this.$('.popup-layer-activity').css({ opacity: '1', transform: 'translate3d(' + x + 'px,' + y + 'px,0px)' });
+
+ this.$('.popup-layer-activity-progress').hide();
+ this.$('.popup-layer-activity-progress-bar').css({ width: '0%' });
+
+ console.error('showActivity is deprecated. Use ToastLayer.showActivity() instead.');
+};
+
+PopupLayer.prototype.hideActivity = function () {
+ this.$('.popup-layer-activity').css({ opacity: '0', pointerEvents: 'none' });
+
+ console.error('hideActivity is deprecated. Use ToastLayer.hideActivity() instead.');
+};
+
+/**
+ *
+ * @param {number} progress 0 to 100
+ */
+PopupLayer.prototype.showActivityProgress = function (progress) {
+ this.$('.popup-layer-activity-progress').show();
+ this.$('.popup-layer-activity-progress-bar').css({ width: progress + '%' });
+
+ console.error('showActivityProgress is deprecated. Use ToastLayer.showProgress() instead.');
+};
+
+// ------------------ Indicate drop on ---------------------
+PopupLayer.prototype.showFileDrop = function () {
+ this.$('.popup-file-drop').show();
+};
+
+PopupLayer.prototype.hideFileDrop = function () {
+ this.$('.popup-file-drop').hide();
+};
+
+// ---------------------------------------------------------------------
+// PopupLayer.StringInputPopup
+// ---------------------------------------------------------------------
+PopupLayer.StringInputPopup = function (args) {
+ for (var i in args) this[i] = args[i];
+};
+PopupLayer.StringInputPopup.prototype = Object.create(View.prototype);
+
+PopupLayer.StringInputPopup.prototype.render = function () {
+ this.el = this.bindView($(StringInputPopupTemplate), this);
+
+ // Only close on Enter for single-line inputs, not textareas
+ const input = this.$('.string-input-popup-input');
+ const isTextarea = input.is('textarea');
+
+ if (!isTextarea) {
+ input.off('keypress').on('keypress', (e) => {
+ if (e.which == 13) {
+ this.onOkClicked();
+ }
+ });
+ }
+
+ return this.el;
+};
+
+PopupLayer.StringInputPopup.prototype.onOkClicked = function () {
+ var val = this.$('.string-input-popup-input')
+ .val()
+ .split(',')
+ .map((x) => x.trim())
+ .join();
+
+ this.owner.hidePopup();
+
+ this.onOk && this.onOk(val);
+};
+
+PopupLayer.StringInputPopup.prototype.onCancelClicked = function () {
+ this.onCancel && this.onCancel();
+ this.owner.hidePopup();
+};
+
+PopupLayer.StringInputPopup.prototype.updateLineNumbers = function () {
+ const textarea = this.$('.string-input-popup-input')[0];
+ const lineNumbersEl = this.$('.string-input-popup-line-numbers')[0];
+
+ if (!textarea || !lineNumbersEl) return;
+
+ // Count lines based on textarea value
+ const text = textarea.value;
+ const lines = text ? text.split('\n').length : 1;
+
+ // Always show at least 8 lines (matching rows="8")
+ const displayLines = Math.max(8, lines);
+
+ // Generate line numbers
+ let lineNumbersHTML = '';
+ for (let i = 1; i <= displayLines; i++) {
+ lineNumbersHTML += i + '\n';
+ }
+
+ lineNumbersEl.textContent = lineNumbersHTML;
+
+ // Sync scroll
+ lineNumbersEl.scrollTop = textarea.scrollTop;
+};
+
+PopupLayer.StringInputPopup.prototype.onOpen = function () {
+ const textarea = this.$('.string-input-popup-input');
+
+ // Initial line numbers
+ this.updateLineNumbers();
+
+ // Update line numbers on input
+ textarea.on('input', () => this.updateLineNumbers());
+
+ // Sync scroll between textarea and line numbers
+ textarea.on('scroll', () => {
+ const lineNumbersEl = this.$('.string-input-popup-line-numbers')[0];
+ if (lineNumbersEl) {
+ lineNumbersEl.scrollTop = textarea[0].scrollTop;
+ }
+ });
+
+ textarea.focus();
+};
+
+// ---------------------------------------------------------------------
+// PopupLayer.YesNoPopup
+// ---------------------------------------------------------------------
+PopupLayer.YesNoPopup = function (args) {
+ for (var i in args) this[i] = args[i];
+
+ if (!this.yesLabel) this.yesLabel = 'Yes';
+ if (!this.noLabel) this.noLabel = 'No';
+};
+PopupLayer.YesNoPopup.prototype = Object.create(View.prototype);
+
+PopupLayer.YesNoPopup.prototype.render = function () {
+ var _this = this;
+
+ this.el = this.bindView($(YesNoPopupTemplate), this);
+
+ return this.el;
+};
+
+PopupLayer.YesNoPopup.prototype.onYesClicked = function () {
+ this.owner.hidePopup();
+
+ this.onYes && this.onYes();
+};
+
+PopupLayer.YesNoPopup.prototype.onNoClicked = function () {
+ this.owner.hidePopup();
+
+ this.onNo && this.onNo();
+};
+
+module.exports = PopupLayer;
diff --git a/packages/noodl-editor/tests/services/github/GitHubClient.test.ts b/packages/noodl-editor/tests/services/github/GitHubClient.test.ts
new file mode 100644
index 0000000..4265b10
--- /dev/null
+++ b/packages/noodl-editor/tests/services/github/GitHubClient.test.ts
@@ -0,0 +1,500 @@
+/**
+ * Unit tests for GitHubClient
+ *
+ * Tests caching, rate limiting, error handling, and auth integration
+ */
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { describe, it, expect, beforeEach, jest } from '@jest/globals';
+
+import { GitHubClient } from '../../../src/editor/src/services/github/GitHubClient';
+import { GitHubOAuthService } from '../../../src/editor/src/services/GitHubOAuthService';
+
+// Mock Octokit
+jest.mock('@octokit/rest', () => ({
+ Octokit: jest.fn().mockImplementation(() => ({
+ repos: {
+ get: jest.fn(),
+ listForAuthenticatedUser: jest.fn()
+ },
+ issues: {
+ listForRepo: jest.fn(),
+ get: jest.fn(),
+ create: jest.fn(),
+ update: jest.fn(),
+ listComments: jest.fn(),
+ createComment: jest.fn(),
+ listLabelsForRepo: jest.fn()
+ },
+ pulls: {
+ list: jest.fn(),
+ get: jest.fn(),
+ listCommits: jest.fn()
+ },
+ rateLimit: {
+ get: jest.fn()
+ }
+ }))
+}));
+
+// Mock GitHubOAuthService
+jest.mock('../../../src/editor/src/services/GitHubOAuthService', () => ({
+ GitHubOAuthService: {
+ instance: {
+ isAuthenticated: jest.fn(() => false),
+ getToken: jest.fn(() => Promise.resolve('mock-token')),
+ on: jest.fn(),
+ off: jest.fn()
+ }
+ }
+}));
+
+describe('GitHubClient', () => {
+ let client: GitHubClient;
+ let mockOctokit: any;
+
+ beforeEach(() => {
+ // Reset singleton
+ (GitHubClient as any)._instance = undefined;
+
+ // Clear all mocks
+ jest.clearAllMocks();
+
+ // Get client instance
+ client = GitHubClient.instance;
+
+ // Get mock Octokit instance
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { Octokit } = require('@octokit/rest');
+ mockOctokit = new Octokit();
+ });
+
+ describe('initialization', () => {
+ it('should create singleton instance', () => {
+ const instance1 = GitHubClient.instance;
+ const instance2 = GitHubClient.instance;
+ expect(instance1).toBe(instance2);
+ });
+
+ it('should listen for auth state changes', () => {
+ expect(GitHubOAuthService.instance.on).toHaveBeenCalledWith(
+ 'auth-state-changed',
+ expect.any(Function),
+ expect.anything()
+ );
+ });
+
+ it('should listen for disconnection', () => {
+ expect(GitHubOAuthService.instance.on).toHaveBeenCalledWith(
+ 'disconnected',
+ expect.any(Function),
+ expect.anything()
+ );
+ });
+ });
+
+ describe('caching', () => {
+ beforeEach(async () => {
+ // Setup authenticated state
+ (GitHubOAuthService.instance.isAuthenticated as jest.Mock).mockReturnValue(true);
+
+ // Mock rate limit response
+ mockOctokit.rateLimit.get.mockResolvedValue({
+ data: {
+ rate: {
+ limit: 5000,
+ remaining: 4999,
+ reset: Math.floor(Date.now() / 1000) + 3600,
+ used: 1
+ }
+ }
+ });
+
+ // Mock repo response
+ mockOctokit.repos.get.mockResolvedValue({
+ data: { id: 1, name: 'test-repo' },
+ headers: {
+ 'x-ratelimit-limit': '5000',
+ 'x-ratelimit-remaining': '4999',
+ 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 3600),
+ 'x-ratelimit-used': '1'
+ }
+ });
+
+ // Initialize client
+ await (client as any).initializeOctokit();
+ });
+
+ it('should cache API responses', async () => {
+ // First call
+ await client.getRepository('owner', 'repo');
+
+ // Second call (should use cache)
+ await client.getRepository('owner', 'repo');
+
+ // API should only be called once
+ expect(mockOctokit.repos.get).toHaveBeenCalledTimes(1);
+ });
+
+ it('should respect cache TTL', async () => {
+ // First call
+ await client.getRepository('owner', 'repo');
+
+ // Wait for cache to expire (mock time)
+ jest.useFakeTimers();
+ jest.advanceTimersByTime(61000); // 61 seconds > 60 second TTL
+
+ // Second call (cache expired)
+ await client.getRepository('owner', 'repo');
+
+ // API should be called twice
+ expect(mockOctokit.repos.get).toHaveBeenCalledTimes(2);
+
+ jest.useRealTimers();
+ });
+
+ it('should invalidate cache on mutations', async () => {
+ // Mock issue responses
+ mockOctokit.issues.listForRepo.mockResolvedValue({
+ data: [{ id: 1, number: 1 }],
+ headers: {
+ 'x-ratelimit-limit': '5000',
+ 'x-ratelimit-remaining': '4998',
+ 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 3600),
+ 'x-ratelimit-used': '2'
+ }
+ });
+
+ mockOctokit.issues.create.mockResolvedValue({
+ data: { id: 2, number: 2 },
+ headers: {
+ 'x-ratelimit-limit': '5000',
+ 'x-ratelimit-remaining': '4997',
+ 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 3600),
+ 'x-ratelimit-used': '3'
+ }
+ });
+
+ // List issues (cached)
+ await client.listIssues('owner', 'repo');
+
+ // Create issue (invalidates cache)
+ await client.createIssue('owner', 'repo', { title: 'Test' });
+
+ // List again (cache invalidated, should call API)
+ await client.listIssues('owner', 'repo');
+
+ // Should be called twice (once before create, once after)
+ expect(mockOctokit.issues.listForRepo).toHaveBeenCalledTimes(2);
+ });
+
+ it('should clear all cache on disconnect', () => {
+ // Add some cache entries
+ (client as any).setCache('test-key', { data: 'test' });
+ expect((client as any).cache.size).toBeGreaterThan(0);
+
+ // Disconnect
+ client.clearCache();
+
+ // Cache should be empty
+ expect((client as any).cache.size).toBe(0);
+ });
+ });
+
+ describe('rate limiting', () => {
+ beforeEach(async () => {
+ (GitHubOAuthService.instance.isAuthenticated as jest.Mock).mockReturnValue(true);
+
+ mockOctokit.rateLimit.get.mockResolvedValue({
+ data: {
+ rate: {
+ limit: 5000,
+ remaining: 4999,
+ reset: Math.floor(Date.now() / 1000) + 3600,
+ used: 1
+ }
+ }
+ });
+
+ await (client as any).initializeOctokit();
+ });
+
+ it('should track rate limit from response headers', async () => {
+ mockOctokit.repos.get.mockResolvedValue({
+ data: { id: 1 },
+ headers: {
+ 'x-ratelimit-limit': '5000',
+ 'x-ratelimit-remaining': '4500',
+ 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 3600),
+ 'x-ratelimit-used': '500'
+ }
+ });
+
+ await client.getRepository('owner', 'repo');
+
+ const rateLimit = client.getRateLimit();
+ expect(rateLimit).toEqual({
+ limit: 5000,
+ remaining: 4500,
+ reset: expect.any(Number),
+ used: 500
+ });
+ });
+
+ it('should emit warning when approaching rate limit', async () => {
+ const warningListener = jest.fn();
+ client.on('rate-limit-warning', warningListener, client);
+
+ // Mock low remaining rate limit (9% = below 10% threshold)
+ mockOctokit.repos.get.mockResolvedValue({
+ data: { id: 1 },
+ headers: {
+ 'x-ratelimit-limit': '5000',
+ 'x-ratelimit-remaining': '450', // 9%
+ 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 3600),
+ 'x-ratelimit-used': '4550'
+ }
+ });
+
+ await client.getRepository('owner', 'repo');
+
+ expect(warningListener).toHaveBeenCalledWith({
+ rateLimit: expect.objectContaining({
+ remaining: 450,
+ limit: 5000
+ })
+ });
+ });
+
+ it('should calculate time until rate limit reset', async () => {
+ const resetTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
+
+ mockOctokit.repos.get.mockResolvedValue({
+ data: { id: 1 },
+ headers: {
+ 'x-ratelimit-limit': '5000',
+ 'x-ratelimit-remaining': '4999',
+ 'x-ratelimit-reset': String(resetTime),
+ 'x-ratelimit-used': '1'
+ }
+ });
+
+ await client.getRepository('owner', 'repo');
+
+ const timeUntilReset = client.getTimeUntilRateLimitReset();
+
+ // Should be approximately 1 hour (within 1 second tolerance)
+ expect(timeUntilReset).toBeGreaterThan(3599000);
+ expect(timeUntilReset).toBeLessThan(3601000);
+ });
+ });
+
+ describe('error handling', () => {
+ beforeEach(async () => {
+ (GitHubOAuthService.instance.isAuthenticated as jest.Mock).mockReturnValue(true);
+
+ mockOctokit.rateLimit.get.mockResolvedValue({
+ data: {
+ rate: {
+ limit: 5000,
+ remaining: 4999,
+ reset: Math.floor(Date.now() / 1000) + 3600,
+ used: 1
+ }
+ }
+ });
+
+ await (client as any).initializeOctokit();
+ });
+
+ it('should handle 404 errors with friendly message', async () => {
+ mockOctokit.repos.get.mockRejectedValue({
+ status: 404,
+ response: { data: { message: 'Not Found' } }
+ });
+
+ await expect(client.getRepository('owner', 'repo')).rejects.toThrow('Repository or resource not found.');
+ });
+
+ it('should handle 401 errors with friendly message', async () => {
+ mockOctokit.repos.get.mockRejectedValue({
+ status: 401,
+ response: { data: { message: 'Unauthorized' } }
+ });
+
+ await expect(client.getRepository('owner', 'repo')).rejects.toThrow(
+ 'Authentication failed. Please reconnect your GitHub account.'
+ );
+ });
+
+ it('should handle 403 rate limit errors', async () => {
+ const resetTime = Math.floor(Date.now() / 1000) + 1800;
+
+ // Set rate limit in client
+ (client as any).rateLimit = {
+ limit: 5000,
+ remaining: 0,
+ reset: resetTime,
+ used: 5000
+ };
+
+ mockOctokit.repos.get.mockRejectedValue({
+ status: 403,
+ response: {
+ data: {
+ message: 'API rate limit exceeded'
+ }
+ }
+ });
+
+ await expect(client.getRepository('owner', 'repo')).rejects.toThrow(/Rate limit exceeded/);
+ });
+
+ it('should handle 422 validation errors', async () => {
+ mockOctokit.issues.create.mockRejectedValue({
+ status: 422,
+ response: {
+ data: {
+ message: 'Validation Failed',
+ errors: [{ field: 'title', code: 'missing' }]
+ }
+ }
+ });
+
+ await expect(client.createIssue('owner', 'repo', { title: '' })).rejects.toThrow(/Invalid request/);
+ });
+ });
+
+ describe('API methods', () => {
+ beforeEach(async () => {
+ (GitHubOAuthService.instance.isAuthenticated as jest.Mock).mockReturnValue(true);
+
+ mockOctokit.rateLimit.get.mockResolvedValue({
+ data: {
+ rate: {
+ limit: 5000,
+ remaining: 4999,
+ reset: Math.floor(Date.now() / 1000) + 3600,
+ used: 1
+ }
+ }
+ });
+
+ await (client as any).initializeOctokit();
+ });
+
+ it('should list issues with filters', async () => {
+ mockOctokit.issues.listForRepo.mockResolvedValue({
+ data: [{ id: 1, number: 1, title: 'Test' }],
+ headers: {
+ 'x-ratelimit-limit': '5000',
+ 'x-ratelimit-remaining': '4998',
+ 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 3600)
+ }
+ });
+
+ const result = await client.listIssues('owner', 'repo', {
+ state: 'open',
+ labels: ['bug', 'enhancement'],
+ sort: 'updated'
+ });
+
+ expect(result.data).toHaveLength(1);
+ expect(result.data[0].title).toBe('Test');
+
+ // Verify filters were converted correctly
+ expect(mockOctokit.issues.listForRepo).toHaveBeenCalledWith({
+ owner: 'owner',
+ repo: 'repo',
+ state: 'open',
+ labels: 'bug,enhancement',
+ sort: 'updated',
+ milestone: undefined
+ });
+ });
+
+ it('should create issue with options', async () => {
+ mockOctokit.issues.create.mockResolvedValue({
+ data: { id: 1, number: 1, title: 'New Issue' },
+ headers: {
+ 'x-ratelimit-limit': '5000',
+ 'x-ratelimit-remaining': '4998',
+ 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 3600)
+ }
+ });
+
+ const result = await client.createIssue('owner', 'repo', {
+ title: 'New Issue',
+ body: 'Description',
+ labels: ['bug'],
+ assignees: ['user1']
+ });
+
+ expect(result.data.title).toBe('New Issue');
+ expect(mockOctokit.issues.create).toHaveBeenCalledWith({
+ owner: 'owner',
+ repo: 'repo',
+ title: 'New Issue',
+ body: 'Description',
+ labels: ['bug'],
+ assignees: ['user1']
+ });
+ });
+
+ it('should list pull requests with converted filters', async () => {
+ mockOctokit.pulls.list.mockResolvedValue({
+ data: [{ id: 1, number: 1, title: 'PR' }],
+ headers: {
+ 'x-ratelimit-limit': '5000',
+ 'x-ratelimit-remaining': '4998',
+ 'x-ratelimit-reset': String(Math.floor(Date.now() / 1000) + 3600)
+ }
+ });
+
+ await client.listPullRequests('owner', 'repo', {
+ state: 'open',
+ sort: 'comments' // Should be converted to 'created' for PRs
+ });
+
+ expect(mockOctokit.pulls.list).toHaveBeenCalledWith({
+ owner: 'owner',
+ repo: 'repo',
+ state: 'open',
+ sort: 'created', // Converted from 'comments'
+ direction: undefined,
+ per_page: undefined,
+ page: undefined
+ });
+ });
+ });
+
+ describe('utility methods', () => {
+ it('should report ready status', async () => {
+ expect(client.isReady()).toBe(false);
+
+ (GitHubOAuthService.instance.isAuthenticated as jest.Mock).mockReturnValue(true);
+
+ mockOctokit.rateLimit.get.mockResolvedValue({
+ data: {
+ rate: { limit: 5000, remaining: 4999, reset: Date.now() / 1000 + 3600, used: 1 }
+ }
+ });
+
+ await (client as any).initializeOctokit();
+
+ expect(client.isReady()).toBe(true);
+ });
+
+ it('should clear cache on demand', () => {
+ (client as any).setCache('test-1', { data: 'value1' });
+ (client as any).setCache('test-2', { data: 'value2' });
+
+ expect((client as any).cache.size).toBe(2);
+
+ client.clearCache();
+
+ expect((client as any).cache.size).toBe(0);
+ });
+ });
+});
diff --git a/packages/noodl-runtime/src/nodes/std-library/logic-builder.js b/packages/noodl-runtime/src/nodes/std-library/logic-builder.js
index 16c3542..e4386f5 100644
--- a/packages/noodl-runtime/src/nodes/std-library/logic-builder.js
+++ b/packages/noodl-runtime/src/nodes/std-library/logic-builder.js
@@ -223,6 +223,7 @@ const LogicBuilderNode = {
editorType: 'logic-builder-workspace'
},
displayName: 'Logic Blocks',
+ group: '', // Empty group to avoid "Other" label
set: function (value) {
const internal = this._internal;
internal.workspace = value;
@@ -230,10 +231,14 @@ const LogicBuilderNode = {
}
},
generatedCode: {
- type: 'string',
- displayName: 'Generated Code',
+ type: {
+ name: 'string',
+ allowEditOnly: true,
+ codeeditor: 'javascript',
+ readOnly: true // ✅ Inside type object - this gets passed through to property panel!
+ },
+ displayName: 'Generated code',
group: 'Advanced',
- editorName: 'Hidden', // Hide from property panel
set: function (value) {
const internal = this._internal;
internal.generatedCode = value;