=> {
329 | reject(
330 | shouldResume && (await shouldResume())
331 | ? InputFlowAction.resume
332 | : InputFlowAction.cancel
333 | )
334 | })()
335 | })
336 | )
337 | if (this.current) {
338 | this.current.dispose()
339 | }
340 | this.current = input
341 | this.current.show()
342 | })
343 | } finally {
344 | disposables.forEach((d) => d.dispose())
345 | }
346 | }
347 |
348 | async showInputBox({
349 | title,
350 | step,
351 | totalSteps,
352 | value,
353 | ignoreFocusOut,
354 | prompt,
355 | validate,
356 | buttons,
357 | shouldResume
358 | }: P): Promise {
359 | const disposables: Disposable[] = []
360 | try {
361 | return await new Promise<
362 | string | (P extends { buttons: (infer I)[] } ? I : never)
363 | >((resolve, reject) => {
364 | const input = window.createInputBox()
365 | input.title = title
366 | input.step = step
367 | input.totalSteps = totalSteps
368 | input.value = value || ''
369 | input.prompt = prompt
370 | input.ignoreFocusOut = ignoreFocusOut || true
371 | input.buttons = [
372 | ...(this.steps.length > 1 ? [QuickInputButtons.Back] : []),
373 | ...(buttons || [])
374 | ]
375 | let validating
376 | disposables.push(
377 | input.onDidTriggerButton((item) => {
378 | if (item === QuickInputButtons.Back) {
379 | reject(InputFlowAction.back)
380 | } else {
381 | resolve(item as any)
382 | }
383 | }),
384 | input.onDidAccept(async () => {
385 | const value = input.value
386 | input.enabled = false
387 | input.busy = true
388 | if (!(await validate(value))) {
389 | resolve(value)
390 | }
391 | input.enabled = true
392 | input.busy = false
393 | }),
394 | input.onDidChangeValue(async (text) => {
395 | const current = validate(text)
396 | validating = current
397 | const validationMessage = await current
398 | // for async validate
399 | if (current === validating) {
400 | input.validationMessage = validationMessage
401 | }
402 | }),
403 | input.onDidHide(() => {
404 | // this.current && this.current.show()
405 | ;(async (): Promise => {
406 | reject(
407 | shouldResume && (await shouldResume())
408 | ? InputFlowAction.resume
409 | : InputFlowAction.cancel
410 | )
411 | })()
412 | })
413 | )
414 | if (this.current) {
415 | this.current.dispose()
416 | }
417 | this.current = input
418 | this.current.show()
419 | })
420 | } finally {
421 | disposables.forEach((d) => d.dispose())
422 | }
423 | }
424 | }
425 |
--------------------------------------------------------------------------------
/src/commands/uploadFromClipboard.ts:
--------------------------------------------------------------------------------
1 | import Logger from '@/utils/log'
2 | import path from 'path'
3 | import fs from 'fs'
4 | import execa from 'execa'
5 | import os from 'os'
6 | import { uploadUris } from '@/uploader/uploadUris'
7 | import vscode from 'vscode'
8 | import { format } from 'date-fns'
9 |
10 | interface ClipboardImage {
11 | noImage: boolean
12 | data: string
13 | }
14 |
15 | export async function uploadFromClipboard(
16 | bucketFolder?: string
17 | ): Promise {
18 | const targetPath = path.resolve(
19 | os.tmpdir(),
20 | format(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.png'
21 | )
22 | const clipboardImage = await saveClipboardImageToFile(targetPath)
23 | if (!clipboardImage) return
24 | if (clipboardImage.noImage) {
25 | Logger.showErrorMessage('The clipboard does not contain image data.')
26 | return
27 | }
28 | await uploadUris([vscode.Uri.file(targetPath)], bucketFolder)
29 | }
30 |
31 | export async function saveClipboardImageToFile(
32 | targetFilePath: string
33 | ): Promise {
34 | const platform = process.platform
35 | let saveResult
36 |
37 | try {
38 | if (platform === 'win32') {
39 | saveResult = await saveWin32ClipboardImageToFile(targetFilePath)
40 | } else if (platform === 'darwin') {
41 | saveResult = await saveMacClipboardImageToFile(targetFilePath)
42 | } else {
43 | saveResult = await saveLinuxClipboardImageToFile(targetFilePath)
44 | }
45 | return saveResult
46 | } catch (err) {
47 | // encoding maybe wrong(powershell may use gbk encoding in China, etc)
48 | Logger.showErrorMessage(err.message)
49 | }
50 | }
51 |
52 | function getClipboardConfigPath(fileName: string): string {
53 | return path.resolve(
54 | __dirname,
55 | process.env.NODE_ENV === 'production'
56 | ? './clipboard'
57 | : '../utils/clipboard/',
58 | fileName
59 | )
60 | }
61 |
62 | async function saveWin32ClipboardImageToFile(
63 | targetFilePath: string
64 | ): Promise {
65 | const scriptPath = getClipboardConfigPath('pc.ps1')
66 |
67 | let command = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
68 | const powershellExisted = fs.existsSync(command)
69 | command = powershellExisted ? command : 'powershell'
70 | try {
71 | const { stdout } = await execa(command, [
72 | '-noprofile',
73 | '-noninteractive',
74 | '-nologo',
75 | '-sta',
76 | '-executionpolicy',
77 | 'unrestricted',
78 | '-windowstyle',
79 | 'hidden',
80 | '-file',
81 | scriptPath,
82 | targetFilePath
83 | ])
84 |
85 | return { noImage: stdout === 'no image', data: stdout }
86 | } catch (err) {
87 | if (err.code === 'ENOENT') {
88 | Logger.showErrorMessage('Failed to execute powershell')
89 | return
90 | }
91 | throw err
92 | }
93 | }
94 |
95 | async function saveMacClipboardImageToFile(
96 | targetFilePath: string
97 | ): Promise {
98 | const scriptPath = getClipboardConfigPath('mac.applescript')
99 |
100 | const { stderr, stdout } = await execa('osascript', [
101 | scriptPath,
102 | targetFilePath
103 | ])
104 | if (stderr) {
105 | Logger.showErrorMessage(stderr)
106 | return
107 | }
108 | return { noImage: stdout === 'no image', data: stdout }
109 | }
110 |
111 | async function saveLinuxClipboardImageToFile(
112 | targetFilePath: string
113 | ): Promise {
114 | const scriptPath = getClipboardConfigPath('linux.sh')
115 |
116 | const { stderr, stdout } = await execa('sh', [scriptPath, targetFilePath])
117 | if (stderr) {
118 | Logger.showErrorMessage(stderr)
119 | return
120 | }
121 | return { noImage: stdout === 'no image', data: stdout }
122 | }
123 |
--------------------------------------------------------------------------------
/src/commands/uploadFromExplorer.ts:
--------------------------------------------------------------------------------
1 | import vscode from 'vscode'
2 | import { uploadUris } from '@/uploader/uploadUris'
3 | import { SUPPORT_EXT } from '@/constant'
4 | import { ext } from '@/extensionVariables'
5 |
6 | export async function uploadFromExplorer(): Promise {
7 | const result = await vscode.window.showOpenDialog({
8 | filters: ext.elanConfiguration.onlyShowImages
9 | ? {
10 | Images: SUPPORT_EXT.slice()
11 | }
12 | : {},
13 | canSelectMany: true
14 | })
15 | if (!result) return
16 |
17 | await uploadUris(result)
18 | }
19 |
--------------------------------------------------------------------------------
/src/commands/uploadFromExplorerContext.ts:
--------------------------------------------------------------------------------
1 | import vscode from 'vscode'
2 | import { uploadUris } from '@/uploader/uploadUris'
3 |
4 | // TODO: compatible with Bucket Folder > ${relativeToVsRootPath} even no active file
5 | export async function uploadFromExplorerContext(
6 | uri: vscode.Uri
7 | ): Promise {
8 | await uploadUris([uri])
9 | }
10 |
--------------------------------------------------------------------------------
/src/constant.ts:
--------------------------------------------------------------------------------
1 | export enum CommandContext {
2 | BUCKET_EXPLORER_UPLOAD_CLIPBOARD = 'elan.bucketExplorer.uploadFromClipboard',
3 | BUCKET_EXPLORER_UPLOAD_CONTEXT = 'elan.bucketExplorer.uploadFromContext',
4 | BUCKET_EXPLORER_DELETE_CONTEXT = 'elan.bucketExplorer.deleteFromContext',
5 | BUCKET_EXPLORER_COPY_CONTEXT = 'elan.bucketExplorer.copyFromContext',
6 | BUCKET_EXPLORER_MOVE_CONTEXT = 'elan.bucketExplorer.moveFromContext',
7 | BUCKET_EXPLORER_REFRESH_ROOT = 'elan.bucketExplorer.refreshRoot',
8 | BUCKET_EXPLORER_COPY_LINK = 'elan.bucketExplorer.copyLink',
9 | BUCKET_EXPLORER_SHOW_MORE_CHILDREN = 'elan.bucketExplorer.showMoreChildren'
10 | }
11 | export const SUPPORT_EXT: ReadonlyArray = [
12 | 'png',
13 | 'jpg',
14 | 'jpeg',
15 | 'webp',
16 | 'gif',
17 | 'bmp',
18 | 'tiff',
19 | 'ico',
20 | 'svg'
21 | ]
22 | export const MARKDOWN_PATH_REG = /!\[.*?\]\((.+?)\)/g
23 |
24 | export const TIP_FAILED_INIT =
25 | 'Failed to connect OSS. Is the configuration correct?'
26 | export const CONTEXT_VALUE = {
27 | BUCKET: 'elan:bucket',
28 | OBJECT: 'elan:object',
29 | FOLDER: 'elan:folder',
30 | CONNECT_ERROR: 'elan:connectError',
31 | PAGER: 'elan:pager'
32 | }
33 |
34 | export const OSS_REGION = [
35 | 'oss-cn-hangzhou',
36 | 'oss-cn-shanghai',
37 | 'oss-cn-qingdao',
38 | 'oss-cn-beijing',
39 | 'oss-cn-zhangjiakou',
40 | 'oss-cn-huhehaote',
41 | 'oss-cn-wulanchabu',
42 | 'oss-cn-shenzhen',
43 | 'oss-cn-heyuan',
44 | 'oss-cn-chengdu',
45 | 'oss-cn-hongkong',
46 | 'oss-us-west-1',
47 | 'oss-us-east-1',
48 | 'oss-ap-southeast-1',
49 | 'oss-ap-southeast-2',
50 | 'oss-ap-southeast-3',
51 | 'oss-ap-southeast-5',
52 | 'oss-ap-northeast-1',
53 | 'oss-ap-south-1',
54 | 'oss-eu-central-1',
55 | 'oss-eu-west-1',
56 | 'oss-me-east-1'
57 | ]
58 |
--------------------------------------------------------------------------------
/src/extension.ts:
--------------------------------------------------------------------------------
1 | import vscode from 'vscode'
2 | import { uploadFromClipboard } from './commands/uploadFromClipboard'
3 | import { uploadFromExplorer } from './commands/uploadFromExplorer'
4 | import { uploadFromExplorerContext } from './commands/uploadFromExplorerContext'
5 | import { setOSSConfiguration } from './commands/setOSSConfiguration'
6 | import deleteByHover from './commands/deleteByHover'
7 | import hover from './language/hover'
8 | import Logger from './utils/log'
9 | import { ext } from '@/extensionVariables'
10 | import { getElanConfiguration } from '@/utils/index'
11 | import { registerBucket } from './views/registerBucket'
12 | import { ElanImagePreviewPanel } from '@/webview/imagePreview'
13 |
14 | // this method is called when your extension is activated
15 | // your extension is activated the very first time the command is executed
16 |
17 | export function activate(context: vscode.ExtensionContext): void {
18 | initializeExtensionVariables(context)
19 | Logger.channel = vscode.window.createOutputChannel('Elan')
20 | const registeredCommands = [
21 | vscode.commands.registerCommand('elan.webView.imagePreview', (imageSrc) => {
22 | ElanImagePreviewPanel.createOrShow(context.extensionUri, imageSrc)
23 | }),
24 | vscode.commands.registerCommand(
25 | 'elan.setOSSConfiguration',
26 | setOSSConfiguration
27 | ),
28 | vscode.commands.registerCommand(
29 | 'elan.uploadFromClipboard',
30 | uploadFromClipboard
31 | ),
32 | vscode.commands.registerCommand(
33 | 'elan.uploadFromExplorer',
34 | uploadFromExplorer
35 | ),
36 | vscode.commands.registerCommand(
37 | 'elan.uploadFromExplorerContext',
38 | uploadFromExplorerContext
39 | ),
40 | vscode.commands.registerCommand('elan.deleteByHover', deleteByHover),
41 | vscode.languages.registerHoverProvider('markdown', hover)
42 | // TODO: command registry refactor
43 | ]
44 | context.subscriptions.push(...registeredCommands)
45 |
46 | // views/bucket
47 | context.subscriptions.push(...registerBucket())
48 | }
49 |
50 | // this method is called when your extension is deactivated
51 | // eslint-disable-next-line @typescript-eslint/no-empty-function
52 | export function deactivate(): void {}
53 |
54 | function initializeExtensionVariables(ctx: vscode.ExtensionContext): void {
55 | ext.context = ctx
56 | // there are two position get oss configuration now, may redundant
57 | ext.elanConfiguration = getElanConfiguration()
58 | ctx.subscriptions.push(
59 | vscode.workspace.onDidChangeConfiguration(() => {
60 | ext.elanConfiguration = getElanConfiguration()
61 | })
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/src/extensionVariables.ts:
--------------------------------------------------------------------------------
1 | import vscode, { ExtensionContext } from 'vscode'
2 | import { OSSObjectTreeItem, BucketExplorerProvider } from '@/views/bucket'
3 | import { ElanConfiguration } from '@/utils/index'
4 | /**
5 | * Namespace for common variables used throughout the extension. They must be initialized in the activate() method of extension.ts
6 | */
7 |
8 | // eslint-disable-next-line @typescript-eslint/no-namespace
9 | export namespace ext {
10 | export let context: vscode.ExtensionContext
11 | export let bucketExplorer: BucketExplorerProvider
12 | export let bucketExplorerTreeView: vscode.TreeView
13 | export let bucketExplorerTreeViewVisible: boolean
14 | export let elanConfiguration: ElanConfiguration
15 | }
16 |
--------------------------------------------------------------------------------
/src/language/hover.ts:
--------------------------------------------------------------------------------
1 | import vscode, { Hover } from 'vscode'
2 | import { isAliyunOssUri } from '@/utils/index'
3 | import { MARKDOWN_PATH_REG } from '@/constant'
4 |
5 | function getCommandUriString(
6 | text: string,
7 | command: string,
8 | ...args: unknown[]
9 | ): string {
10 | const uri = vscode.Uri.parse(
11 | `command:${command}` +
12 | (args.length ? `?${encodeURIComponent(JSON.stringify(args))}` : '')
13 | )
14 | return `[${text}](${uri})`
15 | }
16 |
17 | class HoverProvider implements vscode.HoverProvider {
18 | provideHover(
19 | document: vscode.TextDocument,
20 | position: vscode.Position
21 | ): vscode.ProviderResult {
22 | const keyRange = this.getKeyRange(document, position)
23 | if (!keyRange) return
24 |
25 | const uriMatch = MARKDOWN_PATH_REG.exec(document.getText(keyRange))
26 | if (!uriMatch) return
27 |
28 | const uri = uriMatch[1]
29 |
30 | if (!isAliyunOssUri(uri)) return
31 |
32 | const delCommandUri = getCommandUriString(
33 | 'Delete image',
34 | 'elan.deleteByHover',
35 | encodeURIComponent(uri), // should encode so that 'elan.deleteByHover' can get correct uri
36 | document.fileName,
37 | keyRange
38 | )
39 | const contents = new vscode.MarkdownString(delCommandUri)
40 | contents.isTrusted = true
41 | return new Hover(contents, keyRange)
42 | }
43 | getKeyRange(
44 | document: vscode.TextDocument,
45 | position: vscode.Position
46 | ): vscode.Range | undefined {
47 | // TODO: get word range by link regexp?
48 | const keyRange = document.getWordRangeAtPosition(
49 | position,
50 | MARKDOWN_PATH_REG
51 | )
52 |
53 | return keyRange
54 | }
55 | }
56 |
57 | export default new HoverProvider()
58 |
--------------------------------------------------------------------------------
/src/test/runTest.ts:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 |
3 | import { runTests } from 'vscode-test'
4 |
5 | async function main() {
6 | try {
7 | // The folder containing the Extension Manifest package.json
8 | // Passed to `--extensionDevelopmentPath`
9 | const extensionDevelopmentPath = path.resolve(__dirname, '../../')
10 |
11 | // The path to test runner
12 | // Passed to --extensionTestsPath
13 | const extensionTestsPath = path.resolve(__dirname, './suite/index')
14 |
15 | // Download VS Code, unzip it and run the integration test
16 | await runTests({ extensionDevelopmentPath, extensionTestsPath })
17 | } catch (err) {
18 | console.error('Failed to run tests')
19 | process.exit(1)
20 | }
21 | }
22 |
23 | main()
24 |
--------------------------------------------------------------------------------
/src/test/suite/extension.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'assert'
2 |
3 | // You can import and use all API from the 'vscode' module
4 | // as well as import your extension to test it
5 | import vscode from 'vscode'
6 | // import * as myExtension from '../../extension';
7 |
8 | suite('Extension Test Suite', () => {
9 | vscode.window.showInformationMessage('Start all tests.')
10 |
11 | test('Sample test', () => {
12 | assert.equal(-1, [1, 2, 3].indexOf(5))
13 | assert.equal(-1, [1, 2, 3].indexOf(0))
14 | })
15 | })
16 |
--------------------------------------------------------------------------------
/src/test/suite/index.ts:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import Mocha from 'mocha'
3 | import glob from 'glob'
4 |
5 | export function run(): Promise {
6 | // Create the mocha test
7 | const mocha = new Mocha({
8 | ui: 'tdd',
9 | color: true
10 | })
11 |
12 | const testsRoot = path.resolve(__dirname, '..')
13 |
14 | return new Promise((c, e) => {
15 | glob('**/**.test.js', { cwd: testsRoot }, (err, files) => {
16 | if (err) {
17 | return e(err)
18 | }
19 |
20 | // Add files to the test suite
21 | files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f)))
22 |
23 | try {
24 | // Run the mocha test
25 | mocha.run((failures) => {
26 | if (failures > 0) {
27 | e(new Error(`${failures} tests failed.`))
28 | } else {
29 | c()
30 | }
31 | })
32 | } catch (err) {
33 | console.error(err)
34 | e(err)
35 | }
36 | })
37 | })
38 | }
39 |
--------------------------------------------------------------------------------
/src/uploader/copyUri.ts:
--------------------------------------------------------------------------------
1 | import Uploader from './index'
2 | import { getProgress, removeLeadingSlash, Progress } from '@/utils'
3 | import vscode from 'vscode'
4 | import Logger from '@/utils/log'
5 |
6 | export async function copyUri(
7 | targetUri: vscode.Uri,
8 | sourceUri: vscode.Uri,
9 | showProgress = true
10 | ): Promise {
11 | const uploader = Uploader.get()
12 | // init OSS instance failed
13 | if (!uploader) return
14 |
15 | // path '/ex/path', the 'ex' means source bucket name, should remove leading slash
16 | const sourceName = removeLeadingSlash(sourceUri.path)
17 | // leading slash of targetName is irrelevant
18 | const targetName = removeLeadingSlash(targetUri.path)
19 |
20 | let progress: Progress['progress'] | undefined
21 | let progressResolve: Progress['progressResolve'] | undefined
22 | if (showProgress) {
23 | const p = getProgress(`Copying object`)
24 | progress = p.progress
25 | progressResolve = p.progressResolve
26 | }
27 | try {
28 | await uploader.copy(targetName, sourceName)
29 | if (progress && progressResolve) {
30 | progress.report({
31 | message: `Finish.`,
32 | increment: 100
33 | })
34 | ;((fn): void => {
35 | setTimeout(() => {
36 | fn()
37 | }, 1000)
38 | })(progressResolve)
39 | }
40 | } catch (err) {
41 | progressResolve && progressResolve()
42 | Logger.showErrorMessage(
43 | `Failed to copy object. See output channel for more details`
44 | )
45 | Logger.log(
46 | `Failed: copy from ${sourceName} to ${targetName}.` +
47 | ` Reason: ${err.message}`
48 | )
49 | // should throw err, moveUri will catch it
50 | throw err
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/uploader/deleteUri.ts:
--------------------------------------------------------------------------------
1 | import Uploader from './index'
2 | import { getProgress, removeLeadingSlash, Progress } from '@/utils'
3 | import vscode from 'vscode'
4 | import Logger from '@/utils/log'
5 |
6 | export async function deleteUri(
7 | uri: vscode.Uri,
8 | showProgress = true
9 | ): Promise {
10 | const uploader = Uploader.get()
11 | // init OSS instance failed
12 | if (!uploader) return
13 |
14 | const name = removeLeadingSlash(uri.path)
15 | let progress: Progress['progress'] | undefined
16 | let progressResolve: Progress['progressResolve'] | undefined
17 | if (showProgress) {
18 | const p = getProgress(`Deleting object`)
19 | progress = p.progress
20 | progressResolve = p.progressResolve
21 | }
22 | try {
23 | await uploader.delete(name)
24 | if (progress && progressResolve) {
25 | progress.report({
26 | message: `Finish.`,
27 | increment: 100
28 | })
29 | ;((fn): void => {
30 | setTimeout(() => {
31 | fn()
32 | }, 1000)
33 | })(progressResolve)
34 | }
35 | } catch (err) {
36 | progressResolve && progressResolve()
37 | Logger.showErrorMessage(
38 | `Failed to delete object. See output channel for more details`
39 | )
40 | Logger.log(`Failed: ${name}.` + ` Reason: ${err.message}`)
41 |
42 | // should throw err, moveUri will catch it
43 | throw err
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/uploader/index.ts:
--------------------------------------------------------------------------------
1 | import vscode from 'vscode'
2 | import OSS from 'ali-oss'
3 | import Logger from '@/utils/log'
4 | import { getElanConfiguration, ElanConfiguration } from '@/utils/index'
5 | import { ext } from '@/extensionVariables'
6 |
7 | interface DeleteResponse {
8 | res: OSS.NormalSuccessResponse
9 | }
10 |
11 | export default class Uploader {
12 | private static cacheUploader: Uploader | null = null
13 | private client: OSS
14 | public configuration: ElanConfiguration
15 | public expired: boolean
16 | constructor() {
17 | this.configuration = getElanConfiguration()
18 | this.client = new OSS({
19 | bucket: this.configuration.bucket,
20 | region: this.configuration.region,
21 | accessKeyId: this.configuration.accessKeyId,
22 | accessKeySecret: this.configuration.accessKeySecret,
23 | secure: this.configuration.secure,
24 | cname: !!this.configuration.customDomain,
25 | endpoint: this.configuration.customDomain || undefined
26 | })
27 | this.expired = false
28 |
29 | // instance is expired if configuration update
30 | ext.context.subscriptions.push(
31 | vscode.workspace.onDidChangeConfiguration(() => {
32 | this.expired = true
33 | })
34 | )
35 | }
36 | // singleton
37 | static get(): Uploader | null {
38 | let u
39 | try {
40 | u =
41 | Uploader.cacheUploader && !Uploader.cacheUploader.expired
42 | ? Uploader.cacheUploader
43 | : (Uploader.cacheUploader = new Uploader())
44 | } catch (err) {
45 | // TODO: e.g.: require options.endpoint or options.region, how to corresponding to our vscode configuration?
46 | Logger.showErrorMessage(err.message)
47 | u = null
48 | }
49 | return u
50 | }
51 | async put(
52 | name: string,
53 | fsPath: string,
54 | options?: OSS.PutObjectOptions
55 | ): Promise {
56 | return this.client.put(name, fsPath, options)
57 | }
58 | async delete(
59 | name: string,
60 | options?: OSS.RequestOptions
61 | ): Promise {
62 | // FIXME: @types/ali-oss bug, I will create pr
63 | return this.client.delete(name, options) as any
64 | }
65 |
66 | async list(
67 | query: OSS.ListObjectsQuery,
68 | options?: OSS.RequestOptions
69 | ): Promise {
70 | const defaultConfig = {
71 | 'max-keys': this.configuration.maxKeys,
72 | delimiter: '/'
73 | }
74 | query = Object.assign(defaultConfig, query)
75 | return this.client.list(query, options)
76 | }
77 |
78 | async copy(
79 | name: string,
80 | sourceName: string,
81 | sourceBucket?: string,
82 | options?: OSS.CopyObjectOptions
83 | ): Promise {
84 | return this.client.copy(name, sourceName, sourceBucket, options)
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/uploader/moveUri.ts:
--------------------------------------------------------------------------------
1 | import { getProgress } from '@/utils'
2 | import vscode from 'vscode'
3 | import Logger from '@/utils/log'
4 | import { copyUri } from './copyUri'
5 | import { deleteUri } from './deleteUri'
6 |
7 | export async function moveUri(
8 | targetUri: vscode.Uri,
9 | sourceUri: vscode.Uri
10 | ): Promise {
11 | const { progress, progressResolve } = getProgress(`Moving object`)
12 | try {
13 | // not atomic
14 | await copyUri(targetUri, sourceUri, false)
15 | await deleteUri(sourceUri, false)
16 | progress.report({
17 | message: `Finish.`,
18 | increment: 100
19 | })
20 | setTimeout(() => {
21 | progressResolve()
22 | }, 1000)
23 | } catch (err) {
24 | progressResolve()
25 | Logger.showErrorMessage(
26 | `Failed to move object. See output channel for more details`
27 | )
28 | Logger.log(
29 | `Failed: move from ${sourceUri.path} to ${targetUri.path}.` +
30 | ` Reason: ${err.message}`
31 | )
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/uploader/templateStore.ts:
--------------------------------------------------------------------------------
1 | import vscode from 'vscode'
2 | import path from 'path'
3 | import { getHashDigest } from '@/utils/index'
4 | import { getDate, format, getYear } from 'date-fns'
5 |
6 | interface RawConfig {
7 | outputFormat: string
8 | uploadName: string
9 | bucketFolder: string
10 | }
11 |
12 | function getRe(match: keyof Store): RegExp {
13 | return new RegExp(`\\$\\{${match}\\}`, 'gi')
14 | }
15 |
16 | const fileNameRe = getRe('fileName')
17 | const uploadNameRe = getRe('uploadName')
18 | const urlRe = getRe('url')
19 | const extRe = getRe('ext')
20 | // const relativeToVsRootPathRe = getRe('relativeToVsRootPath')
21 | const relativeToVsRootPathRe = /\$\{relativeToVsRootPath(?::([^:}]*))?\}/gi
22 | const activeMdFilenameRe = getRe('activeMdFilename')
23 | const dateRe = getRe('date')
24 | const monthRe = getRe('month')
25 | const yearRe = getRe('year')
26 | const pathnameRe = getRe('pathname')
27 | // ${:contentHash::}
28 | const contentHashRe = /\$\{(?:([^:}]+):)?contentHash(?::([a-z]+\d*))?(?::(\d+))?\}/gi
29 |
30 | interface Store {
31 | readonly year: string
32 | readonly month: string
33 | readonly date: string
34 | // maybe should named to filename ....
35 | fileName: string
36 | pathname: string
37 | activeMdFilename: string
38 | uploadName: string
39 | url: string
40 | ext: string
41 | relativeToVsRootPath: string
42 | contentHash: string
43 | imageUri: vscode.Uri | null
44 | }
45 |
46 | class TemplateStore {
47 | private store: Store = {
48 | get year(): string {
49 | return getYear(new Date()).toString()
50 | },
51 | get month(): string {
52 | return format(new Date(), 'MM')
53 | },
54 | get date(): string {
55 | const d = getDate(new Date()).toString()
56 | return d.length > 1 ? d : '0' + d
57 | },
58 | fileName: '',
59 | activeMdFilename: '',
60 | uploadName: '',
61 | url: '',
62 | pathname: '',
63 | ext: '',
64 | relativeToVsRootPath: '',
65 | contentHash: '',
66 | imageUri: null
67 | }
68 | public raw = this.rawConfig()
69 |
70 | private rawConfig(): RawConfig {
71 | const config = vscode.workspace.getConfiguration('elan')
72 |
73 | return {
74 | outputFormat: config.get('outputFormat')?.trim() || '',
75 | uploadName: config.get('uploadName')?.trim() || '',
76 | bucketFolder: config.get('bucketFolder')?.trim() || ''
77 | }
78 | }
79 |
80 | set(key: K, value: Store[K]): void {
81 | this.store[key] = value
82 | }
83 | get(key: K): Store[K] {
84 | return this.store[key]
85 | }
86 | transform(key: keyof RawConfig): string {
87 | switch (key) {
88 | case 'uploadName': {
89 | let uploadName = this.raw.uploadName
90 | .replace(fileNameRe, this.get('fileName'))
91 | .replace(extRe, this.get('ext'))
92 | .replace(activeMdFilenameRe, this.get('activeMdFilename'))
93 |
94 | const imageUri = this.get('imageUri')
95 | if (imageUri) {
96 | uploadName = uploadName.replace(
97 | contentHashRe,
98 | (_, hashType, digestType, maxLength) => {
99 | return getHashDigest(
100 | imageUri,
101 | hashType,
102 | digestType,
103 | parseInt(maxLength, 10)
104 | )
105 | }
106 | )
107 | }
108 |
109 | this.set('uploadName', uploadName)
110 | return uploadName || this.get('fileName')
111 | }
112 | case 'outputFormat': {
113 | const outputFormat = this.raw.outputFormat
114 | .replace(fileNameRe, this.get('fileName'))
115 | .replace(uploadNameRe, this.get('uploadName'))
116 | .replace(urlRe, this.get('url'))
117 | .replace(pathnameRe, this.get('pathname'))
118 | .replace(activeMdFilenameRe, this.get('activeMdFilename'))
119 |
120 | return outputFormat
121 | }
122 | case 'bucketFolder': {
123 | const activeTextEditorFilename =
124 | vscode.window.activeTextEditor?.document.fileName
125 |
126 | let bucketFolder = this.raw.bucketFolder
127 | if (
128 | relativeToVsRootPathRe.test(this.raw.bucketFolder) &&
129 | vscode.workspace.workspaceFolders &&
130 | activeTextEditorFilename
131 | ) {
132 | const activeTextEditorFolder = path.dirname(activeTextEditorFilename)
133 |
134 | // when 'includeWorkspaceFolder' is true, name of the workspaceFolder is prepended
135 | // here we don't prepend workspaceFolder name
136 | const relativePath = vscode.workspace.asRelativePath(
137 | activeTextEditorFolder,
138 | false
139 | )
140 | if (relativePath !== activeTextEditorFolder) {
141 | this.set('relativeToVsRootPath', relativePath)
142 | }
143 | }
144 |
145 | bucketFolder = this.raw.bucketFolder
146 | .replace(relativeToVsRootPathRe, (_, prefix?: string) => {
147 | const relativePath = this.get('relativeToVsRootPath')
148 | if (!prefix) return relativePath
149 | prefix = prefix.trim()
150 | const prefixOfRelativePath = relativePath.substring(
151 | 0,
152 | prefix.length
153 | )
154 | return prefix === prefixOfRelativePath
155 | ? relativePath.substring(prefix.length)
156 | : relativePath
157 | })
158 | .replace(activeMdFilenameRe, this.get('activeMdFilename'))
159 | .replace(yearRe, this.get('year'))
160 | .replace(monthRe, this.get('month'))
161 | .replace(dateRe, this.get('date'))
162 |
163 | // since relativeToVsRootPath may be empty string, normalize it
164 | bucketFolder =
165 | bucketFolder
166 | .split('/')
167 | .filter((s) => s !== '')
168 | .join('/') + '/'
169 |
170 | return bucketFolder
171 | }
172 |
173 | default:
174 | exhaustiveCheck(key)
175 | }
176 |
177 | function exhaustiveCheck(message: never): never {
178 | throw new Error(message)
179 | }
180 | }
181 | }
182 |
183 | export { TemplateStore }
184 |
--------------------------------------------------------------------------------
/src/uploader/uploadUris.ts:
--------------------------------------------------------------------------------
1 | import vscode from 'vscode'
2 | import path from 'path'
3 | import { TemplateStore } from './templateStore'
4 | import Logger from '@/utils/log'
5 | import { getActiveMd, getProgress } from '@/utils/index'
6 | import Uploader from './index'
7 | import { URL } from 'url'
8 |
9 | declare global {
10 | interface PromiseConstructor {
11 | allSettled(
12 | promises: Array>
13 | ): Promise<
14 | Array<{
15 | status: 'fulfilled' | 'rejected'
16 | value?: unknown
17 | reason?: unknown
18 | }>
19 | >
20 | }
21 | }
22 |
23 | interface WrapError extends Error {
24 | imageName: string
25 | }
26 |
27 | export async function uploadUris(
28 | uris: vscode.Uri[],
29 | bucketFolder?: string
30 | ): Promise {
31 | const uploader = Uploader.get()
32 | // init OSS instance failed
33 | if (!uploader) return
34 |
35 | const { progress, progressResolve } = getProgress(
36 | `Uploading ${uris.length} object(s)`
37 | )
38 | const clipboard: string[] = []
39 |
40 | let finished = 0
41 | const urisPut = uris.map((uri) => {
42 | const templateStore = new TemplateStore()
43 | const ext = path.extname(uri.fsPath)
44 | const name = path.basename(uri.fsPath, ext)
45 | const activeMd = getActiveMd()
46 | if (activeMd) {
47 | const fileName = activeMd.document.fileName
48 | const ext = path.extname(fileName)
49 | const name = path.basename(fileName, ext)
50 | templateStore.set('activeMdFilename', name)
51 | }
52 |
53 | templateStore.set('fileName', name)
54 | templateStore.set('ext', ext)
55 | templateStore.set('imageUri', uri)
56 |
57 | const uploadName = templateStore.transform('uploadName')
58 | const bucketFolderFromConfiguration = templateStore.transform(
59 | 'bucketFolder'
60 | )
61 |
62 | if (bucketFolder == null) bucketFolder = bucketFolderFromConfiguration
63 | const putName = `${bucketFolder || ''}${uploadName}`
64 | const u = uploader.put(putName, uri.fsPath)
65 | u.then((putObjectResult) => {
66 | progress.report({
67 | message: `(${++finished} / ${uris.length})`,
68 | increment: Math.ceil(100 / uris.length)
69 | })
70 |
71 | templateStore.set('url', putObjectResult.url)
72 | templateStore.set('pathname', new URL(putObjectResult.url).pathname)
73 | clipboard.push(templateStore.transform('outputFormat'))
74 |
75 | return putObjectResult
76 | }).catch((err) => {
77 | Logger.log(err.stack)
78 | const defaultName = name + ext
79 | err.imageName =
80 | uploadName + (uploadName !== defaultName ? `(${defaultName})` : '')
81 | })
82 | return u
83 | })
84 |
85 | const settled = await Promise.allSettled(urisPut)
86 | const rejects = settled.filter((r) => {
87 | return r.status === 'rejected'
88 | })
89 |
90 | if (!rejects.length) {
91 | progress.report({
92 | message: 'Finish.'
93 | })
94 |
95 | setTimeout(() => {
96 | progressResolve()
97 | }, 1000)
98 | } else {
99 | progress.report({
100 | message: `${uris.length - rejects.length} objects uploaded.`
101 | })
102 | setTimeout(() => {
103 | progressResolve()
104 | Logger.showErrorMessage(`Failed to upload ${rejects.length} object(s).`)
105 |
106 | // show first error message
107 | Logger.showErrorMessage(
108 | (rejects[0].reason as WrapError).message +
109 | '. See output channel for more details.'
110 | )
111 |
112 | for (const r of rejects) {
113 | Logger.log(
114 | `Failed: ${(r.reason as WrapError).imageName}.` +
115 | ` Reason: ${(r.reason as WrapError).message}`
116 | )
117 | }
118 | }, 1000)
119 | }
120 |
121 | afterUpload(clipboard)
122 | }
123 |
124 | function afterUpload(clipboard: string[]): void {
125 | if (!clipboard.length) return
126 | const GFM = clipboard.join('\n\n') + '\n\n'
127 | vscode.env.clipboard.writeText(GFM)
128 |
129 | const activeTextMd = getActiveMd()
130 | activeTextMd?.edit((textEditorEdit) => {
131 | textEditorEdit.insert(activeTextMd.selection.active, GFM)
132 | })
133 | }
134 |
--------------------------------------------------------------------------------
/src/utils/clipboard/linux.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # require xclip(see http://stackoverflow.com/questions/592620/check-if-a-program-exists-from-a-bash-script/677212#677212)
4 | command -v xclip >/dev/null 2>&1 || { echo >&2 "no xclip"; exit 0; }
5 |
6 | # write image in clipboard to file (see http://unix.stackexchange.com/questions/145131/copy-image-from-clipboard-to-file)
7 | if
8 | xclip -selection clipboard -target image/png -o >/dev/null 2>&1
9 | then
10 | xclip -selection clipboard -target image/png -o >$1 2>/dev/null
11 | echo $1
12 | else
13 | echo "no image"
14 | fi
--------------------------------------------------------------------------------
/src/utils/clipboard/mac.applescript:
--------------------------------------------------------------------------------
1 | property fileTypes : {{«class PNGf», ".png"}}
2 |
3 | on run argv
4 | if argv is {} then
5 | return ""
6 | end if
7 |
8 | set imagePath to (item 1 of argv)
9 | set theType to getType()
10 |
11 | if theType is not missing value then
12 | try
13 | set myFile to (open for access imagePath with write permission)
14 | set eof myFile to 0
15 | write (the clipboard as (first item of theType)) to myFile
16 | close access myFile
17 | return (POSIX path of imagePath)
18 | on error
19 | try
20 | close access myFile
21 | end try
22 | return ""
23 | end try
24 | else
25 | return "no image"
26 | end if
27 | end run
28 |
29 | on getType()
30 | repeat with aType in fileTypes
31 | repeat with theInfo in (clipboard info)
32 | if (first item of theInfo) is equal to (first item of aType) then return aType
33 | end repeat
34 | end repeat
35 | return missing value
36 | end getType
--------------------------------------------------------------------------------
/src/utils/clipboard/pc.ps1:
--------------------------------------------------------------------------------
1 | param($imagePath)
2 |
3 | # Adapted from https://github.com/octan3/img-clipboard-dump/blob/master/dump-clipboard-png.ps1
4 |
5 | Add-Type -Assembly PresentationCore
6 | $img = [Windows.Clipboard]::GetImage()
7 |
8 | if ($img -eq $null) {
9 | "no image"
10 | Exit
11 | }
12 |
13 | if (-not $imagePath) {
14 | "no image"
15 | Exit
16 | }
17 |
18 | $fcb = new-object Windows.Media.Imaging.FormatConvertedBitmap($img, [Windows.Media.PixelFormats]::Rgb24, $null, 0)
19 | $stream = [IO.File]::Open($imagePath, "OpenOrCreate")
20 | $encoder = New-Object Windows.Media.Imaging.PngBitmapEncoder
21 | $encoder.Frames.Add([Windows.Media.Imaging.BitmapFrame]::Create($fcb)) | out-null
22 | $encoder.Save($stream) | out-null
23 | $stream.Dispose() | out-null
24 |
25 | $imagePath
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import vscode from 'vscode'
3 | import crypto from 'crypto'
4 | import fs from 'fs'
5 | import Logger from './log'
6 | import OSS from 'ali-oss'
7 | import { SUPPORT_EXT } from '@/constant'
8 |
9 | export function isSubDirectory(parent: string, dir: string): boolean {
10 | const relative = path.relative(parent, dir)
11 | return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative)
12 | }
13 |
14 | export function getHashDigest(
15 | uri: vscode.Uri,
16 | hashType = 'md5',
17 | digestType: crypto.HexBase64Latin1Encoding = 'hex',
18 | maxLength: number
19 | ): string {
20 | try {
21 | maxLength = maxLength || 9999
22 | const imageBuffer = fs.readFileSync(uri.fsPath)
23 | const contentHash = crypto
24 | .createHash(hashType)
25 | .update(imageBuffer)
26 | .digest(digestType)
27 |
28 | return contentHash.substr(0, maxLength)
29 | } catch (err) {
30 | Logger.showErrorMessage(
31 | 'Failed to calculate contentHash. See output channel for more details.'
32 | )
33 | Logger.log(
34 | `fsPath: ${uri.fsPath}, hashType: ${hashType}, digestType: ${digestType}, maxLength: ${maxLength} ${err.message}`
35 | )
36 | return 'EF_BF_BD'
37 | }
38 | }
39 |
40 | export function getActiveMd(): vscode.TextEditor | undefined {
41 | const activeTextEditor = vscode.window.activeTextEditor
42 | if (!activeTextEditor || activeTextEditor.document.languageId !== 'markdown')
43 | return
44 | return activeTextEditor
45 | }
46 |
47 | export function isAliyunOssUri(uri: string): boolean {
48 | try {
49 | const vsUri = vscode.Uri.parse(uri)
50 |
51 | if (!['http', 'https'].includes(vsUri.scheme)) return false
52 |
53 | const { bucket, region, customDomain } = getElanConfiguration()
54 | // the priority of customDomain is highest
55 | if (customDomain) {
56 | if (vsUri.authority !== customDomain) return false
57 | } else {
58 | // consider bucket and region when no customDomain
59 | const [_bucket, _region] = vsUri.authority.split('.')
60 | if (bucket !== _bucket) return false
61 | if (region !== _region) return false
62 | }
63 |
64 | const ext = path.extname(vsUri.path).substr(1)
65 | if (!SUPPORT_EXT.includes(ext.toLowerCase())) return false
66 |
67 | return true
68 | } catch {
69 | return false
70 | }
71 | }
72 |
73 | export function removeLeadingSlash(p: string): string {
74 | return p.replace(/^\/+/, '')
75 | }
76 |
77 | export function removeTrailingSlash(p: string): string {
78 | return p.replace(/\/+$/, '')
79 | }
80 |
81 | export interface OSSConfiguration extends OSS.Options {
82 | maxKeys: number
83 | secure: boolean
84 | customDomain: string
85 | }
86 |
87 | export interface BucketViewConfiguration {
88 | onlyShowImages: boolean
89 | }
90 |
91 | export type ElanConfiguration = OSSConfiguration & BucketViewConfiguration
92 |
93 | export function getElanConfiguration(): ElanConfiguration {
94 | const config = vscode.workspace.getConfiguration('elan')
95 | const aliyunConfig = config.get('aliyun', {
96 | accessKeyId: '',
97 | accessKeySecret: '',
98 | maxKeys: 100,
99 | secure: true,
100 | customDomain: ''
101 | })
102 | const bucketViewConfig = config.get('bucketView', {
103 | onlyShowImages: true
104 | })
105 | return {
106 | secure: aliyunConfig.secure, // ensure protocol of callback url is https
107 | customDomain: aliyunConfig.customDomain.trim(),
108 | accessKeyId: aliyunConfig.accessKeyId.trim(),
109 | accessKeySecret: aliyunConfig.accessKeySecret.trim(),
110 | bucket: aliyunConfig.bucket?.trim(),
111 | region: aliyunConfig.region?.trim(),
112 | maxKeys: aliyunConfig.maxKeys,
113 | onlyShowImages: bucketViewConfig.onlyShowImages
114 | }
115 | }
116 |
117 | export async function updateOSSConfiguration(
118 | options: OSS.Options
119 | ): Promise {
120 | const config = vscode.workspace.getConfiguration('elan')
121 | // update global settings
122 | return Promise.all([
123 | config.update('aliyun.bucket', options.bucket?.trim(), true),
124 | config.update('aliyun.region', options.region?.trim(), true),
125 | config.update('aliyun.accessKeyId', options.accessKeyId.trim(), true),
126 | config.update(
127 | 'aliyun.accessKeySecret',
128 | options.accessKeySecret.trim(),
129 | true
130 | )
131 | ])
132 | }
133 |
134 | export interface Progress {
135 | progress: vscode.Progress<{ message?: string; increment?: number }>
136 | progressResolve: (value?: unknown) => void
137 | progressReject: (value?: unknown) => void
138 | }
139 |
140 | export function getProgress(title = 'Uploading object'): Progress {
141 | let progressResolve, progressReject, progress
142 | vscode.window.withProgress(
143 | {
144 | location: vscode.ProgressLocation.Notification,
145 | title
146 | },
147 | (p) => {
148 | return new Promise((resolve, reject) => {
149 | progressResolve = resolve
150 | progressReject = reject
151 | progress = p
152 | })
153 | }
154 | )
155 | if (!progress || !progressResolve || !progressReject)
156 | throw new Error('Failed to init vscode progress')
157 | return {
158 | progress,
159 | progressResolve,
160 | progressReject
161 | }
162 | }
163 |
164 | export async function showFolderNameInputBox(
165 | folderPlaceholder: string
166 | ): Promise {
167 | return vscode.window.showInputBox({
168 | value: removeLeadingSlash(folderPlaceholder),
169 | prompt: 'Confirm the target folder',
170 | placeHolder: `Enter folder name. e.g., 'example/folder/name/', '' means root folder`,
171 | validateInput: (text) => {
172 | text = text.trim()
173 | if (text === '') return null
174 | if (text[0] === '/') return `Please do not start with '/'.`
175 | if (!text.endsWith('/')) return `Please end with '/'`
176 | return null
177 | }
178 | })
179 | }
180 |
181 | export async function showObjectNameInputBox(
182 | objectNamePlaceholder: string,
183 | options?: vscode.InputBoxOptions
184 | ): Promise {
185 | return vscode.window.showInputBox({
186 | prompt: 'Confirm the target object name',
187 | value: removeLeadingSlash(objectNamePlaceholder),
188 | placeHolder: `Enter target name. e.g., 'example/folder/name/target.jpg'`,
189 | validateInput: (text) => {
190 | text = text.trim()
191 | if (text[0] === '/') return `Please do not start with '/'.`
192 | if (text === '') return `Please enter target name.`
193 | },
194 | ...options
195 | })
196 | }
197 |
--------------------------------------------------------------------------------
/src/utils/log.ts:
--------------------------------------------------------------------------------
1 | import vscode from 'vscode'
2 | import { format } from 'date-fns'
3 |
4 | export default class Logger {
5 | static channel: vscode.OutputChannel
6 |
7 | static log(message: string): void {
8 | if (this.channel) {
9 | this.channel.appendLine(
10 | `[${format(new Date(), 'MM-dd HH:mm:ss')}]: ${message}`
11 | )
12 | }
13 | }
14 |
15 | static showInformationMessage(
16 | message: string,
17 | ...items: string[]
18 | ): Thenable {
19 | this.log(message)
20 | return vscode.window.showInformationMessage(message, ...items)
21 | }
22 |
23 | static showErrorMessage(
24 | message: string,
25 | ...items: string[]
26 | ): Thenable {
27 | this.log(message)
28 | return vscode.window.showErrorMessage(message, ...items)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/views/bucket.ts:
--------------------------------------------------------------------------------
1 | import vscode from 'vscode'
2 | import Uploader from '@/uploader/index'
3 | import { removeTrailingSlash } from '@/utils/index'
4 | import { CONTEXT_VALUE, TIP_FAILED_INIT, SUPPORT_EXT } from '@/constant'
5 | import { getThemedIconPath } from './iconPath'
6 | import { CommandContext } from '@/constant'
7 | import path from 'path'
8 | import Logger from '@/utils/log'
9 | import { ext } from '@/extensionVariables'
10 | type State = 'uninitialized' | 'initialized'
11 |
12 | export class BucketExplorerProvider
13 | implements vscode.TreeDataProvider {
14 | private _state: State = 'uninitialized'
15 | private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter()
16 |
17 | readonly onDidChangeTreeData: vscode.Event = this
18 | ._onDidChangeTreeData.event
19 |
20 | private root: OSSObjectTreeItem | null = null
21 | constructor() {
22 | this.setState('uninitialized')
23 | if (this.uploader && this.uploader.configuration.bucket) {
24 | // after the codes below are executed, the state will always be 'initialized
25 | this.setState('initialized')
26 | }
27 | }
28 |
29 | get uploader(): Uploader | null {
30 | const u = Uploader.get()
31 | // after the codes below are executed, the state will always be 'initialized
32 | if (u && u.configuration.bucket && this._state === 'uninitialized')
33 | this.setState('initialized')
34 | return u
35 | }
36 |
37 | setState(state: State): void {
38 | this._state = state
39 | vscode.commands.executeCommand('setContext', 'elan.state', state)
40 | }
41 |
42 | refresh(element?: OSSObjectTreeItem, reset = true): void {
43 | // if reset is false, means show next page date(pagination)
44 | if (element && reset) {
45 | element.marker = ''
46 | }
47 | this._onDidChangeTreeData.fire(element)
48 | }
49 | getErrorTreeItem(): OSSObjectTreeItem {
50 | return new ErrorTreeItem()
51 | }
52 |
53 | getTreeItem(element: OSSObjectTreeItem): vscode.TreeItem {
54 | return element
55 | }
56 |
57 | getChildren(element?: OSSObjectTreeItem): Thenable {
58 | if (!this.uploader) {
59 | if (this._state === 'uninitialized') return Promise.resolve([])
60 | return Promise.resolve([this.getErrorTreeItem()])
61 | }
62 | if (this.root && this.root.label !== this.uploader.configuration.bucket) {
63 | this.root = null
64 | this.refresh()
65 | return Promise.resolve([])
66 | }
67 |
68 | // element is 'folder', should add prefix to its label
69 | if (element) {
70 | return Promise.resolve(
71 | this.getObjects(
72 | element.prefix + element.label,
73 | element.marker,
74 | element
75 | ).then((children) => {
76 | element.children = children
77 | return children
78 | })
79 | )
80 | }
81 | // root
82 | const bucket = this.uploader.configuration.bucket
83 | if (!bucket) {
84 | if (this._state === 'uninitialized') return Promise.resolve([])
85 | return Promise.resolve([this.getErrorTreeItem()])
86 | }
87 | this.root = new OSSObjectTreeItem({
88 | label: bucket,
89 | collapsibleState: vscode.TreeItemCollapsibleState.Expanded,
90 | iconPath: getThemedIconPath('database'),
91 | contextValue: CONTEXT_VALUE.BUCKET
92 | })
93 | return Promise.resolve([this.root])
94 | }
95 | // getObjects with certain prefix ('folder') and marker
96 | private async getObjects(
97 | prefix: string,
98 | marker = '',
99 | parentFolder: OSSObjectTreeItem
100 | ): Promise {
101 | try {
102 | if (!this.uploader) return [this.getErrorTreeItem()]
103 |
104 | prefix = prefix === this.uploader.configuration.bucket ? '' : prefix + '/'
105 |
106 | const res = await this.uploader.list({
107 | prefix,
108 | marker
109 | })
110 | // we should create an empty 'folder' sometimes
111 | // this 'empty object' is the 'parent folder' of these objects
112 | let emptyObjectIndex: null | number = null
113 | res.objects = res.objects || []
114 | res.prefixes = res.prefixes || []
115 |
116 | res.objects.some((p, index) => {
117 | const isEmpty = p.name === prefix
118 | if (isEmpty) emptyObjectIndex = index
119 | return isEmpty
120 | })
121 | const commonOptions = {
122 | prefix,
123 | parentFolder,
124 | parentFolderIsObject: emptyObjectIndex !== null
125 | }
126 | let _objects = res.objects.map((p, index) => {
127 | const isImage = SUPPORT_EXT.includes(
128 | path.extname(p.name).substr(1).toLowerCase()
129 | )
130 | const isEmpty = index === emptyObjectIndex
131 | return new OSSObjectTreeItem({
132 | ...commonOptions,
133 | url: p.url,
134 | label: p.name.substr(prefix.length),
135 | hidden: isEmpty, // TODO: maybe delete this property
136 | contextValue: CONTEXT_VALUE.OBJECT,
137 | iconPath: vscode.ThemeIcon.File,
138 | resourceUri: vscode.Uri.parse(p.url),
139 | command: isImage
140 | ? ({
141 | command: 'elan.webView.imagePreview',
142 | title: 'preview',
143 | arguments: [p.url]
144 | } as vscode.Command)
145 | : undefined
146 | })
147 | })
148 | if (emptyObjectIndex != null) _objects.splice(emptyObjectIndex, 1)
149 |
150 | if (this.uploader.configuration.onlyShowImages) {
151 | _objects = _objects.filter((o) => {
152 | return SUPPORT_EXT.includes(
153 | path.extname(o.label).substr(1).toLowerCase()
154 | )
155 | })
156 | }
157 |
158 | const _prefixes = res.prefixes.map((p) => {
159 | // e.g. if prefix is 'github', return prefix is 'github/*', should remove redundant string
160 | p = removeTrailingSlash(p).substr(prefix.length)
161 | return new OSSObjectTreeItem({
162 | ...commonOptions,
163 | label: p,
164 | collapsibleState: vscode.TreeItemCollapsibleState.Collapsed,
165 | contextValue: CONTEXT_VALUE.FOLDER,
166 | iconPath: vscode.ThemeIcon.Folder
167 | })
168 | })
169 | const nodes = _prefixes.concat(_objects)
170 |
171 | // click 'Show More' button
172 | if (marker) {
173 | // remove 'hasMore' item
174 | parentFolder.children.pop()
175 | nodes.unshift(...parentFolder.children)
176 | }
177 |
178 | if (!res.isTruncated) return nodes
179 | // if has nextPage
180 | nodes.push(
181 | new ShowMoreTreeItem({
182 | parentFolder,
183 | // since isTruncated is true, nextMarker must be string
184 | nextMarker: res.nextMarker as string
185 | })
186 | )
187 | return nodes
188 | } catch (err) {
189 | Logger.showErrorMessage(
190 | 'Failed to list objects. See output channel for more details.'
191 | )
192 | Logger.log(
193 | `Failed: list objects.` +
194 | ` Reason: ${err.message}` +
195 | ` If you set customDomain, is it match to the bucket? `
196 | )
197 | return [this.getErrorTreeItem()]
198 | }
199 | }
200 | }
201 |
202 | interface OSSObjectTreeItemOptions extends vscode.TreeItem {
203 | label: string
204 | url?: string
205 | prefix?: string
206 | parentFolder?: OSSObjectTreeItem
207 | parentFolderIsObject?: boolean
208 | hidden?: boolean
209 | }
210 |
211 | export class OSSObjectTreeItem extends vscode.TreeItem {
212 | label: string
213 | prefix: string
214 | hidden: boolean
215 | url: string
216 | isFolder: boolean
217 | parentFolder: OSSObjectTreeItem | null
218 | parentFolderIsObject: boolean
219 | children: OSSObjectTreeItem[] = []
220 | isTruncated = false
221 | marker = ''
222 | constructor(options: OSSObjectTreeItemOptions) {
223 | super(options.label, options.collapsibleState)
224 | // folder has children object/folder
225 | this.isFolder = options.collapsibleState !== undefined
226 | // this.id = options.id
227 | this.label = options.label
228 | this.description = options.description
229 | this.iconPath = options.iconPath
230 | this.contextValue = options.contextValue
231 | this.prefix = options.prefix || ''
232 | this.hidden = !!options.hidden
233 | this.url = options.url || ''
234 | this.parentFolder = options.parentFolder || null
235 | this.parentFolderIsObject = !!options.parentFolderIsObject
236 | this.resourceUri = options.resourceUri
237 | this.command = options.command
238 | }
239 | get tooltip(): string {
240 | return `${this.label}`
241 | }
242 | }
243 |
244 | class ErrorTreeItem extends OSSObjectTreeItem {
245 | constructor() {
246 | super({
247 | label: TIP_FAILED_INIT,
248 | iconPath: getThemedIconPath('statusWarning'),
249 | contextValue: CONTEXT_VALUE.CONNECT_ERROR
250 | })
251 | }
252 | }
253 | interface ShowMoreTreeItemOptions {
254 | parentFolder: OSSObjectTreeItem
255 | nextMarker: string
256 | }
257 |
258 | export class ShowMoreTreeItem extends OSSObjectTreeItem {
259 | nextMarker: string
260 | constructor(options: ShowMoreTreeItemOptions) {
261 | super({ label: 'Show More', parentFolder: options.parentFolder })
262 | this.contextValue = CONTEXT_VALUE.PAGER
263 | this.nextMarker = options.nextMarker
264 | this.command = this.getCommand()
265 | this.iconPath = getThemedIconPath('ellipsis')
266 | }
267 | getCommand(): vscode.Command {
268 | return {
269 | command: CommandContext.BUCKET_EXPLORER_SHOW_MORE_CHILDREN,
270 | title: 'Show More',
271 | arguments: [this]
272 | }
273 | }
274 | showMore(): void {
275 | if (!this.parentFolder) return
276 | this.parentFolder.marker = this.nextMarker
277 | ext.bucketExplorer.refresh(this.parentFolder, false)
278 | }
279 | }
280 |
--------------------------------------------------------------------------------
/src/views/iconPath.ts:
--------------------------------------------------------------------------------
1 | /*---------------------------------------------------------------------------------------------
2 | * Visual Studio Code Extension for Docker
3 | * Copyright (c) Microsoft Corporation. All rights reserved.
4 | * Licensed under the MIT License.
5 | *--------------------------------------------------------------------------------------------*/
6 |
7 | import path from 'path'
8 | import { Uri } from 'vscode'
9 | import { ext } from '../extensionVariables'
10 |
11 | export type IconPath =
12 | | string
13 | | Uri
14 | | { light: string | Uri; dark: string | Uri }
15 |
16 | export function getIconPath(iconName: string): IconPath {
17 | return path.join(getResourcesPath(), `${iconName}.svg`)
18 | }
19 |
20 | export function getThemedIconPath(iconName: string): IconPath {
21 | return {
22 | light: path.join(getResourcesPath(), 'light', `${iconName}.svg`),
23 | dark: path.join(getResourcesPath(), 'dark', `${iconName}.svg`)
24 | }
25 | }
26 |
27 | function getResourcesPath(): string {
28 | return ext.context.asAbsolutePath('resources')
29 | }
30 |
--------------------------------------------------------------------------------
/src/views/registerBucket.ts:
--------------------------------------------------------------------------------
1 | import vscode from 'vscode'
2 | import { ext } from '@/extensionVariables'
3 | import { deleteFromBucketExplorerContext } from '@/commands/bucketExplorer/deleteFromContext'
4 | import { uploadFromBucketExplorerContext } from '@/commands/bucketExplorer/uploadFromContext'
5 | import { uploadFromBucketExplorerClipboard } from '@/commands/bucketExplorer/uploadFromClipboard'
6 | import { copyLinkFromBucketExplorer } from '@/commands/bucketExplorer/copyLink'
7 | import { copyFromBucketExplorerContext } from '@/commands/bucketExplorer/copyFromContext'
8 | import { moveFromBucketExplorerContext } from '@/commands/bucketExplorer/moveFromContext'
9 | import { BucketExplorerProvider } from './bucket'
10 | import { CommandContext } from '@/constant'
11 | import { ShowMoreTreeItem } from '@/views/bucket'
12 |
13 | export function registerBucket(): vscode.Disposable[] {
14 | ext.bucketExplorer = new BucketExplorerProvider()
15 | ext.bucketExplorerTreeView = vscode.window.createTreeView('bucketExplorer', {
16 | treeDataProvider: ext.bucketExplorer,
17 | // TODO: support select many
18 | // canSelectMany: true,
19 | showCollapseAll: true
20 | })
21 | const _disposable = [
22 | vscode.commands.registerCommand(
23 | uploadFromBucketExplorerContext.command,
24 | uploadFromBucketExplorerContext
25 | ),
26 | vscode.commands.registerCommand(
27 | CommandContext.BUCKET_EXPLORER_SHOW_MORE_CHILDREN,
28 | (node: ShowMoreTreeItem) => {
29 | node.showMore()
30 | }
31 | ),
32 | vscode.commands.registerCommand(
33 | CommandContext.BUCKET_EXPLORER_REFRESH_ROOT,
34 | () => ext.bucketExplorer.refresh()
35 | ),
36 | vscode.commands.registerCommand(
37 | deleteFromBucketExplorerContext.command,
38 | deleteFromBucketExplorerContext
39 | ),
40 | vscode.commands.registerCommand(
41 | uploadFromBucketExplorerClipboard.command,
42 | uploadFromBucketExplorerClipboard
43 | ),
44 | vscode.commands.registerCommand(
45 | copyLinkFromBucketExplorer.command,
46 | copyLinkFromBucketExplorer
47 | ),
48 | vscode.commands.registerCommand(
49 | moveFromBucketExplorerContext.command,
50 | moveFromBucketExplorerContext
51 | ),
52 | vscode.commands.registerCommand(
53 | copyFromBucketExplorerContext.command,
54 | copyFromBucketExplorerContext
55 | ),
56 | ext.bucketExplorerTreeView
57 | ]
58 |
59 | ext.bucketExplorerTreeView.onDidChangeVisibility(
60 | ({ visible }) => {
61 | ext.bucketExplorerTreeViewVisible = visible
62 | },
63 | null,
64 | _disposable
65 | )
66 | return _disposable
67 | }
68 |
--------------------------------------------------------------------------------
/src/webview/imagePreview.ts:
--------------------------------------------------------------------------------
1 | import vscode from 'vscode'
2 | import path from 'path'
3 |
4 | export class ElanImagePreviewPanel {
5 | /**
6 | * Track the currently panel. Only allow a single panel to exist at a time.
7 | */
8 | public static currentPanel: ElanImagePreviewPanel | undefined
9 |
10 | public static readonly viewType = 'elanImagePreview'
11 |
12 | private readonly _panel: vscode.WebviewPanel
13 | private readonly _extensionUri: vscode.Uri
14 | private _disposables: vscode.Disposable[] = []
15 |
16 | private _imageSrc: string
17 |
18 | public static createOrShow(extensionUri: vscode.Uri, imageSrc: string): void {
19 | // If we already have a panel, show it.
20 | if (ElanImagePreviewPanel.currentPanel) {
21 | ElanImagePreviewPanel.currentPanel.setImageSrc(imageSrc)
22 | const panel = ElanImagePreviewPanel.currentPanel._panel
23 | panel.reveal()
24 |
25 | panel.webview.postMessage({
26 | type: 'setActive',
27 | value: panel.active
28 | })
29 | return
30 | }
31 |
32 | const column = vscode.window.activeTextEditor
33 | ? // ? vscode.window.activeTextEditor.viewColumn
34 | vscode.ViewColumn.Beside
35 | : undefined
36 |
37 | // Otherwise, create a new panel.
38 | const panel = vscode.window.createWebviewPanel(
39 | ElanImagePreviewPanel.viewType,
40 | 'Elan Preview',
41 | column || vscode.ViewColumn.One,
42 | {
43 | // Enable javascript in the webview
44 | enableScripts: true,
45 |
46 | // And restrict the webview to only loading content from our extension's `media` directory.
47 | localResourceRoots: [
48 | vscode.Uri.file(path.join(extensionUri.fsPath, 'resources'))
49 | ]
50 | }
51 | )
52 |
53 | ElanImagePreviewPanel.currentPanel = new ElanImagePreviewPanel(
54 | panel,
55 | imageSrc,
56 | extensionUri
57 | )
58 | }
59 |
60 | public static revive(
61 | panel: vscode.WebviewPanel,
62 | imageSrc: string,
63 | extensionUri: vscode.Uri
64 | ): void {
65 | ElanImagePreviewPanel.currentPanel = new ElanImagePreviewPanel(
66 | panel,
67 | imageSrc,
68 | extensionUri
69 | )
70 | }
71 |
72 | private constructor(
73 | panel: vscode.WebviewPanel,
74 | imageSrc: string,
75 | extensionUri: vscode.Uri
76 | ) {
77 | this._panel = panel
78 | this._extensionUri = extensionUri
79 | this._imageSrc = imageSrc
80 |
81 | // Listen for when the panel is disposed
82 | // This happens when the user closes the panel or when the panel is closed programatically
83 | this._panel.onDidDispose(() => this.dispose(), null, this._disposables)
84 | this._panel.onDidChangeViewState(
85 | () => {
86 | this._panel.webview.postMessage({
87 | type: 'setActive',
88 | value: this._panel.active
89 | })
90 | },
91 | this,
92 | this._disposables
93 | )
94 |
95 | // Set the webview's initial html content
96 | this._update()
97 | this._panel.webview.postMessage({
98 | type: 'setActive',
99 | value: this._panel.active
100 | })
101 | }
102 | public setImageSrc(imageSrc: string): void {
103 | this._imageSrc = imageSrc
104 | // TODO: use postMessage to replace image-src, because we should not reload .js .css ?
105 | // TODO: add force-update configuration for loading image from oss or add cache-control in oss client's put method? since the object name contains hash, don't care ?
106 | // but .js .css load by memory cache, so it doesn't matter?
107 | this._update()
108 | }
109 |
110 | public dispose(): void {
111 | ElanImagePreviewPanel.currentPanel = undefined
112 |
113 | // Clean up our resources
114 | this._panel.dispose()
115 |
116 | while (this._disposables.length) {
117 | const x = this._disposables.pop()
118 | if (x) {
119 | x.dispose()
120 | }
121 | }
122 | }
123 |
124 | private _update(): void {
125 | const webview = this._panel.webview
126 | webview.html = this._getHtmlForWebview(webview)
127 | }
128 |
129 | private _getHtmlForWebview(webview: vscode.Webview): string {
130 | // // Local path to main script run in the webview
131 | // const scriptPathOnDisk = vscode.Uri.file(
132 | // path.join(this._extensionUri, 'media', 'main.js')
133 | // )
134 |
135 | // And the uri we use to load this script in the webview
136 | // const scriptUri = webview.asWebviewUri(scriptPathOnDisk)
137 |
138 | // Use a nonce to whitelist which scripts can be run
139 | const nonce = getNonce()
140 | const settings = {
141 | isMac: process.platform === 'darwin',
142 | src: this._imageSrc
143 | }
144 |
145 | return `
146 |
147 |
148 |
149 |
150 |
154 |
157 |
162 |
163 |
164 |
167 | Image Preview
168 |
169 |
170 |
171 |
172 |
${'An error occurred while loading the image.'}
173 |
174 |
177 |
178 | `
179 | }
180 | private extensionResource(path: string): vscode.Uri {
181 | return this._panel.webview.asWebviewUri(
182 | this._extensionUri.with({
183 | path: this._extensionUri.path + path
184 | })
185 | )
186 | }
187 | }
188 |
189 | function getNonce(): string {
190 | let text = ''
191 | const possible =
192 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
193 | for (let i = 0; i < 32; i++) {
194 | text += possible.charAt(Math.floor(Math.random() * possible.length))
195 | }
196 | return text
197 | }
198 |
199 | function escapeAttribute(value: string | vscode.Uri): string {
200 | return value.toString().replace(/"/g, '"')
201 | }
202 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "target": "es6",
5 | "outDir": "out",
6 | "baseUrl": ".",
7 | "paths": {
8 | "@/*": ["src/*"]
9 | },
10 | "lib": ["es6"],
11 | "sourceMap": true,
12 | "rootDir": "src",
13 | "strict": true /* enable all strict type-checking options */,
14 | "esModuleInterop": true
15 | /* Additional Checks */
16 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
17 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
18 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
19 | },
20 | "exclude": ["node_modules", ".vscode-test"]
21 | }
22 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | const path = require('path')
3 | const webpack = require('webpack')
4 | const CopyPlugin = require('copy-webpack-plugin')
5 |
6 | const { CleanWebpackPlugin } = require('clean-webpack-plugin') // Wow!
7 |
8 | /**@type {import('webpack').Configuration}*/ const config = {
9 | target: 'node',
10 | node: {
11 | __dirname: false
12 | },
13 |
14 | entry: './src/extension.ts',
15 | output: {
16 | path: path.resolve(__dirname, 'dist'),
17 | filename: 'extension.js',
18 | libraryTarget: 'commonjs2',
19 | devtoolModuleFilenameTemplate: '../[resource-path]' // means source code in dist/../[resource-path]
20 | },
21 | devtool: 'source-map',
22 | externals: {
23 | vscode: 'commonjs vscode'
24 | },
25 | resolve: {
26 | extensions: ['.ts', '.js'],
27 | alias: {
28 | '@': path.resolve(__dirname, 'src')
29 | }
30 | },
31 | module: {
32 | rules: [
33 | {
34 | test: /\.ts$/,
35 | exclude: /node_modules/,
36 | use: [
37 | {
38 | loader: 'ts-loader',
39 | options: {
40 | // TODO: since @types/ali-oss has bug, we shouldn't emit error when build for release, need pr @types/ali-oss
41 | transpileOnly: process.env.NODE_ENV === 'production'
42 | }
43 | }
44 | ]
45 | }
46 | ]
47 | },
48 | plugins: [
49 | new CleanWebpackPlugin(),
50 | new webpack.DefinePlugin({
51 | 'process.env.NODE_ENV': JSON.stringify('production')
52 | }),
53 | new CopyPlugin({
54 | patterns: [{ from: 'src/utils/clipboard', to: 'clipboard' }]
55 | })
56 | ]
57 | }
58 |
59 | module.exports = config
60 |
--------------------------------------------------------------------------------