├── .gitignore ├── images ├── logo │ ├── logo128.png │ ├── logo256.png │ ├── logoVS.png │ └── readme.txt ├── readme │ ├── login.png │ ├── usage.gif │ ├── logout1.png │ ├── logout2.png │ ├── publish.png │ └── config │ │ ├── coloredIconsTrue.png │ │ ├── coloredIconsFalse.png │ │ └── showRepositoryCommandsIcons.png └── changelog │ ├── 0_3_0.png │ └── 0_3_6.png ├── src ├── main │ ├── consts.ts │ ├── utils.ts │ ├── configs.ts │ └── storage.ts ├── commands │ ├── git │ │ ├── pathHasGit │ │ │ └── pathHasGit.ts │ │ ├── getRepositoryGitUrl.ts │ │ ├── getHead.ts │ │ ├── gitHasRemote │ │ │ └── gitHasRemote.ts │ │ ├── dirtiness │ │ │ └── dirtiness.ts │ │ ├── getRemoteHeadBranch │ │ │ └── getRemoteHeadBranch.ts │ │ ├── initGit │ │ │ └── initGit.ts │ │ └── cloneRepository │ │ │ └── cloneRepository.ts │ ├── github │ │ ├── getOctokitErrorMessage.ts │ │ ├── createGitHubRepository.ts │ │ ├── getUserData.ts │ │ ├── getUserRepos.ts │ │ └── getOrgRepos.ts │ └── searchClonedRepos │ │ └── searchClonedRepos.ts ├── store │ ├── repository.ts │ ├── organization.ts │ ├── workspace.ts │ └── user.ts ├── commandsUi │ ├── uiPublish │ │ ├── noGit.ts │ │ ├── noRemote.ts │ │ └── uiPublish.ts │ ├── uiCloneTo.ts │ └── uiCreateRepo.ts ├── extension.ts ├── vscode │ └── myQuickPick.ts └── treeView │ ├── repositories │ ├── notClonedRepos.ts │ ├── repositories.ts │ ├── repoItem.ts │ └── clonedRepos.ts │ ├── treeViewBase.ts │ └── account │ └── account.ts ├── usefulLinks.txt ├── webpack ├── webpack.config.dev.js ├── webpack.config.prod.js └── webpack.config.js ├── tsconfig.lint.json ├── .vscodeignore ├── tsconfig.json ├── .vscode ├── settings.json ├── launch.json └── tasks.json ├── .eslintrc.js ├── LICENSE.md ├── README.md ├── CHANGELOG.md └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | out/ 2 | dist/ 3 | node_modules/ 4 | .vscode-test/ 5 | *.vsix 6 | .env 7 | .VSCodeCounter 8 | test.js -------------------------------------------------------------------------------- /images/logo/logo128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftzi/GitHub-Repository-Manager/HEAD/images/logo/logo128.png -------------------------------------------------------------------------------- /images/logo/logo256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftzi/GitHub-Repository-Manager/HEAD/images/logo/logo256.png -------------------------------------------------------------------------------- /images/logo/logoVS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftzi/GitHub-Repository-Manager/HEAD/images/logo/logoVS.png -------------------------------------------------------------------------------- /images/readme/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftzi/GitHub-Repository-Manager/HEAD/images/readme/login.png -------------------------------------------------------------------------------- /images/readme/usage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftzi/GitHub-Repository-Manager/HEAD/images/readme/usage.gif -------------------------------------------------------------------------------- /images/changelog/0_3_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftzi/GitHub-Repository-Manager/HEAD/images/changelog/0_3_0.png -------------------------------------------------------------------------------- /images/changelog/0_3_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftzi/GitHub-Repository-Manager/HEAD/images/changelog/0_3_6.png -------------------------------------------------------------------------------- /images/readme/logout1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftzi/GitHub-Repository-Manager/HEAD/images/readme/logout1.png -------------------------------------------------------------------------------- /images/readme/logout2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftzi/GitHub-Repository-Manager/HEAD/images/readme/logout2.png -------------------------------------------------------------------------------- /images/readme/publish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftzi/GitHub-Repository-Manager/HEAD/images/readme/publish.png -------------------------------------------------------------------------------- /images/readme/config/coloredIconsTrue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftzi/GitHub-Repository-Manager/HEAD/images/readme/config/coloredIconsTrue.png -------------------------------------------------------------------------------- /images/readme/config/coloredIconsFalse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftzi/GitHub-Repository-Manager/HEAD/images/readme/config/coloredIconsFalse.png -------------------------------------------------------------------------------- /src/main/consts.ts: -------------------------------------------------------------------------------- 1 | /** Used in package.json. 2 | * 3 | * Is it require to have this before everything? */ 4 | export const extensionIdentifier = 'githubRepoMgr'; -------------------------------------------------------------------------------- /images/readme/config/showRepositoryCommandsIcons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ftzi/GitHub-Repository-Manager/HEAD/images/readme/config/showRepositoryCommandsIcons.png -------------------------------------------------------------------------------- /usefulLinks.txt: -------------------------------------------------------------------------------- 1 | icons 2 | https://code.visualstudio.com/api/references/icons-in-labels 3 | 4 | "when" conditionals 5 | https://code.visualstudio.com/api/references/when-clause-contexts -------------------------------------------------------------------------------- /webpack/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { merge } = require('webpack-merge'); 4 | const common = require('./webpack.config.js'); 5 | 6 | 7 | module.exports = merge(common, { 8 | mode: 'development', 9 | devtool: 'source-map', 10 | }); -------------------------------------------------------------------------------- /src/commands/git/pathHasGit/pathHasGit.ts: -------------------------------------------------------------------------------- 1 | import Path from 'path'; 2 | import fse from 'fs-extra'; 3 | 4 | 5 | /** True if has git, false if don't. */ 6 | export function pathHasGit(path: string): Promise { 7 | return fse.pathExists(Path.join(path, '.git')); 8 | } -------------------------------------------------------------------------------- /tsconfig.lint.json: -------------------------------------------------------------------------------- 1 | // We need this file for eslint, as in tsconfig.json we exclude the tests from the compilation, 2 | // but then, jest will complain the test files aren't included in tsconfig. 3 | { 4 | "extends": "./tsconfig.json", 5 | "include": ["src/**/*", "*.js"] 6 | } -------------------------------------------------------------------------------- /webpack/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { merge } = require('webpack-merge'); 4 | const common = require('./webpack.config.js'); 5 | 6 | 7 | module.exports = merge(common, { 8 | mode: 'production', 9 | optimization: { 10 | minimize: true, 11 | }, 12 | }); -------------------------------------------------------------------------------- /images/logo/readme.txt: -------------------------------------------------------------------------------- 1 | logo256.png is used on GitHub OAuth image. The background color should be #000. 2 | logo128.png is used as VSCode Extension / Marketplace image. 3 | 4 | For some reason nextLogo5 isn't showing the purple when opening on Inkscape (maybe on other tools too). You need to move stuff and Ctrl+z to get it visible (wtf?) 5 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | out/test/** 3 | src/** 4 | .gitignore 5 | vsc-extension-quickstart.md 6 | **/tsconfig.json 7 | **/tsconfig.lint.json 8 | **/.eslintrc.json 9 | **/.eslintrc.js 10 | webpack 11 | 12 | **/*.map 13 | **/*.ts 14 | node_modules 15 | .github 16 | .git 17 | 18 | usefulLinks.txt 19 | pnpm-lock.yaml 20 | images/* 21 | !images/logo 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES2018", 5 | "outDir": "dist", 6 | "lib": ["ES2020"], 7 | "rootDir": "src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "noImplicitAny": true, 11 | "noUncheckedIndexedAccess": true, 12 | }, 13 | "include": ["src/**/*"] 14 | } 15 | -------------------------------------------------------------------------------- /src/commands/git/getRepositoryGitUrl.ts: -------------------------------------------------------------------------------- 1 | /** Includes .git on end. */ 2 | export function getRepositoryGitUrl(options: { 3 | owner: string; 4 | repositoryName: string; 5 | token?: string; 6 | }): string { 7 | return options.token 8 | ? `https://${options.token}@github.com/${options.owner}/${options.repositoryName}.git` 9 | : `https://github.com/${options.owner}/${options.repositoryName}.git`; 10 | } -------------------------------------------------------------------------------- /src/commands/git/getHead.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | 3 | 4 | export async function getHeadBranch(cwd: string): Promise { 5 | // Requires Git >1.8, but will properly error if dettached head, whatever that means. 6 | // https://stackoverflow.com/a/19147667/10247962 but read other answers. no '-q' here, as it would 'quiet' 7 | // the stderr. 8 | return (await execa('git', ['symbolic-ref', '--short', 'HEAD'], { cwd })).stdout; 9 | } -------------------------------------------------------------------------------- /src/commands/git/gitHasRemote/gitHasRemote.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | 3 | 4 | /** Throw error if path hasn't git. */ 5 | export async function gitHasRemote(path: string): Promise { 6 | /** returns '' if no remote, 'origin' if origin remote, 'origin\norigin2' for 2 remotes. On windows certainly 7 | * may include \r. */ 8 | const { stdout } = await execa('git', ['remote', 'show'], { cwd: path }); 9 | return !!stdout; 10 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.watcherExclude": { 4 | "**/.git/objects/**": true, 5 | "**/.git/subtree-cache/**": true, 6 | "**/node_modules/**": true, 7 | "**/.hg/store/**": true 8 | }, 9 | "search.exclude": { 10 | "dist": true // set this to false to include "dist" folder in search results 11 | }, 12 | "typescript.tsdk": "node_modules/typescript/lib" 13 | } 14 | -------------------------------------------------------------------------------- /src/main/utils.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import vscode from 'vscode'; 3 | import { extensionIdentifier } from './consts'; 4 | 5 | 6 | /** Will prefix it with `${extensionIdentifier}.` */ 7 | export async function myExtensionSetContext(context: string, value: any): Promise { 8 | await vscode.commands.executeCommand('setContext', `${extensionIdentifier}.${context}`, value); 9 | } 10 | export function replaceTildeToHomedir(uri: string): string { 11 | return uri.replace(/^~/, os.homedir()); 12 | } 13 | 14 | 15 | // export const channel = vscode.window.createOutputChannel('GitHub Repository Manager'); -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // https://github.com/SrBrahma/eslint-config-gev 2 | // This is a workaround for https://github.com/eslint/eslint/issues/3458 3 | require('@rushstack/eslint-patch/modern-module-resolution'); 4 | 5 | 6 | module.exports = { 7 | root: true, 8 | env: { 9 | es2021: true, 10 | node: true, 11 | }, 12 | extends: ['eslint-config-gev/js'], 13 | overrides: [ 14 | { 15 | files: ['*.ts'], 16 | extends: ['eslint-config-gev/ts'], 17 | parser: '@typescript-eslint/parser', 18 | parserOptions: { 19 | tsconfigRootDir: __dirname, 20 | project: ['./tsconfig.json'], 21 | ecmaVersion: 12, 22 | sourceType: 'module', 23 | }, 24 | }, 25 | ], 26 | ignorePatterns: ['/lib/**/*', '/dist/**/*'], 27 | rules: { 28 | }, 29 | }; -------------------------------------------------------------------------------- /src/store/repository.ts: -------------------------------------------------------------------------------- 1 | import type { Dirtiness } from '../commands/git/dirtiness/dirtiness'; 2 | 3 | 4 | /** TODO rename */ 5 | export type LocalRepository = { 6 | type: 'local'; 7 | name: string; 8 | ownerLogin: string; 9 | /** GitHub project url. */ 10 | url: string; 11 | localPath?: string; 12 | dirty?: Dirtiness; 13 | }; 14 | 15 | export type RemoteRepository = Omit & { 16 | type: 'remote'; 17 | description: string | null; 18 | languageName?: string; // "C++" etc 19 | 20 | isPrivate: boolean; 21 | isTemplate: boolean; 22 | isFork: boolean; 23 | 24 | parentRepoName?: string; 25 | parentRepoOwnerLogin?: string; 26 | 27 | createdAt: Date; 28 | updatedAt: Date; 29 | 30 | isFavorited?: boolean; 31 | }; 32 | 33 | 34 | export type Repository = LocalRepository | RemoteRepository; 35 | -------------------------------------------------------------------------------- /src/commandsUi/uiPublish/noGit.ts: -------------------------------------------------------------------------------- 1 | import { initGit } from '../../commands/git/initGit/initGit'; 2 | import { pathHasGit } from '../../commands/git/pathHasGit/pathHasGit'; 3 | import { User } from '../../store/user'; 4 | import type { NewRepository } from '../uiCreateRepo'; 5 | 6 | 7 | export async function preNoGit({ cwd }: { cwd: string }): Promise { 8 | if (await pathHasGit(cwd)) 9 | throw new Error('Project already has .git!'); 10 | } 11 | 12 | export async function posNoGit({ cwd, newRepository }: { 13 | cwd: string; 14 | newRepository: NewRepository; 15 | }): Promise { 16 | await initGit(cwd, { 17 | remote: { 18 | owner: newRepository.owner.login, 19 | repositoryName: newRepository.name, 20 | }, 21 | commitAllAndPush: { 22 | token: User.token!, // Already checked above. 23 | }, 24 | cleanOnError: true, 25 | }); 26 | } -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import type vscode from 'vscode'; 2 | import { Storage } from './main/storage'; 3 | import { User } from './store/user'; 4 | import { Workspace } from './store/workspace'; 5 | import { activateTreeViewAccount } from './treeView/account/account'; 6 | import { activateTreeViewRepositories } from './treeView/repositories/repositories'; 7 | 8 | 9 | // This method is called when your extension is activated 10 | // your extension is activated the very first time the command is executed 11 | 12 | export function activate(context: vscode.ExtensionContext): void { 13 | Storage.activate(context); 14 | void User.activate(); // No errors to catch. 15 | activateTreeViewAccount(); 16 | activateTreeViewRepositories(); 17 | Workspace.activate(); 18 | } 19 | 20 | 21 | // this method is called when your extension is deactivated 22 | export function deactivate(): void { 23 | Workspace.deactivate(); 24 | } 25 | -------------------------------------------------------------------------------- /.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": "Launch Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": [ 14 | "--extensionDevelopmentPath=${workspaceFolder}", 15 | ], 16 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 17 | "skipFiles": ["/**/*.js"], 18 | 19 | // found below at https://github.com/microsoft/vscode-pull-request-github/blob/master/.vscode/launch.json 20 | "smartStep": true, 21 | "sourceMaps": true, 22 | // "preLaunchTask": "${defaultBuildTask}" // with webpack now it doesn't work. // FIXME 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Henrique Bruno Fantauzzi de Almeida 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. -------------------------------------------------------------------------------- /src/commands/github/getOctokitErrorMessage.ts: -------------------------------------------------------------------------------- 1 | export function upperCaseFirstLetter(string: string): string { 2 | return string.charAt(0).toUpperCase() + string.slice(1); 3 | } 4 | 5 | // We change a little the default error octokit outputs. 6 | // TODO: As we are using Graphql (doesn't have error.status as the v3 REST API), 7 | // this function is partially deprecated, needs to be updated 8 | export function getOctokitErrorMessage(error: any): string { 9 | function defaultErrorMsg() { 10 | // name + code + : + message Ex: Http Error 500: Bla bla bla 11 | return `${error.name} ${error.status} : ${upperCaseFirstLetter(error.message)}`; 12 | } 13 | function customErrorMsg(msg: string) { 14 | return `${msg} [${defaultErrorMsg()}]`; 15 | } 16 | 17 | let errorMessage = ''; 18 | switch (error.status) { 19 | case 401: 20 | errorMessage = customErrorMsg('The entered or stored token is wrong, has expired or has been revoked! If you want, authenticate again!'); break; 21 | case 500: 22 | errorMessage = customErrorMsg('Looks like your internet is off!'); break; 23 | default: 24 | errorMessage = defaultErrorMsg(); 25 | } 26 | return errorMessage; 27 | } -------------------------------------------------------------------------------- /src/main/configs.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import { workspace } from 'vscode'; 3 | 4 | 5 | // Outside of class to call without `this.`. 6 | function getConfig(section: string, defaultVal: T): T { 7 | // Is this default needed? Or settings.json will always use its default value? 8 | // because get() has `undefined` on its type. 9 | return workspace.getConfiguration('githubRepositoryManager').get(section) ?? defaultVal; 10 | } 11 | 12 | 13 | class ConfigsClass { 14 | get alwaysCloneToDefaultDirectory() { return getConfig('alwaysCloneToDefaultDirectory', false); } 15 | get defaultCloneDirectoryMaximumDepth() { return getConfig('defaultCloneDirectoryMaximumDepth', 3); } 16 | get directoriesToIgnore() { return getConfig('directoriesToIgnore', ['.vscode', '.git', 'node_modules']); } 17 | 18 | get gitDefaultCloneDir(): string | undefined { 19 | let path = workspace.getConfiguration('git').get('defaultCloneDirectory'); 20 | if (path) 21 | path = path.replace(/^~/, os.homedir()); 22 | return path; 23 | } 24 | 25 | get defaultCloneToDir(): string { return this.gitDefaultCloneDir || os.homedir(); } 26 | } 27 | 28 | 29 | export const Configs = new ConfigsClass(); -------------------------------------------------------------------------------- /src/commands/git/dirtiness/dirtiness.ts: -------------------------------------------------------------------------------- 1 | // git diff-index --quiet HEAD 2 | // based on / copied https://github.com/JPeer264/node-is-git-dirty/blob/main/index.ts 3 | import execa from 'execa'; 4 | 5 | 6 | export type Dirtiness = 'clean' | 'dirty' | 'unknown' | 'error'; 7 | 8 | /** @returns 'clean', 'dirty' or 'error'. 9 | * 10 | * Won't throw errors. */ 11 | export async function getDirtiness(projectPath: string): Promise { 12 | try { 13 | return (await isGitDirty(projectPath)) ? 'dirty' : 'clean'; 14 | } catch (err: any) { 15 | console.error(`Error getting local project dirtiness with 'git status --short'. ProjectPath='${projectPath}', Error=${err}`); 16 | return 'error'; 17 | } 18 | } 19 | 20 | 21 | /** May throw errors. */ 22 | // We used to use 'git diff-index --quiet HEAD' (https://unix.stackexchange.com/a/394674/447527), 23 | // which was a little faster but it would false-positive at some cases, like for new project without 24 | // head/remote. We used both diff-index and status but to avoid code complexness, we are just using the status. 25 | export async function isGitDirty(gitDirPath: string): Promise { 26 | const { stdout } = await execa('git', ['status', '--short'], { cwd: gitDirPath }); 27 | return (stdout.length > 0); 28 | } -------------------------------------------------------------------------------- /.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": "webpack-dev", 9 | // "isBackground": true, 10 | // "presentation": { 11 | // "reveal": "never" 12 | // }, 13 | // "group": { 14 | // "kind": "build", 15 | // "isDefault": true 16 | // } 17 | // } 18 | // ] 19 | // } 20 | // TODO: Needs ts-loader problemmatcher 21 | 22 | // not working? 23 | { 24 | "version": "2.0.0", 25 | "tasks": [ 26 | { 27 | "label": "tsc watch", 28 | "type": "shell", 29 | "command": "tsc", 30 | "isBackground": true, 31 | "args": [ 32 | "--build", 33 | "--watch" 34 | ], 35 | "group": { 36 | "kind": "build", 37 | "isDefault": true 38 | }, 39 | "presentation": { 40 | "reveal": "never", 41 | "echo": false, 42 | "focus": false, 43 | "panel": "dedicated" 44 | }, 45 | "problemMatcher": { 46 | "base": "$tsc-watch", 47 | "applyTo": "allDocuments" 48 | }, 49 | "options": { 50 | "cwd": "${workspaceFolder}/functions" 51 | } 52 | }, 53 | ] 54 | } -------------------------------------------------------------------------------- /src/vscode/myQuickPick.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | 3 | 4 | type SelectedItem = { label: string; detail: string | undefined; description: string | undefined }; 5 | 6 | 7 | /** vscode.window.showQuickPick() is fast but weak. createQuickPick is good, but too complex to be used quickly. 8 | * 9 | * Here we merge the two good good points of each one! */ 10 | export async function myQuickPick(options: { 11 | items: vscode.QuickPickItem[]; 12 | title?: string; 13 | ignoreFocusOut?: boolean; 14 | }): Promise { 15 | 16 | const quickPick = vscode.window.createQuickPick(); 17 | 18 | if (options.title) quickPick.title = options.title; 19 | if (options.ignoreFocusOut) quickPick.ignoreFocusOut = options.ignoreFocusOut; 20 | 21 | quickPick.items = options.items; 22 | 23 | quickPick.show(); 24 | 25 | const selection = await new Promise((resolve) => { 26 | quickPick.onDidHide(() => { resolve(undefined); }); 27 | quickPick.onDidChangeSelection((e) => { 28 | const innerSelection = e[0]; 29 | if (!innerSelection) 30 | return resolve(undefined); // won't happen but just type checking 31 | resolve({ 32 | label: innerSelection.label, 33 | description: innerSelection.description, 34 | detail: innerSelection.detail, 35 | }); 36 | }); 37 | }); 38 | quickPick.dispose(); 39 | 40 | return selection; 41 | } -------------------------------------------------------------------------------- /src/commands/github/createGitHubRepository.ts: -------------------------------------------------------------------------------- 1 | import type { Octokit } from '@octokit/rest'; 2 | import { octokit } from '../../store/user'; 3 | 4 | 5 | type Fun = Octokit['repos']['createForAuthenticatedUser']; 6 | type ThenArg = T extends PromiseLike ? U : T; 7 | export type CreateGitHubRepositoryReturn = ThenArg>['data']; 8 | 9 | type Options = { 10 | /** Repository name */ 11 | name: string; 12 | /** The organization to create the repository. 13 | * 14 | * If undefined, will create the repo for the user. */ 15 | organizationLogin?: string; 16 | /** Repository description */ 17 | description?: string; 18 | /** If will be private or public. 19 | * @default private */ 20 | isPrivate: boolean; 21 | }; 22 | 23 | // https://octokit.github.io/rest.js/v17#repos-create-for-authenticated-user 24 | // TODO: Create new repo with README.md and other optional stuff (user config.) 25 | 26 | /** @returns the data result of the octokit function. */ 27 | export async function createGitHubRepository( 28 | { name, description, isPrivate, organizationLogin }: Options, 29 | ): Promise { 30 | if (!octokit) 31 | throw new Error('Octokit not set up!'); 32 | 33 | if (organizationLogin) 34 | return (await octokit.repos.createInOrg({ 35 | name, 36 | org: organizationLogin, 37 | description, 38 | private: isPrivate, // Can't use 'private' in param destructuring 39 | })).data; 40 | 41 | else 42 | return (await octokit.repos.createForAuthenticatedUser({ 43 | name, 44 | description, 45 | private: isPrivate, 46 | })).data; 47 | 48 | } -------------------------------------------------------------------------------- /webpack/webpack.config.js: -------------------------------------------------------------------------------- 1 | // https://code.visualstudio.com/api/working-with-extensions/bundling-extension 2 | // Also big thanks to Extensions Sync code! 3 | 4 | 'use strict'; 5 | 6 | const path = require('path'); 7 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 8 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 9 | 10 | /** @type {import('webpack').Configuration}*/ 11 | const config = { 12 | target: 'node', 13 | 14 | entry: path.resolve(__dirname, '..', 'src', 'extension.ts'), 15 | output: { 16 | path: path.resolve(__dirname, '..', 'dist'), 17 | filename: 'extension.js', 18 | libraryTarget: 'commonjs2', 19 | devtoolModuleFilenameTemplate: '../[resource-path]', 20 | }, 21 | devtool: 'source-map', 22 | externals: { 23 | vscode: 'commonjs vscode', // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ 24 | }, 25 | resolve: { 26 | extensions: ['.ts', '.js'], 27 | }, 28 | stats: { 29 | warningsFilter: /Critical dependency: the request of a dependency is an expression/, 30 | }, 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.ts$/, 35 | exclude: /node_modules/, 36 | loader: 'ts-loader', 37 | options: { // Makes the compilation faster https://github.com/TypeStrong/ts-loader#faster-builds 38 | transpileOnly: true, 39 | }, 40 | }, 41 | ], 42 | }, 43 | plugins: [ 44 | new CleanWebpackPlugin(), 45 | new ForkTsCheckerWebpackPlugin({ 46 | eslint: { 47 | files: './src/**/*.{ts,tsx,js,jsx}', // required - same as command `eslint ./src/**/*.{ts,tsx,js,jsx} --ext .ts,.tsx,.js,.jsx` 48 | }, 49 | }), 50 | ], 51 | }; 52 | module.exports = config; -------------------------------------------------------------------------------- /src/commands/github/getUserData.ts: -------------------------------------------------------------------------------- 1 | // GitHub GraphQL API Explorer: https://docs.github.com/en/graphql/overview/explorer 2 | 3 | import { octokit } from '../../store/user'; 4 | import { getOctokitErrorMessage } from './getOctokitErrorMessage'; 5 | 6 | 7 | type GetUser = { 8 | login: string; 9 | profileUri: string; 10 | organizations: { 11 | login: string; name: string; viewerCanCreateRepositories: boolean; 12 | }[]; 13 | }; 14 | 15 | export async function getUser(): Promise { 16 | if (!octokit) 17 | throw new Error('Octokit not set up!'); 18 | try { 19 | const userData = (await octokit.graphql( 20 | // Doesn't seem to be possible to orderBy name as getRepo does. But it prob should be done automatically. 21 | `query getUser ($after: String) { 22 | viewer { 23 | login 24 | url 25 | organizations(first: 100, after: $after) { 26 | edges { 27 | node { 28 | login 29 | name 30 | viewerCanCreateRepositories 31 | } 32 | } 33 | } 34 | } 35 | }`) as any).viewer; 36 | 37 | return { 38 | login: userData.login, 39 | profileUri: userData.url, 40 | organizations: userData.organizations.edges.map((org: any) => org.node), 41 | }; 42 | } catch (err: any) { // Octokit has a pattern for errors, which we display properly at octokitErrorDisplay(). 43 | // Handle insufficient scope by logging user out 44 | if (err.errors?.find((error: any) => error.type === 'INSUFFICIENT_SCOPES')) { 45 | console.error(err); 46 | throw new Error('Insufficient access permitions! Just re-OAuth again, or if using Personal Access Token, have "repo" and "read:org" (new!) permissions checked!'); 47 | } 48 | 49 | throw new Error(getOctokitErrorMessage(err)); 50 | } 51 | } -------------------------------------------------------------------------------- /src/commands/github/getUserRepos.ts: -------------------------------------------------------------------------------- 1 | // GitHub GraphQL API Explorer: https://docs.github.com/en/graphql/overview/explorer 2 | 3 | import type { Repository } from '../../store/repository'; 4 | import { octokit } from '../../store/user'; 5 | import { getOctokitErrorMessage } from './getOctokitErrorMessage'; 6 | import { extractRepositoryFromData, repoInfosQuery } from './getOrgRepos'; 7 | 8 | 9 | export async function getUserRepos(): Promise { 10 | if (!octokit) 11 | throw new Error('Octokit not set up!'); 12 | 13 | try { 14 | const repos: Repository[] = []; 15 | // For pagination (if user has more repos than the query results (current max per query is 100)) 16 | let endCursor: string | null = null; 17 | let hasNextPage = false; 18 | 19 | do { 20 | // https://github.com/octokit/graphql.js/#variables 21 | const { nodes, pageInfo }: { nodes: any; pageInfo: any } = (await octokit.graphql(query, { 22 | after: endCursor, 23 | }) as any).viewer.repositories; 24 | 25 | ({ endCursor, hasNextPage } = pageInfo); 26 | 27 | repos.push(...nodes.map((node: any) => extractRepositoryFromData(node))); 28 | } while (hasNextPage); 29 | 30 | return repos; 31 | } catch (err: any) { // Octokit has a patter for errors, which we display properly at octokitErrorDisplay(). 32 | throw new Error(getOctokitErrorMessage(err)); 33 | } 34 | } 35 | 36 | 37 | // Made with https://developer.github.com/v4/explorer/ 38 | const query = ` 39 | query getRepos ($after: String) { 40 | viewer { 41 | repositories( 42 | first: 100, after: $after 43 | affiliations: [OWNER], ownerAffiliations:[OWNER], 44 | orderBy: { field: NAME, direction: ASC } 45 | ) { 46 | pageInfo { 47 | endCursor 48 | hasNextPage 49 | } 50 | nodes { 51 | ${repoInfosQuery} 52 | } 53 | } 54 | } 55 | }`; -------------------------------------------------------------------------------- /src/treeView/repositories/notClonedRepos.ts: -------------------------------------------------------------------------------- 1 | import vscode, { commands } from 'vscode'; 2 | import { uiCloneTo } from '../../commandsUi/uiCloneTo'; 3 | import { OrgStatus } from '../../store/organization'; 4 | import { User } from '../../store/user'; 5 | import { TreeItem } from '../treeViewBase'; 6 | import { RepoItem } from './repoItem'; 7 | 8 | 9 | export function activateNotClonedRepos(): void { 10 | // Clone repo to [open select repo location]. You must pass the repo as arg. 11 | commands.registerCommand('githubRepoMgr.commands.notClonedRepos.cloneTo', 12 | ({ repo }: RepoItem) => uiCloneTo({ 13 | name: repo.name, ownerLogin: repo.ownerLogin, reloadRepos: true, 14 | })); 15 | } 16 | 17 | // Much like unused right now. Orgs will always be loaded here. 18 | function getEmptyOrgLabel(status: OrgStatus): string { 19 | switch (status) { 20 | case OrgStatus.errorLoading: 21 | return 'Error loading'; 22 | case OrgStatus.notLoaded: // Same as loading. 23 | case OrgStatus.loading: 24 | return 'Loading...'; 25 | case OrgStatus.loaded: 26 | return 'Empty'; 27 | } 28 | } 29 | 30 | export function getNotClonedTreeItem(): TreeItem { 31 | const orgs: TreeItem[] = User.organizations.map((org) => { 32 | return new TreeItem({ 33 | label: `${org.name}`, 34 | children: (org.repositories.length 35 | ? org.notClonedRepos.map((repo) => new RepoItem({ 36 | repo, 37 | contextValue: 'githubRepoMgr.context.notClonedRepo', 38 | command: { 39 | // We wrap the repo in {} because we may call the cloneTo from the right click, and it passes the RepoItem. 40 | command: 'githubRepoMgr.commands.notClonedRepos.cloneTo', 41 | arguments: [{ repo }], 42 | }, 43 | })) 44 | : new TreeItem({ label: getEmptyOrgLabel(org.status) })), 45 | collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, 46 | }); 47 | }); 48 | 49 | return new TreeItem({ 50 | label: 'Not Cloned', 51 | children: orgs, 52 | }); 53 | } -------------------------------------------------------------------------------- /src/commands/git/getRemoteHeadBranch/getRemoteHeadBranch.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | 3 | /** 4 | * Get git HEAD branch. 5 | * For some mysterious git reason, it must be executed inside any git dir. 6 | * May throw errors. 7 | * @returns undefined if repository hasn't a remote HEAD, else, returns HEAD name. 8 | * https://stackoverflow.com/a/50056710/10247962 9 | * */ 10 | export async function getRemoteHead({ remoteUrl, repositoryPath }: { 11 | remoteUrl: string; 12 | repositoryPath: string; 13 | }): Promise { 14 | 15 | const gitRemoteShowResult = (await execa( 16 | 'git', 17 | ['remote', 'show', remoteUrl], 18 | { cwd: repositoryPath }) 19 | ).stdout; 20 | // We used to get the branch by Regexing the HEAD, but it would lend to locale issues as 21 | // the output is language dependent. We now get the 4th line, after the whitespace, and then the last word. 22 | /** (unknown) if no branch, = new empty repository. */ 23 | const headFromRemoteShow: '(unknown)' | string | undefined = gitRemoteShowResult.split('\n')[3]?.split(' ')?.pop()?.trim(); 24 | 25 | if (!headFromRemoteShow) 26 | throw new Error(`'git remote show ' hasn't returned a HEAD branch! Report this error to the extension author! stdout: "${gitRemoteShowResult}"`); 27 | 28 | if (headFromRemoteShow === '(unknown)') 29 | return undefined; 30 | 31 | return headFromRemoteShow; 32 | } 33 | 34 | 35 | /* Examples of 'git remote show $url' 36 | * PT-BR 37 | * remoto https://github.com/SrBrahma/GitHub-Repository-Manager.git 38 | Buscar URL: https://github.com/SrBrahma/GitHub-Repository-Manager.git 39 | Atirar URL: https://github.com/SrBrahma/GitHub-Repository-Manager.git 40 | Ramo HEAD: main 41 | Referências locais configuradas para 'git push': 42 | 1.5.0 publica em 1.5.0 (atualizado) 43 | main publica em main (local desatualizado) 44 | 45 | * DE 46 | * Remote-Repository https://github.com/SrBrahma/GitHub-Repository-Manager.git 47 | URL zum Abholen: https://github.com/SrBrahma/GitHub-Repository-Manager.git 48 | URL zum Versenden: https://github.com/SrBrahma/GitHub-Repository-Manager.git 49 | Hauptbranch: main 50 | Lokale Referenz konfiguriert für 'git push': 51 | main versendet nach main (aktuell) 52 | */ -------------------------------------------------------------------------------- /src/main/storage.ts: -------------------------------------------------------------------------------- 1 | import type vscode from 'vscode'; 2 | 3 | 4 | let context: vscode.ExtensionContext; 5 | 6 | 7 | // https://stackoverflow.com/a/57857305 8 | 9 | function get(key: string): T | undefined; 10 | function get(key: string, defaultValue: T): T; 11 | function get(key: string, defaultValue?: T): T | undefined; 12 | function get(key: string, defaultValue?: T): T | undefined { return context.globalState.get(key, defaultValue); } 13 | function set(key: string, value: T) { return context.globalState.update(key, value); } 14 | function remove(key: string) { return context.globalState.update(key, undefined); } 15 | 16 | 17 | type ItemCommon = { 18 | additionalKey: string | string[]; 19 | }; 20 | class Item { 21 | constructor(private key: string) { } 22 | private getKey(additionalKey: string | string[]) { 23 | const array = [this.key]; 24 | array.push(...(typeof additionalKey === 'string' ? [additionalKey] : additionalKey)); 25 | return array.join('.'); 26 | } 27 | 28 | get(args: ItemCommon & { defaultValue: D }): T | D { 29 | return get(this.getKey(args.additionalKey), args.defaultValue); 30 | } 31 | 32 | set(args: ItemCommon & { value: T }) { 33 | return set(this.getKey(args.additionalKey), args.value); 34 | } 35 | 36 | remove(args: ItemCommon) { return remove(this.getKey(args.additionalKey)); } 37 | } 38 | 39 | class StorageClass { 40 | activate(contextArg: vscode.ExtensionContext) { 41 | context = contextArg; 42 | } 43 | 44 | // Call it favorites2 if change its schema 45 | item = new Item('favorites'); 46 | favoritesRepos = { 47 | _item: new Item('favorites'), 48 | isFavorite(repoName: string): boolean { return this._item.get({ additionalKey: repoName, defaultValue: false }); }, 49 | setFavorite(repoName: string) { return this._item.set({ additionalKey: repoName, value: true }); }, 50 | unsetFavorite(repoName: string) { return this._item.set({ additionalKey: repoName, value: false }); }, 51 | }; 52 | 53 | // Removes all keys 54 | // resetGlobalState() { 55 | // // Forces the read of the private _value. 56 | // const keys = Object.keys((context.globalState as any)._value); 57 | // keys.forEach(key => remove(key)); 58 | // } 59 | } 60 | 61 | export const Storage = new StorageClass(); -------------------------------------------------------------------------------- /src/store/organization.ts: -------------------------------------------------------------------------------- 1 | import { getOrgRepos } from '../commands/github/getOrgRepos'; 2 | import { getUserRepos } from '../commands/github/getUserRepos'; 3 | import type { DirWithGitUrl } from '../commands/searchClonedRepos/searchClonedRepos'; 4 | import type { Repository } from './repository'; 5 | 6 | 7 | export enum OrgStatus { 8 | notLoaded, 9 | loading, 10 | errorLoading, 11 | loaded, 12 | } 13 | 14 | type OrganizationConstructor = { 15 | /** Organization raw name, e.g 'srbrahmaTest' */ 16 | login: string; 17 | /** Organization pretty name, e.g 'SrBrahma Test' */ 18 | name: string; 19 | isUserOrg: boolean; 20 | userCanCreateRepositories: boolean; 21 | }; 22 | 23 | export class Organization { 24 | name: string; // Used in notClonedRepo tree. 25 | login: string; 26 | status: OrgStatus; 27 | repositories: Repository[] = []; 28 | clonedRepos: Repository[] = []; 29 | notClonedRepos: Repository[] = []; 30 | /** If it is the user organization, = the repos belong to him. */ 31 | isUserOrg: boolean; 32 | /** If the user can create new repositories in this organization */ 33 | userCanCreateRepositories: boolean; 34 | 35 | constructor(args: OrganizationConstructor) { 36 | this.login = args.login; 37 | this.name = args.name; 38 | this.isUserOrg = args.isUserOrg; 39 | this.userCanCreateRepositories = args.userCanCreateRepositories; 40 | this.status = OrgStatus.notLoaded; 41 | } 42 | 43 | /** Call it after constructing it. */ 44 | async loadOrgRepos({ localRepos }: { 45 | localRepos: DirWithGitUrl[]; 46 | }): Promise { 47 | try { 48 | this.status = OrgStatus.loading; 49 | 50 | this.repositories = this.isUserOrg ? await getUserRepos() : await getOrgRepos(this.login); 51 | // TODO splice localRepos so other orgs won't loop over it? Good for users/orgs with hundreds/thousands of repos. 52 | localRepos.forEach((localRepo) => { 53 | const repoMatch = this.repositories.find((repo) => repo.url === localRepo.gitUrl); 54 | if (repoMatch) { 55 | repoMatch.localPath = localRepo.dirPath; 56 | repoMatch.dirty = 'unknown'; 57 | } 58 | }); 59 | this.clonedRepos = this.repositories.filter((repo) => repo.localPath); 60 | this.notClonedRepos = this.repositories.filter((repo) => !repo.localPath); 61 | 62 | this.status = OrgStatus.loaded; 63 | } catch (err: any) { 64 | this.status = OrgStatus.errorLoading; 65 | throw new Error(err); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/treeView/treeViewBase.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | 3 | 4 | type CommandWithoutTitle = Omit & { 5 | title?: string; 6 | }; 7 | 8 | export type TreeItemConstructor = Omit & { 9 | label: string; 10 | children?: TreeItem[] | TreeItem; 11 | /** You can provide a vscode.Command like object, or the command directly, as a string. */ 12 | command?: CommandWithoutTitle | string; 13 | }; 14 | 15 | 16 | export class TreeItem extends vscode.TreeItem { 17 | children: TreeItem[] | undefined; 18 | 19 | constructor({ label, children, command, ...rest }: TreeItemConstructor) { 20 | const collapsibleState = children === undefined 21 | ? vscode.TreeItemCollapsibleState.None 22 | : vscode.TreeItemCollapsibleState.Expanded; 23 | 24 | super(label, collapsibleState); 25 | 26 | if (children) 27 | this.children = Array.isArray(children) ? children : [children]; 28 | 29 | if (typeof command === 'string') 30 | this.command = { command, title: '' }; 31 | 32 | else if (command) // If command was given (not undefined) 33 | this.command = { ...command, title: command.title || '' }; // Just a way to omit writing empty titles. 34 | 35 | Object.assign(this, rest); 36 | } 37 | } 38 | 39 | 40 | // https://www.typescriptlang.org/docs/handbook/classes.html#abstract-classes 41 | export abstract class BaseTreeDataProvider implements vscode.TreeDataProvider { 42 | 43 | // idk yet what really are these two. 44 | // https://code.visualstudio.com/api/extension-guides/tree-view#updating-tree-view-content 45 | private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); 46 | readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; 47 | 48 | protected data: TreeItem | TreeItem[] = []; 49 | 50 | constructor() { 51 | this.makeData(); 52 | } 53 | 54 | refresh(): void { 55 | this.makeData(); 56 | this._onDidChangeTreeData.fire(null as any); // arg don't seem to care. 57 | } 58 | 59 | protected abstract makeData(): void; 60 | 61 | // Both below are required by vscode.TreeDataProvider, that it implements. 62 | getTreeItem(element: TreeItem): vscode.TreeItem | Thenable { 63 | return element; 64 | } 65 | 66 | getChildren(element?: TreeItem): vscode.ProviderResult { 67 | if (element === undefined) 68 | return Array.isArray(this.data) ? this.data : [this.data]; 69 | return element.children; 70 | } 71 | } -------------------------------------------------------------------------------- /src/commands/git/initGit/initGit.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import execa from 'execa'; 3 | import fse from 'fs-extra'; 4 | import { getRepositoryGitUrl } from '../getRepositoryGitUrl'; 5 | import { pathHasGit } from '../pathHasGit/pathHasGit'; 6 | 7 | 8 | type Options = { 9 | /** If defined, will add remote and a branch with it as remote. '.git' is added to its end. */ 10 | remote?: { 11 | owner: string; 12 | repositoryName: string; 13 | }; 14 | /** Will 'git add .', 'git commit -m "Initial Commit"' and 'git push'. 15 | * remoteUrl must be defined. 16 | * @default undefined */ 17 | commitAllAndPush?: { 18 | token: string; 19 | }; 20 | /** If should remove .git on error, if it didn't exist before this function. 21 | * @default true */ 22 | cleanOnError?: boolean; 23 | }; 24 | 25 | export async function initGit(projectPath: string, options?: Options): Promise { 26 | const { cleanOnError, commitAllAndPush, remote } = options ?? {}; 27 | 28 | if (await pathHasGit(projectPath)) 29 | throw new Error('Path already contains .git!'); 30 | 31 | try { 32 | 33 | await execa('git', ['init'], { cwd: projectPath }); 34 | 35 | const headBranch: string = 'main'; 36 | /** https://stackoverflow.com/a/42871621/10247962 */ 37 | await execa('git', ['checkout', '-b', headBranch], { cwd: projectPath }); 38 | 39 | if (remote) { 40 | const remoteUrl = getRepositoryGitUrl(remote); 41 | await execa('git', ['remote', 'add', 'origin', remoteUrl], { cwd: projectPath }); 42 | 43 | // Manually add the upstream 44 | await execa('git', ['config', '--local', `branch.${headBranch}.remote`, 'origin'], { cwd: projectPath }); 45 | await execa('git', ['config', '--local', `branch.${headBranch}.merge`, `refs/heads/${headBranch}`], { cwd: projectPath }); 46 | 47 | 48 | await fse.appendFile(path.join(projectPath, '.git', 'config'), 49 | `[branch "${headBranch}"] 50 | \tremote = origin 51 | \tmerge = refs/heads/${headBranch}`); 52 | 53 | if (commitAllAndPush) { 54 | await execa('git', ['add', '.'], { cwd: projectPath }); 55 | await execa('git', ['commit', '-m', 'Initial Commit'], { cwd: projectPath }); 56 | // push needs user auth. https://stackoverflow.com/a/57624220/10247962 57 | const tokenizedRepositoryUrl = getRepositoryGitUrl({ 58 | owner: remote.owner, 59 | repositoryName: remote.repositoryName, 60 | token: commitAllAndPush.token, 61 | }); 62 | await execa('git', ['push', tokenizedRepositoryUrl], { cwd: projectPath }); 63 | } 64 | } 65 | } catch (err: any) { 66 | if (cleanOnError) 67 | await fse.remove(path.join(projectPath, '.git')); // Remove created .git on error 68 | throw err; 69 | } 70 | } -------------------------------------------------------------------------------- /src/commands/github/getOrgRepos.ts: -------------------------------------------------------------------------------- 1 | // GitHub GraphQL API Explorer: https://docs.github.com/en/graphql/overview/explorer 2 | 3 | import type { Repository } from '../../store/repository'; 4 | import { octokit } from '../../store/user'; 5 | import { getOctokitErrorMessage } from './getOctokitErrorMessage'; 6 | 7 | 8 | export function extractRepositoryFromData(data: any): Repository { 9 | return { 10 | type: 'remote', 11 | name: data.name, 12 | description: data.description, 13 | ownerLogin: data.owner.login, 14 | languageName: data.primaryLanguage?.name, 15 | url: data.url, 16 | 17 | // gitUrl: data., 18 | 19 | isPrivate: data.isPrivate, 20 | isFork: data.isFork, 21 | isTemplate: data.isTemplate, 22 | 23 | // parent may be null if isn't a fork. 24 | parentRepoName: data.parent?.name, 25 | parentRepoOwnerLogin: data.parent?.owner.login, 26 | 27 | createdAt: new Date(data.createdAt), 28 | updatedAt: new Date(data.updatedAt), 29 | }; 30 | } 31 | 32 | /** Used for both orgRepos and userRepos. 33 | * 34 | * The different indendation from query doesn't matter. https://stackoverflow.com/q/62398415/10247962 */ 35 | export const repoInfosQuery = ` 36 | name 37 | description 38 | owner { 39 | login 40 | } 41 | primaryLanguage { 42 | name 43 | } 44 | url 45 | isPrivate 46 | isFork 47 | isTemplate 48 | parent { 49 | name 50 | owner { 51 | login 52 | } 53 | } 54 | createdAt 55 | updatedAt 56 | `; 57 | 58 | export async function getOrgRepos(login: string): Promise { 59 | if (!octokit) 60 | throw new Error('Octokit not set up!'); 61 | try { 62 | const repos: Repository[] = []; 63 | 64 | let endCursor: string | null = null; 65 | let hasNextPage = false; 66 | 67 | do { 68 | // https://github.com/octokit/graphql.js/#variables 69 | const response = (await octokit.graphql(query, { 70 | after: endCursor, 71 | org: login, 72 | }) as any); 73 | 74 | if (response.viewer.organization === null) 75 | return repos; 76 | 77 | const { nodes, pageInfo } = response.viewer.organization.repositories; 78 | ({ endCursor, hasNextPage } = pageInfo); 79 | repos.push(...nodes.map((node: any) => extractRepositoryFromData(node))); 80 | } while (hasNextPage); 81 | 82 | return repos; 83 | } catch (err: any) { // Octokit has a patter for errors, which we display properly at octokitErrorDisplay(). 84 | throw new Error(getOctokitErrorMessage(err)); 85 | } 86 | } 87 | 88 | 89 | const query = ` 90 | query getOrgRepos ($after: String, $org: String!) { 91 | viewer { 92 | organization(login: $org) { 93 | repositories( 94 | isFork: false, first: 100, after: $after 95 | orderBy: { field: NAME, direction: ASC } 96 | ) { 97 | pageInfo { 98 | endCursor 99 | hasNextPage 100 | } 101 | nodes { 102 | ${repoInfosQuery} 103 | } 104 | } 105 | } 106 | } 107 | }`; -------------------------------------------------------------------------------- /src/commandsUi/uiPublish/noRemote.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import { getHeadBranch } from '../../commands/git/getHead'; 3 | import { getRepositoryGitUrl } from '../../commands/git/getRepositoryGitUrl'; 4 | import { gitHasRemote } from '../../commands/git/gitHasRemote/gitHasRemote'; 5 | import { User } from '../../store/user'; 6 | import { myQuickPick } from '../../vscode/myQuickPick'; 7 | import type { NewRepository } from '../uiCreateRepo'; 8 | 9 | 10 | /** If returned undefined, do exit. */ 11 | export async function preNoRemote({ cwd }: { cwd: string }): Promise { 12 | if (await gitHasRemote(cwd)) 13 | throw new Error('Project already contains git remote!'); 14 | } 15 | 16 | export async function preRepositoryCreateNoRemote( 17 | { cwd }: { cwd: string }, 18 | ): Promise<{ headBranch: string } | undefined> { 19 | const originalHeadBranch = await getHeadBranch(cwd); 20 | let headBranch = originalHeadBranch; 21 | 22 | // We do this before Repository creation so user may safely cancel this prompt. 23 | if (originalHeadBranch === 'master') { 24 | const convertMasterToMain = (await myQuickPick({ 25 | ignoreFocusOut: false, 26 | items: [{ label: 'Yes' }, { label: 'No' }], 27 | title: "Rename local 'master' branch to 'main' before pushing to GitHub?", 28 | }))?.label; 29 | 30 | if (convertMasterToMain === undefined) 31 | return; // Exit on cancel 32 | 33 | if (convertMasterToMain === 'Yes') { 34 | // Check if main branch already exists 35 | const branchesString = (await execa('git', ['branch'], { cwd })).stdout; // Multiline branches names. May contain whitespaces before/after its name. 36 | const mainBranchExists = branchesString.match(/\bmain\b/g); // https://stackoverflow.com/q/2232934/10247962 37 | if (mainBranchExists) 38 | throw new Error("Branch 'main' already exists!"); 39 | // Rename master to main 40 | await execa('git', ['branch', '-m', 'master', 'main'], { cwd }); 41 | headBranch = 'main'; 42 | } 43 | } 44 | return { headBranch }; 45 | } 46 | 47 | export async function posNoRemote({ cwd, newRepository, headBranch }: { 48 | cwd: string; 49 | newRepository: NewRepository; 50 | headBranch: string; 51 | }): Promise { 52 | const repositoryUrl = newRepository.html_url; // this prop is the right one, = 'https://github.com/author/repo' 53 | const remoteUrl = repositoryUrl + '.git'; 54 | 55 | await execa('git', ['remote', 'add', 'origin', remoteUrl], { cwd }); 56 | 57 | // Set the HEAD branch remote manually (without push -u etc, as it requires git auth) 58 | await execa('git', ['config', '--local', `branch.${headBranch}.remote`, 'origin'], { cwd }); 59 | await execa('git', ['config', '--local', `branch.${headBranch}.merge`, `refs/heads/${headBranch}`], { cwd }); 60 | 61 | // Push local to GitHub. Note that user may have a dirty local, but we will leave the next commit to him. 62 | const tokenizedRepositoryUrl = getRepositoryGitUrl({ 63 | owner: newRepository.owner.login, 64 | repositoryName: newRepository.name, 65 | token: User.token!, 66 | }); 67 | await execa('git', ['push', tokenizedRepositoryUrl], { cwd }); 68 | } -------------------------------------------------------------------------------- /src/treeView/account/account.ts: -------------------------------------------------------------------------------- 1 | import vscode, { ThemeIcon } from 'vscode'; 2 | import { noLocalSearchPaths } from '../../commands/searchClonedRepos/searchClonedRepos'; 3 | import { User, UserState } from '../../store/user'; 4 | import { BaseTreeDataProvider, TreeItem } from '../treeViewBase'; 5 | 6 | 7 | export let accountTreeDataProvider: TreeDataProvider; 8 | 9 | export function activateTreeViewAccount(): void { 10 | accountTreeDataProvider = new TreeDataProvider(); 11 | vscode.window.registerTreeDataProvider('githubRepoMgr.views.account', accountTreeDataProvider); 12 | 13 | User.subscribe('account', () => { accountTreeDataProvider.refresh(); }); 14 | 15 | // Open user profile page 16 | vscode.commands.registerCommand('githubRepoMgr.commands.user.openProfilePage', async () => { 17 | if (User.profileUri) 18 | await vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(User.profileUri)); 19 | }); 20 | 21 | // Open Extension README 22 | // vscode.commands.registerCommand('githubRepoMgr.commands.user.openReadme', async () => { 23 | // if (User.profileUri) 24 | // await vscode.commands.executeCommand('vscode.open', vscode.Uri.parse('https://github.com/SrBrahma/GitHub-Repository-Manager#github-repository-manager')); 25 | // }); 26 | 27 | } 28 | 29 | 30 | // There is a TreeItem from vscode. Should I use it? But it would need a workaround to 31 | // avoid using title in command. 32 | class TreeDataProvider extends BaseTreeDataProvider { 33 | 34 | constructor() { super(); } 35 | 36 | getData() { 37 | switch (User.state) { 38 | case UserState.errorLogging: // TODO: Bad when token already stored and we have a connection error 39 | return new TreeItem({ label: 'An error happened!' }); 40 | case UserState.notLogged: // If going to change it, beware it is also being used in helpers.loadUser(). 41 | return []; // Empty, do show nothing. 42 | case UserState.init: 43 | case UserState.logging: 44 | return new TreeItem({ label: 'Loading...' }); 45 | case UserState.logged: 46 | return getLoggedTreeData(); 47 | } 48 | } 49 | 50 | protected makeData() { 51 | this.data = this.getData(); 52 | } 53 | 54 | } 55 | 56 | 57 | export function getLoggedTreeData(): TreeItem[] { 58 | return [ 59 | new TreeItem({ 60 | label: `Hi, ${User.login}!`, 61 | iconPath: new ThemeIcon('verified'), 62 | children: [ 63 | // That space before the label improves readability (that the icon reduces, but they look cool!) 64 | new TreeItem({ 65 | label: ' Open your GitHub page', 66 | command: 'githubRepoMgr.commands.user.openProfilePage', 67 | iconPath: new ThemeIcon('github'), 68 | }), 69 | ...(noLocalSearchPaths 70 | ? [] 71 | : [new TreeItem({ 72 | label: ' Change "git.defaultCloneDirectory"', 73 | command: 'githubRepoMgr.commands.pick.defaultCloneDirectory', 74 | iconPath: new ThemeIcon('file-directory'), 75 | })]), 76 | // new TreeItem({ 77 | // label: ' Open extension Readme', 78 | // command: 'githubRepoMgr.commands.user.openReadme', 79 | // iconPath: new ThemeIcon('notebook'), 80 | // }), // TODO Looked awful, annoying. Find a better way to point to it. 81 | ], 82 | }), 83 | ]; 84 | } -------------------------------------------------------------------------------- /src/treeView/repositories/repositories.ts: -------------------------------------------------------------------------------- 1 | import vscode, { commands, Uri, window, workspace } from 'vscode'; 2 | import { uiCreateRepo } from '../../commandsUi/uiCreateRepo'; 3 | import { uiPublish } from '../../commandsUi/uiPublish/uiPublish'; 4 | import { Configs } from '../../main/configs'; 5 | import { RepositoriesState, User } from '../../store/user'; 6 | import { BaseTreeDataProvider, TreeItem } from '../treeViewBase'; 7 | import { activateClonedRepos, getClonedOthersTreeItem, getClonedTreeItem } from './clonedRepos'; 8 | import { activateNotClonedRepos, getNotClonedTreeItem } from './notClonedRepos'; 9 | import type { RepoItem } from './repoItem'; 10 | 11 | 12 | export function activateTreeViewRepositories(): void { 13 | const repositoriesTreeDataProvider = new TreeDataProvider(); 14 | 15 | vscode.window.registerTreeDataProvider('githubRepoMgr.views.repositories', 16 | repositoriesTreeDataProvider); 17 | User.subscribe('repos', () => { repositoriesTreeDataProvider.refresh(); }); 18 | 19 | // Access GitHub Web Page 20 | vscode.commands.registerCommand('githubRepoMgr.commands.repos.openWebPage', ({ repo }: RepoItem) => 21 | vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(repo.url))); 22 | 23 | // Will have .git on the end. 24 | vscode.commands.registerCommand('githubRepoMgr.commands.repos.copyRepositoryUrl', ({ repo }: RepoItem) => 25 | vscode.env.clipboard.writeText(`${repo.url}.git`)); 26 | 27 | // Reload repos 28 | vscode.commands.registerCommand('githubRepoMgr.commands.repos.reload', () => User.reloadRepos()); 29 | 30 | // Create Repo 31 | vscode.commands.registerCommand('githubRepoMgr.commands.repos.createRepo', () => uiCreateRepo()); 32 | 33 | 34 | vscode.commands.registerCommand('githubRepoMgr.commands.repos.publish', () => uiPublish()); 35 | 36 | // Sets the default directory for cloning 37 | commands.registerCommand('githubRepoMgr.commands.pick.defaultCloneDirectory', async () => { 38 | const thenable = await window.showOpenDialog({ 39 | defaultUri: Uri.file(Configs.defaultCloneToDir), 40 | openLabel: `Select as default directory`, 41 | canSelectFiles: false, 42 | canSelectFolders: true, 43 | canSelectMany: false, 44 | }); 45 | 46 | if (thenable) { 47 | // 3rd param as true to change global setting. Else wouldn't work. 48 | await workspace.getConfiguration('git').update('defaultCloneDirectory', thenable[0]!.fsPath, true); 49 | await User.reloadRepos(); 50 | } 51 | }); 52 | 53 | activateClonedRepos(); 54 | activateNotClonedRepos(); 55 | } 56 | 57 | 58 | class TreeDataProvider extends BaseTreeDataProvider { 59 | constructor() { super(); } 60 | getData() { 61 | switch (User.repositoriesState) { 62 | case RepositoriesState.none: 63 | return []; // So on not logged user it won't be 'Loading...' for ever. 64 | case RepositoriesState.fetching: 65 | return new TreeItem({ 66 | label: 'Loading...', 67 | }); 68 | case RepositoriesState.partial: 69 | case RepositoriesState.fullyLoaded: { 70 | const clonedFromOthers = User.clonedOtherRepos.length 71 | ? [getClonedOthersTreeItem()] 72 | : []; 73 | return [getClonedTreeItem(), ...clonedFromOthers, getNotClonedTreeItem()]; 74 | } 75 | } 76 | } 77 | 78 | protected makeData() { 79 | this.data = this.getData(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/commandsUi/uiCloneTo.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { commands, Uri, window, workspace } from 'vscode'; 3 | import { cloneRepo } from '../commands/git/cloneRepository/cloneRepository'; 4 | import { Configs } from '../main/configs'; 5 | import { User } from '../store/user'; 6 | 7 | // Made to look similar to vscode clone command. Also, took some small pieces from it. 8 | // uses the git.defaultCloneDirectory setting, as, you know, the default clone directory. 9 | 10 | // VsCode clone openLabel : 'Select repository location' 11 | // Our Label with same len: 'Clone /12345678901... Here', as I don't know the max label length. 12 | // From here we took the magic numbers 15 and 12 used on labelRepoName. 13 | // Uses the repoName in the openLabel, as the user could missclick the desired repo, 14 | // and clone a wrong one. 15 | // This '/' is to give to the user a hint that it will create a directory. 16 | 17 | const openStr = 'Open'; 18 | const openInNewWindowStr = 'Open in New Window'; 19 | const addToWorkspaceStr = 'Add to Workspace'; 20 | 21 | // TODO: Add cancel button 22 | /** Doesn't throw errors. */ 23 | export async function uiCloneTo({ ownerLogin, name, reloadRepos }: { 24 | /** The repository name */ 25 | name: string; 26 | /** The owner login */ 27 | ownerLogin: string; 28 | /** If should reloadRepos() on clone success. */ 29 | reloadRepos: boolean; 30 | }): Promise { 31 | 32 | if (!User.token) 33 | throw new Error('User token is not set!'); 34 | 35 | let labelRepoName = `/${name}`; 36 | if (labelRepoName.length >= 15) 37 | labelRepoName = `${labelRepoName.substr(0, 12)}...`; 38 | 39 | let parentPath: string = ''; 40 | 41 | if (!Configs.alwaysCloneToDefaultDirectory) { 42 | const thenable = await window.showOpenDialog({ 43 | defaultUri: Uri.file(Configs.defaultCloneToDir), 44 | openLabel: `Clone ${labelRepoName} here`, 45 | canSelectFiles: false, 46 | canSelectFolders: true, 47 | canSelectMany: false, 48 | }); 49 | 50 | if (!thenable) // Cancel if quitted dialog 51 | return; 52 | 53 | parentPath = thenable[0]!.fsPath; 54 | } else { 55 | parentPath = Configs.defaultCloneToDir; 56 | } 57 | 58 | const repoPath = path.join(parentPath, name); 59 | const uri = Uri.file(repoPath); 60 | 61 | // Will leave it as status bar until we have a cancel button. 62 | const statusBar = window.setStatusBarMessage(`Cloning ${name} to ${repoPath}...`); 63 | try { 64 | await cloneRepo({ owner: ownerLogin, repositoryName: name, parentPath, token: User.token }); 65 | statusBar.dispose(); 66 | } catch (err: any) { 67 | statusBar.dispose(); 68 | void window.showErrorMessage(err.message); 69 | return; 70 | } 71 | 72 | await Promise.all([ 73 | reloadRepos ? User.reloadRepos() : undefined, 74 | (async () => { 75 | const action = await window.showInformationMessage(`Cloned ${name} to ${repoPath}!`, 76 | openStr, openInNewWindowStr, addToWorkspaceStr); 77 | 78 | switch (action) { 79 | case openStr: 80 | void commands.executeCommand('vscode.openFolder', uri); break; 81 | case openInNewWindowStr: 82 | void commands.executeCommand('vscode.openFolder', uri, true); break; 83 | case addToWorkspaceStr: 84 | workspace.updateWorkspaceFolders(workspace.workspaceFolders?.length ?? 0, 0, { uri }); break; 85 | } 86 | })(), 87 | ]); 88 | 89 | } -------------------------------------------------------------------------------- /src/commands/searchClonedRepos/searchClonedRepos.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import execa from 'execa'; 3 | import GitUrlParse from 'git-url-parse'; 4 | import globby from 'globby'; 5 | import { Configs } from '../../main/configs'; 6 | import { replaceTildeToHomedir } from '../../main/utils'; 7 | 8 | 9 | export type DirWithGitUrl = { 10 | dirPath: string; 11 | gitUrl: string; 12 | }; 13 | async function getGitUrls(dirsPath: string[]): Promise { 14 | const dirsWithGitUrl: DirWithGitUrl[] = []; 15 | 16 | // forEach would call all execs 'at the same time', as it doesnt wait await. 17 | for (const dirPath of dirsPath) 18 | try { 19 | // https://stackoverflow.com/a/23682620/10247962 20 | // was using git remote -v, but git ls-remote --get-url seems to also do the job with a single output. 21 | const { stdout: result } = await execa('git', ['ls-remote', '--get-url'], { cwd: dirPath }); 22 | 23 | // Remove whitespaces chars. 24 | const url = result.trim(); 25 | // TODO we can allow repos without url, to allow repos without remote 26 | if (url) { 27 | // Parse the git URL into a repository URL, as it could be the git@github.com:author/reponame url pattern. 28 | // This changes any known kind to the https://github.com/author/reponame pattern. 29 | const gitUrl = GitUrlParse(url).toString('https').replace(/\.git$/, ''); // remove final .git 30 | dirsWithGitUrl.push({ gitUrl, dirPath }); 31 | } 32 | } catch (err: any) { 33 | // If error, it's because there isn't a remote. No need to manage it, may be left empty. 34 | } 35 | 36 | return dirsWithGitUrl; 37 | } 38 | 39 | 40 | export let noLocalSearchPaths: boolean = false; 41 | 42 | 43 | interface StartingSearchPaths { 44 | path: string; 45 | availableDepth: number; 46 | } 47 | function getStartingSearchPaths(): StartingSearchPaths[] { 48 | const searchPaths: StartingSearchPaths[] = []; 49 | 50 | if (Configs.gitDefaultCloneDir) 51 | searchPaths.push({ 52 | path: Configs.gitDefaultCloneDir, 53 | availableDepth: Configs.defaultCloneDirectoryMaximumDepth, 54 | }); 55 | 56 | return searchPaths; 57 | } 58 | 59 | 60 | // TODO: Add custom dirs 61 | // This method returns all found git folders in the search location regardless if they are in the users Github or not 62 | export async function getLocalReposPathAndUrl(): Promise { 63 | // If the user don't have any repository in GitHub 64 | 65 | // Get starting search paths. 66 | const startingSearchPaths = getStartingSearchPaths(); 67 | if (startingSearchPaths.length === 0) { 68 | noLocalSearchPaths = true; 69 | return []; 70 | } 71 | 72 | noLocalSearchPaths = false; // Reset it if was true 73 | 74 | // Get local repositories paths. 75 | const repositoriesPaths: string[] = []; 76 | 77 | const ignore = Configs.directoriesToIgnore 78 | .filter((d) => !/\.git\/?$/.test(d)) // Remove .git if present in ignore list. We need it! 79 | .map((d) => `**/${d}`); // Add **/ to the patterns 80 | 81 | for (const startingSearchPath of startingSearchPaths) 82 | repositoriesPaths.push(...(await globby('**/.git', { 83 | deep: startingSearchPath.availableDepth, 84 | cwd: replaceTildeToHomedir(startingSearchPath.path), 85 | followSymbolicLinks: false, 86 | absolute: true, 87 | ignore, 88 | caseSensitiveMatch: false, 89 | onlyDirectories: true, 90 | onlyFiles: false, 91 | })).map((gitPath) => path.resolve(gitPath, '..'))); 92 | 93 | return await getGitUrls(repositoriesPaths); 94 | } 95 | -------------------------------------------------------------------------------- /src/treeView/repositories/repoItem.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import vscode, { ThemeColor } from 'vscode'; 3 | import type { Dirtiness } from '../../commands/git/dirtiness/dirtiness'; 4 | import type { Repository } from '../../store/repository'; 5 | import type { TreeItemConstructor } from '../treeViewBase'; 6 | import { TreeItem } from '../treeViewBase'; 7 | 8 | // https://code.visualstudio.com/api/references/icons-in-labels 9 | 10 | // TODO: Use GitHub icons (must resize them) 11 | // we may use repo-cloned as icon for template. 12 | function getIcon(repo: Repository): vscode.ThemeIcon | undefined { 13 | if (repo.type === 'local') return; // No icon if local (sure?) 14 | 15 | const args = ((): [name: string, color: string | undefined] => { 16 | if (repo.isPrivate) 17 | return ['lock', 'githubRepositoryManager.private']; 18 | else if (repo.isFork) 19 | return ['repo-forked', 'githubRepositoryManager.fork']; 20 | else // is then public 21 | return ['repo', 'githubRepositoryManager.public']; 22 | })(); 23 | return new vscode.ThemeIcon( 24 | args[0], 25 | args[1] ? new ThemeColor(args[1]) : undefined, 26 | ); 27 | } 28 | 29 | 30 | const dirtyToMessage: Record = { 31 | clean: '', 32 | dirty: 'This repository has local changes', 33 | error: 'An error has happened while getting dirtiness state! Read extension Output!', 34 | unknown: 'Checking if it\'s dirty...', 35 | 36 | }; 37 | // + (repo.isTemplate ? ' | Template' : '') //TODO 38 | function getTooltip(repo: Repository) { 39 | // TODO Maybe for windows it requires regex escape? 40 | // os.homedir e.g. = linux: '/home/user' 41 | const localPath = repo.localPath?.replace(RegExp(`^${os.homedir()}`), '~') ?? ''; 42 | 43 | // the |   | adds a little more spacing. 44 | const R = repo.type === 'remote' ? repo : undefined; 45 | 46 | const string = ` 47 | | | | | 48 | | --- | --- | --- | 49 | **Name** |   | ${repo.name}` 50 | + (!R ? '' : `\r\n**Description** |   | ${R.description ? R.description : 'No description'}`) 51 | + `\r\n**Author** |   | ${repo.ownerLogin}` 52 | + (!R ? '' : `\r\n**Visibility** |   | ${R.isPrivate ? 'Private' : 'Public'}`) 53 | + (!R ? '' : (R.languageName ? `\r\n**Language** |   |${R.languageName}` : '')) 54 | + (!R ? '' : (R.isFork ? `\r\n**Fork of** |   | ${R.parentRepoOwnerLogin} / ${R.parentRepoName}` : '')) 55 | + (!R ? '' : `\r\n**Updated at** |   | ${R.updatedAt.toLocaleString()}`) 56 | + (!R ? '' : `\r\n**Created at** |   | ${R.createdAt.toLocaleString()}`) 57 | + (repo.localPath ? `\r\n**Local path** |   | ${localPath}` : '') 58 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 59 | + ((repo.dirty && repo.dirty !== 'clean') ? `\r\n**Dirty** |   | ${dirtyToMessage[repo.dirty ?? 'clean']}` : ''); 60 | 61 | return new vscode.MarkdownString(string); 62 | } 63 | 64 | 65 | type RepoItemConstructor = Omit & { 66 | repo: Repository; 67 | includeOwner?: boolean; 68 | }; 69 | 70 | const dirtyToChar: Record = { 71 | clean: '', 72 | dirty: '*', 73 | error: 'E', 74 | unknown: '?', 75 | }; 76 | 77 | export class RepoItem extends TreeItem { 78 | repo: Repository; 79 | 80 | constructor({ repo, command, includeOwner, ...rest }: RepoItemConstructor) { 81 | const repoName = includeOwner ? `${repo.ownerLogin} / ${repo.name}` : repo.name; 82 | 83 | let description = ''; 84 | // if (Math.random() > 0.8) // Favorite 85 | // description += 'F '; 86 | if (repo.dirty) 87 | description += dirtyToChar[repo.dirty]; 88 | 89 | super({ 90 | label: repoName, 91 | tooltip: getTooltip(repo), 92 | command, 93 | iconPath: getIcon(repo), 94 | description: description || undefined, // '' to undefined. 95 | }); 96 | Object.assign(this, rest); 97 | this.repo = repo; 98 | } 99 | } -------------------------------------------------------------------------------- /src/store/workspace.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | import { gitHasRemote } from '../commands/git/gitHasRemote/gitHasRemote'; 3 | import { myExtensionSetContext } from '../main/utils'; 4 | 5 | 6 | export type WorkspaceFolderState = 'noGit' | 'gitWithoutRemote' | 'gitWithRemote'; 7 | export type PublishableFolderState = Exclude; 8 | type WorkspaceFolderSpecial = { 9 | workspaceFolder: vscode.WorkspaceFolder; 10 | state: WorkspaceFolderState; 11 | disposable: () => void; 12 | }; 13 | 14 | 15 | async function checkState(path: string): Promise { 16 | try { 17 | const hasRemote = await gitHasRemote(path); 18 | return hasRemote ? 'gitWithRemote' : 'gitWithoutRemote'; 19 | } catch (err: any) { // gitHasRemote throws error if path has no git. 20 | return 'noGit'; 21 | } 22 | } 23 | 24 | 25 | class WorkspaceClass { 26 | 27 | /** To be used inside this class */ 28 | private workspaceFolderSpecial: WorkspaceFolderSpecial[] = []; 29 | 30 | /** To be used by Publish UI command */ 31 | public get foldersAndStates(): { state: WorkspaceFolderState; path: string; name: string }[] { 32 | return this.workspaceFolderSpecial.map((w) => ({ 33 | path: w.workspaceFolder.uri.fsPath, 34 | state: w.state, 35 | name: w.workspaceFolder.name, 36 | })); 37 | } 38 | 39 | public get publishableFoldersAndStates(): { state: PublishableFolderState; path: string; name: string }[] { 40 | return this.foldersAndStates.filter((w) => w.state === 'gitWithoutRemote' || w.state === 'noGit') as any; // We are sure. 41 | } 42 | 43 | activate() { 44 | const fun = () => { 45 | this.resetGitWatcher().catch((err: any) => console.error(err.message)); 46 | }; 47 | 48 | fun(); // Run on start 49 | vscode.workspace.onDidChangeWorkspaceFolders(() => fun()); // Run on changes 50 | } 51 | 52 | deactivate() { 53 | this.workspaceFolderSpecial.forEach((w) => w.disposable()); 54 | this.workspaceFolderSpecial = []; 55 | } 56 | 57 | private updated() { 58 | const containsNoGit = this.workspaceFolderSpecial.find((w) => w.state === 'noGit'); 59 | const containsGitWithoutRemote = this.workspaceFolderSpecial.find((w) => w.state === 'gitWithoutRemote'); 60 | void myExtensionSetContext('canPublish', containsNoGit || containsGitWithoutRemote); 61 | } 62 | 63 | private async resetGitWatcher() { 64 | this.workspaceFolderSpecial.forEach((w) => w.disposable()); // dispose all: ; 65 | this.workspaceFolderSpecial = []; // Reset 66 | const workspaceFolders = vscode.workspace.workspaceFolders ?? []; 67 | this.workspaceFolderSpecial = await Promise.all(workspaceFolders.map(async (workspaceFolder) => { 68 | const watcherDir = vscode.workspace.createFileSystemWatcher(`${workspaceFolder.uri.path}/.git`); // just watching config wouldn't catch ./git deletion. 69 | const watcherFile = vscode.workspace.createFileSystemWatcher(`${workspaceFolder.uri.path}/.git/config`); 70 | const newSpecial: WorkspaceFolderSpecial = { 71 | workspaceFolder, 72 | disposable: () => { watcherDir.dispose(); watcherFile.dispose(); }, 73 | state: await checkState(workspaceFolder.uri.fsPath), 74 | }; 75 | const fun = async () => { 76 | newSpecial.state = await checkState(workspaceFolder.uri.fsPath); 77 | this.updated(); 78 | }; 79 | 80 | watcherDir.onDidChange(() => fun()); 81 | watcherDir.onDidCreate(() => fun()); 82 | watcherDir.onDidDelete(() => fun()); 83 | watcherFile.onDidChange(() => fun()); 84 | watcherFile.onDidCreate(() => fun()); 85 | watcherFile.onDidDelete(() => fun()); 86 | 87 | return newSpecial; 88 | })); // End of Promise.all; 89 | this.updated(); // update after setting the new array. 90 | } // End of resetGitWatcher; 91 | 92 | } // End of WorkspaceClass; 93 | 94 | 95 | export const Workspace = new WorkspaceClass(); -------------------------------------------------------------------------------- /src/commandsUi/uiPublish/uiPublish.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import vscode from 'vscode'; 3 | import { User } from '../../store/user'; 4 | import { Workspace } from '../../store/workspace'; 5 | import { myQuickPick } from '../../vscode/myQuickPick'; 6 | import type { OnRepositoryCreation } from '../uiCreateRepo'; 7 | import { uiCreateRepoCore } from '../uiCreateRepo'; 8 | import { posNoGit, preNoGit } from './noGit'; 9 | import { posNoRemote, preNoRemote, preRepositoryCreateNoRemote as preRepositoryCreationNoRemote } from './noRemote'; 10 | 11 | 12 | async function getWorkspaceFolderPathToPublish() { 13 | if (!User.login) 14 | return undefined; 15 | 16 | const publishableFolders = Workspace.publishableFoldersAndStates; 17 | 18 | if (publishableFolders.length === 0) 19 | return undefined; 20 | 21 | if (publishableFolders.length === 1) { 22 | const folder = publishableFolders[0]!; 23 | return { path: folder.path, state: folder.state }; 24 | } else { // If multiple folders 25 | 26 | const quickPick = await myQuickPick({ 27 | items: publishableFolders.map((w) => ({ 28 | label: w.name, 29 | description: w.path, 30 | // detail: w.state === 'noGit' ? "Will also initialize Git" : "Doesn't have a remote, will be added", 31 | // ^ hmm... not using it. too much info for the user. 32 | })), 33 | title: 'Select the workspace folder to publish to GitHub', 34 | ignoreFocusOut: false, 35 | }); 36 | 37 | if (!quickPick) // If user cancelled 38 | return undefined; 39 | 40 | const path = quickPick.description!; 41 | return { 42 | path, 43 | state: (publishableFolders.find((f) => path === f.path))!.state, 44 | }; 45 | 46 | } 47 | } 48 | 49 | 50 | export async function uiPublish(): Promise { 51 | try { 52 | if (!User.token) 53 | throw new Error('User token isn\'t set!'); 54 | 55 | /** Get project path and state. */ 56 | const folder = await getWorkspaceFolderPathToPublish(); 57 | 58 | if (!folder) 59 | return; 60 | 61 | const { path: cwd, state } = folder; 62 | 63 | let headBranch: string | undefined; 64 | 65 | if (state === 'noGit') 66 | await preNoGit({ cwd }); 67 | else 68 | await preNoRemote({ cwd }); 69 | 70 | 71 | const onRepositoryCreation: OnRepositoryCreation = async (newRepository) => { 72 | if (state === 'noGit') { await posNoGit({ cwd, newRepository }); } else { 73 | if (!headBranch) 74 | throw new Error('headBranch not set! Contact extension developer.'); // Shouldn't happen. 75 | await posNoRemote({ cwd, newRepository, headBranch }); 76 | } 77 | 78 | await Promise.all([ 79 | User.reloadRepos(), 80 | (async () => { 81 | const actions = ['Open GitHub Page']; 82 | const action = await vscode.window.showInformationMessage( 83 | `Repository '${newRepository.name}' created for the current project!`, 84 | ...actions, 85 | ); 86 | if (action === actions[0]) { 87 | const repositoryUrl = newRepository.html_url; // this prop is the right one, = 'https://github.com/author/repo' 88 | await vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(repositoryUrl)); 89 | } 90 | })(), 91 | ]); // End of Promise.all 92 | }; 93 | 94 | /** May error without throwing. Don't execute anything after it. */ 95 | await uiCreateRepoCore({ 96 | repositoryNamePrompt: `Enter the new repository name for the chosen workspace folder`, 97 | repositoryNameInitialValue: path.basename(cwd), 98 | onRepositoryCreation, 99 | preRepositoryCreation: async () => { 100 | if (state === 'gitWithoutRemote') { 101 | const result = await preRepositoryCreationNoRemote({ cwd }); 102 | if (!result) 103 | return 'cancel'; 104 | headBranch = result.headBranch; 105 | } 106 | }, 107 | }); 108 | 109 | } catch (err: any) { 110 | void vscode.window.showErrorMessage(err.message); 111 | } 112 | } -------------------------------------------------------------------------------- /src/commands/git/cloneRepository/cloneRepository.ts: -------------------------------------------------------------------------------- 1 | // Notice that it doesn't actually uses git clone. Read if you want to. 2 | // 3 | // Writing this simple function took me more than a day. Here are the main whys: 4 | // 5 | // 1) If we used `git clone https://${user.token}@github.com/${owner}/${repositoryName}`, 6 | // it would save the token in the .git.config. 7 | // 8 | // 2) If we used git clone with only the username (so, it wouldn't save the password / token 9 | // in .git files), it would request the password via user input. I didn't find out a way to 10 | // automatically input the password (= token) when requested by git clone, using childProcess 11 | // (probably using .spawn() instead of .exec() and using some Stream STDIN sorcery that I didn't find out). 12 | // Also, I ain't sure of how it would works if user already already logged via git --global credential. 13 | // Didn't want to find out. I really don't like using git via cli (or any program at all). Not in the mood. 14 | // 15 | // 3) I didn't want to get the user's username and password, like the vscode does. We have a token, right? 16 | // Don't make too much sense not using it. 17 | // 18 | // Links for the solution: 19 | // 1) https://serverfault.com/a/815145 -> The answer command required mkdir and cd, and I don't know 20 | // if it would work properly in windows the same way. I found out that git init creates a dir. 21 | // 22 | // 2) https://www.reddit.com/r/git/comments/30i3we/git_pull_to_a_different_directory_other_than/cpsncul?utm_source=share&utm_medium=web2x 23 | // -> We can pull to a specific path using git -C pull <...> 24 | // 25 | // After those two, the pulled repo don't have a remote (github link). So we add it. 26 | 27 | import path from 'path'; 28 | import execa from 'execa'; 29 | import fse from 'fs-extra'; 30 | import { getRemoteHead as getRemoteHeadBranch } from '../getRemoteHeadBranch/getRemoteHeadBranch'; 31 | import { getRepositoryGitUrl } from '../getRepositoryGitUrl'; 32 | 33 | 34 | export async function cloneRepo(options: { 35 | repositoryName: string; 36 | owner: string; 37 | parentPath: string; 38 | token: string; 39 | }): Promise { 40 | 41 | const { owner, repositoryName, parentPath, token } = options; 42 | 43 | const repositoryPath = path.join(parentPath, repositoryName); 44 | 45 | if (await fse.pathExists(repositoryPath)) 46 | throw new Error(`There is already a directory named '${repositoryName}' at '${parentPath}'!`); 47 | 48 | const remoteUrl = getRepositoryGitUrl({ owner, repositoryName }); 49 | const remoteUrlTokenized = getRepositoryGitUrl({ owner, repositoryName, token }); 50 | 51 | try { 52 | await execa('git', ['init', repositoryName], { cwd: parentPath }); 53 | 54 | const headBranchRaw = await getRemoteHeadBranch({ remoteUrl: remoteUrlTokenized, repositoryPath }); 55 | const repositoryIsEmpty = !headBranchRaw; 56 | 57 | /** Defaults to main if there is no HEAD */ 58 | const headBranch: string = repositoryIsEmpty ? 'main' : headBranchRaw; 59 | 60 | /** https://stackoverflow.com/a/42871621/10247962 */ 61 | await execa('git', ['checkout', '-b', headBranch], { cwd: repositoryPath }); 62 | await execa('git', ['remote', 'add', 'origin', remoteUrl], { cwd: repositoryPath }); 63 | 64 | // If repository has a HEAD branch, aka has a commit, aka isn't empty 65 | if (!repositoryIsEmpty) 66 | /** TODO: "If you don't want your token to be stored in your shell history, you can set GITHUB_TOKEN 67 | * in the environment and that will be read instead" 68 | * https://github.com/mheap/github-default-branch */ 69 | await execa('git', ['pull', remoteUrlTokenized], { cwd: repositoryPath }); 70 | 71 | 72 | /** I didn't find a way to automatically set the push destination. 73 | * The usual way is by doing "git push -u origin main", however, it requires the user being 74 | * logged, which isn't always true (and we actually didn't in previous steps). #22. */ 75 | await fse.appendFile(path.join(repositoryPath, '.git', 'config'), 76 | `[branch "${headBranch}"] 77 | \tremote = origin 78 | \tmerge = refs/heads/${headBranch}`); 79 | 80 | } catch (err: any) { 81 | // Removes the repo dir if error. We already checked before the try if the path existed, 82 | // so we are only removing what we possibly created. 83 | await fse.remove(repositoryPath); 84 | 85 | /** Error message with user token censored, if included */ 86 | const censoredMsg = (err.message as string).replace(new RegExp(token, 'g'), '[tokenHidden]'); 87 | throw new Error(censoredMsg); 88 | } 89 | } -------------------------------------------------------------------------------- /src/treeView/repositories/clonedRepos.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fse from 'fs-extra'; 3 | import type { MessageItem } from 'vscode'; 4 | import { commands, env, ThemeIcon, Uri, window, workspace } from 'vscode'; 5 | import { isGitDirty } from '../../commands/git/dirtiness/dirtiness'; 6 | import { noLocalSearchPaths } from '../../commands/searchClonedRepos/searchClonedRepos'; 7 | import type { Repository } from '../../store/repository'; 8 | import { User } from '../../store/user'; 9 | import { TreeItem } from '../treeViewBase'; 10 | import { RepoItem } from './repoItem'; 11 | 12 | 13 | export function activateClonedRepos(): void { 14 | // Open 15 | commands.registerCommand('githubRepoMgr.commands.clonedRepos.open', ({ repo }: RepoItem) => 16 | repo.localPath && commands.executeCommand('vscode.openFolder', Uri.file(repo.localPath))); 17 | 18 | // Open in New Window 19 | commands.registerCommand('githubRepoMgr.commands.clonedRepos.openInNewWindow', ({ repo }: RepoItem) => 20 | repo.localPath && commands.executeCommand('vscode.openFolder', Uri.file(repo.localPath), true)); 21 | 22 | // Add to Workspace 23 | commands.registerCommand('githubRepoMgr.commands.clonedRepos.addToWorkspace', ({ repo }: RepoItem) => 24 | repo.localPath && workspace.updateWorkspaceFolders(workspace.workspaceFolders?.length ?? 0, 0, { uri: Uri.file(repo.localPath) })); 25 | 26 | // Open Containing Folder 27 | commands.registerCommand('githubRepoMgr.commands.clonedRepos.openContainingFolder', ({ repo }: RepoItem) => 28 | // revealFileInOS always open the parent path. So, to open the repo dir in fact, we pass the 29 | repo.localPath && commands.executeCommand('revealFileInOS', Uri.file(path.resolve(repo.localPath, '.git')))); 30 | 31 | // Copy local path to clipboard 32 | commands.registerCommand('githubRepoMgr.commands.clonedRepos.copyPath', ({ repo }: RepoItem) => { 33 | repo.localPath && void env.clipboard.writeText(repo.localPath); 34 | }); 35 | 36 | // Delete repo 37 | commands.registerCommand('githubRepoMgr.commands.clonedRepos.delete', async ({ repo }: RepoItem) => { 38 | if (!repo.localPath) 39 | return; // DO nothing if repo hasn't local path 40 | const isDirty = await isGitDirty(repo.localPath); 41 | 42 | const title = isDirty ? `Delete DIRTY ${repo.name} repository?` : `Delete ${repo.name} repository?`; 43 | const message = isDirty 44 | ? `The repository is DIRTY; there are uncommitted local changes. Are you sure you want to locally delete this repository? This action is IRREVERSIBLE.` 45 | : `Are you sure you want to locally delete the repository? This action is irreversible.`; 46 | 47 | const deleteString = 'Delete'; 48 | const answer = await window.showWarningMessage(title, 49 | { 50 | detail: message, 51 | modal: true, 52 | }, 53 | { title: 'Cancel', isCloseAffordance: true }, 54 | { title: deleteString }, 55 | ); 56 | 57 | if (answer?.title === deleteString) { 58 | const disposable = window.setStatusBarMessage(`Locally deleting ${repo.name}...`); 59 | try { 60 | await fse.remove(repo.localPath); 61 | void window.showInformationMessage(`Locally deleted the ${repo.name} repository.`); 62 | await User.reloadRepos(); 63 | } catch (err) { 64 | void window.showErrorMessage((err as any).message); 65 | } finally { 66 | disposable.dispose(); 67 | } 68 | } 69 | }); 70 | } 71 | 72 | function parseChildren(clonedRepos: Repository[], userLogin?: string): TreeItem | TreeItem[] { 73 | return clonedRepos.map((repo) => new RepoItem({ 74 | repo, 75 | contextValue: 'githubRepoMgr.context.clonedRepo', 76 | command: { 77 | // We wrap the repo in {} because we may call the cloneTo from the right click, and it passes the RepoItem. 78 | command: 'githubRepoMgr.commands.clonedRepos.open', 79 | arguments: [{ repo }], 80 | }, 81 | includeOwner: repo.ownerLogin !== userLogin, 82 | })); 83 | } 84 | 85 | function sortClonedRepos(clonedRepos: Repository[], userLogin?: string): Repository[] { 86 | return clonedRepos.sort((a, b) => { 87 | 88 | if (userLogin) { 89 | // User repos comes first 90 | if (a.ownerLogin === userLogin && b.ownerLogin !== userLogin) 91 | return -1; 92 | if (a.ownerLogin !== userLogin && b.ownerLogin === userLogin) 93 | return 1; 94 | } 95 | 96 | // Different Authors are sorted (ownerLogin === userLogin doesn't enter this block) 97 | if (a.ownerLogin !== b.ownerLogin) 98 | return (a.ownerLogin.toLocaleUpperCase() < b.ownerLogin.toLocaleUpperCase()) 99 | ? -1 : 1; 100 | 101 | // If same owner login, repos are sorted by name. 102 | return (a.name.toLocaleUpperCase() < b.name.toLocaleUpperCase()) 103 | ? -1 : 1; 104 | }); 105 | } 106 | 107 | // TODO: Add remember cloned repos when not logged option? 108 | export function getClonedTreeItem(): TreeItem { 109 | if (!User.login) 110 | throw new Error('User.login is not set!'); 111 | 112 | const sortedRepos = sortClonedRepos(User.clonedRepos, User.login); 113 | return new TreeItem({ 114 | label: 'Cloned', 115 | children: noLocalSearchPaths 116 | ? new TreeItem({ 117 | label: ' Press here to select "git.defaultCloneDirectory"', 118 | command: 'githubRepoMgr.commands.pick.defaultCloneDirectory', 119 | iconPath: new ThemeIcon('file-directory'), 120 | }) 121 | : parseChildren(sortedRepos, User.login), 122 | }); 123 | } 124 | 125 | 126 | // TODO: Add remember cloned repos when not logged option? 127 | export function getClonedOthersTreeItem(): TreeItem { 128 | const sortedRepos = sortClonedRepos(User.clonedOtherRepos); 129 | return new TreeItem({ 130 | label: 'Cloned - Others', 131 | children: parseChildren(sortedRepos, User.login), 132 | }); 133 | } 134 | -------------------------------------------------------------------------------- /src/commandsUi/uiCreateRepo.ts: -------------------------------------------------------------------------------- 1 | import vscode from 'vscode'; 2 | import type { CreateGitHubRepositoryReturn } from '../commands/github/createGitHubRepository'; 3 | import { createGitHubRepository } from '../commands/github/createGitHubRepository'; 4 | import { User } from '../store/user'; 5 | import { myQuickPick } from '../vscode/myQuickPick'; 6 | import { uiCloneTo } from './uiCloneTo'; 7 | 8 | 9 | export type NewRepository = CreateGitHubRepositoryReturn; 10 | export type OnRepositoryCreation = (newRepository: NewRepository) => Promise; 11 | 12 | /** To be used by the 3 repo creations UI functions. 13 | * 14 | * Doesn't throw errors. */ 15 | export async function uiCreateRepoCore(options: { 16 | repositoryNamePrompt: string; 17 | repositoryNameInitialValue: string; 18 | onRepositoryCreation: OnRepositoryCreation; 19 | /** Run after the repository info questions but before the creation itself. 20 | * 21 | * Return 'cancel' to cancel and exit the flow. */ 22 | preRepositoryCreation?: () => Promise<('cancel' | undefined)>; 23 | }): Promise { 24 | 25 | try { 26 | /** If undefined, should create repository to the user */ 27 | let organizationLogin: string | undefined = undefined; 28 | 29 | const orgsUserCanCreateRepo = User.organizationUserCanCreateRepositories; 30 | 31 | if (orgsUserCanCreateRepo.length === 0) { 32 | return; // Do nothing. The user just hasn't loaded yet! 33 | } else if (orgsUserCanCreateRepo.length === 1) { 34 | // Do nothing if user is the only available org. 35 | } else { // Else, pick one! 36 | const userDescription = 'Your personal account'; 37 | const pick = (await myQuickPick({ 38 | items: orgsUserCanCreateRepo.map((e) => ({ 39 | label: (e.login === User.login) ? e.login : e.name, 40 | description: (e.login === User.login) ? userDescription : e.login, 41 | })), 42 | ignoreFocusOut: false, 43 | title: 'Should the new repository be created in your account or in an Organization?', 44 | })); 45 | 46 | if (!pick) 47 | return; 48 | 49 | const havePickedUser = pick.description === userDescription; 50 | 51 | if (!havePickedUser) 52 | organizationLogin = pick.description!; 53 | } 54 | 55 | const name: string = (await vscode.window.showInputBox({ 56 | prompt: options.repositoryNamePrompt, 57 | placeHolder: 'Repository name', 58 | value: options.repositoryNameInitialValue, 59 | ignoreFocusOut: true, 60 | }))?.trim() ?? ''; 61 | 62 | if (!name) // Don't allow empty names. This also catches `undefined`, if user pressed Esc. 63 | return; 64 | 65 | // User won't be able to quit dialogue now by pressing Esc, as empty descriptions are allowed. 66 | const description = (await vscode.window.showInputBox({ 67 | prompt: 'Enter the repository description', 68 | placeHolder: 'Repository description (optional)', 69 | value: '\r\n', // By luck (I thought it would work, and it did! :)) I found out that using this as default value, 70 | // we can differentiate a Esc (returns undefined) to a empty input Enter (would originally also returns undefined, 71 | // now return \r\n). It also keeps showing the place holder. Also, it doesn't seem to be possible to the user erase it. 72 | ignoreFocusOut: true, 73 | }))?.trim(); // Removes the '\r\n' and other whitespaces. 74 | 75 | if (description === undefined) 76 | return; 77 | 78 | const visibility = (await myQuickPick({ 79 | // Private is first so if the user creates the repo by mistake (Enter-enter-enter), 80 | // he won't 'feel too much bad' for publishing trash to his GitHub, like I did lol. 81 | items: [{ label: 'Private' }, { label: 'Public' }], 82 | title: 'Select the repository visibility', 83 | ignoreFocusOut: true, 84 | }))?.label; 85 | 86 | if (visibility === undefined) // If user pressed Esc 87 | return; 88 | 89 | const isPrivate = visibility === 'Private'; 90 | 91 | const preRepoCreationResult = await options.preRepositoryCreation?.(); 92 | if (preRepoCreationResult === 'cancel') // Quit if true 93 | return; 94 | 95 | const newRepository = await createGitHubRepository({ name, description, isPrivate, organizationLogin }); 96 | 97 | await options.onRepositoryCreation(newRepository); 98 | 99 | } catch (err: any) { 100 | const errMessage = err.message as string; 101 | let message = errMessage; 102 | // The error message from octokit is a little strange. Is a JSON but has a string before it. 103 | // We manually transform the common error messages here. 104 | if (errMessage.includes('name already exists on this account')) 105 | message = 'Repository name already exists on this account!'; 106 | void vscode.window.showErrorMessage(message); 107 | return; 108 | } 109 | } 110 | 111 | /** Doesn't throw errors. */ 112 | export async function uiCreateRepo(): Promise { 113 | await uiCreateRepoCore({ 114 | repositoryNameInitialValue: '', 115 | repositoryNamePrompt: 'Enter the new repository name', 116 | onRepositoryCreation: async (newRepository) => { 117 | await Promise.all([ 118 | User.reloadRepos(), 119 | (async () => { 120 | const actions = ['Clone it']; 121 | const answer = await vscode.window.showInformationMessage(`Repository '${newRepository.name}' created successfully!`, ...actions); 122 | 123 | if (answer === actions[0]) { 124 | if (!newRepository.owner.login) // certainly wont happen 125 | throw new Error(`newRepository.owner?.login doesn't exist!`); 126 | await uiCloneTo({ 127 | ownerLogin: newRepository.owner.login, 128 | name: newRepository.name, 129 | reloadRepos: true, 130 | }); 131 | } 132 | })(), 133 | ]); 134 | }, 135 | }); 136 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 |

