Skip to content

Commit ce3c3b8

Browse files
authored
feat: add persistent terminal module (#44)
* feat: add Terminal component and styles - Introduced a new Terminal component for embedding a terminal interface within the application. - Added corresponding styles in Terminal.scss to ensure proper layout and appearance. - Updated index.ts to export the Terminal component for accessibility within the module. * feat: integrate Terminal component into CustomEmbeddableRenderer - Updated renderCustomEmbeddable function to include support for the new Terminal component. - Added case handling for 'terminal' to render the Terminal interface when the corresponding link is detected. - Enhanced user experience by providing additional embedding options within the application. * feat: add Terminal option to Main Menu - Integrated a new Terminal item in the Main Menu for user access. - Implemented handleTerminalClick function to create and place a terminal element in the canvas. - Enhanced user experience by providing direct access to terminal functionality within the application. * feat: enhance Terminal component with connection management - Added state management for terminal ID and connection info within the Terminal component. - Implemented UUID generation for unique terminal identification. - Updated customData handling to store and retrieve terminal connection information. - Improved terminal URL generation logic to include reconnect parameters based on terminal ID. - Enhanced initialization logic to ensure proper setup of terminal connection data. * feat: add showClickableHint option to customData in default_canvas.json * refactor: streamline ActionButton URL handling - Renamed getUrl to getCodeUrl for clarity. - Simplified URL generation logic for terminal and code targets. - Consolidated embedding logic by directly using ExcalidrawElementFactory for creating embeddable elements. - Improved error handling for URL determination in both embedding and opening actions. - Enhanced debug logging for better traceability of actions.
1 parent 71f9710 commit ce3c3b8

File tree

7 files changed

+283
-44
lines changed

7 files changed

+283
-44
lines changed

src/backend/default_canvas.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2176,7 +2176,8 @@
21762176
"boundElements": [],
21772177
"backgroundColor": "#e9ecef",
21782178
"customData": {
2179-
"showHyperlinkIcon": false
2179+
"showHyperlinkIcon": false,
2180+
"showClickableHint": false
21802181
}
21812182
}
21822183
]

