From 9701b5fdd2a35a42f6f7a252eb06054108bea655 Mon Sep 17 00:00:00 2001 From: netcon Date: Thu, 17 Sep 2026 02:31:51 +0800 Subject: [PATCH] feat: replace commit list with SourceControlHistoryProvider (#750) --- extensions/github1s/package.json | 56 +++-- extensions/github1s/src/changes/history.ts | 151 ++++++++++++ extensions/github1s/src/changes/index.ts | 44 ++-- extensions/github1s/src/commands/commit.ts | 53 ++-- extensions/github1s/src/commands/editor.ts | 23 +- extensions/github1s/src/extension.ts | 5 +- .../github1s/src/listeners/router/changes.ts | 2 - .../src/providers/decorations/changed-file.ts | 4 + extensions/github1s/src/providers/index.ts | 1 - .../github1s/src/repository/commit-manager.ts | 232 ++++++++---------- extensions/github1s/src/repository/index.ts | 26 +- extensions/github1s/src/views/index.ts | 4 +- extensions/github1s/src/vscode.proposed.d.ts | 111 +++++++++ 13 files changed, 500 insertions(+), 212 deletions(-) create mode 100644 extensions/github1s/src/changes/history.ts diff --git a/extensions/github1s/package.json b/extensions/github1s/package.json index cec49ada6..7cf57d414 100644 --- a/extensions/github1s/package.json +++ b/extensions/github1s/package.json @@ -7,7 +7,9 @@ "enabledApiProposals": [ "fileSearchProvider", "textSearchProvider", - "resolvers" + "scmHistoryProvider", + "contribSourceControlHistoryTitleMenu", + "contribSourceControlHistoryItemMenu" ], "directories": { "lib": "lib" @@ -66,6 +68,7 @@ { "id": "github1s.views.settings", "name": "Settings", + "icon": "$(settings-gear)", "type": "webview", "when": "github1s:views:settings:visible == true" } @@ -74,16 +77,13 @@ { "id": "github1s.views.fileHistory", "name": "File History", + "icon": "$(history)", "when": "github1s:views:fileHistory:visible == true" }, - { - "id": "github1s.views.commitList", - "name": "Commits", - "when": "github1s:views:commitList:visible == true" - }, { "id": "github1s.views.codeReviewList", "name": "Code Reviews", + "icon": "$(git-pull-request)", "when": "github1s:views:codeReviewList:visible == true" } ] @@ -496,16 +496,6 @@ "command": "github1s.commands.searchCommit", "when": "view == 'github1s.views.fileHistory'", "group": "navigation@2" - }, - { - "command": "github1s.commands.refreshCommitList", - "when": "view == 'github1s.views.commitList'", - "group": "navigation@1" - }, - { - "command": "github1s.commands.searchCommit", - "when": "view == 'github1s.views.commitList'", - "group": "navigation@2" } ], "view/item/context": [ @@ -616,6 +606,40 @@ "when": "!isInDiffEditor && github1s:features:gutterBlame:enabled && github1s:features:gutterBlame:open", "group": "navigation@6" } + ], + "scm/history/title": [ + { + "command": "github1s.commands.searchCommit", + "when": "scmProvider == github1s", + "group": "navigation@3" + } + ], + "scm/historyItem/context": [ + { + "command": "github1s.commands.switchToCommit", + "when": "scmProvider == github1s", + "group": "inline@1" + }, + { + "command": "github1s.commands.openCommitOnGitHub", + "when": "scmProvider == github1s && github1s:adapters:default:platformName == 'GitHub'", + "group": "inline@2" + }, + { + "command": "github1s.commands.openCommitOnGitLab", + "when": "scmProvider == github1s && github1s:adapters:default:platformName == 'GitLab'", + "group": "inline@2" + }, + { + "command": "github1s.commands.openCommitOnBitbucket", + "when": "scmProvider == github1s && github1s:adapters:default:platformName == 'Bitbucket'", + "group": "inline@2" + }, + { + "command": "github1s.commands.openCommitOnOfficialPage", + "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" + } ] } }, diff --git a/extensions/github1s/src/changes/history.ts b/extensions/github1s/src/changes/history.ts new file mode 100644 index 000000000..9fde57e47 --- /dev/null +++ b/extensions/github1s/src/changes/history.ts @@ -0,0 +1,151 @@ +import * as vscode from 'vscode'; +import queryString from 'query-string'; +import { Commit, FileChangeStatus } from '@/adapters/types'; +import { Repository } from '@/repository'; +import router from '@/router'; +import { getCommitChangedFiles } from './files'; + +export class GitHub1sHistoryProvider implements vscode.SourceControlHistoryProvider, vscode.Disposable { + // Current route ref, with its commit SHA filled in after the first page loads. + 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(); + } + }); + + refresh() { + const { repo, ref } = router.getState(); + const previousRef = this.currentHistoryItemRef; + this.currentHistoryItemRef = repo ? { id: ref, name: ref, icon: new vscode.ThemeIcon('target') } : undefined; + this.currentRefsChanged.fire(); + // Repository navigation can keep the same branch name (for example, "main"). + if (previousRef?.id === this.currentHistoryItemRef?.id) { + this.refsChanged.fire({ + added: [], + removed: [], + modified: this.currentHistoryItemRef ? [this.currentHistoryItemRef] : [], + silent: false, + }); + } + } + + provideHistoryItemRefs(ids: string[] | undefined): vscode.SourceControlHistoryItemRef[] { + const ref = this.currentHistoryItemRef; + return ref && (!ids || ids.includes(ref.id)) ? [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); + } + + if (token.isCancellationRequested || currentRef !== this.currentHistoryItemRef) { + return []; + } + + if (skip === 0) { + this.currentHistoryItemRef = { ...currentRef, revision: commits[0]?.sha }; + this.currentRefsChanged.fire(); + } + return commits.slice(skip, skip + limit).map((commit) => this.toHistoryItem(commit)); + } + + async resolveHistoryItem(id: string) { + const commit = await Repository.getCurrentInstance().getCommitItem(id); + return commit ? this.toHistoryItem(commit) : undefined; + } + + 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. + 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; + // Carry context for our diff editor commands (open either side, previous/next revision). + const query = + originalUri && modifiedUri + ? queryString.stringify({ + base: originalUri.with({ query: '' }).toString(), + head: modifiedUri.with({ query: '' }).toString(), + status: file.status, + }) + : ''; + 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: originalUri?.with({ query }), + modifiedUri: modifiedUri?.with({ query }), + }; + }); + } + + resolveHistoryItemRefsCommonAncestor(): undefined { + return undefined; + } + + resolveHistoryItemChatContext(): undefined { + return undefined; + } + + resolveHistoryItemChangeRangeChatContext(): undefined { + return 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, + displayId: commit.sha.slice(0, 7), + author: commit.author, + 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 4a3456f8b..8038f84b7 100644 --- a/extensions/github1s/src/changes/index.ts +++ b/extensions/github1s/src/changes/index.ts @@ -5,26 +5,34 @@ import * as vscode from 'vscode'; import * as adapterTypes from '@/adapters/types'; +import { getExtensionContext } from '@/helpers/context'; import { GitHub1sQuickDiffProvider } from './quick-diff'; import { getChangedFileDiffCommand, getChangedFiles } from './files'; +import { GitHub1sHistoryProvider } from './history'; -export const updateSourceControlChanges = (() => { - const sourceControl = vscode.scm.createSourceControl('github1s', 'GitHub1s'); - const changesGroup = sourceControl.createResourceGroup('changes', 'Changes'); - sourceControl.quickDiffProvider = new GitHub1sQuickDiffProvider(); +const sourceControl = vscode.scm.createSourceControl('github1s', 'GitHub1s'); +const changesGroup = sourceControl.createResourceGroup('changes', 'Changes'); +sourceControl.quickDiffProvider = new GitHub1sQuickDiffProvider(); - return async () => { - const changedFiles = await getChangedFiles(); +export const registerSourceControlHistory = () => { + const context = getExtensionContext(); + const historyProvider = new GitHub1sHistoryProvider(); + sourceControl.historyProvider = historyProvider; + context.subscriptions.push(sourceControl, historyProvider); + historyProvider.refresh(); +}; - changesGroup.resourceStates = changedFiles.map((changedFile) => { - return { - resourceUri: changedFile.headFileUri.with({ authority: '' }), - decorations: { - strikeThrough: changedFile.status === adapterTypes.FileChangeStatus.Removed, - tooltip: changedFile.status, - }, - command: getChangedFileDiffCommand(changedFile), - }; - }); - }; -})(); +export const updateSourceControlChanges = async () => { + const changedFiles = await getChangedFiles(); + + changesGroup.resourceStates = changedFiles.map((changedFile) => { + return { + resourceUri: changedFile.headFileUri.with({ authority: '' }), + decorations: { + strikeThrough: changedFile.status === adapterTypes.FileChangeStatus.Removed, + tooltip: changedFile.status, + }, + command: getChangedFileDiffCommand(changedFile), + }; + }); +}; diff --git a/extensions/github1s/src/commands/commit.ts b/extensions/github1s/src/commands/commit.ts index 97e3df402..cd244916a 100644 --- a/extensions/github1s/src/commands/commit.ts +++ b/extensions/github1s/src/commands/commit.ts @@ -4,11 +4,22 @@ */ 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 { commitTreeDataProvider, fileHistoryTreeDataProvider } from '@/views'; +import { fileHistoryTreeDataProvider } from '@/views'; + +type CommitCommandArgument = string | CommitTreeItem | vscode.SourceControl; + +// Graph actions pass (sourceControl, historyItem); tree actions pass a CommitTreeItem. +const getCommitSha = (item?: CommitCommandArgument, historyItem?: vscode.SourceControlHistoryItem): string => { + if (historyItem) { + return historyItem.id; + } + return typeof item === 'string' ? item : item && 'commit' in item ? item.commit.sha : ''; +}; export const checkCommitExists = async (repo: string, commitSha: string) => { const dataSoruce = await getAdapter().resolveDataSource(); @@ -24,12 +35,11 @@ export const checkCommitExists = async (repo: string, commitSha: string) => { } }; -const commandSwitchToCommit = async (commitItemOrSha?: string | CommitTreeItem) => { - let commitSha: string | undefined = commitItemOrSha - ? typeof commitItemOrSha === 'string' - ? commitItemOrSha - : commitItemOrSha.commit.sha - : ''; +const commandSwitchToCommit = async ( + commitItemOrSha?: CommitCommandArgument, + historyItem?: vscode.SourceControlHistoryItem, +) => { + let commitSha: string | undefined = getCommitSha(commitItemOrSha, historyItem); const { repo } = router.getState(); const repository = Repository.getCurrentInstance(); @@ -89,17 +99,18 @@ const commandDiffCommitFile = async (commitItem: CommitTreeItem) => { if (!activeDocumentUri) { return; } - const fileUri = router.buildUri({ ref: commitSha }, activeDocumentUri).with({ query: '' }); + const fileUri = router.buildUri({ ref: commitSha }, activeDocumentUri).with({ + query: queryString.stringify({ from: router.getState().ref }), + }); return vscode.commands.executeCommand('github1s.commands.openFilePreviousRevision', fileUri); }; // this command is used in `source control commit list view` -const commandOpenCommitOnOfficialPage = async (commitItemOrSha?: string | CommitTreeItem) => { - const commitSha = commitItemOrSha - ? typeof commitItemOrSha === 'string' - ? commitItemOrSha - : commitItemOrSha.commit.sha - : ''; +const commandOpenCommitOnOfficialPage = async ( + commitItemOrSha?: CommitCommandArgument, + historyItem?: vscode.SourceControlHistoryItem, +) => { + const commitSha = getCommitSha(commitItemOrSha, historyItem); if (commitSha) { const { repo } = router.getState(); const routerParser = router.getParser(); @@ -109,16 +120,8 @@ const commandOpenCommitOnOfficialPage = async (commitItemOrSha?: string | Commit } }; -const commandRefreshCommitList = (forceUpdate = true) => { - return commitTreeDataProvider.updateTree(forceUpdate); -}; - -const commandLoadMoreCommits = async () => { - return commitTreeDataProvider.loadMoreCommits(); -}; - -const commandLoadMoreCommitChangedFiles = async (commitSha: string) => { - return commitTreeDataProvider.loadMoreChangedFiles(commitSha); +const commandRefreshCommitList = () => { + return vscode.commands.executeCommand('workbench.scm.action.graph.refresh'); }; const commandRefreshFileHistoryCommitList = (forceUpdate = true) => { @@ -143,8 +146,6 @@ export const registerCommitCommands = (context: vscode.ExtensionContext) => { 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.loadMoreCommits', commandLoadMoreCommits), - vscode.commands.registerCommand('github1s.commands.loadMoreCommitChangedFiles', commandLoadMoreCommitChangedFiles), vscode.commands.registerCommand('github1s.commands.loadMoreFileHistoryCommits', commandLoadMoreFileHistoryCommits), vscode.commands.registerCommand( 'github1s.commands.loadMoreFileHistoryCommitChangedFiles', diff --git a/extensions/github1s/src/commands/editor.ts b/extensions/github1s/src/commands/editor.ts index 4d936cb01..819e7ac9d 100644 --- a/extensions/github1s/src/commands/editor.ts +++ b/extensions/github1s/src/commands/editor.ts @@ -64,7 +64,13 @@ const getConcreteFileUri = async (fileUri: vscode.Uri) => { // show the file's diff between current commit and previous commit const commandOpenFilePreviousRevision = async (fileUri: vscode.Uri) => { - const queryBaseUriStr = queryString.parse(fileUri.query).base; + const queryParams = queryString.parse(fileUri.query); + const queryBaseUriStr = queryParams.base; + const headFileUri = typeof queryParams.head === 'string' ? vscode.Uri.parse(queryParams.head) : undefined; + const from = + typeof queryParams.from === 'string' + ? queryParams.from + : router.parseUri(headFileUri && headFileUri.scheme !== emptyFileUri.scheme ? headFileUri : fileUri).ref; const rightFileUri = await getConcreteFileUri( // if the `queryBaseUriStr` is empty, which means this command is called from // a normal file editor (not a diff editor), just use `fileUri` in this case @@ -72,16 +78,17 @@ const commandOpenFilePreviousRevision = async (fileUri: vscode.Uri) => { ); const { scheme, repo, ref: rightCommitSha } = router.parseUri(rightFileUri); const repository = Repository.getInstance(scheme, repo); - const leftCommit = await repository.getPreviousCommit(rightCommitSha, rightFileUri.path); + const leftCommit = await repository.getPreviousCommit(rightCommitSha, rightFileUri.path, from); // if we can't find previous commit, use the `emptyFileUri` as the leftFileUri const leftFileUri = leftCommit ? router.buildUri({ ref: leftCommit.sha }, rightFileUri) : emptyFileUri; const changedStatus = leftCommit ? FileChangeStatus.Modified : FileChangeStatus.Added; - const hasNextRevision = !!(await repository.getNextCommit(rightCommitSha, rightFileUri.path)); + const hasNextRevision = !!(await repository.getNextCommit(rightCommitSha, rightFileUri.path, from)); const query = queryString.stringify({ base: leftFileUri.with({ query: '' }).toString(), head: rightFileUri.with({ query: '' }).toString(), + from, status: changedStatus, // if we can't find a newer commit for this file, // the `Show Next Commit` Button would be disabled. @@ -98,22 +105,26 @@ const commandOpenFilePreviousRevision = async (fileUri: vscode.Uri) => { // show the file's diff between current commit and next commit const commandOpenFileNextRevision = async (fileUri: vscode.Uri) => { - const leftFileUri = await getConcreteFileUri(fileUri); + const queryParams = queryString.parse(fileUri.query); + const headFileUri = typeof queryParams.head === 'string' ? vscode.Uri.parse(queryParams.head) : fileUri; + const from = typeof queryParams.from === 'string' ? queryParams.from : router.parseUri(headFileUri).ref; + const leftFileUri = await getConcreteFileUri(headFileUri); const { scheme, repo, ref: leftCommitSha } = router.parseUri(leftFileUri); const repository = Repository.getInstance(scheme, repo); - const rightCommit = await repository.getNextCommit(leftCommitSha, leftFileUri.path); + const rightCommit = await repository.getNextCommit(leftCommitSha, leftFileUri.path, from); if (!rightCommit) { return vscode.window.showInformationMessage('There is no next commit found.'); } const rightFileUri = router.buildUri({ ref: rightCommit.sha }, leftFileUri); - const hasNextRevision = !!(await repository.getNextCommit(rightCommit.sha, rightFileUri.path)); + const hasNextRevision = !!(await repository.getNextCommit(rightCommit.sha, rightFileUri.path, from)); const query = queryString.stringify({ base: leftFileUri.with({ query: '' }).toString(), head: rightFileUri.with({ query: '' }).toString(), + from, status: FileChangeStatus.Modified, hasNextRevision, }); diff --git a/extensions/github1s/src/extension.ts b/extensions/github1s/src/extension.ts index 7be17e063..c4f2ad020 100644 --- a/extensions/github1s/src/extension.ts +++ b/extensions/github1s/src/extension.ts @@ -12,7 +12,7 @@ import { decorateStatusBar } from '@/statusbar'; import { registerEventListeners } from '@/listeners'; import { registerVSCodeProviders } from '@/providers'; import { registerGitHub1sCommands } from '@/commands'; -import { updateSourceControlChanges } from '@/changes'; +import { registerSourceControlHistory, updateSourceControlChanges } from '@/changes'; import { addRecentRepositories, setExtensionContext } from '@/helpers/context'; const browserUrlManager = { @@ -40,6 +40,7 @@ export async function activate(context: vscode.ExtensionContext) { registerEventListeners(), registerGitHub1sCommands(), registerCustomViews(), + registerSourceControlHistory(), updateSourceControlChanges(), decorateStatusBar(), ]); @@ -65,7 +66,7 @@ const initialVSCodeState = async () => { } else if (routerState.pageType === PageType.CodeReviewList) { vscode.commands.executeCommand('github1s.views.codeReviewList.focus'); } else if (routerState.pageType === PageType.CommitList) { - vscode.commands.executeCommand('github1s.views.commitList.focus'); + vscode.commands.executeCommand('workbench.scm.history.focus'); } else if ([PageType.CodeReview, PageType.Commit].includes(routerState.pageType)) { vscode.commands.executeCommand('workbench.scm.focus'); } else if (routerState.pageType === PageType.Search) { diff --git a/extensions/github1s/src/listeners/router/changes.ts b/extensions/github1s/src/listeners/router/changes.ts index 2fad3c2fd..75778ff40 100644 --- a/extensions/github1s/src/listeners/router/changes.ts +++ b/extensions/github1s/src/listeners/router/changes.ts @@ -6,14 +6,12 @@ import { RouterState } from '@/adapters/types'; import { updateCheckoutTo } from '@/statusbar/checkout'; import { updateSourceControlChanges } from '@/changes'; -import { commitTreeDataProvider } from '@/views'; type NewType = RouterState; export const sourceControlRouterListener = (currentState: NewType, previousState: RouterState) => { if (currentState.ref !== previousState.ref) { updateCheckoutTo(); - commitTreeDataProvider.updateTree(); } if ((currentState as any).codeReviewId !== (previousState as any).codeReviewId) { diff --git a/extensions/github1s/src/providers/decorations/changed-file.ts b/extensions/github1s/src/providers/decorations/changed-file.ts index b891bb063..4bf3b4de2 100644 --- a/extensions/github1s/src/providers/decorations/changed-file.ts +++ b/extensions/github1s/src/providers/decorations/changed-file.ts @@ -104,6 +104,10 @@ export class GitHub1sChangedFileDecorationProvider implements FileDecorationProv if (uri.scheme !== getAdapter().scheme) { return null; } + // Explicit revision statuses are decorated by the source-control provider. + if (uri.query.includes('changeStatus')) { + return null; + } const routerState = router.getState(); if (routerState.pageType === PageType.CodeReview) { diff --git a/extensions/github1s/src/providers/index.ts b/extensions/github1s/src/providers/index.ts index 331d8f521..0fb44ed81 100644 --- a/extensions/github1s/src/providers/index.ts +++ b/extensions/github1s/src/providers/index.ts @@ -15,7 +15,6 @@ import { GitHub1sSourceControlDecorationProvider } from './decorations/source-co import { GitHub1sDefinitionProvider } from './definition'; import { GitHub1sReferenceProvider } from './reference'; import { GitHub1sHoverProvider } from './hover'; -import router from '@/router'; export const EMPTY_FILE_SCHEME = 'github1s-empty-file'; export const emptyFileUri = vscode.Uri.from({ scheme: EMPTY_FILE_SCHEME }); diff --git a/extensions/github1s/src/repository/commit-manager.ts b/extensions/github1s/src/repository/commit-manager.ts index 951652a7c..7569ce53b 100644 --- a/extensions/github1s/src/repository/commit-manager.ts +++ b/extensions/github1s/src/repository/commit-manager.ts @@ -34,10 +34,10 @@ class CommitChangedFilesManager { getList = reuseable(async (forceUpdate: boolean = false): Promise => { if (forceUpdate || !this._changedFilesList) { this._currentPage = 1; - this._changedFilesList = []; + this._changedFilesList = null; await this.loadMore(); } - return this._changedFilesList; + return this._changedFilesList || []; }); loadMore = reuseable(async (): Promise => { @@ -64,20 +64,20 @@ class CommitChangedFilesManager { } } +const historyKey = (from: string, filePath: string) => `${from} ${filePath}`; + export class CommitManager { private static instancesMap = new Map(); - private static _commitMap = new Map(); // commitSha -> CommitWithDirection - // if `previous` or `next` is null, it means this is an end node - private static _relationMap = new Map>(); - - private _latestCommitSha: string | null = null; - private _currentPage = 1; + private _refMap = new Map(); // ref -> sha + private _shaMap = new Map(); // sha -> commit + // `from filePath` -> [sha[], page, hasMore] + private _historyMap = new Map(); private _pageSize = 100; - public static getInstance(scheme: string, repo: string, from: string, filePath: string) { - const mapKey = `${scheme} ${repo} ${from} ${filePath}`; + public static getInstance(scheme: string, repo: string) { + const mapKey = `${scheme} ${repo}`; if (!CommitManager.instancesMap.has(mapKey)) { - CommitManager.instancesMap.set(mapKey, new CommitManager(scheme, repo, from, filePath)); + CommitManager.instancesMap.set(mapKey, new CommitManager(scheme, repo)); } return CommitManager.instancesMap.get(mapKey)!; } @@ -85,155 +85,135 @@ export class CommitManager { private constructor( private _scheme: string, private _repo: string, - private _from: string, - private _filePath: string, ) {} - // link two commitSha - private linkCommitShas(previousCommitSha: string | null, nextCommitSha: string | null) { - if (!CommitManager._relationMap.has(this._filePath)) { - CommitManager._relationMap.set(this._filePath, new Map()); - } - const relation = CommitManager._relationMap.get(this._filePath)!; - if (previousCommitSha) { - !relation.has(previousCommitSha) && relation.set(previousCommitSha, {}); - relation.get(previousCommitSha)!.next = nextCommitSha; - } - if (nextCommitSha) { - !relation.has(nextCommitSha) && relation.set(nextCommitSha, {}); - relation.get(nextCommitSha)!.previous = previousCommitSha; - } - } - - // construct commit list with commit relations - private resolveCommitList() { - const commitList: Commit[] = []; - const relation = CommitManager._relationMap.get(this._filePath); - let currentCommitSha: string | undefined | null = this._latestCommitSha; - while (currentCommitSha && CommitManager._commitMap.has(currentCommitSha)) { - const commit = CommitManager._commitMap.get(currentCommitSha)!; - commitList.push(commit); - currentCommitSha = relation?.get(commit.sha)?.previous; + private async fetchCommits(from: string, page: number, filePath: string): Promise { + const dataSource = await getAdapter(this._scheme).resolveDataSource(); + const commits = await dataSource.provideCommits(this._repo, { + from, + page, + pageSize: this._pageSize, + ...(!filePath || filePath === '/' ? {} : { path: filePath }), + }); + if (page == 1 && commits.length && (!filePath || filePath === '/')) { + this._refMap.set(from, commits[0]?.sha); } - return commitList; + commits.forEach((commit) => this._shaMap.set(commit.sha, commit)); + return commits; } - getList = reuseable(async (forceUpdate: boolean = false): Promise => { - const hasMore = await this.hasMore(); - const commitList = this.resolveCommitList(); - const shouldLoadMore = hasMore && commitList.length < this._pageSize; - - if (forceUpdate || shouldLoadMore) { - this._currentPage = 1; - this._latestCommitSha = null; - CommitManager._relationMap.set(this._filePath, new Map()); - await this.loadMore(); + getList = reuseable(async (from: string, filePath: string, forceUpdate = false): Promise => { + const history = this._historyMap.get(historyKey(from, filePath)); + if (forceUpdate || !history) { + const commits = await this.fetchCommits(from, 1, filePath); + const hasMore = commits.length >= this._pageSize; + this._historyMap.set(historyKey(from, filePath), [commits.map((commit) => commit.sha), 1, hasMore]); + return commits; } - return this.resolveCommitList(); + return history[0].map((sha) => this._shaMap.get(sha)!).filter(Boolean); }); - getItem = reuseable(async (forceUpdate: boolean = false): Promise => { - if (forceUpdate || !CommitManager._commitMap.has(this._from)) { + getItem = reuseable(async (ref: string, forceUpdate = false): Promise => { + const sha = this._refMap.get(ref) || ref; + let commit: Commit | null | undefined = this._shaMap.get(sha); + if (forceUpdate || !commit) { const dataSource = await getAdapter(this._scheme).resolveDataSource(); - const commit = await dataSource.provideCommit(this._repo, this._from); - - commit && CommitManager._commitMap.set(this._from, commit); - commit && CommitManager._commitMap.set(commit.sha, commit); - if (commit?.files) { - const manager = CommitChangedFilesManager.getInstance(this._scheme, this._repo, commit.sha); - manager.setChangedFiles(commit.files); - } + commit = await dataSource.provideCommit(this._repo, ref); + commit && this._refMap.set(ref, commit.sha); + commit && this._shaMap.set(commit.sha, commit); } - return CommitManager._commitMap.get(this._from)!; + return commit; }); - loadMore = reuseable(async (): Promise => { - const commitList = this.resolveCommitList(); - const dataSource = await getAdapter(this._scheme).resolveDataSource(); - const queryOptions = { - page: this._currentPage, - pageSize: this._pageSize, - from: this._from, - path: this._filePath, - }; - const commits = await dataSource.provideCommits(this._repo, queryOptions); - - if (this._currentPage === 1 && commits.length) { - this._latestCommitSha = commits[0].sha; - // also map `this._from` to the first commit for repository history - this._filePath === '/' && CommitManager._commitMap.set(this._from, commits[0]); + loadMore = reuseable(async (from: string, filePath: string): Promise => { + const key = historyKey(from, filePath); + const history = this._historyMap.get(key); + if (history && !history[2]) { + // no more commits to load + return Promise.resolve([]); } - commits.forEach((commit) => { - CommitManager._commitMap.set(commit.sha, commit); - // directly set changed files if they are in response - if (commit?.files) { - const manager = CommitChangedFilesManager.getInstance(this._scheme, this._repo, commit.sha); - manager.setChangedFiles(commit.files); - } - }); - if (this._currentPage > 1 && commitList.length && commits.length) { - this.linkCommitShas(commits[0].sha, commitList[commitList.length - 1].sha); + const nextPage = (history?.[1] || 0) + 1; + // TODO: Pin a root SHA per history and use it from the first page onward. + // File history queries may leave _refMap unset, while other queries can + // overwrite it between pages, causing duplicate or inconsistent results. + const fromSha = this._refMap.get(from) || from; + const commits = await this.fetchCommits(fromSha, nextPage, filePath); + // A refresh may have replaced this history while the page was loading. + if (this._historyMap.get(key) !== history) { + return []; } - for (let i = 1, len = commits.length; i < len; i++) { - const previousCommitSha = commits[i].sha; - const nextCommitSha = commits[i - 1].sha; - this.linkCommitShas(previousCommitSha, nextCommitSha); - } - // if has more commits - const hasMore = commits.length === this._pageSize; - if (!hasMore) { - const latestCommit = commits.length ? commits[commits.length - 1] : commitList[commitList.length - 1]; - this.linkCommitShas(null, latestCommit.sha); - } - this._currentPage += 1; + const hasMore = commits.length >= this._pageSize; + const allCommits = [...(history?.[0] || []), ...commits.map((commit) => commit.sha)]; + this._historyMap.set(key, [allCommits, nextPage, hasMore]); return commits; }); - hasMore = reuseable(async (): Promise => { - const commitList = this.resolveCommitList(); - const relation = CommitManager._relationMap.get(this._filePath); - const commitRelation = commitList.length ? relation?.get(commitList[commitList.length - 1].sha) : null; - return !commitRelation || commitRelation.previous !== null; - }); + async hasMore(from: string, filePath: string): Promise { + const history = this._historyMap.get(historyKey(from, filePath)); + return history ? history[2] : true; + } - public getChangedFiles = reuseable(async (forceUpdate: boolean = false): Promise => { - const commit = await this.getItem(); + public getChangedFiles = reuseable(async (ref: string, forceUpdate = false): Promise => { + const commit = await this.getItem(ref); const manager = commit ? CommitChangedFilesManager.getInstance(this._scheme, this._repo, commit.sha) : null; return manager ? manager.getList(forceUpdate) : []; }); - public loadMoreChangedFiles = reuseable(async (): Promise => { - const commit = await this.getItem(); + public loadMoreChangedFiles = reuseable(async (ref: string): Promise => { + const commit = await this.getItem(ref); const manager = commit ? CommitChangedFilesManager.getInstance(this._scheme, this._repo, commit.sha) : null; return manager ? manager.loadMore() : []; }); - public hasMoreChangedFiles = reuseable(async (): Promise => { - const commit = await this.getItem(); + public hasMoreChangedFiles = reuseable(async (ref: string): Promise => { + const commit = await this.getItem(ref); const manager = commit ? CommitChangedFilesManager.getInstance(this._scheme, this._repo, commit.sha) : null; return manager ? manager.hasMore() : false; }); - // get the lastest commit of `file with modifications`, - // the commit of `this._from` could be newer than result - public getLatestCommit = reuseable(async (): Promise => { - const commit = this._latestCommitSha ? CommitManager._commitMap.get(this._latestCommitSha) : null; - return commit || (await this.loadMore())[0] || null; + // The latest commit touching the file may be older than the requested revision. + public getLatestCommit = reuseable(async (from: string, filePath: string): Promise => { + const commits = await this.getList(from, filePath); + return commits[0] || null; }); - public getPreviousCommit = reuseable(async (): Promise => { - const commit = await this.getItem(); - const commitRelation = commit ? CommitManager._relationMap.get(this._filePath)?.get(commit.sha) : null; - if (!commitRelation || commitRelation.previous === undefined) { - return (await this.loadMore())[0] || null; + // Keep the original history's `from`: at merges, the previous file revision + // in that list can differ from the one found by querying from `sha`. + public getPreviousCommit = reuseable(async (sha: string, filePath: string, from: string): Promise => { + let key = historyKey(from, filePath); + let history = this._historyMap.get(key); + if (!history) { + // no history for the file, fetch the history first. + await this.getList(from, filePath); + history = this._historyMap.get(key)!; + } + + if (history[0].indexOf(sha) < 0) { + // If history exists, but sha is not in it. We use `sha` as + // `from` to request commitList again. This might not be very + // accurate, but it avoids an unknown number of fetch requests. + key = historyKey((from = sha), filePath); + await this.getList(from, filePath); + history = this._historyMap.get(key)!; + } + + let index = history[0].indexOf(sha); + if (index == history[0].length - 1 && history[2]) { + // we should load more commits if the current commit + // is the last one and there are more commits to load + await this.loadMore(from || sha, filePath); + history = this._historyMap.get(key); + index = history?.[0].indexOf(sha) ?? -1; } - return (commitRelation.previous ? CommitManager._commitMap.get(commitRelation.previous) : null) || null; + const targetSha = index >= 0 ? history?.[0][index + 1] : null; + return targetSha ? this._shaMap.get(targetSha) || null : null; }); - public getNextCommit = reuseable(async (): Promise => { - const commit = await this.getItem(); - const commitRelation = commit ? CommitManager._relationMap.get(this._filePath)?.get(commit.sha) : null; - const nextCommitSha = commitRelation ? commitRelation.next : null; - return (nextCommitSha ? CommitManager._commitMap.get(nextCommitSha) : null) || null; + // Newer entries depend on the original history's `from`; a commit + // SHA and file path alone do not provide that navigation context. + public getNextCommit = reuseable(async (ref: string, filePath: string, from: string): Promise => { + const history = this._historyMap.get(historyKey(from || ref, filePath)); + const index = history?.[0].indexOf(ref) ?? -1; + return index > 0 ? this._shaMap.get(history![0][index - 1]) || null : null; }); } diff --git a/extensions/github1s/src/repository/index.ts b/extensions/github1s/src/repository/index.ts index 2379cffa3..7cc0f5c63 100644 --- a/extensions/github1s/src/repository/index.ts +++ b/extensions/github1s/src/repository/index.ts @@ -15,6 +15,7 @@ export class Repository { private _branchTagManager: BranchTagManager; private _codeReviewManager: CodeReviewManager; + private _commitManager: CommitManager; private _blameRangesCache: Map; public static getInstance(scheme: string, repo: string) { @@ -35,6 +36,7 @@ export class Repository { ) { this._branchTagManager = BranchTagManager.getInstance(_scheme, _repo); this._codeReviewManager = CodeReviewManager.getInstance(_scheme, _repo); + this._commitManager = CommitManager.getInstance(_scheme, _repo); this._blameRangesCache = new Map(); } @@ -71,43 +73,43 @@ export class Repository { } getCommitList(ref: string = 'HEAD', filePath: string = '/', forceUpdate: boolean = false) { - return CommitManager.getInstance(this._scheme, this._repo, ref, filePath).getList(forceUpdate); + return this._commitManager.getList(ref, filePath, forceUpdate); } getCommitItem(ref: string, forceUpdate: boolean = false) { - return CommitManager.getInstance(this._scheme, this._repo, ref, '/').getItem(forceUpdate); + return this._commitManager.getItem(ref, forceUpdate); } loadMoreCommits(ref: string = 'HEAD', filePath: string = '/') { - return CommitManager.getInstance(this._scheme, this._repo, ref, filePath).loadMore(); + return this._commitManager.loadMore(ref, filePath); } hasMoreCommits(ref: string = 'HEAD', filePath: string = '/') { - return CommitManager.getInstance(this._scheme, this._repo, ref, filePath).hasMore(); + return this._commitManager.hasMore(ref, filePath); } getCommitChangedFiles(ref: string, forceUpdate: boolean = false) { - return CommitManager.getInstance(this._scheme, this._repo, ref, '/').getChangedFiles(forceUpdate); + return this._commitManager.getChangedFiles(ref, forceUpdate); } loadMoreCommitChangedFiles(ref: string) { - return CommitManager.getInstance(this._scheme, this._repo, ref, '/').loadMoreChangedFiles(); + return this._commitManager.loadMoreChangedFiles(ref); } hasMoreCommitChangedFiles(ref: string) { - return CommitManager.getInstance(this._scheme, this._repo, ref, '/').hasMoreChangedFiles(); + return this._commitManager.hasMoreChangedFiles(ref); } getFileLatestCommit(ref: string, filePath: string) { - return CommitManager.getInstance(this._scheme, this._repo, ref, filePath).getLatestCommit(); + return this._commitManager.getLatestCommit(ref, filePath); } - getPreviousCommit(ref: string, filePath: string) { - return CommitManager.getInstance(this._scheme, this._repo, ref, filePath).getPreviousCommit(); + getPreviousCommit(ref: string, filePath: string, from: string) { + return this._commitManager.getPreviousCommit(ref, filePath, from); } - getNextCommit(ref: string, filePath: string) { - return CommitManager.getInstance(this._scheme, this._repo, ref, filePath).getNextCommit(); + getNextCommit(ref: string, filePath: string, from: string) { + return this._commitManager.getNextCommit(ref, filePath, from); } getCodeReviewList(...args: Parameters) { diff --git a/extensions/github1s/src/views/index.ts b/extensions/github1s/src/views/index.ts index a59956a9b..4866e1931 100644 --- a/extensions/github1s/src/views/index.ts +++ b/extensions/github1s/src/views/index.ts @@ -7,10 +7,9 @@ import { getAdapter } from '@/adapters'; import { CodeReviewType } from '@/adapters/types'; import { getExtensionContext } from '@/helpers/context'; import { CodeReviewTreeDataProvider } from './code-review-list'; -import { CommitTreeDataProvider, FileHistoryTreeDataProvider } from './commit-list'; +import { FileHistoryTreeDataProvider } from './commit-list'; export const fileHistoryTreeDataProvider = new FileHistoryTreeDataProvider(); -export const commitTreeDataProvider = new CommitTreeDataProvider(); export const codeReviewRequestTreeDataProvider = new CodeReviewTreeDataProvider(); export const codeReviewViewTitle = { @@ -34,6 +33,5 @@ export const registerCustomViews = () => { context.subscriptions.push( // register commit view which is in source control panel vscode.window.registerTreeDataProvider(FileHistoryTreeDataProvider.viewType, fileHistoryTreeDataProvider), - vscode.window.registerTreeDataProvider(CommitTreeDataProvider.viewType, commitTreeDataProvider), ); }; diff --git a/extensions/github1s/src/vscode.proposed.d.ts b/extensions/github1s/src/vscode.proposed.d.ts index 4eff483da..593d070a1 100644 --- a/extensions/github1s/src/vscode.proposed.d.ts +++ b/extensions/github1s/src/vscode.proposed.d.ts @@ -360,3 +360,114 @@ declare module 'vscode' { export function registerTextSearchProvider(scheme: string, provider: TextSearchProvider): Disposable; } } + +/*--------------------------------------------------------------------------------------------- + * 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/185269 + + export interface SourceControl { + historyProvider?: SourceControlHistoryProvider; + } + + export interface SourceControlHistoryProvider { + readonly currentHistoryItemRef: SourceControlHistoryItemRef | undefined; + readonly currentHistoryItemRemoteRef: SourceControlHistoryItemRef | undefined; + readonly currentHistoryItemBaseRef: SourceControlHistoryItemRef | undefined; + + /** + * Fires when the current history item refs (local, remote, base) + * change after a user action (ex: commit, checkout, fetch, pull, push) + */ + readonly onDidChangeCurrentHistoryItemRefs: Event; + + /** + * Fires when history item refs change + */ + readonly onDidChangeHistoryItemRefs: Event; + + provideHistoryItemRefs( + historyItemRefs: string[] | undefined, + token: CancellationToken, + ): ProviderResult; + provideHistoryItems( + options: SourceControlHistoryOptions, + token: CancellationToken, + ): ProviderResult; + provideHistoryItemChanges( + historyItemId: string, + historyItemParentId: string | undefined, + token: CancellationToken, + ): ProviderResult; + + resolveHistoryItem(historyItemId: string, token: CancellationToken): ProviderResult; + resolveHistoryItemChatContext(historyItemId: string, token: CancellationToken): ProviderResult; + resolveHistoryItemChangeRangeChatContext( + historyItemId: string, + historyItemParentId: string, + path: string, + token: CancellationToken, + ): ProviderResult; + resolveHistoryItemRefsCommonAncestor(historyItemRefs: string[], token: CancellationToken): ProviderResult; + } + + export interface SourceControlHistoryOptions { + readonly skip?: number; + readonly limit?: number | { id?: string }; + readonly historyItemRefs?: readonly string[]; + readonly filterText?: string; + } + + export interface SourceControlHistoryItemStatistics { + readonly files: number; + readonly insertions: number; + readonly deletions: number; + } + + export interface SourceControlHistoryItem { + readonly id: string; + readonly parentIds: string[]; + readonly subject: string; + readonly message: string; + readonly displayId?: string; + readonly author?: string; + readonly authorEmail?: string; + readonly authorIcon?: IconPath; + readonly timestamp?: number; + readonly statistics?: SourceControlHistoryItemStatistics; + readonly references?: SourceControlHistoryItemRef[]; + readonly tooltip?: MarkdownString | Array | undefined; + } + + export interface SourceControlHistoryItemRef { + readonly id: string; + readonly name: string; + readonly description?: string; + readonly revision?: string; + readonly category?: string; + readonly icon?: IconPath; + } + + export interface SourceControlHistoryItemChange { + readonly uri: Uri; + readonly originalUri: Uri | undefined; + readonly modifiedUri: Uri | undefined; + } + + export interface SourceControlHistoryItemRefsChangeEvent { + readonly added: readonly SourceControlHistoryItemRef[]; + readonly removed: readonly SourceControlHistoryItemRef[]; + readonly modified: readonly SourceControlHistoryItemRef[]; + + /** + * Flag to indicate if the operation that caused the event to trigger was due + * to a user action or a background operation (ex: Auto Fetch). The flag is used + * to determine whether to automatically refresh the user interface or present + * the user with a visual cue that the user interface is outdated. + */ + readonly silent: boolean; + } +}