diff --git a/package.json b/package.json index 66960c1..2e06b24 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "onLanguage:jupyter-notebook", "workspaceContains:**/*.ipynb" ], + "extensionDependencies": [ + "vscode.git" + ], "main": "./dist/extension.js", "contributes": { "commands": [ @@ -165,4 +168,4 @@ "markdown-it": "^14.1.0", "ws": "^8.19.0" } -} \ No newline at end of file +} diff --git a/src/extension.ts b/src/extension.ts index 0bb570e..9d921a0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -17,11 +17,11 @@ import * as vscode from 'vscode'; import { NotebookConflictResolver, ConflictedNotebook, onDidResolveConflict, onDidResolveConflictWithDetails } from './resolver'; import * as gitIntegration from './gitIntegration'; import { getWebServer } from './web'; +import type { API as GitAPI, GitExtension, Repository } from './typings/git'; let resolver: NotebookConflictResolver; let statusBarItem: vscode.StatusBarItem; -let currentFileHasConflicts = false; -let successTimeout: NodeJS.Timeout | undefined; +let statusBarVisible = false; let lastResolvedDetails: { uri: string; resolvedNotebook: unknown; @@ -61,7 +61,7 @@ async function updateStatusBar(): Promise { if (!activeUri) { statusBarItem.hide(); - currentFileHasConflicts = false; + statusBarVisible = false; return; } @@ -74,13 +74,57 @@ async function updateStatusBar(): Promise { statusBarItem.command = 'merge-nb.findConflicts'; statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); statusBarItem.show(); - currentFileHasConflicts = true; + statusBarVisible = true; } else { statusBarItem.hide(); - currentFileHasConflicts = false; + statusBarVisible = false; } } +function registerGitStateWatchers(context: vscode.ExtensionContext): void { + const extension = vscode.extensions.getExtension('vscode.git'); + if (!extension) { + console.warn('[MergeNB] vscode.git extension was not found; Git state watchers are disabled.'); + return; + } + + const watchedRepositories = new WeakSet(); + const attachRepositoryWatcher = (repository: Repository): void => { + if (!repository?.state || watchedRepositories.has(repository)) { + return; + } + watchedRepositories.add(repository); + context.subscriptions.push( + repository.state.onDidChange(() => { + decorationChangeEmitter.fire(undefined); + void updateStatusBar(); + }) + ); + }; + + const registerWithApi = (api: GitAPI): void => { + for (const repository of api.repositories) { + attachRepositoryWatcher(repository); + } + + context.subscriptions.push( + api.onDidOpenRepository((repository) => { + attachRepositoryWatcher(repository); + decorationChangeEmitter.fire(undefined); + void updateStatusBar(); + }) + ); + }; + + const api = extension.exports?.getAPI(1); + if (!api) { + console.warn('[MergeNB] Git extension API unavailable; Git state watchers are disabled.'); + return; + } + + registerWithApi(api); +} + export function activate(context: vscode.ExtensionContext) { console.log('MergeNB extension is now active'); const isTestMode = process.env.MERGENB_TEST_MODE === 'true'; @@ -98,7 +142,8 @@ export function activate(context: vscode.ExtensionContext) { ); // Initial status bar update - updateStatusBar(); + void updateStatusBar(); + registerGitStateWatchers(context); // Listen for resolution success events context.subscriptions.push( @@ -191,6 +236,14 @@ export function activate(context: vscode.ExtensionContext) { return webServer.isRunning() ? webServer.getPort() : 0; }) ); + context.subscriptions.push( + vscode.commands.registerCommand('merge-nb.getStatusBarState', () => { + return { + visible: statusBarVisible, + text: statusBarItem.text, + }; + }) + ); } // Register file decoration for notebooks with conflicts @@ -225,28 +278,18 @@ export function activate(context: vscode.ExtensionContext) { fileWatcher, fileWatcher.onDidChange(uri => { decorationChangeEmitter.fire(uri); - updateStatusBar(); + void updateStatusBar(); }), fileWatcher.onDidCreate(uri => { decorationChangeEmitter.fire(uri); - updateStatusBar(); + void updateStatusBar(); }), fileWatcher.onDidDelete(uri => { decorationChangeEmitter.fire(uri); - updateStatusBar(); + void updateStatusBar(); }) ); - // Also watch for Git repository changes - const gitWatcher = vscode.workspace.createFileSystemWatcher('**/.git/index'); - context.subscriptions.push( - gitWatcher, - gitWatcher.onDidChange(async () => { - // Git index changed, refresh all notebook decorations - decorationChangeEmitter.fire(undefined); - updateStatusBar(); - }) - ); } export function deactivate() { @@ -256,4 +299,3 @@ export function deactivate() { webServer.stop(); } } - diff --git a/src/typings/git.d.ts b/src/typings/git.d.ts new file mode 100644 index 0000000..287dd43 --- /dev/null +++ b/src/typings/git.d.ts @@ -0,0 +1,506 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Uri, Event, Disposable, ProviderResult, Command, CancellationToken, SourceControlHistoryItem } from 'vscode'; +export { ProviderResult } from 'vscode'; + +export interface Git { + readonly path: string; +} + +export interface InputBox { + value: string; +} + +export const enum ForcePushMode { + Force, + ForceWithLease, + ForceWithLeaseIfIncludes, +} + +export const enum RefType { + Head, + RemoteHead, + Tag +} + +export interface Ref { + readonly type: RefType; + readonly name?: string; + readonly commit?: string; + readonly commitDetails?: Commit; + readonly remote?: string; +} + +export interface UpstreamRef { + readonly remote: string; + readonly name: string; + readonly commit?: string; +} + +export interface Branch extends Ref { + readonly upstream?: UpstreamRef; + readonly ahead?: number; + readonly behind?: number; +} + +export interface CommitShortStat { + readonly files: number; + readonly insertions: number; + readonly deletions: number; +} + +export interface Commit { + readonly hash: string; + readonly message: string; + readonly parents: string[]; + readonly authorDate?: Date; + readonly authorName?: string; + readonly authorEmail?: string; + readonly commitDate?: Date; + readonly shortStat?: CommitShortStat; +} + +export interface Submodule { + readonly name: string; + readonly path: string; + readonly url: string; +} + +export interface Remote { + readonly name: string; + readonly fetchUrl?: string; + readonly pushUrl?: string; + readonly isReadOnly: boolean; +} + +export interface Worktree { + readonly name: string; + readonly path: string; + readonly ref: string; + readonly main: boolean; + readonly detached: boolean; +} + +export const enum Status { + INDEX_MODIFIED, + INDEX_ADDED, + INDEX_DELETED, + INDEX_RENAMED, + INDEX_COPIED, + + MODIFIED, + DELETED, + UNTRACKED, + IGNORED, + INTENT_TO_ADD, + INTENT_TO_RENAME, + TYPE_CHANGED, + + ADDED_BY_US, + ADDED_BY_THEM, + DELETED_BY_US, + DELETED_BY_THEM, + BOTH_ADDED, + BOTH_DELETED, + BOTH_MODIFIED +} + +export interface Change { + + /** + * Returns either `originalUri` or `renameUri`, depending + * on whether this change is a rename change. When + * in doubt always use `uri` over the other two alternatives. + */ + readonly uri: Uri; + readonly originalUri: Uri; + readonly renameUri: Uri | undefined; + readonly status: Status; +} + +export interface DiffChange extends Change { + readonly insertions: number; + readonly deletions: number; +} + +export type RepositoryKind = 'repository' | 'submodule' | 'worktree'; + +export interface RepositoryState { + readonly HEAD: Branch | undefined; + readonly refs: Ref[]; + readonly remotes: Remote[]; + readonly submodules: Submodule[]; + readonly worktrees: Worktree[]; + readonly rebaseCommit: Commit | undefined; + + readonly mergeChanges: Change[]; + readonly indexChanges: Change[]; + readonly workingTreeChanges: Change[]; + readonly untrackedChanges: Change[]; + + readonly onDidChange: Event; +} + +export interface RepositoryUIState { + readonly selected: boolean; + readonly onDidChange: Event; +} + +export interface RepositoryAccessDetails { + readonly rootUri: Uri; + readonly lastAccessTime: number; +} + +/** + * Log options. + */ +export interface LogOptions { + /** Max number of log entries to retrieve. If not specified, the default is 32. */ + readonly maxEntries?: number; + readonly path?: string; + /** A commit range, such as "0a47c67f0fb52dd11562af48658bc1dff1d75a38..0bb4bdea78e1db44d728fd6894720071e303304f" */ + readonly range?: string; + readonly reverse?: boolean; + readonly sortByAuthorDate?: boolean; + readonly shortStats?: boolean; + readonly author?: string; + readonly grep?: string; + readonly refNames?: string[]; + readonly maxParents?: number; + readonly skip?: number; +} + +export interface CommitOptions { + all?: boolean | 'tracked'; + amend?: boolean; + signoff?: boolean; + /** + * true - sign the commit + * false - do not sign the commit + * undefined - use the repository/global git config + */ + signCommit?: boolean; + empty?: boolean; + noVerify?: boolean; + requireUserConfig?: boolean; + useEditor?: boolean; + verbose?: boolean; + /** + * string - execute the specified command after the commit operation + * undefined - execute the command specified in git.postCommitCommand + * after the commit operation + * null - do not execute any command after the commit operation + */ + postCommitCommand?: string | null; +} + +export interface FetchOptions { + remote?: string; + ref?: string; + all?: boolean; + prune?: boolean; + depth?: number; +} + +export interface InitOptions { + defaultBranch?: string; +} + +export interface CloneOptions { + parentPath?: Uri; + /** + * ref is only used if the repository cache is missed. + */ + ref?: string; + recursive?: boolean; + /** + * If no postCloneAction is provided, then the users setting for git.openAfterClone is used. + */ + postCloneAction?: 'none'; +} + +export interface RefQuery { + readonly contains?: string; + readonly count?: number; + readonly pattern?: string | string[]; + readonly sort?: 'alphabetically' | 'committerdate' | 'creatordate'; +} + +export interface BranchQuery extends RefQuery { + readonly remote?: boolean; +} + +export interface Repository { + + readonly rootUri: Uri; + readonly inputBox: InputBox; + readonly state: RepositoryState; + readonly ui: RepositoryUIState; + readonly kind: RepositoryKind; + + readonly onDidCommit: Event; + readonly onDidCheckout: Event; + + getConfigs(): Promise<{ key: string; value: string; }[]>; + getConfig(key: string): Promise; + setConfig(key: string, value: string): Promise; + unsetConfig(key: string): Promise; + getGlobalConfig(key: string): Promise; + + getObjectDetails(treeish: string, path: string): Promise<{ mode: string, object: string, size: number }>; + detectObjectType(object: string): Promise<{ mimetype: string, encoding?: string }>; + buffer(ref: string, path: string): Promise; + show(ref: string, path: string): Promise; + getCommit(ref: string): Promise; + + add(paths: string[]): Promise; + revert(paths: string[]): Promise; + clean(paths: string[]): Promise; + + apply(patch: string, reverse?: boolean): Promise; + apply(patch: string, options?: { allowEmpty?: boolean; reverse?: boolean; threeWay?: boolean; }): Promise; + diff(cached?: boolean): Promise; + diffWithHEAD(): Promise; + diffWithHEAD(path: string): Promise; + diffWithHEADShortStats(path?: string): Promise; + diffWith(ref: string): Promise; + diffWith(ref: string, path: string): Promise; + diffIndexWithHEAD(): Promise; + diffIndexWithHEAD(path: string): Promise; + diffIndexWithHEADShortStats(path?: string): Promise; + diffIndexWith(ref: string): Promise; + diffIndexWith(ref: string, path: string): Promise; + diffBlobs(object1: string, object2: string): Promise; + diffBetween(ref1: string, ref2: string): Promise; + diffBetween(ref1: string, ref2: string, path: string): Promise; + diffBetweenPatch(ref1: string, ref2: string, path?: string): Promise; + diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise; + + hashObject(data: string): Promise; + + createBranch(name: string, checkout: boolean, ref?: string): Promise; + deleteBranch(name: string, force?: boolean): Promise; + getBranch(name: string): Promise; + getBranches(query: BranchQuery, cancellationToken?: CancellationToken): Promise; + getBranchBase(name: string): Promise; + setBranchUpstream(name: string, upstream: string): Promise; + + checkIgnore(paths: string[]): Promise>; + + getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise; + + getMergeBase(ref1: string, ref2: string): Promise; + + tag(name: string, message: string, ref?: string | undefined): Promise; + deleteTag(name: string): Promise; + + status(): Promise; + checkout(treeish: string): Promise; + + addRemote(name: string, url: string): Promise; + removeRemote(name: string): Promise; + renameRemote(name: string, newName: string): Promise; + + fetch(options?: FetchOptions): Promise; + fetch(remote?: string, ref?: string, depth?: number): Promise; + pull(unshallow?: boolean): Promise; + push(remoteName?: string, branchName?: string, setUpstream?: boolean, force?: ForcePushMode): Promise; + + blame(path: string): Promise; + log(options?: LogOptions): Promise; + + commit(message: string, opts?: CommitOptions): Promise; + merge(ref: string): Promise; + mergeAbort(): Promise; + + createStash(options?: { message?: string; includeUntracked?: boolean; staged?: boolean }): Promise; + applyStash(index?: number): Promise; + popStash(index?: number): Promise; + dropStash(index?: number): Promise; + + createWorktree(options?: { path?: string; commitish?: string; branch?: string }): Promise; + deleteWorktree(path: string, options?: { force?: boolean }): Promise; + + migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise; +} + +export interface RemoteSource { + readonly name: string; + readonly description?: string; + readonly url: string | string[]; +} + +export interface RemoteSourceProvider { + readonly name: string; + readonly icon?: string; // codicon name + readonly supportsQuery?: boolean; + getRemoteSources(query?: string): ProviderResult; + getBranches?(url: string): ProviderResult; + publishRepository?(repository: Repository): Promise; +} + +export interface RemoteSourcePublisher { + readonly name: string; + readonly icon?: string; // codicon name + publishRepository(repository: Repository): Promise; +} + +export interface Credentials { + readonly username: string; + readonly password: string; +} + +export interface CredentialsProvider { + getCredentials(host: Uri): ProviderResult; +} + +export interface PostCommitCommandsProvider { + getCommands(repository: Repository): Command[]; +} + +export interface PushErrorHandler { + handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode: GitErrorCodes }): Promise; +} + +export interface BranchProtection { + readonly remote: string; + readonly rules: BranchProtectionRule[]; +} + +export interface BranchProtectionRule { + readonly include?: string[]; + readonly exclude?: string[]; +} + +export interface BranchProtectionProvider { + onDidChangeBranchProtection: Event; + provideBranchProtection(): BranchProtection[]; +} + +export interface AvatarQueryCommit { + readonly hash: string; + readonly authorName?: string; + readonly authorEmail?: string; +} + +export interface AvatarQuery { + readonly commits: AvatarQueryCommit[]; + readonly size: number; +} + +export interface SourceControlHistoryItemDetailsProvider { + provideAvatar(repository: Repository, query: AvatarQuery): ProviderResult>; + provideHoverCommands(repository: Repository): ProviderResult; + provideMessageLinks(repository: Repository, message: string): ProviderResult; +} + +export type APIState = 'uninitialized' | 'initialized'; + +export interface PublishEvent { + repository: Repository; + branch?: string; +} + +export interface API { + readonly state: APIState; + readonly onDidChangeState: Event; + readonly onDidPublish: Event; + readonly git: Git; + readonly repositories: Repository[]; + readonly recentRepositories: Iterable; + readonly onDidOpenRepository: Event; + readonly onDidCloseRepository: Event; + + toGitUri(uri: Uri, ref: string): Uri; + getRepository(uri: Uri): Repository | null; + getRepositoryRoot(uri: Uri): Promise; + getRepositoryWorkspace(uri: Uri): Promise; + init(root: Uri, options?: InitOptions): Promise; + /** + * Checks the cache of known cloned repositories, and clones if the repository is not found. + * Make sure to pass `postCloneAction` 'none' if you want to have the uri where you can find the repository returned. + * @returns The URI of a folder or workspace file which, when opened, will open the cloned repository. + */ + clone(uri: Uri, options?: CloneOptions): Promise; + openRepository(root: Uri): Promise; + + registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; + registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; + registerCredentialsProvider(provider: CredentialsProvider): Disposable; + registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable; + registerPushErrorHandler(handler: PushErrorHandler): Disposable; + registerBranchProtectionProvider(root: Uri, provider: BranchProtectionProvider): Disposable; + registerSourceControlHistoryItemDetailsProvider(provider: SourceControlHistoryItemDetailsProvider): Disposable; +} + +export interface GitExtension { + + readonly enabled: boolean; + readonly onDidChangeEnablement: Event; + + /** + * Returns a specific API version. + * + * Throws error if git extension is disabled. You can listen to the + * [GitExtension.onDidChangeEnablement](#GitExtension.onDidChangeEnablement) event + * to know when the extension becomes enabled/disabled. + * + * @param version Version number. + * @returns API instance + */ + getAPI(version: 1): API; +} + +export const enum GitErrorCodes { + BadConfigFile = 'BadConfigFile', + BadRevision = 'BadRevision', + AuthenticationFailed = 'AuthenticationFailed', + NoUserNameConfigured = 'NoUserNameConfigured', + NoUserEmailConfigured = 'NoUserEmailConfigured', + NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', + NotAGitRepository = 'NotAGitRepository', + NotASafeGitRepository = 'NotASafeGitRepository', + NotAtRepositoryRoot = 'NotAtRepositoryRoot', + Conflict = 'Conflict', + StashConflict = 'StashConflict', + UnmergedChanges = 'UnmergedChanges', + PushRejected = 'PushRejected', + ForcePushWithLeaseRejected = 'ForcePushWithLeaseRejected', + ForcePushWithLeaseIfIncludesRejected = 'ForcePushWithLeaseIfIncludesRejected', + RemoteConnectionError = 'RemoteConnectionError', + DirtyWorkTree = 'DirtyWorkTree', + CantOpenResource = 'CantOpenResource', + GitNotFound = 'GitNotFound', + CantCreatePipe = 'CantCreatePipe', + PermissionDenied = 'PermissionDenied', + CantAccessRemote = 'CantAccessRemote', + RepositoryNotFound = 'RepositoryNotFound', + RepositoryIsLocked = 'RepositoryIsLocked', + BranchNotFullyMerged = 'BranchNotFullyMerged', + NoRemoteReference = 'NoRemoteReference', + InvalidBranchName = 'InvalidBranchName', + BranchAlreadyExists = 'BranchAlreadyExists', + NoLocalChanges = 'NoLocalChanges', + NoStashFound = 'NoStashFound', + LocalChangesOverwritten = 'LocalChangesOverwritten', + NoUpstreamBranch = 'NoUpstreamBranch', + IsInSubmodule = 'IsInSubmodule', + WrongCase = 'WrongCase', + CantLockRef = 'CantLockRef', + CantRebaseMultipleBranches = 'CantRebaseMultipleBranches', + PatchDoesNotApply = 'PatchDoesNotApply', + NoPathFound = 'NoPathFound', + UnknownPath = 'UnknownPath', + EmptyCommitMessage = 'EmptyCommitMessage', + BranchFastForwardRejected = 'BranchFastForwardRejected', + BranchNotYetBorn = 'BranchNotYetBorn', + TagConflict = 'TagConflict', + CherryPickEmpty = 'CherryPickEmpty', + CherryPickConflict = 'CherryPickConflict', + WorktreeContainsChanges = 'WorktreeContainsChanges', + WorktreeAlreadyExists = 'WorktreeAlreadyExists', + WorktreeBranchAlreadyUsed = 'WorktreeBranchAlreadyUsed' +}