diff --git a/.eslintrc.js b/.eslintrc.js index c83464ab..d1666db6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -25,6 +25,10 @@ module.exports = { '!.*rc.*', '!*.config.js', '!*.d.ts', + + // Ignore, since this file is vendored from VSCode and should not be changed. + // See `CONTRIBUTING.md`. + 'src/types/git.ts', ], rules: { /* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f44ebfa..3352e438 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,3 +140,16 @@ npx vsce package --no-dependencies ``` This should generate a .vsix file which you can then import into VS Code (`Ctrl + Shift + P` + "install vsix"). + +## Vendored Dependencies + +Types for the [Git integration for VSCode](https://github.com/microsoft/vscode/tree/main/extensions/git) are vendored at [`src/types/git.ts`](src/types/git.ts). + +To update those, proceed as follows: + +```sh +$ export VSCODE_TAG="1.88.1" # Adjust this to match desired version of VSCode. +$ git remote add vscode "https://github.com/microsoft/vscode.git" +$ git fetch --depth 1 vscode "$VSCODE_TAG" +$ git show $(git ls-remote --refs --tags vscode "$VSCODE_TAG" | cut -d$'\t' -f1):extensions/git/src/api/git.ts > src/types/git.ts +``` diff --git a/package.json b/package.json index 38273133..bb040a82 100644 --- a/package.json +++ b/package.json @@ -506,6 +506,9 @@ "ts-xor": "^1.3.0", "typescript": "^5.3.3" }, + "extensionDependencies": [ + "vscode.git" + ], "simple-git-hooks": { "pre-commit": "npm run lint" }, diff --git a/src/helpers/command.ts b/src/helpers/command.ts index 6162c030..d8a46807 100644 --- a/src/helpers/command.ts +++ b/src/helpers/command.ts @@ -1,4 +1,4 @@ -import { type TextDocumentShowOptions, Uri, commands, window } from 'vscode' +import { type TextDocumentShowOptions, type Uri, commands, window } from 'vscode' import { getExtensionContext, usePatchStore } from '../stores' import { exec, log, showLog } from '../utils' import { @@ -122,8 +122,8 @@ export function registerAllCommands(): void { registerVsCodeCmd( 'radicle.openOriginalVersionOfPatchedFile', async (node: FilechangeNode | undefined) => { - if (node?.oldVersionUrl) { - await commands.executeCommand('vscode.open', Uri.file(node.oldVersionUrl)) + if (node?.oldVersionUri) { + await commands.executeCommand('vscode.open', node.oldVersionUri) commands.executeCommand('workbench.action.files.setActiveEditorReadonlyInSession') } else { log( @@ -137,8 +137,8 @@ export function registerAllCommands(): void { registerVsCodeCmd( 'radicle.openChangedVersionOfPatchedFile', async (node: FilechangeNode | undefined) => { - if (node?.newVersionUrl) { - await commands.executeCommand('vscode.open', Uri.file(node.newVersionUrl)) + if (node?.newVersionUri) { + await commands.executeCommand('vscode.open', node.newVersionUri) commands.executeCommand('workbench.action.files.setActiveEditorReadonlyInSession') } else { log( diff --git a/src/types/git.ts b/src/types/git.ts new file mode 100644 index 00000000..4b2c0abc --- /dev/null +++ b/src/types/git.ts @@ -0,0 +1,348 @@ +/* --------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *-------------------------------------------------------------------------------------------- */ + +import type { Disposable, Event, ProviderResult, Uri } from 'vscode' + +export type { 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 interface CommitOptions { + all?: boolean | 'tracked' + amend?: boolean + signoff?: boolean + signCommit?: boolean + empty?: boolean + noVerify?: boolean + requireUserConfig?: boolean +} + +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 }> + show(ref: string, path: string): Promise + getCommit(ref: string): Promise + + add(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 listed 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', +} diff --git a/src/types/index.ts b/src/types/index.ts index d40d9662..53334922 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,3 +3,5 @@ export * from './httpd-augmented' export * from './httpd' export * from './prettify' export * from './webviewInjectedState' +export type { API as GitExtensionAPI, GitExtension, Repository, Change } from './git' +export { Status } from './git' diff --git a/src/utils/git.ts b/src/utils/git.ts index 285474c0..4c9c24aa 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -1,3 +1,5 @@ +import * as vscode from 'vscode' +import { type GitExtension, type GitExtensionAPI, Status } from '../types' import { exec } from '.' /** @@ -34,3 +36,35 @@ export function getCurrentGitBranch(): string | undefined { return currentBranch } + +export function getGitExtensionAPI(): GitExtensionAPI { + const gitExtensionId = 'vscode.git' + const gitExtension = vscode.extensions.getExtension(gitExtensionId) + + if (gitExtension === undefined) { + throw new Error(`Could not load VSCode Git extension with id '${gitExtensionId}'`) + } + + return gitExtension.exports.getAPI(1) +} + +export function gitExtensionStatusToInternalFileChangeState( + status: Status, +): 'added' | 'copied' | 'deleted' | 'modified' | 'moved' { + switch (status) { + case Status.INDEX_RENAMED: + return 'moved' + case Status.INDEX_COPIED: + return 'copied' + case Status.INDEX_MODIFIED: + case Status.MODIFIED: + return 'modified' + case Status.INDEX_DELETED: + case Status.DELETED: + return 'deleted' + case Status.INDEX_ADDED: + return 'added' + default: + throw new Error(`Encountered unexpected Git extension Status '${status}'`) + } +} diff --git a/src/ux/patchesView.ts b/src/ux/patchesView.ts index 42f6d295..326cdf22 100644 --- a/src/ux/patchesView.ts +++ b/src/ux/patchesView.ts @@ -1,5 +1,4 @@ -import Path, { sep } from 'node:path' -import * as fs from 'node:fs/promises' +import Path from 'node:path' import { EventEmitter, MarkdownString, @@ -11,28 +10,27 @@ import { TreeItemCollapsibleState, Uri, } from 'vscode' -import { extTempDir } from '../constants' import { usePatchStore } from '../stores' import { debouncedClearMemoizedGetCurrentProjectIdCache, - fetchFromHttpd, memoizedGetCurrentProjectId, } from '../helpers' import { type AugmentedPatch, + type Change, + type GitExtensionAPI, type Patch, - type Unarray, - isCopiedOrMovedFilechangeWithDiff, - isMovedFilechangeWithoutDiff, isPatch, } from '../types' import { assertUnreachable, capitalizeFirstLetter, getFirstAndLatestRevisions, + getGitExtensionAPI, getIdentityAliasOrId, + getRepoRoot, getTimeAgo, - log, + gitExtensionStatusToInternalFileChangeState, shortenHash, } from '../utils' @@ -45,8 +43,8 @@ let timesPatchListFetchErroredConsecutively = 0 export interface FilechangeNode { relativeInRepoUrl: string - oldVersionUrl?: string - newVersionUrl?: string + oldVersionUri?: Uri + newVersionUri?: Uri patch: AugmentedPatch getTreeItem: () => ReturnType<(typeof patchesTreeDataProvider)['getTreeItem']> } @@ -140,201 +138,96 @@ export const patchesTreeDataProvider: TreeDataProvider< return patchesSortedByRevisionTsPerStatus } - // get children of patch - else if (isPatch(elem)) { - const patch = elem - const { latestRevision } = getFirstAndLatestRevisions(patch) - const oldVersionCommitSha = latestRevision.base - const newVersionCommitSha = latestRevision.oid + if (!isPatch(elem)) { + return undefined + } - const { data: diffResponse, error } = await fetchFromHttpd( - `/projects/${rid}/diff/${oldVersionCommitSha}/${newVersionCommitSha}`, - ) - if (error) { - return ['Patch details could not be resolved due to an error!'] - } + const patch = elem + const { latestRevision } = getFirstAndLatestRevisions(patch) + const range = { + old: latestRevision.base, + new: latestRevision.oid, + } - // create a placeholder empty file used to diff added or removed files - const emptyFileUrl = `${extTempDir}${sep}empty` - - try { - await fs.mkdir(Path.dirname(emptyFileUrl), { recursive: true }) - await fs.writeFile(emptyFileUrl, '') - } catch (error) { - log( - "Failed saving placeholder empty file to enable diff for Patch's changed files.", - 'error', - (error as Partial)?.message, - ) - } + const gitExtensionApi: GitExtensionAPI = getGitExtensionAPI() - const filechangeNodes: FilechangeNode[] = diffResponse.diff.files - .map((filechange) => { - const filePath = - filechange.state === 'copied' || filechange.state === 'moved' - ? filechange.newPath - : filechange.path - const fileDir = Path.dirname(filePath) - const filename = Path.basename(filePath) - - const oldVersionUrl = `${extTempDir}${sep}${shortenHash( - oldVersionCommitSha, - )}${sep}${fileDir}${sep}${filename}` - // TODO: should the newVersionUrl be just the filechange.path (with full path to the actual file on the fs) if the current git commit is same as newVersionCommitSha and the file isn't on the git (un-)staged changes? - const newVersionUrl = `${extTempDir}${sep}${shortenHash( - newVersionCommitSha, - )}${sep}${fileDir}${sep}${filename}` - - const node: FilechangeNode = { - relativeInRepoUrl: filePath, - oldVersionUrl, - newVersionUrl, - patch, - getTreeItem: async () => { - // god forgive me for I have sinned due to httpd's glorious schema... - type FileChangeWithOldAndNew = Extract< - Unarray<(typeof diffResponse)['diff']['files']>, - { old: NonNullable; new: NonNullable } - > - type FilechangeWithDiff = Extract< - Unarray<(typeof diffResponse)['diff']['files']>, - { diff: NonNullable } - > - type FilechangeWithoutDiffButDiffViewerRegardless = Extract< - Unarray<(typeof diffResponse)['diff']['files']>, - { current: NonNullable } - > - type FileContent = (typeof diffResponse)['files'][string]['content'] - - try { - switch (filechange.state) { - case 'added': - await fs.mkdir(Path.dirname(newVersionUrl), { recursive: true }) - await fs.writeFile( - newVersionUrl, - diffResponse.files[filechange.new.oid]?.content as FileContent, - ) - break - case 'deleted': - await fs.mkdir(Path.dirname(oldVersionUrl), { recursive: true }) - await fs.writeFile( - oldVersionUrl, - diffResponse.files[filechange.old.oid]?.content as FileContent, - ) - break - case 'modified': - case 'copied': - case 'moved': - await Promise.all([ - fs.mkdir(Path.dirname(oldVersionUrl), { recursive: true }), - fs.mkdir(Path.dirname(newVersionUrl), { recursive: true }), - ]) - - if ( - filechange.state === 'modified' || - isCopiedOrMovedFilechangeWithDiff(filechange) - ) { - await Promise.all([ - fs.writeFile( - oldVersionUrl, - diffResponse.files[filechange.old.oid]?.content as FileContent, - ), - fs.writeFile( - newVersionUrl, - diffResponse.files[filechange.new.oid]?.content as FileContent, - ), - ]) - } else if (isMovedFilechangeWithoutDiff(filechange)) { - await Promise.all([ - fs.writeFile( - oldVersionUrl, - diffResponse.files[filechange.current.oid]?.content as FileContent, - ), - fs.writeFile( - newVersionUrl, - diffResponse.files[filechange.current.oid]?.content as FileContent, - ), - ]) - } - break - default: - assertUnreachable(filechange) - } - } catch (error) { - log( - `Failed saving temp files to enable diff for ${filePath}.`, - 'error', - (error as Partial)?.message, - ) - } - - const filechangeTreeItem: TreeItem = { - id: `${patch.id} ${oldVersionCommitSha}..${newVersionCommitSha} ${filePath}`, - contextValue: - (filechange as FilechangeWithDiff).diff ?? - (filechange as FilechangeWithoutDiffButDiffViewerRegardless).current - ? `filechange:${filechange.state}` - : undefined, - label: filename, - description: fileDir === '.' ? undefined : fileDir, - tooltip: `${ - filechange.state === 'copied' || filechange.state === 'moved' - ? `${filechange.oldPath} ${filechange.state === 'copied' ? '↦' : '➟'} ${ - filechange.newPath - }` - : filechange.path - } ${dot} ${capitalizeFirstLetter(filechange.state)}`, - resourceUri: Uri.file(filePath), - command: - (filechange as FilechangeWithDiff).diff ?? - (filechange as FilechangeWithoutDiffButDiffViewerRegardless).current - ? { - command: 'radicle.openDiff', - title: `Open changes`, - tooltip: `Show this file's changes between its \ -before-the-Patch version and its latest version committed in the Radicle Patch`, - arguments: [ - Uri.file( - (filechange as Partial).old?.oid || - (filechange as FilechangeWithoutDiffButDiffViewerRegardless) - .current - ? oldVersionUrl - : emptyFileUrl, - ), - Uri.file( - (filechange as Partial).new?.oid || - (filechange as FilechangeWithoutDiffButDiffViewerRegardless) - .current - ? newVersionUrl - : emptyFileUrl, - ), - `${filename} (${shortenHash(oldVersionCommitSha)} ⟷ ${shortenHash( - newVersionCommitSha, - )}) ${capitalizeFirstLetter(filechange.state)}`, - { preview: true } satisfies TextDocumentShowOptions, - ], - } - : undefined, - } - - return filechangeTreeItem - }, - } - - return node - }) - .sort((n1, n2) => (n1.relativeInRepoUrl < n2.relativeInRepoUrl ? -1 : 0)) - - return filechangeNodes.length - ? filechangeNodes - : [ - `No changes between latest revision's base "${shortenHash( - latestRevision.base, - )}" and head "${shortenHash(latestRevision.oid)}" commits.`, - ] + const repoRoot = getRepoRoot() + if (repoRoot === undefined) { + throw new Error(`Failed to determine Git repository root.`) } - return undefined + const repo = gitExtensionApi.getRepository(Uri.file(repoRoot)) + if (repo === null) { + throw new Error(`Failed access Git repository.`) + } + + const changes: Change[] = await repo?.diffBetween(range.old, range.new) + if (changes.length === 0) { + return [ + `No changes between latest revision's base "${shortenHash( + latestRevision.base, + )}" and head "${shortenHash(latestRevision.oid)}" commits.`, + ] + } + + return changes + .map((change: Change): FilechangeNode => { + const { originalUri, status } = change + const uri = change.renameUri || change.uri + const internalFileChangeState = gitExtensionStatusToInternalFileChangeState(status) + const isCopy = internalFileChangeState === 'copied' + const isMoveOrCopy = isCopy || internalFileChangeState === 'moved' + const humanReadable = capitalizeFirstLetter(internalFileChangeState) + const basename = Path.basename(uri.fsPath) + const git = { + originalUri: gitExtensionApi.toGitUri(originalUri, range.old), + uri: gitExtensionApi.toGitUri(uri, range.new), + } + const relative = { + originalUri: Path.relative(repoRoot, originalUri.fsPath), + uri: Path.relative(repoRoot, uri.fsPath), + } + const relativeDirname = Path.dirname(relative.uri) + + return { + relativeInRepoUrl: relative.uri, + oldVersionUri: git.originalUri, + newVersionUri: git.uri, + patch, + getTreeItem: () => { + const filechangeTreeItem: TreeItem = { + id: `${patch.id} ${range.old}..${range.new} ${uri}`, + contextValue: `filechange:${internalFileChangeState}`, + label: basename, + description: relativeDirname === '.' ? '' : relativeDirname, + tooltip: `${ + isMoveOrCopy ? `${relative.originalUri} ${isCopy ? '↦' : '➟'} ` : '' + }${relative.uri} ${dot} ${humanReadable}`, + resourceUri: uri, + command: { + command: 'radicle.openDiff', + title: `Open changes`, + tooltip: `Show this file's changes between its \ +before-the-Patch version and its latest version committed in the Radicle Patch`, + arguments: [ + git.originalUri, + git.uri, + `${basename} (${shortenHash(range.old)} ⟷ ${shortenHash( + range.new, + )}) ${humanReadable}`, + { preview: true } satisfies TextDocumentShowOptions, + ], + }, + } + + return filechangeTreeItem + }, + } + }) + .sort((n1, n2) => + // FIXME(lorenzleutgeb): Use user's locale for comparison once cytechmobile/radicle-vscode-extension#116 is resolved. + n1.relativeInRepoUrl.localeCompare(n2.relativeInRepoUrl), + ) }, getParent: (elem) => { if (typeof elem === 'string' || isPatch(elem)) {