├── .cz-config.js ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .prettierrc.js ├── .versionrc.js ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── README_en_US.md ├── package.json ├── resources ├── activitybar.svg ├── dark │ ├── add.svg │ ├── database.svg │ ├── ellipsis.svg │ ├── link.svg │ ├── refresh.svg │ └── statusWarning.svg ├── light │ ├── add.svg │ ├── database.svg │ ├── ellipsis.svg │ ├── link.svg │ ├── refresh.svg │ └── statusWarning.svg ├── logo.png ├── readme-logo.png └── webview │ ├── loading-dark.svg │ ├── loading-hc.svg │ ├── loading.svg │ ├── main.css │ └── main.js ├── src ├── commands │ ├── bucketExplorer │ │ ├── copyFromContext.ts │ │ ├── copyLink.ts │ │ ├── deleteFromContext.ts │ │ ├── moveFromContext.ts │ │ ├── showMore.ts │ │ ├── uploadFromClipboard.ts │ │ └── uploadFromContext.ts │ ├── deleteByHover.ts │ ├── setOSSConfiguration.ts │ ├── uploadFromClipboard.ts │ ├── uploadFromExplorer.ts │ └── uploadFromExplorerContext.ts ├── constant.ts ├── extension.ts ├── extensionVariables.ts ├── language │ └── hover.ts ├── test │ ├── runTest.ts │ └── suite │ │ ├── extension.test.ts │ │ └── index.ts ├── uploader │ ├── copyUri.ts │ ├── deleteUri.ts │ ├── index.ts │ ├── moveUri.ts │ ├── templateStore.ts │ └── uploadUris.ts ├── utils │ ├── clipboard │ │ ├── linux.sh │ │ ├── mac.applescript │ │ └── pc.ps1 │ ├── index.ts │ └── log.ts ├── views │ ├── bucket.ts │ ├── iconPath.ts │ └── registerBucket.ts └── webview │ └── imagePreview.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.cz-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | types: [ 3 | { value: 'feat', name: '✨ feat: A new feature' }, 4 | { value: 'fix', name: '🐛 fix: A bug fix' }, 5 | { value: 'docs', name: '📝 docs: Documentation only changes' }, 6 | { 7 | value: 'style', 8 | name: 9 | '🎨 style: Changes that do not affect the meaning of the code\n (white-space, formatting, missing semi-colons, etc)' 10 | }, 11 | { 12 | value: 'refactor', 13 | name: 14 | '🔨 refactor: A code change that neither fixes a bug nor adds a feature' 15 | }, 16 | { 17 | value: 'perf', 18 | name: '🚄 perf: A code change that improves performance' 19 | }, 20 | { value: 'test', name: '✅ test: Adding missing tests' }, 21 | { 22 | value: 'chore', 23 | name: 24 | '💩 chore: Changes to the build process or auxiliary tools\n and libraries such as documentation generation' 25 | }, 26 | { value: 'revert', name: '⏪ revert: Revert to a commit' }, 27 | { value: 'WIP', name: '🚧 WIP: Work in progress' }, 28 | { 29 | value: 'ci', 30 | name: '💚 ci: CI Build.' 31 | } 32 | ], 33 | 34 | scopes: [{ name: 'uploader' }, {name: 'hover'}, {name: 'bucketExplorer'}, { name: 'templateSubstitute' }], 35 | 36 | allowTicketNumber: false, 37 | isTicketNumberRequired: false, 38 | ticketNumberPrefix: 'TICKET-', 39 | ticketNumberRegExp: '\\d{1,5}', 40 | 41 | // it needs to match the value for field type. Eg.: 'fix' 42 | /* 43 | scopeOverrides: { 44 | fix: [ 45 | {name: 'merge'}, 46 | {name: 'style'}, 47 | {name: 'e2eTest'}, 48 | {name: 'unitTest'} 49 | ] 50 | }, 51 | */ 52 | // override the messages, defaults are as follows 53 | messages: { 54 | type: "Select the type of change that you're committing:", 55 | scope: '\nDenote the SCOPE of this change (optional):', 56 | // used if allowCustomScopes is true 57 | customScope: 'Denote the SCOPE of this change:', 58 | subject: 'Write a SHORT, IMPERATIVE tense description of the change:\n', 59 | body: 60 | 'Provide a LONGER description of the change (optional). Use "|" to break new line:\n', 61 | breaking: 'List any BREAKING CHANGES (optional):\n', 62 | footer: 63 | 'List any ISSUES CLOSED by this change (optional). E.g.: #31, #34:\n', 64 | confirmCommit: 'Are you sure you want to proceed with the commit above?' 65 | }, 66 | 67 | allowCustomScopes: true, 68 | allowBreakingChanges: ['feat', 'fix'], 69 | // skip any questions you want 70 | // skipQuestions: ['body'], 71 | 72 | // limit subject length 73 | subjectLimit: 100 74 | // breaklineChar: '|', // It is supported for fields body and footer. 75 | // footerPrefix : 'ISSUES CLOSED:' 76 | // askForBreakingChangeFirst : true, // default is false 77 | } 78 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = false 9 | insert_final_newline = false -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | out 3 | webpack.config.js 4 | resources/webview/main.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | parserOptions: { 5 | ecmaVersion: 6, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint'], 9 | rules: { 10 | '@typescript-eslint/no-use-before-define': 'off', 11 | 'no-console': ['error'] 12 | }, 13 | extends: [ 14 | 'eslint:recommended', 15 | 'plugin:@typescript-eslint/eslint-recommended', 16 | 'plugin:@typescript-eslint/recommended', 17 | "plugin:prettier/recommended", 18 | "prettier/@typescript-eslint", 19 | "prettier/standard" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build Asset and Upload Release Asset 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [14.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - run: npm install yarn@1.22.4 -g 23 | - name: Get yarn cache directory path 24 | id: yarn-cache-dir-path 25 | run: echo "::set-output name=dir::$(yarn cache dir)" 26 | - uses: actions/cache@v2 27 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 28 | with: 29 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 30 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 31 | restore-keys: | 32 | ${{ runner.os }}-yarn- 33 | - run: yarn install 34 | - run: yarn build 35 | - run: yarn vspack 36 | - name: Create Release 37 | id: create_release 38 | uses: actions/create-release@v1 39 | env: 40 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 41 | with: 42 | tag_name: ${{github.ref}} 43 | release_name: Release ${{github.ref}} 44 | draft: false 45 | prerelease: false 46 | - name: Get tag name 47 | id: get_tag_name 48 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 49 | - name: Upload Release Asset 50 | id: upload-release-asset 51 | uses: actions/upload-release-asset@v1 52 | env: 53 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 54 | with: 55 | upload_url: ${{ steps.create_release.outputs.upload_url }} 56 | asset_path: ./aliyun-oss-uploader.vsix 57 | asset_name: aliyun-oss-uploader-${{steps.get_tag_name.outputs.VERSION}}.vsix 58 | asset_content_type: application/zip 59 | - run: npm install vsce -g 60 | - name: Publish Extension to Market 61 | run: vsce publish -p ${{secrets.VSCE_TOKEN}} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test/ 4 | *.vsix 5 | .DS_Store 6 | dist -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | semi: false, 4 | trailingComma: 'none' 5 | } -------------------------------------------------------------------------------- /.versionrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | types: [ 3 | { type: 'feat', section: 'Features' }, 4 | { type: 'fix', section: 'Bug Fixes' }, 5 | { type: 'test', section: 'Tests', hidden: true }, 6 | { type: 'ci', hidden: true }, 7 | { type: 'chore', hidden: true }, 8 | { type: 'docs', hidden: true }, 9 | { type: 'style', hidden: true }, 10 | { type: 'refactor', hidden: true }, 11 | { type: 'perf', hidden: true }, 12 | { type: 'ci', hidden: true} 13 | ], 14 | releaseCommitMessageFormat: 'chore(release): v{{currentTag}}' 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": [ 14 | "--disable-extensions", 15 | "--extensionDevelopmentPath=${workspaceFolder}" 16 | ], 17 | "outFiles": [ 18 | "${workspaceFolder}/dist/**/*.js" 19 | ], 20 | "preLaunchTask": "${defaultBuildTask}" 21 | }, 22 | { 23 | "name": "Extension Tests", 24 | "type": "extensionHost", 25 | "request": "launch", 26 | "runtimeExecutable": "${execPath}", 27 | "args": [ 28 | "--disable-extensions", 29 | "--extensionDevelopmentPath=${workspaceFolder}", 30 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 31 | ], 32 | "outFiles": [ 33 | "${workspaceFolder}/out/test/**/*.js" 34 | ], 35 | "preLaunchTask": "${defaultBuildTask}" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off" 11 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "always" 13 | } 14 | }, 15 | // https://zhuanlan.zhihu.com/p/54428900 16 | { 17 | "type": "npm", 18 | "script": "webpack-dev", 19 | "problemMatcher": { 20 | "owner": "typescript", 21 | "pattern": [ 22 | { 23 | // this regexp is invalid? not catch file/location/message ? 24 | "regexp": "\\[tsl\\] ERROR", 25 | "file": 1, 26 | "location": 2, 27 | "message": 3 28 | } 29 | ], 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": "Compilation \\w+ starting…", 33 | "endsPattern": "Compilation\\s+finished" 34 | } 35 | }, 36 | "isBackground": true, 37 | 38 | "presentation": { 39 | "reveal": "always" 40 | }, 41 | "group": { 42 | "kind": "build", 43 | "isDefault": true 44 | } 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | .github/** 4 | out/** 5 | src/** 6 | **/tsconfig.json 7 | **/*.map 8 | **/*.ts 9 | node_modules/** 10 | 11 | .editorconfig 12 | .gitignore 13 | .eslintrc.js 14 | .eslintignore 15 | webpack.config.js 16 | tsconfig*.json 17 | yarn.lock 18 | .prettierrc.js 19 | .versionrc.js 20 | .cz-config.js 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [1.7.0](https://github.com/fangbinwei/aliyun-oss-uploader/compare/v1.6.0...v1.7.0) (2020-08-22) 6 | 7 | 8 | ### Features 9 | 10 | * **templateSubstitute:** support 'relativeToVsRootPath' trim prefix ([2ccef64](https://github.com/fangbinwei/aliyun-oss-uploader/commit/2ccef64a92e15991723d448fcdd594b209cb1567)) 11 | 12 | ## [1.6.0](https://github.com/fangbinwei/aliyun-oss-uploader/compare/v1.5.1...v1.6.0) (2020-08-22) 13 | 14 | 15 | ### Features 16 | 17 | * **bucketExplorer:** support pagination ([134da5e](https://github.com/fangbinwei/aliyun-oss-uploader/commit/134da5e2cae74bf0462d98272e6e4f53a506658f)) 18 | * **templateSubstitute:** support 'year','month','date', 'pathname' ([f2bb312](https://github.com/fangbinwei/aliyun-oss-uploader/commit/f2bb3121728db1765687f5e8efc529cbc14012b7)), closes [#4](https://github.com/fangbinwei/aliyun-oss-uploader/issues/4) 19 | 20 | 21 | ### Bug Fixes 22 | 23 | * **templateSubstitute:** fix `relativeToVsRootPath` bug ([28a8504](https://github.com/fangbinwei/aliyun-oss-uploader/commit/28a850435eb01fed0a83f097fea6766378958e94)) 24 | 25 | ### [1.5.1](https://github.com/fangbinwei/aliyun-oss-uploader/compare/v1.5.0...v1.5.1) (2020-07-26) 26 | 27 | 28 | ### Bug Fixes 29 | 30 | * **hover:** fix hoverDelete when customDomain has been set ([77241df](https://github.com/fangbinwei/aliyun-oss-uploader/commit/77241df245d8af659ce6a330b07cdb0a893c63f6)) 31 | 32 | ## [1.5.0](https://github.com/fangbinwei/aliyun-oss-uploader/compare/v1.4.1...v1.5.0) (2020-07-26) 33 | 34 | 35 | ### Features 36 | 37 | * can upload files other than pic ([03a1d47](https://github.com/fangbinwei/aliyun-oss-uploader/commit/03a1d472c6f5f65b01ed66effe59be5a28051565)) 38 | * **uploader:** support custom domain ([e367373](https://github.com/fangbinwei/aliyun-oss-uploader/commit/e36737381b02f64ec0d868fc8a2180e0665268f9)), closes [#2](https://github.com/fangbinwei/aliyun-oss-uploader/issues/2) 39 | 40 | 41 | ### Bug Fixes 42 | 43 | * **configuration:** ignore focus out ([34ea93e](https://github.com/fangbinwei/aliyun-oss-uploader/commit/34ea93e97a584fd60c6fe1da4c57691821155a5f)) 44 | 45 | ### [1.4.1](https://github.com/fangbinwei/aliyun-oss-uploader/compare/v1.4.0...v1.4.1) (2020-06-27) 46 | 47 | 48 | ### Bug Fixes 49 | 50 | * **bucketExplorer:** fix bucket list bug ([44bebbd](https://github.com/fangbinwei/aliyun-oss-uploader/commit/44bebbd23092afe5cf73c76348ead2c6f84f160c)) 51 | 52 | ## [1.4.0](https://github.com/fangbinwei/aliyun-oss-uploader/compare/v1.2.0...v1.4.0) (2020-06-27) 53 | 54 | 55 | ### Features 56 | 57 | * **bucketExplorer:** add config 'elan.bucketView.onlyShowImages' ([a618ab9](https://github.com/fangbinwei/aliyun-oss-uploader/commit/a618ab9b076ebeb67929993c35deeb15384a1da4)) 58 | * **bucketExplorer:** add treeView of bucket ([b5021e4](https://github.com/fangbinwei/aliyun-oss-uploader/commit/b5021e4fad7dc9d409f3754af7b54ab9ff30ad16)) 59 | * **bucketExplorer:** can delete single from bucketExplorer ([bbea6d5](https://github.com/fangbinwei/aliyun-oss-uploader/commit/bbea6d510701a8334d7b7c0a21f3bfc1edc424d6)) 60 | * **bucketExplorer:** can upload from bucket explorer ([9c033f8](https://github.com/fangbinwei/aliyun-oss-uploader/commit/9c033f81025175fce097fcf3fec840d8329af868)) 61 | * **bucketExplorer:** copy link ([8a0bf04](https://github.com/fangbinwei/aliyun-oss-uploader/commit/8a0bf044346fcc721a95a4756444d5c8f6dec6fe)) 62 | * **bucketExplorer:** copy object ([acea402](https://github.com/fangbinwei/aliyun-oss-uploader/commit/acea40242a7571584981b1155e794a9ab69bdeef)) 63 | * **bucketExplorer:** move/rename object ([e30d95b](https://github.com/fangbinwei/aliyun-oss-uploader/commit/e30d95bf4456e4ec2ca06cd4c1bf78e8547d7884)) 64 | * **bucketExplorer:** support uploading from clipboard ([8edae28](https://github.com/fangbinwei/aliyun-oss-uploader/commit/8edae282c7cd72822bb88ebe7323406957007a58)) 65 | * **uploader:** add config 'elan.aliyun.maxKeys' ([3a0aac9](https://github.com/fangbinwei/aliyun-oss-uploader/commit/3a0aac9788f9afa557866819c94cc64e7b6d5cfa)) 66 | * **webview:** preview the image of bucket explorer ([81c0606](https://github.com/fangbinwei/aliyun-oss-uploader/commit/81c060669f9422c7f94bac576c3b68305df81628)) 67 | * help user to set configuration ([3451fbe](https://github.com/fangbinwei/aliyun-oss-uploader/commit/3451fbe155aaa3a0a93e9c2ef3f7944a735bff05)) 68 | 69 | 70 | ### Bug Fixes 71 | 72 | * **bucketExplorer:** add resourceUri for 'File' ([5411d54](https://github.com/fangbinwei/aliyun-oss-uploader/commit/5411d5432b1546e00433e561af4b575f2f561abf)) 73 | * **bucketExplorer:** copyFromContext selection ([319c620](https://github.com/fangbinwei/aliyun-oss-uploader/commit/319c620a4089503d1742feceeebd7b57791010f7)) 74 | * **bucketExplorer:** ext case-insensitive ([5f6916b](https://github.com/fangbinwei/aliyun-oss-uploader/commit/5f6916bb964fafba73f5b56d15ad008ae72d08aa)) 75 | * **hover:** fix hover delete can't get correct uri ([b2f2d65](https://github.com/fangbinwei/aliyun-oss-uploader/commit/b2f2d65d01108dddb24b022e48801e730da28030)) 76 | * fix disposable ([76d67e9](https://github.com/fangbinwei/aliyun-oss-uploader/commit/76d67e93a0b023963ecc446935c08abc7ad70295)) 77 | 78 | ## [1.3.0](https://github.com/fangbinwei/aliyun-oss-uploader/compare/v1.2.0...v1.3.0) (2020-06-21) 79 | 80 | 81 | ### Features 82 | 83 | * **bucketExplorer:** add config 'elan.bucketView.onlyShowImages' ([a618ab9](https://github.com/fangbinwei/aliyun-oss-uploader/commit/a618ab9b076ebeb67929993c35deeb15384a1da4)) 84 | * **bucketExplorer:** can delete single from bucketExplorer ([bbea6d5](https://github.com/fangbinwei/aliyun-oss-uploader/commit/bbea6d510701a8334d7b7c0a21f3bfc1edc424d6)) 85 | * **bucketExplorer:** can upload from bucket explorer ([9c033f8](https://github.com/fangbinwei/aliyun-oss-uploader/commit/9c033f81025175fce097fcf3fec840d8329af868)) 86 | * **uploader:** add config 'elan.aliyun.maxKeys' ([3a0aac9](https://github.com/fangbinwei/aliyun-oss-uploader/commit/3a0aac9788f9afa557866819c94cc64e7b6d5cfa)) 87 | * help user to set configuration ([3451fbe](https://github.com/fangbinwei/aliyun-oss-uploader/commit/3451fbe155aaa3a0a93e9c2ef3f7944a735bff05)) 88 | * **bucketExplorer:** add treeView of bucket ([b5021e4](https://github.com/fangbinwei/aliyun-oss-uploader/commit/b5021e4fad7dc9d409f3754af7b54ab9ff30ad16)) 89 | * **bucketExplorer:** copy link ([8a0bf04](https://github.com/fangbinwei/aliyun-oss-uploader/commit/8a0bf044346fcc721a95a4756444d5c8f6dec6fe)) 90 | * **bucketExplorer:** copy object ([acea402](https://github.com/fangbinwei/aliyun-oss-uploader/commit/acea40242a7571584981b1155e794a9ab69bdeef)) 91 | * **bucketExplorer:** move/rename object ([e30d95b](https://github.com/fangbinwei/aliyun-oss-uploader/commit/e30d95bf4456e4ec2ca06cd4c1bf78e8547d7884)) 92 | * **bucketExplorer:** support uploading from clipboard ([8edae28](https://github.com/fangbinwei/aliyun-oss-uploader/commit/8edae282c7cd72822bb88ebe7323406957007a58)) 93 | 94 | 95 | ### Bug Fixes 96 | 97 | * **bucketExplorer:** copyFromContext selection ([319c620](https://github.com/fangbinwei/aliyun-oss-uploader/commit/319c620a4089503d1742feceeebd7b57791010f7)) 98 | * **hover:** fix hover delete can't get correct uri ([b2f2d65](https://github.com/fangbinwei/aliyun-oss-uploader/commit/b2f2d65d01108dddb24b022e48801e730da28030)) 99 | * fix disposable ([76d67e9](https://github.com/fangbinwei/aliyun-oss-uploader/commit/76d67e93a0b023963ecc446935c08abc7ad70295)) 100 | 101 | ## [1.2.0](https://github.com/fangbinwei/aliyun-oss-uploader/compare/v1.1.0...v1.2.0) (2020-06-11) 102 | 103 | 104 | ### Features 105 | 106 | * **uploader:** delete image when hovering link in markdown ([27e3022](https://github.com/fangbinwei/aliyun-oss-uploader/commit/27e302217d99fbdf8bdf0427d83cd174d0ab0370)) 107 | 108 | ## [1.1.0](https://github.com/fangbinwei/aliyun-oss-uploader/compare/v1.0.2...v1.1.0) (2020-06-08) 109 | 110 | 111 | ### Features 112 | 113 | * **templateSubstitute:** add '${activeMdFilename}' ([6aecef5](https://github.com/fangbinwei/aliyun-oss-uploader/commit/6aecef57e647c336bff914b86fb388d4ebc32b36)) 114 | 115 | ### [1.0.2](https://github.com/fangbinwei/aliyun-oss-uploader/compare/v1.0.1...v1.0.2) (2020-06-01) 116 | 117 | 118 | ### Bug Fixes 119 | 120 | * **templateSubstitute:** fix 'bucketFolder' replace error ([89cdddb](https://github.com/fangbinwei/aliyun-oss-uploader/commit/89cdddb7c6c411f5a7bf3175266978299d6ba0a6)) 121 | 122 | ### [1.0.1](https://github.com/fangbinwei/aliyun-oss-uploader/compare/v1.0.0...v1.0.1) (2020-05-31) 123 | 124 | 125 | ### Bug Fixes 126 | 127 | * delete defalut value explanation ([b410aa6](https://github.com/fangbinwei/aliyun-oss-uploader/commit/b410aa672e0beaa6c275bc2bc0b904fd1240803d)) 128 | 129 | ## 1.0.0 (2020-05-31) 130 | 131 | 132 | ### Features 133 | 134 | * **templateSubstitute:** support contentHash ([1bb0c46](https://github.com/fangbinwei/aliyun-oss-uploader/commit/1bb0c46174954f9c5cf52b8eafd238a34b6e549a)) 135 | * github flavor markdown ([f7b95ec](https://github.com/fangbinwei/aliyun-oss-uploader/commit/f7b95ecf487965d6bfade2d677e6abd402a6e649)) 136 | * init ([25d7ef0](https://github.com/fangbinwei/aliyun-oss-uploader/commit/25d7ef0a312406bfeabad255d398e0992dc725e0)) 137 | * specify 'folder' of bucket ([f0bcb16](https://github.com/fangbinwei/aliyun-oss-uploader/commit/f0bcb164d2c0e16ae74483718edfc513268bec84)) 138 | * upload from clipboard ([963706d](https://github.com/fangbinwei/aliyun-oss-uploader/commit/963706d53db9dc6374f1948dc9cb6704dc35da0c)) 139 | * upload image from explorer context ([0cd8e4d](https://github.com/fangbinwei/aliyun-oss-uploader/commit/0cd8e4d98e5b887447906970f2c9443c11819b7f)) 140 | 141 | 142 | ### Bug Fixes 143 | 144 | * **explorer/context:** ext case-insensitive ([560c4fd](https://github.com/fangbinwei/aliyun-oss-uploader/commit/560c4fd683308ec59a9bb003b172b29054b716ed)) 145 | * **templateSubstitute:** handle boundary conditions of `bucketFolder` slash ([747c905](https://github.com/fangbinwei/aliyun-oss-uploader/commit/747c905bc48da160376278434f867b8bfd8fc332)) 146 | * **uploader:** fix error handler ([74c08ff](https://github.com/fangbinwei/aliyun-oss-uploader/commit/74c08ff7c23c6e14ac08cb95142b6035bd0ba013)) 147 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Binwei Fang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aliyun OSS Uploader 2 | 3 |

