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,201 @@ 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\r\n" ) ;
734+ } ,
735+ close : ( ) => {
736+ closed = true ;
737+ clearTimeout ( forceKillTimer ) ;
738+ sendSignal ( "SIGKILL" ) ;
739+ writeEmitter . dispose ( ) ;
740+ closeEmitter . dispose ( ) ;
741+ } ,
742+ handleInput : ( data : string ) => {
743+ if ( exited ) {
744+ closeEmitter . fire ( ) ;
745+ } else if ( data === "\x03" ) {
746+ if ( forceKillTimer ) {
747+ // Second Ctrl+C: force kill immediately.
748+ clearTimeout ( forceKillTimer ) ;
749+ sendSignal ( "SIGKILL" ) ;
750+ } else {
751+ if ( ! closed ) {
752+ writeEmitter . fire ( "\r\nStopping...\r\n" ) ;
753+ }
754+ gracefulKill ( ) ;
755+ }
756+ }
757+ } ,
758+ } ,
759+ } ) ;
760+
761+ const fireLines = ( data : Buffer ) => {
762+ if ( closed ) {
763+ return ;
764+ }
765+ const lines = data
766+ . toString ( )
767+ . split ( / \r * \n / )
768+ . filter ( ( line ) => line !== "" ) ;
769+ for ( const line of lines ) {
770+ writeEmitter . fire ( line + "\r\n" ) ;
771+ }
772+ } ;
773+
774+ proc . stdout ?. on ( "data" , fireLines ) ;
775+ proc . stderr ?. on ( "data" , fireLines ) ;
776+ proc . on ( "error" , ( err ) => {
777+ exited = true ;
778+ clearTimeout ( forceKillTimer ) ;
779+ if ( closed ) {
780+ return ;
781+ }
782+ writeEmitter . fire ( `\r\nFailed to start: ${ err . message } \r\n` ) ;
783+ writeEmitter . fire ( "Press any key to close.\r\n" ) ;
784+ } ) ;
785+ proc . on ( "close" , ( code , signal ) => {
786+ exited = true ;
787+ clearTimeout ( forceKillTimer ) ;
788+ if ( closed ) {
789+ return ;
790+ }
791+ const reason = signal
792+ ? `Stopped by ${ signal } `
793+ : `Process exited with code ${ code } ` ;
794+ writeEmitter . fire ( `\r\n${ reason } . Press any key to close.\r\n` ) ;
795+ } ) ;
796+
797+ terminal . show ( false ) ;
798+ } ,
799+ ) ;
800+ }
801+
802+ private async resolveCliEnv (
803+ client : CoderApi ,
804+ ) : Promise < { binary : string ; globalFlags : string [ ] } > {
805+ const baseUrl = client . getAxiosInstance ( ) . defaults . baseURL ;
806+ if ( ! baseUrl ) {
807+ throw new Error ( "You are not logged in" ) ;
808+ }
809+ const safeHost = toSafeHost ( baseUrl ) ;
810+ const binary = await this . cliManager . fetchBinary ( client ) ;
811+ const version = semver . parse ( await cliUtils . version ( binary ) ) ;
812+ const featureSet = featureSetForVersion ( version ) ;
813+ const configDir = this . pathResolver . getGlobalConfigDir ( safeHost ) ;
814+ const configs = vscode . workspace . getConfiguration ( ) ;
815+ const auth = resolveCliAuth ( configs , featureSet , baseUrl , configDir ) ;
816+ const globalFlags = getGlobalFlags ( configs , auth ) ;
817+ return { binary, globalFlags } ;
818+ }
819+
664820 /**
665821 * Ask the user to select a workspace. Return undefined if canceled.
666822 */
667- private async pickWorkspace ( ) : Promise < Workspace | undefined > {
823+ private async pickWorkspace ( options ?: {
824+ title ?: string ;
825+ initialValue ?: string ;
826+ placeholder ?: string ;
827+ filter ?: ( w : Workspace ) => boolean ;
828+ } ) : Promise < Workspace | undefined > {
668829 const quickPick = vscode . window . createQuickPick ( ) ;
669- quickPick . value = "owner:me " ;
670- quickPick . placeholder = "owner:me template:go" ;
671- quickPick . title = `Connect to a workspace` ;
830+ quickPick . value = options ?. initialValue ?? "owner:me " ;
831+ quickPick . placeholder = options ?. placeholder ?? "owner:me template:go" ;
832+ quickPick . title = options ?. title ?? "Connect to a workspace" ;
833+ const filter = options ?. filter ;
834+
672835 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 ) => {
836+ const disposables : vscode . Disposable [ ] = [ ] ;
837+ disposables . push (
838+ quickPick . onDidChangeValue ( ( value ) => {
839+ quickPick . busy = true ;
840+ this . extensionClient
841+ . getWorkspaces ( {
842+ q : value ,
843+ } )
844+ . then ( ( workspaces ) => {
845+ const filtered = filter
846+ ? workspaces . workspaces . filter ( filter )
847+ : workspaces . workspaces ;
848+ lastWorkspaces = filtered ;
849+ quickPick . items = filtered . map ( ( workspace ) => {
683850 let icon = "$(debug-start)" ;
684851 if ( workspace . latest_build . status !== "running" ) {
685852 icon = "$(debug-stop)" ;
@@ -692,32 +859,40 @@ export class Commands {
692859 label : `${ icon } ${ workspace . owner_name } / ${ workspace . name } ` ,
693860 detail : `Template: ${ workspace . template_display_name || workspace . template_name } • Status: ${ status } ` ,
694861 } ;
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- } ) ;
862+ } ) ;
863+ } )
864+ . catch ( ( ex ) => {
865+ this . logger . error ( "Failed to fetch workspaces" , ex ) ;
866+ if ( ex instanceof CertificateError ) {
867+ void ex . showNotification ( ) ;
868+ }
869+ } )
870+ . finally ( ( ) => {
871+ quickPick . busy = false ;
872+ } ) ;
873+ } ) ,
874+ ) ;
875+
709876 quickPick . show ( ) ;
710877 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- } ) ;
878+ disposables . push (
879+ quickPick . onDidHide ( ( ) => {
880+ resolve ( undefined ) ;
881+ } ) ,
882+ quickPick . onDidChangeSelection ( ( selected ) => {
883+ if ( selected . length < 1 ) {
884+ return resolve ( undefined ) ;
885+ }
886+ const workspace =
887+ lastWorkspaces [ quickPick . items . indexOf ( selected [ 0 ] ) ] ;
888+ resolve ( workspace ) ;
889+ } ) ,
890+ ) ;
891+ } ) . finally ( ( ) => {
892+ for ( const d of disposables ) {
893+ d . dispose ( ) ;
894+ }
895+ quickPick . dispose ( ) ;
721896 } ) ;
722897 }
723898
0 commit comments