@@ -51,9 +51,13 @@ import {
5151} from "./supportBundle/remoteServerDataPath" ;
5252import { runExportTelemetryCommand } from "./telemetry/export/command" ;
5353import {
54+ LegacyEditorId ,
55+ currentEditorId ,
5456 hostEditorId ,
5557 isRemoteAuthorityCompatible ,
5658 parseRemoteAuthority ,
59+ sshHostOf ,
60+ toLegacyAuthority ,
5761 toRemoteAuthority ,
5862} from "./util/authority" ;
5963import { openInBrowser , toSafeHost } from "./util/uri" ;
@@ -88,6 +92,12 @@ import type {
8892 PongMessage ,
8993} from "./workspace/duplicateWorkspaceIpc" ;
9094
95+ /** One entry from the private `_workbench.getRecentlyOpened` command. */
96+ interface RecentlyOpened {
97+ folderUri ?: vscode . Uri ;
98+ workspace ?: { configPath ?: vscode . Uri } ;
99+ }
100+
91101const NO_SSH_CONFIG_MESSAGE =
92102 "No SSH config has been generated yet. It is written when you connect to a workspace." ;
93103
@@ -573,15 +583,12 @@ export class Commands {
573583 ) ;
574584 }
575585
576- /** Open this editor's generated SSH config, picking a deployment when several exist. */
586+ /** Open the generated SSH config, picking a file when several exist. */
577587 public async openSshConfig ( ) : Promise < void > {
578- let configPath = this . connectedSshConfigPath ( ) ;
588+ const configPath =
589+ this . connectedSshConfigPath ( ) ?? ( await this . pickSshConfigPath ( ) ) ;
579590 if ( ! configPath ) {
580- const hostname = await this . pickSshHostname ( ) ;
581- if ( ! hostname ) {
582- return ;
583- }
584- configPath = this . pathResolver . getSshConfigPath ( hostname ) ;
591+ return ;
585592 }
586593 try {
587594 await openFile ( configPath ) ;
@@ -613,24 +620,33 @@ export class Commands {
613620 }
614621 }
615622
616- /** Ask which deployment, when this editor has generated more than one config. */
617- private async pickSshHostname ( ) : Promise < string | undefined > {
618- const hostnames = (
619- await readdirOrEmpty ( this . pathResolver . getSshConfigDir ( ) )
620- )
621- . map ( ( file ) => this . pathResolver . parseSshConfigFile ( file ) )
622- . filter ( ( name ) => name !== undefined ) ;
623- if ( hostnames . length === 0 ) {
623+ /** Ask which config to open, of this editor's prefixes and the legacy one. */
624+ private async pickSshConfigPath ( ) : Promise < string | undefined > {
625+ const files = await readdirOrEmpty ( this . pathResolver . getSshConfigDir ( ) ) ;
626+ const items = [ ...new Set ( [ currentEditorId ( ) , LegacyEditorId ] ) ] . flatMap (
627+ ( editorId ) =>
628+ files
629+ . map ( ( file ) => this . pathResolver . parseSshConfigFile ( file , editorId ) )
630+ . filter ( ( safeHostname ) => safeHostname !== undefined )
631+ . map ( ( safeHostname ) => ( {
632+ label : safeHostname ,
633+ // Which hosts it serves; two files can share a deployment.
634+ description : `coder-${ editorId } .${ safeHostname } --*` ,
635+ path : this . pathResolver . getSshConfigPath ( safeHostname , editorId ) ,
636+ } ) ) ,
637+ ) ;
638+ if ( items . length === 0 ) {
624639 vscode . window . showInformationMessage ( NO_SSH_CONFIG_MESSAGE ) ;
625640 return undefined ;
626641 }
627- if ( hostnames . length === 1 ) {
628- return hostnames [ 0 ] ;
642+ if ( items . length === 1 ) {
643+ return items [ 0 ] . path ;
629644 }
630- return vscode . window . showQuickPick ( hostnames , {
645+ const picked = await vscode . window . showQuickPick ( items , {
631646 title : "Open generated SSH configuration" ,
632647 placeHolder : "Select a deployment" ,
633648 } ) ;
649+ return picked ?. path ;
634650 }
635651
636652 /**
@@ -1118,6 +1134,39 @@ export class Commands {
11181134 ) ;
11191135 }
11201136
1137+ /** Recently opened folders, and every entry including workspace files. */
1138+ private async recentlyOpened ( ) : Promise < {
1139+ folders : vscode . Uri [ ] ;
1140+ entries : vscode . Uri [ ] ;
1141+ } > {
1142+ let recents : RecentlyOpened [ ] = [ ] ;
1143+ try {
1144+ // Private command; without it there is just no history to reuse.
1145+ const output : { workspaces ?: RecentlyOpened [ ] } =
1146+ await vscode . commands . executeCommand ( "_workbench.getRecentlyOpened" ) ;
1147+ recents = output ?. workspaces ?? [ ] ;
1148+ } catch ( error ) {
1149+ this . logger . warn ( "Failed to read recently opened folders" , error ) ;
1150+ }
1151+ return {
1152+ folders : recents . flatMap ( ( recent ) => recent . folderUri ?? [ ] ) ,
1153+ entries : recents . flatMap (
1154+ ( recent ) => recent . folderUri ?? recent . workspace ?. configPath ?? [ ] ,
1155+ ) ,
1156+ } ;
1157+ }
1158+
1159+ /** The host this workspace was last opened on, or this editor's own. */
1160+ private reusableAuthority ( recents : vscode . Uri [ ] , target : string ) : string {
1161+ const legacyAuthority = toLegacyAuthority ( target ) ;
1162+ const legacyHost = sshHostOf ( legacyAuthority ) ;
1163+ const currentHost = sshHostOf ( target ) ;
1164+ const lastUsed = recents
1165+ . map ( ( uri ) => sshHostOf ( uri . authority ) )
1166+ . find ( ( host ) => host === currentHost || host === legacyHost ) ;
1167+ return lastUsed === legacyHost ? legacyAuthority : target ;
1168+ }
1169+
11211170 private async runOpenDevContainer (
11221171 workspaceOwner : string ,
11231172 workspaceName : string ,
@@ -1155,20 +1204,25 @@ export class Commands {
11551204 ) . toString ( "hex" ) ;
11561205
11571206 const type = localWorkspaceFolder ? "dev-container" : "attached-container" ;
1158- const devContainerAuthority = `${ type } +${ devContainer } @${ remoteAuthority } ` ;
1207+ const target = `${ type } +${ devContainer } @${ remoteAuthority } ` ;
11591208
11601209 let newWindow = true ;
11611210 if ( ! vscode . workspace . workspaceFolders ?. length ) {
11621211 newWindow = false ;
11631212 }
11641213
1214+ const { entries } = await this . recentlyOpened ( ) ;
1215+ const authority = this . reusableAuthority ( entries , target ) ;
1216+
1217+ this . logger . info ( "Opening devcontainer" , { remoteAuthority : authority } ) ;
1218+
11651219 // Only set the memento when opening a new folder
11661220 await this . mementoManager . setStartupMode ( "start" ) ;
11671221 await vscode . commands . executeCommand (
11681222 "vscode.openFolder" ,
11691223 vscode . Uri . from ( {
11701224 scheme : "vscode-remote" ,
1171- authority : devContainerAuthority ,
1225+ authority,
11721226 path : devContainerFolder ,
11731227 } ) ,
11741228 newWindow ,
@@ -1263,7 +1317,7 @@ export class Commands {
12631317 agentName,
12641318 client : this . extensionClient ,
12651319 workspaceId : createWorkspaceIdentifier ( item . workspace ) ,
1266- remoteAuthority : this . toWorkspaceAuthority (
1320+ remoteAuthority : await this . toWorkspaceAuthority (
12671321 this . extensionClient ,
12681322 item . workspace ,
12691323 agentName ,
@@ -1290,7 +1344,7 @@ export class Commands {
12901344 status : "selected" ,
12911345 client : this . extensionClient ,
12921346 workspaceId : createWorkspaceIdentifier ( pick . workspace ) ,
1293- remoteAuthority : this . toWorkspaceAuthority (
1347+ remoteAuthority : await this . toWorkspaceAuthority (
12941348 this . extensionClient ,
12951349 pick . workspace ,
12961350 ) ,
@@ -1303,20 +1357,24 @@ export class Commands {
13031357 * Reconstruct the authority Remote-SSH would use, defaulting to the
13041358 * first agent like the CLI does.
13051359 */
1306- private toWorkspaceAuthority (
1360+ private async toWorkspaceAuthority (
13071361 client : CoderApi ,
13081362 workspace : Workspace ,
13091363 agentName ?: string ,
1310- ) : string | undefined {
1364+ ) : Promise < string | undefined > {
13111365 const baseUrl = client . getAxiosInstance ( ) . defaults . baseURL ;
13121366 if ( ! baseUrl ) {
13131367 return undefined ;
13141368 }
1315- return toRemoteAuthority (
1316- baseUrl ,
1317- workspace . owner_name ,
1318- workspace . name ,
1319- agentName ?? extractAgents ( workspace . latest_build . resources ) [ 0 ] ?. name ,
1369+ const { entries } = await this . recentlyOpened ( ) ;
1370+ return this . reusableAuthority (
1371+ entries ,
1372+ toRemoteAuthority (
1373+ baseUrl ,
1374+ workspace . owner_name ,
1375+ workspace . name ,
1376+ agentName ?? extractAgents ( workspace . latest_build . resources ) [ 0 ] ?. name ,
1377+ ) ,
13201378 ) ;
13211379 }
13221380
@@ -1537,38 +1595,35 @@ export class Commands {
15371595 folderPath = agent . expanded_directory ;
15381596 }
15391597
1598+ const { folders, entries } = await this . recentlyOpened ( ) ;
15401599 // If the agent had no folder or we have been asked to open the most recent,
15411600 // we can try to open a recently opened folder/workspace.
15421601 if ( ! folderPath || openRecent ) {
1543- const output : {
1544- workspaces : Array < { folderUri : vscode . Uri ; remoteAuthority : string } > ;
1545- } = await vscode . commands . executeCommand ( "_workbench.getRecentlyOpened" ) ;
1546- const opened = output . workspaces . filter ( ( opened ) =>
1547- isRemoteAuthorityCompatible (
1548- opened . folderUri ?. authority ,
1549- remoteAuthority ,
1602+ // One entry per folder: the same path can be in the list once per host.
1603+ const paths = [
1604+ ...new Set (
1605+ folders
1606+ . filter ( ( uri ) =>
1607+ isRemoteAuthorityCompatible ( uri . authority , remoteAuthority ) ,
1608+ )
1609+ . map ( ( uri ) => uri . path ) ,
15501610 ) ,
1551- ) ;
1611+ ] ;
15521612 // openRecent will always use the most recent. Otherwise, if there are
15531613 // multiple we ask the user which to use.
1554- if ( opened . length === 1 || ( opened . length > 1 && openRecent ) ) {
1555- folderPath = opened [ 0 ] . folderUri . path ;
1556- } else if ( opened . length > 1 ) {
1557- const items = opened . map ( ( f ) => f . folderUri . path ) ;
1558- folderPath = await vscode . window . showQuickPick ( items , {
1614+ if ( paths . length === 1 || ( paths . length > 1 && openRecent ) ) {
1615+ folderPath = paths [ 0 ] ;
1616+ } else if ( paths . length > 1 ) {
1617+ folderPath = await vscode . window . showQuickPick ( paths , {
15591618 title : "Select a recently opened folder" ,
15601619 } ) ;
15611620 if ( ! folderPath ) {
15621621 // User aborted.
15631622 return { status : "cancelled" , stage : "recent_folder_picker" } ;
15641623 }
15651624 }
1566- // A compatible folder can still be on the legacy coder-vscode host.
1567- // Reopen it there, since the editor keys window state by the whole URI.
1568- remoteAuthority =
1569- opened . find ( ( f ) => f . folderUri . path === folderPath ) ?. folderUri
1570- . authority ?? remoteAuthority ;
15711625 }
1626+ remoteAuthority = this . reusableAuthority ( entries , remoteAuthority ) ;
15721627
15731628 // Only set the memento when opening a new folder/window
15741629 await this . mementoManager . setStartupMode ( "start" ) ;
0 commit comments