diff --git a/package.json b/package.json index 14ab6b8..3056987 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "duocode", - "version": "2.3.0", + "version": "2.4.0", "description": "WebRTC-based collaboration tool with PDF export and syntax highlighting", "main": "index.html", "scripts": { diff --git a/src/App.tsx b/src/App.tsx index fa7709f..ee2ce25 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { useWebRTC } from './hooks/useWebRTC'; import { useCodeSync } from './hooks/useCodeSync'; import { useCanvasSync } from './hooks/useCanvasSync'; import { useMessageSync } from './hooks/useMessageSync'; +import { useExecutionSync } from './hooks/useExecutionSync'; import { usePersistence } from './hooks/usePersistence'; import { useSessionInit } from './hooks/useSessionInit'; import { installDuoCodeDebug } from './services/debug-utility'; @@ -46,6 +47,7 @@ function App() { const { handleMessage: handleCodeMessage } = useCodeSync({ sendMessage: stableSend }); const { handleMessage: handleCanvasMessage } = useCanvasSync({ sendMessage: stableSend }); const { handleMessage: handleChatMessage } = useMessageSync({ sendMessage: stableSend }); + const { handleMessage: handleExecutionMessage } = useExecutionSync({ sendMessage: stableSend }); // Route incoming data-channel messages to the appropriate sync hook const onMessage = useCallback( @@ -72,6 +74,11 @@ function App() { handleChatMessage(message); break; + case 'execution-start': + case 'execution-result': + handleExecutionMessage(message); + break; + case 'state-request': handleCodeMessage(message); handleCanvasMessage(message); @@ -85,7 +92,7 @@ function App() { break; } }, - [handleCodeMessage, handleCanvasMessage, handleChatMessage] + [handleCodeMessage, handleCanvasMessage, handleChatMessage, handleExecutionMessage] ); // WebRTC connection lifecycle — populates sendRef when the data channel opens diff --git a/src/__tests__/LanguageSelector.test.tsx b/src/__tests__/LanguageSelector.test.tsx index fd2194f..6a8ee72 100644 --- a/src/__tests__/LanguageSelector.test.tsx +++ b/src/__tests__/LanguageSelector.test.tsx @@ -23,8 +23,9 @@ describe('LanguageSelector', () => { it('displays human-readable labels', () => { const { getByText } = render(); - expect(getByText('JavaScript')).toBeInTheDocument(); - expect(getByText('TypeScript')).toBeInTheDocument(); + // Executable languages get a ▶ prefix + expect(getByText(/JavaScript/)).toBeInTheDocument(); + expect(getByText(/TypeScript/)).toBeInTheDocument(); expect(getByText('Python')).toBeInTheDocument(); expect(getByText('C++')).toBeInTheDocument(); expect(getByText('C#')).toBeInTheDocument(); diff --git a/src/components/CodeEditor/CodeEditor.tsx b/src/components/CodeEditor/CodeEditor.tsx index 91c1852..5c37e2c 100644 --- a/src/components/CodeEditor/CodeEditor.tsx +++ b/src/components/CodeEditor/CodeEditor.tsx @@ -17,7 +17,9 @@ import 'prismjs/components/prism-markup-templating'; import 'prismjs/components/prism-php'; import 'prismjs/components/prism-sql'; import { useEditorStore } from '../../stores/editorStore'; +import { useExecutionStore } from '../../stores/executionStore'; import { getPrismLanguage, dedentLines, getLeadingWhitespace } from '../../services/code-editor-logic'; +import { isExecutable } from '../../services/code-executor'; import { calculateTextOperation } from '../../services/ot-engine'; import RemoteCursors from './RemoteCursors'; @@ -69,6 +71,17 @@ export default function CodeEditor() { const handleKeyDown = useCallback( (e: KeyboardEvent) => { + // Ctrl/Cmd + Enter: run code + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + const lang = useEditorStore.getState().language; + if (isExecutable(lang)) { + const run = useExecutionStore.getState().runCode; + run?.(); + } + return; + } + const textarea = inputRef.current; if (!textarea) return; diff --git a/src/components/CodeEditor/LanguageSelector.tsx b/src/components/CodeEditor/LanguageSelector.tsx index f5b39fe..5645788 100644 --- a/src/components/CodeEditor/LanguageSelector.tsx +++ b/src/components/CodeEditor/LanguageSelector.tsx @@ -1,5 +1,6 @@ import { useEditorStore } from '../../stores/editorStore'; import { codeTemplates } from '../../services/code-editor-logic'; +import { isExecutable } from '../../services/code-executor'; const LANGUAGES = Object.keys(codeTemplates); @@ -34,7 +35,7 @@ export default function LanguageSelector() { > {LANGUAGES.map((lang) => ( ))} diff --git a/src/components/CodeEditor/OutputPanel.tsx b/src/components/CodeEditor/OutputPanel.tsx new file mode 100644 index 0000000..2f896d3 --- /dev/null +++ b/src/components/CodeEditor/OutputPanel.tsx @@ -0,0 +1,80 @@ +import { useRef, useEffect } from 'react'; +import { useExecutionStore } from '../../stores/executionStore'; +import { useEditorStore } from '../../stores/editorStore'; +import { isExecutable } from '../../services/code-executor'; + +export default function OutputPanel() { + const isRunning = useExecutionStore((s) => s.isRunning); + const output = useExecutionStore((s) => s.output); + const panelExpanded = useExecutionStore((s) => s.panelExpanded); + const togglePanel = useExecutionStore((s) => s.togglePanel); + const language = useEditorStore((s) => s.language); + const outputRef = useRef(null); + + // Auto-scroll to bottom when output changes + useEffect(() => { + if (outputRef.current && panelExpanded) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [output, panelExpanded]); + + // Hide when language doesn't support execution, or no output and not running + if (!isExecutable(language) || (!output && !isRunning)) return null; + + const hasError = output && output.exitCode !== 0; + const hasOutput = output && (output.stdout || output.stderr); + + return ( +
+
+
+ {isRunning ? ( + + ) : output ? ( + + {hasError ? '\u2717' : '\u2713'} + + ) : null} + Output + {output && ( + {output.duration}ms + )} + {isRunning && ( + Running... + )} +
+ +
+ {panelExpanded && ( +
+          {isRunning && !hasOutput && (
+            Executing...
+          )}
+          {output?.stdout && (
+            {output.stdout}
+          )}
+          {output?.stdout && output?.stderr && '\n'}
+          {output?.stderr && (
+            {output.stderr}
+          )}
+          {output && !output.stdout && !output.stderr && (
+            (No output)
+          )}
+        
+ )} +
+ ); +} diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 8f22422..047e664 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -1,9 +1,18 @@ import { useUIStore } from '../stores/uiStore'; +import { useEditorStore } from '../stores/editorStore'; +import { useExecutionStore } from '../stores/executionStore'; +import { isExecutable } from '../services/code-executor'; import LanguageSelector from './CodeEditor/LanguageSelector'; export default function TabBar() { const activeTab = useUIStore((s) => s.activeTab); const switchTab = useUIStore((s) => s.switchTab); + const language = useEditorStore((s) => s.language); + const isRunning = useExecutionStore((s) => s.isRunning); + const runCode = useExecutionStore((s) => s.runCode); + const cancelCode = useExecutionStore((s) => s.cancelCode); + + const showRunButton = activeTab === 'code' && isExecutable(language); return (
@@ -22,6 +31,31 @@ export default function TabBar() {
{activeTab === 'code' && } + {showRunButton && ( + isRunning ? ( + + ) : ( + + ) + )} ); } diff --git a/src/components/TabContent.tsx b/src/components/TabContent.tsx index f0c26d6..8509481 100644 --- a/src/components/TabContent.tsx +++ b/src/components/TabContent.tsx @@ -1,5 +1,6 @@ import { useUIStore } from '../stores/uiStore'; import CodeEditor from './CodeEditor/CodeEditor'; +import OutputPanel from './CodeEditor/OutputPanel'; import DiagramCanvas from './DiagramCanvas/DiagramCanvas'; export default function TabContent() { @@ -9,6 +10,7 @@ export default function TabContent() {
+
diff --git a/src/hooks/useExecutionSync.ts b/src/hooks/useExecutionSync.ts new file mode 100644 index 0000000..cd1d376 --- /dev/null +++ b/src/hooks/useExecutionSync.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect } from 'react'; +import { useExecutionStore } from '../stores/executionStore'; +import { useEditorStore } from '../stores/editorStore'; +import { runCode, stopExecution as stopWorker, isExecutable } from '../services/code-executor'; +import type { DataChannelMessage } from '../services/connection-manager'; + +interface UseExecutionSyncOptions { + sendMessage?: (data: DataChannelMessage) => boolean; +} + +interface UseExecutionSyncReturn { + handleMessage: (message: DataChannelMessage) => void; +} + +/** + * useExecutionSync — runs code locally and syncs results over the data channel. + * + * - executeCode() reads the current editor code/language, runs it in a Worker, + * broadcasts execution-start and execution-result to peers. + * - handleMessage() receives execution events from peers and updates the store. + */ +export function useExecutionSync({ + sendMessage, +}: UseExecutionSyncOptions = {}): UseExecutionSyncReturn { + const startExecution = useExecutionStore((s) => s.startExecution); + const setResult = useExecutionStore((s) => s.setResult); + const stopExecution = useExecutionStore((s) => s.stopExecution); + const setCallbacks = useExecutionStore((s) => s.setCallbacks); + + const executeCode = useCallback(async () => { + const { code, language } = useEditorStore.getState(); + if (!isExecutable(language)) return; + + startExecution(); + + if (sendMessage) { + sendMessage({ + type: 'execution-start', + language, + timestamp: Date.now(), + }); + } + + try { + const result = await runCode(code, language); + setResult(result); + + if (sendMessage) { + sendMessage({ + type: 'execution-result', + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + duration: result.duration, + }); + } + } catch { + const errorResult = { + stdout: '', + stderr: 'Execution failed', + exitCode: 1, + duration: 0, + }; + setResult(errorResult); + + if (sendMessage) { + sendMessage({ + type: 'execution-result', + ...errorResult, + }); + } + } + }, [sendMessage, startExecution, setResult]); + + const cancelExecution = useCallback(() => { + stopWorker(); + stopExecution(); + }, [stopExecution]); + + // Register callbacks on the store so components can trigger execution + useEffect(() => { + setCallbacks(executeCode, cancelExecution); + }, [executeCode, cancelExecution, setCallbacks]); + + const handleMessage = useCallback( + (message: DataChannelMessage) => { + switch (message.type) { + case 'execution-start': + startExecution(); + break; + + case 'execution-result': + setResult({ + stdout: message.stdout, + stderr: message.stderr, + exitCode: message.exitCode, + duration: message.duration, + }); + break; + + default: + break; + } + }, + [startExecution, setResult], + ); + + return { handleMessage }; +} diff --git a/src/services/code-executor.ts b/src/services/code-executor.ts new file mode 100644 index 0000000..6ab7267 --- /dev/null +++ b/src/services/code-executor.ts @@ -0,0 +1,67 @@ +/** + * code-executor.ts — Framework-agnostic service for running JS/TS code + * in a sandboxed Web Worker. + */ + +import type { ExecutionResult } from '../stores/executionStore'; + +const EXECUTABLE_LANGUAGES = new Set(['javascript', 'typescript']); + +let worker: Worker | null = null; +let currentReject: ((reason: string) => void) | null = null; + +export function isExecutable(language: string): boolean { + return EXECUTABLE_LANGUAGES.has(language); +} + +export function runCode( + code: string, + language: string, + timeout = 10_000, +): Promise { + // Terminate any in-progress run + terminateWorker(); + + return new Promise((resolve, reject) => { + currentReject = reject; + + worker = new Worker( + new URL('./code-executor.worker.ts', import.meta.url), + { type: 'module' }, + ); + + worker.onmessage = (event: MessageEvent) => { + if (event.data.type === 'result') { + resolve(event.data as ExecutionResult); + cleanupWorker(); + } + }; + + worker.onerror = (event: ErrorEvent) => { + reject(event.message || 'Worker error'); + cleanupWorker(); + }; + + worker.postMessage({ type: 'run', code, language, timeout }); + }); +} + +export function stopExecution(): void { + if (currentReject) { + currentReject('Execution stopped by user'); + } + terminateWorker(); +} + +function terminateWorker(): void { + if (worker) { + worker.terminate(); + worker = null; + } + currentReject = null; +} + +function cleanupWorker(): void { + worker = null; + currentReject = null; +} diff --git a/src/services/code-executor.worker.ts b/src/services/code-executor.worker.ts new file mode 100644 index 0000000..fbfc693 --- /dev/null +++ b/src/services/code-executor.worker.ts @@ -0,0 +1,144 @@ +/** + * code-executor.worker.ts — Sandboxed Web Worker for JS/TS code execution. + * + * Receives { type: 'run', code, language } messages. + * Captures console output, enforces a timeout, and posts back results. + */ + +import ts from 'typescript'; + +interface RunMessage { + type: 'run'; + code: string; + language: string; + timeout?: number; +} + +interface OutputLine { + stream: 'stdout' | 'stderr'; + text: string; +} + +const DEFAULT_TIMEOUT = 10_000; // 10 seconds +const MAX_OUTPUT_SIZE = 100_000; // 100KB per stream + +self.onmessage = (event: MessageEvent) => { + const { code, language, timeout = DEFAULT_TIMEOUT } = event.data; + if (event.data.type !== 'run') return; + + const output: OutputLine[] = []; + let totalSize = 0; + let truncated = false; + + function addOutput(stream: 'stdout' | 'stderr', args: unknown[]) { + if (truncated) return; + const text = args.map(stringifyArg).join(' '); + totalSize += text.length; + if (totalSize > MAX_OUTPUT_SIZE) { + output.push({ stream: 'stderr', text: '\n[Output truncated — exceeded 100KB limit]' }); + truncated = true; + return; + } + output.push({ stream, text }); + } + + // Override console methods to capture output + const fakeConsole = { + log: (...args: unknown[]) => addOutput('stdout', args), + info: (...args: unknown[]) => addOutput('stdout', args), + warn: (...args: unknown[]) => addOutput('stderr', args), + error: (...args: unknown[]) => addOutput('stderr', args), + debug: (...args: unknown[]) => addOutput('stdout', args), + dir: (...args: unknown[]) => addOutput('stdout', args), + table: (...args: unknown[]) => addOutput('stdout', args), + clear: () => { output.length = 0; totalSize = 0; truncated = false; }, + }; + + const start = performance.now(); + + // Timeout guard + const timeoutId = setTimeout(() => { + const duration = Math.round(performance.now() - start); + self.postMessage({ + type: 'result', + stdout: collectStream(output, 'stdout'), + stderr: collectStream(output, 'stderr') + `\n[Execution timed out after ${timeout / 1000}s]`, + exitCode: 1, + duration, + }); + self.close(); + }, timeout); + + try { + let jsCode = code; + + if (language === 'typescript') { + const result = ts.transpileModule(code, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + strict: false, + esModuleInterop: true, + skipLibCheck: true, + }, + }); + jsCode = result.outputText; + } + + // Execute in a closure that provides our fake console + const wrappedCode = ` + "use strict"; + return (function(console) { + ${jsCode} + }); + `; + const factory = new Function(wrappedCode); + const executor = factory(); + executor(fakeConsole); + + clearTimeout(timeoutId); + const duration = Math.round(performance.now() - start); + + self.postMessage({ + type: 'result', + stdout: collectStream(output, 'stdout'), + stderr: collectStream(output, 'stderr'), + exitCode: 0, + duration, + }); + } catch (error: unknown) { + clearTimeout(timeoutId); + const duration = Math.round(performance.now() - start); + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error && error.stack ? error.stack : ''; + + self.postMessage({ + type: 'result', + stdout: collectStream(output, 'stdout'), + stderr: collectStream(output, 'stderr') + + (output.some(l => l.stream === 'stderr') ? '\n' : '') + + (errorStack || errorMessage), + exitCode: 1, + duration, + }); + } +}; + +function stringifyArg(arg: unknown): string { + if (arg === null) return 'null'; + if (arg === undefined) return 'undefined'; + if (typeof arg === 'string') return arg; + if (typeof arg === 'function') return `[Function: ${arg.name || 'anonymous'}]`; + try { + return JSON.stringify(arg, null, 2); + } catch { + return String(arg); + } +} + +function collectStream(output: OutputLine[], stream: 'stdout' | 'stderr'): string { + return output + .filter((l) => l.stream === stream) + .map((l) => l.text) + .join('\n'); +} diff --git a/src/services/connection-manager.ts b/src/services/connection-manager.ts index b4a9cf9..f87ecb5 100644 --- a/src/services/connection-manager.ts +++ b/src/services/connection-manager.ts @@ -29,7 +29,9 @@ export type DataChannelMessage = | { type: 'canvas-clear' } | { type: 'canvas-sync'; strokes: Stroke[]; zoom?: number; panOffset?: Point } | { type: 'message'; id: string; text: string; sender: string; timestamp: number } - | { type: 'message-ack'; messageId: string }; + | { type: 'message-ack'; messageId: string } + | { type: 'execution-start'; language: string; timestamp: number } + | { type: 'execution-result'; stdout: string; stderr: string; exitCode: number; duration: number }; export interface ConnectionManagerOptions { stunServers?: RTCIceServer[]; diff --git a/src/stores/executionStore.ts b/src/stores/executionStore.ts new file mode 100644 index 0000000..a3cb463 --- /dev/null +++ b/src/stores/executionStore.ts @@ -0,0 +1,58 @@ +import { create } from 'zustand'; + +export interface ExecutionResult { + stdout: string; + stderr: string; + exitCode: number; + duration: number; +} + +interface ExecutionState { + isRunning: boolean; + output: ExecutionResult | null; + panelExpanded: boolean; + /** Set by useExecutionSync — triggers code execution with sync. */ + runCode: (() => void) | null; + /** Set by useExecutionSync — cancels in-progress execution. */ + cancelCode: (() => void) | null; +} + +interface ExecutionActions { + startExecution: () => void; + setResult: (result: ExecutionResult) => void; + stopExecution: () => void; + togglePanel: () => void; + setCallbacks: (run: () => void, cancel: () => void) => void; + reset: () => void; +} + +export type ExecutionStore = ExecutionState & ExecutionActions; + +const initialState: ExecutionState = { + isRunning: false, + output: null, + panelExpanded: false, + runCode: null, + cancelCode: null, +}; + +export const useExecutionStore = create((set) => ({ + ...initialState, + + startExecution: () => + set({ isRunning: true, output: null, panelExpanded: true }), + + setResult: (result) => + set({ isRunning: false, output: result }), + + stopExecution: () => + set({ isRunning: false }), + + togglePanel: () => + set((state) => ({ panelExpanded: !state.panelExpanded })), + + setCallbacks: (run, cancel) => + set({ runCode: run, cancelCode: cancel }), + + reset: () => set(initialState), +})); diff --git a/src/styles.css b/src/styles.css index 27c0ae5..21cbb14 100644 --- a/src/styles.css +++ b/src/styles.css @@ -912,7 +912,8 @@ button:disabled { } #languageSelector select { - padding: 6px 10px; + height: 36px; + padding: 0 10px; border: 1px solid var(--border-tertiary); border-radius: 4px; background-color: var(--bg-secondary); @@ -942,6 +943,163 @@ button:disabled { display: none; } +/* Run / Stop button in tab bar */ +.run-btn { + color: #22c55e !important; + border-color: #22c55e !important; + margin-left: 4px; + margin-bottom: 4px; +} + +.run-btn:hover { + background-color: #22c55e !important; + color: white !important; +} + +.stop-btn { + color: #ef4444 !important; + border-color: #ef4444 !important; + margin-left: 4px; + margin-bottom: 4px; +} + +.stop-btn:hover { + background-color: #ef4444 !important; + color: white !important; +} + +/* Output Panel */ +.output-panel { + display: flex; + flex-direction: column; + flex-shrink: 0; + border-top: 1px solid var(--border-primary); + background-color: var(--code-bg); + border-radius: 0 0 4px 4px; + overflow: hidden; + transition: max-height 0.2s ease; +} + +.output-panel.expanded { + max-height: 40vh; + min-height: 80px; +} + +.output-panel.collapsed { + max-height: 32px; +} + +.output-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + background-color: var(--bg-tertiary); + border-bottom: 1px solid var(--border-tertiary); + cursor: pointer; + user-select: none; + flex-shrink: 0; + min-height: 32px; +} + +.output-header:hover { + background-color: var(--bg-hover); +} + +.output-header-left { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); +} + +.output-title { + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.output-duration { + font-size: 11px; + color: var(--text-tertiary); + background-color: var(--bg-hover); + padding: 1px 6px; + border-radius: 3px; +} + +.output-running-label { + font-size: 11px; + color: var(--text-tertiary); + font-style: italic; +} + +.output-status-icon { + font-size: 13px; + font-weight: 700; + line-height: 1; +} + +.output-status-icon.success { + color: #22c55e; +} + +.output-status-icon.error { + color: #ef4444; +} + +.output-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid var(--border-tertiary); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: output-spin 0.6s linear infinite; +} + +@keyframes output-spin { + to { transform: rotate(360deg); } +} + +.output-toggle { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 2px; + display: flex; + align-items: center; +} + +.output-body { + flex: 1; + overflow: auto; + padding: 10px 12px; + margin: 0; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace; + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + color: var(--text-primary); + min-height: 0; +} + +.output-stdout { + color: var(--text-primary); +} + +.output-stderr { + color: #ef4444; +} + +.output-placeholder { + color: var(--text-tertiary); + font-style: italic; +} + #codeEditorWrapper { flex: 1; position: relative; @@ -2058,6 +2216,15 @@ button:disabled { font-size: 14px; } + .output-panel.expanded { + max-height: 30vh; + } + + .output-body { + font-size: 12px; + padding: 8px; + } + #codeInput, #codeHighlight { font-size: 13px; diff --git a/src/version.ts b/src/version.ts index 72f53fa..3068eab 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = '2.3'; +export const APP_VERSION = '2.4';