diff --git a/plugins/code-server/app/App.vue b/plugins/code-server/app/App.vue index bb6f0b71..01b87ad3 100644 --- a/plugins/code-server/app/App.vue +++ b/plugins/code-server/app/App.vue @@ -6,7 +6,7 @@ import LauncherView from './components/LauncherView.vue' import { useCodeServer } from './composables/code-server' import { connection, connect as connectRpc } from './composables/rpc' -const { detection, server, connect, busy, phase, launch, stop, recheck, bootstrap } = useCodeServer() +const { detection, server, connect, busy, phase, canViewInTerminal, launch, stop, recheck, viewInTerminal, bootstrap } = useCodeServer() // Keep the editor iframe mounted for as long as we hold a connect descriptor, // so a transient restart (running → starting → running) hides it via `v-show` @@ -41,8 +41,10 @@ defineExpose({ stop }) :detection="detection" :server="server" :busy="busy" + :can-view-in-terminal="canViewInTerminal" @launch="launch" @recheck="recheck" + @view-in-terminal="viewInTerminal" /> diff --git a/plugins/code-server/app/components/LauncherView.stories.ts b/plugins/code-server/app/components/LauncherView.stories.ts index a87a6584..ec757b7a 100644 --- a/plugins/code-server/app/components/LauncherView.stories.ts +++ b/plugins/code-server/app/components/LauncherView.stories.ts @@ -109,6 +109,21 @@ export const Starting: Story = { }, } +/** Starting inside a hub with the terminals dock: offer a jump to the session. */ +export const StartingViewInTerminal: Story = { + args: { + phase: 'starting', + detection: localCodeServer, + server: { status: 'starting', port: 8080, terminalSessionId: 'devframes_plugin_code-server' }, + busy: true, + canViewInTerminal: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await expect(canvas.getByRole('button', { name: 'View in terminal' })).toBeInTheDocument() + }, +} + /** Tunnel mode, waiting on the interactive device-login prompt. */ export const TunnelLogin: Story = { args: { diff --git a/plugins/code-server/app/components/LauncherView.vue b/plugins/code-server/app/components/LauncherView.vue index ad162551..b51d4d40 100644 --- a/plugins/code-server/app/components/LauncherView.vue +++ b/plugins/code-server/app/components/LauncherView.vue @@ -10,11 +10,14 @@ const props = defineProps<{ detection: CodeServerDetection server: CodeServerServerInfo busy: boolean + /** Whether a hosting hub can jump to this editor's terminal session. */ + canViewInTerminal?: boolean }>() const emit = defineEmits<{ (e: 'launch'): void (e: 'recheck'): void + (e: 'viewInTerminal'): void }>() const DOCS_URL = 'https://coder.com/docs/code-server/latest/install' @@ -67,6 +70,15 @@ const errorText = computed(() => (props.server.status === 'error' ? props.server {{ detection.mode === 'tunnel' ? 'Opening the tunnel…' : 'Starting the editor…' }} + + View in terminal +
({ status: 'stopped' }) const connect = shallowRef(undefined) const busy = ref(false) + /** Whether a hub with the terminals plugin dock is hosting this panel. */ + const terminalsDockAvailable = ref(false) + + /** + * The editor's launch can be watched live in the terminals dock, but only + * when the process runs as a hub terminal session (`terminalSessionId`) and + * that dock is actually mounted. + */ + const canViewInTerminal = computed(() => + terminalsDockAvailable.value && !!server.terminalSessionId) const phase = computed(() => { if (server.status === 'running') @@ -101,6 +120,18 @@ export function useCodeServer() { } } + /** + * Jump the host shell to this editor's session in the terminals dock. Inert + * unless a hub with the terminals dock is hosting the panel and the process + * runs as a hub terminal session. + */ + async function viewInTerminal(): Promise { + const sessionId = server.terminalSessionId + if (!sessionId) + return + await call(DOCKS_ACTIVATE, { dockId: TERMINALS_DOCK_ID, params: { sessionId } }) + } + async function recheck(): Promise { if (busy.value) return @@ -142,7 +173,35 @@ export function useCodeServer() { connect.value = undefined }) onScopeDispose(() => off?.()) + + await watchTerminalsDock(client) + } + + /** + * Track whether the hosting hub has the terminals plugin dock mounted, so the + * launcher only offers "view in terminal" when the jump can land somewhere. + * Reads the hub's `devframe:docks` slot; outside a hub it never resolves and + * the action stays hidden. + */ + async function watchTerminalsDock(client: NonNullable['value']>): Promise { + interface DockEntry { id?: string } + const hasTerminals = (docks: readonly DockEntry[] | undefined): boolean => + Array.isArray(docks) && docks.some(d => d?.id === TERMINALS_DOCK_ID) + try { + const slot = await client.sharedState.get(DOCKS_STATE_KEY, { initialValue: [] }) as { + value: () => readonly DockEntry[] + on: (event: string, cb: (v: readonly DockEntry[]) => void) => (() => void) | void + } + terminalsDockAvailable.value = hasTerminals(slot.value()) + const off = slot.on('updated', (docks) => { + terminalsDockAvailable.value = hasTerminals(docks) + }) + onScopeDispose(() => off?.()) + } + catch { + // No hub or shared state; the terminals jump simply stays unavailable. + } } - return { detection, server, connect, busy, phase, launch, stop, recheck, bootstrap } + return { detection, server, connect, busy, phase, canViewInTerminal, launch, stop, recheck, viewInTerminal, bootstrap } } diff --git a/plugins/code-server/src/node/supervisor.ts b/plugins/code-server/src/node/supervisor.ts index 36aeef0f..6d903474 100644 --- a/plugins/code-server/src/node/supervisor.ts +++ b/plugins/code-server/src/node/supervisor.ts @@ -207,11 +207,23 @@ export class CodeServerSupervisor { status(): CodeServerStatusResult { return { detection: { ...this.detection }, - server: { ...this.server }, + server: this.serverInfo(), connect: this.connectInfo(), } } + /** + * The server info projected to clients and shared state, tagged with the + * live hub terminal session id when one exists so the launcher can offer a + * "view in terminal" jump. Standalone runtimes hold no session and leave it + * undefined. + */ + private serverInfo(): CodeServerServerInfo { + return this.session + ? { ...this.server, terminalSessionId: this.sessionId } + : { ...this.server } + } + /** * Launch the editor (if not already up) and resolve once it is reachable. * Idempotent while starting/running; returns the live status instead of @@ -669,7 +681,7 @@ export class CodeServerSupervisor { private publish(): void { this.state?.mutate((draft) => { draft.detection = { ...this.detection } - draft.server = { ...this.server } + draft.server = this.serverInfo() }) } diff --git a/plugins/code-server/src/node/types.ts b/plugins/code-server/src/node/types.ts index c2cfab5e..f7960aa5 100644 --- a/plugins/code-server/src/node/types.ts +++ b/plugins/code-server/src/node/types.ts @@ -71,6 +71,14 @@ export interface CodeServerServerInfo { error?: string /** Device-login prompt while a tunnel is authenticating. */ login?: CodeServerLogin + /** + * Id of the hub terminal session mirroring this process, present only when + * the editor was launched through the hub's terminals subsystem + * (`ctx.terminals`). The launcher uses it to jump the user to that session + * in the terminals dock via `hub:docks:activate`; standalone runtimes have + * no hub session and leave it undefined. + */ + terminalSessionId?: string } /** diff --git a/plugins/code-server/test/code-server.test.ts b/plugins/code-server/test/code-server.test.ts index 8e31af6e..a679edd0 100644 --- a/plugins/code-server/test/code-server.test.ts +++ b/plugins/code-server/test/code-server.test.ts @@ -71,6 +71,8 @@ describe('@devframes/plugin-code-server', () => { const result = await supervisor.start() expect(result.server.status).toBe('running') expect(result.server.port).toBeGreaterThan(0) + // No hub: the process runs directly, so there is no terminal session to jump to. + expect(result.server.terminalSessionId).toBeUndefined() expect(result.connect?.cookie?.name).toBe('code-server-session') expect(result.connect?.cookie?.value).toMatch(/^[a-f0-9]{64}$/) expect(result.connect?.path).toBe('/') @@ -212,6 +214,8 @@ describe('@devframes/plugin-code-server', () => { const result = await supervisor.start() expect(result.server.status).toBe('running') + // The launcher jumps to this session in the terminals dock. + expect(result.server.terminalSessionId).toBe(PLUGIN_ID) // Launched through the hub, surfaced as exactly one session. expect(terminals.sessions.size).toBe(1) @@ -243,9 +247,11 @@ describe('@devframes/plugin-code-server', () => { await supervisor.start() const first = terminals.sessions.get(PLUGIN_ID) - supervisor.stop() + const stopped = supervisor.stop() // The session stays visible, marked stopped. expect(terminals.sessions.get(PLUGIN_ID)?.status).toBe('stopped') + // With no live session, the launcher has nothing to jump to. + expect(stopped.server.terminalSessionId).toBeUndefined() // A fresh start replaces the stale session under the same stable id. await supervisor.start()