diff --git a/extension.js b/extension.js index 620bd7d..3a19a91 100644 --- a/extension.js +++ b/extension.js @@ -1,7 +1,8 @@ let assert = require("node:assert"); let path = require("node:path"); let { homedir } = require("node:os"); -let { window, workspace, commands, Uri, EventEmitter, FileType, Selection, languages, Range, Diagnostic, DiagnosticRelatedInformation, Location } = require("vscode"); +let { window, workspace, commands, Uri, EventEmitter, FileType, Selection, languages, Range, Diagnostic, DiagnosticRelatedInformation, Location, extensions, ThemeColor } = require("vscode"); +let gitDecorations = require("./git-decorations"); /** * The scheme is used to associate vsnetrw documents with the text content provider @@ -120,6 +121,7 @@ async function openExplorer(dirName) { await languages.setTextDocumentLanguage(doc, languageId); moveCursorToPreviousFile(); refreshDiagnostics(); + gitDecorations.updateDecorations(); refresh(); } @@ -414,14 +416,6 @@ async function provideTextDocumentContent(documentUri) { return listings.join("\n"); } -/** - * @type {import("vscode").TextDocumentContentProvider} - */ -let contentProvider = { - onDidChange: uriChangeEmitter.event, - provideTextDocumentContent, -}; - let diagnostics = languages.createDiagnosticCollection("vsnetrw"); /** @@ -445,10 +439,10 @@ function refreshDiagnostics() { let severities = childDiagnostics.map(diagnostic => diagnostic.severity); let severity = Math.min(...severities); let name = path.basename(uri.fsPath); - let range = new Range(line, 0, line, name.length); let diagnostic = new Diagnostic( - range, `${childDiagnostics.length} problems in this file`, + new Range(line, 0, line, name.length), + `${childDiagnostics.length} problems in this file`, severity ); @@ -465,6 +459,14 @@ function refreshDiagnostics() { diagnostics.set(document.uri, ownDiagnostics); } +/** + * @type {import("vscode").TextDocumentContentProvider} + */ +let contentProvider = { + onDidChange: uriChangeEmitter.event, + provideTextDocumentContent, +}; + /** * @param {import("vscode").ExtensionContext} context */ diff --git a/git-decorations.js b/git-decorations.js new file mode 100644 index 0000000..e3d4718 --- /dev/null +++ b/git-decorations.js @@ -0,0 +1,92 @@ +let assert = require("assert"); +let path = require("path"); +let { window, Uri, Range, extensions, ThemeColor } = require("vscode"); + +let gitExt = extensions.getExtension("vscode.git")?.exports; + +/** + * @type {import("./git").API} + */ +let git = gitExt.getAPI(1); + +/** + * @param {string} label + * @param {string} [themeColorId] + */ +function createDecorationType(label, themeColorId) { + return window.createTextEditorDecorationType({ + before: { + contentText: `${label} `, + color: themeColorId ? new ThemeColor(themeColorId) : undefined, + }, + }); +} + +let modifiedDecorationType = createDecorationType("M", "gitDecoration.modifiedResourceForeground"); +let addedDecorationType = createDecorationType("A", "gitDecoration.addedResourceForeground"); +let deletedDecorationType = createDecorationType("D", "gitDecoration.deletedResourceForeground"); +let untrackedDecorationType = createDecorationType("U", "gitDecoration.untrackedResourceForeground"); +let ignoredDecorationType = createDecorationType("I", "gitDecoration.ignoredResourceForeground"); +let noStatusDecorationType = createDecorationType(" "); + +// Corresponds to `Status` enum in ./git.d.ts +let decorationTypesByStatus = [ + modifiedDecorationType, // 0 INDEX_MODIFIED + addedDecorationType, // 1 INDEX_ADDED + deletedDecorationType, // 2 INDEX_DELETED + null, // 3 INDEX_RENAMED + null, // 4 INDEX_COPIED + + modifiedDecorationType, // 5 MODIFIED + deletedDecorationType, // 6 DELETED + untrackedDecorationType, // 7 UNTRACKED + ignoredDecorationType, // 8 IGNORED + addedDecorationType, // INTENT_TO_ADD +]; + +function updateDecorations() { + if (!git) return; + assert(window.activeTextEditor); + + let repo = git.repositories[0]; + let changes = repo ? [ + ...repo.state.indexChanges, + ...repo.state.workingTreeChanges, + ] : []; + + let document = window.activeTextEditor.document; + let base = document.uri.path; + let lines = document.getText().split("\n"); + + /** + * @type {Record} + */ + let linesByStatus = {}; + + for (let line = 0; line < lines.length; line++) { + let pathToFile = path.join(base, lines[line]); + let uri = Uri.file(pathToFile); + let change = changes.find(change => change.uri.toString() === uri.toString()); + let status = change ? change.status : -1; + linesByStatus[status] = linesByStatus[status] || []; + linesByStatus[status].push(line); + } + + for (let key of Object.keys(linesByStatus)) { + let status = parseInt(key); + + let decorationType = status > 0 + ? decorationTypesByStatus[status] + : noStatusDecorationType; + + let ranges = linesByStatus[status].map(line => { + return new Range(line, 0, line, lines[line].length); + }); + + if (decorationType) { + window.activeTextEditor.setDecorations(decorationType, ranges); + } + } +} + +module.exports = { updateDecorations }; diff --git a/git.d.ts b/git.d.ts new file mode 100644 index 0000000..fa5d816 --- /dev/null +++ b/git.d.ts @@ -0,0 +1,349 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * + *--------------------------------------------------------------------------------------------*/ + +// Types copied from https://github.com/microsoft/vscode/blob/main/extensions/git/src/api/git.d.ts +// as recommended here: https://github.com/microsoft/vscode/tree/main/extensions/git#api + +import { Uri, Event, Disposable, ProviderResult } from 'vscode'; +export { ProviderResult } from 'vscode'; + +export interface Git { + readonly path: string; +} + +export interface InputBox { + value: string; +} + +export const enum ForcePushMode { + Force, + ForceWithLease +} + +export const enum RefType { + Head, + RemoteHead, + Tag +} + +export interface Ref { + readonly type: RefType; + readonly name?: string; + readonly commit?: string; + readonly remote?: string; +} + +export interface UpstreamRef { + readonly remote: string; + readonly name: string; +} + +export interface Branch extends Ref { + readonly upstream?: UpstreamRef; + readonly ahead?: number; + readonly behind?: 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; +} + +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 const enum Status { + INDEX_MODIFIED, + INDEX_ADDED, + INDEX_DELETED, + INDEX_RENAMED, + INDEX_COPIED, + + MODIFIED, + DELETED, + UNTRACKED, + IGNORED, + INTENT_TO_ADD, + + 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 RepositoryState { + readonly HEAD: Branch | undefined; + readonly refs: Ref[]; + readonly remotes: Remote[]; + readonly submodules: Submodule[]; + readonly rebaseCommit: Commit | undefined; + + readonly mergeChanges: Change[]; + readonly indexChanges: Change[]; + readonly workingTreeChanges: Change[]; + + readonly onDidChange: Event; +} + +export interface RepositoryUIState { + readonly selected: boolean; + readonly onDidChange: Event; +} + +/** + * 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; +} + +export type PostCommitCommand = 'push' | 'sync' | string; + +export interface CommitOptions { + all?: boolean | 'tracked'; + amend?: boolean; + signoff?: boolean; + signCommit?: boolean; + empty?: boolean; + noVerify?: boolean; + requireUserConfig?: boolean; + useEditor?: boolean; + verbose?: boolean; + postCommitCommand?: PostCommitCommand; +} + +export interface FetchOptions { + remote?: string; + ref?: string; + all?: boolean; + prune?: boolean; + depth?: number; +} + +export interface BranchQuery { + readonly remote?: boolean; + readonly pattern?: string; + readonly count?: number; + readonly contains?: string; +} + +export interface Repository { + + readonly rootUri: Uri; + readonly inputBox: InputBox; + readonly state: RepositoryState; + readonly ui: RepositoryUIState; + + getConfigs(): Promise<{ key: string; value: string; }[]>; + getConfig(key: string): Promise; + setConfig(key: string, value: 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; + diff(cached?: boolean): Promise; + diffWithHEAD(): Promise; + diffWithHEAD(path: string): Promise; + diffWith(ref: string): Promise; + diffWith(ref: string, path: string): Promise; + diffIndexWithHEAD(): Promise; + diffIndexWithHEAD(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; + + 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): Promise; + setBranchUpstream(name: string, upstream: string): Promise; + + getMergeBase(ref1: string, ref2: string): Promise; + + tag(name: string, upstream: string): 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; +} + +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 PushErrorHandler { + handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode: GitErrorCodes }): Promise; +} + +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 onDidOpenRepository: Event; + readonly onDidCloseRepository: Event; + + toGitUri(uri: Uri, ref: string): Uri; + getRepository(uri: Uri): Repository | null; + init(root: Uri): Promise; + openRepository(root: Uri): Promise + + registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; + registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; + registerCredentialsProvider(provider: CredentialsProvider): Disposable; + registerPushErrorHandler(handler: PushErrorHandler): 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', + AuthenticationFailed = 'AuthenticationFailed', + NoUserNameConfigured = 'NoUserNameConfigured', + NoUserEmailConfigured = 'NoUserEmailConfigured', + NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', + NotAGitRepository = 'NotAGitRepository', + NotAtRepositoryRoot = 'NotAtRepositoryRoot', + Conflict = 'Conflict', + StashConflict = 'StashConflict', + UnmergedChanges = 'UnmergedChanges', + PushRejected = 'PushRejected', + 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' +}