diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ed83fe6ef..4c487267d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,6 +10,9 @@ on: jobs: build: + permissions: + contents: read + strategy: matrix: os: [macos-14] @@ -19,6 +22,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: @@ -28,5 +33,12 @@ jobs: - run: npm install - run: npm run eslint - run: npm run build - - uses: microsoft/playwright-github-action@v1 - - run: npm run test:ci + env: + GITHUB_API_PREFIX: http://localhost:8080/api/github + - name: Install snapshot test dependencies + working-directory: tests + run: npm ci && npx playwright install --with-deps + - name: Run snapshot tests + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx start-server-and-test watch:dev-server http://localhost:8080 "env -u GITHUB_TOKEN npm --prefix tests test" diff --git a/.github/workflows/test-wtih-vscode-build.yml b/.github/workflows/test-wtih-vscode-build.yml index d04876a72..2d35e02a9 100644 --- a/.github/workflows/test-wtih-vscode-build.yml +++ b/.github/workflows/test-wtih-vscode-build.yml @@ -37,5 +37,12 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npm run build - run: npm run link && npm run build - - uses: microsoft/playwright-github-action@v1 - - run: npm run test:ci + env: + GITHUB_API_PREFIX: http://localhost:8080/api/github + - name: Install snapshot test dependencies + working-directory: tests + run: npm ci && npx playwright install --with-deps + - name: Run snapshot tests + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx start-server-and-test watch:dev-server http://localhost:8080 "env -u GITHUB_TOKEN npm --prefix tests test" diff --git a/.gitignore b/.gitignore index 7f61e2ee3..14ab9cbcc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist out node_modules .worktrees/ +tests/__tests__/__image_snapshots__/__diff_output__/ diff --git a/docs/deployment.md b/docs/deployment.md index 0f74e4b4f..6ee4a85df 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -93,7 +93,7 @@ npm run build npx wrangler pages dev dist ``` -Omit the OAuth configuration and export for a preview without GitHub OAuth. Open `http://localhost:8788/conwnet/github1s`. This differs from `npm run watch`, whose webpack server runs on port `8080` and only proxies GitHub code search. See [Pages local development](https://developers.cloudflare.com/pages/functions/local-development/) and [local secrets](https://developers.cloudflare.com/pages/functions/bindings/#local-development-with-secrets). +Omit the OAuth configuration and export for a preview without GitHub OAuth. Open `http://localhost:8788/conwnet/github1s`. See [Pages local development](https://developers.cloudflare.com/pages/functions/local-development/) and [local secrets](https://developers.cloudflare.com/pages/functions/bindings/#local-development-with-secrets). ## Other static hosts diff --git a/docs/development.md b/docs/development.md index b66663f99..7512f4710 100644 --- a/docs/development.md +++ b/docs/development.md @@ -25,7 +25,7 @@ The install step also installs dependencies for the local extensions. Watch mode Wait for the application and both extensions to finish compiling, then open [localhost:8080/conwnet/github1s](http://localhost:8080/conwnet/github1s). The development server uses port `8080` and writes generated assets to `dist/`. -GitHub code search is proxied by the development server. OAuth callback Functions are not run by `npm run watch`; use a manually supplied token for repository authentication or follow the [Pages development instructions](deployment.md#preview-pages-functions-locally). +OAuth callback Functions are not run by `npm run watch`; use a manually supplied token for repository authentication or follow the [Pages development instructions](deployment.md#preview-pages-functions-locally). ## Build the application diff --git a/extensions/github1s/package.json b/extensions/github1s/package.json index 325daaa83..6ce5881c1 100644 --- a/extensions/github1s/package.json +++ b/extensions/github1s/package.json @@ -9,7 +9,8 @@ "textSearchProvider", "scmHistoryProvider", "contribSourceControlHistoryTitleMenu", - "contribSourceControlHistoryItemMenu" + "contribSourceControlHistoryItemMenu", + "timeline" ], "directories": { "lib": "lib" @@ -74,12 +75,6 @@ } ], "scm": [ - { - "id": "github1s.views.fileHistory", - "name": "File History", - "icon": "$(history)", - "when": "github1s:views:fileHistory:visible == true" - }, { "id": "github1s.views.codeReviewList", "name": "Code Reviews", @@ -172,18 +167,6 @@ "icon": "$(globe)", "enablement": "github1s:adapters:default:platformName != 'GitHub' && github1s:adapters:default:platformName != 'GitLab' && github1s:adapters:default:platformName != 'Bitbucket'" }, - { - "command": "github1s.commands.refreshFileHistoryCommitList", - "title": "Refresh", - "category": "GitHub1s", - "icon": "$(refresh)" - }, - { - "command": "github1s.commands.refreshCommitList", - "title": "Refresh", - "category": "GitHub1s", - "icon": "$(refresh)" - }, { "command": "github1s.commands.searchCommit", "title": "Search", @@ -196,12 +179,6 @@ "category": "GitHub1s", "icon": "$(log-in)" }, - { - "command": "github1s.commands.diffCommitFile", - "title": "Diff File", - "category": "GitHub1s", - "icon": "$(diff)" - }, { "command": "github1s.commands.openCommitOnGitHub", "title": "Open on GitHub", @@ -423,10 +400,6 @@ "command": "github1s.commands.openCodeReviewOnOfficialPage", "when": "false" }, - { - "command": "github1s.commands.refreshCommitList", - "when": "false" - }, { "command": "github1s.commands.searchCommit", "when": "false" @@ -486,16 +459,6 @@ "command": "github1s.commands.searchCodeReview", "when": "view == 'github1s.views.codeReviewList'", "group": "navigation@2" - }, - { - "command": "github1s.commands.refreshFileHistoryCommitList", - "when": "view == 'github1s.views.fileHistory'", - "group": "navigation@1" - }, - { - "command": "github1s.commands.searchCommit", - "when": "view == 'github1s.views.fileHistory'", - "group": "navigation@2" } ], "view/item/context": [ @@ -538,36 +501,6 @@ "command": "github1s.commands.openCodeReviewOnOfficialPage", "when": "viewItem == 'github1s:viewItems:codeReviewListItem' && github1s:adapters:default:platformName != 'GitHub' && github1s:adapters:default:platformName != 'GitLab' && github1s:adapters:default:platformName != 'Bitbucket' && github1s:adapters:default:platformName != 'npm'", "group": "inline@2" - }, - { - "command": "github1s.commands.switchToCommit", - "when": "viewItem == 'github1s:viewItems:commitListItem'", - "group": "inline@1" - }, - { - "command": "github1s.commands.openCommitOnGitHub", - "when": "viewItem == 'github1s:viewItems:commitListItem' && github1s:adapters:default:platformName == 'GitHub'", - "group": "inline@2" - }, - { - "command": "github1s.commands.diffCommitFile", - "when": "viewItem == 'github1s:viewItems:commitListItem' && view == 'github1s.views.fileHistory'", - "group": "inline@3" - }, - { - "command": "github1s.commands.openCommitOnGitLab", - "when": "viewItem == 'github1s:viewItems:commitListItem' && github1s:adapters:default:platformName == 'GitLab'", - "group": "inline@2" - }, - { - "command": "github1s.commands.openCommitOnBitbucket", - "when": "viewItem == 'github1s:viewItems:commitListItem' && github1s:adapters:default:platformName == 'Bitbucket'", - "group": "inline@2" - }, - { - "command": "github1s.commands.openCommitOnOfficialPage", - "when": "viewItem == 'github1s:viewItems:commitListItem' && github1s:adapters:default:platformName != 'GitHub' && github1s:adapters:default:platformName != 'GitLab' && github1s:adapters:default:platformName != 'Bitbucket' && github1s:adapters:default:platformName != 'npm'", - "group": "inline@2" } ], "editor/title": [ @@ -640,6 +573,33 @@ "when": "scmProvider == github1s && github1s:adapters:default:platformName != 'GitHub' && github1s:adapters:default:platformName != 'GitLab' && github1s:adapters:default:platformName != 'Bitbucket' && github1s:adapters:default:platformName != 'npm'", "group": "inline@2" } + ], + "timeline/item/context": [ + { + "command": "github1s.commands.switchToCommit", + "when": "timelineItem == 'github1s:timeline:commit'", + "group": "inline@1" + }, + { + "command": "github1s.commands.openCommitOnGitHub", + "when": "timelineItem == 'github1s:timeline:commit' && github1s:adapters:default:platformName == 'GitHub'", + "group": "inline@2" + }, + { + "command": "github1s.commands.openCommitOnGitLab", + "when": "timelineItem == 'github1s:timeline:commit' && github1s:adapters:default:platformName == 'GitLab'", + "group": "inline@2" + }, + { + "command": "github1s.commands.openCommitOnBitbucket", + "when": "timelineItem == 'github1s:timeline:commit' && github1s:adapters:default:platformName == 'Bitbucket'", + "group": "inline@2" + }, + { + "command": "github1s.commands.openCommitOnOfficialPage", + "when": "timelineItem == 'github1s:timeline:commit' && github1s:adapters:default:platformName != 'GitHub' && github1s:adapters:default:platformName != 'GitLab' && github1s:adapters:default:platformName != 'Bitbucket' && github1s:adapters:default:platformName != 'npm'", + "group": "inline@2" + } ] } }, diff --git a/extensions/github1s/src/adapters/bitbucket1s/index.ts b/extensions/github1s/src/adapters/bitbucket1s/index.ts index 8be7a8b89..11a4bded2 100644 --- a/extensions/github1s/src/adapters/bitbucket1s/index.ts +++ b/extensions/github1s/src/adapters/bitbucket1s/index.ts @@ -23,13 +23,11 @@ export class BitbucketAdapter implements Adapter { activateAsDefault() { setVSCodeContext('github1s:views:commitList:visible', true); - setVSCodeContext('github1s:views:fileHistory:visible', true); setVSCodeContext('github1s:features:gutterBlame:enabled', true); } deactivateAsDefault() { setVSCodeContext('github1s:views:commitList:visible', false); - setVSCodeContext('github1s:views:fileHistory:visible', false); setVSCodeContext('github1s:features:gutterBlame:enabled', false); } } diff --git a/extensions/github1s/src/adapters/github1s/data-source.ts b/extensions/github1s/src/adapters/github1s/data-source.ts index c3c0c3b0d..ef6eb9e2e 100644 --- a/extensions/github1s/src/adapters/github1s/data-source.ts +++ b/extensions/github1s/src/adapters/github1s/data-source.ts @@ -325,11 +325,11 @@ export class GitHub1sDataSource extends DataSource { async provideCommitChangedFiles( repoFullName: string, ref: string, - _options?: CommonQueryOptions, + options?: CommonQueryOptions, ): Promise { const fetcher = GitHubFetcher.getInstance(); const { owner, repo } = parseRepoFullName(repoFullName); - const requestParams = { owner, repo, ref }; + const requestParams = { owner, repo, ref, per_page: options?.pageSize, page: options?.page }; const { data } = await fetcher.request('GET /repos/{owner}/{repo}/commits/{ref}', requestParams); return ( data.files?.map((item) => ({ diff --git a/extensions/github1s/src/adapters/github1s/index.ts b/extensions/github1s/src/adapters/github1s/index.ts index 7451ae337..cb42e44af 100644 --- a/extensions/github1s/src/adapters/github1s/index.ts +++ b/extensions/github1s/src/adapters/github1s/index.ts @@ -31,7 +31,6 @@ export class GitHub1sAdapter implements Adapter { setVSCodeContext('github1s:views:settings:visible', true); setVSCodeContext('github1s:views:codeReviewList:visible', true); setVSCodeContext('github1s:views:commitList:visible', true); - setVSCodeContext('github1s:views:fileHistory:visible', true); setVSCodeContext('github1s:features:gutterBlame:enabled', true); vscode.window.registerWebviewViewProvider( @@ -54,7 +53,6 @@ export class GitHub1sAdapter implements Adapter { setVSCodeContext('github1s:views:settings:visible', false); setVSCodeContext('github1s:views:codeReviewList:visible', false); setVSCodeContext('github1s:views:commitList:visible', false); - setVSCodeContext('github1s:views:fileHistory:visible', false); setVSCodeContext('github1s:features:gutterBlame:enabled', false); } } diff --git a/extensions/github1s/src/adapters/gitlab1s/data-source.ts b/extensions/github1s/src/adapters/gitlab1s/data-source.ts index 73cff23b9..665a7e0a5 100644 --- a/extensions/github1s/src/adapters/gitlab1s/data-source.ts +++ b/extensions/github1s/src/adapters/gitlab1s/data-source.ts @@ -296,10 +296,13 @@ export class GitLab1sDataSource extends DataSource { } @trySourcegraphApiFirst - async provideCommitChangedFiles(repo: string, ref: string, _options?: CommonQueryOptions): Promise { + async provideCommitChangedFiles(repo: string, ref: string, options?: CommonQueryOptions): Promise { const fetcher = GitLabFetcher.getInstance(); - const requestParams = { repo, ref }; - const { data } = await fetcher.request('GET /projects/{repo}/repository/commits/{ref}/diff', requestParams); + const requestParams = { repo, ref, per_page: options?.pageSize, page: options?.page }; + const { data } = await fetcher.request( + 'GET /projects/{repo}/repository/commits/{ref}/diff?per_page={per_page}&page={page}', + requestParams, + ); return ( data?.map((item) => ({ path: normalizePath(item.new_path || item.old_path!), diff --git a/extensions/github1s/src/adapters/gitlab1s/index.ts b/extensions/github1s/src/adapters/gitlab1s/index.ts index 5d6db81c0..34091859e 100644 --- a/extensions/github1s/src/adapters/gitlab1s/index.ts +++ b/extensions/github1s/src/adapters/gitlab1s/index.ts @@ -31,7 +31,6 @@ export class GitLab1sAdapter implements Adapter { setVSCodeContext('github1s:views:settings:visible', true); setVSCodeContext('github1s:views:codeReviewList:visible', true); setVSCodeContext('github1s:views:commitList:visible', true); - setVSCodeContext('github1s:views:fileHistory:visible', true); setVSCodeContext('github1s:features:gutterBlame:enabled', true); vscode.window.registerWebviewViewProvider( @@ -52,7 +51,6 @@ export class GitLab1sAdapter implements Adapter { setVSCodeContext('github1s:views:settings:visible', false); setVSCodeContext('github1s:views:codeReviewList:visible', false); setVSCodeContext('github1s:views:commitList:visible', false); - setVSCodeContext('github1s:views:fileHistory:visible', false); setVSCodeContext('github1s:features:gutterBlame:enabled', false); } } diff --git a/extensions/github1s/src/adapters/sourcegraph/comparison.ts b/extensions/github1s/src/adapters/sourcegraph/comparison.ts index dc7607ef1..d46adadae 100644 --- a/extensions/github1s/src/adapters/sourcegraph/comparison.ts +++ b/extensions/github1s/src/adapters/sourcegraph/comparison.ts @@ -8,10 +8,10 @@ import { ChangedFile, FileChangeStatus } from '../types'; import { querySourcegraphRepository } from './common'; const ComparisonQuery = gql` - query ($repository: String!, $base: String!, $head: String!) { + query ($repository: String!, $base: String!, $head: String!, $first: Int) { repository(name: $repository) { comparison(base: $base, head: $head) { - fileDiffs { + fileDiffs(first: $first) { nodes { newPath oldPath @@ -35,10 +35,15 @@ const getFileChangeStatus = (oldPath: string | null, newPath: string | null): Fi return FileChangeStatus.Modified; }; -export const compareCommits = async (repository: string, base: string, head: string): Promise => { +export const compareCommits = async ( + repository: string, + base: string, + head: string, + limit?: number, +): Promise => { const repositoryData = await querySourcegraphRepository({ query: ComparisonQuery, - variables: { repository, base, head }, + variables: { repository, base, head, first: limit }, }); const diffFiles = repositoryData.comparison?.fileDiffs?.nodes || []; diff --git a/extensions/github1s/src/adapters/sourcegraph/data-source.ts b/extensions/github1s/src/adapters/sourcegraph/data-source.ts index bf82b0a51..901c165f6 100644 --- a/extensions/github1s/src/adapters/sourcegraph/data-source.ts +++ b/extensions/github1s/src/adapters/sourcegraph/data-source.ts @@ -206,8 +206,15 @@ export class SourcegraphDataSource extends DataSource { return getCommit(this.buildRepository(repo), ref); } - async provideCommitChangedFiles(repo: string, ref: string, _options?: CommonQueryOptions): Promise { - return (await compareCommits(this.buildRepository(repo), `${ref}~`, ref)).map((file) => ({ + async provideCommitChangedFiles(repo: string, ref: string, options?: CommonQueryOptions): Promise { + const changedFiles = await compareCommits( + this.buildRepository(repo), + `${ref}~`, + ref, + options?.pageSize ? options.pageSize * (options.page || 1) : undefined, + ); + const files = options?.pageSize ? changedFiles.slice(options.pageSize * ((options.page || 1) - 1)) : changedFiles; + return files.map((file) => ({ ...file, path: normalizePath(file.path), previousPath: file.previousPath ? normalizePath(file.previousPath) : undefined, diff --git a/extensions/github1s/src/changes/files.ts b/extensions/github1s/src/changes/files.ts index 8594edc94..c20d45407 100644 --- a/extensions/github1s/src/changes/files.ts +++ b/extensions/github1s/src/changes/files.ts @@ -38,14 +38,17 @@ export const getCodeReviewChangedFiles = async ( }); }; -export const getCommitChangedFiles = async (commit: adapterTypes.Commit) => { - const repository = Repository.getCurrentInstance(); +export const getCommitChangedFiles = async ( + commit: adapterTypes.Commit, + repository = Repository.getCurrentInstance(), +) => { + const { scheme, repo } = repository; // if the commit.parents is more than one element // the parents[1].sha should be the merge source commitSha // so we use the parents[0].sha as the parent commitSha const parentCommitSha = commit?.parents?.[0] || ''; - const baseRootUri = router.buildUri({ ref: parentCommitSha }); - const headRootUri = router.buildUri({ ref: commit.sha }, baseRootUri); + const headRootUri = router.buildUri({ scheme, repo, ref: commit.sha }); + const baseRootUri = router.buildUri({ ref: parentCommitSha }, headRootUri); const changedFiles = await repository.getCommitChangedFiles(commit.sha); return changedFiles.map((commitFile) => { diff --git a/extensions/github1s/src/changes/history.ts b/extensions/github1s/src/changes/history.ts index 742953637..410b363b6 100644 --- a/extensions/github1s/src/changes/history.ts +++ b/extensions/github1s/src/changes/history.ts @@ -5,31 +5,35 @@ import { Repository } from '@/repository'; import router from '@/router'; import { getCommitChangedFiles } from './files'; +// Expose commit history for the router's current repository and ref in the SCM graph. export class GitHub1sHistoryProvider implements vscode.SourceControlHistoryProvider, vscode.Disposable { - // Current route ref, with its commit SHA filled in after the first page loads. + private static instance: GitHub1sHistoryProvider | null = null; + // The SCM graph requires a current ref to initialize, but no commit is marked as current. currentHistoryItemRef: vscode.SourceControlHistoryItemRef | undefined; - // This provider does not expose upstream or comparison-base refs. readonly currentHistoryItemRemoteRef = undefined; readonly currentHistoryItemBaseRef = undefined; - // Tell VS Code to reread the current refs above. private readonly currentRefsChanged = new vscode.EventEmitter(); readonly onDidChangeCurrentHistoryItemRefs = this.currentRefsChanged.event; - // Report ref changes so VS Code can refresh the graph, even when the ref ID is unchanged. private readonly refsChanged = new vscode.EventEmitter(); readonly onDidChangeHistoryItemRefs = this.refsChanged.event; - private readonly removeRouterListener = router.addListener((current, previous) => { - if (current.repo !== previous.repo || current.ref !== previous.ref) { - this.refresh(); + + private constructor() {} + + public static getInstance(): GitHub1sHistoryProvider { + if (!GitHub1sHistoryProvider.instance) { + GitHub1sHistoryProvider.instance = new GitHub1sHistoryProvider(); } - }); + return GitHub1sHistoryProvider.instance; + } refresh() { const { repo, ref } = router.getState(); const previousRef = this.currentHistoryItemRef; - this.currentHistoryItemRef = repo ? { id: ref, name: ref, icon: new vscode.ThemeIcon('target') } : undefined; + this.currentHistoryItemRef = repo ? { id: ref, name: ref } : undefined; this.currentRefsChanged.fire(); // Repository navigation can keep the same branch name (for example, "main"). + // Explicitly mark the ref as modified so the graph reloads even when its ID is unchanged. if (previousRef?.id === this.currentHistoryItemRef?.id) { this.refsChanged.fire({ added: [], @@ -40,87 +44,95 @@ export class GitHub1sHistoryProvider implements vscode.SourceControlHistoryProvi } } + // Release event emitters and invalidate pending history requests. + dispose() { + this.currentHistoryItemRef = undefined; + this.currentRefsChanged.dispose(); + this.refsChanged.dispose(); + } + + // List refs for the SCM graph's reference picker and filters, + // optionally restricted by ID. Only the router's current ref is exposed. provideHistoryItemRefs(ids: string[] | undefined): vscode.SourceControlHistoryItemRef[] { const ref = this.currentHistoryItemRef; return ref && (!ids || ids.includes(ref.id)) ? [ref] : []; } + // Return a page of commits for the SCM graph using the current router ref. async provideHistoryItems(options: vscode.SourceControlHistoryOptions, token: vscode.CancellationToken) { const currentRef = this.currentHistoryItemRef; if (!currentRef || token.isCancellationRequested) { return []; } - const repository = Repository.getCurrentInstance(); - const { ref } = router.getState(); - const skip = options.skip ?? 0; - const limit = typeof options.limit === 'number' ? options.limit : 50; - let commits = await repository.getCommitList(ref, '/', skip === 0); - - while (commits.length < skip + limit && (await repository.hasMoreCommits(ref))) { - if (token.isCancellationRequested || currentRef !== this.currentHistoryItemRef) { - return []; - } - await repository.loadMoreCommits(ref); - commits = await repository.getCommitList(ref); + const commits = await this.getCommitPage(currentRef, options, token); + // Refresh replaces the ref object, so identity also detects navigation to the same ref name. + // Discard results from requests that no longer belong to the current history view. + if (!commits || token.isCancellationRequested || currentRef !== this.currentHistoryItemRef) { + return []; } - if (token.isCancellationRequested || currentRef !== this.currentHistoryItemRef) { - return []; + return commits.map((commit) => this.toHistoryItem(commit)); + } + + private async getCommitPage( + ref: vscode.SourceControlHistoryItemRef, + { skip = 0, limit = 50 }: vscode.SourceControlHistoryOptions, + token: vscode.CancellationToken, + ): Promise { + const repository = Repository.getCurrentInstance(); + let commits = await repository.getCommitList(ref.id); + if (token.isCancellationRequested || ref !== this.currentHistoryItemRef) { + return undefined; } - if (skip === 0) { - this.currentHistoryItemRef = { ...currentRef, revision: commits[0]?.sha }; - this.currentRefsChanged.fire(); + // Reuse cached history and load at most one more page when it has been consumed. + if (skip > 0 && skip >= commits.length) { + await repository.loadMoreCommits(ref.id); + commits = await repository.getCommitList(ref.id); } - return commits.slice(skip, skip + limit).map((commit) => this.toHistoryItem(commit)); + // Commit-ID limits are not resolved here; use the default page size for that form. + const count = typeof limit === 'number' ? Math.max(0, limit) : 50; + return commits.slice(skip, skip + count); } + // Resolve a single commit by ID, independently of the currently loaded history pages. async resolveHistoryItem(id: string) { const commit = await Repository.getCurrentInstance().getCommitItem(id); return commit ? this.toHistoryItem(commit) : undefined; } + // Provide changed files and before/after URIs for a commit's diff view. async provideHistoryItemChanges(id: string, parentId: string | undefined) { const commit = await Repository.getCurrentInstance().getCommitItem(id); if (!commit) { return []; } - // The existing data source exposes individual commits, not arbitrary ranges. + // Changed files are computed against the first parent, including for merge commits. + // Reject other parents or ranges to avoid displaying a diff against the wrong base. if (parentId !== commit.parents[0]) { throw new Error("Only changes against a commit's first parent are supported."); } const files = await getCommitChangedFiles(commit); - return files.map((file) => { - // A missing side makes VS Code open the existing file directly for additions/deletions. - const originalUri = !parentId || file.status === FileChangeStatus.Added ? undefined : file.baseFileUri; - const modifiedUri = file.status === FileChangeStatus.Removed ? undefined : file.headFileUri; - return { - // Display resource for the file label and status badge, including deleted files. - uri: file.headFileUri.with({ query: queryString.stringify({ changeStatus: file.status }) }), - // Content resources for the diff's left (before) and right (after) sides. - originalUri, - modifiedUri, - }; - }); - } - - resolveHistoryItemRefsCommonAncestor(): undefined { - return undefined; - } - - resolveHistoryItemChatContext(): undefined { - return undefined; + return files.map((file) => ({ + // The decoration provider reads changeStatus from the resource URI. + uri: file.headFileUri.with({ query: queryString.stringify({ changeStatus: file.status }) }), + // Root commits and added files have no original content; removed files have no modified content. + originalUri: !parentId || file.status === FileChangeStatus.Added ? undefined : file.baseFileUri, + modifiedUri: file.status === FileChangeStatus.Removed ? undefined : file.headFileUri, + })); } - resolveHistoryItemChangeRangeChatContext(): undefined { - return undefined; - } + // Find the common ancestor of the requested refs for comparisons; unsupported here. + resolveHistoryItemRefsCommonAncestor = () => undefined; + // Provide commit context for chat requests; unsupported here. + resolveHistoryItemChatContext = () => undefined; + // Provide file-change context between two commits for chat requests; unsupported here. + resolveHistoryItemChangeRangeChatContext = () => undefined; private toHistoryItem(commit: Commit): vscode.SourceControlHistoryItem { return { id: commit.sha, - // Parent IDs define the graph edges. parentIds: commit.parents, subject: commit.message.split('\n')[0], message: commit.message, @@ -129,14 +141,6 @@ export class GitHub1sHistoryProvider implements vscode.SourceControlHistoryProvi authorEmail: commit.email, authorIcon: commit.avatarUrl ? vscode.Uri.parse(commit.avatarUrl) : undefined, timestamp: commit.createTime?.getTime(), - // Attach the current ref label to the commit it points to. - references: this.currentHistoryItemRef?.revision === commit.sha ? [this.currentHistoryItemRef] : undefined, }; } - - dispose() { - this.removeRouterListener(); - this.currentRefsChanged.dispose(); - this.refsChanged.dispose(); - } } diff --git a/extensions/github1s/src/changes/index.ts b/extensions/github1s/src/changes/index.ts index 8038f84b7..89a734990 100644 --- a/extensions/github1s/src/changes/index.ts +++ b/extensions/github1s/src/changes/index.ts @@ -16,7 +16,7 @@ sourceControl.quickDiffProvider = new GitHub1sQuickDiffProvider(); export const registerSourceControlHistory = () => { const context = getExtensionContext(); - const historyProvider = new GitHub1sHistoryProvider(); + const historyProvider = GitHub1sHistoryProvider.getInstance(); sourceControl.historyProvider = historyProvider; context.subscriptions.push(sourceControl, historyProvider); historyProvider.refresh(); diff --git a/extensions/github1s/src/commands/commit.ts b/extensions/github1s/src/commands/commit.ts index bc4aca509..6b6859ab4 100644 --- a/extensions/github1s/src/commands/commit.ts +++ b/extensions/github1s/src/commands/commit.ts @@ -4,25 +4,44 @@ */ import * as vscode from 'vscode'; -import queryString from 'query-string'; import router from '@/router'; import { getAdapter } from '@/adapters'; import { Repository } from '@/repository'; -import { CommitTreeItem, getCommitTreeItemDescription } from '@/views/commit-list'; -import { fileHistoryTreeDataProvider } from '@/views'; - -type CommitCommandArgument = string | CommitTreeItem | vscode.SourceControl; +import { getCommitDescription } from '@/helpers/commit'; +import { getChangedFileDiffCommand, getCommitChangedFiles } from '@/changes/files'; + +type CommitCommandArgument = string | vscode.TimelineItem | vscode.SourceControl; + +interface CommitContext { + scheme: string; + repo: string; + sha?: string; +} + +// SCM menus pass (sourceControl, historyItem); timeline menus pass (item, uri, source). +const resolveCommitContext = ( + item?: CommitCommandArgument, + historyItemOrUri?: vscode.SourceControlHistoryItem | vscode.Uri, +): CommitContext => { + if (historyItemOrUri && 'scheme' in historyItemOrUri) { + // for extensions/github1s/src/changes/history.ts + const { scheme, repo } = router.parseUri(historyItemOrUri); + return { scheme, repo, sha: typeof item === 'object' ? item.id : item }; + } -// Graph actions pass (sourceControl, historyItem); tree actions pass a CommitTreeItem. -const getCommitSha = (item?: CommitCommandArgument, historyItem?: vscode.SourceControlHistoryItem): string => { - if (historyItem) { - return historyItem.id; + let sha: string | undefined; + if (historyItemOrUri && 'id' in historyItemOrUri) { + // for extensions/github1s/src/providers/timeline.ts + sha = historyItemOrUri.id; + } else if (typeof item === 'string') { + sha = item; } - return typeof item === 'string' ? item : item && 'commit' in item ? item.commit.sha : ''; + + return { scheme: getAdapter().scheme, repo: router.getState().repo, sha }; }; -export const checkCommitExists = async (repo: string, commitSha: string) => { - const dataSoruce = await getAdapter().resolveDataSource(); +const checkCommitExists = async (repo: string, commitSha: string, scheme?: string) => { + const dataSoruce = await getAdapter(scheme).resolveDataSource(); try { return !!(await dataSoruce.provideCommit(repo, commitSha)); } catch (error) { @@ -37,11 +56,10 @@ export const checkCommitExists = async (repo: string, commitSha: string) => { const commandSwitchToCommit = async ( commitItemOrSha?: CommitCommandArgument, - historyItem?: vscode.SourceControlHistoryItem, + historyItemOrUri?: vscode.SourceControlHistoryItem | vscode.Uri, ) => { - let commitSha: string | undefined = getCommitSha(commitItemOrSha, historyItem); - const { repo } = router.getState(); - const repository = Repository.getCurrentInstance(); + const { scheme, repo, sha } = resolveCommitContext(commitItemOrSha, historyItemOrUri); + let commitSha = sha; // if the a commitSha isn't provided, use quickInput if (!commitSha) { @@ -51,22 +69,18 @@ const commandSwitchToCommit = async ( alwaysShow: true, }; // use the commit list as the candidates + const repository = Repository.getInstance(scheme, repo); const commits = await repository.getCommitList(); const commitItems: vscode.QuickPickItem[] = commits.map((commit) => ({ commitSha: commit.sha, label: commit.message, - description: getCommitTreeItemDescription(commit), + description: getCommitDescription(commit), })); - const quickPick = vscode.window.createQuickPick(); - quickPick.matchOnDescription = true; - quickPick.items = [inputCommitShaItem, ...commitItems]; - quickPick.show(); - - const choice = (await new Promise((resolve) => - quickPick.onDidAccept(() => resolve(quickPick.activeItems[0])), - )) as vscode.QuickPickItem & { commitSha?: string }; - quickPick.hide(); + const choice = await vscode.window.showQuickPick( + [inputCommitShaItem, ...commitItems], + { matchOnDescription: true }, + ); // select nothing if (!choice) { @@ -84,78 +98,56 @@ const commandSwitchToCommit = async ( } } - const routerParser = router.getParser(); - if (await checkCommitExists(repo, commitSha!)) { - router.replace(await routerParser.buildCommitPath(repo, commitSha!)); - } -}; - -const commandDiffCommitFile = async (commitItem: CommitTreeItem) => { - const commitSha = commitItem.commit.sha; if (!commitSha) { return; } - const activeDocumentUri = vscode.window.activeTextEditor?.document?.uri; - if (!activeDocumentUri) { - return; - } - const from = await Repository.getCurrentInstance().getCommitItem(router.getState().ref); - if (!from) { - return; + + if (await checkCommitExists(repo, commitSha, scheme)) { + const routerParser = await getAdapter(scheme).resolveRouterParser(); + router.replace(await routerParser.buildCommitPath(repo, commitSha)); } - const fileUri = router.buildUri({ ref: commitSha }, activeDocumentUri); - return vscode.commands.executeCommand('github1s.commands.openFilePreviousRevision', fileUri); }; -// this command is used in `source control commit list view` const commandOpenCommitOnOfficialPage = async ( commitItemOrSha?: CommitCommandArgument, - historyItem?: vscode.SourceControlHistoryItem, + historyItemOrUri?: vscode.SourceControlHistoryItem | vscode.Uri, ) => { - const commitSha = getCommitSha(commitItemOrSha, historyItem); - if (commitSha) { - const { repo } = router.getState(); - const routerParser = router.getParser(); - const commitPath = await routerParser.buildCommitPath(repo, commitSha); - const commitLink = await routerParser.buildExternalLink(commitPath); - return vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(commitLink)); + const { scheme, repo, sha } = resolveCommitContext(commitItemOrSha, historyItemOrUri); + if (!sha) { + return; } -}; - -const commandRefreshCommitList = () => { - return vscode.commands.executeCommand('workbench.scm.action.graph.refresh'); -}; - -const commandRefreshFileHistoryCommitList = (forceUpdate = true) => { - return fileHistoryTreeDataProvider.updateTree(forceUpdate); -}; -const commandLoadMoreFileHistoryCommits = async () => { - return fileHistoryTreeDataProvider.loadMoreCommits(); + const routerParser = await getAdapter(scheme).resolveRouterParser(); + const commitPath = await routerParser.buildCommitPath(repo, sha); + const commitLink = await routerParser.buildExternalLink(commitPath); + return vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(commitLink)); }; -const commandLoadMoreFileHistoryCommitChangedFiles = async (commitSha: string) => { - return fileHistoryTreeDataProvider.loadMoreChangedFiles(commitSha); +const commandDiffCommitFile = async (uri: vscode.Uri) => { + const { scheme, repo, ref, path } = router.parseUri(uri); + const repository = Repository.getInstance(scheme, repo); + const commit = await repository.getCommitItem(ref); + if (!commit) { + throw new Error(`Commit not found: ${ref}`); + } + const files = await getCommitChangedFiles(commit, repository); + const file = + files.find((file) => file.headFileUri.path === path) || files.find((file) => file.baseFileUri.path === path); + if (!file) { + throw new Error(`No changes found for ${path} in ${commit.sha}`); + } + const command = getChangedFileDiffCommand(file); + return vscode.commands.executeCommand(command.command, ...(command.arguments || [])); }; export const registerCommitCommands = (context: vscode.ExtensionContext) => { return context.subscriptions.push( - vscode.commands.registerCommand('github1s.commands.refreshCommitList', commandRefreshCommitList), + vscode.commands.registerCommand('github1s.commands.diffCommitFile', commandDiffCommitFile), vscode.commands.registerCommand('github1s.commands.searchCommit', commandSwitchToCommit), vscode.commands.registerCommand('github1s.commands.switchToCommit', commandSwitchToCommit), - vscode.commands.registerCommand('github1s.commands.diffCommitFile', commandDiffCommitFile), vscode.commands.registerCommand('github1s.commands.openCommitOnGitHub', commandOpenCommitOnOfficialPage), vscode.commands.registerCommand('github1s.commands.openCommitOnGitLab', commandOpenCommitOnOfficialPage), vscode.commands.registerCommand('github1s.commands.openCommitOnBitbucket', commandOpenCommitOnOfficialPage), vscode.commands.registerCommand('github1s.commands.openCommitOnOfficialPage', commandOpenCommitOnOfficialPage), - vscode.commands.registerCommand('github1s.commands.loadMoreFileHistoryCommits', commandLoadMoreFileHistoryCommits), - vscode.commands.registerCommand( - 'github1s.commands.loadMoreFileHistoryCommitChangedFiles', - commandLoadMoreFileHistoryCommitChangedFiles, - ), - vscode.commands.registerCommand( - 'github1s.commands.refreshFileHistoryCommitList', - commandRefreshFileHistoryCommitList, - ), ); }; diff --git a/extensions/github1s/src/helpers/commit.ts b/extensions/github1s/src/helpers/commit.ts new file mode 100644 index 000000000..7288b2898 --- /dev/null +++ b/extensions/github1s/src/helpers/commit.ts @@ -0,0 +1,19 @@ +/** + * @file Commit display helpers + */ + +import type { Commit } from '@/adapters/types'; +import { relativeTimeTo, toISOString } from './date'; + +export const getCommitDescription = (commit: Commit): string => { + const shortCommitSha = commit.sha.slice(0, 7); + const relativeTimeStr = commit.createTime ? relativeTimeTo(commit.createTime) : null; + return [shortCommitSha, commit.author, relativeTimeStr].filter(Boolean).join(', '); +}; + +export const getCommitTooltip = (commit: Commit): string => { + const shortCommitSha = commit.sha.slice(0, 7); + const ISOTimeStr = commit.createTime ? toISOString(commit.createTime) : null; + const detailText = [shortCommitSha, commit.author, ISOTimeStr].filter(Boolean).join(', '); + return `${commit.message}\n(${detailText})`; +}; diff --git a/extensions/github1s/src/listeners/router/history.ts b/extensions/github1s/src/listeners/router/history.ts new file mode 100644 index 000000000..4e86782dc --- /dev/null +++ b/extensions/github1s/src/listeners/router/history.ts @@ -0,0 +1,12 @@ +/** + * @file Router listener for the source control history + */ + +import type { RouterState } from '@/adapters/types'; +import { GitHub1sHistoryProvider } from '@/changes/history'; + +export const historyRouterListener = (currentState: RouterState, previousState: RouterState) => { + if (currentState.repo !== previousState.repo || currentState.ref !== previousState.ref) { + GitHub1sHistoryProvider.getInstance().refresh(); + } +}; diff --git a/extensions/github1s/src/listeners/router/index.ts b/extensions/github1s/src/listeners/router/index.ts index bd2d28f6a..ccdeff80f 100644 --- a/extensions/github1s/src/listeners/router/index.ts +++ b/extensions/github1s/src/listeners/router/index.ts @@ -3,11 +3,23 @@ * @author netcon */ +import * as vscode from 'vscode'; import router from '@/router'; +import { getExtensionContext } from '@/helpers/context'; import { explorerRouterListener } from './explorer'; import { sourceControlRouterListener } from './changes'; +import { historyRouterListener } from './history'; +import { timelineRouterListener } from './timeline'; export const registerRouterEventListeners = () => { - router.addListener(explorerRouterListener); - router.addListener(sourceControlRouterListener); + const listeners = [ + explorerRouterListener, + sourceControlRouterListener, + historyRouterListener, + timelineRouterListener, + ]; + + for (const listener of listeners) { + getExtensionContext().subscriptions.push(new vscode.Disposable(router.addListener(listener))); + } }; diff --git a/extensions/github1s/src/listeners/router/timeline.ts b/extensions/github1s/src/listeners/router/timeline.ts new file mode 100644 index 000000000..22f7ca457 --- /dev/null +++ b/extensions/github1s/src/listeners/router/timeline.ts @@ -0,0 +1,12 @@ +/** + * @file Router listener for the file history timeline + */ + +import type { RouterState } from '@/adapters/types'; +import { FileHistoryTimelineProvider } from '@/providers/timeline'; + +export const timelineRouterListener = (currentState: RouterState, previousState: RouterState) => { + if (currentState.repo !== previousState.repo || currentState.ref !== previousState.ref) { + FileHistoryTimelineProvider.getInstance().refresh(); + } +}; diff --git a/extensions/github1s/src/listeners/vscode.ts b/extensions/github1s/src/listeners/vscode.ts index 9fbb311bf..bd1708f5c 100644 --- a/extensions/github1s/src/listeners/vscode.ts +++ b/extensions/github1s/src/listeners/vscode.ts @@ -70,17 +70,11 @@ const handleRouterOnTextEditorSelectionChange = async (editor: vscode.TextEditor browserPath !== router.getPath() && router.replace(browserPath); }; -// refresh file history view if active editor changed -const handleRefreshFileHistoryView = () => { - vscode.commands.executeCommand('github1s.commands.refreshFileHistoryCommitList', false); -}; - export const registerVSCodeEventListeners = () => { vscode.window.onDidChangeActiveTextEditor((editor) => { handleRouterOnActiveEditorChange(editor); handleOpenChangesContextOnActiveEditorChange(editor); handlegutterBlameOpenContextOnActiveEditorChange(); - handleRefreshFileHistoryView(); }); // debounce to update the browser url diff --git a/extensions/github1s/src/providers/index.ts b/extensions/github1s/src/providers/index.ts index 0fb44ed81..df8ad4e9f 100644 --- a/extensions/github1s/src/providers/index.ts +++ b/extensions/github1s/src/providers/index.ts @@ -15,12 +15,14 @@ import { GitHub1sSourceControlDecorationProvider } from './decorations/source-co import { GitHub1sDefinitionProvider } from './definition'; import { GitHub1sReferenceProvider } from './reference'; import { GitHub1sHoverProvider } from './hover'; +import { FileHistoryTimelineProvider } from './timeline'; export const EMPTY_FILE_SCHEME = 'github1s-empty-file'; export const emptyFileUri = vscode.Uri.from({ scheme: EMPTY_FILE_SCHEME }); export const registerVSCodeProviders = () => { const context = getExtensionContext(); + const fileHistoryProvider = FileHistoryTimelineProvider.getInstance(); const allSchemes = getAllAdapters().map((item) => item.scheme); allSchemes.forEach((scheme) => { @@ -38,6 +40,8 @@ export const registerVSCodeProviders = () => { }); context.subscriptions.push( + fileHistoryProvider, + vscode.workspace.registerTimelineProvider(FileHistoryTimelineProvider.schemes, fileHistoryProvider), vscode.window.registerFileDecorationProvider(GitHub1sSubmoduleDecorationProvider.getInstance()), vscode.window.registerFileDecorationProvider(GitHub1sChangedFileDecorationProvider.getInstance()), vscode.window.registerFileDecorationProvider(GitHub1sSourceControlDecorationProvider.getInstance()), diff --git a/extensions/github1s/src/providers/timeline.ts b/extensions/github1s/src/providers/timeline.ts new file mode 100644 index 000000000..6fdac4501 --- /dev/null +++ b/extensions/github1s/src/providers/timeline.ts @@ -0,0 +1,132 @@ +/** + * @file File history timeline + */ + +import * as vscode from 'vscode'; +import router, { UriState } from '@/router'; +import { Repository } from '@/repository'; +import { getCommitTooltip } from '@/helpers/commit'; +import { Commit } from '@/adapters/types'; + +export class FileHistoryTimelineProvider implements vscode.TimelineProvider, vscode.Disposable { + private static instance: FileHistoryTimelineProvider | null = null; + public static readonly schemes = ['github1s', 'gitlab1s', 'bitbucket1s']; + readonly id = 'github1s.fileHistory'; + readonly label = 'Git History'; + + private readonly changed = new vscode.EventEmitter(); + readonly onDidChange = this.changed.event; + // Incremented on refresh so requests started earlier cannot publish stale results. + private refreshVersion = 0; + + private constructor() {} + + public static getInstance(): FileHistoryTimelineProvider { + if (!FileHistoryTimelineProvider.instance) { + FileHistoryTimelineProvider.instance = new FileHistoryTimelineProvider(); + } + return FileHistoryTimelineProvider.instance; + } + + refresh() { + this.refreshVersion++; + this.changed.fire(undefined); + } + + dispose() { + this.refreshVersion++; + this.changed.dispose(); + } + + async provideTimeline( + uri: vscode.Uri, + options: vscode.TimelineOptions, + token: vscode.CancellationToken, + ): Promise { + if ( + !FileHistoryTimelineProvider.schemes.includes(uri.scheme) || + uri.path === '/' || + token.isCancellationRequested + ) { + return undefined; + } + + const { scheme, repo, ref: fileRef, path } = router.parseUri(uri); + if (!repo) { + return undefined; + } + const state = router.getState(); + // Keep historical editors anchored to the workspace ref. + const ref = repo === state.repo ? state.ref : fileRef; + const version = this.refreshVersion; + const page = await this.getCommitPage({ scheme, repo, ref, path }, options, token); + if (!page || token.isCancellationRequested || version !== this.refreshVersion) { + return undefined; + } + + const fileUri = router.buildUri({ repo, ref }, uri).with({ query: '', fragment: '' }); + return { + items: page.commits.map((commit) => this.toTimelineItem(commit, fileUri)), + paging: { cursor: page.cursor }, + }; + } + + private async getCommitPage( + { scheme, repo, ref, path }: UriState, + { cursor, limit }: vscode.TimelineOptions, + token: vscode.CancellationToken, + ): Promise<{ commits: Commit[]; cursor?: string } | undefined> { + const repository = Repository.getInstance(scheme, repo); + const version = this.refreshVersion; + let commits = await repository.getCommitList(ref, path); + if (token.isCancellationRequested || version !== this.refreshVersion) { + return undefined; + } + + // Reuse cached history and load at most one more page when its last commit is reached. + if (cursor && cursor === commits[commits.length - 1]?.sha) { + await repository.loadMoreCommits(ref, path); + commits = await repository.getCommitList(ref, path); + } + + const cursorIndex = cursor ? commits.findIndex((commit) => commit.sha === cursor) : -1; + if (cursor && cursorIndex === -1) { + return { commits: [] }; + } + let page = commits.slice(cursorIndex + 1); + if (typeof limit === 'number') { + page = page.slice(0, Math.max(0, limit)); + } else if (limit) { + // Include the ID boundary; exclude commits older than the timestamp boundary. + const boundaryIndex = page.findIndex((commit) => + limit.id ? commit.sha === limit.id : (commit.createTime?.getTime() ?? 0) < limit.timestamp, + ); + if (boundaryIndex !== -1) { + page = page.slice(0, boundaryIndex + (limit.id ? 1 : 0)); + } + } + + const lastCommit = page[page.length - 1]; + const hasMore = + lastCommit !== undefined && + (lastCommit.sha !== commits[commits.length - 1]?.sha || (await repository.hasMoreCommits(ref, path))); + return { commits: page, cursor: hasMore ? lastCommit.sha : undefined }; + } + + private toTimelineItem(commit: Commit, uri: vscode.Uri): vscode.TimelineItem { + return { + id: commit.sha, + label: commit.message.split(/[\r\n]/)[0], + timestamp: commit.createTime?.getTime() ?? 0, + description: [commit.sha.slice(0, 7), commit.author].filter(Boolean).join(', '), + tooltip: getCommitTooltip(commit), + iconPath: commit.avatarUrl ? vscode.Uri.parse(commit.avatarUrl) : new vscode.ThemeIcon('git-commit'), + contextValue: 'github1s:timeline:commit', + command: { + title: 'Open Changes', + command: 'github1s.commands.diffCommitFile', + arguments: [router.buildUri({ ref: commit.sha }, uri)], + }, + }; + } +} diff --git a/extensions/github1s/src/repository/index.ts b/extensions/github1s/src/repository/index.ts index ae8336983..f66c41f91 100644 --- a/extensions/github1s/src/repository/index.ts +++ b/extensions/github1s/src/repository/index.ts @@ -31,12 +31,12 @@ export class Repository { } private constructor( - private _scheme: string, - private _repo: string, + public readonly scheme: string, + public readonly repo: string, ) { - this._branchTagManager = BranchTagManager.getInstance(_scheme, _repo); - this._codeReviewManager = CodeReviewManager.getInstance(_scheme, _repo); - this._commitManager = CommitManager.getInstance(_scheme, _repo); + this._branchTagManager = BranchTagManager.getInstance(scheme, repo); + this._codeReviewManager = CodeReviewManager.getInstance(scheme, repo); + this._commitManager = CommitManager.getInstance(scheme, repo); this._blameRangesCache = new Map(); } @@ -143,8 +143,8 @@ export class Repository { async getFileBlameRanges(ref: string, path: string) { const cacheKey = `${ref} ${path}`; if (!this._blameRangesCache.has(cacheKey)) { - const dataSource = await getAdapter(this._scheme).resolveDataSource(); - const blameRanges = await dataSource.provideFileBlameRanges(this._repo, ref, path); + const dataSource = await getAdapter(this.scheme).resolveDataSource(); + const blameRanges = await dataSource.provideFileBlameRanges(this.repo, ref, path); this._blameRangesCache.set(cacheKey, blameRanges); } return this._blameRangesCache.get(cacheKey) || []; diff --git a/extensions/github1s/src/views/commit-list.ts b/extensions/github1s/src/views/commit-list.ts deleted file mode 100644 index 0b787b35a..000000000 --- a/extensions/github1s/src/views/commit-list.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * @file GitHub Commit List View - * @author netcon - */ - -import * as vscode from 'vscode'; -import router from '@/router'; -import { getAdapter } from '@/adapters'; -import { Repository } from '@/repository'; -import { Barrier } from '@/helpers/async'; -import queryString from 'query-string'; -import { relativeTimeTo, toISOString } from '@/helpers/date'; -import * as adapterTypes from '@/adapters/types'; -import { getChangedFileDiffCommand, getCommitChangedFiles } from '@/changes/files'; -import { GitHub1sSourceControlDecorationProvider } from '@/providers/decorations/source-control'; -import { getFileTreeItemDescription } from '@/helpers/util'; - -export const getCommitTreeItemDescription = (commit: adapterTypes.Commit): string => { - const shortCommitSha = commit.sha.slice(0, 7); - const relativeTimeStr = commit.createTime ? relativeTimeTo(commit.createTime) : null; - return [shortCommitSha, commit.author, relativeTimeStr].filter(Boolean).join(', '); -}; - -export const getCommitTreeItemTooltip = (commit: adapterTypes.Commit): string => { - const shortCommitSha = commit.sha.slice(0, 7); - const ISOTimeStr = commit.createTime ? toISOString(commit.createTime) : null; - const detailText = [shortCommitSha, commit.author, ISOTimeStr].filter(Boolean).join(', '); - return `${commit.message}\n(${detailText})`; -}; - -export interface CommitTreeItem extends vscode.TreeItem { - commit: adapterTypes.Commit; -} - -export class CommitTreeDataProvider implements vscode.TreeDataProvider { - public static viewType = 'github1s.views.commitList'; - - protected _forceUpdate = false; - protected _loadingBarrier: Barrier | null = null; - protected _onDidChangeTreeData = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - - protected loadMoreCommitItem: vscode.TreeItem = { - label: 'Load more', - tooltip: 'Load more commits', - command: { - title: 'Load more commits', - command: 'github1s.commands.loadMoreCommits', - tooltip: 'Load more commits', - }, - }; - - protected createLoadMoreChangedFilesItem = (commitSha: string): vscode.TreeItem => ({ - label: 'Load more', - tooltip: 'Load more changed files', - command: { - title: 'Load more changed files', - command: 'github1s.commands.loadMoreCommitChangedFiles', - tooltip: 'Load more changed files', - arguments: [commitSha], - }, - }); - - async resolveFilePath() { - return '/'; - } - - public updateTree(forceUpdate = true) { - this._forceUpdate = forceUpdate; - this._onDidChangeTreeData.fire(); - } - - public async loadMoreCommits() { - if (!this._loadingBarrier || this._loadingBarrier.isOpen()) { - this._loadingBarrier = new Barrier(5000); - this.updateTree(false); - const repository = Repository.getCurrentInstance(); - const commit = await repository.getCommitItem(router.getState().ref); - if (commit) { - await repository.loadMoreCommits(commit.sha, await this.resolveFilePath()); - } - this._loadingBarrier.open(); - } - } - - public async loadMoreChangedFiles(commitSha: string) { - if (!this._loadingBarrier || this._loadingBarrier.isOpen()) { - this._loadingBarrier = new Barrier(5000); - this.updateTree(false); - const repository = Repository.getCurrentInstance(); - await repository.loadMoreCommitChangedFiles(commitSha); - this._loadingBarrier.open(); - } - } - - async getCommitItems(): Promise { - this._loadingBarrier && (await this._loadingBarrier.wait()); - const filePath = await this.resolveFilePath(); - const repository = Repository.getCurrentInstance(); - // Use the same pinned history as revision navigation, including loaded pages. - const commit = await repository.getCommitItem(router.getState().ref, this._forceUpdate); - if (!commit) { - return []; - } - const ref = commit.sha; - const repositoryCommits = await repository.getCommitList(ref, filePath, this._forceUpdate); - const commitTreeItems = repositoryCommits.map((commit) => { - const label = commit.message.split(/[\r\n]/)[0]; - const description = getCommitTreeItemDescription(commit); - const tooltip = getCommitTreeItemTooltip(commit); - const iconPath = vscode.Uri.parse(commit.avatarUrl || ''); - const contextValue = 'github1s:viewItems:commitListItem'; - - return { - commit, - label, - iconPath, - description, - tooltip, - contextValue, - resourceUri: vscode.Uri.from({ - scheme: GitHub1sSourceControlDecorationProvider.commitSchema, - query: queryString.stringify({ sha: commit.sha }), - }), - collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, - }; - }); - this._forceUpdate = false; - const hasMore = await repository.hasMoreCommits(ref, filePath); - return hasMore ? [...commitTreeItems, this.loadMoreCommitItem] : commitTreeItems; - } - - async getCommitFileItems(commit: adapterTypes.Commit): Promise { - const repository = Repository.getCurrentInstance(); - const changedFiles = await getCommitChangedFiles(commit); - const changedFileItems = changedFiles.map((changedFile) => { - const filePath = changedFile.headFileUri.path; - const id = `${commit.sha} ${filePath}`; - const command = getChangedFileDiffCommand(changedFile); - - return { - id, - command, - description: getFileTreeItemDescription(filePath), - resourceUri: changedFile.headFileUri.with({ - query: queryString.stringify({ changeStatus: changedFile.status }), - }), - collapsibleState: vscode.TreeItemCollapsibleState.None, - }; - }); - const hasMore = await repository.hasMoreCommitChangedFiles(commit.sha); - const loadMoreChangedFilesItem = this.createLoadMoreChangedFilesItem(commit.sha); - return hasMore ? [...changedFileItems, loadMoreChangedFilesItem] : changedFileItems; - } - - getTreeItem(element: vscode.TreeItem): vscode.TreeItem | Thenable { - return element; - } - - getChildren(element?: vscode.TreeItem): vscode.ProviderResult { - if (!element) { - return this.getCommitItems(); - } - const commit = (element as CommitTreeItem)?.commit; - return commit ? this.getCommitFileItems(commit) : []; - } -} - -export class FileHistoryTreeDataProvider extends CommitTreeDataProvider { - public static viewType = 'github1s.views.fileHistory'; - - protected loadMoreCommitItem: vscode.TreeItem = { - label: 'Load more', - tooltip: 'Load more commits', - command: { - title: 'Load more commits', - command: 'github1s.commands.loadMoreFileHistoryCommits', - tooltip: 'Load more commits', - }, - }; - - protected createLoadMoreChangedFilesItem = (commitSha: string): vscode.TreeItem => ({ - label: 'Load more', - tooltip: 'Load more changed files', - command: { - title: 'Load more changed files', - command: 'github1s.commands.loadMoreFileHistoryCommitChangedFiles', - tooltip: 'Load more changed files', - arguments: [commitSha], - }, - }); - - async resolveFilePath() { - const activeDocumentUri = vscode.window.activeTextEditor?.document?.uri; - return activeDocumentUri?.scheme === getAdapter().scheme ? activeDocumentUri.path : '/'; - } - - async getCommitItems() { - if ((await this.resolveFilePath()) === '/') { - return []; - } - return super.getCommitItems(); - } -} diff --git a/extensions/github1s/src/views/index.ts b/extensions/github1s/src/views/index.ts index 4866e1931..5218f2834 100644 --- a/extensions/github1s/src/views/index.ts +++ b/extensions/github1s/src/views/index.ts @@ -7,9 +7,7 @@ import { getAdapter } from '@/adapters'; import { CodeReviewType } from '@/adapters/types'; import { getExtensionContext } from '@/helpers/context'; import { CodeReviewTreeDataProvider } from './code-review-list'; -import { FileHistoryTreeDataProvider } from './commit-list'; -export const fileHistoryTreeDataProvider = new FileHistoryTreeDataProvider(); export const codeReviewRequestTreeDataProvider = new CodeReviewTreeDataProvider(); export const codeReviewViewTitle = { @@ -30,8 +28,5 @@ export const registerCustomViews = () => { const codeReviewType = getAdapter().codeReviewType || CodeReviewType.CodeReview; codeReviewTreeView.title = codeReviewViewTitle[codeReviewType]; - context.subscriptions.push( - // register commit view which is in source control panel - vscode.window.registerTreeDataProvider(FileHistoryTreeDataProvider.viewType, fileHistoryTreeDataProvider), - ); + context.subscriptions.push(codeReviewTreeView); }; diff --git a/extensions/github1s/src/vscode.proposed.d.ts b/extensions/github1s/src/vscode.proposed.d.ts index 593d070a1..6f3d03cb5 100644 --- a/extensions/github1s/src/vscode.proposed.d.ts +++ b/extensions/github1s/src/vscode.proposed.d.ts @@ -471,3 +471,166 @@ declare module 'vscode' { readonly silent: boolean; } } + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + // https://github.com/microsoft/vscode/issues/84297 + + export class TimelineItem { + /** + * A timestamp (in milliseconds since 1 January 1970 00:00:00) for when the timeline item occurred. + */ + timestamp: number; + + /** + * A human-readable string describing the timeline item. + */ + label: string; + + /** + * Optional id for the timeline item. It must be unique across all the timeline items provided by this source. + * + * If not provided, an id is generated using the timeline item's timestamp. + */ + id?: string; + + /** + * The icon path or {@link ThemeIcon} for the timeline item. + */ + iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon; + + /** + * A human readable string describing less prominent details of the timeline item. + */ + description?: string; + + /** + * The tooltip text when you hover over the timeline item. + */ + tooltip?: string | MarkdownString | undefined; + + /** + * The {@link Command} that should be executed when the timeline item is selected. + */ + command?: Command; + + /** + * Context value of the timeline item. This can be used to contribute specific actions to the item. + * For example, a timeline item is given a context value as `commit`. When contributing actions to `timeline/item/context` + * using `menus` extension point, you can specify context value for key `timelineItem` in `when` expression like `timelineItem == commit`. + * ``` + * "contributes": { + * "menus": { + * "timeline/item/context": [ + * { + * "command": "extension.copyCommitId", + * "when": "timelineItem == commit" + * } + * ] + * } + * } + * ``` + * This will show the `extension.copyCommitId` action only for items where `contextValue` is `commit`. + */ + contextValue?: string; + + /** + * Accessibility information used when screen reader interacts with this timeline item. + */ + accessibilityInformation?: AccessibilityInformation; + + /** + * @param label A human-readable string describing the timeline item + * @param timestamp A timestamp (in milliseconds since 1 January 1970 00:00:00) for when the timeline item occurred + */ + constructor(label: string, timestamp: number); + } + + export interface TimelineChangeEvent { + /** + * The {@link Uri} of the resource for which the timeline changed. + */ + uri: Uri; + + /** + * A flag which indicates whether the entire timeline should be reset. + */ + reset?: boolean; + } + + export interface Timeline { + readonly paging?: { + /** + * A provider-defined cursor specifying the starting point of timeline items which are after the ones returned. + * Use `undefined` to signal that there are no more items to be returned. + */ + readonly cursor: string | undefined; + }; + + /** + * An array of {@link TimelineItem timeline items}. + */ + readonly items: readonly TimelineItem[]; + } + + export interface TimelineOptions { + /** + * A provider-defined cursor specifying the starting point of the timeline items that should be returned. + */ + cursor?: string; + + /** + * An optional maximum number timeline items or the all timeline items newer (inclusive) than the timestamp or id that should be returned. + * If `undefined` all timeline items should be returned. + */ + limit?: number | { timestamp: number; id?: string }; + } + + export interface TimelineProvider { + /** + * An optional event to signal that the timeline for a source has changed. + * To signal that the timeline for all resources (uris) has changed, do not pass any argument or pass `undefined`. + */ + readonly onDidChange?: Event; + + /** + * An identifier of the source of the timeline items. This can be used to filter sources. + */ + readonly id: string; + + /** + * A human-readable string describing the source of the timeline items. This can be used as the display label when filtering sources. + */ + readonly label: string; + + /** + * Provide {@link TimelineItem timeline items} for a {@link Uri}. + * + * @param uri The {@link Uri} of the file to provide the timeline for. + * @param options A set of options to determine how results should be returned. + * @param token A cancellation token. + * @return The {@link TimelineResult timeline result} or a thenable that resolves to such. The lack of a result + * can be signaled by returning `undefined`, `null`, or an empty array. + */ + provideTimeline(uri: Uri, options: TimelineOptions, token: CancellationToken): ProviderResult; + } + + export namespace workspace { + /** + * Register a timeline provider. + * + * Multiple providers can be registered. In that case, providers are asked in + * parallel and the results are merged. A failing provider (rejected promise or exception) will + * not cause a failure of the whole operation. + * + * @param scheme A scheme or schemes that defines which documents this provider is applicable to. Can be `*` to target all documents. + * @param provider A timeline provider. + * @return A {@link Disposable} that unregisters this provider when being disposed. + */ + export function registerTimelineProvider(scheme: string | string[], provider: TimelineProvider): Disposable; + } +} diff --git a/tests/__tests__/__image_snapshots__/index-test-ts-should-load-successfully-1-snap.png b/tests/__tests__/__image_snapshots__/index-test-ts-should-load-successfully-1-snap.png index eb2358883..271d4ad03 100644 Binary files a/tests/__tests__/__image_snapshots__/index-test-ts-should-load-successfully-1-snap.png and b/tests/__tests__/__image_snapshots__/index-test-ts-should-load-successfully-1-snap.png differ diff --git a/tests/__tests__/__image_snapshots__/index-test-ts-should-open-file-correctly-1-snap.png b/tests/__tests__/__image_snapshots__/index-test-ts-should-open-file-correctly-1-snap.png index 4b239f696..1056d3f3c 100644 Binary files a/tests/__tests__/__image_snapshots__/index-test-ts-should-open-file-correctly-1-snap.png and b/tests/__tests__/__image_snapshots__/index-test-ts-should-open-file-correctly-1-snap.png differ diff --git a/tests/__tests__/index.test.ts b/tests/__tests__/index.test.ts index e1fd01bf5..bf9210dd5 100644 --- a/tests/__tests__/index.test.ts +++ b/tests/__tests__/index.test.ts @@ -62,10 +62,11 @@ it('should load successfully', async () => { it('should open file correctly', async () => { await page.goto(`${BASE_URL}/conwnet/github1s`); - await page.waitForTimeout(3000); + await page.waitForSelector('iframe.webview.ready'); await page.click('[aria-label="~/tsconfig.json"]'); await page.click('[data-resource-name="tsconfig.json"]'); - await page.waitForTimeout(3000); + await page.waitForSelector('[role="tab"][aria-selected="true"][data-resource-name="tsconfig.json"]'); + await page.waitForSelector('.monaco-editor[data-uri="github1s:/tsconfig.json"] .view-lines'); const image = await page.screenshot(); expect(image).toMatchImageSnapshot(matchImageSnapshotOptions); diff --git a/webpack.config.js b/webpack.config.js index cab06fade..1edba9575 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -46,6 +46,7 @@ const devVscodeStatic = [ export default (env, argv) => { const devMode = argv.mode === 'development'; const devVscode = !!process.env.DEV_VSCODE; + const ciGithubProxy = devMode && process.env.CI === 'true' && !!process.env.GITHUB_TOKEN; const minifyCSS = (code) => (devMode ? code : new CleanCSS().minify(code).styles); const minifyJS = (code) => (devMode ? code : UglifyJS.minify(code).code); const availableLanguages = devVscode ? [] : fs.readdirSync(path.join(vscodeWebPath, 'nls')); @@ -94,24 +95,31 @@ export default (env, argv) => { ], performance: false, devServer: { + host: ciGithubProxy ? 'localhost' : undefined, port: 8080, proxy: [ { - context: ['/api/github/search/code'], + // Used by code search and by CI builds that point GITHUB_API_PREFIX here. + context: (pathname, req) => pathname.startsWith('/api/github/'), target: 'https://api.github.com', changeOrigin: true, + followRedirects: false, pathRewrite: { '^/api/github': '' }, headers: { 'user-agent': 'GitHub1s' }, on: { proxyReq: (proxyReq) => { proxyReq.removeHeader('cookie'); proxyReq.removeHeader('origin'); + if (ciGithubProxy) { + // Set up credentials for the CI environment to avoid GitHub API rate limits. + proxyReq.setHeader('authorization', `Bearer ${process.env.GITHUB_TOKEN}`); + } }, }, }, ], liveReload: false, - allowedHosts: 'all', + allowedHosts: ciGithubProxy ? 'auto' : 'all', client: { overlay: false }, devMiddleware: { writeToDisk: true }, static: devVscode ? devVscodeStatic : [],