src/frontend/src/CustomEmbeddableRenderer.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ControlButton,
1010
HtmlEditor,
1111
Editor,
12+
Terminal,
1213
} from './pad';
1314
import { ActionButton } from './pad/buttons';
1415
import "./CustomEmbeddableRenderer.scss";
@@ -20,6 +21,7 @@ export const renderCustomEmbeddable = (
2021
) => {
2122

2223
if (element.link && element.link.startsWith('!')) {
24+
2325
let path = element.link.split('!')[1];
2426
let content;
2527
let title;
@@ -28,10 +30,15 @@ export const renderCustomEmbeddable = (
2830
case 'html':
2931
content = <HtmlEditor element={element} appState={appState} excalidrawAPI={excalidrawAPI} />;
3032
title = "HTML Editor";
33+
break;
3134
case 'editor':
3235
content = <Editor element={element} appState={appState} excalidrawAPI={excalidrawAPI} />;
3336
title = "Code Editor";
3437
break;
38+
case 'terminal':
39+
content = <Terminal element={element} appState={appState} excalidrawAPI={excalidrawAPI} />;
40+
title = "Terminal";
41+
break;
3542
case 'state':
3643
content = <StateIndicator />;
3744
title = "State Indicator";

src/frontend/src/pad/buttons/ActionButton.tsx

Lines changed: 45 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -280,35 +280,16 @@ const ActionButton: React.FC<ActionButtonProps> = ({
280280
};
281281

282282

283-
const getUrl = () => {
283+
const getCodeUrl = () => {
284284
if (!workspaceState) {
285-
if (selectedTarget === 'terminal') {
286-
return 'https://terminal.example.dev';
287-
} else {
288-
return 'https://vscode.example.dev';
289-
}
285+
return '';
290286
}
291287

292-
if (selectedTarget === 'terminal') {
293-
return `${workspaceState.base_url}/@${workspaceState.username}/${workspaceState.workspace_id}.${workspaceState.agent}/terminal`;
294-
} else {
295-
return `${workspaceState.base_url}/@${workspaceState.username}/${workspaceState.workspace_id}.${workspaceState.agent}/apps/code-server`;
296-
}
288+
return `${workspaceState.base_url}/@${workspaceState.username}/${workspaceState.workspace_id}.${workspaceState.agent}/apps/code-server`;
297289
};
298290

299291
// Placement logic has been moved to ExcalidrawElementFactory.placeInScene
300292

301-
const createEmbeddableElement = (link: string, buttonElement: HTMLElement | null = null) => {
302-
return ExcalidrawElementFactory.createEmbeddableElement({
303-
link,
304-
width: 600,
305-
height: 400,
306-
strokeColor: "#1e1e1e",
307-
backgroundColor: "#ffffff",
308-
roughness: 1
309-
});
310-
};
311-
312293

313294
const executeAction = () => {
314295
capture('action_button_clicked', {
@@ -324,36 +305,60 @@ const ActionButton: React.FC<ActionButtonProps> = ({
324305
return;
325306
}
326307

327-
const baseUrl = getUrl();
308+
// Determine the link to use
309+
let link: string;
328310

329-
if (!baseUrl) {
330-
console.error('Could not determine URL for embedding');
331-
return;
311+
if (selectedTarget === 'terminal') {
312+
// For terminal, use the !terminal embed link
313+
link = '!terminal';
314+
} else {
315+
// For code, use the code URL
316+
const codeUrl = getCodeUrl();
317+
if (!codeUrl) {
318+
console.error('Could not determine URL for embedding');
319+
return;
320+
}
321+
link = codeUrl;
332322
}
333323

334-
// Create element with our factory
335-
const buttonElement = wrapperRef.current;
336-
const newElement = createEmbeddableElement(baseUrl, buttonElement);
324+
// Create element directly with ExcalidrawElementFactory
325+
const newElement = ExcalidrawElementFactory.createEmbeddableElement({
326+
link,
327+
width: 600,
328+
height: 400,
329+
});
337330

338-
// Place the element in the scene using our new placement logic
331+
// Place the element in the scene
339332
ExcalidrawElementFactory.placeInScene(newElement, excalidrawAPI, {
340333
mode: PlacementMode.NEAR_VIEWPORT_CENTER,
341334
bufferPercentage: 10,
342335
scrollToView: true
343336
});
344337

345-
console.debug(`[pad.ws] Embedded ${selectedTarget} at URL: ${baseUrl}`);
338+
console.debug(`[pad.ws] Embedded ${selectedTarget}`);
346339

347340
} else if (selectedAction === 'open-tab') {
348-
const baseUrl = getUrl();
349-
if (!baseUrl) {
350-
console.error('Could not determine URL for opening in tab');
351-
return;
341+
if (selectedTarget === 'terminal') {
342+
// For terminal, open the terminal URL in a new tab
343+
if (!workspaceState) {
344+
console.error('Workspace state not available for opening terminal in tab');
345+
return;
346+
}
347+
348+
const terminalUrl = `${workspaceState.base_url}/@${workspaceState.username}/${workspaceState.workspace_id}.${workspaceState.agent}/terminal`;
349+
console.debug(`[pad.ws] Opening terminal in new tab: ${terminalUrl}`);
350+
window.open(terminalUrl, '_blank');
351+
} else {
352+
// For code, open the code URL in a new tab
353+
const codeUrl = getCodeUrl();
354+
if (!codeUrl) {
355+
console.error('Could not determine URL for opening in tab');
356+
return;
357+
}
358+
359+
console.debug(`[pad.ws] Opening ${selectedTarget} in new tab: ${codeUrl}`);
360+
window.open(codeUrl, '_blank');
352361
}
353-
354-
console.debug(`[pad.ws] Opening ${selectedTarget} in new tab from ${baseUrl}`);
355-
window.open(baseUrl, '_blank');
356-
357362
} else if (selectedAction === 'magnet') {
358363
if (!workspaceState) {
359364
console.error('Workspace state not available for magnet link');
@@ -365,14 +370,12 @@ const ActionButton: React.FC<ActionButtonProps> = ({
365370
const url = workspaceState.base_url;
366371
const agent = workspaceState.agent;
367372

368-
let magnetLink = '';
369-
370373
if (selectedTarget === 'terminal') {
371374
console.error('Terminal magnet links are not supported');
372375
return;
373376
} else if (selectedTarget === 'code') {
374377
const prefix = selectedCodeVariant === 'cursor' ? 'cursor' : 'vscode';
375-
magnetLink = `${prefix}://coder.coder-remote/open?owner=${owner}&workspace=${workspace}&url=${url}&token=&openRecent=true&agent=${agent}`;
378+
const magnetLink = `${prefix}://coder.coder-remote/open?owner=${owner}&workspace=${workspace}&url=${url}&token=&openRecent=true&agent=${agent}`;
376379
console.debug(`[pad.ws] Opening ${selectedCodeVariant} desktop app with magnet link: ${magnetLink}`);
377380
window.open(magnetLink, '_blank');
378381
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
.terminal-container {
2+
width: 100%;
3+
height: 100%;
4+
display: flex;
5+
flex-direction: column;
6+
overflow: hidden;
7+
}
8+
9+
.terminal-iframe {
10+
flex: 1;
11+
background-color: #1e1e1e;
12+
height: 100%;
13+
width: 100%;
14+
border: 0px !important;
15+
overflow: hidden;
16+
border-bottom-left-radius: var(--embeddable-radius);
17+
border-bottom-right-radius: var(--embeddable-radius);
18+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import React, { useState, useEffect, useCallback, useRef } from 'react';
2+
import { useWorkspaceState } from '../../api/hooks';
3+
import type { NonDeleted, ExcalidrawEmbeddableElement } from '@atyrode/excalidraw/element/types';
4+
import type { AppState } from '@atyrode/excalidraw/types';
5+
import './Terminal.scss';
6+
7+
interface TerminalProps {
8+
element: NonDeleted<ExcalidrawEmbeddableElement>;
9+
appState: AppState;
10+
excalidrawAPI?: any;
11+
}
12+
13+
// Interface for terminal connection info stored in customData
14+
interface TerminalConnectionInfo {
15+
terminalId: string;
16+
baseUrl?: string;
17+
username?: string;
18+
workspaceId?: string;
19+
agent?: string;
20+
}
21+
22+
export const Terminal: React.FC<TerminalProps> = ({
23+
element,
24+
appState,
25+
excalidrawAPI
26+
}) => {
27+
const { data: workspaceState } = useWorkspaceState();
28+
const [terminalId, setTerminalId] = useState<string | null>(null);
29+
const elementIdRef = useRef(element?.id);
30+
const isInitializedRef = useRef(false);
31+
32+
// Generate a UUID for terminal ID
33+
const generateUUID = () => {
34+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
35+
const r = Math.random() * 16 | 0;
36+
const v = c === 'x' ? r : (r & 0x3 | 0x8);
37+
return v.toString(16);
38+
});
39+
};
40+
41+
// Save terminal connection info to element's customData
42+
const saveConnectionInfoToCustomData = useCallback(() => {
43+
if (!element || !excalidrawAPI || !workspaceState || !terminalId) return;
44+
45+
try {
46+
// Get all elements from the scene
47+
const elements = excalidrawAPI.getSceneElements();
48+
49+
// Find and update the element
50+
const updatedElements = elements.map(el => {
51+
if (el.id === element.id) {
52+
// Create a new customData object with the terminal connection info
53+
const connectionInfo: TerminalConnectionInfo = {
54+
terminalId,
55+
baseUrl: workspaceState.base_url,
56+
username: workspaceState.username,
57+
workspaceId: workspaceState.workspace_id,
58+
agent: workspaceState.agent
59+
};
60+
61+
const customData = {
62+
...(el.customData || {}),
63+
terminalConnectionInfo: connectionInfo
64+
};
65+
66+
return { ...el, customData };
67+
}
68+
return el;
69+
});
70+
71+
// Update the scene with the modified elements
72+
excalidrawAPI.updateScene({
73+
elements: updatedElements
74+
});
75+
} catch (error) {
76+
console.error('Error saving terminal connection info:', error);
77+
}
78+
}, [element, excalidrawAPI, workspaceState, terminalId]);
79+
80+
// Generate a terminal ID if one doesn't exist
81+
useEffect(() => {
82+
if (terminalId) return;
83+
84+
// Generate a new terminal ID
85+
const newTerminalId = generateUUID();
86+
setTerminalId(newTerminalId);
87+
}, [terminalId]);
88+
89+
// Initialize terminal connection info
90+
useEffect(() => {
91+
if (!element || !workspaceState || !terminalId || isInitializedRef.current) return;
92+
93+
// Check if element ID has changed (indicating a new element)
94+
if (element.id !== elementIdRef.current) {
95+
elementIdRef.current = element.id;
96+
}
97+
98+
// Check if element already has terminal connection info
99+
if (element.customData?.terminalConnectionInfo) {
100+
const connectionInfo = element.customData.terminalConnectionInfo as TerminalConnectionInfo;
101+
setTerminalId(connectionInfo.terminalId);
102+
} else if (excalidrawAPI) {
103+
// Save the terminal ID to customData
104+
saveConnectionInfoToCustomData();
105+
}
106+
107+
isInitializedRef.current = true;
108+
}, [element, workspaceState, terminalId, saveConnectionInfoToCustomData, excalidrawAPI]);
109+
110+
// Update terminal connection info when element changes
111+
useEffect(() => {
112+
if (!element || !workspaceState) return;
113+
114+
// Check if element ID has changed (indicating a new element)
115+
if (element.id !== elementIdRef.current) {
116+
elementIdRef.current = element.id;
117+
isInitializedRef.current = false;
118+
119+
// Check if element already has terminal connection info
120+
if (element.customData?.terminalConnectionInfo) {
121+
const connectionInfo = element.customData.terminalConnectionInfo as TerminalConnectionInfo;
122+
setTerminalId(connectionInfo.terminalId);
123+
} else if (terminalId && excalidrawAPI) {
124+
// Save the existing terminal ID to customData
125+
saveConnectionInfoToCustomData();
126+
} else if (!terminalId) {
127+
// Generate a new terminal ID if one doesn't exist
128+
const newTerminalId = generateUUID();
129+
setTerminalId(newTerminalId);
130+
131+
// Save the new terminal ID to customData if excalidrawAPI is available
132+
if (excalidrawAPI) {
133+
setTimeout(() => {
134+
saveConnectionInfoToCustomData();
135+
}, 100);
136+
}
137+
}
138+
139+
isInitializedRef.current = true;
140+
} else if (!isInitializedRef.current && terminalId && excalidrawAPI && !element.customData?.terminalConnectionInfo) {
141+
// Handle the case where the element ID hasn't changed but we need to save the terminal ID
142+
saveConnectionInfoToCustomData();
143+
isInitializedRef.current = true;
144+
}
145+
}, [element, workspaceState, excalidrawAPI, terminalId, saveConnectionInfoToCustomData]);
146+
147+
// Effect to handle excalidrawAPI becoming available after component mount
148+
useEffect(() => {
149+
if (!excalidrawAPI || !element || !workspaceState || !terminalId) return;
150+
151+
// Check if element already has terminal connection info
152+
if (element.customData?.terminalConnectionInfo) return;
153+
154+
// Save the terminal ID to customData
155+
saveConnectionInfoToCustomData();
156+
}, [excalidrawAPI, element, workspaceState, terminalId, saveConnectionInfoToCustomData]);
157+
158+
const getTerminalUrl = () => {
159+
if (!workspaceState) {
160+
return '';
161+
}
162+
163+
const baseUrl = `${workspaceState.base_url}/@${workspaceState.username}/${workspaceState.workspace_id}.${workspaceState.agent}/terminal`;
164+
165+
// Add reconnect parameter if terminal ID exists
166+
if (terminalId) {
167+
return `${baseUrl}?reconnect=${terminalId}`;
168+
}
169+
170+
return baseUrl;
171+
};
172+
173+
const terminalUrl = getTerminalUrl();
174+
175+
return (
176+
<div className="terminal-container">
177+
<iframe
178+
className="terminal-iframe"
179+
src={terminalUrl}
180+
title="Terminal"
181+
/>
182+
</div>
183+
);
184+
};
185+
186+
export default Terminal;

src/frontend/src/pad/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
export * from './controls/ControlButton';
33
export * from './controls/StateIndicator';
44
export * from './containers/Dashboard';
5+
export * from './containers/Terminal';
56
export * from './buttons';
67
export * from './editors';
78

89
// Default exports
910
export { default as ControlButton } from './controls/ControlButton';
1011
export { default as StateIndicator } from './controls/StateIndicator';
1112
export { default as Dashboard } from './containers/Dashboard';
13+
export { default as Terminal } from './containers/Terminal';

0 commit comments

Comments
 (0)