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
4 changes: 3 additions & 1 deletion plugins/code-server/app/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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"
/>

<ConnectionOverlay :status="connection.status" :error="connection.error" />
Expand Down
15 changes: 15 additions & 0 deletions plugins/code-server/app/components/LauncherView.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
12 changes: 12 additions & 0 deletions plugins/code-server/app/components/LauncherView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -67,6 +70,15 @@ const errorText = computed(() => (props.server.status === 'error' ? props.server
<span class="text-sm color-muted">
{{ detection.mode === 'tunnel' ? 'Opening the tunnel…' : 'Starting the editor…' }}
</span>
<ActionButton
v-if="canViewInTerminal"
variant="text"
size="sm"
icon="i-ph-terminal-window-duotone"
@click="emit('viewInTerminal')"
>
View in terminal
</ActionButton>
</div>
<div
v-if="server.login"
Expand Down
61 changes: 60 additions & 1 deletion plugins/code-server/app/composables/code-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ const STATUS = 'devframes:plugin:code-server:status'
const START = 'devframes:plugin:code-server:start'
const STOP = 'devframes:plugin:code-server:stop'

// Hub integration, addressed by literal so the plugin keeps no dependency on
// `@devframes/hub` and stays inert standalone (the keys/RPC simply never fire).
/** Shared-state slot holding the hub's projected dock entries. */
const DOCKS_STATE_KEY = 'devframe:docks'
/** Terminals feed dock id (mirrors `@devframes/plugin-terminals`'s `PLUGIN_ID`). */
const TERMINALS_DOCK_ID = 'devframes_plugin_terminals'
/** Hub RPC that steers the host shell's focused dock. */
const DOCKS_ACTIVATE = 'hub:docks:activate'

/**
* Live code-server state, driven off the plugin's shared state plus the
* secret-bearing connect info fetched from the `status` RPC. Exposes the
Expand All @@ -36,6 +45,16 @@ export function useCodeServer() {
const server = reactive<CodeServerServerInfo>({ status: 'stopped' })
const connect = shallowRef<CodeServerConnect | undefined>(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<CodeServerPhase>(() => {
if (server.status === 'running')
Expand Down Expand Up @@ -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<void> {
const sessionId = server.terminalSessionId
if (!sessionId)
return
await call(DOCKS_ACTIVATE, { dockId: TERMINALS_DOCK_ID, params: { sessionId } })
}

async function recheck(): Promise<void> {
if (busy.value)
return
Expand Down Expand Up @@ -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<ReturnType<typeof useRpc>['value']>): Promise<void> {
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 }
}
16 changes: 14 additions & 2 deletions plugins/code-server/src/node/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
})
}

Expand Down
8 changes: 8 additions & 0 deletions plugins/code-server/src/node/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
8 changes: 7 additions & 1 deletion plugins/code-server/test/code-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('/')
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Loading