22 type Workspace ,
33 type WorkspaceAgent ,
44} from "coder/site/src/api/typesGenerated" ;
5+ import { spawn } from "node:child_process" ;
56import * as fs from "node:fs/promises" ;
67import * as path from "node:path" ;
78import * as semver from "semver" ;
@@ -491,21 +492,11 @@ export class Commands {
491492 title : `Connecting to AI Agent...` ,
492493 } ,
493494 async ( ) => {
494- const terminal = vscode . window . createTerminal ( app . name ) ;
495-
496- // If workspace_name is provided, run coder ssh before the command
497- const baseUrl = this . requireExtensionBaseUrl ( ) ;
498- const safeHost = toSafeHost ( baseUrl ) ;
499- const binary = await this . cliManager . fetchBinary (
495+ const { binary, globalFlags } = await this . resolveCliEnv (
500496 this . extensionClient ,
501497 ) ;
502498
503- const version = semver . parse ( await cliUtils . version ( binary ) ) ;
504- const featureSet = featureSetForVersion ( version ) ;
505- const configDir = this . pathResolver . getGlobalConfigDir ( safeHost ) ;
506- const configs = vscode . workspace . getConfiguration ( ) ;
507- const auth = resolveCliAuth ( configs , featureSet , baseUrl , configDir ) ;
508- const globalFlags = getGlobalFlags ( configs , auth ) ;
499+ const terminal = vscode . window . createTerminal ( app . name ) ;
509500 terminal . sendText (
510501 `${ escapeCommandArg ( binary ) } ${ globalFlags . join ( " " ) } ssh ${ app . workspace_name } ` ,
511502 ) ;
@@ -661,25 +652,207 @@ export class Commands {
661652 }
662653 }
663654
655+ public async pingWorkspace ( item ?: OpenableTreeItem ) : Promise < void > {
656+ let client : CoderApi ;
657+ let workspaceId : string ;
658+
659+ if ( item ) {
660+ client = this . extensionClient ;
661+ workspaceId = createWorkspaceIdentifier ( item . workspace ) ;
662+ } else if ( this . workspace && this . remoteWorkspaceClient ) {
663+ client = this . remoteWorkspaceClient ;
664+ workspaceId = createWorkspaceIdentifier ( this . workspace ) ;
665+ } else {
666+ client = this . extensionClient ;
667+ const workspace = await this . pickWorkspace ( {
668+ title : "Ping a running workspace" ,
669+ initialValue : "owner:me status:running " ,
670+ filter : ( w ) => w . latest_build . status === "running" ,
671+ } ) ;
672+ if ( ! workspace ) {
673+ return ;
674+ }
675+ workspaceId = createWorkspaceIdentifier ( workspace ) ;
676+ }
677+
678+ return this . spawnPing ( client , workspaceId ) ;
679+ }
680+
681+ private spawnPing ( client : CoderApi , workspaceId : string ) : Thenable < void > {
682+ return withProgress (
683+ {
684+ location : vscode . ProgressLocation . Notification ,
685+ title : `Starting ping for ${ workspaceId } ...` ,
686+ } ,
687+ async ( ) => {
688+ const { binary, globalFlags } = await this . resolveCliEnv ( client ) ;
689+
690+ const writeEmitter = new vscode . EventEmitter < string > ( ) ;
691+ const closeEmitter = new vscode . EventEmitter < number | void > ( ) ;
692+
693+ const args = [ ...globalFlags , "ping" , escapeCommandArg ( workspaceId ) ] ;
694+ const cmd = `${ escapeCommandArg ( binary ) } ${ args . join ( " " ) } ` ;
695+ // On Unix, spawn in a new process group so we can signal the
696+ // entire group (shell + coder binary) on Ctrl+C. On Windows,
697+ // detached opens a visible console window and negative-PID kill
698+ // is unsupported, so we fall back to proc.kill().
699+ const useProcessGroup = process . platform !== "win32" ;
700+ const proc = spawn ( cmd , {
701+ shell : true ,
702+ detached : useProcessGroup ,
703+ } ) ;
704+
705+ let closed = false ;
706+ let exited = false ;
707+ let forceKillTimer : ReturnType < typeof setTimeout > | undefined ;
708+
709+ const sendSignal = ( sig : "SIGINT" | "SIGKILL" ) => {
710+ try {
711+ if ( useProcessGroup && proc . pid ) {
712+ process . kill ( - proc . pid , sig ) ;
713+ } else {
714+ proc . kill ( sig ) ;
715+ }
716+ } catch {
717+ // Process already exited.
718+ }
719+ } ;
720+
721+ const gracefulKill = ( ) => {
722+ sendSignal ( "SIGINT" ) ;
723+ // Escalate to SIGKILL if the process doesn't exit promptly.
724+ forceKillTimer = setTimeout ( ( ) => sendSignal ( "SIGKILL" ) , 5000 ) ;
725+ } ;
726+
727+ const terminal = vscode . window . createTerminal ( {
728+ name : `Coder Ping: ${ workspaceId } ` ,
729+ pty : {
730+ onDidWrite : writeEmitter . event ,
731+ onDidClose : closeEmitter . event ,
732+ open : ( ) => {
733+ writeEmitter . fire ( "Press Ctrl+C (^C) to stop.\r\n" ) ;
734+ writeEmitter . fire ( "─" . repeat ( 40 ) + "\r\n" ) ;
735+ } ,
736+ close : ( ) => {
737+ closed = true ;
738+ clearTimeout ( forceKillTimer ) ;
739+ sendSignal ( "SIGKILL" ) ;
740+ writeEmitter . dispose ( ) ;
741+ closeEmitter . dispose ( ) ;
742+ } ,
743+ handleInput : ( data : string ) => {
744+ if ( exited ) {
745+ closeEmitter . fire ( ) ;
746+ } else if ( data === "\x03" ) {
747+ if ( forceKillTimer ) {
748+ // Second Ctrl+C: force kill immediately.
749+ clearTimeout ( forceKillTimer ) ;
750+ sendSignal ( "SIGKILL" ) ;
751+ } else {
752+ if ( ! closed ) {
753+ writeEmitter . fire ( "\r\nStopping...\r\n" ) ;
754+ }
755+ gracefulKill ( ) ;
756+ }
757+ }
758+ } ,
759+ } ,
760+ } ) ;
761+
762+ const fireLines = ( data : Buffer ) => {
763+ if ( closed ) {
764+ return ;
765+ }
766+ const lines = data
767+ . toString ( )
768+ . split ( / \r * \n / )
769+ . filter ( ( line ) => line !== "" ) ;
770+ for ( const line of lines ) {
771+ writeEmitter . fire ( line + "\r\n" ) ;
772+ }
773+ } ;
774+
775+ proc . stdout ?. on ( "data" , fireLines ) ;
776+ proc . stderr ?. on ( "data" , fireLines ) ;
777+ proc . on ( "error" , ( err ) => {
778+ exited = true ;
779+ clearTimeout ( forceKillTimer ) ;
780+ if ( closed ) {
781+ return ;
782+ }
783+ writeEmitter . fire ( `\r\nFailed to start: ${ err . message } \r\n` ) ;
784+ writeEmitter . fire ( "Press any key to close.\r\n" ) ;
785+ } ) ;
786+ proc . on ( "close" , ( code , signal ) => {
787+ exited = true ;
788+ clearTimeout ( forceKillTimer ) ;
789+ if ( closed ) {
790+ return ;
791+ }
792+ let reason : string ;
793+ if ( signal === "SIGKILL" ) {
794+ reason = "Ping force killed (SIGKILL)" ;
795+ } else if ( signal ) {
796+ reason = "Ping stopped" ;
797+ } else {
798+ reason = `Process exited with code ${ code } ` ;
799+ }
800+ writeEmitter . fire ( `\r\n${ reason } . Press any key to close.\r\n` ) ;
801+ } ) ;
802+
803+ terminal . show ( false ) ;
804+ } ,
805+ ) ;
806+ }
807+
808+ private async resolveCliEnv (
809+ client : CoderApi ,
810+ ) : Promise < { binary : string ; globalFlags : string [ ] } > {
811+ const baseUrl = client . getAxiosInstance ( ) . defaults . baseURL ;
812+ if ( ! baseUrl ) {
813+ throw new Error ( "You are not logged in" ) ;
814+ }
815+ const safeHost = toSafeHost ( baseUrl ) ;
816+ const binary = await this . cliManager . fetchBinary ( client ) ;
817+ const version = semver . parse ( await cliUtils . version ( binary ) ) ;
818+ const featureSet = featureSetForVersion ( version ) ;
819+ const configDir = this . pathResolver . getGlobalConfigDir ( safeHost ) ;
820+ const configs = vscode . workspace . getConfiguration ( ) ;
821+ const auth = resolveCliAuth ( configs , featureSet , baseUrl , configDir ) ;
822+ const globalFlags = getGlobalFlags ( configs , auth ) ;
823+ return { binary, globalFlags } ;
824+ }
825+
664826 /**
665827 * Ask the user to select a workspace. Return undefined if canceled.
666828 */
667- private async pickWorkspace ( ) : Promise < Workspace | undefined > {
829+ private async pickWorkspace ( options ?: {
830+ title ?: string ;
831+ initialValue ?: string ;
832+ placeholder ?: string ;
833+ filter ?: ( w : Workspace ) => boolean ;
834+ } ) : Promise < Workspace | undefined > {
668835 const quickPick = vscode . window . createQuickPick ( ) ;
669- quickPick . value = "owner:me " ;
670- quickPick . placeholder = "owner:me template:go" ;
671- quickPick . title = `Connect to a workspace` ;
836+ quickPick . value = options ?. initialValue ?? "owner:me " ;
837+ quickPick . placeholder = options ?. placeholder ?? "owner:me template:go" ;
838+ quickPick . title = options ?. title ?? "Connect to a workspace" ;
839+ const filter = options ?. filter ;
840+
672841 let lastWorkspaces : readonly Workspace [ ] ;
673- quickPick . onDidChangeValue ( ( value ) => {
674- quickPick . busy = true ;
675- this . extensionClient
676- . getWorkspaces ( {
677- q : value ,
678- } )
679- . then ( ( workspaces ) => {
680- lastWorkspaces = workspaces . workspaces ;
681- const items : vscode . QuickPickItem [ ] = workspaces . workspaces . map (
682- ( workspace ) => {
842+ const disposables : vscode . Disposable [ ] = [ ] ;
843+ disposables . push (
844+ quickPick . onDidChangeValue ( ( value ) => {
845+ quickPick . busy = true ;
846+ this . extensionClient
847+ . getWorkspaces ( {
848+ q : value ,
849+ } )
850+ . then ( ( workspaces ) => {
851+ const filtered = filter
852+ ? workspaces . workspaces . filter ( filter )
853+ : workspaces . workspaces ;
854+ lastWorkspaces = filtered ;
855+ quickPick . items = filtered . map ( ( workspace ) => {
683856 let icon = "$(debug-start)" ;
684857 if ( workspace . latest_build . status !== "running" ) {
685858 icon = "$(debug-stop)" ;
@@ -692,32 +865,40 @@ export class Commands {
692865 label : `${ icon } ${ workspace . owner_name } / ${ workspace . name } ` ,
693866 detail : `Template: ${ workspace . template_display_name || workspace . template_name } • Status: ${ status } ` ,
694867 } ;
695- } ,
696- ) ;
697- quickPick . items = items ;
698- } )
699- . catch ( ( ex ) => {
700- this . logger . error ( "Failed to fetch workspaces" , ex ) ;
701- if ( ex instanceof CertificateError ) {
702- void ex . showNotification ( ) ;
703- }
704- } )
705- . finally ( ( ) => {
706- quickPick . busy = false ;
707- } ) ;
708- } ) ;
868+ } ) ;
869+ } )
870+ . catch ( ( ex ) => {
871+ this . logger . error ( "Failed to fetch workspaces" , ex ) ;
872+ if ( ex instanceof CertificateError ) {
873+ void ex . showNotification ( ) ;
874+ }
875+ } )
876+ . finally ( ( ) => {
877+ quickPick . busy = false ;
878+ } ) ;
879+ } ) ,
880+ ) ;
881+
709882 quickPick . show ( ) ;
710883 return new Promise < Workspace | undefined > ( ( resolve ) => {
711- quickPick . onDidHide ( ( ) => {
712- resolve ( undefined ) ;
713- } ) ;
714- quickPick . onDidChangeSelection ( ( selected ) => {
715- if ( selected . length < 1 ) {
716- return resolve ( undefined ) ;
717- }
718- const workspace = lastWorkspaces [ quickPick . items . indexOf ( selected [ 0 ] ) ] ;
719- resolve ( workspace ) ;
720- } ) ;
884+ disposables . push (
885+ quickPick . onDidHide ( ( ) => {
886+ resolve ( undefined ) ;
887+ } ) ,
888+ quickPick . onDidChangeSelection ( ( selected ) => {
889+ if ( selected . length < 1 ) {
890+ return resolve ( undefined ) ;
891+ }
892+ const workspace =
893+ lastWorkspaces [ quickPick . items . indexOf ( selected [ 0 ] ) ] ;
894+ resolve ( workspace ) ;
895+ } ) ,
896+ ) ;
897+ } ) . finally ( ( ) => {
898+ for ( const d of disposables ) {
899+ d . dispose ( ) ;
900+ }
901+ quickPick . dispose ( ) ;
721902 } ) ;
722903 }
723904
0 commit comments