14 | GitHub Repository Manager 15 |

16 | 17 |
18 | 19 | [![Installs](https://img.shields.io/visual-studio-marketplace/i/henriqueBruno.github-repository-manager)](https://marketplace.visualstudio.com/items?itemName=henriqueBruno.github-repository-manager) 20 | 21 |
22 | 23 |

24 | VS Code extension that lists your personal and organizations GitHub repositories, allowing you to clone and access them and create new ones 25 |

26 | 27 | 28 |
29 | 30 | 31 | 32 |
33 | 34 |
35 |
36 | 37 | _This gif is a little old! We are now using the VS Code integrated GitHub login system and the extension is prettier!_ 38 | 39 |
40 | 41 |
42 | 43 | ![](images/readme/config/coloredIconsTrue.png) 44 | 45 | _The `*` means that the repository is **dirty**! So it has local changes that aren't yet commited!_ 46 | 47 |
48 | 49 |

50 | Guide 51 |

52 | 53 | 54 | 65 | 66 | 67 |

Cloned Repositories Search

68 | 69 | To make your GitHub cloned repositories show up in the **Cloned** tree view, you will need to set the **`"git.defaultCloneDirectory"`** in your VSCode `settings.json` to the path your cloned repositories are located (they may be deeply located there). 70 | 71 | **1.4.0** - there is now a button to set this config interactively! 72 | 73 | ![](https://user-images.githubusercontent.com/28177550/171236229-86a8fbd4-d21d-4b96-938a-ff0f09a59948.png) 74 | 75 |

Creating a repository

76 | 77 | By hovering the **REPOSITORIES** tree view title, a **+** button appears. Click on it, enter the new repository name, description (optional) and visibility. On success, you may choose to clone the new repository. 78 | 79 | If you are a member of at least one organization that allows you to create repositories for it, it will be asked, before the repository name input, to pick the new repository owner: your own account or one of those organizations. 80 | 81 |

Creating a repository for current project

82 | 83 | ![](images/readme/publish.png) 84 | 85 | You may create a GitHub repository and push your current project within the same flow. If there are multiple folders in your workspace that may be published to GitHub, it will be prompted to pick one. 86 | 87 | There are 2 possible cases that allows using that publish functionality: 88 | 89 | 1) **Your project doesn't have a Git yet.** After entering the repository name, description and visibility, the repository will be created, the git will be initialized for the workspace folder, `main` branch will be created and selected, GitHub remote will be added as `origin` and your files will then be pushed to it. 90 | 91 | 2) **Your project has a Git, but it hasn't a remote yet.** After filling the repository information, it will be checked if your git HEAD is `master`. If so, it will ask if you want the branch to be renamed to `main`. Then the repository will be created, the GitHub remote is added as `origin` and your code is pushed. 92 | 93 |
94 |

Settings

95 | 96 | 97 | - ### Always Clone To Default Directory 98 | ##### _"githubRepositoryManager.alwaysCloneToDefaultDirectory"_ 99 | Always clone to the directory specified in "git.defaultCloneDirectory". 100 | ##### Default: **false** 101 | 102 | - ## Default Clone Directory Maximum Depth 103 | ##### _"githubRepositoryManager.clonedRepositoriesSearch.defaultCloneDirectoryMaximumDepth"_ 104 | How deep on `"git.defaultCloneDirectory"` the cloned repositories will be searched. A depth of 0 means it will only search in the directory itself, a depth of 3 means it will search up to 3 directories below. The lesser the faster. 105 | ##### Default: **2** 106 | 107 | 108 | - ## Directories To Ignore 109 | ##### _"githubRepositoryManager.clonedRepositoriesSearch.directoriesToIgnore"_ 110 | Directories names that are ignored on the search for the cloned repositories. `**/` is added to their start. 111 | ##### Default: **["node_modules", ".vscode", ".git/*", "logs", "src", "lib", "out", "build"]** 112 | 113 | 114 |
115 | 116 |

117 | 118 | Changelog 119 | 120 |

121 | 122 | 142 | -------------------------------------------------------------------------------- /src/store/user.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/rest'; 2 | import gitUrlParse from 'git-url-parse'; 3 | import vscode from 'vscode'; 4 | import { getDirtiness } from '../commands/git/dirtiness/dirtiness'; 5 | import { getUser } from '../commands/github/getUserData'; 6 | import type { DirWithGitUrl } from '../commands/searchClonedRepos/searchClonedRepos'; 7 | import { getLocalReposPathAndUrl } from '../commands/searchClonedRepos/searchClonedRepos'; 8 | import { myExtensionSetContext } from '../main/utils'; 9 | import { Organization } from './organization'; 10 | import type { LocalRepository, Repository } from './repository'; 11 | 12 | 13 | const AUTH_PROVIDER_ID = 'github'; 14 | const SCOPES = ['repo', 'read:org']; 15 | 16 | 17 | // Values are named as we use them to setContext and also for better debugging (console.log(user.state)) 18 | export enum UserState { 19 | /** On extension start */ 20 | init = 'init', 21 | /** User is not logged */ 22 | notLogged = 'notLogged', 23 | /** User is logging */ 24 | logging = 'logging', 25 | /** Error on logging */ 26 | errorLogging = 'errorLogging', 27 | /** User is logged */ 28 | logged = 'logged', 29 | } 30 | 31 | export enum RepositoriesState { 32 | /** Nothing to be shown. */ 33 | none, 34 | /** Fetching the repositories list from the server. A "Loading" should be shown. */ 35 | fetching, 36 | /** Repos may be shown, but we are still fetching some data or doing local steps. 37 | * // TODO rename this! */ 38 | partial, 39 | /** There are no informations remaining to be fetched */ 40 | fullyLoaded, 41 | } 42 | 43 | // TODO refactor state management. It's quite non intuictive right now. State changing maybe should 44 | // be all in reloadRepos(). 45 | 46 | class UserClass { 47 | /** The User current status. */ 48 | readonly state: UserState = UserState.init; 49 | /** Used by cloneRepo() */ 50 | token: string | undefined; 51 | /** The user raw name, e.g 'SrBrahma' (not the pretty customizable one). */ 52 | login: string | undefined; 53 | /** The user GitHub url/uri. */ 54 | profileUri: string | undefined; 55 | userOrganization: Organization | undefined; 56 | /** Also includes the user Organization */ 57 | organizations: Organization[] = []; 58 | /** The User current status. */ 59 | repositoriesState: RepositoriesState = RepositoriesState.none; 60 | clonedRepos: Repository[] = []; 61 | /** Repositories that are cloned but not from user / user's org */ 62 | clonedOtherRepos: LocalRepository[] = []; 63 | 64 | /** Returns the orgs that the user can create new repositories. 65 | * As it uses this.organizations, it includes the user Organization. */ 66 | get organizationUserCanCreateRepositories() { return this.organizations.filter((o) => o.userCanCreateRepositories); } 67 | 68 | /** The ones listening for changes. */ 69 | private subscribers: ['account' | 'repos', () => void][] = []; 70 | public subscribe(changed: 'account' | 'repos', callback: () => void) { this.subscribers.push([changed, callback]); } 71 | private informSubscribers(changed: 'account' | 'repos') { this.subscribers.forEach(([c, cb]) => (changed === c) && cb()); } 72 | 73 | /** Will also informSubscribers('account') */ 74 | setUserState(state: UserState) { 75 | (this.state as any) = state; // as any to override readonly 76 | void myExtensionSetContext('userState', state); 77 | this.informSubscribers('account'); 78 | } 79 | 80 | /** Will also informSubscribers('repos') */ 81 | setRepositoriesState(state: RepositoriesState) { 82 | this.repositoriesState = state; 83 | this.informSubscribers('repos'); 84 | } 85 | 86 | 87 | private resetUser(opts: { resetUserStatus: boolean; resetOctokit: boolean }) { 88 | this.login = undefined; 89 | this.profileUri = undefined; 90 | this.organizations = []; 91 | if (opts.resetUserStatus) 92 | this.setUserState(UserState.notLogged); 93 | if (opts.resetOctokit) { 94 | octokit = undefined; 95 | this.token = undefined; 96 | } 97 | } 98 | 99 | private resetRepos(opts: { resetRepositoriesStatus: boolean }) { 100 | this.clonedRepos = []; 101 | this.clonedOtherRepos = []; 102 | if (opts.resetRepositoriesStatus) 103 | this.setRepositoriesState(RepositoriesState.none); 104 | } 105 | 106 | onLogOut() { 107 | this.resetUser({ resetOctokit: true, resetUserStatus: true }); 108 | this.resetRepos({ resetRepositoriesStatus: true }); 109 | } 110 | 111 | 112 | async activate() { 113 | vscode.commands.registerCommand('githubRepoMgr.commands.auth.vscodeAuth', async () => { 114 | try { 115 | /** Returns the new authed token. May throw errors. */ 116 | const token = (await vscode.authentication.getSession(AUTH_PROVIDER_ID, SCOPES, { createIfNone: true })) 117 | .accessToken; 118 | await this.initOctokit(token); 119 | } catch (err: any) { 120 | void vscode.window.showErrorMessage(err.message); 121 | } 122 | }); 123 | try { 124 | // vscode.authentication.onDidChangeSessions(e => ...); // It doesn't get the logout. 125 | /** Stored token */ 126 | const token = (await vscode.authentication.getSession(AUTH_PROVIDER_ID, SCOPES, { createIfNone: false }))?.accessToken; 127 | /** Init octokit if we have a stored token */ 128 | if (token) 129 | await this.initOctokit(token); 130 | else 131 | this.setUserState(UserState.notLogged); 132 | } catch (err: any) { 133 | void vscode.window.showErrorMessage(err.message); 134 | } 135 | } 136 | 137 | /** Inits octokit and sets token. */ 138 | async initOctokit(token: string): Promise { 139 | octokit = new Octokit({ auth: token }); 140 | this.token = token; 141 | 142 | /** reloadRepos() will change the userState on its end. */ 143 | await User.reloadRepos().catch ((err) => { 144 | void vscode.window.showErrorMessage(err.message); 145 | console.error('Octokit init error: ', err); 146 | octokit = undefined; 147 | this.token = undefined; 148 | }); 149 | } 150 | 151 | private async loadUser(): Promise { 152 | try { 153 | this.setUserState(UserState.logging); 154 | const { login, organizations, profileUri } = await getUser(); 155 | this.login = login; 156 | this.profileUri = profileUri; 157 | // We set name as login. The user real name really isn't useful anywhere here and would be too personal, invasive. 158 | this.userOrganization = new Organization({ login, name: login, isUserOrg: true, userCanCreateRepositories: true }); 159 | 160 | this.organizations.push(this.userOrganization); 161 | this.organizations.push(...organizations.map((org: any) => new Organization({ 162 | isUserOrg: false, 163 | login: org.login, 164 | name: org.name, 165 | userCanCreateRepositories: org.viewerCanCreateRepositories, 166 | }))); 167 | this.setUserState(UserState.logged); 168 | } catch (err: any) { 169 | void vscode.window.showErrorMessage(err.message); 170 | this.setUserState(UserState.errorLogging); 171 | throw new Error(err); 172 | } 173 | } 174 | 175 | 176 | /** Must be executed after loadUser and loadLocalRepos */ 177 | private async loadRepos({ localRepos }: { localRepos: DirWithGitUrl[] }): Promise { 178 | this.setRepositoriesState(RepositoriesState.fetching); 179 | await Promise.all(this.organizations.map((org) => org.loadOrgRepos({ localRepos }))); 180 | 181 | // Get dirtyness status of local repos 182 | this.clonedRepos = this.organizations.map((org) => org.clonedRepos).flat(); 183 | this.clonedOtherRepos = localRepos 184 | // Remove repos that are on Cloned tree 185 | .filter((r) => !this.clonedRepos.find((c) => c.localPath === r.dirPath)) 186 | .map((r) => ({ 187 | name: gitUrlParse(r.gitUrl).name, 188 | ownerLogin: gitUrlParse(r.gitUrl).owner, 189 | url: r.gitUrl, 190 | localPath: r.dirPath, 191 | type: 'local', 192 | })); 193 | 194 | // To show unknown dirtiness or at least the not-cloned repos, if none is cloned. 195 | this.setRepositoriesState(RepositoriesState.partial); 196 | // Updates dirty forms from time to time while not done 197 | const interval = setInterval(() => { this.informSubscribers('repos'); }, 250); 198 | 199 | // Check dirty of orgs' local repos 200 | await Promise.all(this.organizations.map((org) => Promise.all( 201 | org.clonedRepos.map(async (localRepo) => { 202 | localRepo.dirty = await getDirtiness(localRepo.localPath!); 203 | })), 204 | )); 205 | clearInterval(interval); 206 | 207 | this.setRepositoriesState(RepositoriesState.fullyLoaded); 208 | } 209 | 210 | /** Fetch again the user data and its orgs that he belongs to. */ 211 | // TODO add local reload, for actions like delete to avoid unncessary refetch 212 | public async reloadRepos(): Promise { 213 | if (this.repositoriesState === RepositoriesState.fetching) 214 | return; // Don't allow multiple fetches (it should, by cancelling first) 215 | 216 | this.resetUser({ resetUserStatus: false, resetOctokit: false }); 217 | this.resetRepos({ resetRepositoriesStatus: false }); 218 | if (!octokit) // Ignore if octokit not set up, after resetting above. 219 | return; 220 | this.setRepositoriesState(RepositoriesState.fetching); 221 | const [, localRepos] = await Promise.all([this.loadUser(), getLocalReposPathAndUrl()]); 222 | await this.loadRepos({ localRepos }); 223 | } 224 | 225 | } 226 | 227 | export const User = new UserClass(); 228 | export let octokit: Octokit | undefined = undefined; -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log : GitHub Repository Manager 2 | 3 | ## 1.6.1 4 | 5 | - Fix: 'setAsFavorite' is already registered [#50](https://github.com/SrBrahma/GitHub-Repository-Manager/pull/50). 6 | 7 | ## 1.6.0 8 | 9 | - Added `Cloned - Others` tree for local repositories that aren't from the user or from an organization the user is part of. For now they must have a remote URL. Later I will add support for repositories without remote. 10 | - Added `Delete` to cloned repositories' context menu. It has a confirmation and informs if the repository is dirty, but still, be careful! 11 | 12 | ## 1.5.1 - 2022-06-20 13 | 14 | - Improved HEAD detection for repository cloning. It shall work for all the languages. [#42](https://github.com/SrBrahma/GitHub-Repository-Manager/issues/42). Thanks, [lo-ca](https://github.com/lo-ca)! 15 | 16 | ## 1.5.0 - 2022-06-09 17 | 18 | - Now using `globby` to find the dirs that contains .git. It's not only faster but fixes a strange bug where there wouldn't appear any cloned repositories. 19 | - Removed `coloredIcons` option. It's just ugly if the repos don't have colors, certainly no one disabled that. Contact me if you did. 20 | - Added `logs, src, lib, out, build` as default to `"githubRepositoryManager.directoriesToIgnore"`. 21 | - Changed `"git.defaultCloneDirectory"` default from `3` to `2`. 22 | 23 | ## 1.4.0~1 - 2022-05-31 24 | 25 | - Added button in the TreeView to set the "git.defaultCloneDirectory"! [#40](https://github.com/SrBrahma/GitHub-Repository-Manager/issues/40) [#29](https://github.com/SrBrahma/GitHub-Repository-Manager/issues/29) 26 | 27 | ## 1.3.3 - 2022-03-03 28 | 29 | - Fixed the Remote HEAD obtaining for japanese Git - [#35](https://github.com/SrBrahma/GitHub-Repository-Manager/issues/35). Thanks, [@YuuJinS](https://github.com/YuuJinS)! 30 | - Fixed `tokenHidden` replace not being applied on all tokens in error messages. 31 | 32 | ## 1.3.2 - 2022-01-19 33 | 34 | - Possible solution. Seems it was a git language output issue on `git remote show ...`. But no idea on why it isn't happenning on Dev. 35 | 36 | ## 1.3.1 - 2022-01-19 37 | 38 | - Trying to fix #33 by setting specific dependencies version. Strangely, this error doesn't happen on dev. 39 | 40 | ## 1.3.0 - 2021/08/03 41 | 42 | - Added support to create repositories for organizations the user has permission to. 43 | - Organization repositories are now alphabetically ordered. 44 | - Create repository and reload buttons now will only show up if user is logged. 45 | 46 | ## 1.2.0 - 2021/08/03 47 | 48 | - Added "Create Repository for Current Files", our "Publish to GitHub". Finally, after more than a year, had the time, the will and the hacky ideas to have it working! [#2](https://github.com/SrBrahma/GitHub-Repository-Manager/issues/2) Thanks [hediet](https://github.com/hediet) for the idea and the patience! 😅 49 | 50 | ## 1.1.0 - 2021/07/25 51 | 52 | - Fixed repositories without remote HEAD (new repositories, empty) being marked as dirty. 53 | - Dirtiness may now be marked as `E`, for errors. 54 | - Fixed master/main issue. 55 | - Fixed [#22](https://github.com/SrBrahma/GitHub-Repository-Manager/issues/22) 56 | - Refactor: Removed mz and rimraf packages. Added fs-extra and execa. 57 | 58 | ## 1.0.1 - 2021/07/23 59 | 60 | - Fixed `directoriesToIgnore` setting not working. 61 | - Removed `"clonedRepositoriesSearch."` section from `"githubRepositoryManager.clonedRepositoriesSearch.defaultCloneDirectoryMaximumDepth"` and `"githubRepositoryManager.clonedRepositoriesSearch.directoriesToIgnore"` settings. 62 | 63 | - Removed excess keywords in package.json 64 | 65 | # 1.0.0 - 2021/07/23 66 | 67 | - Added the new VS Code GitHub Authentication. (!) 68 | - Added dirty repository indicator ([#16](https://github.com/SrBrahma/GitHub-Repository-Manager/issues/16)). Being ok and validated, later I will add a 'Delete' context menu option to non-dirty cloned repos. Please report errors. 69 | - Repositories in Tree View are now colored. There is a new option to turn that off and use the old monochromatic style. 70 | - Now using `main` instead of `master` [#18](https://github.com/SrBrahma/GitHub-Repository-Manager/issues/18) 71 | - Fixed `Fork of` in repository tooltip not being bold. 72 | - Fixed the Account tree view displaying a "not logged" view at the initial credentials load. Now shows a "Loading...", as happens while logging in. 73 | - Now shows local path of the cloned repository in its tooltip. 74 | - Removed `showRepositoryCommandsIcons` option. If you used that, tell me! 75 | - Removed the OAuth login system. 76 | - Removed the manual token login system. 77 | - Removed the save token setting as it is no longer used. 78 | - Code big rewrite. 79 | - Fixed some minor bugs / exceptions. 80 | 81 | ## 0.6.1 - Feb 03, 2021 82 | 83 | - Fixed [#15](https://github.com/SrBrahma/GitHub-Repository-Manager/issues/15). 84 | - Fixed [#21](https://github.com/SrBrahma/GitHub-Repository-Manager/issues/21). Thanks for the issue, [xCONFLiCTiONx](https://github.com/xCONFLiCTiONx)! 85 | - Updated dependency packages. 86 | 87 | ## 0.6.0 - Aug 29, 2020 88 | 89 | - Fixed breaking bug in Windows. It was not allowing opening the cloned repos. (in [#13](https://github.com/SrBrahma/GitHub-Repository-Manager/issues/13), but isn't actually the original error in #13. #13 looks a VsCode bug in revealFileInOs using the current focused file instead of the provided file path (https://github.com/microsoft/vscode/issues/87804).) 90 | 91 | Sorry, Windows users. It scares me when I think about how much time it has been happening. I will also fix the ugly repository tooltip spacing. 92 | 93 | - Removed the cloned repository "Open Containing Directory" icon for now until VsCode fixes the API (https://github.com/microsoft/vscode/issues/105666). It can still be accessed with the right-click menu. 94 | 95 | - Renamed aux.ts files to utils.ts. Windows doesn't allow 'aux' files. 96 | 97 | - Fixed infinite 'Loading...' on error ([#12](https://github.com/SrBrahma/GitHub-Repository-Manager/issues/12)). Also, if "git.defaultCloneDirectory" is not set, a message will be displayed under Cloned tree view. 98 | 99 | - Fixed some other minor stuff 100 | 101 | ## 0.5.0 - Aug 06, 2020 102 | 103 | Added support to organizations repositories ([#10](https://github.com/SrBrahma/GitHub-Repository-Manager/pull/10))! You will need to re-OAuth again or add the 'org:read' to your Personal Acess Token permissions. 104 | 105 | Many thanks to [jonathan-fielding](https://github.com/jonathan-fielding) for this feature! 106 | 107 | ## 0.4.0 - Jul 08, 2020 108 | 109 | Added 'one click to clone' setting ([#7](https://github.com/SrBrahma/GitHub-Repository-Manager/pull/7)) 110 | 111 | Added support to SSH cloned repositories to be found ([#9](https://github.com/SrBrahma/GitHub-Repository-Manager/pull/9)) 112 | 113 | Thanks to [jonathan-fielding](https://github.com/jonathan-fielding) for both pull requests! 114 | 115 | ## 0.3.6 - May 22, 2020 -> Today was the day of quick and small changes. 116 | 117 | Reworked the Repositories Tree View Item Tooltip. Looks better now. Unfortunatelly, the ":" aren't perfectly alignable, as the font is not monospaced. Yeah, it annoys me too. We have to accept it!! 118 | 119 | 0.3.6 120 | 121 | ## 0.3.5 - May 22, 2020 122 | 123 | Clone command seems to be fully fixed. Now, on repository commit, it will have "master" as the default destination. 124 | 125 | ## 0.3.4 - May 22, 2020 126 | 127 | Quick fix on Activity Bar name. It was still being called GitHub Repository Loader (early name) instead of Manager. 128 | 129 | ## 0.3.3 - May 22, 2020 130 | 131 | Fixed private repositories not showing as private. 132 | 133 | Added "Created" and "Updated" dates on repository tooltip. 134 | 135 | Fixed "Git fatal no configured push destination" on a cloned repository push 136 | 137 | ## 0.3.2 - May 16, 2020 138 | 139 | Added "Copy Repository URL" on repository right-click menu 140 | 141 | ## 0.3.1 - May 16, 2020 142 | 143 | Fixed donate button centering in Visual Studio Marketplace web page. 144 | 145 | ## 0.3.0 - May 15, 2020 146 | 147 | 0.3.0 148 | 149 | Added Show Repository Commands Icons and a setting for it. ([#3](https://github.com/SrBrahma/GitHub-Repository-Manager/issues/3)) 150 | 151 | Thanks for the idea, hediet! 152 | 153 | ## 0.2.7 - May 12, 2020 154 | 155 | Fixed username.github.io repositories not being found by the Cloned Repository Searcher. Thanks, u/tHeSiD! 156 | 157 | ## 0.2.1 ~ 0.2.6 - May 11, 2020 158 | 159 | Quick README fixes. 160 | 161 | Logo updated again. I looked at my screen while I was a little slid in the chair and I got this idea of the gradient. "Blue gradient. Nice. Now let's add pink or purple." and I kept moving two linear gradients and changing its colors until it looked nice on both of my monitors. lol 162 | 163 | ## 0.2.0 - May 11, 2020 164 | 165 | Fixed Cloned Repositories Search not working with some remotes. 166 | 167 | Changed project logo. 168 | 169 | Improved README. 170 | 171 | - Added usage .gif (that took my time to get it "ok"!) 172 | 173 | Added error message if the extension can't open the OAuth callback server. 174 | 175 | Changed REST requests to Graphql. Reduces network usage, reduces time to retrieve the data and also now shows the "Fork of" information when hovering a repository. 176 | 177 | ## 0.1.1 - May 6, 2020 178 | 179 | Quick fixes 180 | 181 | ## 0.1.0 - May 6, 2020 182 | 183 | First release 184 | 185 |
186 | 187 | # Donation 188 | 189 | Help me to keep and improve this project! 190 | 191 | [![](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=6P2HYMMC2VWMG) 192 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-repository-manager", 3 | "version": "1.6.1", 4 | "publisher": "henriqueBruno", 5 | "displayName": "GitHub Repository Manager", 6 | "description": "Easily and quickly clone and access your GitHub repositories and create new ones", 7 | "author": { 8 | "name": "Henrique Bruno", 9 | "email": "henrique.bruno.fa@gmail.com" 10 | }, 11 | "main": "./dist/extension.js", 12 | "engines": { 13 | "vscode": "^1.58.0" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/SrBrahma/GitHub-Repository-Manager" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/SrBrahma/GitHub-Repository-Manager/issues" 21 | }, 22 | "icon": "images/logo/logo128.png", 23 | "scripts": { 24 | "typecheck": "tsc --noEmit", 25 | "lint": "npm run typecheck && eslint --ext js,ts .", 26 | "lint:fix": "npm run lint --fix", 27 | "vscode:prepublish": "webpack --config webpack/webpack.config.prod.js", 28 | "publish": "vsce publish", 29 | "webpack-dev": "webpack --config webpack/webpack.config.dev.js --watch", 30 | "watch": "npm run webpack-dev", 31 | "start": "npm run watch" 32 | }, 33 | "categories": [ 34 | "Other", 35 | "SCM Providers" 36 | ], 37 | "activationEvents": [ 38 | "onStartupFinished" 39 | ], 40 | "license": "MIT", 41 | "keywords": [ 42 | "github", 43 | "repository", 44 | "manager", 45 | "repo", 46 | "git" 47 | ], 48 | "dependencies": { 49 | "@octokit/core": "3.5.1", 50 | "@octokit/rest": "18.12.0", 51 | "execa": "5.1.1", 52 | "fs-extra": "10.0.1", 53 | "git-url-parse": "11.6.0", 54 | "globby": "11.1.0" 55 | }, 56 | "devDependencies": { 57 | "@types/fs-extra": "9.0.13", 58 | "@types/git-url-parse": "^9.0.1", 59 | "@types/node": "^17.0.21", 60 | "@types/vscode": "1.58.0", 61 | "clean-webpack-plugin": "^4.0.0", 62 | "eslint-config-gev": "2.47.1", 63 | "fork-ts-checker-webpack-plugin": "^6.5.0", 64 | "ts-loader": "^9.2.7", 65 | "typescript": "^4.8.4", 66 | "vsce": "2.13.0", 67 | "webpack": "^5.69.1", 68 | "webpack-cli": "^4.9.2", 69 | "webpack-merge": "^5.8.0" 70 | }, 71 | "contributes": { 72 | "colors": [ 73 | { 74 | "id": "githubRepositoryManager.private", 75 | "description": "Color for private repositories icons in the tree view.", 76 | "defaults": { 77 | "dark": "#d4c964", 78 | "light": "#d4c964", 79 | "highContrast": "contrastBorder" 80 | } 81 | }, 82 | { 83 | "id": "githubRepositoryManager.public", 84 | "description": "Color for private repositories icons in the tree view.", 85 | "defaults": { 86 | "dark": "#7bbb7b", 87 | "light": "#7bbb7b", 88 | "highContrast": "contrastBorder" 89 | } 90 | }, 91 | { 92 | "id": "githubRepositoryManager.fork", 93 | "description": "Color for private repositories icons in the tree view.", 94 | "defaults": { 95 | "dark": "#64c8e9", 96 | "light": "#64c8e9", 97 | "highContrast": "contrastBorder" 98 | } 99 | } 100 | ], 101 | "configuration": { 102 | "title": "GitHub Repository Manager", 103 | "properties": { 104 | "githubRepositoryManager.alwaysCloneToDefaultDirectory": { 105 | "type": "boolean", 106 | "default": false, 107 | "description": "Always clone to the directory specified in \"git.defaultCloneDirectory\"." 108 | }, 109 | "githubRepositoryManager.defaultCloneDirectoryMaximumDepth": { 110 | "minimum": 0, 111 | "default": 2, 112 | "type": "integer", 113 | "description": "How deep on \"git.defaultCloneDirectory\" the cloned repositories will be searched. A depth of 0 means it will only search in the directory itself, a depth of 3 means it will search up to 3 directories below." 114 | }, 115 | "githubRepositoryManager.directoriesToIgnore": { 116 | "type": "array", 117 | "default": [ 118 | "node_modules", 119 | ".vscode", 120 | ".git/*", 121 | "logs", 122 | "images", 123 | "src", 124 | "lib", 125 | "out", 126 | "build", 127 | "etc", 128 | "public" 129 | ], 130 | "description": "Directories names that our cloned repositories searcher will ignore." 131 | } 132 | } 133 | }, 134 | "viewsContainers": { 135 | "activitybar": [ 136 | { 137 | "id": "githubRepoMgr", 138 | "title": "GitHub Repository Manager", 139 | "icon": "images/logo/logoVS.png" 140 | } 141 | ] 142 | }, 143 | "viewsWelcome": [ 144 | { 145 | "view": "githubRepoMgr.views.account", 146 | "contents": "You are not yet logged in\n[Login with your GitHub account](command:githubRepoMgr.commands.auth.vscodeAuth)", 147 | "when": "githubRepoMgr.userState == 'notLogged'" 148 | } 149 | ], 150 | "views": { 151 | "githubRepoMgr": [ 152 | { 153 | "id": "githubRepoMgr.views.account", 154 | "name": "Account" 155 | }, 156 | { 157 | "id": "githubRepoMgr.views.repositories", 158 | "name": "Repositories" 159 | } 160 | ] 161 | }, 162 | "commands": [ 163 | { 164 | "title": "Create Repository", 165 | "command": "githubRepoMgr.commands.repos.createRepo", 166 | "icon": "$(repo-create)" 167 | }, 168 | { 169 | "title": "Create Repository for Current Project", 170 | "command": "githubRepoMgr.commands.repos.publish", 171 | "icon": "$(repo-push)" 172 | }, 173 | { 174 | "title": "Reload Repositories", 175 | "command": "githubRepoMgr.commands.repos.reload", 176 | "icon": "$(refresh)" 177 | }, 178 | { 179 | "command": "githubRepoMgr.commands.repos.openWebPage", 180 | "title": "Open GitHub Page", 181 | "icon": "$(globe)" 182 | }, 183 | { 184 | "command": "githubRepoMgr.commands.repos.copyRepositoryUrl", 185 | "title": "Copy Repository URL" 186 | }, 187 | { 188 | "command": "githubRepoMgr.commands.clonedRepos.open", 189 | "title": "Open" 190 | }, 191 | { 192 | "command": "githubRepoMgr.commands.clonedRepos.openInNewWindow", 193 | "title": "Open in New Window", 194 | "icon": "$(multiple-windows)" 195 | }, 196 | { 197 | "command": "githubRepoMgr.commands.clonedRepos.addToWorkspace", 198 | "title": "Add to Workspace" 199 | }, 200 | { 201 | "command": "githubRepoMgr.commands.clonedRepos.openContainingFolder", 202 | "title": "Open Containing Folder", 203 | "icon": "$(symbol-folder)" 204 | }, 205 | { 206 | "command": "githubRepoMgr.commands.clonedRepos.copyPath", 207 | "title": "Copy Path" 208 | }, 209 | { 210 | "command": "githubRepoMgr.commands.notClonedRepos.cloneTo", 211 | "title": "Clone to" 212 | }, 213 | { 214 | "command": "githubRepoMgr.commands.clonedRepos.setAsFavorite", 215 | "title": "Set as Favorite", 216 | "icon": "$(star)" 217 | }, 218 | { 219 | "command": "githubRepoMgr.commands.clonedRepos.unsetAsFavorite", 220 | "title": "Unset as Favorite", 221 | "icon": "$(star-full)" 222 | }, 223 | { 224 | "command": "githubRepoMgr.commands.clonedRepos.delete", 225 | "title": "Delete" 226 | } 227 | ], 228 | "menus": { 229 | "view/title": [ 230 | { 231 | "command": "githubRepoMgr.commands.repos.publish", 232 | "when": "view == githubRepoMgr.views.repositories && githubRepoMgr.userState == 'logged' && githubRepoMgr.canPublish == true", 233 | "group": "navigation@1" 234 | }, 235 | { 236 | "command": "githubRepoMgr.commands.repos.createRepo", 237 | "when": "view == githubRepoMgr.views.repositories && githubRepoMgr.userState == 'logged'", 238 | "group": "navigation@2" 239 | }, 240 | { 241 | "command": "githubRepoMgr.commands.repos.reload", 242 | "when": "view == githubRepoMgr.views.repositories && githubRepoMgr.userState == 'logged'", 243 | "group": "navigation@3" 244 | } 245 | ], 246 | "view/item/context": [ 247 | { 248 | "command": "githubRepoMgr.commands.repos.openWebPage", 249 | "when": "viewItem =~ /githubRepoMgr\\.context\\.(not)?clonedRepo/i", 250 | "group": "9@0" 251 | }, 252 | { 253 | "command": "githubRepoMgr.commands.repos.copyRepositoryUrl", 254 | "when": "viewItem =~ /githubRepoMgr\\.context\\.(not)?clonedRepo/i", 255 | "group": "9@1" 256 | }, 257 | { 258 | "command": "githubRepoMgr.commands.clonedRepos.open", 259 | "when": "viewItem == githubRepoMgr.context.clonedRepo", 260 | "group": "0@0" 261 | }, 262 | { 263 | "command": "githubRepoMgr.commands.clonedRepos.openInNewWindow", 264 | "when": "viewItem == githubRepoMgr.context.clonedRepo", 265 | "group": "0@1" 266 | }, 267 | { 268 | "command": "githubRepoMgr.commands.clonedRepos.addToWorkspace", 269 | "when": "viewItem == githubRepoMgr.context.clonedRepo", 270 | "group": "0@2" 271 | }, 272 | { 273 | "command": "githubRepoMgr.commands.clonedRepos.openContainingFolder", 274 | "when": "viewItem == githubRepoMgr.context.clonedRepo", 275 | "group": "1@0" 276 | }, 277 | { 278 | "command": "githubRepoMgr.commands.clonedRepos.copyPath", 279 | "when": "viewItem == githubRepoMgr.context.clonedRepo", 280 | "group": "1@1" 281 | }, 282 | { 283 | "command": "githubRepoMgr.commands.clonedRepos.delete", 284 | "when": "viewItem == githubRepoMgr.context.clonedRepo", 285 | "group": "1@2" 286 | }, 287 | { 288 | "command": "githubRepoMgr.commands.notClonedRepos.cloneTo", 289 | "when": "viewItem == githubRepoMgr.context.notClonedRepo", 290 | "group": "0@0" 291 | }, 292 | { 293 | "command": "githubRepoMgr.commands.clonedRepos.setAsFavorite", 294 | "when": "false && TODO", 295 | "group": "inline@0" 296 | }, 297 | { 298 | "command": "githubRepoMgr.commands.clonedRepos.openInNewWindow", 299 | "when": "viewItem == githubRepoMgr.context.clonedRepo", 300 | "group": "inline@1" 301 | }, 302 | { 303 | "command": "githubRepoMgr.commands.clonedRepos.openContainingFolder", 304 | "when": "viewItem == githubRepoMgr.context.clonedRepo", 305 | "group": "inline@2" 306 | }, 307 | { 308 | "command": "githubRepoMgr.commands.repos.openWebPage", 309 | "when": "viewItem =~ /githubRepoMgr\\.context\\.(not)?clonedRepo/i", 310 | "group": "inline@5" 311 | } 312 | ] 313 | } 314 | } 315 | } 316 | --------------------------------------------------------------------------------