4 |
5 | Élan Logo. Logo designed by https://www.launchaco.com/ 6 |

7 | 8 | > Élan is a vscode extension focus on uploading image to Alibaba Cloud (Aliyun) OSS. 9 | 10 | [English README](https://github.com/fangbinwei/aliyun-oss-uploader/blob/master/README_en_US.md) 11 | 12 | 如果github README看demo gif卡顿, 可以在[vscode market中查看](https://marketplace.visualstudio.com/items?itemName=fangbinwei.aliyun-oss-uploader) 13 | 14 | ## Support 15 | If you find it useful, please [star me on Github](https://github.com/fangbinwei/aliyun-oss-uploader). 16 | 17 | ## Usage 18 | 19 | 1. 首先需要在阿里云上创建一个[OSS实例](https://www.aliyun.com/product/oss/?lang=en), 创建一个新的bucket, 获取`accessKeyId` and `accessKeySecret`, 具体可以参考[这里](#create-bucket) 20 | 21 | 2. 设置插件的配置 22 | 23 | - 初次使用可以在侧边栏点击插件的图标, 里面有个按钮可以帮助设置配置. 24 | - 在命令面板(`ctrl+shift+p`/`command+shift+p`/`F1`)输入`elan set configuration`, 和上面点击按钮设置配置的效果一样. 25 | - 当然也可以直接在vscode的配置页面找到本插件, 进行配置 26 | 27 | ![set configuration](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/setConfiguration.png) 28 | 29 | 30 | ### 上传图片 31 | 32 | * bucket树上传指定图片/剪贴板中的图片 33 | * 使用命令来上传指定图片/剪贴板中的图片 34 | - 在命令版中输入'elan' 可以看到对应命令 35 | 36 | * 在vscode的文件explorer中右键图片, 选择上传(`Elan: upload image`) 37 | 38 | ### 删除已上传的图片 39 | * bucket树中右键要删除的图片, 进行删除 40 | 41 | * 在markdown中, 鼠标hover到图片的语法上, 点击`Delete image` 42 | 43 | > 暂时只支持markdown的图片语法 44 | 45 | ### 预览bucket中的图片 46 | 图片上传到OSS的bucket中后, 可以在bucket树中, 点击要预览的图片进行预览. 47 | 48 | ![image preview](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/image-preview.png) 49 | 50 | ### Usage Demo (Bucket TreeView) 51 | 52 | #### 上传系统中的图片/剪贴板中的图片 53 | 在bucket树中, 右键‘文件夹’或者bucket, 然后填写上传路径, 选择上传的图片, 进行上传. 上传完后, 你的剪贴板中会有markdown图片语法的输出, 你可以直接粘贴进行使用. 如果你已经打开了一个markdown文件, 会自动粘贴到你的文件中. 54 | 55 | ![bucketTreeView_upload](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/bucketTreeView_upload_9d027122.png) 56 | 57 | #### 复制/移动/重命名/删除 bucket中的图片 58 | 在bucket树中, 右键要操作的图片, 选择复制, 然后填写目标路径, 进行复制. 选择移动/重命名, 则要求填写的目标名称(包括路径和文件名). 59 | 60 | ![bucketTreeView_delete_copy_move](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/bucketTreeView_delete_copy_move_240549f5.png) 61 | 62 | ### 复制bucket中对象的链接 63 | 点击文件右侧的链接🔗图标, 即可复制 64 | 65 | ![bucketTreeView_copy_link](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/bucketTreeView_copy_link_6e710ef5.png) 66 | 67 | ### Usage Demo (Other) 68 | 69 | #### 使用命令上传剪贴板中的图片 70 | 打开命令面板, 输入`elan upload from clipboard` 71 | 72 | ![updateFromClipboard](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/updateFromClipboard_bf2399e2.gif) 73 | 74 | #### 通过vscode的dialog选择图片上传 75 | 打开命令面板, 输入`elan upload from explorer` 76 | 77 | ![updateFromExplorer](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/updateFromExplorer_9f6ee648.gif) 78 | 79 | 80 | #### 在vscode的文件explorer选择文件上传 81 | 在文件explorer中, 右键要上传的图片, 选择`elan: upload image` 82 | 83 | ![updateFromExplorerContext](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/updateFromExplorerContext_37c3aac0.gif) 84 | 85 | #### 通过hover删除图片 86 | 87 | ![hoverDeleteCut](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/hoverDeleteCut_f9af47b7.png) 88 | 89 | ![hoverDelete](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/hoverDelete_03dc5db7.gif) 90 | 91 | > 本README中所用的图片都是用本插件上传的 92 | 93 | ## 配置信息 94 | ### `elan.aliyun.accessKeyId` 95 | ### `elan.aliyun.accessKeySecret` 96 | ### `elan.aliyun.bucket` 97 | ### `elan.aliyun.region` 98 | 例如 `oss-cn-shanghai`, [具体可以查看这里](https://github.com/ali-sdk/ali-oss#data-regions). 99 | ### `elan.aliyun.maxKeys` 100 | bucket树每个层级显示的对象数量, 超出则会进行分页. 101 | 102 | 默认为100 103 | ### `elan.aliyun.secure` 104 | 是否启用HTTPS, 若开启, 对象url将使用https协议. 105 | 106 | 默认启用 107 | 108 | ### `elan.aliyun.customDomain` 109 | 若配置自定义域名, 对象url将使用所配置的域名. 110 | 111 | 需要注意的是, 若配置自定义域名, 且启用HTTPS(`elan.aliyun.secure`), 需要在阿里云配置证书. 可以使用CDN, 然后直接在CDN配置界面申请免费证书. [具体配置参考官方文档](https://help.aliyun.com/document_detail/27118.html?spm=5176.11785003.domainDetail.14.11264a14IGTgko). 112 | 113 | > 如果配置了自定义域名, `elan.aliyun.region`将被忽略. 114 | 115 | ### `elan.uploadName` 116 | 所上传的OSS对象, 在OSS中的命名 117 | 118 | 默认格式: `${fileName}_${contentHash:8}${ext}` 119 | 120 | - `${fileName}`: 所上传的文件名 121 | - `${ext}`: 所上传文件的扩展后缀, 例如`.png`. 122 | - `${contentHash}`: 文件内容的hash值. 和webpack的`contentHash`类似, 也可以指定选择使用的位数. 123 | - `${activeMdFilename}`: 如果当前打开了markdown文件, 这个就指所打开md文件的文件名. 124 | 125 | `contentHash`的计算方式, `${:contentHash::}`, 默认`hashType`是`md5`, `digestType`为`hex` 126 | 127 | ```js 128 | crypto.createHash('md5') 129 | .update(imageBuffer) 130 | .digest('hex') 131 | .substr(0, maxLength) 132 | 133 | ``` 134 | 135 | ### `elan.outputFormat` 136 | 成功上传后, 剪贴板中会有一段输出的字符串, 这个配置决定了输出字符串的格式. 如果你当前打开了一个markdown文件, 这段字符串会自动插入到你的markdown文件中. 137 | 138 | - `${fileName}`: 文件名 139 | - `${uploadName}`: 在oss中所保存的文件名, 是在`elan.uploadName`中配置的. 140 | - `${activeMdFilename}`: 如果上传的时候, 打开了md文件, 这个就是md文件名. 141 | - `${url}`: 文件的url. 142 | - `${pathname}`: 文件url的pathname, 若 `${url}`为 `https://example.org/path/to/your/image.png`, `${pathname}` 则为 `/path/to/your/image.png`. 143 | 144 | ### `elan.bucketFolder` 145 | > 如果你觉得这个配置有点复杂, 又想将文件上传到指定的文件夹, 建议在bucket树中上传 146 | 147 | 默认情况, 你的文件上传到bucket后, 它都是在bucket的‘根目录’, 文件命名是由`elan.uploadName`决定的. 但是有时候希望能用‘文件夹’来组织所上传的文件, 这个配置就有用了. 148 | 149 | > 如果在bucket树中上传, 则不需要这个配置, 因为bucket树中上传的时候可以直接指定文件路径 150 | 151 | 举个例子, 上传`example.png`. 如果设置`elan.bucketFold` 为 `github/aliyun-oss-uploader`, 那我们的图片就会被上传到"文件夹" `github/aliyun-oss-uploader/`中. 我们这里提到的文件夹和传统的文件夹不一样, [OSS只是模拟了文件夹](https://help.aliyun.com/document_detail/31827.html), 其实我们上传的`example.png`在OSS中保存的是`github/aliyun-oss-uploader/example.png`, OSS只是用`/`分割符来模拟文件夹. 152 | 153 | ![oss browser](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/2020-05-31-19-02-13_55660788.png) 154 | 155 | - `${year}`: 当前年 156 | - `${month}`: 当前月, 会补零, 例如 '02' 157 | - `${date}`: 当前日, 会补零, 例如 '04' 158 | - `${relativeToVsRootPath}`: 这个配置比较抽象, 假如我们上传的时候, vscode编辑器有打开一个文件, 那这个配置就指代所打开文件所在的路径, 另外支持`${relativeToVsRootPath:trimPrefixPath}`的形式来删除前缀路径 159 | - `${activeMdFilename}`: 如果打开了一个markdown文件, 这个配置就指md文件的文件名. 160 | 161 | 举个例子, 设置 `elan.bucketFolder` 为 `blog/${relativeToVsRootPath}/`, vscode的explorer如下 162 | 163 | ```bash 164 | . 165 | ├── FrontEnd 166 | │ └── Engineering 167 | │ └── webpack 168 | │ ├── example.js 169 | │ └── example.md 170 | ``` 171 | 172 | 如果你打开了 `example.md`, 那`elan.bucketFolder` 结果为 `blog/FrontEnd/Engineering/webpack/`. 173 | 174 | 如果设置 `elan.bucketFold` 为 `blog/${relativeToVsRootPath}/${activeMdFilename}/`,`elan.bucketFolder`结果为 `blog/FrontEnd/Engineering/webpack/example/`. 175 | 176 | 如果没有打开文件, `${relativeToVsRootPath}` 会被解析成空字符 `''`, `${activeMdFilename}` 同理. 177 | 178 | | opened file | `blog/${relativeToVsRootPath}/` | `blog/${relativeToVsRootPath}/${activeMdFilename}/` | 179 | | ---------- | --- | --- | 180 | | example.md | blog/FrontEnd/Engineering/webpack/ | blog/FrontEnd/Engineering/webpack/example/ | 181 | | example.js | blog/FrontEnd/Engineering/webpack/ | blog/FrontEnd/Engineering/webpack/ | 182 | | no opened file | blog/ | blog/ | 183 | 184 | 假如你的文档全都放在`doc`目录下, 你又希望`${relativeToVsRootPath}`输出路径不包含前缀`doc`, 可以使用`${relativeToVsRootPath:doc}`来去除前缀 185 | 186 | ## Create Bucket 187 | 188 | ![create-bucket](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/create-bucket_5f7df897.png) 189 | 190 | ## 调试项目 191 | 192 | 如果你想调试这个项目, 可以按 `F5`, 然后就可以调试webpack输出的dist目录 193 | 194 | > 因为`@types/ali-oss` 有点过时, 你会看到一些ts的报错 195 | 196 | ## TODO 197 | 198 | * [x] aliyun oss 199 | * [x] upload image by explorer dialog 200 | * [x] upload image from clipboard 201 | * [x] specify 'folder' of bucket 202 | * [x] upload image from explorer context (sidebar) 203 | * [x] content hash 204 | * [x] extension icon 205 | * [x] bundle by webpack/rollup 206 | * [x] enhance 'bucketFolder' 207 | * [x] delete image when hover GFM(github flavored markdown) 208 | * [x] sidebar extension (e.g. show recent uploaded image)/ (should consider icon theme) 209 | * [x] preview image of bucket by webview 210 | * [x] bucket treeView pagination 211 | * [ ] recently uploaded show in bucket treeView 212 | * [ ] batch operation in treeView 213 | * [ ] confirmation before deleting image 214 | * [ ] inquire before upload to check folder 215 | * [ ] decoupling logic by tapable 216 | * [ ] upload embed svg as *.svg from clipboard 217 | * [ ] image compress (by imagemin/ aliyun OSS can realize it by adding '?x-oss-process=' after url) 218 | * [ ] x-oss-process & vscode.CodeActionProvider 219 | * [ ] unit test 220 | * [ ] editor/title button to upload image 221 | * [ ] drag image and drop to markdown. [Limit](https://github.com/microsoft/vscode/issues/5240) 222 | * [ ] add keyboard shortcut for explorer/context command. [Limit](https://github.com/microsoft/vscode/issues/3553) -------------------------------------------------------------------------------- /README_en_US.md: -------------------------------------------------------------------------------- 1 | # Aliyun OSS Uploader 2 | 3 |

4 |
5 | Élan Logo. Logo designed by https://www.launchaco.com/ 6 |

7 | 8 | > Élan is a vscode extension focus on uploading image to Alibaba Cloud (Aliyun) OSS. 9 | 10 | ## Support 11 | If you find it useful, please [star me on Github](https://github.com/fangbinwei/aliyun-oss-uploader). 12 | 13 | ## Usage 14 | 15 | 1. You should create the [OSS instance](https://www.aliyun.com/product/oss/?lang=en) and get the `accessKeyId` and `accessKeySecret`. Then you should create bucket instance and get the `bucket` name. [see chapter 'Create bucket'](#create-bucket) 16 | 17 | 2. Setting the configuration of the extension 18 | 19 | - Click button to set the configuration. 20 | - Open the command panel (`ctrl+shift+p`/`command+shift+p`/`F1`) and type `elan set configuration`. 21 | 22 | ![set configuration](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/setConfiguration.png) 23 | 24 | 25 | ### Upload Image(s) 26 | 27 | * Upload image through bucket treeView. 28 | * Open the command panel and type 'elan'. 29 | - upload image from clipboard 30 | - upload image from explorer 31 | 32 | * Right click the image of file explorer, click the menu item `Elan: upload image` 33 | 34 | ### Delete Image 35 | * Delete the image by bucketView 36 | 37 | * Hover the image syntax in markdown, click `Delete image` to delete the image in OSS. 38 | 39 | >only support image syntax now 40 | 41 | ### Preview Remote Image 42 | Select the Image in bucket treeView. 43 | 44 | ![image preview](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/image-preview.png) 45 | 46 | ### Usage Demo (Bucket TreeView) 47 | 48 | #### Upload Image from File Explorer/ Clipboard 49 | Right click the folder, and upload image 50 | 51 | ![bucketTreeView_upload](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/bucketTreeView_upload_9d027122.png) 52 | 53 | #### Copy/Move/Rename/Delete Image in Bucket TreeView 54 | Right click the image, and copy/move/delete the image. 55 | 56 | ![bucketTreeView_delete_copy_move](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/bucketTreeView_delete_copy_move_240549f5.png) 57 | 58 | ### Copy Link 59 | 60 | ![bucketTreeView_copy_link](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/bucketTreeView_copy_link_6e710ef5.png) 61 | 62 | ### Usage Demo (Other) 63 | 64 | #### Upload from Clipboard 65 | Open the command panel and type `elan upload from clipboard` 66 | 67 | ![updateFromClipboard](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/updateFromClipboard_bf2399e2.gif) 68 | 69 | #### Upload by Explorer Dialog 70 | 71 | Open the command panel and type `elan upload from explorer` 72 | 73 | ![updateFromExplorer](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/updateFromExplorer_9f6ee648.gif) 74 | 75 | 76 | #### Upload by Explorer Context 77 | Right click the image in vscode file explorer, choose `elan: upload image` 78 | 79 | ![updateFromExplorerContext](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/updateFromExplorerContext_37c3aac0.gif) 80 | 81 | #### Delete Image (Hover) 82 | 83 | ![hoverDeleteCut](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/hoverDeleteCut_f9af47b7.png) 84 | 85 | ![hoverDelete](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/hoverDelete_03dc5db7.gif) 86 | 87 | > demo gif upload by this vscode extension. 88 | 89 | ## Configuration 90 | ### `elan.aliyun.accessKeyId` 91 | ### `elan.aliyun.accessKeySecret` 92 | ### `elan.aliyun.bucket` 93 | ### `elan.aliyun.region` 94 | e.g. `oss-cn-shanghai`, [check details](https://github.com/ali-sdk/ali-oss#data-regions). 95 | 96 | ### `elan.uploadName` 97 | Object name store on OSS 98 | 99 | Default: `${fileName}_${contentHash:8}${ext}` 100 | 101 | - `${fileName}`: Filename of uploaded file. 102 | - `${ext}`: Filename extension of uploaded file. 103 | - `${contentHash}`: The hash of image. By default, it's the hex digest of md5 hash. You can specify the length of the hash, e.g. `${contentHash:8}`. 104 | - `${activeMdFilename}`: Filename of active markdown in text editor. 105 | 106 | Support `${:hash::}`, default: 107 | ```js 108 | crypto.createHash('md5') 109 | .update(imageBuffer) 110 | .digest('hex') 111 | .substr(0, maxLength) 112 | 113 | ``` 114 | 115 | ### `elan.outputFormat` 116 | After uploading image, this output will be pasted to your clipboard. If you have opened a *.md, this will be pasted to your markdown automatically . 117 | 118 | - `${fileName}`: Filename of uploaded file. 119 | - `${uploadName}`: see `elan.uploadName`. 120 | - `${url}`: After a file is uploaded successfully, the OSS sends a callback request to this URL. 121 | - `${activeMdFilename}`: Filename of active markdown in text editor. 122 | 123 | ### `elan.bucketFolder` 124 | By default, you can find your image object by `elan.uploadName`, e.g. `example.png`. If we set `elan.bucketFold` to `github/aliyun-oss-uploader`, our images will be upload to "folder" ([not a real folder](https://help.aliyun.com/document_detail/31827.html)). You can find uploaded image in "folder" `github/aliyun-oss-uploader/`. 125 | 126 | ![oss browser](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/2020-05-31-19-02-13_55660788.png) 127 | 128 | - `${relativeToVsRootPath}`: The path of the directory where the currently active file is located relative to the workspace root directory 129 | - `${activeMdFilename}`: Filename of active markdown in text editor. 130 | 131 | For example, you set `elan.bucketFolder` to `blog/${relativeToVsRootPath}/`, and workspace is like below 132 | 133 | ```bash 134 | . 135 | ├── FrontEnd 136 | │ └── Engineering 137 | │ └── webpack 138 | │ ├── example.js 139 | │ └── example.md 140 | ``` 141 | 142 | If you open the `example.md` by text editor, the "folder" will be `blog/FrontEnd/Engineering/webpack/`. 143 | 144 | Or you set `elan.bucketFold` to `blog/${relativeToVsRootPath}/${activeMdFilename}/`, the "folder" will be `blog/FrontEnd/Engineering/webpack/example/`. 145 | 146 | If no file is opened, `${relativeToVsRootPath}` will be parsed to `''`, . If no active markdown, `${activeMdFilename}` will be parsed to `''`. 147 | 148 | | opened file | `blog/${relativeToVsRootPath}/` | `blog/${relativeToVsRootPath}/${activeMdFilename}/` | 149 | | ---------- | --- | --- | 150 | | example.md | blog/FrontEnd/Engineering/webpack/ | blog/FrontEnd/Engineering/webpack/example/ | 151 | | example.js | blog/FrontEnd/Engineering/webpack/ | blog/FrontEnd/Engineering/webpack/ | 152 | | no opened file | blog/ | blog/ | 153 | 154 | 155 | ## Create Bucket 156 | 157 | ![create-bucket](https://fangbinwei-blog-image.oss-cn-shanghai.aliyuncs.com/github/aliyun-oss-uploader/create-bucket_5f7df897.png) 158 | 159 | ## Debugger Project 160 | 161 | If you want to debugger the project, just press `F5` to Run Extension. Then we can debugger the output of webpack dist. 162 | 163 | > since `@types/ali-oss` is outdated you may see some ts error in your local 164 | 165 | 170 | 171 | ## TODO 172 | 173 | * [x] aliyun oss 174 | * [x] upload image by explorer dialog 175 | * [x] upload image from clipboard 176 | * [x] specify 'folder' of bucket 177 | * [x] upload image from explorer context (sidebar) 178 | * [x] content hash 179 | * [x] extension icon 180 | * [x] bundle by webpack/rollup 181 | * [x] enhance 'bucketFolder' 182 | * [x] delete image when hover GFM(github flavored markdown) 183 | * [x] sidebar extension (e.g. show recent uploaded image)/ (should consider icon theme) 184 | * [x] preview image of bucket by webview (WIP) 185 | * [ ] recently uploaded show in bucket treeView 186 | * [ ] bucket treeView pagination/ batch operation (WIP) 187 | * [ ] confirmation before deleting image 188 | * [ ] inquire before upload to check folder 189 | * [ ] decoupling logic by tapable 190 | * [ ] upload embed svg as *.svg from clipboard 191 | * [ ] image compress (by imagemin/ aliyun OSS can realize it by adding '?x-oss-process=' after url) 192 | * [ ] x-oss-process & vscode.CodeActionProvider 193 | * [ ] unit test 194 | * [ ] editor/title button to upload image 195 | * [ ] drag image and drop to markdown. [Limit](https://github.com/microsoft/vscode/issues/5240) 196 | * [ ] add keyboard shortcut for explorer/context command. [Limit](https://github.com/microsoft/vscode/issues/3553) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aliyun-oss-uploader", 3 | "displayName": "Aliyun OSS Uploader", 4 | "description": "Focus on uploading image to aliyun OSS.", 5 | "publisher": "fangbinwei", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/fangbinwei/aliyun-oss-uploader.git" 9 | }, 10 | "keywords": [ 11 | "aliyun", 12 | "oss", 13 | "image", 14 | "picture", 15 | "upload", 16 | "aliyun oss", 17 | "alibaba cloud", 18 | "image uploader", 19 | "upload image" 20 | ], 21 | "version": "1.7.0", 22 | "icon": "resources/logo.png", 23 | "engines": { 24 | "vscode": "^1.40.0" 25 | }, 26 | "categories": [ 27 | "Other" 28 | ], 29 | "activationEvents": [ 30 | "onCommand:elan.setOSSConfiguration", 31 | "onCommand:elan.uploadFromClipboard", 32 | "onCommand:elan.uploadFromExplorer", 33 | "onCommand:elan.uploadFromExplorerContext", 34 | "onLanguage:markdown", 35 | "onView:bucketExplorer" 36 | ], 37 | "main": "./dist/extension.js", 38 | "contributes": { 39 | "commands": [ 40 | { 41 | "command": "elan.setOSSConfiguration", 42 | "title": "Set OSS Configuration", 43 | "category": "Elan" 44 | }, 45 | { 46 | "command": "elan.uploadFromClipboard", 47 | "title": "Upload Image from Clipboard", 48 | "category": "Elan" 49 | }, 50 | { 51 | "command": "elan.uploadFromExplorer", 52 | "title": "Upload Image from Explorer", 53 | "category": "Elan" 54 | }, 55 | { 56 | "command": "elan.uploadFromExplorerContext", 57 | "title": "Elan: Upload Image" 58 | }, 59 | { 60 | "command": "elan.bucketExplorer.deleteFromContext", 61 | "title": "Delete" 62 | }, 63 | { 64 | "command": "elan.bucketExplorer.uploadFromContext", 65 | "title": "Upload" 66 | }, 67 | { 68 | "command": "elan.bucketExplorer.uploadFromClipboard", 69 | "title": "Upload from Clipboard" 70 | }, 71 | { 72 | "command": "elan.bucketExplorer.refreshRoot", 73 | "title": "Refresh", 74 | "icon": { 75 | "light": "resources/light/refresh.svg", 76 | "dark": "resources/dark/refresh.svg" 77 | } 78 | }, 79 | { 80 | "command": "elan.bucketExplorer.copyLink", 81 | "title": "Copy Link", 82 | "icon": { 83 | "light": "resources/light/link.svg", 84 | "dark": "resources/dark/link.svg" 85 | } 86 | }, 87 | { 88 | "command": "elan.bucketExplorer.moveFromContext", 89 | "title": "Move/Rename to" 90 | }, 91 | { 92 | "command": "elan.bucketExplorer.copyFromContext", 93 | "title": "Copy to" 94 | } 95 | ], 96 | "viewsContainers": { 97 | "activitybar": [ 98 | { 99 | "id": "elanView", 100 | "title": "Elan", 101 | "icon": "resources/activitybar.svg" 102 | } 103 | ] 104 | }, 105 | "views": { 106 | "elanView": [ 107 | { 108 | "id": "bucketExplorer", 109 | "name": "Bucket" 110 | } 111 | ] 112 | }, 113 | "viewsWelcome": [ 114 | { 115 | "view": "bucketExplorer", 116 | "contents": "Failed to connect OSS. [Learn more](https://github.com/fangbinwei/aliyun-oss-uploader#configuration/).\n[Set Configuration](command:elan.setOSSConfiguration)", 117 | "when": "elan.state == uninitialized" 118 | } 119 | ], 120 | "menus": { 121 | "commandPalette": [ 122 | { 123 | "command": "elan.uploadFromExplorerContext", 124 | "when": "false" 125 | }, 126 | { 127 | "command": "elan.bucketExplorer.deleteFromContext", 128 | "when": "false" 129 | }, 130 | { 131 | "command": "elan.bucketExplorer.uploadFromContext", 132 | "when": "false" 133 | }, 134 | { 135 | "command": "elan.bucketExplorer.uploadFromClipboard", 136 | "when": "false" 137 | }, 138 | { 139 | "command": "elan.bucketExplorer.copyLink", 140 | "when": "false" 141 | }, 142 | { 143 | "command": "elan.bucketExplorer.moveFromContext", 144 | "when": "false" 145 | }, 146 | { 147 | "command": "elan.bucketExplorer.copyFromContext", 148 | "when": "false" 149 | } 150 | ], 151 | "explorer/context": [ 152 | { 153 | "when": "resourceExtname =~/^\\.(png|jpg|jpeg|webp|gif|bmp|tiff|ico|svg)$/i", 154 | "command": "elan.uploadFromExplorerContext", 155 | "group": "5_cutcopypaste" 156 | } 157 | ], 158 | "view/title": [ 159 | { 160 | "command": "elan.bucketExplorer.refreshRoot", 161 | "when": "view == bucketExplorer", 162 | "group": "navigation" 163 | } 164 | ], 165 | "view/item/context": [ 166 | { 167 | "command": "elan.bucketExplorer.deleteFromContext", 168 | "when": "view == bucketExplorer && viewItem == elan:object", 169 | "group": "7_modification" 170 | }, 171 | { 172 | "command": "elan.bucketExplorer.copyLink", 173 | "when": "view == bucketExplorer && viewItem == elan:object", 174 | "group": "inline" 175 | }, 176 | { 177 | "command": "elan.bucketExplorer.uploadFromContext", 178 | "when": "view == bucketExplorer && viewItem == elan:folder || view == bucketExplorer &&viewItem == elan:bucket", 179 | "group": "upload@1" 180 | }, 181 | { 182 | "command": "elan.bucketExplorer.uploadFromClipboard", 183 | "when": "view == bucketExplorer && viewItem == elan:folder || view == bucketExplorer &&viewItem == elan:bucket", 184 | "group": "upload@1" 185 | }, 186 | { 187 | "command": "elan.bucketExplorer.moveFromContext", 188 | "when": "view == bucketExplorer && viewItem == elan:object", 189 | "group": "5_cutcopypaste" 190 | }, 191 | { 192 | "command": "elan.bucketExplorer.copyFromContext", 193 | "when": "view == bucketExplorer && viewItem == elan:object", 194 | "group": "5_cutcopypaste" 195 | } 196 | ] 197 | }, 198 | "configuration": { 199 | "title": "Elan", 200 | "properties": { 201 | "elan.aliyun.accessKeyId": { 202 | "type": "string", 203 | "description": "Aliyun OSS accessKeyId.", 204 | "default": "" 205 | }, 206 | "elan.aliyun.accessKeySecret": { 207 | "type": "string", 208 | "description": "Aliyun OSS accessKeySecret.", 209 | "default": "" 210 | }, 211 | "elan.aliyun.bucket": { 212 | "type": "string", 213 | "description": "Aliyun OSS bucket instance.", 214 | "default": "" 215 | }, 216 | "elan.aliyun.region": { 217 | "type": "string", 218 | "markdownDescription": "e.g. `oss-cn-shanghai`, [check details](https://github.com/ali-sdk/ali-oss#data-regions).", 219 | "default": "" 220 | }, 221 | "elan.aliyun.maxKeys": { 222 | "type": "number", 223 | "description": "Max objects in the same level directory of bucket treeView.", 224 | "default": 100, 225 | "minimum": 1, 226 | "maximum": 1000 227 | }, 228 | "elan.aliyun.customDomain": { 229 | "type": "string", 230 | "markdownDescription": "If you want to use HTTPS with `Custom Domain`, you should configure the HTTPS certificate on Aliyun OSS. [Check detail](https://help.aliyun.com/document_detail/97187.html?spm=a2c4g.11186623.2.10.43848bddZaQgmF#section-cu6-eyc-ek6).", 231 | "default": "" 232 | }, 233 | "elan.aliyun.secure": { 234 | "type": "boolean", 235 | "markdownDescription": "The protocol of URL. HTTPS (secure: `checked`) or HTTP protocol.", 236 | "default": true 237 | }, 238 | "elan.bucketView.onlyShowImages": { 239 | "type": "boolean", 240 | "description": "Only show images in bucket treeView.", 241 | "default": true 242 | }, 243 | "elan.uploadName": { 244 | "type": "string", 245 | "markdownDescription": "Object name store on OSS\n- `${fileName}`: Filename of uploaded file.\n-`${ext}`: Filename extension of uploaded file.\n- `${contentHash}`: The hash of image. By default, it's the hex digest of md5 hash. You can specify the length of the hash, e.g. `${contentHash:8}`. \n-`${activeMdFilename}`: Filename of active markdown in text editor.", 246 | "default": "${fileName}_${contentHash:8}${ext}" 247 | }, 248 | "elan.outputFormat": { 249 | "type": "string", 250 | "markdownDescription": "- `${fileName}`: Filename of uploaded file.\n- `${uploadName}`: Custom defined config. \n-`${activeMdFilename}`: Filename of markdown in active text editor.\n- `${url}`: After a file is uploaded successfully, the OSS sends a callback request to this URL. \n- `${pathname}`: Pathname of `${url}`. e.g. `${url}` is `https://example.org/path/to/your/image.png`, `${pathname}` is `/path/to/your/image.png`", 251 | "default": "![${fileName}](${url})" 252 | }, 253 | "elan.bucketFolder": { 254 | "type": "string", 255 | "markdownDescription": "- `${relativeToVsRootPath}`: The path of the directory where the currently active file is located relative to the workspace root directory. \n- `${activeMdFilename}`: Filename of markdown in active text editor.\n- If today is 1993.2.4.\n- `${year}`: 1993\n- `${month}`: 02\n- `${date}`: 04", 256 | "default": "" 257 | } 258 | } 259 | } 260 | }, 261 | "scripts": { 262 | "compile": "yarn run copy && tsc -p ./", 263 | "build": "cross-env NODE_ENV=production webpack --mode production", 264 | "vspack": "vsce package -o aliyun-oss-uploader.vsix", 265 | "release": "standard-version", 266 | "clean": "rm -rf dist out", 267 | "watch": "yarn run copy && tsc --watch -p ./", 268 | "webpack-dev": "webpack --mode development --watch --info-verbosity verbose", 269 | "copy": "cpy ./src/utils/clipboard/* ./out/utils/clipboard/", 270 | "lint": "eslint src --ext ts", 271 | "pretest": "yarn run compile && yarn run lint", 272 | "test": "node ./out/test/runTest.js" 273 | }, 274 | "devDependencies": { 275 | "@types/glob": "^7.1.1", 276 | "@types/mocha": "^7.0.2", 277 | "@types/vscode": "^1.40.0", 278 | "@types/webpack": "^4.41.17", 279 | "@typescript-eslint/eslint-plugin": "^2.30.0", 280 | "@typescript-eslint/parser": "^2.30.0", 281 | "builtin-modules": "^3.1.0", 282 | "commitizen": "^4.1.2", 283 | "copy-webpack-plugin": "^6.0.1", 284 | "cpy-cli": "^3.1.1", 285 | "cross-env": "^7.0.2", 286 | "cz-customizable": "^6.2.0", 287 | "eslint": "^6.8.0", 288 | "eslint-config-prettier": "^6.11.0", 289 | "eslint-plugin-prettier": "^3.1.3", 290 | "glob": "^7.1.6", 291 | "mocha": "^7.1.2", 292 | "prettier": "^2.0.5", 293 | "standard-version": "^8.0.1", 294 | "ts-loader": "^7.0.5", 295 | "typescript": "^3.8.3", 296 | "vsce": "^1.75.0", 297 | "vscode-test": "^1.3.0", 298 | "webpack": "^4.43.0", 299 | "webpack-cli": "^3.3.11" 300 | }, 301 | "dependencies": { 302 | "@types/ali-oss": "^6.0.5", 303 | "ali-oss": "^6.8.0", 304 | "clean-webpack-plugin": "^3.0.0", 305 | "date-fns": "^2.14.0", 306 | "execa": "^4.0.1" 307 | }, 308 | "config": { 309 | "commitizen": { 310 | "path": "node_modules/cz-customizable" 311 | } 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /resources/activitybar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/dark/add.svg: -------------------------------------------------------------------------------- 1 | Layer 1 -------------------------------------------------------------------------------- /resources/dark/database.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/dark/ellipsis.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/dark/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/dark/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resources/dark/statusWarning.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/light/add.svg: -------------------------------------------------------------------------------- 1 | Layer 1 -------------------------------------------------------------------------------- /resources/light/database.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/light/ellipsis.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/light/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/light/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resources/light/statusWarning.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resources/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fangbinwei/aliyun-oss-uploader/ff0f877cc4c6d76d29eb87a998d4f90fcf74bbd3/resources/logo.png -------------------------------------------------------------------------------- /resources/readme-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fangbinwei/aliyun-oss-uploader/ff0f877cc4c6d76d29eb87a998d4f90fcf74bbd3/resources/readme-logo.png -------------------------------------------------------------------------------- /resources/webview/loading-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /resources/webview/loading-hc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /resources/webview/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /resources/webview/main.css: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | html, body { 7 | width: 100%; 8 | height: 100%; 9 | text-align: center; 10 | } 11 | 12 | body img { 13 | max-width: none; 14 | max-height: none; 15 | } 16 | 17 | .container:focus { 18 | outline: none !important; 19 | } 20 | 21 | .container { 22 | padding: 5px 0 0 10px; 23 | box-sizing: border-box; 24 | -webkit-user-select: none; 25 | user-select: none; 26 | } 27 | 28 | .container.image { 29 | padding: 0; 30 | display: flex; 31 | box-sizing: border-box; 32 | } 33 | 34 | .container.image img { 35 | padding: 0; 36 | background-position: 0 0, 8px 8px; 37 | background-size: 16px 16px; 38 | border: 1px solid var(--vscode-imagePreview-border); 39 | } 40 | 41 | .container.image img { 42 | background-image: 43 | linear-gradient(45deg, rgb(230, 230, 230) 25%, transparent 25%, transparent 75%, rgb(230, 230, 230) 75%, rgb(230, 230, 230)), 44 | linear-gradient(45deg, rgb(230, 230, 230) 25%, transparent 25%, transparent 75%, rgb(230, 230, 230) 75%, rgb(230, 230, 230)); 45 | } 46 | 47 | .vscode-dark.container.image img { 48 | background-image: 49 | linear-gradient(45deg, rgb(20, 20, 20) 25%, transparent 25%, transparent 75%, rgb(20, 20, 20) 75%, rgb(20, 20, 20)), 50 | linear-gradient(45deg, rgb(20, 20, 20) 25%, transparent 25%, transparent 75%, rgb(20, 20, 20) 75%, rgb(20, 20, 20)); 51 | } 52 | 53 | .container img.pixelated { 54 | image-rendering: pixelated; 55 | } 56 | 57 | .container img.scale-to-fit { 58 | max-width: calc(100% - 20px); 59 | max-height: calc(100% - 20px); 60 | object-fit: contain; 61 | } 62 | 63 | .container img { 64 | margin: auto; 65 | } 66 | 67 | .container.ready.zoom-in { 68 | cursor: zoom-in; 69 | } 70 | 71 | .container.ready.zoom-out { 72 | cursor: zoom-out; 73 | } 74 | 75 | .container .embedded-link, 76 | .container .embedded-link:hover { 77 | cursor: pointer; 78 | text-decoration: underline; 79 | margin-left: 5px; 80 | } 81 | 82 | .container.loading, 83 | .container.error { 84 | display: flex; 85 | justify-content: center; 86 | align-items: center; 87 | } 88 | 89 | .loading-indicator { 90 | width: 30px; 91 | height: 30px; 92 | background-image: url('./loading.svg'); 93 | background-size: cover; 94 | } 95 | 96 | .loading-indicator, 97 | .image-load-error { 98 | display: none; 99 | } 100 | 101 | .loading .loading-indicator, 102 | .error .image-load-error { 103 | display: block; 104 | } 105 | 106 | .image-load-error { 107 | margin: 1em; 108 | } 109 | 110 | .vscode-dark .loading-indicator { 111 | background-image: url('./loading-dark.svg'); 112 | } 113 | 114 | .vscode-high-contrast .loading-indicator { 115 | background-image: url('./loading-hc.svg'); 116 | } 117 | -------------------------------------------------------------------------------- /resources/webview/main.js: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | // code from vscode/extensions/image-preview 6 | // @ts-check 7 | 'use strict' 8 | ;(function () { 9 | /** 10 | * @param {number} value 11 | * @param {number} min 12 | * @param {number} max 13 | * @return {number} 14 | */ 15 | function clamp(value, min, max) { 16 | return Math.min(Math.max(value, min), max) 17 | } 18 | 19 | function getSettings() { 20 | const element = document.getElementById('image-preview-settings') 21 | if (element) { 22 | const data = element.getAttribute('data-settings') 23 | if (data) { 24 | return JSON.parse(data) 25 | } 26 | } 27 | 28 | throw new Error(`Could not load settings`) 29 | } 30 | 31 | /** 32 | * Enable image-rendering: pixelated for images scaled by more than this. 33 | */ 34 | const PIXELATION_THRESHOLD = 3 35 | 36 | const SCALE_PINCH_FACTOR = 0.075 37 | const MAX_SCALE = 20 38 | const MIN_SCALE = 0.1 39 | 40 | const zoomLevels = [ 41 | 0.1, 42 | 0.2, 43 | 0.3, 44 | 0.4, 45 | 0.5, 46 | 0.6, 47 | 0.7, 48 | 0.8, 49 | 0.9, 50 | 1, 51 | 1.5, 52 | 2, 53 | 3, 54 | 5, 55 | 7, 56 | 10, 57 | 15, 58 | 20 59 | ] 60 | 61 | const settings = getSettings() 62 | const isMac = settings.isMac 63 | 64 | const vscode = acquireVsCodeApi() 65 | 66 | const initialState = { 67 | scale: 'fit', 68 | offsetX: 0, 69 | offsetY: 0 70 | } 71 | 72 | // State 73 | let scale = initialState.scale 74 | let ctrlPressed = false 75 | let altPressed = false 76 | let hasLoadedImage = false 77 | let consumeClick = true 78 | let isActive = false 79 | 80 | // Elements 81 | const container = document.body 82 | const image = document.createElement('img') 83 | 84 | function updateScale(newScale) { 85 | if (!image || !hasLoadedImage || !image.parentElement) { 86 | return 87 | } 88 | 89 | if (newScale === 'fit') { 90 | scale = 'fit' 91 | image.classList.add('scale-to-fit') 92 | image.classList.remove('pixelated') 93 | image.style.minWidth = 'auto' 94 | image.style.width = 'auto' 95 | } else { 96 | scale = clamp(newScale, MIN_SCALE, MAX_SCALE) 97 | if (scale >= PIXELATION_THRESHOLD) { 98 | image.classList.add('pixelated') 99 | } else { 100 | image.classList.remove('pixelated') 101 | } 102 | 103 | const dx = 104 | (window.scrollX + container.clientWidth / 2) / container.scrollWidth 105 | const dy = 106 | (window.scrollY + container.clientHeight / 2) / container.scrollHeight 107 | 108 | image.classList.remove('scale-to-fit') 109 | image.style.minWidth = `${image.naturalWidth * scale}px` 110 | image.style.width = `${image.naturalWidth * scale}px` 111 | 112 | const newScrollX = container.scrollWidth * dx - container.clientWidth / 2 113 | const newScrollY = 114 | container.scrollHeight * dy - container.clientHeight / 2 115 | 116 | window.scrollTo(newScrollX, newScrollY) 117 | 118 | } 119 | 120 | vscode.postMessage({ 121 | type: 'zoom', 122 | value: scale 123 | }) 124 | } 125 | 126 | function setActive(value) { 127 | isActive = value 128 | if (value) { 129 | if (isMac ? altPressed : ctrlPressed) { 130 | container.classList.remove('zoom-in') 131 | container.classList.add('zoom-out') 132 | } else { 133 | container.classList.remove('zoom-out') 134 | container.classList.add('zoom-in') 135 | } 136 | } else { 137 | ctrlPressed = false 138 | altPressed = false 139 | container.classList.remove('zoom-out') 140 | container.classList.remove('zoom-in') 141 | } 142 | } 143 | 144 | function firstZoom() { 145 | if (!image || !hasLoadedImage) { 146 | return 147 | } 148 | 149 | scale = image.clientWidth / image.naturalWidth 150 | updateScale(scale) 151 | } 152 | 153 | function zoomIn() { 154 | if (scale === 'fit') { 155 | firstZoom() 156 | } 157 | 158 | let i = 0 159 | for (; i < zoomLevels.length; ++i) { 160 | if (zoomLevels[i] > scale) { 161 | break 162 | } 163 | } 164 | updateScale(zoomLevels[i] || MAX_SCALE) 165 | } 166 | 167 | function zoomOut() { 168 | if (scale === 'fit') { 169 | firstZoom() 170 | } 171 | 172 | let i = zoomLevels.length - 1 173 | for (; i >= 0; --i) { 174 | if (zoomLevels[i] < scale) { 175 | break 176 | } 177 | } 178 | updateScale(zoomLevels[i] || MIN_SCALE) 179 | } 180 | 181 | window.addEventListener('keydown', (/** @type {KeyboardEvent} */ e) => { 182 | if (!image || !hasLoadedImage) { 183 | return 184 | } 185 | ctrlPressed = e.ctrlKey 186 | altPressed = e.altKey 187 | 188 | if (isMac ? altPressed : ctrlPressed) { 189 | container.classList.remove('zoom-in') 190 | container.classList.add('zoom-out') 191 | } 192 | }) 193 | 194 | window.addEventListener('keyup', (/** @type {KeyboardEvent} */ e) => { 195 | if (!image || !hasLoadedImage) { 196 | return 197 | } 198 | 199 | ctrlPressed = e.ctrlKey 200 | altPressed = e.altKey 201 | 202 | if (!(isMac ? altPressed : ctrlPressed)) { 203 | container.classList.remove('zoom-out') 204 | container.classList.add('zoom-in') 205 | } 206 | }) 207 | 208 | container.addEventListener('mousedown', (/** @type {MouseEvent} */ e) => { 209 | if (!image || !hasLoadedImage) { 210 | return 211 | } 212 | 213 | if (e.button !== 0) { 214 | return 215 | } 216 | 217 | ctrlPressed = e.ctrlKey 218 | altPressed = e.altKey 219 | 220 | consumeClick = !isActive 221 | }) 222 | 223 | container.addEventListener('click', (/** @type {MouseEvent} */ e) => { 224 | if (!image || !hasLoadedImage) { 225 | return 226 | } 227 | 228 | if (e.button !== 0) { 229 | return 230 | } 231 | 232 | if (consumeClick) { 233 | consumeClick = false 234 | return 235 | } 236 | // left click 237 | if (scale === 'fit') { 238 | firstZoom() 239 | } 240 | 241 | if (!(isMac ? altPressed : ctrlPressed)) { 242 | // zoom in 243 | zoomIn() 244 | } else { 245 | zoomOut() 246 | } 247 | }) 248 | 249 | container.addEventListener( 250 | 'wheel', 251 | (/** @type {WheelEvent} */ e) => { 252 | // Prevent pinch to zoom 253 | if (e.ctrlKey) { 254 | e.preventDefault() 255 | } 256 | 257 | if (!image || !hasLoadedImage) { 258 | return 259 | } 260 | 261 | const isScrollWheelKeyPressed = isMac ? altPressed : ctrlPressed 262 | if (!isScrollWheelKeyPressed && !e.ctrlKey) { 263 | // pinching is reported as scroll wheel + ctrl 264 | return 265 | } 266 | 267 | if (scale === 'fit') { 268 | firstZoom() 269 | } 270 | 271 | const delta = e.deltaY > 0 ? 1 : -1 272 | updateScale(scale * (1 - delta * SCALE_PINCH_FACTOR)) 273 | }, 274 | { passive: false } 275 | ) 276 | 277 | window.addEventListener( 278 | 'scroll', 279 | (e) => { 280 | if ( 281 | !image || 282 | !hasLoadedImage || 283 | !image.parentElement || 284 | scale === 'fit' 285 | ) { 286 | return 287 | } 288 | }, 289 | { passive: true } 290 | ) 291 | 292 | container.classList.add('image') 293 | 294 | image.classList.add('scale-to-fit') 295 | 296 | image.addEventListener('load', () => { 297 | if (hasLoadedImage) { 298 | return 299 | } 300 | hasLoadedImage = true 301 | 302 | vscode.postMessage({ 303 | type: 'size', 304 | value: `${image.naturalWidth}x${image.naturalHeight}` 305 | }) 306 | 307 | document.body.classList.remove('loading') 308 | document.body.classList.add('ready') 309 | document.body.append(image) 310 | 311 | updateScale(scale) 312 | 313 | if (initialState.scale !== 'fit') { 314 | window.scrollTo(initialState.offsetX, initialState.offsetY) 315 | } 316 | }) 317 | 318 | image.addEventListener('error', (e) => { 319 | if (hasLoadedImage) { 320 | return 321 | } 322 | 323 | hasLoadedImage = true 324 | document.body.classList.add('error') 325 | document.body.classList.remove('loading') 326 | }) 327 | 328 | image.src = settings.src 329 | 330 | window.addEventListener('message', (e) => { 331 | switch (e.data.type) { 332 | case 'setScale': 333 | updateScale(e.data.scale) 334 | break 335 | 336 | case 'setActive': 337 | setActive(e.data.value) 338 | break 339 | 340 | case 'zoomIn': 341 | zoomIn() 342 | break 343 | 344 | case 'zoomOut': 345 | zoomOut() 346 | break 347 | } 348 | }) 349 | })() 350 | -------------------------------------------------------------------------------- /src/commands/bucketExplorer/copyFromContext.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode' 2 | import { OSSObjectTreeItem } from '@/views/bucket' 3 | import { ext } from '@/extensionVariables' 4 | import { CommandContext } from '@/constant' 5 | import { copyUri } from '@/uploader/copyUri' 6 | import Logger from '@/utils/log' 7 | import { showObjectNameInputBox } from '@/utils' 8 | import path from 'path' 9 | 10 | async function copyFromBucketExplorerContext( 11 | treeItem: OSSObjectTreeItem 12 | ): Promise { 13 | const sourceUri = vscode.Uri.parse(treeItem.url) 14 | const sourcePath = sourceUri.path 15 | 16 | const targetName = await showObjectNameInputBox(sourceUri.path, { 17 | valueSelection: [0, sourcePath.length - path.extname(sourcePath).length - 1] 18 | }) 19 | if (!targetName) return 20 | try { 21 | await copyUri(vscode.Uri.file(targetName.trim()), sourceUri) 22 | } catch { 23 | Logger.log('catch function copyUri error') 24 | } 25 | 26 | ext.bucketExplorer.refresh() 27 | } 28 | // eslint-disable-next-line @typescript-eslint/no-namespace 29 | namespace copyFromBucketExplorerContext { 30 | export const command = CommandContext.BUCKET_EXPLORER_COPY_CONTEXT 31 | } 32 | 33 | export { copyFromBucketExplorerContext } 34 | -------------------------------------------------------------------------------- /src/commands/bucketExplorer/copyLink.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode' 2 | import { OSSObjectTreeItem } from '@/views/bucket' 3 | import { CommandContext } from '@/constant' 4 | import Logger from '@/utils/log' 5 | 6 | async function copyLinkFromBucketExplorer( 7 | treeItem: OSSObjectTreeItem 8 | ): Promise { 9 | vscode.env.clipboard.writeText(treeItem.url) 10 | Logger.showInformationMessage('Copy Link Successfully!') 11 | } 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-namespace 14 | namespace copyLinkFromBucketExplorer { 15 | export const command = CommandContext.BUCKET_EXPLORER_COPY_LINK 16 | } 17 | 18 | export { copyLinkFromBucketExplorer } 19 | -------------------------------------------------------------------------------- /src/commands/bucketExplorer/deleteFromContext.ts: -------------------------------------------------------------------------------- 1 | import { deleteUri } from '@/uploader/deleteUri' 2 | import vscode from 'vscode' 3 | import { OSSObjectTreeItem } from '@/views/bucket' 4 | import { ext } from '@/extensionVariables' 5 | import { CommandContext } from '@/constant' 6 | 7 | async function deleteFromBucketExplorerContext( 8 | treeItem: OSSObjectTreeItem 9 | ): Promise { 10 | await deleteUri(vscode.Uri.parse(treeItem.url)) 11 | 12 | if (!treeItem.parentFolder) return 13 | ext.bucketExplorer.refresh(treeItem.parentFolder) 14 | } 15 | // eslint-disable-next-line @typescript-eslint/no-namespace 16 | namespace deleteFromBucketExplorerContext { 17 | export const command = CommandContext.BUCKET_EXPLORER_DELETE_CONTEXT 18 | } 19 | 20 | export { deleteFromBucketExplorerContext } 21 | -------------------------------------------------------------------------------- /src/commands/bucketExplorer/moveFromContext.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode' 2 | import { OSSObjectTreeItem } from '@/views/bucket' 3 | import { ext } from '@/extensionVariables' 4 | import { CommandContext } from '@/constant' 5 | import { showObjectNameInputBox } from '@/utils/index' 6 | import { moveUri } from '@/uploader/moveUri' 7 | import path from 'path' 8 | 9 | async function moveFromBucketExplorerContext( 10 | treeItem: OSSObjectTreeItem 11 | ): Promise { 12 | const sourceUri = vscode.Uri.parse(treeItem.url) 13 | const sourcePath = sourceUri.path 14 | 15 | const targetName = await showObjectNameInputBox(sourceUri.path, { 16 | valueSelection: [0, sourcePath.length - path.extname(sourcePath).length - 1] 17 | }) 18 | if (!targetName) return 19 | 20 | await moveUri(vscode.Uri.file(targetName.trim()), sourceUri) 21 | 22 | ext.bucketExplorer.refresh() 23 | } 24 | // eslint-disable-next-line @typescript-eslint/no-namespace 25 | namespace moveFromBucketExplorerContext { 26 | export const command = CommandContext.BUCKET_EXPLORER_MOVE_CONTEXT 27 | } 28 | 29 | export { moveFromBucketExplorerContext } 30 | -------------------------------------------------------------------------------- /src/commands/bucketExplorer/showMore.ts: -------------------------------------------------------------------------------- 1 | import { ShowMoreTreeItem } from '@/views/bucket' 2 | import { CommandContext } from '@/constant' 3 | function showMoreChildren(node: ShowMoreTreeItem): void { 4 | node.showMore() 5 | } 6 | // eslint-disable-next-line 7 | namespace showMoreChildren { 8 | export const command = CommandContext.BUCKET_EXPLORER_SHOW_MORE_CHILDREN 9 | } 10 | 11 | export { showMoreChildren } 12 | -------------------------------------------------------------------------------- /src/commands/bucketExplorer/uploadFromClipboard.ts: -------------------------------------------------------------------------------- 1 | import { OSSObjectTreeItem } from '@/views/bucket' 2 | import { ext } from '@/extensionVariables' 3 | import vscode from 'vscode' 4 | import { CommandContext } from '@/constant' 5 | import { showFolderNameInputBox } from '@/utils/index' 6 | 7 | async function uploadFromBucketExplorerClipboard( 8 | selected: OSSObjectTreeItem 9 | ): Promise { 10 | const folderPlaceholder = 11 | selected.label === ext.elanConfiguration.bucket 12 | ? '' 13 | : selected.prefix + selected.label + '/' 14 | const folder = await showFolderNameInputBox(folderPlaceholder) 15 | if (folder === undefined) return 16 | 17 | await vscode.commands.executeCommand( 18 | 'elan.uploadFromClipboard', 19 | folder.trim() 20 | ) 21 | 22 | ext.bucketExplorer.refresh() 23 | } 24 | // eslint-disable-next-line 25 | namespace uploadFromBucketExplorerClipboard { 26 | export const command = CommandContext.BUCKET_EXPLORER_UPLOAD_CLIPBOARD 27 | } 28 | 29 | export { uploadFromBucketExplorerClipboard } 30 | -------------------------------------------------------------------------------- /src/commands/bucketExplorer/uploadFromContext.ts: -------------------------------------------------------------------------------- 1 | import { OSSObjectTreeItem } from '@/views/bucket' 2 | import { ext } from '@/extensionVariables' 3 | import { SUPPORT_EXT } from '@/constant' 4 | import { uploadUris } from '@/uploader/uploadUris' 5 | import vscode from 'vscode' 6 | import { CommandContext } from '@/constant' 7 | import { showFolderNameInputBox } from '@/utils/index' 8 | 9 | async function uploadFromBucketExplorerContext( 10 | selected: OSSObjectTreeItem 11 | ): Promise { 12 | const folderPlaceholder = 13 | selected.label === ext.elanConfiguration.bucket 14 | ? '' 15 | : selected.prefix + selected.label + '/' 16 | 17 | const folder = await showFolderNameInputBox(folderPlaceholder) 18 | if (folder === undefined) return 19 | 20 | const images = await vscode.window.showOpenDialog({ 21 | filters: ext.elanConfiguration.onlyShowImages 22 | ? { 23 | Images: SUPPORT_EXT.slice() 24 | } 25 | : {}, 26 | canSelectMany: true 27 | }) 28 | if (!images) return 29 | 30 | await uploadUris(images, folder.trim()) 31 | 32 | ext.bucketExplorer.refresh() 33 | } 34 | 35 | // eslint-disable-next-line 36 | namespace uploadFromBucketExplorerContext { 37 | export const command = CommandContext.BUCKET_EXPLORER_UPLOAD_CONTEXT 38 | } 39 | 40 | export { uploadFromBucketExplorerContext } 41 | -------------------------------------------------------------------------------- /src/commands/deleteByHover.ts: -------------------------------------------------------------------------------- 1 | import { deleteUri } from '@/uploader/deleteUri' 2 | import { getActiveMd } from '@/utils/index' 3 | import vscode from 'vscode' 4 | import { ext } from '@/extensionVariables' 5 | 6 | interface PositionToJSON { 7 | readonly line: number 8 | readonly character: number 9 | } 10 | 11 | type RangeToJSON = Array 12 | 13 | export default async function hoverDelete( 14 | uri: string, 15 | fileName: string, 16 | range: RangeToJSON 17 | ): Promise { 18 | await deleteUri(vscode.Uri.parse(uri)) 19 | const [start, end] = range 20 | const vsRange = new vscode.Range( 21 | start.line, 22 | start.character, 23 | end.line, 24 | end.character 25 | ) 26 | deleteGFM(fileName, vsRange) 27 | if (ext.bucketExplorerTreeViewVisible) ext.bucketExplorer.refresh() 28 | } 29 | 30 | function deleteGFM(fileName: string, range: vscode.Range): void { 31 | const activeTextMd = getActiveMd() 32 | if (!activeTextMd) return 33 | if (activeTextMd.document.fileName !== fileName) return 34 | activeTextMd.edit((editBuilder) => { 35 | editBuilder.delete(range) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /src/commands/setOSSConfiguration.ts: -------------------------------------------------------------------------------- 1 | import { ext } from '@/extensionVariables' 2 | import { getThemedIconPath, IconPath } from '@/views/iconPath' 3 | import { getElanConfiguration, updateOSSConfiguration } from '@/utils/index' 4 | import { OSS_REGION } from '@/constant' 5 | 6 | import { 7 | QuickPickItem, 8 | window, 9 | Disposable, 10 | QuickInputButton, 11 | QuickInput, 12 | QuickInputButtons 13 | } from 'vscode' 14 | import Logger from '@/utils/log' 15 | 16 | /** 17 | * A multi-step input using window.createQuickPick() and window.createInputBox(). 18 | * 19 | * This first part uses the helper class `MultiStepInput` that wraps the API for the multi-step case. 20 | */ 21 | export async function setOSSConfiguration(): Promise { 22 | class MyButton implements QuickInputButton { 23 | constructor(public iconPath: IconPath, public tooltip: string) {} 24 | } 25 | 26 | interface State { 27 | title: string 28 | step: number 29 | totalSteps: number 30 | region: QuickPickItem | string 31 | bucket: string 32 | accessKeyId: string 33 | accessKeySecret: string 34 | } 35 | const regions: QuickPickItem[] = [...OSS_REGION].map((label) => ({ label })) 36 | 37 | async function collectInputs(): Promise { 38 | const oldConfiguration = getElanConfiguration() 39 | const state: Partial = {} 40 | state.accessKeyId = oldConfiguration.accessKeyId 41 | state.accessKeySecret = oldConfiguration.accessKeySecret 42 | state.bucket = oldConfiguration.bucket 43 | const regionInList = regions.find( 44 | (item) => item.label === oldConfiguration.region 45 | ) 46 | state.region = regionInList || oldConfiguration.region 47 | 48 | await MultiStepInput.run((multiStepInput) => 49 | pickRegion(multiStepInput, state) 50 | ) 51 | return state as State 52 | } 53 | 54 | const title = 'Set OSS Configuration' 55 | 56 | const addRegionButton = new MyButton( 57 | getThemedIconPath('add'), 58 | 'Create Region' 59 | ) 60 | async function pickRegion( 61 | multiStepInput: MultiStepInput, 62 | state: Partial 63 | ): Promise { 64 | const pick = await multiStepInput.showQuickPick({ 65 | title, 66 | step: 1, 67 | totalSteps: 4, 68 | placeholder: `Pick a region or click 'Add' icon to enter the region`, 69 | items: regions, 70 | buttons: [addRegionButton], 71 | activeItem: typeof state.region !== 'string' ? state.region : undefined, 72 | shouldResume: shouldResume 73 | }) 74 | if (pick instanceof MyButton) { 75 | return (multiStepInput: MultiStepInput): Promise => 76 | inputRegion(multiStepInput, state) 77 | } 78 | state.region = pick 79 | return (multiStepInput: MultiStepInput): Promise => 80 | inputBucket(multiStepInput, state) 81 | } 82 | 83 | async function inputRegion( 84 | multiStepInput: MultiStepInput, 85 | state: Partial 86 | ): Promise { 87 | state.region = await multiStepInput.showInputBox({ 88 | title, 89 | step: 2, 90 | totalSteps: 5, 91 | value: typeof state.region === 'string' ? state.region : '', 92 | prompt: 'Enter the region', 93 | validate: validateInputValue, 94 | shouldResume: shouldResume 95 | }) 96 | return (multiStepInput: MultiStepInput): Promise => 97 | inputBucket(multiStepInput, state) 98 | } 99 | 100 | async function inputBucket( 101 | multiStepInput: MultiStepInput, 102 | state: Partial 103 | ): Promise { 104 | const additionalSteps = typeof state.region === 'string' ? 1 : 0 105 | // TODO: Remember current value when navigating back. 106 | state.bucket = await multiStepInput.showInputBox({ 107 | title, 108 | step: 2 + additionalSteps, 109 | totalSteps: 4 + additionalSteps, 110 | value: state.bucket || '', 111 | prompt: 'Enter the bucket name', 112 | validate: validateInputValue, 113 | shouldResume: shouldResume 114 | }) 115 | return (multiStepInput: MultiStepInput): Promise => 116 | inputAccessKeyId(multiStepInput, state) 117 | } 118 | 119 | async function inputAccessKeyId( 120 | multiStepInput: MultiStepInput, 121 | state: Partial 122 | ): Promise { 123 | const additionalSteps = typeof state.region === 'string' ? 1 : 0 124 | state.accessKeyId = await multiStepInput.showInputBox({ 125 | title, 126 | step: 3 + additionalSteps, 127 | totalSteps: 4 + additionalSteps, 128 | value: state.accessKeyId || '', 129 | prompt: 'Enter the accessKeyId', 130 | validate: validateInputValue, 131 | shouldResume: shouldResume 132 | }) 133 | 134 | return (multiStepInput: MultiStepInput): Promise => 135 | inputAccessKeySecret(multiStepInput, state) 136 | } 137 | async function inputAccessKeySecret( 138 | multiStepInput: MultiStepInput, 139 | state: Partial 140 | ): Promise { 141 | const additionalSteps = typeof state.region === 'string' ? 1 : 0 142 | state.accessKeySecret = await multiStepInput.showInputBox({ 143 | title, 144 | step: 4 + additionalSteps, 145 | totalSteps: 4 + additionalSteps, 146 | value: state.accessKeySecret || '', 147 | prompt: 'Enter the accessKeySecret', 148 | validate: validateInputValue, 149 | shouldResume: shouldResume 150 | }) 151 | } 152 | 153 | function shouldResume(): Promise { 154 | return new Promise((resolve) => { 155 | window 156 | .showInformationMessage( 157 | 'Continue Configuration?', 158 | { 159 | modal: true 160 | }, 161 | 'Continue' 162 | ) 163 | .then( 164 | (v) => { 165 | if (v === 'Continue') return resolve(true) 166 | resolve(false) 167 | }, 168 | () => { 169 | resolve(false) 170 | } 171 | ) 172 | }) 173 | } 174 | 175 | // it support validating async 176 | async function validateInputValue( 177 | input: string 178 | ): Promise { 179 | if (!input) return 'Required' 180 | } 181 | 182 | try { 183 | const state = await collectInputs() 184 | await updateOSSConfiguration({ 185 | region: 186 | typeof state.region === 'string' ? state.region : state.region.label, 187 | accessKeyId: state.accessKeyId, 188 | accessKeySecret: state.accessKeySecret, 189 | bucket: state.bucket 190 | }) 191 | 192 | if (ext.bucketExplorerTreeViewVisible) ext.bucketExplorer.refresh() 193 | window.showInformationMessage(`Configuration Updated`) 194 | } catch (err) { 195 | if (err.message === 'cancel') return 196 | Logger.log(`Failed to set configuration. Reason ${err.message}`) 197 | } 198 | } 199 | 200 | // ------------------------------------------------------- 201 | // Helper code that wraps the API for the multi-step case. 202 | // ------------------------------------------------------- 203 | 204 | class InputFlowAction { 205 | static back = new InputFlowAction() 206 | static cancel = new InputFlowAction() 207 | static resume = new InputFlowAction() 208 | } 209 | 210 | type InputStep = (multiStepInput: MultiStepInput) => Promise 211 | 212 | interface QuickPickParameters { 213 | title: string 214 | step: number 215 | totalSteps: number 216 | ignoreFocusOut?: boolean 217 | items: T[] 218 | activeItem?: T 219 | placeholder: string 220 | buttons?: QuickInputButton[] 221 | shouldResume: () => Promise 222 | } 223 | 224 | interface InputBoxParameters { 225 | title: string 226 | step: number 227 | totalSteps: number 228 | ignoreFocusOut?: boolean 229 | value: string 230 | prompt: string 231 | validate: (value: string) => Promise 232 | buttons?: QuickInputButton[] 233 | shouldResume: () => Promise 234 | } 235 | 236 | class MultiStepInput { 237 | static async run(start: InputStep): Promise { 238 | const multiStepInput = new MultiStepInput() 239 | return multiStepInput.stepThrough(start) 240 | } 241 | 242 | private current?: QuickInput 243 | private steps: InputStep[] = [] 244 | 245 | private async stepThrough(start: InputStep): Promise { 246 | let step: InputStep | void = start 247 | let cancel = false 248 | while (step) { 249 | this.steps.push(step) 250 | if (this.current) { 251 | this.current.enabled = false 252 | this.current.busy = true 253 | } 254 | try { 255 | step = await step(this) 256 | } catch (err) { 257 | if (err === InputFlowAction.back) { 258 | this.steps.pop() 259 | step = this.steps.pop() 260 | } else if (err === InputFlowAction.resume) { 261 | step = this.steps.pop() 262 | } else if (err === InputFlowAction.cancel) { 263 | step = undefined 264 | cancel = true 265 | } else { 266 | throw err 267 | } 268 | } 269 | } 270 | if (this.current) { 271 | this.current.dispose() 272 | } 273 | if (cancel) { 274 | throw new Error('cancel') 275 | } 276 | } 277 | 278 | async showQuickPick< 279 | T extends QuickPickItem, 280 | P extends QuickPickParameters 281 | >({ 282 | title, 283 | step, 284 | totalSteps, 285 | ignoreFocusOut, 286 | items, 287 | activeItem, 288 | placeholder, 289 | buttons, 290 | shouldResume 291 | }: P): Promise< 292 | | T 293 | | (P extends { 294 | buttons: (infer I)[] 295 | } 296 | ? I 297 | : never) 298 | > { 299 | const disposables: Disposable[] = [] 300 | try { 301 | return await new Promise< 302 | T | (P extends { buttons: (infer I)[] } ? I : never) 303 | >((resolve, reject) => { 304 | const input = window.createQuickPick() 305 | input.title = title 306 | input.step = step 307 | input.totalSteps = totalSteps 308 | input.ignoreFocusOut = ignoreFocusOut || true 309 | input.placeholder = placeholder 310 | input.items = items 311 | if (activeItem) { 312 | input.activeItems = [activeItem] 313 | } 314 | input.buttons = [ 315 | ...(this.steps.length > 1 ? [QuickInputButtons.Back] : []), 316 | ...(buttons || []) 317 | ] 318 | disposables.push( 319 | input.onDidTriggerButton((item) => { 320 | if (item === QuickInputButtons.Back) { 321 | reject(InputFlowAction.back) 322 | } else { 323 | resolve(item as any) 324 | } 325 | }), 326 | input.onDidChangeSelection((items) => resolve(items[0])), 327 | input.onDidHide(() => { 328 | ;(async (): Promise => { 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 | --------------------------------------------------------------------------------