├── .gitignore ├── media ├── icon.png ├── screenshots │ ├── tree.jpg │ └── editor.jpg ├── dark │ └── icon.svg ├── light │ └── icon.svg └── icon.svg ├── .vscodeignore ├── .vscode ├── extensions.json ├── settings.json ├── tasks.json └── launch.json ├── tsconfig.json ├── src ├── webview │ ├── main.css │ ├── ButtonInput.ts │ ├── AssetItem.ts │ ├── AssetList.ts │ └── main.ts ├── types │ ├── webview.d.ts │ └── git.ts ├── extension.ts ├── RemoteList.ts ├── Commands.ts ├── ReleaseProvider.ts ├── Remote.ts └── WebviewProvider.ts ├── eslint.config.mjs ├── README.md ├── LICENSE ├── CHANGELOG.md ├── esbuild.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | out 3 | node_modules 4 | *.vsix -------------------------------------------------------------------------------- /media/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrandonXLF/vscode-github-releases/main/media/icon.png -------------------------------------------------------------------------------- /media/screenshots/tree.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrandonXLF/vscode-github-releases/main/media/screenshots/tree.jpg -------------------------------------------------------------------------------- /media/screenshots/editor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrandonXLF/vscode-github-releases/main/media/screenshots/editor.jpg -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | node_modules/** 3 | src/** 4 | .eslintignore 5 | .eslintrc.json 6 | .gitignore 7 | esbuild.js 8 | tsconfig.json 9 | **/*.map 10 | -------------------------------------------------------------------------------- /.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 | "connor4312.esbuild-problem-matchers" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2020", 5 | "outDir": "out", 6 | "lib": ["DOM", "ES2020"], 7 | "sourceMap": false, 8 | "rootDir": "src", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "experimentalDecorators": true 12 | }, 13 | "exclude": ["node_modules"] 14 | } 15 | -------------------------------------------------------------------------------- /src/webview/main.css: -------------------------------------------------------------------------------- 1 | .vscode-dark { 2 | color-scheme: dark; 3 | } 4 | 5 | body { 6 | display: flex; 7 | flex-direction: column; 8 | gap: 1em; 9 | padding: 20px; 10 | } 11 | 12 | #title, 13 | #desc { 14 | width: 100%; 15 | } 16 | 17 | .button-row { 18 | display: flex; 19 | align-items: center; 20 | gap: 1em; 21 | } 22 | 23 | #target { 24 | word-break: break-all; 25 | } 26 | -------------------------------------------------------------------------------- /.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 | "svg.preview.background": "editor" 12 | } 13 | -------------------------------------------------------------------------------- /.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": "$esbuild-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.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 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 13 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 14 | "preLaunchTask": "${defaultBuildTask}" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, globalIgnores } from 'eslint/config'; 2 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 3 | import tsParser from '@typescript-eslint/parser'; 4 | 5 | export default defineConfig([ 6 | globalIgnores(['src/types/git.ts']), 7 | { 8 | plugins: { 9 | '@typescript-eslint': typescriptEslint, 10 | }, 11 | 12 | languageOptions: { 13 | parser: tsParser, 14 | ecmaVersion: 6, 15 | sourceType: 'module', 16 | }, 17 | 18 | rules: { 19 | '@typescript-eslint/naming-convention': 'warn', 20 | eqeqeq: 'warn', 21 | 'no-throw-literal': 'warn', 22 | }, 23 | 24 | ignores: ['src/types/git.ts'], 25 | }, 26 | ]); 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Releases 2 | 3 | View, create, and edit GitHub releases right from Visual Studio Code. 4 | 5 | ## Features 6 | 7 | ### Release Tree View 8 | 9 | View releases from all git repositories with remotes on GitHub with a handy tree view in the activity bar. 10 | 11 | For each release, information about the release and associated release assets can be shown. Actions are added to checkout the release's tag or to download release assets. 12 | 13 | 14 | 15 | ### Create and Edit Releases 16 | 17 | Create new releases or edit exiting ones with an activity bar editor. The editor supports the creation of new tags as well as the creation, renaming, and deletion of release assets. 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/types/webview.d.ts: -------------------------------------------------------------------------------- 1 | export type Asset = { name: string } & ( 2 | | { new: true; path: string } 3 | | { new: false; id: number } 4 | ); 5 | 6 | export type WebviewState = { 7 | tag: { 8 | name: string; 9 | existing: boolean; 10 | }; 11 | target: { 12 | ref: string; 13 | display: string; 14 | }; 15 | title: string; 16 | desc: string; 17 | draft: boolean; 18 | prerelease: boolean; 19 | assets: { 20 | current: Asset[]; 21 | deleted: [number, string][]; 22 | renamed: [number, [string, string]][]; 23 | }; 24 | makeLatest: boolean; 25 | isLatest: boolean; 26 | }; 27 | 28 | export type PartialWebviewState = Partial; 29 | 30 | export type WebviewStateMessage = { 31 | type: 'set-state'; 32 | } & WebviewState; 33 | 34 | export type PartialWebviewStateMessage = { 35 | type: 'set-state'; 36 | } & PartialWebviewState; 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Brandon Fowler 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /media/dark/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /media/light/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /media/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 1.4.1 4 | 5 | - Also refresh the remote list when the refresh icon is pressed 6 | - Continue to show other releases when no latest release is found 7 | 8 | ## 1.4.0 9 | 10 | - Mark pre-releases with "[Pre-release]" 11 | - Continue looking at other repos when a repo has no releases 12 | - Add pagination support 13 | - Remove unusable commands from command palette 14 | 15 | ## 1.3.1 16 | 17 | - Show local tags from newest to oldest 18 | 19 | ## 1.3.0 20 | 21 | - Mark the latest release with "[Latest]" 22 | - Show a "Save" button for drafts 23 | - Force the state of "Make latest" to reflect GitHub's behaviour 24 | 25 | ## 1.2.0 26 | 27 | - Allow for selection multiple assets at once 28 | - Remove the open repo action from the terminal 29 | 30 | ## 1.1.0 31 | 32 | - Add ability to push local tags 33 | - Show "Open Release on GitHub" for single repos 34 | - Add checkbox to make a release the latest release 35 | 36 | ## 1.0.2 37 | 38 | - Focus text field when renaming assets 39 | - Make quick popups more descriptive 40 | 41 | ## 1.0.1 42 | 43 | - Always show refresh button 44 | 45 | ## 1.0.0 46 | 47 | - Initial release 48 | -------------------------------------------------------------------------------- /src/webview/ButtonInput.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FASTElement, 3 | attr, 4 | customElement, 5 | html, 6 | ref, 7 | } from '@microsoft/fast-element'; 8 | import { Button } from '@vscode/webview-ui-toolkit'; 9 | import { WebviewApi } from 'vscode-webview'; 10 | 11 | const template = html` 12 | x.sendRequest()} 16 | > 17 | ${(x) => x.prefix}${(x) => x.valueLabel || x.value || x.placeholder} 18 | 19 | `; 20 | 21 | export class ButtonInputElement extends FASTElement { 22 | @attr prefix = ''; 23 | @attr name = ''; 24 | @attr placeholder = ''; 25 | @attr value = ''; 26 | @attr valueLabel = ''; 27 | 28 | private vscode?: WebviewApi; 29 | button?: Button; 30 | 31 | init(vscode: WebviewApi) { 32 | this.vscode = vscode; 33 | } 34 | 35 | sendRequest() { 36 | this.vscode?.postMessage({ 37 | type: `select-${this.name}`, 38 | }); 39 | } 40 | } 41 | 42 | export function registerButtonInput() { 43 | customElement({ 44 | name: 'button-input', 45 | template, 46 | })(ButtonInputElement); 47 | } 48 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import { ReleaseProvider } from './ReleaseProvider'; 2 | import * as vscode from 'vscode'; 3 | import * as git from './types/git'; 4 | import { WebviewProvider } from './WebviewProvider'; 5 | import { RemoteList } from './RemoteList'; 6 | import { Octokit } from '@octokit/rest'; 7 | import { Commands } from './Commands'; 8 | 9 | export async function activate(ctx: vscode.ExtensionContext) { 10 | const gitExtension = 11 | vscode.extensions.getExtension('vscode.git'); 12 | 13 | await gitExtension!.activate(); 14 | 15 | const git = gitExtension!.exports.getAPI(1); 16 | const auth = await vscode.authentication.getSession('github', ['repo'], { 17 | createIfNone: true, 18 | }); 19 | 20 | if (!auth) { 21 | vscode.window.showErrorMessage('Failed to get GitHub auth.'); 22 | return; 23 | } 24 | 25 | const octokit = new Octokit({ 26 | auth: auth.accessToken, 27 | userAgent: `BrandonXLF/vscode-github-releases v${ctx.extension.packageJSON.version}`, 28 | }); 29 | const remotes = new RemoteList(octokit, ctx, git); 30 | const releaseProvider = new ReleaseProvider(ctx, remotes); 31 | const webviewProvider = new WebviewProvider(ctx, remotes); 32 | 33 | new Commands(ctx, remotes, releaseProvider, webviewProvider).registerAll(); 34 | 35 | ctx.subscriptions.push( 36 | vscode.window.registerWebviewViewProvider( 37 | 'github-releases-create-release', 38 | webviewProvider, 39 | ), 40 | vscode.window.registerTreeDataProvider( 41 | 'github-releases-release-list', 42 | releaseProvider, 43 | ), 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/RemoteList.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as git from './types/git'; 3 | import gitUrlParse from 'git-url-parse'; 4 | import { Remote } from './Remote'; 5 | import { Octokit } from '@octokit/rest'; 6 | 7 | export class RemoteList { 8 | private readonly onDidRemoteListChangeEmitter = new vscode.EventEmitter< 9 | Remote[] 10 | >(); 11 | readonly onDidRemoteListChange = this.onDidRemoteListChangeEmitter.event; 12 | 13 | private knownRemotes: Remote[] = []; 14 | 15 | constructor( 16 | private readonly octokit: Octokit, 17 | ctx: vscode.ExtensionContext, 18 | private git: git.API, 19 | ) { 20 | ctx.subscriptions.push(git.onDidChangeState(() => this.refresh())); 21 | this.refresh(); 22 | } 23 | 24 | private getRemotes() { 25 | const remoteUrls: [string, string, git.Repository][] = []; 26 | 27 | this.git.repositories.forEach((repo) => { 28 | repo.state.remotes.forEach((remote) => { 29 | if (remote.fetchUrl) 30 | remoteUrls.push([remote.fetchUrl, remote.name, repo]); 31 | 32 | if (remote.pushUrl && remote.fetchUrl !== remote.pushUrl) 33 | remoteUrls.push([remote.pushUrl, remote.name, repo]); 34 | }); 35 | }); 36 | 37 | return remoteUrls 38 | .map(([url, name, repo]) => [gitUrlParse(url), name, repo] as const) 39 | .filter(([url]) => url.source === 'github.com') 40 | .map( 41 | ([url, name, repo]) => 42 | new Remote( 43 | this.octokit, 44 | url.owner, 45 | url.name, 46 | repo, 47 | name, 48 | url.toString('https').replace(/\.git$/, ''), 49 | ), 50 | ); 51 | } 52 | 53 | refresh(): boolean { 54 | const newRemotes = this.getRemotes(); 55 | 56 | if ( 57 | newRemotes.length === this.knownRemotes.length && 58 | newRemotes.every( 59 | (newRemote, i) => 60 | newRemote.identifier === this.knownRemotes[i].identifier, 61 | ) 62 | ) 63 | return false; 64 | 65 | this.knownRemotes = newRemotes; 66 | 67 | this.onDidRemoteListChangeEmitter.fire(newRemotes); 68 | vscode.commands.executeCommand( 69 | 'setContext', 70 | 'gitHubReleases:knownGitHubRepos', 71 | newRemotes.length, 72 | ); 73 | 74 | return true; 75 | } 76 | 77 | get list() { 78 | return this.knownRemotes; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/webview/AssetItem.ts: -------------------------------------------------------------------------------- 1 | import { 2 | html, 3 | customElement, 4 | FASTElement, 5 | when, 6 | observable, 7 | ref, 8 | attr, 9 | css, 10 | } from '@microsoft/fast-element'; 11 | import { TextField } from '@vscode/webview-ui-toolkit'; 12 | 13 | const codiconsUri = (document.getElementById('codicons')! as HTMLLinkElement) 14 | .href; 15 | 16 | const template = html` 17 | 18 | ${when( 19 | (x) => x.editing, 20 | html` 21 | { 25 | x.name = x.nameInput?.value ?? ''; 26 | x.$emit('rename', x.name); 27 | }} 28 | @keydown=${(x, c) => { 29 | if ((c.event as KeyboardEvent).key === 'Enter') { 30 | x.editing = false; 31 | } 32 | 33 | return true; 34 | }} 35 | @focusout=${(x) => (x.editing = false)} 36 | > 37 | `, 38 | html` 39 | ${(x) => x.name} 40 | x.$emit('delete')} 45 | > 46 | 47 | 48 | (x.editing = true)} 53 | > 54 | 55 | 56 | `, 57 | )} 58 | `; 59 | 60 | const styles = css` 61 | :host { 62 | display: flex; 63 | align-items: center; 64 | border-radius: calc(var(--corner-radius-round) * 1px); 65 | } 66 | 67 | :host(:hover) { 68 | background: var(--vscode-list-hoverBackground); 69 | } 70 | 71 | :host > :nth-child(2) { 72 | flex: 1; 73 | padding: 4px; 74 | } 75 | `; 76 | 77 | export class AssetItemElement extends FASTElement { 78 | @attr name = ''; 79 | @observable editing = false; 80 | @observable nameInput?: TextField; 81 | 82 | nameInputChanged() { 83 | setTimeout(() => this.nameInput?.focus(), 0); 84 | } 85 | } 86 | 87 | export function registerAssetItem() { 88 | customElement({ 89 | name: 'asset-item', 90 | template, 91 | styles, 92 | })(AssetItemElement); 93 | } 94 | -------------------------------------------------------------------------------- /src/Commands.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { 3 | AssetItem, 4 | ReleaseItem, 5 | ReleaseProvider, 6 | RemoteItem, 7 | TagItem, 8 | } from './ReleaseProvider'; 9 | import { WebviewProvider } from './WebviewProvider'; 10 | import { RemoteList } from './RemoteList'; 11 | 12 | export class Commands { 13 | static readonly defined: [string, (...args: any[]) => any][] = []; 14 | 15 | static define(id: string) { 16 | return (_target: Commands, _prop: string, desc: PropertyDescriptor) => { 17 | Commands.defined.push([id, desc.value]); 18 | }; 19 | } 20 | 21 | constructor( 22 | private readonly ctx: vscode.ExtensionContext, 23 | public remotes: RemoteList, 24 | public releaseProvider: ReleaseProvider, 25 | public webviewProvider: WebviewProvider, 26 | ) {} 27 | 28 | registerAll() { 29 | Commands.defined.forEach(([id, func]) => { 30 | this.ctx.subscriptions.push( 31 | vscode.commands.registerCommand(id, func, this), 32 | ); 33 | }); 34 | } 35 | 36 | @Commands.define('github-releases.createRelease') 37 | createRelease() { 38 | return this.webviewProvider.show(); 39 | } 40 | 41 | @Commands.define('github-releases.refreshReleases') 42 | refreshReleases() { 43 | this.releaseProvider.refresh(); 44 | } 45 | 46 | @Commands.define('github-releases.setPage') 47 | setPage(repo: string, page: number) { 48 | this.releaseProvider.setPage(repo, page); 49 | this.releaseProvider.refresh(); 50 | } 51 | 52 | @Commands.define('github-releases.editRelease') 53 | editRelease(release: ReleaseItem) { 54 | return this.webviewProvider.show(release.release); 55 | } 56 | 57 | @Commands.define('github-releases.deleteRelease') 58 | async deleteRelease(release: ReleaseItem) { 59 | const action = await vscode.window.showInformationMessage( 60 | `Are you sure you want to delete release "${release.release.title}" from ${release.release.remote.identifier}?`, 61 | { modal: true }, 62 | { title: 'Yes' }, 63 | ); 64 | 65 | if (action?.title !== 'Yes') return; 66 | 67 | release.release.remote.deleteRelease(release.release.id); 68 | this.releaseProvider.refresh(); 69 | } 70 | 71 | @Commands.define('github-releases.openRepoReleases') 72 | openRepoReleases(remoteItem?: RemoteItem) { 73 | const remote = remoteItem?.remote ?? this.remotes.list[0]; 74 | 75 | return vscode.env.openExternal( 76 | vscode.Uri.parse(`${remote.url}/releases`), 77 | ); 78 | } 79 | 80 | @Commands.define('github-releases.openRelease') 81 | openRelease(release: ReleaseItem) { 82 | return vscode.env.openExternal(vscode.Uri.parse(release.release.url)); 83 | } 84 | 85 | @Commands.define('github-releases.downloadAsset') 86 | downloadAsset(asset: AssetItem) { 87 | return vscode.env.openExternal(vscode.Uri.parse(asset.asset.url)); 88 | } 89 | 90 | @Commands.define('github-releases.checkoutTag') 91 | checkoutTag(tag: TagItem) { 92 | return tag.remote.checkoutTag(tag.tagName); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /esbuild.js: -------------------------------------------------------------------------------- 1 | const { build, context } = require('esbuild'); 2 | const copyStaticFiles = require('esbuild-copy-static-files'); 3 | 4 | const args = process.argv.slice(2); 5 | const PROD = args.includes('--production'); 6 | 7 | // @ts-check 8 | /** @typedef {import('esbuild').BuildOptions} BuildOptions **/ 9 | 10 | // https://github.com/connor4312/esbuild-problem-matchers#esbuild-via-js 11 | /** @type {import('esbuild').Plugin} */ 12 | const esbuildProblemMatcherPlugin = { 13 | name: 'esbuild-problem-matcher', 14 | setup(build) { 15 | build.onStart(() => { 16 | console.log('[watch] build started'); 17 | }); 18 | 19 | build.onEnd((result) => { 20 | result.errors.forEach(({ text, location }) => { 21 | console.error(`✘ [ERROR] ${text}`); 22 | console.error( 23 | ` ${location.file}:${location.line}:${location.column}:`, 24 | ); 25 | }); 26 | 27 | console.log('[watch] build finished'); 28 | }); 29 | }, 30 | }; 31 | 32 | /** @type BuildOptions */ 33 | const baseConfig = { 34 | bundle: true, 35 | minify: PROD, 36 | sourcemap: !PROD, 37 | }; 38 | 39 | // Config for extension source code (to be run in a Node-based context) 40 | /** @type BuildOptions */ 41 | const extensionConfig = { 42 | ...baseConfig, 43 | platform: 'node', 44 | mainFields: ['module', 'main'], 45 | format: 'cjs', 46 | entryPoints: ['./src/extension.ts'], 47 | outfile: './out/extension.js', 48 | external: ['vscode'], 49 | }; 50 | 51 | // Config for webview source code (to be run in a web-based context) 52 | /** @type BuildOptions */ 53 | const webviewConfig = { 54 | ...baseConfig, 55 | target: 'es2020', 56 | format: 'esm', 57 | entryPoints: ['./src/webview/main.ts'], 58 | outfile: './out/webview.js', 59 | plugins: [ 60 | copyStaticFiles({ 61 | src: './node_modules/@vscode/codicons/dist/codicon.css', 62 | dest: './out/codicon.css', 63 | }), 64 | copyStaticFiles({ 65 | src: './node_modules/@vscode/codicons/dist/codicon.ttf', 66 | dest: './out/codicon.ttf', 67 | }), 68 | copyStaticFiles({ 69 | src: './src/webview/main.css', 70 | dest: './out/webview.css', 71 | }), 72 | ], 73 | }; 74 | 75 | /** @type BuildOptions */ 76 | const watchConfig = { 77 | plugins: [esbuildProblemMatcherPlugin], 78 | }; 79 | 80 | // Build script 81 | (async () => { 82 | try { 83 | if (args.includes('--watch')) { 84 | // Build and watch extension and webview code 85 | const ctx1 = await context({ 86 | ...extensionConfig, 87 | ...watchConfig, 88 | }); 89 | 90 | const ctx2 = await context({ 91 | ...webviewConfig, 92 | ...watchConfig, 93 | }); 94 | 95 | await ctx1.watch(); 96 | await ctx2.watch(); 97 | } else { 98 | // Build extension and webview code 99 | await build(extensionConfig); 100 | await build(webviewConfig); 101 | 102 | console.log('build complete'); 103 | } 104 | } catch (err) { 105 | process.stderr.write(err.stderr); 106 | process.exit(1); 107 | } 108 | })(); 109 | -------------------------------------------------------------------------------- /src/webview/AssetList.ts: -------------------------------------------------------------------------------- 1 | import { 2 | html, 3 | repeat, 4 | Observable, 5 | customElement, 6 | FASTElement, 7 | observable, 8 | attr, 9 | css, 10 | } from '@microsoft/fast-element'; 11 | import { TextField } from '@vscode/webview-ui-toolkit'; 12 | import { registerAssetItem } from './AssetItem'; 13 | import { Asset } from '../types/webview'; 14 | 15 | const template = html` 16 | ${repeat( 17 | (x) => x.list, 18 | html` x.name} 20 | @delete=${(x, c) => c.parent.removeAsset(x)} 21 | @rename=${(x, c) => 22 | c.parent.renameAsset(x, (c.event as CustomEvent).detail)} 23 | >`, 24 | )} 25 | `; 26 | 27 | const styles = css` 28 | :host { 29 | display: none; 30 | } 31 | `; 32 | 33 | export class AssetListElement extends FASTElement { 34 | @attr empty = true; 35 | @observable editing?: Asset; 36 | nameInput?: TextField; 37 | 38 | private assets: Asset[] = []; 39 | private usedNames = new Set(); 40 | private deletedAssets = new Map(); 41 | private renamedAssets = new Map(); 42 | 43 | appendAsset(asset: Asset) { 44 | if (this.usedNames.has(asset.name)) { 45 | return false; 46 | } 47 | 48 | this.assets.push(asset); 49 | Observable.notify(this, 'list'); 50 | 51 | this.usedNames.add(asset.name); 52 | this.$fastController.element.style.display = 'block'; 53 | 54 | return true; 55 | } 56 | 57 | removeAsset(asset: Asset) { 58 | if (!asset.new) { 59 | this.deletedAssets.set(asset.id, asset.name); 60 | this.renamedAssets.delete(asset.id); 61 | } 62 | 63 | this.assets = this.assets.filter((x) => x !== asset); 64 | Observable.notify(this, 'list'); 65 | 66 | this.usedNames.delete(asset.name); 67 | 68 | if (!this.assets.length) { 69 | this.$fastController.element.style.display = 'none'; 70 | } 71 | } 72 | 73 | renameAsset(asset: Asset, newName: string) { 74 | if (!asset.new) { 75 | this.renamedAssets.set(asset.id, [asset.name, newName]); 76 | } 77 | 78 | asset.name = newName; 79 | 80 | this.usedNames.delete(asset.name); 81 | this.usedNames.add(newName); 82 | } 83 | 84 | setState( 85 | assets: Asset[], 86 | deletedAssets: [number, string][], 87 | renamedAssets: [number, [string, string]][], 88 | ) { 89 | this.deletedAssets = new Map(deletedAssets); 90 | this.renamedAssets = new Map(renamedAssets); 91 | 92 | this.assets = assets; 93 | Observable.notify(this, 'list'); 94 | 95 | this.$fastController.element.style.display = this.assets.length 96 | ? 'block' 97 | : 'none'; 98 | } 99 | 100 | get list() { 101 | Observable.track(this, 'list'); 102 | return [...this.assets.values()]; 103 | } 104 | 105 | get deleted() { 106 | return [...this.deletedAssets.entries()]; 107 | } 108 | 109 | get renamed() { 110 | return [...this.renamedAssets.entries()]; 111 | } 112 | } 113 | 114 | export function registerAssetList() { 115 | registerAssetItem(); 116 | 117 | customElement({ 118 | name: 'asset-list', 119 | template, 120 | styles, 121 | })(AssetListElement); 122 | } 123 | -------------------------------------------------------------------------------- /src/webview/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | provideVSCodeDesignSystem, 3 | vsCodeButton, 4 | vsCodeTextField, 5 | vsCodeTextArea, 6 | TextField, 7 | vsCodeCheckbox, 8 | Checkbox, 9 | } from '@vscode/webview-ui-toolkit'; 10 | import { AssetListElement, registerAssetList } from './AssetList'; 11 | import { ButtonInputElement, registerButtonInput } from './ButtonInput'; 12 | import { Observable } from '@microsoft/fast-element'; 13 | import { PartialWebviewState, WebviewState } from '../types/webview'; 14 | 15 | const vscode = acquireVsCodeApi(); 16 | 17 | provideVSCodeDesignSystem().register( 18 | vsCodeButton(), 19 | vsCodeTextField(), 20 | vsCodeTextArea(), 21 | vsCodeCheckbox(), 22 | ); 23 | 24 | registerAssetList(); 25 | registerButtonInput(); 26 | 27 | const generateContainer = document.getElementById('generate-container')!; 28 | const publishBtn = document.getElementById('publish')!; 29 | 30 | const assetList = document.getElementById('asset-list') as AssetListElement; 31 | Observable.getNotifier(assetList).subscribe( 32 | { 33 | handleChange: () => saveState(), 34 | }, 35 | 'list', 36 | ); 37 | 38 | const titleInput = document.getElementById('title') as TextField; 39 | titleInput.addEventListener('input', () => saveState()); 40 | 41 | const descInput = document.getElementById('desc') as TextField; 42 | descInput.addEventListener('input', () => { 43 | updateGenerateButton(); 44 | saveState(); 45 | }); 46 | 47 | const draftCheck = document.getElementById('draft') as Checkbox; 48 | draftCheck.addEventListener('change', () => { 49 | updatePublishText(); 50 | saveState(); 51 | }); 52 | 53 | const prereleaseCheck = document.getElementById('prerelease') as Checkbox; 54 | prereleaseCheck.addEventListener('change', () => { 55 | updateMakeLatest(); 56 | saveState(); 57 | }); 58 | 59 | const makeLatestCheck = document.getElementById('makeLatest') as Checkbox; 60 | makeLatestCheck.addEventListener('change', () => saveState()); 61 | 62 | const tagInput = document.getElementById('tag') as ButtonInputElement; 63 | tagInput.init(vscode); 64 | 65 | const targetInput = document.getElementById('target') as ButtonInputElement; 66 | targetInput.init(vscode); 67 | targetInput.value = 'HEAD'; 68 | 69 | let existingTag = false; 70 | let isLatest = false; 71 | 72 | function updateGenerateButton() { 73 | generateContainer.style.display = descInput.value ? 'none' : ''; 74 | } 75 | 76 | function updatePublishText() { 77 | publishBtn.innerText = draftCheck.checked ? 'Save' : 'Publish'; 78 | } 79 | 80 | function updateMakeLatest(userChecked?: boolean) { 81 | if (prereleaseCheck.checked) { 82 | makeLatestCheck.checked = false; 83 | makeLatestCheck.disabled = true; 84 | return; 85 | } 86 | 87 | if (isLatest) { 88 | makeLatestCheck.checked = true; 89 | makeLatestCheck.disabled = true; 90 | return; 91 | } 92 | 93 | makeLatestCheck.disabled = false; 94 | 95 | if (userChecked) { 96 | makeLatestCheck.checked = userChecked; 97 | } 98 | } 99 | 100 | function getState() { 101 | return { 102 | tag: { 103 | name: tagInput.value, 104 | existing: existingTag, 105 | }, 106 | target: { 107 | ref: targetInput.value, 108 | display: targetInput.valueLabel, 109 | }, 110 | title: titleInput.value, 111 | desc: descInput.value, 112 | draft: draftCheck.checked, 113 | prerelease: prereleaseCheck.checked, 114 | assets: { 115 | current: assetList.list, 116 | deleted: assetList.deleted, 117 | renamed: assetList.renamed, 118 | }, 119 | makeLatest: makeLatestCheck.checked, 120 | isLatest, 121 | } satisfies WebviewState; 122 | } 123 | 124 | function saveState() { 125 | vscode.postMessage({ 126 | type: 'save-state', 127 | ...getState(), 128 | }); 129 | } 130 | 131 | function setState(state: PartialWebviewState) { 132 | if (state.tag !== undefined) { 133 | tagInput.value = state.tag.name; 134 | existingTag = state.tag.existing; 135 | targetInput.style.display = existingTag ? 'none' : ''; 136 | } 137 | 138 | if (state.target !== undefined) { 139 | targetInput.value = state.target.ref; 140 | targetInput.valueLabel = state.target.display; 141 | } 142 | 143 | if (state.title !== undefined) { 144 | titleInput.value = state.title; 145 | } 146 | 147 | if (state.desc !== undefined) { 148 | descInput.value = state.desc; 149 | } 150 | 151 | if (state.draft !== undefined) { 152 | draftCheck.checked = state.draft; 153 | } 154 | 155 | if (state.prerelease !== undefined) { 156 | prereleaseCheck.checked = state.prerelease; 157 | } 158 | 159 | if (state.isLatest !== undefined) { 160 | isLatest = state.isLatest; 161 | } 162 | 163 | if ('assets' in state && state.assets !== undefined) 164 | assetList.setState( 165 | state.assets.current, 166 | state.assets.deleted, 167 | state.assets.renamed, 168 | ); 169 | 170 | updateGenerateButton(); 171 | updateMakeLatest(state.makeLatest); 172 | updatePublishText(); 173 | saveState(); 174 | } 175 | 176 | window.addEventListener('message', (e) => { 177 | switch (e.data.type) { 178 | case 'set-state': 179 | setState(e.data); 180 | break; 181 | case 'add-asset': 182 | if (!assetList.appendAsset(e.data.asset)) { 183 | vscode.postMessage({ 184 | type: 'name-in-use', 185 | }); 186 | } 187 | } 188 | }); 189 | 190 | document.getElementById('generate')?.addEventListener('click', () => { 191 | vscode.postMessage({ 192 | type: 'generate-release-notes', 193 | tag: tagInput.value, 194 | target: targetInput.value, 195 | }); 196 | }); 197 | 198 | document.getElementById('add-file')?.addEventListener('click', () => { 199 | vscode.postMessage({ 200 | type: 'request-asset', 201 | }); 202 | }); 203 | 204 | document.getElementById('cancel')?.addEventListener('click', () => { 205 | vscode.postMessage({ 206 | type: 'cancel', 207 | }); 208 | }); 209 | 210 | publishBtn.addEventListener('click', () => { 211 | vscode.postMessage({ 212 | type: 'publish-release', 213 | ...getState(), 214 | }); 215 | }); 216 | 217 | vscode.postMessage({ 218 | type: 'start', 219 | }); 220 | -------------------------------------------------------------------------------- /src/ReleaseProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { RemoteList } from './RemoteList'; 3 | import { PaginationMap, Release, ReleaseAsset, Remote } from './Remote'; 4 | 5 | export class RemoteItem extends vscode.TreeItem { 6 | constructor(public remote: Remote) { 7 | super(remote.identifier, vscode.TreeItemCollapsibleState.Expanded); 8 | this.contextValue = 'repo'; 9 | } 10 | } 11 | 12 | export class ReleaseItem extends vscode.TreeItem { 13 | constructor(public readonly release: Release) { 14 | let suffix = ''; 15 | 16 | if (release.draft) { 17 | suffix = ' [Draft]'; 18 | } else if (release.prerelease) { 19 | suffix = ' [Pre-release]'; 20 | } else if (release.remote.isLatest(release)) { 21 | suffix = ' [Latest]'; 22 | } 23 | 24 | super( 25 | release.title + suffix, 26 | vscode.TreeItemCollapsibleState.Collapsed, 27 | ); 28 | 29 | this.contextValue = 'release'; 30 | } 31 | } 32 | 33 | export class AssetItem extends vscode.TreeItem { 34 | constructor( 35 | public readonly asset: ReleaseAsset, 36 | first = false, 37 | ) { 38 | super(asset.name, vscode.TreeItemCollapsibleState.None); 39 | this.contextValue = 'asset'; 40 | 41 | if (first) { 42 | this.iconPath = vscode.ThemeIcon.File; 43 | } 44 | } 45 | } 46 | 47 | export class TagItem extends vscode.TreeItem { 48 | static readonly TagIcon = new vscode.ThemeIcon('tag'); 49 | 50 | constructor( 51 | public readonly remote: Remote, 52 | public readonly tagName: string, 53 | ) { 54 | super(`Tag: ${tagName}`, vscode.TreeItemCollapsibleState.None); 55 | this.contextValue = 'tag'; 56 | this.iconPath = TagItem.TagIcon; 57 | } 58 | } 59 | 60 | export class MessageItem extends vscode.TreeItem { 61 | constructor( 62 | public readonly label: string, 63 | public readonly iconPath?: vscode.ThemeIcon | vscode.Uri, 64 | ) { 65 | super(label, vscode.TreeItemCollapsibleState.None); 66 | this.contextValue = 'message'; 67 | } 68 | } 69 | 70 | export class ReleaseProvider 71 | implements vscode.TreeDataProvider 72 | { 73 | private static readonly paginationTypes = { 74 | first: ['arrow-left', 'First Page'] as const, 75 | prev: ['arrow-left', 'Previous Page'] as const, 76 | next: ['arrow-right', 'Next Page'] as const, 77 | last: ['arrow-right', 'Last Page'] as const, 78 | }; 79 | 80 | private readonly onDidChangeTreeDataEmitter = 81 | new vscode.EventEmitter(); 82 | private readonly pages = new Map(); 83 | public readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; 84 | 85 | constructor( 86 | ctx: vscode.ExtensionContext, 87 | private readonly remotes: RemoteList, 88 | ) { 89 | ctx.subscriptions.push( 90 | remotes.onDidRemoteListChange(() => 91 | this.onDidChangeTreeDataEmitter.fire(null), 92 | ), 93 | ); 94 | } 95 | 96 | getTreeItem(element: vscode.TreeItem): vscode.TreeItem { 97 | return element; 98 | } 99 | 100 | async getChildren(element?: vscode.TreeItem): Promise { 101 | if (!element) { 102 | if (!this.remotes.list.length) { 103 | return [new MessageItem('No GitHub repositories found')]; 104 | } 105 | 106 | if (this.remotes.list.length > 1) { 107 | return this.remotes.list.map( 108 | (remote) => new RemoteItem(remote), 109 | ); 110 | } 111 | 112 | element = new RemoteItem(this.remotes.list[0]); 113 | } 114 | 115 | if (element instanceof RemoteItem) { 116 | const page = this.pages.get(element.remote.identifier) ?? 1; 117 | 118 | let releases: Release[] = []; 119 | let pagination: PaginationMap = new Map(); 120 | let details: string | undefined; 121 | 122 | try { 123 | ({ releases, pagination } = 124 | await element.remote.getReleases(page)); 125 | } catch (error) { 126 | details = (error as any).toString(); 127 | } 128 | 129 | let children: vscode.TreeItem[]; 130 | 131 | if (!releases.length) { 132 | const msg = new MessageItem('No releases found'); 133 | msg.description = details; 134 | children = [msg]; 135 | } else { 136 | children = releases.map((release) => new ReleaseItem(release)); 137 | } 138 | 139 | for (const [type, page] of pagination) { 140 | if (page === undefined) continue; 141 | 142 | const [icon, title] = ReleaseProvider.paginationTypes[type]; 143 | const item = new MessageItem(title, new vscode.ThemeIcon(icon)); 144 | 145 | item.command = { 146 | command: 'github-releases.setPage', 147 | title, 148 | arguments: [element.remote.identifier, page], 149 | }; 150 | 151 | children.push(item); 152 | } 153 | 154 | return children; 155 | } 156 | 157 | if (element instanceof ReleaseItem) { 158 | const formattedDate = new Intl.DateTimeFormat(vscode.env.language, { 159 | dateStyle: 'medium', 160 | timeStyle: 'medium', 161 | }).format( 162 | new Date( 163 | element.release.publishDate ?? element.release.createDate, 164 | ), 165 | ); 166 | 167 | const children: vscode.TreeItem[] = [ 168 | new MessageItem( 169 | `${element.release.author} at ${formattedDate}`, 170 | vscode.Uri.parse(element.release.authorIcon), 171 | ), 172 | new TagItem(element.release.remote, element.release.tag), 173 | new MessageItem('——'), 174 | ]; 175 | 176 | children.push( 177 | ...element.release.desc 178 | .split(/\n/g) 179 | .map( 180 | (line, i) => 181 | new MessageItem( 182 | line.trim(), 183 | i === 0 184 | ? new vscode.ThemeIcon('output-view-icon') 185 | : undefined, 186 | ), 187 | ), 188 | ); 189 | 190 | if (element.release.assets.length) { 191 | children.push( 192 | new MessageItem('——'), 193 | ...element.release.assets.map( 194 | (asset, i) => new AssetItem(asset, i === 0), 195 | ), 196 | ); 197 | } 198 | 199 | return children; 200 | } 201 | 202 | return []; 203 | } 204 | 205 | setPage(repo: string, page: number): void { 206 | this.pages.set(repo, page); 207 | } 208 | 209 | refresh() { 210 | if (!this.remotes.refresh()) { 211 | this.onDidChangeTreeDataEmitter.fire(null); 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-releases", 3 | "displayName": "GitHub Releases", 4 | "description": "View, create, and edit GitHub releases right from Visual Studio Code", 5 | "publisher": "brandonfowler", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/BrandonXLF/vscode-github-releases" 10 | }, 11 | "homepage": "https://github.com/BrandonXLF/vscode-github-releases", 12 | "bugs": { 13 | "url": "https://github.com/BrandonXLF/vscode-github-releases/issues" 14 | }, 15 | "icon": "media/icon.png", 16 | "version": "1.4.1", 17 | "engines": { 18 | "vscode": "^1.55.0" 19 | }, 20 | "categories": [ 21 | "Other" 22 | ], 23 | "extensionKind": [ 24 | "workspace" 25 | ], 26 | "extensionDependencies": [ 27 | "vscode.git" 28 | ], 29 | "activationEvents": [], 30 | "main": "./out/extension.js", 31 | "contributes": { 32 | "commands": [ 33 | { 34 | "category": "GitHub Releases", 35 | "command": "github-releases.createRelease", 36 | "title": "Create GitHub Release", 37 | "icon": "$(add)" 38 | }, 39 | { 40 | "category": "GitHub Releases", 41 | "command": "github-releases.refreshReleases", 42 | "title": "Refresh GitHub Releases", 43 | "icon": "$(refresh)" 44 | }, 45 | { 46 | "category": "GitHub Releases", 47 | "command": "github-releases.setPage", 48 | "title": "Set GitHub Releases Page" 49 | }, 50 | { 51 | "category": "GitHub Releases", 52 | "command": "github-releases.editRelease", 53 | "title": "Edit Release", 54 | "icon": "$(edit)" 55 | }, 56 | { 57 | "category": "GitHub Releases", 58 | "command": "github-releases.deleteRelease", 59 | "title": "Delete Release", 60 | "icon": "$(trash)" 61 | }, 62 | { 63 | "category": "GitHub Releases", 64 | "command": "github-releases.openRepoReleases", 65 | "title": "Open Release Page on GitHub", 66 | "icon": "$(link-external)" 67 | }, 68 | { 69 | "category": "GitHub Releases", 70 | "command": "github-releases.openRelease", 71 | "title": "Open Release on GitHub", 72 | "icon": "$(link-external)" 73 | }, 74 | { 75 | "category": "GitHub Releases", 76 | "command": "github-releases.downloadAsset", 77 | "title": "Download Asset", 78 | "icon": "$(desktop-download)" 79 | }, 80 | { 81 | "category": "GitHub Releases", 82 | "command": "github-releases.checkoutTag", 83 | "title": "Checkout Tag", 84 | "icon": "$(git-branch)" 85 | } 86 | ], 87 | "viewsContainers": { 88 | "activitybar": [ 89 | { 90 | "id": "github-releases-view", 91 | "title": "GitHub Releases", 92 | "icon": "media/light/icon.svg" 93 | } 94 | ] 95 | }, 96 | "views": { 97 | "github-releases-view": [ 98 | { 99 | "id": "github-releases-create-release", 100 | "type": "webview", 101 | "name": "Create / Edit Release", 102 | "contextualTitle": "Create GitHub Release", 103 | "icon": "media/light/icon.svg", 104 | "when": "gitHubReleases:createRelease" 105 | }, 106 | { 107 | "id": "github-releases-release-list", 108 | "type": "tree", 109 | "name": "Releases", 110 | "contextualTitle": "GitHub Releases", 111 | "icon": "media/light/icon.svg" 112 | } 113 | ] 114 | }, 115 | "menus": { 116 | "view/title": [ 117 | { 118 | "command": "github-releases.createRelease", 119 | "when": "view == github-releases-release-list && gitHubReleases:knownGitHubRepos", 120 | "group": "navigation@1" 121 | }, 122 | { 123 | "command": "github-releases.refreshReleases", 124 | "when": "view == github-releases-release-list", 125 | "group": "navigation@2" 126 | }, 127 | { 128 | "command": "github-releases.openRepoReleases", 129 | "when": "view == github-releases-release-list && gitHubReleases:knownGitHubRepos == 1", 130 | "group": "navigation@3" 131 | } 132 | ], 133 | "view/item/context": [ 134 | { 135 | "command": "github-releases.editRelease", 136 | "when": "view == github-releases-release-list && viewItem == release", 137 | "group": "inline" 138 | }, 139 | { 140 | "command": "github-releases.deleteRelease", 141 | "when": "view == github-releases-release-list && viewItem == release", 142 | "group": "inline" 143 | }, 144 | { 145 | "command": "github-releases.openRepoReleases", 146 | "when": "view == github-releases-release-list && viewItem == repo", 147 | "group": "inline" 148 | }, 149 | { 150 | "command": "github-releases.openRelease", 151 | "when": "view == github-releases-release-list && viewItem == release", 152 | "group": "inline" 153 | }, 154 | { 155 | "command": "github-releases.downloadAsset", 156 | "when": "view == github-releases-release-list && viewItem == asset", 157 | "group": "inline" 158 | }, 159 | { 160 | "command": "github-releases.checkoutTag", 161 | "when": "view == github-releases-release-list && viewItem == tag", 162 | "group": "inline" 163 | } 164 | ], 165 | "commandPalette": [ 166 | { 167 | "command": "github-releases.createRelease", 168 | "when": "gitHubReleases:knownGitHubRepos" 169 | }, 170 | { 171 | "command": "github-releases.refreshReleases", 172 | "when": "true" 173 | }, 174 | { 175 | "command": "github-releases.setPage", 176 | "when": "false" 177 | }, 178 | { 179 | "command": "github-releases.editRelease", 180 | "when": "false" 181 | }, 182 | { 183 | "command": "github-releases.deleteRelease", 184 | "when": "false" 185 | }, 186 | { 187 | "command": "github-releases.openRepoReleases", 188 | "when": "gitHubReleases:knownGitHubRepos" 189 | }, 190 | { 191 | "command": "github-releases.openRelease", 192 | "when": "false" 193 | }, 194 | { 195 | "command": "github-releases.downloadAsset", 196 | "when": "false" 197 | }, 198 | { 199 | "command": "github-releases.checkoutTag", 200 | "when": "false" 201 | } 202 | ] 203 | } 204 | }, 205 | "scripts": { 206 | "vscode:prepublish": "npm run package", 207 | "compile": "node ./esbuild.js", 208 | "package": "node ./esbuild.js --production", 209 | "lint": "eslint src --ext ts && prettier --write --tab-width 4 --single-quote .", 210 | "watch": "node ./esbuild.js --watch" 211 | }, 212 | "devDependencies": { 213 | "@types/git-url-parse": "^16.0.0", 214 | "@types/node": "14.14.9", 215 | "@types/parse-link-header": "^2.0.3", 216 | "@types/vscode": "1.55.0", 217 | "@types/vscode-webview": "1.57.4", 218 | "@typescript-eslint/eslint-plugin": "^8.8.0", 219 | "@typescript-eslint/parser": "^8.8.0", 220 | "esbuild": "0.25.8", 221 | "esbuild-copy-static-files": "^0.1.0", 222 | "eslint": "^9.12.0", 223 | "prettier": "^3.1.1", 224 | "typescript": "^5.3.3" 225 | }, 226 | "dependencies": { 227 | "@microsoft/fast-element": "1.14.0", 228 | "@octokit/rest": "^22.0.0", 229 | "@vscode/codicons": "^0.0.38", 230 | "@vscode/webview-ui-toolkit": "^1.4.0", 231 | "git-url-parse": "^16.0.0", 232 | "parse-link-header": "^2.0.0" 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/Remote.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as git from './types/git'; 3 | import { Octokit } from '@octokit/rest'; 4 | import { exec } from 'child_process'; 5 | import fs from 'fs/promises'; 6 | import parseLinkHeader from 'parse-link-header'; 7 | 8 | export interface ReleaseAsset { 9 | id: number; 10 | name: string; 11 | url: string; 12 | } 13 | 14 | export interface Release { 15 | id: number; 16 | tag: string; 17 | title: string; 18 | desc: string; 19 | url: string; 20 | assets: ReleaseAsset[]; 21 | draft: boolean; 22 | prerelease: boolean; 23 | remote: Remote; 24 | author: string; 25 | authorIcon: string; 26 | createDate: string; 27 | publishDate: string | null; 28 | } 29 | 30 | export type PaginationMap = Map< 31 | 'first' | 'prev' | 'next' | 'last', 32 | number | undefined 33 | >; 34 | 35 | function parsePaginationPage( 36 | links: parseLinkHeader.Links | null, 37 | type: string, 38 | ): number | undefined { 39 | return links?.[type]?.page ? +links[type].page : undefined; 40 | } 41 | 42 | export class Remote { 43 | public static readonly ReleasesPerPage = 40; 44 | 45 | private latestId: number | null = null; 46 | 47 | constructor( 48 | private readonly octokit: Octokit, 49 | public readonly owner: string, 50 | public readonly name: string, 51 | public readonly localRepo: git.Repository, 52 | public readonly localName: string, 53 | public readonly url: string, 54 | ) {} 55 | 56 | get identifier() { 57 | return `${this.owner}/${this.name}`; 58 | } 59 | 60 | private async updateLatest() { 61 | try { 62 | const res = await this.octokit.repos.getLatestRelease({ 63 | owner: this.owner, 64 | repo: this.name, 65 | }); 66 | 67 | this.latestId = res.data.id; 68 | } catch (e) { 69 | console.error( 70 | `Failed to get latest release for ${this.identifier}:`, 71 | e, 72 | ); 73 | 74 | this.latestId = null; 75 | } 76 | } 77 | 78 | async getReleases(page = 1) { 79 | await this.updateLatest(); 80 | 81 | const res = await this.octokit.repos.listReleases({ 82 | owner: this.owner, 83 | repo: this.name, 84 | per_page: Remote.ReleasesPerPage, 85 | page, 86 | }); 87 | 88 | const releases = res.data.map((item) => ({ 89 | id: item.id, 90 | tag: item.tag_name, 91 | title: item.name ?? '', 92 | desc: item.body ?? '', 93 | url: item.html_url, 94 | assets: item.assets.map((assetItem) => ({ 95 | id: assetItem.id, 96 | name: assetItem.name, 97 | url: assetItem.browser_download_url, 98 | })), 99 | draft: item.draft, 100 | prerelease: item.prerelease, 101 | remote: this, 102 | author: item.author.login, 103 | authorIcon: item.author.avatar_url, 104 | createDate: item.created_at, 105 | publishDate: item.published_at, 106 | })); 107 | 108 | const links = parseLinkHeader(res.headers.link); 109 | 110 | return { 111 | releases, 112 | pagination: new Map([ 113 | ['first', parsePaginationPage(links, 'first')], 114 | ['prev', parsePaginationPage(links, 'prev')], 115 | ['next', parsePaginationPage(links, 'next')], 116 | ['last', parsePaginationPage(links, 'last')], 117 | ]) satisfies PaginationMap, 118 | }; 119 | } 120 | 121 | isLatest(release: Release) { 122 | return this.latestId === release.id; 123 | } 124 | 125 | async getTags() { 126 | const res = await this.octokit.repos.listTags({ 127 | owner: this.owner, 128 | repo: this.name, 129 | }); 130 | 131 | return res.data.map((item) => item.name); 132 | } 133 | 134 | async getLocalTags() { 135 | const refs = (await this.localRepo.getRefs({})) ?? []; 136 | const tags = refs.filter((ref) => ref.type === git.RefType.Tag); 137 | 138 | tags.reverse(); 139 | return tags.map((ref) => ref.name!); 140 | } 141 | 142 | async pushLocalTag(tag: string) { 143 | try { 144 | await new Promise((resolve, reject) => { 145 | exec( 146 | `git push ${this.localName} ${tag} --quiet`, 147 | { 148 | cwd: this.localRepo.rootUri.fsPath, 149 | }, 150 | (_err, _stdout, stderr) => 151 | stderr ? reject(new Error(stderr)) : resolve(), 152 | ); 153 | }); 154 | 155 | return true; 156 | } catch (e) { 157 | vscode.window.showErrorMessage( 158 | `Failed to push local tag: ${(e as Error).message}`, 159 | ); 160 | 161 | return false; 162 | } 163 | } 164 | 165 | async getBranches() { 166 | const res = await this.octokit.git.listMatchingRefs({ 167 | owner: this.owner, 168 | repo: this.name, 169 | ref: 'heads', 170 | }); 171 | 172 | return res.data.map((item) => item.ref.replace(/^refs\/heads\//, '')); 173 | } 174 | 175 | async getCommits() { 176 | const res = await this.octokit.repos.listCommits({ 177 | owner: this.owner, 178 | repo: this.name, 179 | }); 180 | 181 | return res.data.map((item) => ({ 182 | sha: item.sha, 183 | message: item.commit.message.split('\n')[0], 184 | })); 185 | } 186 | 187 | async checkoutTag(tag: string) { 188 | try { 189 | await new Promise((resolve, reject) => { 190 | exec( 191 | `git fetch ${this.localName} tag ${tag} --porcelain`, 192 | { 193 | cwd: this.localRepo.rootUri.fsPath, 194 | }, 195 | (_err, _stdout, stderr) => 196 | stderr ? reject(new Error(stderr)) : resolve(), 197 | ); 198 | }); 199 | 200 | await new Promise((resolve, reject) => { 201 | exec( 202 | `git checkout ${tag} --quiet`, 203 | { 204 | cwd: this.localRepo.rootUri.fsPath, 205 | }, 206 | (_err, _stdout, stderr) => 207 | stderr ? reject(new Error(stderr)) : resolve(), 208 | ); 209 | }); 210 | 211 | vscode.window.showInformationMessage(`Switched to tag ${tag}`); 212 | } catch (e) { 213 | vscode.window.showErrorMessage( 214 | `Failed to checkout tag: ${(e as Error).message}`, 215 | ); 216 | } 217 | } 218 | 219 | async updateOrPublishRelease(data: { 220 | id?: number; 221 | tag: string; 222 | target: string; 223 | title: string; 224 | desc: string; 225 | draft: boolean; 226 | prerelease: boolean; 227 | makeLatest: boolean; 228 | }) { 229 | const endpoint = data.id 230 | ? this.octokit.repos.updateRelease 231 | : this.octokit.repos.createRelease; 232 | 233 | try { 234 | const res = await endpoint({ 235 | owner: this.owner, 236 | repo: this.name, 237 | release_id: data.id as number, 238 | tag_name: data.tag, 239 | target_commitish: data.target || undefined, 240 | name: data.title, 241 | body: data.desc, 242 | draft: data.draft, 243 | prerelease: data.prerelease, 244 | make_latest: data.makeLatest ? 'true' : 'false', 245 | }); 246 | 247 | return res.data; 248 | } catch (e) { 249 | vscode.window.showErrorMessage( 250 | `Failed to publish release: ${(e as Error).message}`, 251 | ); 252 | } 253 | } 254 | 255 | async deleteRelease(releaseId: number) { 256 | await this.octokit.repos.deleteRelease({ 257 | owner: this.owner, 258 | repo: this.name, 259 | release_id: releaseId, 260 | }); 261 | } 262 | 263 | async tryUploadReleaseAsset(releaseId: number, name: string, path: string) { 264 | try { 265 | await this.octokit.repos.uploadReleaseAsset({ 266 | owner: this.owner, 267 | repo: this.name, 268 | release_id: releaseId, 269 | name: name, 270 | data: (await fs.readFile(path)) as unknown as string, // https://github.com/octokit/octokit.js/discussions/2087 271 | }); 272 | } catch { 273 | vscode.window.showErrorMessage( 274 | `Failed to add release asset ${name}`, 275 | ); 276 | } 277 | } 278 | 279 | async tryDeleteReleaseAsset(id: number, name: string) { 280 | try { 281 | await this.octokit.repos.deleteReleaseAsset({ 282 | owner: this.owner, 283 | repo: this.name, 284 | asset_id: id, 285 | }); 286 | } catch { 287 | vscode.window.showErrorMessage( 288 | `Failed to delete release asset "${name}"`, 289 | ); 290 | } 291 | } 292 | 293 | async tryRenameReleaseAsset(id: number, oldName: string, newName: string) { 294 | try { 295 | await this.octokit.repos.updateReleaseAsset({ 296 | owner: this.owner, 297 | repo: this.name, 298 | asset_id: id, 299 | name: newName, 300 | }); 301 | } catch { 302 | vscode.window.showErrorMessage( 303 | `Failed to rename release asset "${oldName}" to "${newName}"`, 304 | ); 305 | } 306 | } 307 | 308 | async generateReleaseNotes(tag: string, target?: string) { 309 | try { 310 | const res = await this.octokit.repos.generateReleaseNotes({ 311 | owner: this.owner, 312 | repo: this.name, 313 | tag_name: tag, 314 | target_commitish: target || undefined, 315 | }); 316 | 317 | return { 318 | title: res.data.name, 319 | desc: res.data.body, 320 | }; 321 | } catch (e) { 322 | vscode.window.showErrorMessage( 323 | `Failed to generate release notes: ${(e as Error).message}`, 324 | ); 325 | } 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /src/types/git.ts: -------------------------------------------------------------------------------- 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 | import { 7 | Uri, 8 | Event, 9 | Disposable, 10 | ProviderResult, 11 | Command, 12 | CancellationToken, 13 | } from 'vscode'; 14 | export { ProviderResult } from 'vscode'; 15 | 16 | export interface Git { 17 | readonly path: string; 18 | } 19 | 20 | export interface InputBox { 21 | value: string; 22 | } 23 | 24 | export const enum ForcePushMode { 25 | Force, 26 | ForceWithLease, 27 | ForceWithLeaseIfIncludes, 28 | } 29 | 30 | export const enum RefType { 31 | Head, 32 | RemoteHead, 33 | Tag, 34 | } 35 | 36 | export interface Ref { 37 | readonly type: RefType; 38 | readonly name?: string; 39 | readonly commit?: string; 40 | readonly remote?: string; 41 | } 42 | 43 | export interface UpstreamRef { 44 | readonly remote: string; 45 | readonly name: string; 46 | readonly commit?: string; 47 | } 48 | 49 | export interface Branch extends Ref { 50 | readonly upstream?: UpstreamRef; 51 | readonly ahead?: number; 52 | readonly behind?: number; 53 | } 54 | 55 | export interface CommitShortStat { 56 | readonly files: number; 57 | readonly insertions: number; 58 | readonly deletions: number; 59 | } 60 | 61 | export interface Commit { 62 | readonly hash: string; 63 | readonly message: string; 64 | readonly parents: string[]; 65 | readonly authorDate?: Date; 66 | readonly authorName?: string; 67 | readonly authorEmail?: string; 68 | readonly commitDate?: Date; 69 | readonly shortStat?: CommitShortStat; 70 | } 71 | 72 | export interface Submodule { 73 | readonly name: string; 74 | readonly path: string; 75 | readonly url: string; 76 | } 77 | 78 | export interface Remote { 79 | readonly name: string; 80 | readonly fetchUrl?: string; 81 | readonly pushUrl?: string; 82 | readonly isReadOnly: boolean; 83 | } 84 | 85 | export const enum Status { 86 | INDEX_MODIFIED, 87 | INDEX_ADDED, 88 | INDEX_DELETED, 89 | INDEX_RENAMED, 90 | INDEX_COPIED, 91 | 92 | MODIFIED, 93 | DELETED, 94 | UNTRACKED, 95 | IGNORED, 96 | INTENT_TO_ADD, 97 | INTENT_TO_RENAME, 98 | TYPE_CHANGED, 99 | 100 | ADDED_BY_US, 101 | ADDED_BY_THEM, 102 | DELETED_BY_US, 103 | DELETED_BY_THEM, 104 | BOTH_ADDED, 105 | BOTH_DELETED, 106 | BOTH_MODIFIED, 107 | } 108 | 109 | export interface Change { 110 | /** 111 | * Returns either `originalUri` or `renameUri`, depending 112 | * on whether this change is a rename change. When 113 | * in doubt always use `uri` over the other two alternatives. 114 | */ 115 | readonly uri: Uri; 116 | readonly originalUri: Uri; 117 | readonly renameUri: Uri | undefined; 118 | readonly status: Status; 119 | } 120 | 121 | export interface RepositoryState { 122 | readonly HEAD: Branch | undefined; 123 | readonly refs: Ref[]; 124 | readonly remotes: Remote[]; 125 | readonly submodules: Submodule[]; 126 | readonly rebaseCommit: Commit | undefined; 127 | 128 | readonly mergeChanges: Change[]; 129 | readonly indexChanges: Change[]; 130 | readonly workingTreeChanges: Change[]; 131 | 132 | readonly onDidChange: Event; 133 | } 134 | 135 | export interface RepositoryUIState { 136 | readonly selected: boolean; 137 | readonly onDidChange: Event; 138 | } 139 | 140 | /** 141 | * Log options. 142 | */ 143 | export interface LogOptions { 144 | /** Max number of log entries to retrieve. If not specified, the default is 32. */ 145 | readonly maxEntries?: number; 146 | readonly path?: string; 147 | /** A commit range, such as "0a47c67f0fb52dd11562af48658bc1dff1d75a38..0bb4bdea78e1db44d728fd6894720071e303304f" */ 148 | readonly range?: string; 149 | readonly reverse?: boolean; 150 | readonly sortByAuthorDate?: boolean; 151 | readonly shortStats?: boolean; 152 | } 153 | 154 | export interface CommitOptions { 155 | all?: boolean | 'tracked'; 156 | amend?: boolean; 157 | signoff?: boolean; 158 | signCommit?: boolean; 159 | empty?: boolean; 160 | noVerify?: boolean; 161 | requireUserConfig?: boolean; 162 | useEditor?: boolean; 163 | verbose?: boolean; 164 | /** 165 | * string - execute the specified command after the commit operation 166 | * undefined - execute the command specified in git.postCommitCommand 167 | * after the commit operation 168 | * null - do not execute any command after the commit operation 169 | */ 170 | postCommitCommand?: string | null; 171 | } 172 | 173 | export interface FetchOptions { 174 | remote?: string; 175 | ref?: string; 176 | all?: boolean; 177 | prune?: boolean; 178 | depth?: number; 179 | } 180 | 181 | export interface InitOptions { 182 | defaultBranch?: string; 183 | } 184 | 185 | export interface RefQuery { 186 | readonly contains?: string; 187 | readonly count?: number; 188 | readonly pattern?: string; 189 | readonly sort?: 'alphabetically' | 'committerdate'; 190 | } 191 | 192 | export interface BranchQuery extends RefQuery { 193 | readonly remote?: boolean; 194 | } 195 | 196 | export interface Repository { 197 | readonly rootUri: Uri; 198 | readonly inputBox: InputBox; 199 | readonly state: RepositoryState; 200 | readonly ui: RepositoryUIState; 201 | 202 | getConfigs(): Promise<{ key: string; value: string }[]>; 203 | getConfig(key: string): Promise; 204 | setConfig(key: string, value: string): Promise; 205 | getGlobalConfig(key: string): Promise; 206 | 207 | getObjectDetails( 208 | treeish: string, 209 | path: string, 210 | ): Promise<{ mode: string; object: string; size: number }>; 211 | detectObjectType( 212 | object: string, 213 | ): Promise<{ mimetype: string; encoding?: string }>; 214 | buffer(ref: string, path: string): Promise; 215 | show(ref: string, path: string): Promise; 216 | getCommit(ref: string): Promise; 217 | 218 | add(paths: string[]): Promise; 219 | revert(paths: string[]): Promise; 220 | clean(paths: string[]): Promise; 221 | 222 | apply(patch: string, reverse?: boolean): Promise; 223 | diff(cached?: boolean): Promise; 224 | diffWithHEAD(): Promise; 225 | diffWithHEAD(path: string): Promise; 226 | diffWith(ref: string): Promise; 227 | diffWith(ref: string, path: string): Promise; 228 | diffIndexWithHEAD(): Promise; 229 | diffIndexWithHEAD(path: string): Promise; 230 | diffIndexWith(ref: string): Promise; 231 | diffIndexWith(ref: string, path: string): Promise; 232 | diffBlobs(object1: string, object2: string): Promise; 233 | diffBetween(ref1: string, ref2: string): Promise; 234 | diffBetween(ref1: string, ref2: string, path: string): Promise; 235 | 236 | getDiff(): Promise; 237 | 238 | hashObject(data: string): Promise; 239 | 240 | createBranch(name: string, checkout: boolean, ref?: string): Promise; 241 | deleteBranch(name: string, force?: boolean): Promise; 242 | getBranch(name: string): Promise; 243 | getBranches( 244 | query: BranchQuery, 245 | cancellationToken?: CancellationToken, 246 | ): Promise; 247 | getBranchBase(name: string): Promise; 248 | setBranchUpstream(name: string, upstream: string): Promise; 249 | 250 | getRefs( 251 | query: RefQuery, 252 | cancellationToken?: CancellationToken, 253 | ): Promise; 254 | 255 | getMergeBase(ref1: string, ref2: string): Promise; 256 | 257 | tag(name: string, upstream: string): Promise; 258 | deleteTag(name: string): Promise; 259 | 260 | status(): Promise; 261 | checkout(treeish: string): Promise; 262 | 263 | addRemote(name: string, url: string): Promise; 264 | removeRemote(name: string): Promise; 265 | renameRemote(name: string, newName: string): Promise; 266 | 267 | fetch(options?: FetchOptions): Promise; 268 | fetch(remote?: string, ref?: string, depth?: number): Promise; 269 | pull(unshallow?: boolean): Promise; 270 | push( 271 | remoteName?: string, 272 | branchName?: string, 273 | setUpstream?: boolean, 274 | force?: ForcePushMode, 275 | ): Promise; 276 | 277 | blame(path: string): Promise; 278 | log(options?: LogOptions): Promise; 279 | 280 | commit(message: string, opts?: CommitOptions): Promise; 281 | } 282 | 283 | export interface RemoteSource { 284 | readonly name: string; 285 | readonly description?: string; 286 | readonly url: string | string[]; 287 | } 288 | 289 | export interface RemoteSourceProvider { 290 | readonly name: string; 291 | readonly icon?: string; // codicon name 292 | readonly supportsQuery?: boolean; 293 | getRemoteSources(query?: string): ProviderResult; 294 | getBranches?(url: string): ProviderResult; 295 | publishRepository?(repository: Repository): Promise; 296 | } 297 | 298 | export interface RemoteSourcePublisher { 299 | readonly name: string; 300 | readonly icon?: string; // codicon name 301 | publishRepository(repository: Repository): Promise; 302 | } 303 | 304 | export interface Credentials { 305 | readonly username: string; 306 | readonly password: string; 307 | } 308 | 309 | export interface CredentialsProvider { 310 | getCredentials(host: Uri): ProviderResult; 311 | } 312 | 313 | export interface PostCommitCommandsProvider { 314 | getCommands(repository: Repository): Command[]; 315 | } 316 | 317 | export interface PushErrorHandler { 318 | handlePushError( 319 | repository: Repository, 320 | remote: Remote, 321 | refspec: string, 322 | error: Error & { gitErrorCode: GitErrorCodes }, 323 | ): Promise; 324 | } 325 | 326 | export interface BranchProtection { 327 | readonly remote: string; 328 | readonly rules: BranchProtectionRule[]; 329 | } 330 | 331 | export interface BranchProtectionRule { 332 | readonly include?: string[]; 333 | readonly exclude?: string[]; 334 | } 335 | 336 | export interface BranchProtectionProvider { 337 | onDidChangeBranchProtection: Event; 338 | provideBranchProtection(): BranchProtection[]; 339 | } 340 | 341 | export type APIState = 'uninitialized' | 'initialized'; 342 | 343 | export interface PublishEvent { 344 | repository: Repository; 345 | branch?: string; 346 | } 347 | 348 | export interface API { 349 | readonly state: APIState; 350 | readonly onDidChangeState: Event; 351 | readonly onDidPublish: Event; 352 | readonly git: Git; 353 | readonly repositories: Repository[]; 354 | readonly onDidOpenRepository: Event; 355 | readonly onDidCloseRepository: Event; 356 | 357 | toGitUri(uri: Uri, ref: string): Uri; 358 | getRepository(uri: Uri): Repository | null; 359 | init(root: Uri, options?: InitOptions): Promise; 360 | openRepository(root: Uri): Promise; 361 | 362 | registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; 363 | registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; 364 | registerCredentialsProvider(provider: CredentialsProvider): Disposable; 365 | registerPostCommitCommandsProvider( 366 | provider: PostCommitCommandsProvider, 367 | ): Disposable; 368 | registerPushErrorHandler(handler: PushErrorHandler): Disposable; 369 | registerBranchProtectionProvider( 370 | root: Uri, 371 | provider: BranchProtectionProvider, 372 | ): Disposable; 373 | } 374 | 375 | export interface GitExtension { 376 | readonly enabled: boolean; 377 | readonly onDidChangeEnablement: Event; 378 | 379 | /** 380 | * Returns a specific API version. 381 | * 382 | * Throws error if git extension is disabled. You can listen to the 383 | * [GitExtension.onDidChangeEnablement](#GitExtension.onDidChangeEnablement) event 384 | * to know when the extension becomes enabled/disabled. 385 | * 386 | * @param version Version number. 387 | * @returns API instance 388 | */ 389 | getAPI(version: 1): API; 390 | } 391 | 392 | export const enum GitErrorCodes { 393 | BadConfigFile = 'BadConfigFile', 394 | AuthenticationFailed = 'AuthenticationFailed', 395 | NoUserNameConfigured = 'NoUserNameConfigured', 396 | NoUserEmailConfigured = 'NoUserEmailConfigured', 397 | NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', 398 | NotAGitRepository = 'NotAGitRepository', 399 | NotAtRepositoryRoot = 'NotAtRepositoryRoot', 400 | Conflict = 'Conflict', 401 | StashConflict = 'StashConflict', 402 | UnmergedChanges = 'UnmergedChanges', 403 | PushRejected = 'PushRejected', 404 | ForcePushWithLeaseRejected = 'ForcePushWithLeaseRejected', 405 | ForcePushWithLeaseIfIncludesRejected = 'ForcePushWithLeaseIfIncludesRejected', 406 | RemoteConnectionError = 'RemoteConnectionError', 407 | DirtyWorkTree = 'DirtyWorkTree', 408 | CantOpenResource = 'CantOpenResource', 409 | GitNotFound = 'GitNotFound', 410 | CantCreatePipe = 'CantCreatePipe', 411 | PermissionDenied = 'PermissionDenied', 412 | CantAccessRemote = 'CantAccessRemote', 413 | RepositoryNotFound = 'RepositoryNotFound', 414 | RepositoryIsLocked = 'RepositoryIsLocked', 415 | BranchNotFullyMerged = 'BranchNotFullyMerged', 416 | NoRemoteReference = 'NoRemoteReference', 417 | InvalidBranchName = 'InvalidBranchName', 418 | BranchAlreadyExists = 'BranchAlreadyExists', 419 | NoLocalChanges = 'NoLocalChanges', 420 | NoStashFound = 'NoStashFound', 421 | LocalChangesOverwritten = 'LocalChangesOverwritten', 422 | NoUpstreamBranch = 'NoUpstreamBranch', 423 | IsInSubmodule = 'IsInSubmodule', 424 | WrongCase = 'WrongCase', 425 | CantLockRef = 'CantLockRef', 426 | CantRebaseMultipleBranches = 'CantRebaseMultipleBranches', 427 | PatchDoesNotApply = 'PatchDoesNotApply', 428 | NoPathFound = 'NoPathFound', 429 | UnknownPath = 'UnknownPath', 430 | EmptyCommitMessage = 'EmptyCommitMessage', 431 | BranchFastForwardRejected = 'BranchFastForwardRejected', 432 | BranchNotYetBorn = 'BranchNotYetBorn', 433 | TagConflict = 'TagConflict', 434 | } 435 | -------------------------------------------------------------------------------- /src/WebviewProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { basename } from 'path'; 3 | import { RemoteList } from './RemoteList'; 4 | import { Release, Remote } from './Remote'; 5 | import { 6 | WebviewState, 7 | WebviewStateMessage, 8 | PartialWebviewStateMessage, 9 | } from './types/webview'; 10 | 11 | export class WebviewProvider implements vscode.WebviewViewProvider { 12 | webviewView?: vscode.WebviewView; 13 | 14 | constructor( 15 | private readonly ctx: vscode.ExtensionContext, 16 | private readonly remotes: RemoteList, 17 | ) {} 18 | 19 | private baseRelease?: Release; 20 | private state?: WebviewState; 21 | private remote?: Remote; 22 | 23 | clear() { 24 | this.baseRelease = undefined; 25 | this.state = undefined; 26 | this.remote = undefined; 27 | } 28 | 29 | async show(release?: Release) { 30 | this.clear(); 31 | 32 | this.baseRelease = release; 33 | 34 | if (release) { 35 | this.remote = this.baseRelease?.remote; 36 | } else if (this.remotes.list.length === 1) { 37 | this.remote = this.remotes.list[0]; 38 | } else { 39 | this.remote = ( 40 | await vscode.window.showQuickPick( 41 | this.remotes.list.map((remote) => ({ 42 | label: remote.identifier, 43 | remote, 44 | })), 45 | { 46 | placeHolder: 'Select GitHub repository', 47 | }, 48 | ) 49 | )?.remote; 50 | } 51 | 52 | if (!this.remote) { 53 | vscode.window.showInformationMessage('Release creation cancelled.'); 54 | return; 55 | } 56 | 57 | if (this.webviewView) { 58 | this.sendStartMessage(); 59 | this.webviewView.show(); 60 | } 61 | 62 | await vscode.commands.executeCommand( 63 | 'setContext', 64 | 'gitHubReleases:createRelease', 65 | true, 66 | ); 67 | } 68 | 69 | async hide() { 70 | this.clear(); 71 | 72 | await vscode.commands.executeCommand( 73 | 'setContext', 74 | 'gitHubReleases:createRelease', 75 | false, 76 | ); 77 | } 78 | 79 | sendStartMessage() { 80 | const defaultTarget = this.remote!.localRepo.state.HEAD?.name ?? ''; 81 | 82 | return this.webviewView!.webview.postMessage({ 83 | type: 'set-state', 84 | tag: { 85 | name: this.state?.tag.name ?? this.baseRelease?.tag ?? '', 86 | existing: this.state?.tag.existing ?? !!this.baseRelease?.tag, 87 | }, 88 | target: { 89 | ref: this.state?.target.ref ?? defaultTarget, 90 | display: this.state?.target.display ?? defaultTarget, 91 | }, 92 | title: this.state?.title ?? this.baseRelease?.title ?? '', 93 | desc: this.state?.desc ?? this.baseRelease?.desc ?? '', 94 | draft: this.state?.draft ?? this.baseRelease?.draft ?? false, 95 | prerelease: 96 | this.state?.prerelease ?? this.baseRelease?.prerelease ?? false, 97 | assets: { 98 | current: 99 | this.state?.assets.current ?? 100 | this.baseRelease?.assets.map((asset) => ({ 101 | new: false, 102 | ...asset, 103 | })) ?? 104 | [], 105 | deleted: this.state?.assets.deleted ?? [], 106 | renamed: this.state?.assets.renamed ?? [], 107 | }, 108 | makeLatest: 109 | this.state?.makeLatest ?? this.baseRelease?.draft ?? true, 110 | isLatest: 111 | this.baseRelease?.remote.isLatest(this.baseRelease) ?? false, 112 | } satisfies WebviewStateMessage); 113 | } 114 | 115 | async getAsset() { 116 | const res = await vscode.window.showOpenDialog({ canSelectMany: true }); 117 | 118 | res?.map((uri) => 119 | this.webviewView!.webview.postMessage({ 120 | type: 'add-asset', 121 | asset: { 122 | new: true, 123 | name: basename(uri.fsPath), 124 | path: uri.fsPath, 125 | }, 126 | }), 127 | ); 128 | } 129 | 130 | async selectTag() { 131 | const tags = await this.remote!.getTags(); 132 | 133 | const tag = await new Promise((resolve) => { 134 | const quickPick = vscode.window.createQuickPick(); 135 | const items = [ 136 | { 137 | label: 'Push a local tag...', 138 | showLocal: true, 139 | } as vscode.QuickPickItem, 140 | { 141 | label: 'Remote tags', 142 | kind: -1, 143 | } as vscode.QuickPickItem, 144 | ...tags.map((label) => ({ label })), 145 | { 146 | label: '', 147 | kind: -1, 148 | } as vscode.QuickPickItem, 149 | ]; 150 | 151 | quickPick.items = items; 152 | quickPick.placeholder = 153 | 'Select an existing tag or enter a name for a new one'; 154 | 155 | quickPick.onDidChangeValue(() => { 156 | if (quickPick.value && !tags.includes(quickPick.value)) { 157 | quickPick.items = [{ label: quickPick.value }, ...items]; 158 | } else { 159 | quickPick.items = items; 160 | } 161 | }); 162 | 163 | quickPick.onDidAccept(async () => { 164 | const selection = quickPick.activeItems[0]; 165 | quickPick.hide(); 166 | 167 | if ('showLocal' in selection) { 168 | const localTag = await vscode.window.showQuickPick( 169 | await this.remote!.getLocalTags(), 170 | { placeHolder: 'Select a tag to push' }, 171 | ); 172 | 173 | if ( 174 | !localTag || 175 | !(await this.remote!.pushLocalTag(localTag)) 176 | ) { 177 | resolve(''); 178 | return; 179 | } 180 | 181 | resolve(localTag); 182 | return; 183 | } 184 | 185 | resolve(selection.label); 186 | }); 187 | 188 | quickPick.show(); 189 | }); 190 | 191 | if (!tag) return; 192 | 193 | this.webviewView!.webview.postMessage({ 194 | type: 'set-state', 195 | tag: { 196 | name: tag, 197 | existing: tags.includes(tag), 198 | }, 199 | } satisfies PartialWebviewStateMessage); 200 | } 201 | 202 | async selectTarget() { 203 | const targets = [ 204 | { 205 | value: '', 206 | label: 'Branches', 207 | kind: -1, 208 | }, 209 | ...(await this.remote!.getBranches()).map((label) => ({ 210 | value: label, 211 | label, 212 | })), 213 | { 214 | value: '', 215 | label: 'Commits', 216 | kind: -1, 217 | }, 218 | ...(await this.remote!.getCommits()).map((commit) => ({ 219 | value: commit.sha, 220 | label: commit.sha.slice(0, 8), 221 | detail: commit.message, 222 | })), 223 | ]; 224 | 225 | const target = (await vscode.window.showQuickPick(targets, { 226 | placeHolder: 'Select a target for the release tag', 227 | matchOnDescription: true, 228 | matchOnDetail: true, 229 | })) ?? { value: '', label: '' }; 230 | 231 | if (!target.value) return; 232 | 233 | this.webviewView!.webview.postMessage({ 234 | type: 'set-state', 235 | target: { 236 | ref: target.value, 237 | display: target.label, 238 | }, 239 | } satisfies PartialWebviewStateMessage); 240 | } 241 | 242 | async generateReleaseNotes({ 243 | tag, 244 | target, 245 | }: { 246 | tag: string; 247 | target: string; 248 | }) { 249 | const notes = await this.remote!.generateReleaseNotes(tag, target); 250 | 251 | if (!notes) return; 252 | 253 | await this.webviewView!.webview.postMessage({ 254 | type: 'set-state', 255 | title: notes.title, 256 | desc: notes.desc, 257 | } satisfies PartialWebviewStateMessage); 258 | } 259 | 260 | async processPublish(data: WebviewState) { 261 | const newRelease = await this.remote!.updateOrPublishRelease({ 262 | id: this.baseRelease?.id, 263 | tag: data.tag.name, 264 | target: data.target.ref, 265 | title: data.title, 266 | desc: data.desc, 267 | draft: data.draft, 268 | prerelease: data.prerelease, 269 | makeLatest: data.makeLatest, 270 | }); 271 | 272 | if (!newRelease) return; 273 | 274 | if (this.baseRelease) { 275 | for (let [id, name] of data.assets.deleted) { 276 | await this.remote!.tryDeleteReleaseAsset(id, name); 277 | } 278 | 279 | for (let [id, [oldName, newName]] of data.assets.renamed) { 280 | await this.remote!.tryRenameReleaseAsset(id, oldName, newName); 281 | } 282 | } 283 | 284 | for (let asset of data.assets.current) { 285 | if (!asset.new) continue; 286 | 287 | await this.remote!.tryUploadReleaseAsset( 288 | newRelease.id, 289 | asset.name, 290 | asset.path, 291 | ); 292 | } 293 | 294 | this.hide(); 295 | 296 | vscode.commands.executeCommand('github-releases.refreshReleases'); 297 | } 298 | 299 | async processMessage(data: any) { 300 | switch (data.type) { 301 | case 'start': { 302 | this.sendStartMessage(); 303 | break; 304 | } 305 | case 'save-state': 306 | this.state = data; 307 | break; 308 | case 'request-asset': { 309 | this.getAsset(); 310 | break; 311 | } 312 | case 'name-in-use': 313 | vscode.window.showErrorMessage( 314 | 'A file with that name already exists.', 315 | ); 316 | break; 317 | case 'select-tag': { 318 | this.selectTag(); 319 | return; 320 | } 321 | case 'select-target': { 322 | this.selectTarget(); 323 | return; 324 | } 325 | case 'generate-release-notes': { 326 | this.generateReleaseNotes(data); 327 | return; 328 | } 329 | case 'publish-release': { 330 | this.processPublish(data); 331 | break; 332 | } 333 | case 'cancel': 334 | this.hide(); 335 | } 336 | } 337 | 338 | async resolveWebviewView(webviewView: vscode.WebviewView) { 339 | this.webviewView = webviewView; 340 | 341 | webviewView.onDidDispose(() => { 342 | if (this.webviewView === webviewView) { 343 | this.webviewView = undefined; 344 | } 345 | }); 346 | 347 | webviewView.webview.options = { 348 | enableScripts: true, 349 | }; 350 | 351 | webviewView.webview.html = this.getHtml(); 352 | 353 | webviewView.webview.onDidReceiveMessage((data) => 354 | this.processMessage(data), 355 | ); 356 | } 357 | 358 | getHtml() { 359 | const styleURI = this.webviewView!.webview.asWebviewUri( 360 | vscode.Uri.joinPath(this.ctx.extensionUri, 'out', 'webview.css'), 361 | ); 362 | const codiconsUri = this.webviewView!.webview.asWebviewUri( 363 | vscode.Uri.joinPath(this.ctx.extensionUri, 'out', 'codicon.css'), 364 | ); 365 | const scriptURI = this.webviewView!.webview.asWebviewUri( 366 | vscode.Uri.joinPath(this.ctx.extensionUri, 'out', 'webview.js'), 367 | ); 368 | 369 | return ` 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 |
378 | 379 | 380 |
381 |
382 | 383 |
384 |
385 | 386 |
387 |
388 | Generate Notes 389 |
390 | 391 |
392 | Add Files 393 |
394 |
395 | Pre-release 396 | Make latest 397 |
398 |
399 | Draft 400 |
401 |
402 | Cancel 403 | Publish 404 |
405 | 406 | 407 | 408 | `; 409 | } 410 | } 411 | --------------------------------------------------------------------------------