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,180 @@ 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+
708+ const killProc = ( ) => {
709+ try {
710+ if ( useProcessGroup && proc . pid ) {
711+ process . kill ( - proc . pid , "SIGINT" ) ;
712+ } else {
713+ proc . kill ( ) ;
714+ }
715+ } catch {
716+ // Process already exited.
717+ }
718+ } ;
719+
720+ const terminal = vscode . window . createTerminal ( {
721+ name : `Coder Ping: ${ workspaceId } ` ,
722+ pty : {
723+ onDidWrite : writeEmitter . event ,
724+ onDidClose : closeEmitter . event ,
725+ open : ( ) => undefined ,
726+ close : ( ) => {
727+ closed = true ;
728+ killProc ( ) ;
729+ writeEmitter . dispose ( ) ;
730+ closeEmitter . dispose ( ) ;
731+ } ,
732+ handleInput : ( data : string ) => {
733+ if ( exited ) {
734+ closeEmitter . fire ( ) ;
735+ } else if ( data === "\x03" ) {
736+ killProc ( ) ;
737+ }
738+ } ,
739+ } ,
740+ } ) ;
741+
742+ const fireLines = ( data : Buffer ) => {
743+ if ( closed ) {
744+ return ;
745+ }
746+ const lines = data
747+ . toString ( )
748+ . split ( / \r * \n / )
749+ . filter ( ( line ) => line !== "" ) ;
750+ for ( const line of lines ) {
751+ writeEmitter . fire ( line + "\r\n" ) ;
752+ }
753+ } ;
754+
755+ proc . stdout ?. on ( "data" , fireLines ) ;
756+ proc . stderr ?. on ( "data" , fireLines ) ;
757+ proc . on ( "error" , ( err ) => {
758+ exited = true ;
759+ if ( closed ) {
760+ return ;
761+ }
762+ writeEmitter . fire ( `\r\nFailed to start: ${ err . message } \r\n` ) ;
763+ writeEmitter . fire ( "Press any key to close.\r\n" ) ;
764+ } ) ;
765+ proc . on ( "close" , ( code ) => {
766+ exited = true ;
767+ if ( closed ) {
768+ return ;
769+ }
770+ writeEmitter . fire (
771+ `\r\nProcess exited with code ${ code } . Press any key to close.\r\n` ,
772+ ) ;
773+ } ) ;
774+
775+ writeEmitter . fire ( "Press Ctrl+C (^C) to stop.\r\n\r\n" ) ;
776+ terminal . show ( false ) ;
777+ } ,
778+ ) ;
779+ }
780+
781+ private async resolveCliEnv (
782+ client : CoderApi ,
783+ ) : Promise < { binary : string ; globalFlags : string [ ] } > {
784+ const baseUrl = client . getAxiosInstance ( ) . defaults . baseURL ;
785+ if ( ! baseUrl ) {
786+ throw new Error ( "You are not logged in" ) ;
787+ }
788+ const safeHost = toSafeHost ( baseUrl ) ;
789+ const binary = await this . cliManager . fetchBinary ( client ) ;
790+ const version = semver . parse ( await cliUtils . version ( binary ) ) ;
791+ const featureSet = featureSetForVersion ( version ) ;
792+ const configDir = this . pathResolver . getGlobalConfigDir ( safeHost ) ;
793+ const configs = vscode . workspace . getConfiguration ( ) ;
794+ const auth = resolveCliAuth ( configs , featureSet , baseUrl , configDir ) ;
795+ const globalFlags = getGlobalFlags ( configs , auth ) ;
796+ return { binary, globalFlags } ;
797+ }
798+
664799 /**
665800 * Ask the user to select a workspace. Return undefined if canceled.
666801 */
667- private async pickWorkspace ( ) : Promise < Workspace | undefined > {
802+ private async pickWorkspace ( options ?: {
803+ title ?: string ;
804+ initialValue ?: string ;
805+ placeholder ?: string ;
806+ filter ?: ( w : Workspace ) => boolean ;
807+ } ) : Promise < Workspace | undefined > {
668808 const quickPick = vscode . window . createQuickPick ( ) ;
669- quickPick . value = "owner:me " ;
670- quickPick . placeholder = "owner:me template:go" ;
671- quickPick . title = `Connect to a workspace` ;
809+ quickPick . value = options ?. initialValue ?? "owner:me " ;
810+ quickPick . placeholder = options ?. placeholder ?? "owner:me template:go" ;
811+ quickPick . title = options ?. title ?? "Connect to a workspace" ;
812+ const filter = options ?. filter ;
813+
672814 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 ) => {
815+ const disposables : vscode . Disposable [ ] = [ ] ;
816+ disposables . push (
817+ quickPick . onDidChangeValue ( ( value ) => {
818+ quickPick . busy = true ;
819+ this . extensionClient
820+ . getWorkspaces ( {
821+ q : value ,
822+ } )
823+ . then ( ( workspaces ) => {
824+ const filtered = filter
825+ ? workspaces . workspaces . filter ( filter )
826+ : workspaces . workspaces ;
827+ lastWorkspaces = filtered ;
828+ quickPick . items = filtered . map ( ( workspace ) => {
683829 let icon = "$(debug-start)" ;
684830 if ( workspace . latest_build . status !== "running" ) {
685831 icon = "$(debug-stop)" ;
@@ -692,32 +838,40 @@ export class Commands {
692838 label : `${ icon } ${ workspace . owner_name } / ${ workspace . name } ` ,
693839 detail : `Template: ${ workspace . template_display_name || workspace . template_name } • Status: ${ status } ` ,
694840 } ;
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- } ) ;
841+ } ) ;
842+ } )
843+ . catch ( ( ex ) => {
844+ this . logger . error ( "Failed to fetch workspaces" , ex ) ;
845+ if ( ex instanceof CertificateError ) {
846+ void ex . showNotification ( ) ;
847+ }
848+ } )
849+ . finally ( ( ) => {
850+ quickPick . busy = false ;
851+ } ) ;
852+ } ) ,
853+ ) ;
854+
709855 quickPick . show ( ) ;
710856 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- } ) ;
857+ disposables . push (
858+ quickPick . onDidHide ( ( ) => {
859+ resolve ( undefined ) ;
860+ } ) ,
861+ quickPick . onDidChangeSelection ( ( selected ) => {
862+ if ( selected . length < 1 ) {
863+ return resolve ( undefined ) ;
864+ }
865+ const workspace =
866+ lastWorkspaces [ quickPick . items . indexOf ( selected [ 0 ] ) ] ;
867+ resolve ( workspace ) ;
868+ } ) ,
869+ ) ;
870+ } ) . finally ( ( ) => {
871+ for ( const d of disposables ) {
872+ d . dispose ( ) ;
873+ }
874+ quickPick . dispose ( ) ;
721875 } ) ;
722876 }
723877
0 commit comments