Skip to content

Commit aa5b158

Browse files
feat(S3): select recent folders in "Upload Files" wizard #3183
Problem: "AWS: Upload Files..." command only allows selecting a bucket, not a folder. Solution: - Remember the last-expanded and last-uploaded-to folders. - Show them in the wizard. Co-authored-by: Justin M. Keyes <[email protected]>
1 parent b20e150 commit aa5b158

File tree

4 files changed

+128
-15
lines changed

4 files changed

+128
-15
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Feature",
3+
"description": "S3: Choose last-touched and last-uploaded-to S3 folders in the Upload Files wizard."
4+
}

src/awsexplorer/activation.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { once } from '../shared/utilities/functionUtils'
3131
import { Auth, AuthNode } from '../credentials/auth'
3232
import { CodeCatalystRootNode } from '../codecatalyst/explorer'
3333
import { CodeCatalystAuthenticationProvider } from '../codecatalyst/auth'
34+
import { S3FolderNode } from '../s3/explorer/s3FolderNode'
3435

3536
/**
3637
* Activates the AWS Explorer UI and related functionality.
@@ -47,6 +48,14 @@ export async function activate(args: {
4748
treeDataProvider: awsExplorer,
4849
showCollapseAll: true,
4950
})
51+
view.onDidExpandElement(element => {
52+
if (element.element instanceof S3FolderNode) {
53+
globals.context.globalState.update('aws.lastTouchedS3Folder', {
54+
bucket: element.element.bucket,
55+
folder: element.element.folder,
56+
})
57+
}
58+
})
5059
globals.context.subscriptions.push(view)
5160

5261
await registerAwsExplorerCommands(args.context, awsExplorer, args.toolkitOutputChannel)

src/s3/commands/uploadFile.ts

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import * as path from 'path'
77
import * as mime from 'mime-types'
88
import * as vscode from 'vscode'
9+
import * as semver from 'semver'
910
import { statSync } from 'fs'
1011
import { S3 } from 'aws-sdk'
1112
import { getLogger } from '../../shared/logger'
@@ -16,7 +17,7 @@ import { localize } from '../../shared/utilities/vsCodeUtils'
1617
import { showOutputMessage } from '../../shared/utilities/messages'
1718
import { createQuickPick, promptUser, verifySinglePickerOutput } from '../../shared/ui/picker'
1819
import { addCodiconToString } from '../../shared/utilities/textUtilities'
19-
import { S3Client } from '../../shared/clients/s3Client'
20+
import { Bucket, Folder, S3Client } from '../../shared/clients/s3Client'
2021
import { createBucketCommand } from './createBucket'
2122
import { S3BucketNode } from '../explorer/s3BucketNode'
2223
import { S3FolderNode } from '../explorer/s3FolderNode'
@@ -102,6 +103,12 @@ export async function uploadFileCommand(
102103
return fileToUploadRequest(node!.bucket.name, key, file)
103104
})
104105
)
106+
if (node instanceof S3FolderNode) {
107+
globals.context.globalState.update('aws.lastUploadedToS3Folder', {
108+
bucket: node.bucket,
109+
folder: node.folder,
110+
})
111+
}
105112
} else {
106113
while (true) {
107114
const filesToUpload = await getFile(document)
@@ -145,18 +152,28 @@ export async function uploadFileCommand(
145152
return
146153
}
147154

148-
const bucketName = bucketResponse.Name
155+
const bucketName = bucketResponse.bucket!.Name
149156
if (!bucketName) {
150157
throw Error(`bucketResponse is not a S3.Bucket`)
151158
}
152159

153160
uploadRequests.push(
154161
...filesToUpload.map(file => {
155-
const key = path.basename(file.fsPath)
162+
const key =
163+
bucketResponse.folder !== undefined
164+
? bucketResponse.folder.path + path.basename(file.fsPath)
165+
: path.basename(file.fsPath)
156166
return fileToUploadRequest(bucketName, key, file)
157167
})
158168
)
159169

170+
if (bucketResponse.folder) {
171+
globals.context.globalState.update('aws.lastUploadedToS3Folder', {
172+
bucket: bucketResponse.bucket,
173+
folder: bucketResponse.folder,
174+
})
175+
}
176+
160177
break
161178
}
162179
}
@@ -280,7 +297,6 @@ async function uploadBatchOfFiles(
280297
localize('AWS.s3.uploadFile.startUpload', 'Uploading file {0} to {1}', fileName, destinationPath),
281298
outputChannel
282299
)
283-
284300
let remainder = 0
285301
let lastLoaded = 0
286302
// TODO: don't use `withProgress`, it makes it hard to have control over the individual outputs
@@ -374,8 +390,14 @@ async function uploadWithProgress(
374390
return (request.ongoingUpload = undefined)
375391
}
376392

377-
interface BucketQuickPickItem extends vscode.QuickPickItem {
393+
export interface BucketQuickPickItem extends vscode.QuickPickItem {
378394
bucket: S3.Bucket | undefined
395+
folder?: Folder | undefined
396+
}
397+
398+
interface SavedFolder {
399+
bucket: Bucket
400+
folder: Folder
379401
}
380402

381403
// TODO:: extract and reuse logic from sam deploy wizard (bucket selection)
@@ -391,7 +413,7 @@ export async function promptUserForBucket(
391413
s3client: S3Client,
392414
promptUserFunction = promptUser,
393415
createBucket = createBucketCommand
394-
): Promise<S3.Bucket | 'cancel' | 'back'> {
416+
): Promise<BucketQuickPickItem | 'cancel' | 'back'> {
395417
let allBuckets: S3.Bucket[]
396418
try {
397419
allBuckets = await s3client.listAllBuckets()
@@ -418,15 +440,77 @@ export async function promptUserForBucket(
418440
}
419441
})
420442

443+
const lastTouchedFolder = globals.context.globalState.get<SavedFolder | undefined>('aws.lastTouchedS3Folder')
444+
let lastFolderItem: BucketQuickPickItem | undefined = undefined
445+
if (lastTouchedFolder) {
446+
lastFolderItem = {
447+
label: lastTouchedFolder.folder.name,
448+
description: '(last opened S3 folder)',
449+
bucket: { Name: lastTouchedFolder.bucket.name },
450+
folder: lastTouchedFolder.folder,
451+
}
452+
}
453+
454+
const lastUploadedToFolder = globals.context.globalState.get<SavedFolder | undefined>('aws.lastUploadedToS3Folder')
455+
let lastUploadedFolderItem: BucketQuickPickItem | undefined = undefined
456+
if (lastUploadedToFolder) {
457+
lastUploadedFolderItem = {
458+
label: lastUploadedToFolder.folder.name,
459+
description: '(last uploaded-to S3 folder)',
460+
bucket: { Name: lastUploadedToFolder.bucket.name },
461+
folder: lastUploadedToFolder.folder,
462+
}
463+
}
464+
465+
const folderItems = []
466+
if (lastUploadedFolderItem !== undefined) {
467+
folderItems.push(lastUploadedFolderItem)
468+
}
469+
// de-dupe if folders are the same
470+
if (
471+
lastFolderItem !== undefined &&
472+
(lastUploadedFolderItem === undefined || lastFolderItem.folder?.path !== lastUploadedFolderItem.folder?.path)
473+
) {
474+
folderItems.push(lastFolderItem)
475+
}
476+
477+
// Remove this stub after we bump minimum to vscode 1.64
478+
const QuickPickItemKind = semver.gte(vscode.version, '1.64.0') ? (vscode as any).QuickPickItemKind : undefined
479+
const items: BucketQuickPickItem[] = [
480+
// vscode 1.64 supports QuickPickItemKind.Separator.
481+
// https://github.com/microsoft/vscode/commit/eb416b4f9ebfda1c798aa7c8b2f4e81c6ce1984f
482+
...(QuickPickItemKind && folderItems.length > 0
483+
? [
484+
{
485+
label: localize('AWS.s3.uploadFile.folderSeparator', 'Folders'),
486+
kind: QuickPickItemKind.Separator,
487+
bucket: undefined,
488+
} as BucketQuickPickItem,
489+
]
490+
: []),
491+
...folderItems,
492+
...(!QuickPickItemKind
493+
? []
494+
: [
495+
{
496+
label: localize('AWS.s3.uploadFile.bucketSeparator', 'Buckets'),
497+
kind: QuickPickItemKind.Separator,
498+
bucket: undefined,
499+
} as BucketQuickPickItem,
500+
]),
501+
...bucketItems,
502+
createNewBucket,
503+
]
504+
421505
const picker = createQuickPick({
422506
options: {
423507
canPickMany: false,
424508
ignoreFocusOut: true,
425-
title: localize('AWS.message.selectBucket', 'Select an S3 bucket to upload to'),
509+
title: localize('AWS.message.selectBucket', 'Select an S3 bucket or folder to upload to'),
426510
step: 2,
427511
totalSteps: 2,
428512
},
429-
items: [...bucketItems, createNewBucket],
513+
items,
430514
buttons: [vscode.QuickInputButtons.Back],
431515
})
432516
const response = verifySinglePickerOutput(
@@ -459,7 +543,7 @@ export async function promptUserForBucket(
459543
return promptUserForBucket(s3client)
460544
}
461545
} else {
462-
return response.bucket
546+
return response
463547
}
464548
return 'cancel'
465549
}

src/test/s3/commands/uploadFile.test.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getFilesToUpload,
1212
promptUserForBucket,
1313
uploadFileCommand,
14+
BucketQuickPickItem,
1415
} from '../../../s3/commands/uploadFile'
1516
import { S3Node } from '../../../s3/explorer/s3Nodes'
1617
import { S3BucketNode } from '../../../s3/explorer/s3BucketNode'
@@ -27,10 +28,21 @@ describe('uploadFileCommand', function () {
2728
const sizeBytes = 16
2829
const fileLocation = vscode.Uri.file('/file.jpg')
2930
const statFile: FileSizeBytes = _file => sizeBytes
31+
const bucketResponse = { label: 'label', bucket: { Name: bucketName } }
32+
const folderResponse = {
33+
label: 'label',
34+
bucket: { Name: bucketName },
35+
folder: { name: 'folderA', path: 'folderA/', arn: 'arn' },
36+
}
37+
const getFolder: (s3client: S3Client) => Promise<BucketQuickPickItem | 'cancel' | 'back'> = s3Client => {
38+
return new Promise((resolve, reject) => {
39+
resolve(folderResponse)
40+
})
41+
}
3042
let outputChannel: MockOutputChannel
3143
let s3: S3Client
3244
let bucketNode: S3BucketNode
33-
let getBucket: (s3client: S3Client) => Promise<S3.Bucket | 'cancel' | 'back'>
45+
let getBucket: (s3client: S3Client) => Promise<BucketQuickPickItem | 'cancel' | 'back'>
3446
let getFile: (document?: vscode.Uri) => Promise<vscode.Uri[] | undefined>
3547
let commands: Commands
3648
let mockedUpload: S3.ManagedUpload
@@ -109,7 +121,7 @@ describe('uploadFileCommand', function () {
109121

110122
getBucket = s3Client => {
111123
return new Promise((resolve, reject) => {
112-
resolve({ Name: bucketName })
124+
resolve(bucketResponse)
113125
})
114126
}
115127
})
@@ -157,21 +169,25 @@ describe('uploadFileCommand', function () {
157169

158170
getBucket = s3Client => {
159171
return new Promise((resolve, reject) => {
160-
resolve({ Name: bucketName })
172+
resolve(bucketResponse)
161173
})
162174
}
163175

164-
it('successfully upload file', async function () {
176+
it('successfully upload file or folder', async function () {
165177
when(s3.uploadFile(anything())).thenResolve(instance(mockedUpload))
166178
when(mockedUpload.promise()).thenResolve()
167-
168179
getTestWindow().onDidShowDialog(d => d.selectItem(fileLocation))
169180

181+
// Upload to bucket.
170182
await uploadFileCommand(instance(s3), fileLocation, statFile, getBucket, getFile, outputChannel, commands)
183+
// Upload to folder.
184+
await uploadFileCommand(instance(s3), fileLocation, statFile, getFolder, getFile, outputChannel, commands)
171185

172186
assert.deepStrictEqual(outputChannel.lines, [
173187
'Uploading file file.jpg to s3://bucket-name/file.jpg',
174188
`Uploaded 1/1 files`,
189+
'Uploading file file.jpg to s3://bucket-name/folderA/file.jpg',
190+
`Uploaded 1/1 files`,
175191
])
176192
})
177193

@@ -293,7 +309,7 @@ describe('promptUserForBucket', async function () {
293309
when(s3.listAllBuckets()).thenResolve(buckets)
294310

295311
const response = await promptUserForBucket(instance(s3), promptSelect)
296-
assert.deepStrictEqual(response, buckets[0])
312+
assert.deepStrictEqual(response, selection)
297313
})
298314

299315
it('Returns "back" when selected', async function () {

0 commit comments

Comments
 (0)