diff --git a/.gitignore b/.gitignore index 9a28bffee..6553a67c8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ lib dist out node_modules +.idea +.yalc +*.log diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..c4531b45e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "extensions/vscode-pull-request-github"] + path = extensions/vscode-pull-request-github + url = https://github.com/ByteLegend/vscode-pull-request-github.git diff --git a/extensions/github1s/assets/bytelegend.png b/extensions/github1s/assets/bytelegend.png new file mode 100644 index 000000000..ccbd45e12 Binary files /dev/null and b/extensions/github1s/assets/bytelegend.png differ diff --git a/extensions/github1s/package.json b/extensions/github1s/package.json index 2a589bc57..637cb956a 100644 --- a/extensions/github1s/package.json +++ b/extensions/github1s/package.json @@ -33,29 +33,40 @@ "id": "github1s", "title": "GitHub1s", "icon": "assets/github1s.svg" + }, + { + "id": "bytelegend", + "title": "ByteLegend", + "icon": "assets/bytelegend.png" + }, + { + "id": "tutorials", + "title": "Tutorials", + "icon": "$(library)" } ] }, "views": { - "github1s": [ + "bytelegend": [ { - "id": "github1s.views.settings", - "name": "Settings", - "type": "webview" + "id": "bytelegend.views.my-answer-list", + "name": "My Answers" } ], - "scm": [ + "tutorials": [ { - "id": "github1s.views.pull-request-list", - "name": "Pull Requests" - }, - { - "id": "github1s.views.commit-list", - "name": "Commits" + "id": "bytelegend.views.tutorials", + "name": "Tutorials", + "type": "webview" } ] }, "commands": [ + { + "command": "bytelegend.openOnGitHub", + "title": "Open on GitHub", + "icon": "$(link-external)" + }, { "command": "github1s.dev-test", "title": "GitHub1s Dev Test", @@ -174,32 +185,6 @@ "icon": "$(arrow-right)", "enablement": "resource =~ /^[^?]*\\?[^#]*(%26|\\b)hasNextRevision(=|%3D|%3d)true/" }, - { - "command": "github1s.toggle-editor-gutter-blame", - "title": "Toggle File Blame", - "category": "GitHub1s", - "enablement": "!isInDiffEditor && resourceScheme =~ /^github1s$/" - }, - { - "command": "github1s.open-editor-gutter-blame", - "title": "Toggle File Blame", - "category": "GitHub1s", - "icon": { - "dark": "assets/icons/dark/open-blame.svg", - "light": "assets/icons/light/open-blame.svg" - }, - "enablement": "!isInDiffEditor && resourceScheme =~ /^github1s$/" - }, - { - "command": "github1s.close-editor-gutter-blame", - "title": "Toggle File Blame", - "category": "GitHub1s", - "icon": { - "dark": "assets/icons/dark/close-blame.svg", - "light": "assets/icons/light/close-blame.svg" - }, - "enablement": "!isInDiffEditor && resourceScheme =~ /^github1s$/" - }, { "command": "github1s.open-on-github", "title": "Open on GitHub", @@ -316,14 +301,6 @@ { "command": "github1s.editor-view-open-next-revision", "when": "false" - }, - { - "command": "github1s.open-editor-gutter-blame", - "when": "false" - }, - { - "command": "github1s.close-editor-gutter-blame", - "when": "false" } ], "view/title": [ @@ -349,6 +326,11 @@ } ], "view/item/context": [ + { + "command": "bytelegend.openOnGitHub", + "when": "viewItem == MyAnswer", + "group": "inline@1" + }, { "command": "github1s.pull-view-item-switch-to-pull", "when": "viewItem =~ /^github1s:pull-request$/", @@ -386,16 +368,6 @@ "when": "isInDiffEditor", "group": "navigation@3" }, - { - "command": "github1s.open-editor-gutter-blame", - "when": "!isInDiffEditor && resourceScheme =~ /^github1s$/ && !github1s.context.gutterBlameOpening", - "group": "navigation@4" - }, - { - "command": "github1s.close-editor-gutter-blame", - "when": "!isInDiffEditor && resourceScheme =~ /^github1s$/ && github1s.context.gutterBlameOpening", - "group": "navigation@4" - }, { "command": "github1s.editor-view-open-prev-revision", "when": "resourceScheme =~ /^github1s/", @@ -429,7 +401,7 @@ "p-finally": "^2.0.1", "process": "^0.11.10", "query-string": "^7.0.0", - "webpack-cli": "^4.6.0" + "webpack-cli": "^4.9.1" }, "devDependencies": { "@types/vscode": "1.55.0", diff --git a/extensions/github1s/src/bytelegend/bytelegendContext.ts b/extensions/github1s/src/bytelegend/bytelegendContext.ts new file mode 100644 index 000000000..0ae0cc25c --- /dev/null +++ b/extensions/github1s/src/bytelegend/bytelegendContext.ts @@ -0,0 +1,529 @@ +import { AnswerCommit, PullRequestAnswer } from '@/bytelegend/entities'; +import { MyAnswerTreeDataProvider } from '@/bytelegend/my-answer-list-view'; +import * as vscode from 'vscode'; +import { TreeItem } from 'vscode'; +import { ByteLegendLogManager } from '@/bytelegend/bytelegendLogManager'; +import router from '@/router'; +import { initialVSCodeState } from '@/extension'; +import { runCatching } from '@/bytelegend/utils'; +import { TutorialsView } from '@/bytelegend/tutorials-view'; +import repository from '@/repository'; +import { RepositoryRef } from '@/repository/types'; + +/** + * Router states: + * + * 1. Upon open, if there's any PR open, switch to that PR. Otherwise, use main. + * 2. Upon submitting success: + * - if the latestOpenPR is marked as successful, do nothing (or "restart the webeditor to create next answer") + * - if a new PR is created, switch to that branch. + */ +export class ByteLegendContext { + readonly answerTreeDataProvider: MyAnswerTreeDataProvider = new MyAnswerTreeDataProvider( + this + ); + private logManager = new ByteLegendLogManager(this); + private _initData: any; + private _activePullRequestUrl: string; + /** + * The sha of main branch when the web editor is opened, or "origin/main" as a fallback. + * This is used as "baseRef" when creating new pull requests. + */ + private mainBranchSha: string; + + async init(initData: any) { + this._initData = initData; + this.mainBranchSha = await this.determineMainBranchSha(); + await vscode.commands.executeCommand( + 'bytelegend.postMessageToParentWindow', + { + bytelegendEvent: 'webeditor.init.completed', + } + ); + if (this._initData) { + await this.updateAnswers(this._initData.answers); + const anyLiveLogs = + this._initData.liveLogs && this._initData.liveLogs.length > 0; + const anyUnfinished = this.answerTreeDataProvider.answers.find( + (answer) => !answer.accomplished + ); + + if (anyLiveLogs || anyUnfinished) { + await this.focusOnMyAnswerView(); + } + + if (anyLiveLogs) { + this._initData.liveLogs.forEach((liveLog) => { + this.logManager + .getOrCreateLog(liveLog.id, this.determineLogName(liveLog.id), true) + .appendLines(liveLog.logs); + }); + await this.showAnswerLog(this._initData.liveLogs[0].id); + } + } + } + + private async determineMainBranchSha() { + const branches: RepositoryRef[] = await repository.getBranches(); + return ( + branches.find((branch) => { + return branch?.name === 'main'; + })?.object?.sha || 'origin/main' + ); + } + + // Adapt a Kotlin `PullRequestAnswer` to TypeScript `PullRequestAnswer` + private toTypeScriptPullRequestAnswer(jsonObj: any): PullRequestAnswer { + const checkRunSha = new Set(); + const checkRuns = jsonObj.checkRuns; + + // First step: there might be more than 1 check runs for same commit, remove the duplicate commits + const deduplicatedCheckRuns = checkRuns.filter((item) => { + if (checkRunSha.has(item.sha)) { + return false; + } else { + checkRunSha.add(item.sha); + return true; + } + }); + + // Second step: scan the list to get all commits. + const answerCommits: AnswerCommit[] = deduplicatedCheckRuns.map( + (item, index, array) => + this.toAnswerCommit( + item, + index === array.length - 1 ? null : array[index + 1].sha + ) + ); + return new PullRequestAnswer( + this.getI18nText( + 'MyAnswerAt', + this.formatIso8601(jsonObj.lastUpdatedTime) + ), + jsonObj.baseRepoFullName, + jsonObj.headRepoFullName, + jsonObj.number, + jsonObj.branch, + jsonObj.lastUpdatedTime, + jsonObj.open, + jsonObj.accomplished, + answerCommits + ); + } + + // Adapt a Kotlin CheckRun to TypeScript `AnswerCommit` + private toAnswerCommit(jsonObj: any, parentSha?: string): AnswerCommit { + return new AnswerCommit( + this.getI18nText( + 'CommitAt', + jsonObj.sha.substring(0, 7), + this.formatIso8601(jsonObj.time) + ), + jsonObj.sha, + jsonObj.id, + jsonObj.time, + jsonObj.conclusion, + parentSha + ); + } + + private formatIso8601(iso8601: string): string { + return new Date(iso8601).toLocaleString(this.locale); + } + + // get current repo owner + public getOwner() { + const pathname = router.history.location.pathname; + return pathname.split('/').filter(Boolean)[0] || 'ByteLegend'; + } + + // get current repo name + public getRepo() { + const pathname = router.history.location.pathname; + return pathname.split('/').filter(Boolean)[1] || 'ByteLegend'; + } + + // View name to focus on startup. Can be "explorer", "tutorials", "MyAnswer" + get initFocusView(): string { + return this._initData?.initFocusView; + } + + // Init readme to be displayed. Can be: + // 1. https://github.com/owner/repo/blob/main/path/to.md + // 2. https://raw.githubusercontent.com/owner/repo/main/path/to.md + // 3. Raw HTML string + get initReadme(): string { + return this._initData?.initReadme; + } + + get tutorialsPrice(): number { + return this._initData?.tutorialsPrice; + } + + get showActivityBar(): boolean { + return this._initData?.showActivityBar !== false; + } + + get locale(): string { + return this._initData?.locale || 'en'; + } + + get localeName(): string { + return this._initData?.localeName || 'English'; + } + + get missionId(): string { + return this._initData?.missionId; + } + + get challengeId(): string { + return this._initData.challengeId; + } + + get repoFullName(): string { + return this._initData.repoFullName; + } + + get apiServer(): string { + return this._initData?.apiServer || 'https://bytelegend.com'; + } + + get githubApiBaseUrl(): string { + return this._initData?.githubApiBaseUrl || 'https://bytelegend.com/ghapi'; + } + + get whitelist(): string { + return this._initData.whitelist; + } + + get latestOpenPullRequestHtmlUrl(): string { + return this.answerTreeDataProvider.answers.find((pr) => pr.open)?.htmlUrl; + } + + getI18nText(key: string, ...args: string[]): string { + if (!this._initData) { + return key; + } + let template = this._initData.i18nTexts[key]; + args.forEach( + (arg, index) => (template = template.replace(`{${index}}`, arg)) + ); + return template; + } + + async updateAnswers(answers: any[]) { + const oldAnswers = this.answerTreeDataProvider.answers; + const newAnswers = answers + .map((answer) => this.toTypeScriptPullRequestAnswer(answer)) + // don't show forked PRs, because we might not able to push (player doesn't allow us) + .filter((answer) => answer.baseRepoFullName === answer.headRepoFullName); + + if ( + oldAnswers.length > 0 && + newAnswers.length > 0 && + oldAnswers[0].id === newAnswers[0].id && + oldAnswers[0].commits.length > 0 && + newAnswers[0].commits.length === 0 + ) { + // when the PR is just created, check runs haven't started yet. + // otherwise we'll get an empty commit list + return; + } + + if (this._activePullRequestUrl) { + const oldAnswer = oldAnswers.find( + (a) => a.id === this._activePullRequestUrl + ); + const newAnswer = newAnswers.find( + (a) => a.id === this._activePullRequestUrl + ); + if ( + oldAnswer?.commits?.length !== newAnswer?.commits?.length || + (oldAnswer?.commits?.length !== 0 && + newAnswer?.commits?.length !== 0 && + oldAnswer.commits[0].conclusion !== newAnswer.commits[0].conclusion) + ) { + // Something has changed. We need to refresh. + vscode.commands.executeCommand( + 'pr.refreshPullRequest', + this._activePullRequestUrl + ); + } + } + + await this.answerTreeDataProvider.updateTree(newAnswers); + } + + async appendLog(checkRunId: string, lines: string[]) { + this.logManager.clearAllPendingStatuses(); + await this.showTerminal(); + await this.logManager + .getOrCreateLog(checkRunId, this.determineLogName(checkRunId), true) + .appendLines(lines); + } + + private async showTerminal() { + if ( + !(await vscode.commands.executeCommand('bytelegend.isTerminalVisible')) + ) { + await vscode.commands.executeCommand( + 'workbench.action.terminal.toggleTerminal' + ); + } + } + + async openPrDescription(prHtmlUrl: string): Promise { + this._activePullRequestUrl = prHtmlUrl; + await vscode.commands.executeCommand('pr.openDescription', prHtmlUrl); + } + + async showAnswerLog(treeIdOrItem: TreeItem | string): Promise { + await this.showTerminal(); + const nodeId = + typeof treeIdOrItem === 'string' + ? treeIdOrItem + : (treeIdOrItem as TreeItem).id; + + const node = this.answerTreeDataProvider.getNodeById(nodeId); + if (node instanceof AnswerCommit) { + await this.logManager.showCheckRunLog( + node.checkRunId, + this.determineLogName(node.checkRunId), + !node.conclusion + ); + } else if (node instanceof PullRequestAnswer) { + const commit = node.commits[0]; + await this.logManager.showCheckRunLog( + commit.checkRunId, + this.determineLogName(commit.checkRunId), + !commit.conclusion + ); + } else { + console.log(`Skip unrecognized answer: ${nodeId}`); + } + } + + private determineLogName(logId: string) { + return ( + this.answerTreeDataProvider.getNodeByCheckRunId(logId)?.title || logId + ); + } + + private static async setSubmitAnswerButton( + spinning: boolean, + textId: string + ) { + await vscode.commands.executeCommand( + 'bytelegend.postMessageToParentWindow', + { + bytelegendEvent: 'answer.button.control', + bytelegendEventPayload: { + spinning, + textId, + }, + } + ); + } + + private inWhitelist(changedFilePath: string): boolean { + for (const item of this.whitelist) { + if (item.endsWith('/') && changedFilePath.startsWith(item)) { + return true; + } else if (item === changedFilePath) { + return true; + } + } + return false; + } + + async submitAnswer() { + const dirtyDocs = vscode.workspace.textDocuments.filter((doc) => { + return doc.isDirty; + }); + + if ( + !new RegExp(`${this.repoFullName}/(tree|blob)/main`).test( + await ByteLegendContext.getBrowserUrl() + ) && + !this.latestOpenPullRequestHtmlUrl + ) { + vscode.window.showInformationMessage( + this.getI18nText('YouHaveAccomplishedThisChallenge') + ); + ByteLegendContext.setSubmitAnswerButton(false, 'SubmitAnswer'); + return; + } + + if (dirtyDocs.length === 0) { + vscode.window.showInformationMessage(this.getI18nText('NothingToSubmit')); + ByteLegendContext.setSubmitAnswerButton(false, 'SubmitAnswer'); + } else { + const changedFiles = {}; + const changeFilePathsNotInWhitelist = []; + + dirtyDocs.forEach((doc) => { + // github1s:/src/main/java/com/bytelegend/Challenge.java + const filePath = doc.uri.toString().substring('github1s:/'.length); + if (!this.inWhitelist(filePath)) { + changeFilePathsNotInWhitelist.push(filePath); + } + changedFiles[filePath] = doc.getText(); + }); + + if (changeFilePathsNotInWhitelist.length !== 0) { + vscode.window.showErrorMessage( + this.getI18nText( + 'ChangesAreNotAllowed', + changeFilePathsNotInWhitelist.join('\n') + ) + ); + ByteLegendContext.setSubmitAnswerButton(false, 'SubmitAnswer'); + return; + } + + const payload = { + changes: changedFiles, + }; + + if (this.latestOpenPullRequestHtmlUrl) { + payload['pullRequestHtmlUrl'] = this.latestOpenPullRequestHtmlUrl; + } else { + payload['baseRef'] = this.mainBranchSha; + } + + const pendingStatusLog = this.logManager.showPendingStatus( + this.getI18nText('SubmittingAnswer') + ); + + const [err, response] = await runCatching( + this.invokeApi( + 'POST', + `/game/api/mission/${this.missionId}/${this.challengeId}/code`, + payload + ) + ); + if (err) { + pendingStatusLog.stopPendingStatus(); + vscode.window.showErrorMessage(err.toString()); + ByteLegendContext.setSubmitAnswerButton(false, 'SubmitAnswer'); + } else if (response.status > 299) { + pendingStatusLog.stopPendingStatus(); + vscode.window.showErrorMessage( + this.getI18nText('HttpResponseError'), + response.status.toString(), + await response.text() + ); + ByteLegendContext.setSubmitAnswerButton(false, 'SubmitAnswer'); + } else { + const newPullRequestAnswerInResponse = this.toTypeScriptPullRequestAnswer( + await response.json() + ); + + for (const doc of dirtyDocs) { + await doc.save(); + } + + const currentAnswers = this.answerTreeDataProvider.answers; + const samePullRequestAnswer = currentAnswers.find( + (item) => item.id === newPullRequestAnswerInResponse.id + ); + if (samePullRequestAnswer) { + samePullRequestAnswer.commits.unshift( + newPullRequestAnswerInResponse.commits[0] + ); + if (samePullRequestAnswer.htmlUrl == this._activePullRequestUrl) { + vscode.commands.executeCommand( + 'pr.refreshPullRequest', + this._activePullRequestUrl + ); + } + } else { + await this.switchToBranch(newPullRequestAnswerInResponse.branch); + this.openPrDescription(newPullRequestAnswerInResponse.htmlUrl); + currentAnswers.unshift(newPullRequestAnswerInResponse); + } + this.answerTreeDataProvider.updateTree(currentAnswers); + this.focusOnMyAnswerView(); + + ByteLegendContext.setSubmitAnswerButton(true, 'CheckingAnswer'); + } + } + } + + async focusOnMyAnswerView() { + await vscode.commands.executeCommand( + `${MyAnswerTreeDataProvider.viewId}.focus` + ); + } + + async focusOnTutorialsView() { + await vscode.commands.executeCommand(`${TutorialsView.viewId}.focus`); + } + + // {baseUrl}/{owner}/{repo}/blob/main/{path} -> {baseUrl}/{owner}/{repo}/blob/{newBranch}/{path} + // {baseUrl}/{owner}/{repo}/tree/main/{path}? -> {baseUrl}/{owner}/{repo}/tree/{newBranch}/{path} + // {baseUrl}/ByteLegendQuest/java-fix-add/tree/main -> {baseUrl}/ByteLegendQuest/java-fix-add/tree/{newBranch} + // {baseUrl}/ByteLegendQuest/java-fix-add/blob/main/src/main/java/com/bytelegend/Challenge.java -> + // {baseUrl}/ByteLegendQuest/java-fix-add/blob/{newBranch}/src/main/java/com/bytelegend/Challenge.java + private async switchToBranch(newBranch: string) { + const oldUrl = await ByteLegendContext.getBrowserUrl(); + const [newUrl, path] = this.replaceUrlWithNewBranch(oldUrl, newBranch); + console.log(`Switch to ${newUrl}`); + if (oldUrl === newUrl) { + console.warn(`Can't switch to ${newBranch}: ${oldUrl}`); + } else { + await router.replace(newUrl); + await initialVSCodeState(); + } + } + + private static async getBrowserUrl(): Promise { + return (await vscode.commands.executeCommand( + 'github1s.vscode.get-browser-url' + )) as string; + } + + private replaceUrlWithNewBranch(oldUrl: string, newBranch: string): string[] { + const regex = new RegExp(`/${this.repoFullName}/(tree|blob)/main(.*)`); + const matchResult = regex.exec(oldUrl); + + if (matchResult) { + return [ + `/${this.repoFullName}/${matchResult[1]}/${newBranch}${matchResult[2]}`, + matchResult[2], + ]; + } else { + return [oldUrl]; + } + } + + async invokeApi( + method: string, + path: string, + payload?: any + ): Promise { + const init: RequestInit = { + method, + credentials: 'include', + headers: { + // forcibly preflight CORS request + 'X-ByteLegend-From': 'github1s', + }, + }; + if (payload) { + init['headers']['Content-Type'] = 'application/json'; + init['body'] = JSON.stringify(payload); + } + const [err, response] = await runCatching( + fetch(`${this.apiServer}${path}`, init) + ); + if (err) { + vscode.window.showErrorMessage(err.stack); + return Promise.reject(err); + } else { + return response; + } + } +} + +export const byteLegendContext = new ByteLegendContext(); diff --git a/extensions/github1s/src/bytelegend/bytelegendLogManager.ts b/extensions/github1s/src/bytelegend/bytelegendLogManager.ts new file mode 100644 index 000000000..0c2fb20b2 --- /dev/null +++ b/extensions/github1s/src/bytelegend/bytelegendLogManager.ts @@ -0,0 +1,202 @@ +import * as vscode from 'vscode'; +import { Terminal } from 'vscode'; +import { ByteLegendContext } from '@/bytelegend/bytelegendContext'; +import { runCatching } from '@/bytelegend/utils'; + +export class ByteLegendLogManager { + /** + * Key: checkRunId + * Value: the log instance + */ + private readonly checkRunLogs: Map = new Map(); + private readonly context: ByteLegendContext; + + public constructor(context: ByteLegendContext) { + this.context = context; + } + + getOrCreateLog(logId: string, logName: string, live: boolean): CheckRunLog { + if (!this.checkRunLogs.has(logId)) { + this.checkRunLogs.set(logId, new CheckRunLog(logId, logName)); + } else { + const oldLog = this.checkRunLogs.get(logId); + if (oldLog.live && !live) { + // we have to create a new log + oldLog.dispose(); + this.checkRunLogs.set(logId, new CheckRunLog(logId, logName)); + } + } + + const log = this.checkRunLogs.get(logId); + + log.attachTerminalIfNecessary(); + return log; + } + + private static showFetching(log: CheckRunLog) { + setTimeout(() => {}, 1000); + } + + /** + * After submitting the answer, we don't know the checkRun id yet. + * During this interval, we create a dummy log displaying the pending status. + */ + showPendingStatus(line: string): CheckRunLog { + return this.getOrCreateLog( + `PendingStatus-${new Date().getTime()}`, + 'Log', + true + ) + .activate() + .startPendingStatus(line); + } + + clearAllPendingStatuses() { + this.checkRunLogs.forEach((checkRunLog, id) => { + if (id.startsWith('PendingStatus-')) { + checkRunLog.stopPendingStatus().dispose(); + this.checkRunLogs.delete(id); + } + }); + } + + async showCheckRunLog(checkRunId: string, logName: string, live: boolean) { + if (checkRunId.startsWith('DUMMY-CHECK-RUN')) { + // Do nothing, the new events will soon come in and replace the dummy check runs. + return; + } + let currentLog = this.checkRunLogs.get(checkRunId); + if (!currentLog || (currentLog.live && !live)) { + currentLog = this.getOrCreateLog(checkRunId, logName, live); + currentLog.activate(); + + currentLog.startPendingStatus( + this.context.getI18nText('FetchingLog', checkRunId) + ); + + const [err, logResponse] = await runCatching( + this.context.invokeApi( + 'GET', + `/game/api/log?repo=${this.context.repoFullName}&checkRunId=${checkRunId}` + ) + ); + + currentLog.stopPendingStatus(); + + if (err) { + // https://stackoverflow.com/questions/287871/how-to-print-colored-text-to-the-terminal + currentLog.appendLines(['\x1B[91m']); + currentLog.appendLines([err.toString()]); + currentLog.appendLines(['\x1B[0m']); + } else if (logResponse.status === 404 || logResponse.status === 410) { + currentLog.appendLines(['\x1B[91m']); + currentLog.appendLines([this.context.getI18nText('LogCleanedUp')]); + currentLog.appendLines(['\x1B[0m']); + } else if (logResponse.status > 399) { + currentLog.appendLines(['\x1B[91m']); + currentLog.appendLines((await logResponse.text()).split(/\r?\n/)); + currentLog.appendLines(['\x1B[0m']); + } else { + currentLog.appendLines((await logResponse.text()).split(/\r?\n/)); + } + } else { + currentLog.activate(); + } + } +} + +// Show a pending line: +// Fetching log from XX. +// Fetching log from XX.. +// Fetching log from XX... +class CheckRunLogPendingStatus { + private timer: any; + private currentDot: number = 0; + + constructor(private log: CheckRunLog, private readonly line: string) {} + + start(): CheckRunLogPendingStatus { + this.timer = setInterval(() => { + this.currentDot++; + this.log.appendLines([ + `\x1B[1A\x1B[K${this.line}${'.'.repeat((this.currentDot % 3) + 1)}`, + ]); + }, 500); + return this; + } + + stop() { + clearInterval(this.timer); + } +} + +class CheckRunLog { + private pendingStatus: CheckRunLogPendingStatus; + live: boolean; + private lines: string[] = []; + private terminal: Terminal; + // next line to output to the terminal + private terminalCursorIndex: number; + + constructor(readonly id: string, private name: string) {} + + startPendingStatus(line: string): CheckRunLog { + if (!this.pendingStatus) { + this.pendingStatus = new CheckRunLogPendingStatus(this, line); + } + this.pendingStatus.start(); + return this; + } + + stopPendingStatus(): CheckRunLog { + this.pendingStatus?.stop(); + return this; + } + + activate(): CheckRunLog { + this.attachTerminalIfNecessary(); + if (vscode.window.activeTerminal?.name !== this.name) { + vscode.window.terminals.find((t) => t.name === this.name)?.show(); + } + return this; + } + + attachTerminalIfNecessary() { + if (!this.terminal) { + this.terminal = vscode.window.createTerminal(this.name); + this.terminalCursorIndex = this.lines.length; + this.lines.forEach((line) => { + this.terminal.sendText(line, true); + }); + + const disposeFunction = this.terminal.dispose; + this.terminal.dispose = () => { + this.terminal = null; + this.terminalCursorIndex = 0; + disposeFunction(); + }; + } + } + + appendLines(lines: string[]) { + if (lines.length === 0) { + return; + } + this.lines.push(...lines); + + if (this.terminal) { + lines.forEach((line) => { + this.terminal.sendText(line, true); + }); + } + // User closed the terminal when live log is still running + // In this case we silently append to a new terminal + this.attachTerminalIfNecessary(); + } + + dispose() { + if (this.terminal) { + this.terminal.dispose(); + } + } +} diff --git a/extensions/github1s/src/bytelegend/commands.ts b/extensions/github1s/src/bytelegend/commands.ts new file mode 100644 index 000000000..df9b82093 --- /dev/null +++ b/extensions/github1s/src/bytelegend/commands.ts @@ -0,0 +1,161 @@ +import * as vscode from 'vscode'; +import { TreeItem, Uri } from 'vscode'; +import { getExtensionContext } from '@/helpers/context'; +import { PullRequestAnswer, Tutorial } from '@/bytelegend/entities'; +import { byteLegendContext } from '@/bytelegend/bytelegendContext'; +import { runCatching } from '@/bytelegend/utils'; +import { createVideoTutorial } from '@/bytelegend/video-tutorial-view'; +import { createRawHtmlWebview } from '@/bytelegend/raw-html-view'; + +const commands: { id: string; callback: (...args: any[]) => any }[] = [ + { id: 'bytelegend.updateAnswers', callback: updateAnswers }, + { id: 'bytelegend.open', callback: open }, + { id: 'bytelegend.openVideoTutorial', callback: openVideoTutorial }, + { id: 'bytelegend.showAnswerLog', callback: showAnswerLog }, + { + id: 'bytelegend.openPrDescriptionAndShowAnswerLog', + callback: openPrDescriptionAndShowAnswerLog, + }, + { id: 'bytelegend.openOnGitHub', callback: openOnGitHub }, + { id: 'bytelegend.appendLog', callback: appendLog }, + { id: 'bytelegend.submitAnswer', callback: submitAnswer }, + { id: 'bytelegend.getContext', callback: getContext }, +]; + +export function getContext() { + return byteLegendContext; +} + +export const registerByteLegendCommands = () => { + const context = getExtensionContext(); + + context.subscriptions.push( + ...commands.map((command) => + vscode.commands.registerCommand(command.id, command.callback) + ) + ); +}; + +async function openVideoTutorial(tutorial: Tutorial) { + try { + createVideoTutorial(tutorial); + } catch (e) { + console.trace(e); + } +} + +async function openRawHtml(html: string) { + try { + createRawHtmlWebview(html); + } catch (e) { + console.trace(e); + } +} + +// +// github1s:/path/to/file +// github1s://raw.githubusercontent.com/owner/repo/ref/path/to/file +// github1s://github.com/owner/repo/blob/ref/path/to/file +export async function open(urlOrRawHtml: string) { + if (urlOrRawHtml.startsWith('<')) { + await openRawHtml(urlOrRawHtml); + } else { + await runCatching(vscodeOpen(parseByteLegendUri(urlOrRawHtml))); + } +} + +async function vscodeOpen(uri: Uri) { + await vscode.commands.executeCommand('vscode.open', uri); +} + +// github1s:/path/to/file -> as it is +// github1s://github.com/owner/repo/blob/main/path/to/file -> Uri { authority="owner+repo+ref" } +// github1s://raw.githubusercontent.com/gradle/gradle/master/README.md -> Uri { authority="owner+repo+ref" } +function parseByteLegendUri(uri: string): Uri { + if (uri.startsWith('github1s://github.com')) { + const matchResult = /github.com\/([\w_-]+)\/([\w_-]+)\/blob\/([\w_.-]+)\/(.*)/.exec( + uri + ); + return buildByteLegendUri( + matchResult[1], + matchResult[2], + matchResult[3], + matchResult[4] + ); + } else if (uri.startsWith('github1s://raw.githubusercontent.com')) { + const matchResult = /raw.githubusercontent.com\/([\w_-]+)\/([\w_-]+)\/([\w_.-]+)\/(.*)/.exec( + uri + ); + return buildByteLegendUri( + matchResult[1], + matchResult[2], + matchResult[3], + matchResult[4] + ); + } else { + return Uri.parse(uri); + } +} + +function buildByteLegendUri( + owner: string, + repo: string, + ref: string, + relativePathToRoot: string +) { + const ret = Uri.parse(`github1s:/${relativePathToRoot}`); + if ( + repo === byteLegendContext.getRepo() && + owner === byteLegendContext.getOwner() + ) { + // the target url exists in current repo, let's just open it! + return ret; + } else { + return ret.with({ + authority: `${owner}+${repo}+${ref}`, + }); + } +} + +async function openOnGitHub(treeItem: TreeItem) { + if (treeItem.id.startsWith('https')) { + await open(treeItem.id); + } else { + // should be commit id + const commitId = treeItem.id; + const prAnswerId = ( + await byteLegendContext.answerTreeDataProvider.getParent(treeItem) + ).id; + const prAnswer = byteLegendContext.answerTreeDataProvider.getNodeById( + prAnswerId + ) as PullRequestAnswer; + await open( + `https://github.com/${prAnswer.headRepoFullName}/commit/${treeItem.id}` + ); + } +} + +async function openPrDescriptionAndShowAnswerLog(prHtmlUrl: string) { + await runCatching( + Promise.all([ + byteLegendContext.openPrDescription(prHtmlUrl), + byteLegendContext.showAnswerLog(prHtmlUrl), + ]) + ); +} + +async function showAnswerLog(nodeId: string) { + await runCatching(byteLegendContext.showAnswerLog(nodeId)); +} + +async function updateAnswers(answers: any[]) { + await runCatching(byteLegendContext.updateAnswers(answers)); +} + +async function submitAnswer() { + await runCatching(byteLegendContext.submitAnswer()); +} + +async function appendLog(checkRunId: any, lines: string[]) { + await runCatching(byteLegendContext.appendLog(checkRunId.toString(), lines)); +} diff --git a/extensions/github1s/src/bytelegend/entities.ts b/extensions/github1s/src/bytelegend/entities.ts new file mode 100644 index 000000000..621424405 --- /dev/null +++ b/extensions/github1s/src/bytelegend/entities.ts @@ -0,0 +1,147 @@ +import { TreeItem, TreeItemCollapsibleState, Uri } from 'vscode'; +import { CommitTreeDataProvider } from '@/views/commit-list-view'; + +const commitTreeDataProvider = new CommitTreeDataProvider(); + +export class AnswerCommit { + constructor( + readonly title: string, + readonly sha: string, + readonly checkRunId: string, + readonly time: string, + readonly conclusion?: string, + readonly parentSha?: string + ) {} + + get stale(): boolean { + const tenMinutesAgo = new Date( + new Date().getTime() - 10 * 60 * 1000 + ).toISOString(); + return this.time < tenMinutesAgo; + } + + async getChildren(): Promise { + const changedFiles = await commitTreeDataProvider.getCommitFileItems({ + author: { avatar_url: '', login: '' }, + commit: { author: { date: '', email: '', name: '' }, message: '' }, + sha: this.sha, + parents: [ + { + sha: this.parentSha, + }, + ], + }); + return changedFiles; + } + + get id() { + return this.sha; + } + + iconPath() { + if (!this.conclusion) { + return this.stale ? warningIcon : loadingIcon; + } else if (this.conclusion === CheckRunConclusion.SUCCESS) { + return greenTickIcon; + } else { + return redCrossIcon; + } + } + + get treeItem(): TreeItem { + return { + label: this.title, + id: this.id, + iconPath: this.iconPath(), + collapsibleState: TreeItemCollapsibleState.Collapsed, + contextValue: 'MyAnswer', + command: { + title: 'showAnswerLog', + command: 'bytelegend.showAnswerLog', + arguments: [this.id], + }, + }; + } +} + +// https://raw.githubusercontent.com/microsoft/vscode-icons/master/icons/dark/pass.svg +const greenTickIcon = Uri.parse( + "data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.27 10.87h.71l4.56-4.56-.71-.71-4.2 4.21-1.92-1.92L4 8.6l2.27 2.27z' fill='%2389D185'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8.6 1c1.6.1 3.1.9 4.2 2 1.3 1.4 2 3.1 2 5.1 0 1.6-.6 3.1-1.6 4.4-1 1.2-2.4 2.1-4 2.4-1.6.3-3.2.1-4.6-.7-1.4-.8-2.5-2-3.1-3.5C.9 9.2.8 7.5 1.3 6c.5-1.6 1.4-2.9 2.8-3.8C5.4 1.3 7 .9 8.6 1zm.5 12.9c1.3-.3 2.5-1 3.4-2.1.8-1.1 1.3-2.4 1.2-3.8 0-1.6-.6-3.2-1.7-4.3-1-1-2.2-1.6-3.6-1.7-1.3-.1-2.7.2-3.8 1-1.1.8-1.9 1.9-2.3 3.3-.4 1.3-.4 2.7.2 4 .6 1.3 1.5 2.3 2.7 3 1.2.7 2.6.9 3.9.6z' fill='%2389D185'/%3E%3C/svg%3E" +); +// https://raw.githubusercontent.com/microsoft/vscode-icons/master/icons/dark/error.svg +const redCrossIcon = Uri.parse( + "data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8.6 1c1.6.1 3.1.9 4.2 2 1.3 1.4 2 3.1 2 5.1 0 1.6-.6 3.1-1.6 4.4-1 1.2-2.4 2.1-4 2.4-1.6.3-3.2.1-4.6-.7-1.4-.8-2.5-2-3.1-3.5C.9 9.2.8 7.5 1.3 6c.5-1.6 1.4-2.9 2.8-3.8C5.4 1.3 7 .9 8.6 1zm.5 12.9c1.3-.3 2.5-1 3.4-2.1.8-1.1 1.3-2.4 1.2-3.8 0-1.6-.6-3.2-1.7-4.3-1-1-2.2-1.6-3.6-1.7-1.3-.1-2.7.2-3.8 1-1.1.8-1.9 1.9-2.3 3.3-.4 1.3-.4 2.7.2 4 .6 1.3 1.5 2.3 2.7 3 1.2.7 2.6.9 3.9.6zM7.9 7.5L10.3 5l.7.7-2.4 2.5 2.4 2.5-.7.7-2.4-2.5-2.4 2.5-.7-.7 2.4-2.5-2.4-2.5.7-.7 2.4 2.5z' fill='%23F48771'/%3E%3C/svg%3E" +); +// https://raw.githubusercontent.com/microsoft/vscode-icons/master/icons/dark/warning.svg +const warningIcon = Uri.parse( + "data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M7.56 1h.88l6.54 12.26-.44.74H1.44L1 13.26 7.56 1zM8 2.28L2.28 13H13.7L8 2.28zM8.625 12v-1h-1.25v1h1.25zm-1.25-2V6h1.25v4h-1.25z' fill='%23FC0'/%3E%3C/svg%3E" +); +const loadingIcon = Uri.parse( + 'data:image/gif;base64,R0lGODlhIAAgAPMAAMrKyoiIiLGxsevr67+/v9TU1H9/fz8/P2pqalVVVSoqKpSUlKmpqRUVFf///wAAACH/C05FVFNDQVBFMi4wAwEAAAAh/wtYTVAgRGF0YVhNUDw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6QUU4MEFBRTQxQkFCMTFFNjkyRTJENEE2MjgwNzUzNUUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6QUU4MEFBRTUxQkFCMTFFNjkyRTJENEE2MjgwNzUzNUUiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpBRTgwQUFFMjFCQUIxMUU2OTJFMkQ0QTYyODA3NTM1RSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpBRTgwQUFFMzFCQUIxMUU2OTJFMkQ0QTYyODA3NTM1RSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PgH//v38+/r5+Pf29fTz8vHw7+7t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' +); + +export class PullRequestAnswer { + constructor( + readonly title: string, + readonly baseRepoFullName: string, + readonly headRepoFullName: string, + readonly number: string, + readonly branch: string, + readonly time: string, + readonly open: boolean, + readonly accomplished: boolean, + readonly commits: AnswerCommit[] + ) {} + + iconPath() { + return this.commits.length === 0 ? null : this.commits[0].iconPath(); + } + + get treeItem(): TreeItem { + // don't show link icon for dummy check run + const contextValue = this.id.startsWith('DUMMY-CHECK-RUN') + ? '' + : 'MyAnswer'; + return { + label: this.title, + id: this.id, + iconPath: this.iconPath(), + collapsibleState: TreeItemCollapsibleState.Collapsed, + command: { + title: 'showAnswerLog', + command: 'bytelegend.openPrDescriptionAndShowAnswerLog', + arguments: [this.id], + }, + contextValue, + }; + } + + get htmlUrl() { + return `https://github.com/${this.baseRepoFullName}/pull/${this.number}`; + } + + get id() { + return this.htmlUrl; + } +} + +export const CheckRunConclusion = { + ACTION_REQUIRED: 'ACTION_REQUIRED', + CANCELLED: 'CANCELLED', + FAILURE: 'FAILURE', + NEUTRAL: 'NEUTRAL', + SUCCESS: 'SUCCESS', + SKIPPED: 'SKIPPED', + STALE: 'STALE', + TIMED_OUT: 'TIMED_OUT', +}; + +export class Tutorial { + constructor( + readonly id: string, + readonly title: string, + readonly type: string, + readonly href: string, + readonly languages: Array + ) {} +} diff --git a/extensions/github1s/src/bytelegend/my-answer-list-view.ts b/extensions/github1s/src/bytelegend/my-answer-list-view.ts new file mode 100644 index 000000000..c775b76ab --- /dev/null +++ b/extensions/github1s/src/bytelegend/my-answer-list-view.ts @@ -0,0 +1,95 @@ +import * as vscode from 'vscode'; +import { TreeItem } from 'vscode'; +import { AnswerCommit, PullRequestAnswer } from '@/bytelegend/entities'; +import { ByteLegendContext } from '@/bytelegend/bytelegendContext'; + +export class MyAnswerTreeDataProvider + implements vscode.TreeDataProvider { + public static viewId = 'bytelegend.views.my-answer-list'; + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private _answers: PullRequestAnswer[] = []; + private _idToTreeNode: Map = new Map(); + private _idToParentNode: Map = new Map(); + + constructor(private readonly context: ByteLegendContext) {} + + public getNodeById(id: string): AnswerCommit | PullRequestAnswer | null { + const ret = this._idToTreeNode.get(id); + if (ret instanceof AnswerCommit) { + return ret; + } else if (ret instanceof PullRequestAnswer) { + return ret; + } else { + return null; + } + } + + public getNodeByCheckRunId(checkRunId: string): AnswerCommit | null { + for (const value of this._idToTreeNode.values()) { + if (value instanceof AnswerCommit && value.checkRunId === checkRunId) { + return value; + } + } + return null; + } + + get answers() { + return this._answers || []; + } + + public async updateTree(answers: PullRequestAnswer[]) { + this._answers = answers; + this._idToTreeNode.clear(); + this._idToParentNode.clear(); + + answers.forEach((answer) => { + this._idToTreeNode.set(answer.id, answer); + answer.commits.forEach((commit) => { + this._idToTreeNode.set(commit.id, commit); + this._idToParentNode.set(commit.id, answer); + }); + }); + this._onDidChangeTreeData.fire(); + } + + async getParent(element: vscode.TreeItem): Promise { + const node = this._idToParentNode.get(element.id); + if (!node) { + return null; + } else if (node instanceof PullRequestAnswer) { + return node.treeItem; + } else if (node instanceof AnswerCommit) { + return node.treeItem; + } else { + throw Error(`Can't get parent for element: ${element.id}`); + } + } + + async getChildren(element?: vscode.TreeItem): Promise { + if (!element) { + if (!this._answers) { + return []; + } + return this._answers.map((answer) => answer.treeItem); + } else if (this._idToTreeNode.has(element.id)) { + const node = this._idToTreeNode.get(element.id); + if (node instanceof PullRequestAnswer) { + return node.commits.map((commit) => commit.treeItem); + } else if (node instanceof AnswerCommit) { + const changedFiles = await node.getChildren(); + changedFiles.forEach((file) => { + this._idToTreeNode.set(file.id, file); + this._idToParentNode.set(file.id, node); + }); + return changedFiles; + } + } else { + return []; + } + } + + getTreeItem(element: vscode.TreeItem): vscode.TreeItem { + return element; + } +} diff --git a/extensions/github1s/src/bytelegend/raw-html-view.ts b/extensions/github1s/src/bytelegend/raw-html-view.ts new file mode 100644 index 000000000..82f3436a5 --- /dev/null +++ b/extensions/github1s/src/bytelegend/raw-html-view.ts @@ -0,0 +1,25 @@ +import * as vscode from 'vscode'; + +export function createRawHtmlWebview(html: string) { + const panel = vscode.window.createWebviewPanel( + 'RawHtmlWebview', + 'README', + vscode.ViewColumn.Active, + { + enableScripts: true, + } + ); + panel.webview.html = ` + + + + + + README + + +${html} + + + `; +} diff --git a/extensions/github1s/src/bytelegend/tutorials-view.ts b/extensions/github1s/src/bytelegend/tutorials-view.ts new file mode 100644 index 000000000..440ee1bce --- /dev/null +++ b/extensions/github1s/src/bytelegend/tutorials-view.ts @@ -0,0 +1,355 @@ +import * as vscode from 'vscode'; +import { getExtensionContext } from '@/helpers/context'; +import { getNonce, getWebviewOptions } from '@/helpers/util'; +import { ByteLegendContext } from '@/bytelegend/bytelegendContext'; +import { Tutorial } from './entities'; + +export class TutorialsView implements vscode.WebviewViewProvider { + public static readonly viewId = 'bytelegend.views.tutorials'; + private readonly _extensionContext: vscode.ExtensionContext; + private _webviewView: vscode.WebviewView; + + constructor(private readonly bytelegendContext: ByteLegendContext) { + this._extensionContext = getExtensionContext(); + } + + resolveWebviewView( + webviewView: vscode.WebviewView, + context: vscode.WebviewViewResolveContext, + token: vscode.CancellationToken + ): void | Thenable { + this._webviewView = webviewView; + webviewView.webview.options = getWebviewOptions( + this._extensionContext.extensionUri + ); + webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); + webviewView.webview.onDidReceiveMessage((data) => { + if (data.type === 'open.tutorial') { + TutorialsView.openTutorial(data.tutorial as Tutorial); + } + }); + } + + private static openTutorial(tutorial: Tutorial) { + if (tutorial.type.startsWith('text/')) { + vscode.commands.executeCommand( + 'bytelegend.open', + tutorial.href.replace('https://', 'github1s://') + ); + } else if (tutorial.type.startsWith('video/')) { + vscode.commands.executeCommand('bytelegend.openVideoTutorial', tutorial); + } + } + + _getHtmlForWebview(webview): string { + const nonce = getNonce(); + + const ret = ` + + + + + + + Tutorials + + + +
+ +
+
+
+ + + + + `; + return ret; + } +} diff --git a/extensions/github1s/src/bytelegend/utils.ts b/extensions/github1s/src/bytelegend/utils.ts new file mode 100644 index 000000000..ab8c676e4 --- /dev/null +++ b/extensions/github1s/src/bytelegend/utils.ts @@ -0,0 +1,14 @@ +export async function runCatching(promise: Promise): Promise { + return promise + .then((data) => [null, data]) + .catch((err) => { + console.trace(err); + return [err]; + }); +} + +export async function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/extensions/github1s/src/bytelegend/video-tutorial-view.ts b/extensions/github1s/src/bytelegend/video-tutorial-view.ts new file mode 100644 index 000000000..ca48f10b3 --- /dev/null +++ b/extensions/github1s/src/bytelegend/video-tutorial-view.ts @@ -0,0 +1,117 @@ +import * as vscode from 'vscode'; +import { getNonce } from '@/helpers/util'; +import { Tutorial } from '@/bytelegend/entities'; + +function getIframeTagForTutorial(tutorial: Tutorial): string { + if (tutorial.type === 'video/youtube') { + // https://www.youtube.com/watch?v=ABC -> https://www.youtube.com/embed/ABC + return ` + + `; + } else if (tutorial.type === 'video/bilibili') { + // https://www.bilibili.com/video/BV1JJ41197UK -> BV1JJ41197UK + const bvid = getBvid(tutorial.href); + return ` + + `; + } +} + +function substringAfter(str: string, target: string) { + const index = str.indexOf(target); + if (index === -1) { + return str; + } else { + return str.substring(index + target.length); + } +} + +function substringBefore(str: string, target: string) { + const index = str.indexOf(target); + if (index === -1) { + return str; + } else { + return str.substring(0, index); + } +} + +function getBvid(url: string): string { + if (url.indexOf('www.bilibili.com') !== -1) { + return substringAfter(url, 'video/'); + } else { + return substringBefore(substringAfter(url, 'bvid'), '&'); + } +} + +export function createVideoTutorial(tutorial: Tutorial) { + // Create and show panel + const panel = vscode.window.createWebviewPanel( + 'VideoTutorial', + tutorial.title, + vscode.ViewColumn.Active, + { + enableScripts: true, + } + ); + const nonce = getNonce(); + const iframe = getIframeTagForTutorial(tutorial); + + panel.webview.html = ` + + + + + + + ${tutorial.title} + + + +${iframe} + + + `; +} diff --git a/extensions/github1s/src/commands/index.ts b/extensions/github1s/src/commands/index.ts index de1b04e37..7d3d4866e 100644 --- a/extensions/github1s/src/commands/index.ts +++ b/extensions/github1s/src/commands/index.ts @@ -100,10 +100,6 @@ const commands: { id: string; callback: (...args: any[]) => any }[] = [ // toggle the gutter blame of a editor { id: 'github1s.toggle-editor-gutter-blame', callback: commandToggleEditorGutterBlame }, // prettier-ignore - // open the gutter blame of a editor - { id: 'github1s.open-editor-gutter-blame', callback: commandOpenEditorGutterBlame }, // prettier-ignore - // close the gutter blame of a editor - { id: 'github1s.close-editor-gutter-blame', callback: commandCloseEditorGutterBlame }, // prettier-ignore // open current page on GitHub { id: 'github1s.open-on-github', callback: commandOpenOnGitHub }, diff --git a/extensions/github1s/src/extension.ts b/extensions/github1s/src/extension.ts index 72de61822..4cdb00ad8 100644 --- a/extensions/github1s/src/extension.ts +++ b/extensions/github1s/src/extension.ts @@ -4,17 +4,20 @@ */ import * as vscode from 'vscode'; -import { setExtensionContext } from '@/helpers/context'; +import { commands, TreeItemCollapsibleState, Uri } from 'vscode'; +import { getExtensionContext, setExtensionContext } from '@/helpers/context'; import { registerGitHub1sCommands } from '@/commands'; import { registerVSCodeProviders } from '@/providers'; -import { registerCustomViews } from '@/views'; import { GitHub1sFileSystemProvider } from '@/providers/fileSystemProvider'; -import { showSponsors } from '@/sponsors'; -import { showGitpod } from '@/gitpod'; import router from '@/router'; import { activateSourceControl } from '@/source-control'; import { registerEventListeners } from '@/listeners'; import { PageType } from './router/types'; +import { byteLegendContext } from '@/bytelegend/bytelegendContext'; +import { open, registerByteLegendCommands } from '@/bytelegend/commands'; +import { MyAnswerTreeDataProvider } from '@/bytelegend/my-answer-list-view'; +import { TutorialsView } from '@/bytelegend/tutorials-view'; +import { sleep } from '@/bytelegend/utils'; export async function activate(context: vscode.ExtensionContext) { const browserUrl = (await vscode.commands.executeCommand( @@ -31,7 +34,7 @@ export async function activate(context: vscode.ExtensionContext) { // register VS Code providers registerVSCodeProviders(); // register custom views - registerCustomViews(); + // registerCustomViews(); // register GitHub1s Commands registerGitHub1sCommands(); @@ -39,15 +42,206 @@ export async function activate(context: vscode.ExtensionContext) { activateSourceControl(); // sponsors in Status Bar - showSponsors(); - showGitpod(); + // Below is changed by ByteLegend + // showSponsors(); + // showGitpod(); + // Above is changed by ByteLegend // initialize the VSCode's state initialVSCodeState(); + + // Below is changed by ByteLegend + // await byteLegendContext.init(); + await initByteLegendContext(); + registerByteLegendCommands(); + await registerByteLegendViews(); + vscode.workspace.onDidOpenTextDocument((doc) => { + if (doc && doc.languageId === 'markdown') { + openMarkdownPreview(browserUrl, doc.uri); + } + }); + + await setUpActivityBar(); + await configureDefaultSettings(); + await focusInitView(); + await openInitReadme(); +} + +async function initByteLegendContext() { + const initData = await vscode.commands.executeCommand( + 'bytelegend.getInitData' + ); + if (initData) { + await byteLegendContext.init(initData); + } else { + console.warn('Failed to get init data'); + } +} + +async function setUpActivityBar() { + const activityBarVisibleNow = await vscode.commands.executeCommand( + 'bytelegend.isActivityBarVisible' + ); + const expectedActivityBarVisible = byteLegendContext.showActivityBar; + if ( + (activityBarVisibleNow && !expectedActivityBarVisible) || + (!activityBarVisibleNow && expectedActivityBarVisible) + ) { + await vscode.commands.executeCommand( + 'workbench.action.toggleActivityBarVisibility' + ); + } +} + +async function focusInitView() { + if (byteLegendContext.initFocusView) { + const viewName = byteLegendContext.initFocusView.toLowerCase(); + if (viewName.indexOf('answer') !== -1) { + await byteLegendContext.focusOnMyAnswerView(); + } else if (viewName.indexOf('tutorial') !== -1) { + await byteLegendContext.focusOnTutorialsView(); + } + } +} + +async function openInitReadme() { + if (byteLegendContext.initReadme) { + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + if (byteLegendContext.initReadme.startsWith('https://')) { + await open( + byteLegendContext.initReadme.replace('https://', 'github1s://') + ); + } else { + await open(byteLegendContext.initReadme); + } + } +} + +async function configureDefaultSettings() { + vscode.workspace + .getConfiguration() + .update('files.autoSave', 'off', vscode.ConfigurationTarget.Global); + vscode.workspace + .getConfiguration() + .update( + 'editor.bracketPairColorization.enabled', + true, + vscode.ConfigurationTarget.Global + ); +} + +async function registerByteLegendViews() { + const context = getExtensionContext(); + + const myAnswerTreeView = vscode.window.createTreeView( + MyAnswerTreeDataProvider.viewId, + { + treeDataProvider: byteLegendContext.answerTreeDataProvider, + } + ); + myAnswerTreeView.onDidChangeSelection((e) => { + const oldState = e.selection[0].collapsibleState; + if (oldState === TreeItemCollapsibleState.Collapsed) { + myAnswerTreeView.reveal(e.selection[0], { expand: true }); + } + }); + + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + TutorialsView.viewId, + new TutorialsView(byteLegendContext) + ), + myAnswerTreeView + ); +} + +function getFileName(filePath: string) { + const lastIndexOfSlash = filePath.lastIndexOf('/'); + return filePath.substring(lastIndexOfSlash + 1); +} + +/* +This is a bit tricky. Sometimes, things may happen in the following order: + +- Open whatever.md from previous history. +- Triggers `onDidOpenTextDocument`. +- We fire 'workbench.action.closeAllEditors' to close all editors. +- We open initReadme +- The preview of whatever.md pops up as the active tab, hiding initReadme. + +In this method, when we want to open preview, we check if the markdown file is a visible tab, +and only preview if it's visible. If it's not visible, poll for at most 5 seconds. + */ +async function openMarkdownPreview(initBrowserUrl: string, uri: Uri) { + const tabGroups = await waitUntilFound(5000, async () => { + const tabGroups: string[][] = await vscode.commands.executeCommand( + 'bytelegend.getEditorTabGroups' + ); + const flatTabs = Array.prototype.concat.apply([], tabGroups); + const fileName = getFileName(uri.path); + return flatTabs.find((tab) => tab === fileName) ? tabGroups : null; + }); + if (tabGroups) { + // we may have already fired `closeAllEditors` command, don't show the preview for the closed markdown tab + await doOpenPreview(initBrowserUrl, uri, tabGroups); + } else { + console.warn( + `Fail to get open tab matching ${uri.toString()}, skip opening preview.` + ); + } +} + +/** + * Wait until a non-null value is produced + */ +async function waitUntilFound( + timeoutMs: number, + provider: () => Promise +) { + const deadline = new Date().getTime() + timeoutMs; + while (new Date().getTime() < deadline) { + const result = await provider(); + if (result) { + return result; + } + await sleep(500); + } + return null; +} + +async function doOpenPreview( + initBrowserUrl: string, + uri: Uri, + tabGroups: string[][] +) { + await vscode.commands.executeCommand('markdown.showPreview', null, [uri], { + locked: true, + }); + + if ( + uri.toString().endsWith('README.md') && + initBrowserUrl.endsWith('README.md') && + tabGroups.length === 1 + ) { + // only open the challenged file upon first open + const initData = await commands.executeCommand('bytelegend.getInitData'); + const whitelist = initData?.['whitelist'] || []; + if (whitelist.length !== 0 && !whitelist[0].endsWith('/')) { + const fileName = getFileName(whitelist[0]); + if (!tabGroups[0].find((name) => name === fileName)) { + await vscode.commands.executeCommand( + 'vscode.openWith', + Uri.parse(`github1s:/${whitelist[0]}`), + 'default', + vscode.ViewColumn.Beside + ); + } + } + } } // initialize the VSCode's state according to the router url -const initialVSCodeState = async () => { +export const initialVSCodeState = async () => { const routerState = await router.getState(); const { filePath, pageType } = routerState; const scheme = GitHub1sFileSystemProvider.scheme; diff --git a/extensions/github1s/src/helpers/fetch.ts b/extensions/github1s/src/helpers/fetch.ts index e4393a365..5141707a4 100644 --- a/extensions/github1s/src/helpers/fetch.ts +++ b/extensions/github1s/src/helpers/fetch.ts @@ -59,9 +59,19 @@ export const getFetchOptions = (forceUpdate?: boolean): RequestInit => { const cache = new Map(); -const isGitHubApi = (url: string) => url.startsWith('https://api.github.com/'); +function isGitHubApi(url: string) { + return ( + url.startsWith('https://api.github.com/') || + new RegExp('http://localhost:\\d+/ghapi/').test(url) || + url.startsWith('https://bytelegend.com/ghapi/') || + url.startsWith('https://ghapi.bytelegend.com/') + ); +} export const fetch = reuseable(async (url: string, options?: RequestInit) => { + options = options || {}; + options.credentials = 'include'; + const token = getOAuthToken(); const authHeaders = token && isGitHubApi(url) ? { Authorization: `token ${token}` } : {}; @@ -83,6 +93,7 @@ export const fetch = reuseable(async (url: string, options?: RequestInit) => { try { response = await self.fetch(url, { mode: 'cors', + credentials: 'include', ...options, headers: { ...authHeaders, ...customHeaders }, }); diff --git a/extensions/github1s/src/interfaces/github-api-rest.ts b/extensions/github1s/src/interfaces/github-api-rest.ts index 9413b2664..4c137c8f6 100644 --- a/extensions/github1s/src/interfaces/github-api-rest.ts +++ b/extensions/github1s/src/interfaces/github-api-rest.ts @@ -8,9 +8,9 @@ import { encodeFilePath } from '@/helpers/util'; import { fetch, RequestError, - RequestRateLimitError, RequestInvalidTokenError, RequestNotFoundError, + RequestRateLimitError, throttledReportNetworkError, } from '@/helpers/fetch'; @@ -20,6 +20,20 @@ export interface UriState { path: string; } +let gitHubApiServer = ''; + +async function getGitHubApiServer() { + if (gitHubApiServer === '') { + // Don't use getContext, it might not be initialized yet. + const initData: any = await vscode.commands.executeCommand( + 'bytelegend.getInitData' + ); + gitHubApiServer = + initData?.githubApiBaseUrl || 'https://bytelegend.com/ghapi'; + } + return gitHubApiServer; +} + const handleFileSystemRequestError = (error: RequestError) => { if (error instanceof RequestRateLimitError) { if (!error.token) { @@ -44,32 +58,36 @@ const handleFileSystemRequestError = (error: RequestError) => { ); }; -export const readGitHubDirectory = ( +export const readGitHubDirectory = async ( owner: string, repo: string, ref: string, path: string, options?: RequestInit ) => { + const cached = await readGitHubDirectoryCached(owner, repo, ref, path); + if (cached) { + return cached; + } return fetch( - `https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}${encodeFilePath( + `${await getGitHubApiServer()}/repos/${owner}/${repo}/git/trees/${ref}${encodeFilePath( path ).replace(/^\//, ':')}`, options ).catch(handleFileSystemRequestError); }; -export const readGitHubFile = ( +export async function readGitHubFile( owner: string, repo: string, fileSha: string, options?: RequestInit -) => { +) { return fetch( - `https://api.github.com/repos/${owner}/${repo}/git/blobs/${fileSha}`, + `${await getGitHubApiServer()}/repos/${owner}/${repo}/git/blobs/${fileSha}`, options ).catch(handleFileSystemRequestError); -}; +} export const validateToken = (token: string) => { const authHeaders = token ? { Authorization: `token ${token}` } : {}; @@ -93,122 +111,273 @@ export const validateToken = (token: string) => { // [List matching references](https://docs.github.com/en/rest/reference/git#list-matching-references) // can returned all branches for a request, and there is an issue for this API // https://github.com/github/docs/issues/3863 -export const getGitHubBranchRefs = ( +export async function getGitHubBranchRefs( owner: string, repo: string, options?: RequestInit -) => { +) { return fetch( - `https://api.github.com/repos/${owner}/${repo}/git/matching-refs/heads`, + `${await getGitHubApiServer()}/repos/${owner}/${repo}/git/matching-refs/heads`, options ).then((branchRefs) => { // the field in branchRef will looks like `refs/heads/`, we add a name field here return branchRefs.map((item) => ({ ...item, name: item.ref.slice(11) })); }); -}; +} // It's similar to `getGithubBranchRefs` -export const getGitHubTagRefs = ( +export async function getGitHubTagRefs( owner: string, repo: string, options?: RequestInit -) => { +) { return fetch( - `https://api.github.com/repos/${owner}/${repo}/git/matching-refs/tags`, + `${await getGitHubApiServer()}/repos/${owner}/${repo}/git/matching-refs/tags`, options ).then((tagRefs) => { // the field in tagRef will looks like `refs/tags/`, we add a name field here return tagRefs.map((item) => ({ ...item, name: item.ref.slice(10) })); }); -}; +} -export const getGitHubAllFiles = ( +export const getGitHubAllFiles = async ( owner: string, repo: string, ref: string, path: string = '/' ) => { - return fetch( - `https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}${encodeFilePath( + const ret = await fetch( + `${await getGitHubApiServer()}/repos/${owner}/${repo}/git/trees/${ref}${encodeFilePath( path ).replace(/^\//, ':')}?recursive=1` ).catch(handleFileSystemRequestError); + if (path === '/') { + populateFileTreeCache(owner, repo, ref, ret); + } + return ret; }; -export const getGitHubPulls = ( +export async function getGitHubPulls( owner: string, repo: string, pageNumber = 0, pageSize = 100, options?: RequestInit -) => { +) { // TODO: only recent 100 pull requests are supported now return fetch( - `https://api.github.com/repos/${owner}/${repo}/pulls?state=all&order=created&per_page=${pageSize}&page=${pageNumber}`, + `${await getGitHubApiServer()}/repos/${owner}/${repo}/pulls?state=all&order=created&per_page=${pageSize}&page=${pageNumber}`, options ); -}; +} -export const getGitHubPullDetail = ( +export async function getGitHubPullDetail( owner: string, repo: string, pullNumber: number, options?: RequestInit -) => { +) { return fetch( - `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`, + `${await getGitHubApiServer()}/repos/${owner}/${repo}/pulls/${pullNumber}`, options ); -}; +} -export const getGitHubPullFiles = ( +export async function getGitHubPullFiles( owner: string, repo: string, pullNumber: number, options?: RequestInit -) => { +) { // TODO: only the number of change files not greater than 100 are supported now! return fetch( - `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/files?per_page=100`, + `${await getGitHubApiServer()}/repos/${owner}/${repo}/pulls/${pullNumber}/files?per_page=100`, options ); -}; +} -export const getGitHubCommits = ( +export async function getGitHubCommits( owner: string, repo: string, sha: string, pageNumber = 0, pageSize = 100, options?: ResponseInit -) => { +) { return fetch( - `https://api.github.com/repos/${owner}/${repo}/commits?sha=${sha}&per_page=${pageSize}&page=${pageNumber}`, + `${await getGitHubApiServer()}/repos/${owner}/${repo}/commits?sha=${sha}&per_page=${pageSize}&page=${pageNumber}`, options ); -}; +} -export const getGitHubFileCommits = ( +export async function getGitHubFileCommits( owner: string, repo: string, filePath: string, sha: string, options?: ResponseInit -) => { +) { return fetch( - `https://api.github.com/repos/${owner}/${repo}/commits?path=${filePath.slice(1)}&sha=${sha}&per_page=100`, // prettier-ignore + `${await getGitHubApiServer()}/repos/${owner}/${repo}/commits?path=${filePath.slice(1)}&sha=${sha}&per_page=100`, // prettier-ignore options ); -}; +} -export const getGitHubCommitDetail = ( +export async function getGitHubCommitDetail( owner: string, repo: string, commitSha: string, options?: RequestInit -) => { +) { return fetch( - `https://api.github.com/repos/${owner}/${repo}/commits/${commitSha}`, + `${await getGitHubApiServer()}/repos/${owner}/${repo}/commits/${commitSha}`, options ); +} + +const repoFileTreeCacheKey = (owner: string, repo: string, ref: string) => { + return `${owner}/${repo}/${ref}`; }; + +async function readGitHubDirectoryCached( + owner: string, + repo: string, + ref: string, + path: string, + options?: RequestInit +): Promise { + const cacheKey = repoFileTreeCacheKey(owner, repo, ref); + if ( + !repoFileTreeCache.has(cacheKey) || + repoFileTreeCache.get(cacheKey).expireTime < new Date() + ) { + // also populate the cache + await getGitHubAllFiles(owner, repo, ref); + } + const cachedFileTree = repoFileTreeCache.get(cacheKey); + if (cachedFileTree.truncated) { + return null; + } + + const nodeItselfAndChildren = cachedFileTree.fileTree.get( + path.substr(1, path.length - 1) + ); + + const children = nodeItselfAndChildren + .slice(1, nodeItselfAndChildren.length) + .map((child) => { + return path === '/' + ? child + : { + // src/main -> main + path: child.path.substring(path.length, child.path.length), + mode: child.mode, + type: child.type, + sha: child.sha, + url: child.url, + }; + }); + + return { + sha: nodeItselfAndChildren[0].sha, + url: nodeItselfAndChildren[0].url, + truncated: false, + tree: [...children], + }; +} + +const repoFileTreeCache: Map = new Map(); + +function populateFileTreeCache( + owner: string, + repo: string, + ref: string, + jsonObj: any +) { + const cacheKey = repoFileTreeCacheKey(owner, repo, ref); + if (jsonObj.truncated) { + /* + https://docs.github.com/en/rest/reference/git#get-a-tree + If truncated is true in the response then the number of items in the tree array exceeded our maximum limit. + If you need to fetch more items, use the non-recursive method of fetching trees, and fetch one sub-tree at a time. + */ + repoFileTreeCache.set(cacheKey, { + owner, + repo, + ref, + sha: jsonObj.sha, + fileTree: new Map(), + url: jsonObj.url, + truncated: true, + expireTime: new Date(new Date().getTime() + 1000 * 3600 * 24 * 365), + }); + } else { + // for SHA, never expires; otherwise 5 seconds expire time. + const expireTime = /^[0-9a-f]{40}$/.test(ref) + ? new Date(new Date().getTime() + 1000 * 3600 * 24 * 365) + : new Date(new Date().getTime() + 5000); + repoFileTreeCache.set(cacheKey, { + owner, + repo, + ref, + sha: jsonObj.sha, + fileTree: createFileTree(jsonObj), + url: jsonObj.url, + truncated: false, + expireTime, + }); + } +} + +/** + * Key: the file path. "" for root path + * Value: [the node itself, ...the children of the path] + */ +function createFileTree(jsonObj: any): Map { + const all: GitTreeOrBlobObject[] = jsonObj.tree; + const ret = new Map(); + all.forEach((item) => { + const pathSegments = item.path.split('/'); + const parentPath = pathSegments.slice(0, pathSegments.length - 1).join('/'); + + if (ret.has(item.path)) { + ret.set(item.path, [item, ...ret.get(item.path)]); + } else { + ret.set(item.path, [item]); + } + + if (!ret.has(parentPath)) { + ret.set(parentPath, []); + } + ret.get(parentPath).push(item); + }); + // special handling for root node + ret.set('', [ + new GitTreeOrBlobObject('', '', 'tree', jsonObj.sha, jsonObj.url), + ...ret.get(''), + ]); + return ret; +} + +class CacheEntry { + constructor( + readonly owner: string, + readonly repo: string, + readonly ref: string, + readonly sha: string, + readonly fileTree: Map, + readonly url: string, + readonly truncated: boolean, + readonly expireTime: Date + ) {} +} + +class GitTreeOrBlobObject { + constructor( + readonly path: string, + readonly mode: string, + readonly type: string, + readonly sha: string, + readonly url: string + ) {} +} diff --git a/extensions/github1s/src/listeners/router/explorer.ts b/extensions/github1s/src/listeners/router/explorer.ts index ce7bed873..01148c13e 100644 --- a/extensions/github1s/src/listeners/router/explorer.ts +++ b/extensions/github1s/src/listeners/router/explorer.ts @@ -54,7 +54,11 @@ export const explorerRouterListener = ( 'workbench.files.action.refreshFilesExplorer' ); // TODO: maybe we should update the editors but not close it - vscode.commands.executeCommand('workbench.action.closeAllGroups'); + // below is changed by ByteLegend + // When user submit new answer, we switch branch but don't want to close the editor + // maybe we should use if(previousState==main && currentState.match(/gh/.*/.*/)) + // vscode.commands.executeCommand('workbench.action.closeAllGroups'); + // above is changed by ByteLegend changedFileDecorationProvider.updateDecorations(); submoduleDecorationProvider.updateDecorations(); diff --git a/extensions/github1s/src/providers/fileSystemProvider/HackyFileSystemProvider.ts b/extensions/github1s/src/providers/fileSystemProvider/HackyFileSystemProvider.ts new file mode 100644 index 000000000..02b337cbd --- /dev/null +++ b/extensions/github1s/src/providers/fileSystemProvider/HackyFileSystemProvider.ts @@ -0,0 +1,50 @@ +import { GitHub1sFileSystemProvider } from '@/providers/fileSystemProvider/index'; +import { getExtensionContext } from '@/helpers/context'; + +import * as vscode from 'vscode'; + +export class HackyFileSystemProvider extends GitHub1sFileSystemProvider { + private superReadFile: (Uri) => Promise = this['readFile']; + + constructor() { + super(); + vscode.workspace.onDidChangeTextDocument((e) => { + if (e.contentChanges.length === 0) { + return; + } + vscode.workspace.textDocuments.forEach((doc) => { + console.log(`Open? ${doc.fileName}`); + }); + const changedDoc = vscode.workspace.textDocuments.find((doc) => { + return doc.fileName === e.document.fileName; + }); + if (changedDoc) { + const changedDocText = changedDoc.getText(); + getExtensionContext() + .globalState.update( + `${GitHub1sFileSystemProvider.scheme}:${changedDoc.fileName}`, + changedDocText + ) + .then( + () => {}, + (e) => { + console.trace(e); + } + ); + } + }); + } + + readFile: (Uri) => Promise = (uri) => { + return this.superReadFile(uri).then((cleanFile) => { + const localVersion: string = getExtensionContext().globalState.get( + uri.toString() + ); + if (localVersion) { + return new TextEncoder().encode(localVersion); + } else { + return cleanFile; + } + }); + }; +} diff --git a/extensions/github1s/src/providers/index.ts b/extensions/github1s/src/providers/index.ts index 99b954028..22c17f24e 100644 --- a/extensions/github1s/src/providers/index.ts +++ b/extensions/github1s/src/providers/index.ts @@ -41,7 +41,7 @@ export const registerVSCodeProviders = () => { vscode.workspace.registerFileSystemProvider( GitHub1sFileSystemProvider.scheme, fileSystemProvider, - { isCaseSensitive: true, isReadonly: true } + { isCaseSensitive: true, isReadonly: false } ), vscode.workspace.registerFileSearchProvider( GitHub1sFileSearchProvider.scheme, @@ -57,18 +57,18 @@ export const registerVSCodeProviders = () => { sourceControlDecorationProvider ), - vscode.languages.registerDefinitionProvider( - { scheme: GitHub1sDefinitionProvider.scheme }, - definitionProvider - ), - vscode.languages.registerReferenceProvider( - { scheme: GitHub1sReferenceProvider.scheme }, - referenceProvider - ), - vscode.languages.registerHoverProvider( - { scheme: GitHub1sHoverProvider.scheme }, - hoverProvider - ), + // vscode.languages.registerDefinitionProvider( + // { scheme: GitHub1sDefinitionProvider.scheme }, + // definitionProvider + // ), + // vscode.languages.registerReferenceProvider( + // { scheme: GitHub1sReferenceProvider.scheme }, + // referenceProvider + // ), + // vscode.languages.registerHoverProvider( + // { scheme: GitHub1sHoverProvider.scheme }, + // hoverProvider + // ), // provider a readonly empty file for diff vscode.workspace.registerTextDocumentContentProvider(EMPTY_FILE_SCHEME, { diff --git a/extensions/github1s/src/repository/index.ts b/extensions/github1s/src/repository/index.ts index ae833e2b0..86745f11e 100644 --- a/extensions/github1s/src/repository/index.ts +++ b/extensions/github1s/src/repository/index.ts @@ -46,13 +46,13 @@ export class Repository { // get current repo owner public getOwner() { const pathname = router.history.location.pathname; - return pathname.split('/').filter(Boolean)[0] || 'conwnet'; + return pathname.split('/').filter(Boolean)[0] || 'ByteLegend'; } // get current repo name public getRepo() { const pathname = router.history.location.pathname; - return pathname.split('/').filter(Boolean)[1] || 'github1s'; + return pathname.split('/').filter(Boolean)[1] || 'ByteLegend'; } // get all branches for current repository diff --git a/extensions/github1s/src/router/parser/index.ts b/extensions/github1s/src/router/parser/index.ts index 3658c5e7c..3bf26c775 100644 --- a/extensions/github1s/src/router/parser/index.ts +++ b/extensions/github1s/src/router/parser/index.ts @@ -46,8 +46,8 @@ export const parseGitHubUrl = async (path: string): Promise => { // fallback to default return { - owner: 'conwnet', - repo: 'github1s', + owner: 'ByteLegend', + repo: 'ByteLegend', pageType: PageType.TREE, ref: 'HEAD', }; diff --git a/extensions/github1s/src/router/parser/tree.ts b/extensions/github1s/src/router/parser/tree.ts index 9f8427907..b2dc35fbb 100644 --- a/extensions/github1s/src/router/parser/tree.ts +++ b/extensions/github1s/src/router/parser/tree.ts @@ -21,6 +21,14 @@ const findMatchedBranchOrTag = ( } maybeBranch = `${maybeBranch}/${pathParts[++partIndex]}`; } + // below is changed by ByteLegend + // After user submits answers, we create a gh/{userGitHubName}/{timestamp} branch + // But unfortunately, this branch might not be visible immediately, so we have + // to forcibly detect it as a branch. + if (pathParts[3] == 'gh') { + return `${pathParts[3]}/${pathParts[4]}/${pathParts[5]}`; + } + // above is changed by ByteLegend return null; }; @@ -44,7 +52,7 @@ const detectRefFormPathParts = async (pathParts: string[]): Promise => { export const parseTreeUrl = async (path: string): Promise => { const pathParts = parsePath(path).pathname.split('/').filter(Boolean); - const [owner = 'conwnet', repo = 'github1s'] = pathParts; + const [owner = 'ByteLegend', repo = 'ByteLegend'] = pathParts; const ref = await detectRefFormPathParts(pathParts); const filePath = pathParts.slice(3).join('/').slice(ref.length); diff --git a/extensions/vscode-pull-request-github b/extensions/vscode-pull-request-github new file mode 160000 index 000000000..3865f365b --- /dev/null +++ b/extensions/vscode-pull-request-github @@ -0,0 +1 @@ +Subproject commit 3865f365b4bd8ec508d850d5c293cc1df0dc2695 diff --git a/resources/index-dev-vscode.html b/resources/index-dev-vscode.html index 1ea8970e4..afc7aab3e 100644 --- a/resources/index-dev-vscode.html +++ b/resources/index-dev-vscode.html @@ -15,9 +15,31 @@ - GitHub1s + ByteLegend Web Editor + +