Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
9 changes: 8 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand All @@ -85,7 +92,7 @@ function App() {
break;
}
},
[handleCodeMessage, handleCanvasMessage, handleChatMessage]
[handleCodeMessage, handleCanvasMessage, handleChatMessage, handleExecutionMessage]
);

// WebRTC connection lifecycle — populates sendRef when the data channel opens
Expand Down
5 changes: 3 additions & 2 deletions src/__tests__/LanguageSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ describe('LanguageSelector', () => {

it('displays human-readable labels', () => {
const { getByText } = render(<LanguageSelector />);
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();
Expand Down
13 changes: 13 additions & 0 deletions src/components/CodeEditor/CodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -69,6 +71,17 @@ export default function CodeEditor() {

const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
// 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;

Expand Down
3 changes: 2 additions & 1 deletion src/components/CodeEditor/LanguageSelector.tsx
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -34,7 +35,7 @@ export default function LanguageSelector() {
>
{LANGUAGES.map((lang) => (
<option key={lang} value={lang}>
{LANGUAGE_LABELS[lang] || lang}
{isExecutable(lang) ? '\u25B6 ' : ''}{LANGUAGE_LABELS[lang] || lang}
</option>
))}
</select>
Expand Down
80 changes: 80 additions & 0 deletions src/components/CodeEditor/OutputPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLPreElement>(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 (
<div className={`output-panel${panelExpanded ? ' expanded' : ' collapsed'}`}>
<div className="output-header" onClick={togglePanel}>
<div className="output-header-left">
{isRunning ? (
<span className="output-spinner" />
) : output ? (
<span className={`output-status-icon ${hasError ? 'error' : 'success'}`}>
{hasError ? '\u2717' : '\u2713'}
</span>
) : null}
<span className="output-title">Output</span>
{output && (
<span className="output-duration">{output.duration}ms</span>
)}
{isRunning && (
<span className="output-running-label">Running...</span>
)}
</div>
<button
className="output-toggle"
aria-label={panelExpanded ? 'Collapse output' : 'Expand output'}
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="currentColor"
style={{ transform: panelExpanded ? 'rotate(0deg)' : 'rotate(180deg)', transition: 'transform 0.15s' }}
>
<path d="M3 5l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
{panelExpanded && (
<pre className="output-body" ref={outputRef}>
{isRunning && !hasOutput && (
<span className="output-placeholder">Executing...</span>
)}
{output?.stdout && (
<span className="output-stdout">{output.stdout}</span>
)}
{output?.stdout && output?.stderr && '\n'}
{output?.stderr && (
<span className="output-stderr">{output.stderr}</span>
)}
{output && !output.stdout && !output.stderr && (
<span className="output-placeholder">(No output)</span>
)}
</pre>
)}
</div>
);
}
34 changes: 34 additions & 0 deletions src/components/TabBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div id="tabBarWrapper">
Expand All @@ -22,6 +31,31 @@ export default function TabBar() {
</button>
</div>
{activeTab === 'code' && <LanguageSelector />}
{showRunButton && (
isRunning ? (
<button
className="icon-btn stop-btn"
onClick={() => cancelCode?.()}
title="Stop execution"
aria-label="Stop execution"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="3" width="10" height="10" rx="1" />
</svg>
</button>
) : (
<button
className="icon-btn run-btn"
onClick={() => runCode?.()}
title="Run code (Ctrl+Enter)"
aria-label="Run code"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 2l10 6-10 6V2z" />
</svg>
</button>
)
)}
</div>
);
}
2 changes: 2 additions & 0 deletions src/components/TabContent.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -9,6 +10,7 @@ export default function TabContent() {
<div id="tabContent">
<div id="codeCanvas" className={activeTab === 'code' ? 'active' : ''}>
<CodeEditor />
<OutputPanel />
</div>
<div id="diagramCanvas" className={activeTab === 'canvas' ? 'active' : ''}>
<DiagramCanvas />
Expand Down
109 changes: 109 additions & 0 deletions src/hooks/useExecutionSync.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading