diff --git a/src/backend/default_canvas.json b/src/backend/default_canvas.json index 3ecb6dc..e902312 100644 --- a/src/backend/default_canvas.json +++ b/src/backend/default_canvas.json @@ -2176,7 +2176,8 @@ "boundElements": [], "backgroundColor": "#e9ecef", "customData": { - "showHyperlinkIcon": false + "showHyperlinkIcon": false, + "showClickableHint": false } } ] diff --git a/src/frontend/src/CustomEmbeddableRenderer.tsx b/src/frontend/src/CustomEmbeddableRenderer.tsx index 0fcb1c4..20792d7 100644 --- a/src/frontend/src/CustomEmbeddableRenderer.tsx +++ b/src/frontend/src/CustomEmbeddableRenderer.tsx @@ -9,6 +9,7 @@ import { ControlButton, HtmlEditor, Editor, + Terminal, } from './pad'; import { ActionButton } from './pad/buttons'; import "./CustomEmbeddableRenderer.scss"; @@ -20,6 +21,7 @@ export const renderCustomEmbeddable = ( ) => { if (element.link && element.link.startsWith('!')) { + let path = element.link.split('!')[1]; let content; let title; @@ -28,10 +30,15 @@ export const renderCustomEmbeddable = ( case 'html': content = ; title = "HTML Editor"; + break; case 'editor': content = ; title = "Code Editor"; break; + case 'terminal': + content = ; + title = "Terminal"; + break; case 'state': content = ; title = "State Indicator"; diff --git a/src/frontend/src/pad/buttons/ActionButton.tsx b/src/frontend/src/pad/buttons/ActionButton.tsx index 8d4f104..91118f5 100644 --- a/src/frontend/src/pad/buttons/ActionButton.tsx +++ b/src/frontend/src/pad/buttons/ActionButton.tsx @@ -280,35 +280,16 @@ const ActionButton: React.FC = ({ }; - const getUrl = () => { + const getCodeUrl = () => { if (!workspaceState) { - if (selectedTarget === 'terminal') { - return 'https://terminal.example.dev'; - } else { - return 'https://vscode.example.dev'; - } + return ''; } - if (selectedTarget === 'terminal') { - return `${workspaceState.base_url}/@${workspaceState.username}/${workspaceState.workspace_id}.${workspaceState.agent}/terminal`; - } else { - return `${workspaceState.base_url}/@${workspaceState.username}/${workspaceState.workspace_id}.${workspaceState.agent}/apps/code-server`; - } + return `${workspaceState.base_url}/@${workspaceState.username}/${workspaceState.workspace_id}.${workspaceState.agent}/apps/code-server`; }; // Placement logic has been moved to ExcalidrawElementFactory.placeInScene - const createEmbeddableElement = (link: string, buttonElement: HTMLElement | null = null) => { - return ExcalidrawElementFactory.createEmbeddableElement({ - link, - width: 600, - height: 400, - strokeColor: "#1e1e1e", - backgroundColor: "#ffffff", - roughness: 1 - }); - }; - const executeAction = () => { capture('action_button_clicked', { @@ -324,36 +305,60 @@ const ActionButton: React.FC = ({ return; } - const baseUrl = getUrl(); + // Determine the link to use + let link: string; - if (!baseUrl) { - console.error('Could not determine URL for embedding'); - return; + if (selectedTarget === 'terminal') { + // For terminal, use the !terminal embed link + link = '!terminal'; + } else { + // For code, use the code URL + const codeUrl = getCodeUrl(); + if (!codeUrl) { + console.error('Could not determine URL for embedding'); + return; + } + link = codeUrl; } - // Create element with our factory - const buttonElement = wrapperRef.current; - const newElement = createEmbeddableElement(baseUrl, buttonElement); + // Create element directly with ExcalidrawElementFactory + const newElement = ExcalidrawElementFactory.createEmbeddableElement({ + link, + width: 600, + height: 400, + }); - // Place the element in the scene using our new placement logic + // Place the element in the scene ExcalidrawElementFactory.placeInScene(newElement, excalidrawAPI, { mode: PlacementMode.NEAR_VIEWPORT_CENTER, bufferPercentage: 10, scrollToView: true }); - console.debug(`[pad.ws] Embedded ${selectedTarget} at URL: ${baseUrl}`); + console.debug(`[pad.ws] Embedded ${selectedTarget}`); } else if (selectedAction === 'open-tab') { - const baseUrl = getUrl(); - if (!baseUrl) { - console.error('Could not determine URL for opening in tab'); - return; + if (selectedTarget === 'terminal') { + // For terminal, open the terminal URL in a new tab + if (!workspaceState) { + console.error('Workspace state not available for opening terminal in tab'); + return; + } + + const terminalUrl = `${workspaceState.base_url}/@${workspaceState.username}/${workspaceState.workspace_id}.${workspaceState.agent}/terminal`; + console.debug(`[pad.ws] Opening terminal in new tab: ${terminalUrl}`); + window.open(terminalUrl, '_blank'); + } else { + // For code, open the code URL in a new tab + const codeUrl = getCodeUrl(); + if (!codeUrl) { + console.error('Could not determine URL for opening in tab'); + return; + } + + console.debug(`[pad.ws] Opening ${selectedTarget} in new tab: ${codeUrl}`); + window.open(codeUrl, '_blank'); } - - console.debug(`[pad.ws] Opening ${selectedTarget} in new tab from ${baseUrl}`); - window.open(baseUrl, '_blank'); - } else if (selectedAction === 'magnet') { if (!workspaceState) { console.error('Workspace state not available for magnet link'); @@ -365,14 +370,12 @@ const ActionButton: React.FC = ({ const url = workspaceState.base_url; const agent = workspaceState.agent; - let magnetLink = ''; - if (selectedTarget === 'terminal') { console.error('Terminal magnet links are not supported'); return; } else if (selectedTarget === 'code') { const prefix = selectedCodeVariant === 'cursor' ? 'cursor' : 'vscode'; - magnetLink = `${prefix}://coder.coder-remote/open?owner=${owner}&workspace=${workspace}&url=${url}&token=&openRecent=true&agent=${agent}`; + const magnetLink = `${prefix}://coder.coder-remote/open?owner=${owner}&workspace=${workspace}&url=${url}&token=&openRecent=true&agent=${agent}`; console.debug(`[pad.ws] Opening ${selectedCodeVariant} desktop app with magnet link: ${magnetLink}`); window.open(magnetLink, '_blank'); } diff --git a/src/frontend/src/pad/containers/Terminal.scss b/src/frontend/src/pad/containers/Terminal.scss new file mode 100644 index 0000000..31a9462 --- /dev/null +++ b/src/frontend/src/pad/containers/Terminal.scss @@ -0,0 +1,18 @@ +.terminal-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.terminal-iframe { + flex: 1; + background-color: #1e1e1e; + height: 100%; + width: 100%; + border: 0px !important; + overflow: hidden; + border-bottom-left-radius: var(--embeddable-radius); + border-bottom-right-radius: var(--embeddable-radius); +} diff --git a/src/frontend/src/pad/containers/Terminal.tsx b/src/frontend/src/pad/containers/Terminal.tsx new file mode 100644 index 0000000..768e09c --- /dev/null +++ b/src/frontend/src/pad/containers/Terminal.tsx @@ -0,0 +1,186 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { useWorkspaceState } from '../../api/hooks'; +import type { NonDeleted, ExcalidrawEmbeddableElement } from '@atyrode/excalidraw/element/types'; +import type { AppState } from '@atyrode/excalidraw/types'; +import './Terminal.scss'; + +interface TerminalProps { + element: NonDeleted; + appState: AppState; + excalidrawAPI?: any; +} + +// Interface for terminal connection info stored in customData +interface TerminalConnectionInfo { + terminalId: string; + baseUrl?: string; + username?: string; + workspaceId?: string; + agent?: string; +} + +export const Terminal: React.FC = ({ + element, + appState, + excalidrawAPI +}) => { + const { data: workspaceState } = useWorkspaceState(); + const [terminalId, setTerminalId] = useState(null); + const elementIdRef = useRef(element?.id); + const isInitializedRef = useRef(false); + + // Generate a UUID for terminal ID + const generateUUID = () => { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + }; + + // Save terminal connection info to element's customData + const saveConnectionInfoToCustomData = useCallback(() => { + if (!element || !excalidrawAPI || !workspaceState || !terminalId) return; + + try { + // Get all elements from the scene + const elements = excalidrawAPI.getSceneElements(); + + // Find and update the element + const updatedElements = elements.map(el => { + if (el.id === element.id) { + // Create a new customData object with the terminal connection info + const connectionInfo: TerminalConnectionInfo = { + terminalId, + baseUrl: workspaceState.base_url, + username: workspaceState.username, + workspaceId: workspaceState.workspace_id, + agent: workspaceState.agent + }; + + const customData = { + ...(el.customData || {}), + terminalConnectionInfo: connectionInfo + }; + + return { ...el, customData }; + } + return el; + }); + + // Update the scene with the modified elements + excalidrawAPI.updateScene({ + elements: updatedElements + }); + } catch (error) { + console.error('Error saving terminal connection info:', error); + } + }, [element, excalidrawAPI, workspaceState, terminalId]); + + // Generate a terminal ID if one doesn't exist + useEffect(() => { + if (terminalId) return; + + // Generate a new terminal ID + const newTerminalId = generateUUID(); + setTerminalId(newTerminalId); + }, [terminalId]); + + // Initialize terminal connection info + useEffect(() => { + if (!element || !workspaceState || !terminalId || isInitializedRef.current) return; + + // Check if element ID has changed (indicating a new element) + if (element.id !== elementIdRef.current) { + elementIdRef.current = element.id; + } + + // Check if element already has terminal connection info + if (element.customData?.terminalConnectionInfo) { + const connectionInfo = element.customData.terminalConnectionInfo as TerminalConnectionInfo; + setTerminalId(connectionInfo.terminalId); + } else if (excalidrawAPI) { + // Save the terminal ID to customData + saveConnectionInfoToCustomData(); + } + + isInitializedRef.current = true; + }, [element, workspaceState, terminalId, saveConnectionInfoToCustomData, excalidrawAPI]); + + // Update terminal connection info when element changes + useEffect(() => { + if (!element || !workspaceState) return; + + // Check if element ID has changed (indicating a new element) + if (element.id !== elementIdRef.current) { + elementIdRef.current = element.id; + isInitializedRef.current = false; + + // Check if element already has terminal connection info + if (element.customData?.terminalConnectionInfo) { + const connectionInfo = element.customData.terminalConnectionInfo as TerminalConnectionInfo; + setTerminalId(connectionInfo.terminalId); + } else if (terminalId && excalidrawAPI) { + // Save the existing terminal ID to customData + saveConnectionInfoToCustomData(); + } else if (!terminalId) { + // Generate a new terminal ID if one doesn't exist + const newTerminalId = generateUUID(); + setTerminalId(newTerminalId); + + // Save the new terminal ID to customData if excalidrawAPI is available + if (excalidrawAPI) { + setTimeout(() => { + saveConnectionInfoToCustomData(); + }, 100); + } + } + + isInitializedRef.current = true; + } else if (!isInitializedRef.current && terminalId && excalidrawAPI && !element.customData?.terminalConnectionInfo) { + // Handle the case where the element ID hasn't changed but we need to save the terminal ID + saveConnectionInfoToCustomData(); + isInitializedRef.current = true; + } + }, [element, workspaceState, excalidrawAPI, terminalId, saveConnectionInfoToCustomData]); + + // Effect to handle excalidrawAPI becoming available after component mount + useEffect(() => { + if (!excalidrawAPI || !element || !workspaceState || !terminalId) return; + + // Check if element already has terminal connection info + if (element.customData?.terminalConnectionInfo) return; + + // Save the terminal ID to customData + saveConnectionInfoToCustomData(); + }, [excalidrawAPI, element, workspaceState, terminalId, saveConnectionInfoToCustomData]); + + const getTerminalUrl = () => { + if (!workspaceState) { + return ''; + } + + const baseUrl = `${workspaceState.base_url}/@${workspaceState.username}/${workspaceState.workspace_id}.${workspaceState.agent}/terminal`; + + // Add reconnect parameter if terminal ID exists + if (terminalId) { + return `${baseUrl}?reconnect=${terminalId}`; + } + + return baseUrl; + }; + + const terminalUrl = getTerminalUrl(); + + return ( +
+