From 24085f22b2b8cb12eba507a1bbaa3245374c4cfc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 09:51:02 +0000 Subject: [PATCH 01/12] Initial plan From 150f59af7d4ea129edb0ec33e82c67babbdb9dcf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 09:56:27 +0000 Subject: [PATCH 02/12] Add GitHub PR comparison feature with authentication and menu integration Co-authored-by: letmaik <530988+letmaik@users.noreply.github.com> --- package.json | 12 +++++ src/extension.ts | 5 ++ src/treeProvider.ts | 124 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 140 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b5e4e00..790a50b 100644 --- a/package.json +++ b/package.json @@ -163,6 +163,12 @@ "command": "gitTreeCompare.copyRelativePath", "title": "Copy Relative Path", "category": "Git Tree Compare" + }, + { + "command": "gitTreeCompare.compareGitHubPullRequest", + "title": "Compare GitHub Pull Request...", + "icon": "$(github)", + "category": "Git Tree Compare" } ], "menus": { @@ -182,6 +188,11 @@ "when": "view == gitTreeCompare", "group": "1_state" }, + { + "command": "gitTreeCompare.compareGitHubPullRequest", + "when": "view == gitTreeCompare", + "group": "1_state" + }, { "command": "gitTreeCompare.openAllChanges", "when": "view == gitTreeCompare", @@ -486,6 +497,7 @@ "webpack-cli": "^4.2.0" }, "dependencies": { + "@octokit/rest": "^22.0.1", "@vscode/iconv-lite-umd": "0.7.0", "byline": "^5.0.0", "file-type": "^7.2.0", diff --git a/src/extension.ts b/src/extension.ts index 22da17a..1b80a08 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -60,6 +60,11 @@ export function activate(context: ExtensionContext) { provider!.promptChangeBase(); }); }); + commands.registerCommand(NAMESPACE + '.compareGitHubPullRequest', () => { + runAfterInit(() => { + provider!.compareGitHubPullRequest(); + }); + }); commands.registerCommand(NAMESPACE + '.refresh', () => { runAfterInit(() => { provider!.manualRefresh(); diff --git a/src/treeProvider.ts b/src/treeProvider.ts index 82ec670..fb84e99 100644 --- a/src/treeProvider.ts +++ b/src/treeProvider.ts @@ -5,7 +5,7 @@ import * as fs from 'fs' import { TreeDataProvider, TreeItem, TreeItemCollapsibleState, Uri, Disposable, EventEmitter, TextDocumentShowOptions, QuickPickItem, ProgressLocation, Memento, OutputChannel, - workspace, commands, window, env, WorkspaceFoldersChangeEvent, TreeView, ThemeIcon, TreeItemCheckboxState, TreeCheckboxChangeEvent } from 'vscode' + workspace, commands, window, env, WorkspaceFoldersChangeEvent, TreeView, ThemeIcon, TreeItemCheckboxState, TreeCheckboxChangeEvent, authentication } from 'vscode' import { NAMESPACE } from './constants' import { Repository, Git } from './git/git' import { Ref, RefType } from './git/api/git' @@ -16,6 +16,7 @@ import { getDefaultBranch, getHeadModificationDate, getBranchCommit, import { debounce, throttle } from './git/decorators' import { normalizePath } from './fsUtils'; import { API as GitAPI, Repository as GitAPIRepository } from './typings/git'; +import { Octokit } from '@octokit/rest'; interface CheckboxStateInfo { state: TreeItemCheckboxState; @@ -1174,6 +1175,127 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos }); } + async compareGitHubPullRequest() { + if (!this.repository) { + window.showErrorMessage('No repository selected'); + return; + } + + const repository = this.repository; + + // Prompt for PR URL + const prUrl = await window.showInputBox({ + prompt: 'Enter GitHub Pull Request URL', + placeHolder: 'https://github.com/owner/repo/pull/123', + validateInput: (value: string) => { + const match = value.match(/github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/); + if (!match) { + return 'Invalid GitHub PR URL. Expected format: https://github.com/owner/repo/pull/123'; + } + return null; + } + }); + + if (!prUrl) { + return; + } + + // Parse the PR URL + const match = prUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/); + if (!match) { + window.showErrorMessage('Invalid GitHub PR URL format'); + return; + } + + const [, owner, repo, prNumberStr] = match; + const prNumber = parseInt(prNumberStr, 10); + + await window.withProgress({ + location: ProgressLocation.Notification, + title: `Fetching PR #${prNumber} from ${owner}/${repo}`, + cancellable: false + }, async () => { + try { + // Authenticate with GitHub + const session = await authentication.getSession('github', ['repo'], { createIfNone: true }); + const octokit = new Octokit({ auth: session.accessToken }); + + // Fetch PR details + this.log(`Fetching PR details for ${owner}/${repo}#${prNumber}`); + const { data: pr } = await octokit.pulls.get({ + owner, + repo, + pull_number: prNumber + }); + + // Extract base and head information + const baseRef = pr.base.ref; + const headRef = pr.head.ref; + const headSha = pr.head.sha; + + this.log(`PR #${prNumber}: base=${baseRef}, head=${headRef}, sha=${headSha}`); + + // Fetch the PR branch if it's from a fork + const headRepo = pr.head.repo; + if (!headRepo) { + window.showErrorMessage('Cannot access PR head repository. It may have been deleted.'); + return; + } + + const headRepoUrl = headRepo.clone_url; + const isFork = headRepo.full_name !== pr.base.repo.full_name; + + // Fetch the head branch + try { + if (isFork) { + // For forks, we need to add a remote and fetch + this.log(`Fetching from fork: ${headRepoUrl}`); + await repository.exec(['remote', 'add', `pr-${prNumber}`, headRepoUrl]); + await repository.fetch({ remote: `pr-${prNumber}`, ref: headRef }); + await repository.exec(['remote', 'remove', `pr-${prNumber}`]); + } else { + // For same repo, just fetch the branch + this.log(`Fetching branch: ${headRef}`); + await repository.fetch({ remote: 'origin', ref: headRef }); + } + } catch (e: any) { + this.log('Fetch error (may be ok if already fetched)', e); + // Continue even if fetch fails - the commit might already be available + } + + // Checkout the head commit + try { + this.log(`Checking out commit: ${headSha}`); + await repository.checkout(headSha, [], { detached: true }); + } catch (e: any) { + let msg = 'Failed to checkout PR head commit'; + this.log(msg, e); + window.showErrorMessage(`${msg}: ${e.message}`); + return; + } + + // Update the comparison base to the PR base branch + try { + this.log(`Updating base to: ${baseRef}`); + await this.updateRefs(baseRef); + await this.updateDiff(false); + this.log('Refreshing tree'); + this._onDidChangeTreeData.fire(); + window.showInformationMessage(`Now comparing PR #${prNumber}: ${pr.title}`); + } catch (e: any) { + let msg = 'Failed to update comparison base'; + this.log(msg, e); + window.showErrorMessage(`${msg}: ${e.message}`); + return; + } + } catch (e: any) { + let msg = 'Failed to fetch GitHub PR'; + this.log(msg, e); + window.showErrorMessage(`${msg}: ${e.message || e}`); + } + }); + } + async manualRefresh() { window.withProgress({ location: ProgressLocation.Window, title: 'Updating Tree' }, async _ => { try { From aa469a6898b22876a8b39d9cc08612e753949d81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 09:59:42 +0000 Subject: [PATCH 03/12] Add uncommitted changes warning before checking out PR Co-authored-by: letmaik <530988+letmaik@users.noreply.github.com> --- src/treeProvider.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/treeProvider.ts b/src/treeProvider.ts index fb84e99..2967a46 100644 --- a/src/treeProvider.ts +++ b/src/treeProvider.ts @@ -1183,6 +1183,24 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos const repository = this.repository; + // Check for uncommitted changes + try { + if (await hasUncommittedChanges(repository, repository.root)) { + const proceed = await window.showWarningMessage( + 'You have uncommitted changes. Checking out the PR will discard them. Continue?', + { modal: true }, + 'Continue', + 'Cancel' + ); + if (proceed !== 'Continue') { + return; + } + } + } catch (e: any) { + this.log('Error checking for uncommitted changes', e); + // Continue anyway + } + // Prompt for PR URL const prUrl = await window.showInputBox({ prompt: 'Enter GitHub Pull Request URL', From f1747c278cb18bc3b25ff4c6c5106676d8445ba4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 10:01:10 +0000 Subject: [PATCH 04/12] Add documentation for GitHub PR comparison feature Co-authored-by: letmaik <530988+letmaik@users.noreply.github.com> --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index ef51dfd..52c8a9e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ In bigger projects with many files it also provides **context**, it gives you a - Working tree comparison against any chosen branch, tag, or commit +- **Compare GitHub Pull Requests** - Enter a PR URL to quickly view and compare PR changes without leaving VS Code + - Switch between tree and list view - Compare in merge or full mode @@ -34,6 +36,24 @@ By default, the tree view is located in its own container accessible from the ac Moving of Git Tree Compare view between containers +## Compare GitHub Pull Requests + +You can quickly view GitHub PR changes directly in VS Code using the **Compare GitHub Pull Request** command: + +1. Click the "..." menu button in the Git Tree Compare view title bar +2. Select **Compare GitHub Pull Request...** +3. Enter the GitHub PR URL (e.g., `https://github.com/owner/repo/pull/123`) +4. Authenticate with GitHub if prompted (uses VS Code's built-in GitHub authentication) +5. The extension will: + - Fetch the PR's head commit + - Checkout the PR branch + - Compare it against the PR's base branch + - Display all changes in the tree view + +This feature works with both PRs from the same repository and PRs from forks. If you have uncommitted changes, you'll be prompted to confirm before checking out the PR. + +**Note:** This will checkout the PR in detached HEAD state. You can switch back to your previous branch using the Source Control view or by running `git checkout ` in the terminal. + ## Settings `gitTreeCompare.diffMode` Determines how the comparison is performed, either by computing a merge base commit first and then comparing against that (equivalent to pull request diffs, default), or by comparing directly to the given base (useful to see the exact diff). From c90cf6c16055574291da60893351640f7c1bc686 Mon Sep 17 00:00:00 2001 From: Maik Riechert Date: Sun, 25 Jan 2026 12:29:17 +0000 Subject: [PATCH 05/12] tweak uncommitted changes message --- src/gitHelper.ts | 9 +++++++-- src/treeProvider.ts | 14 +++++--------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/gitHelper.ts b/src/gitHelper.ts index ef7fe96..928e880 100644 --- a/src/gitHelper.ts +++ b/src/gitHelper.ts @@ -237,8 +237,13 @@ export async function diffIndex(repo: Repository, ref: string, refreshIndex: boo return statuses; } -export async function hasUncommittedChanges(repo: Repository, path: string): Promise { - const result = await repo.exec(['status', '-z', path]); +export async function hasUncommittedChanges(repo: Repository, path: string, ignoreUntracked: boolean = false): Promise { + const args = ['status', '-z']; + if (ignoreUntracked) { + args.push('-uno'); + } + args.push(path); + const result = await repo.exec(args); return result.stdout.trim() !== ''; } diff --git a/src/treeProvider.ts b/src/treeProvider.ts index 2967a46..eea3492 100644 --- a/src/treeProvider.ts +++ b/src/treeProvider.ts @@ -1183,18 +1183,14 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos const repository = this.repository; - // Check for uncommitted changes + // Check for uncommitted changes (ignoring untracked files) try { - if (await hasUncommittedChanges(repository, repository.root)) { - const proceed = await window.showWarningMessage( - 'You have uncommitted changes. Checking out the PR will discard them. Continue?', - { modal: true }, - 'Continue', - 'Cancel' + if (await hasUncommittedChanges(repository, repository.root, true)) { + window.showErrorMessage( + 'Please commit your changes or stash them before continuing.', + { modal: true } ); - if (proceed !== 'Continue') { return; - } } } catch (e: any) { this.log('Error checking for uncommitted changes', e); From ff59033eff4e17b288546ee245934db9679d9220 Mon Sep 17 00:00:00 2001 From: Maik Riechert Date: Sun, 25 Jan 2026 13:28:32 +0000 Subject: [PATCH 06/12] edit-first --- src/treeProvider.ts | 84 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/src/treeProvider.ts b/src/treeProvider.ts index eea3492..89ae299 100644 --- a/src/treeProvider.ts +++ b/src/treeProvider.ts @@ -1259,39 +1259,87 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos const headRepoUrl = headRepo.clone_url; const isFork = headRepo.full_name !== pr.base.repo.full_name; - // Fetch the head branch + // Create a local branch name for the PR + const localBranchName = `pr/${prNumber}`; + + // Fetch and create/update local branch for the PR try { if (isFork) { - // For forks, we need to add a remote and fetch - this.log(`Fetching from fork: ${headRepoUrl}`); - await repository.exec(['remote', 'add', `pr-${prNumber}`, headRepoUrl]); - await repository.fetch({ remote: `pr-${prNumber}`, ref: headRef }); - await repository.exec(['remote', 'remove', `pr-${prNumber}`]); + // For forks, add a remote with pr-fork- prefix + const forkOwner = pr.head.user?.login || pr.head.repo?.owner.login; + if (!forkOwner) { + throw new Error('Could not determine fork owner'); + } + const forkRemoteName = `pr-fork-${forkOwner}`; + + this.log(`Fetching PR #${prNumber} from fork owned by ${forkOwner}: ${headRepoUrl}`); + + // Check if remote already exists, if not add it + try { + const existingUrl = (await repository.exec(['remote', 'get-url', forkRemoteName])).stdout.trim(); + // Update URL if it's different + if (existingUrl !== headRepoUrl) { + await repository.exec(['remote', 'set-url', forkRemoteName, headRepoUrl]); + this.log(`Updated remote ${forkRemoteName} URL to ${headRepoUrl}`); + } + } catch { + await repository.exec(['remote', 'add', forkRemoteName, headRepoUrl]); + this.log(`Added remote ${forkRemoteName}`); + } + + // Fetch the head ref from the fork + await repository.fetch({ remote: forkRemoteName, ref: headRef }); + + // Create/update local branch pointing to the fetched commit + try { + // Try to create new branch + await repository.exec(['branch', localBranchName, headSha]); + } catch { + // Branch exists, force update it + await repository.exec(['branch', '-f', localBranchName, headSha]); + } + + // Set upstream to the fork remote + await repository.exec(['branch', '--set-upstream-to', `${forkRemoteName}/${headRef}`, localBranchName]); + + this.log(`Created local branch ${localBranchName} tracking ${forkRemoteName}/${headRef}`); } else { - // For same repo, just fetch the branch - this.log(`Fetching branch: ${headRef}`); - await repository.fetch({ remote: 'origin', ref: headRef }); + // For same repo, use GitHub's pull//head refspec + this.log(`Fetching PR #${prNumber} from origin`); + await repository.exec(['fetch', 'origin', `pull/${prNumber}/head:${localBranchName}`]); + + // Set upstream to origin/ if the branch exists there + try { + await repository.exec(['rev-parse', '--verify', `origin/${headRef}`]); + await repository.exec(['branch', '--set-upstream-to', `origin/${headRef}`, localBranchName]); + this.log(`Created local branch ${localBranchName} tracking origin/${headRef}`); + } catch { + this.log(`Created local branch ${localBranchName} (no upstream - origin/${headRef} not found)`); + } } } catch (e: any) { - this.log('Fetch error (may be ok if already fetched)', e); - // Continue even if fetch fails - the commit might already be available + let msg = 'Failed to fetch and create PR branch'; + this.log(msg, e); + window.showErrorMessage(`${msg}: ${e.message}`); + return; } - // Checkout the head commit + // Checkout the local PR branch try { - this.log(`Checking out commit: ${headSha}`); - await repository.checkout(headSha, [], { detached: true }); + this.log(`Checking out branch: ${localBranchName}`); + await repository.checkout(localBranchName, []); } catch (e: any) { - let msg = 'Failed to checkout PR head commit'; + let msg = 'Failed to checkout PR branch'; this.log(msg, e); window.showErrorMessage(`${msg}: ${e.message}`); return; } - // Update the comparison base to the PR base branch + // Update the comparison base to the PR base branch (use origin/* to avoid stale refs) try { - this.log(`Updating base to: ${baseRef}`); - await this.updateRefs(baseRef); + const originBaseRef = `origin/${baseRef}`; + this.log(`Updating base to: ${originBaseRef}`); + await this.updateRefs(originBaseRef); await this.updateDiff(false); this.log('Refreshing tree'); this._onDidChangeTreeData.fire(); From 6f85f7521aef3cb92fd51b6cc185b1a8a3a0cd5b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 13:33:54 +0000 Subject: [PATCH 07/12] Merge latest changes from master - resolve conflicts Co-authored-by: letmaik <530988+letmaik@users.noreply.github.com> --- README.md | 2 + package.json | 96 +++++++++++++++++++++++++++ src/extension.ts | 19 ++++++ src/treeProvider.ts | 154 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 269 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 52c8a9e..1d3448d 100644 --- a/README.md +++ b/README.md @@ -81,3 +81,5 @@ This feature works with both PRs from the same repository and PRs from forks. If `gitTreeCompare.resetCheckboxOnFileChange` When enabled, automatically resets a file's checkbox when the file is modified after being checked. This ensures that checked files reflect their reviewed state, and any subsequent modifications require re-review. Only effective when `showCheckboxes` is enabled. `gitTreeCompare.refSortOrder` Determines how refs (branches, tags) are sorted when changing the comparison base. Default is `committerdate` which sorts by most recently committed first, making it easy to find recently-used branches. Can be set to `alphabetically` for alphabetical sorting. + +`gitTreeCompare.openChangesWithDifftool` When enabled, adds an "Open Changes with Difftool" command to the context menu for files. This command opens the changes in the external diff tool configured in Git (e.g., via `git config diff.tool `). Default is disabled. Note: This requires you to have a difftool configured in your Git settings. diff --git a/package.json b/package.json index 790a50b..663386b 100644 --- a/package.json +++ b/package.json @@ -169,9 +169,74 @@ "title": "Compare GitHub Pull Request...", "icon": "$(github)", "category": "Git Tree Compare" + }, + { + "command": "gitTreeCompare.sortByName", + "title": "Sort by Name", + "category": "Git Tree Compare", + "enablement": "gitTreeCompare.viewAsList" + }, + { + "command": "gitTreeCompare.sortByPath", + "title": "Sort by Path", + "category": "Git Tree Compare", + "enablement": "gitTreeCompare.viewAsList" + }, + { + "command": "gitTreeCompare.sortByStatus", + "title": "Sort by Status", + "category": "Git Tree Compare", + "enablement": "gitTreeCompare.viewAsList" + }, + { + "command": "gitTreeCompare.sortByRecentlyModified", + "title": "Sort by Recently Modified", + "category": "Git Tree Compare", + "enablement": "gitTreeCompare.viewAsList" + }, + { + "command": "gitTreeCompare.openChangesWithDifftool", + "title": "Open Changes with Difftool", + "category": "Git Tree Compare" + } + ], + "submenus": [ + { + "id": "gitTreeCompare.viewAndSort", + "label": "View & Sort" } ], "menus": { + "gitTreeCompare.viewAndSort": [ + { + "command": "gitTreeCompare.viewAsList", + "group": "1_view@1" + }, + { + "command": "gitTreeCompare.viewAsTree", + "group": "1_view@2" + }, + { + "command": "gitTreeCompare.sortByName", + "group": "2_sort@1", + "enablement": "gitTreeCompare.viewAsList" + }, + { + "command": "gitTreeCompare.sortByPath", + "group": "2_sort@2", + "enablement": "gitTreeCompare.viewAsList" + }, + { + "command": "gitTreeCompare.sortByStatus", + "group": "2_sort@3", + "enablement": "gitTreeCompare.viewAsList" + }, + { + "command": "gitTreeCompare.sortByRecentlyModified", + "group": "2_sort@4", + "enablement": "gitTreeCompare.viewAsList" + } + ], "view/title": [ { "command": "gitTreeCompare.changeRepository", @@ -252,6 +317,11 @@ "command": "gitTreeCompare.searchChanges", "when": "view == gitTreeCompare", "group": "2_files" + }, + { + "submenu": "gitTreeCompare.viewAndSort", + "when": "view == gitTreeCompare", + "group": "0_view_and_sort" } ], "view/item/context": [ @@ -370,6 +440,16 @@ "command": "gitTreeCompare.copyRelativePath", "when": "view == gitTreeCompare && viewItem == file", "group": "3_copy" + }, + { + "submenu": "gitTreeCompare.viewAndSort", + "when": "view == gitTreeCompare && viewItem == ref", + "group": "0_view_and_sort" + }, + { + "command": "gitTreeCompare.openChangesWithDifftool", + "when": "view == gitTreeCompare && viewItem == file && config.gitTreeCompare.openChangesWithDifftool", + "group": "1_open" } ] }, @@ -474,6 +554,22 @@ "type": "boolean", "description": "Whether to omit unstaged changes from the diff. When enabled, only staged changes will appear in the tree.", "default": false + }, + "gitTreeCompare.sortOrder": { + "type": "string", + "enum": [ + "name", + "path", + "status", + "recentlyModified" + ], + "description": "How to sort files when viewing as list. Only applies in list view mode. 'name' sorts by file name, 'path' sorts by full path, 'status' sorts by git status, 'recentlyModified' sorts by modification date with most recent first.", + "default": "path" + }, + "gitTreeCompare.openChangesWithDifftool": { + "type": "boolean", + "description": "Whether to show the 'Open Changes with Difftool' command in the context menu for files. This command opens the changes in the external diff tool configured in Git (e.g., via 'git config diff.tool').", + "default": false } } } diff --git a/src/extension.ts b/src/extension.ts index 1b80a08..13b4332 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -103,12 +103,31 @@ export function activate(context: ExtensionContext) { commands.registerCommand(NAMESPACE + '.copyRelativePath', node => { runAfterInit(() => provider!.copyRelativePath(node)); }); + commands.registerCommand(NAMESPACE + '.sortByName', () => { + runAfterInit(() => provider!.sortByName()); + }); + commands.registerCommand(NAMESPACE + '.sortByPath', () => { + runAfterInit(() => provider!.sortByPath()); + }); + commands.registerCommand(NAMESPACE + '.sortByStatus', () => { + runAfterInit(() => provider!.sortByStatus()); + }); + commands.registerCommand(NAMESPACE + '.sortByRecentlyModified', () => { + runAfterInit(() => provider!.sortByRecentlyModified()); + }); + + commands.registerCommand(NAMESPACE + '.openChangesWithDifftool', node => { + runAfterInit(() => provider!.openChangesWithDifftool(node)); + }); createGit(gitApi, outputChannel).then(async git => { const onOutput = (str: string) => outputChannel.append(str); git.onOutput.addListener('log', onOutput); disposables.push(toDisposable(() => git.onOutput.removeListener('log', onOutput))); + // Set initial context for menu enablement (starts in tree view mode) + commands.executeCommand('setContext', NAMESPACE + '.viewAsList', false); + provider = new GitTreeCompareProvider(git, gitApi, outputChannel, context.globalState, context.asAbsolutePath); const treeView = window.createTreeView( diff --git a/src/treeProvider.ts b/src/treeProvider.ts index 89ae299..fb8d77e 100644 --- a/src/treeProvider.ts +++ b/src/treeProvider.ts @@ -18,12 +18,26 @@ import { normalizePath } from './fsUtils'; import { API as GitAPI, Repository as GitAPIRepository } from './typings/git'; import { Octokit } from '@octokit/rest'; +type SortOrder = 'name' | 'path' | 'status' | 'recentlyModified'; + +const STATUS_SORT_ORDER: { [key: string]: number } = { + 'M': 0, // Modified + 'A': 1, // Added + 'D': 2, // Deleted + 'R': 3, // Renamed + 'C': 4, // Conflict + 'U': 5, // Untracked + 'T': 6 // Type change +}; + interface CheckboxStateInfo { state: TreeItemCheckboxState; timestamp: number; // When the checkbox was checked } class FileElement implements IDiffStatus { + modificationDate?: Date; + constructor( public srcAbsPath: string, public dstAbsPath: string, @@ -113,6 +127,7 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos private resetCheckboxOnFileChange: boolean; private omitUntrackedFiles: boolean; private omitUnstagedChanges: boolean; + private sortOrder: SortOrder; // Dynamic options private repository: Repository | undefined; @@ -359,6 +374,7 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos this.resetCheckboxOnFileChange = config.get('resetCheckboxOnFileChange', false); this.omitUntrackedFiles = config.get('omitUntrackedFiles', false); this.omitUnstagedChanges = config.get('omitUnstagedChanges', false); + this.sortOrder = config.get('sortOrder', 'path'); } private async getStoredBaseRef(): Promise { @@ -684,7 +700,10 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos this.filesInsideTreeRoot = filesInsideTreeRoot; this.filesOutsideTreeRoot = filesOutsideTreeRoot; - if (fireChangeEvents && treeHasChanged) { + // Always refresh when sorting by recently modified in list view, as file mtimes may have changed + const needsRefreshForSorting = this.viewAsList && this.sortOrder === 'recentlyModified'; + + if (fireChangeEvents && (treeHasChanged || needsRefreshForSorting)) { this.log('Refreshing tree') this._onDidChangeTreeData.fire(); } @@ -770,6 +789,7 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos const oldshowCheckboxes = this.showCheckboxes; const oldOmitUntrackedFiles = this.omitUntrackedFiles; const oldOmitUnstagedChanges = this.omitUnstagedChanges; + const oldSortOrder = this.sortOrder; this.readConfig(); if (oldTreeRootIsRepo != this.treeRootIsRepo || oldInclude != this.includeFilesOutsideWorkspaceFolderRoot || @@ -783,7 +803,8 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos oldCompactFolders != this.compactFolders || oldshowCheckboxes != this.showCheckboxes || oldOmitUntrackedFiles != this.omitUntrackedFiles || - oldOmitUnstagedChanges != this.omitUnstagedChanges) { + oldOmitUnstagedChanges != this.omitUnstagedChanges || + oldSortOrder != this.sortOrder) { if (!this.repository) { return; @@ -888,9 +909,68 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos } } + // Apply sorting logic only for list view and non-path sorting + // (path sorting uses the existing default logic) + if (this.viewAsList && this.sortOrder !== 'path') { + this.applySorting(entries); + } + return entries } + private applySorting(entries: FileSystemElement[]) { + // Separate files from folders (folders should stay at the top) + const fileElements = entries.filter(e => e instanceof FileElement) as FileElement[]; + const folderElements = entries.filter(e => e instanceof FolderElement); + + // Populate modification dates if sorting by recently modified + if (this.sortOrder === 'recentlyModified') { + for (const file of fileElements) { + try { + const stats = fs.statSync(file.dstAbsPath); + file.modificationDate = stats.mtime; + } catch (e) { + // If file doesn't exist (e.g., deleted), use epoch + file.modificationDate = new Date(0); + } + } + } + + // Sort files based on sort order + switch (this.sortOrder) { + case 'name': + fileElements.sort((a, b) => a.label.localeCompare(b.label)); + break; + case 'status': + fileElements.sort((a, b) => { + const aOrder = STATUS_SORT_ORDER[a.status] ?? 99; + const bOrder = STATUS_SORT_ORDER[b.status] ?? 99; + if (aOrder !== bOrder) { + return aOrder - bOrder; + } + // Secondary sort by path + return a.dstRelPath.localeCompare(b.dstRelPath); + }); + break; + case 'recentlyModified': + fileElements.sort((a, b) => { + const aTime = a.modificationDate?.getTime() ?? 0; + const bTime = b.modificationDate?.getTime() ?? 0; + // Sort descending (most recent first) + if (bTime !== aTime) { + return bTime - aTime; + } + // Secondary sort by path + return a.dstRelPath.localeCompare(b.dstRelPath); + }); + break; + } + + // Replace entries array with sorted files (folders first, then sorted files) + entries.length = 0; + entries.push(...folderElements, ...fileElements); + } + private getDiffStatus(fileEntry?: FileElement): IDiffStatus | undefined { if (fileEntry) { return fileEntry; @@ -1399,6 +1479,26 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos this._onDidChangeTreeData.fire(); } + async sortByName() { + const config = workspace.getConfiguration(NAMESPACE); + await config.update('sortOrder', 'name', true); + } + + async sortByPath() { + const config = workspace.getConfiguration(NAMESPACE); + await config.update('sortOrder', 'path', true); + } + + async sortByStatus() { + const config = workspace.getConfiguration(NAMESPACE); + await config.update('sortOrder', 'status', true); + } + + async sortByRecentlyModified() { + const config = workspace.getConfiguration(NAMESPACE); + await config.update('sortOrder', 'recentlyModified', true); + } + async searchChanges() { const uris = [...this.iterFiles()].map(file => Uri.file(file.dstAbsPath)); const relativePaths = uris.map(uri => path.relative(this.repoRoot, uri.fsPath)); @@ -1428,6 +1528,56 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos await env.clipboard.writeText(relativePath); } + async openChangesWithDifftool(fileEntry: FileElement) { + const diffStatus = this.getDiffStatus(fileEntry); + if (!diffStatus) { + return; + } + + if (!this.repository) { + window.showErrorMessage('No repository is active.'); + return; + } + + const { dstAbsPath, status } = diffStatus; + + // For deleted files, we can't show a diff since the file doesn't exist in the working tree + if (status === 'D') { + window.showInformationMessage('Cannot open difftool for deleted files.'); + return; + } + + // For added/untracked files, there's no base version to compare against + if (status === 'U' || status === 'A') { + window.showInformationMessage('Cannot open difftool for untracked or newly added files that are not in the base commit.'); + return; + } + + // Calculate relative path from repository root + const dstRelPath = path.relative(this.repository.root, dstAbsPath); + + // For modified files, use git difftool + // Use the mergeBase as the comparison base + const args = ['difftool', '--no-prompt', this.mergeBase, '--', dstRelPath]; + + try { + // Execute git difftool - this will launch the external tool + await this.repository.exec(args); + } catch (error: any) { + const errorMessage = error.stderr || error.message || 'Unknown error'; + // Check for common error patterns indicating difftool is not configured + // Note: Error messages may vary across Git versions and locales + if (errorMessage.includes('diff.tool') || errorMessage.includes('not configured') || errorMessage.includes('difftool') && errorMessage.includes('unknown')) { + window.showErrorMessage( + 'Git difftool is not configured. Please configure your diff tool in Git settings (e.g., git config --global diff.tool ).', + ); + } else { + window.showErrorMessage(`Failed to open difftool: ${errorMessage}`); + } + this.log(`Failed to open difftool: ${errorMessage}`); + } + } + dispose(): void { this.disposables.forEach(d => d.dispose()); } From 3e91276034d77a4b3788b94ecf3c0acf18a0ec77 Mon Sep 17 00:00:00 2001 From: Maik Riechert Date: Sun, 25 Jan 2026 15:21:23 +0000 Subject: [PATCH 08/12] fix bad merge --- src/treeProvider.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/treeProvider.ts b/src/treeProvider.ts index 63c4b36..314a637 100644 --- a/src/treeProvider.ts +++ b/src/treeProvider.ts @@ -18,17 +18,6 @@ import { normalizePath } from './fsUtils'; import { API as GitAPI, Repository as GitAPIRepository } from './typings/git'; import { Octokit } from '@octokit/rest'; -type SortOrder = 'name' | 'path' | 'status' | 'recentlyModified'; - -const STATUS_SORT_ORDER: { [key: string]: number } = { - 'M': 0, // Modified - 'A': 1, // Added - 'D': 2, // Deleted - 'R': 3, // Renamed - 'C': 4, // Conflict - 'U': 5, // Untracked - 'T': 6 // Type change -}; type SortOrder = 'name' | 'path' | 'status' | 'recentlyModified'; From cccd3079f3dbfe0122883d42aa787737ea0ad312 Mon Sep 17 00:00:00 2001 From: Maik Riechert Date: Sun, 25 Jan 2026 16:43:14 +0000 Subject: [PATCH 09/12] use pr/// --- src/treeProvider.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/treeProvider.ts b/src/treeProvider.ts index 314a637..49eb8e9 100644 --- a/src/treeProvider.ts +++ b/src/treeProvider.ts @@ -1340,20 +1340,23 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos const headRepoUrl = headRepo.clone_url; const isFork = headRepo.full_name !== pr.base.repo.full_name; - // Create a local branch name for the PR - const localBranchName = `pr/${prNumber}`; + // Extract head owner for branch naming + const headOwner = pr.head.user?.login || pr.head.repo?.owner.login; + if (!headOwner) { + window.showErrorMessage('Could not determine PR head owner.'); + return; + } + + // Create a local branch name for the PR with owner and ref name + const localBranchName = `pr/${prNumber}/${headOwner}/${headRef}`; // Fetch and create/update local branch for the PR try { if (isFork) { // For forks, add a remote with pr-fork- prefix - const forkOwner = pr.head.user?.login || pr.head.repo?.owner.login; - if (!forkOwner) { - throw new Error('Could not determine fork owner'); - } - const forkRemoteName = `pr-fork-${forkOwner}`; + const forkRemoteName = `pr-fork-${headOwner}`; - this.log(`Fetching PR #${prNumber} from fork owned by ${forkOwner}: ${headRepoUrl}`); + this.log(`Fetching PR #${prNumber} from fork owned by ${headOwner}: ${headRepoUrl}`); // Check if remote already exists, if not add it try { From 05f7ff17011dfd800615c30a78d6ba1fcb072fbc Mon Sep 17 00:00:00 2001 From: Maik Riechert Date: Sun, 25 Jan 2026 16:46:35 +0000 Subject: [PATCH 10/12] update readme --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1d3448d..5932d0e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ In bigger projects with many files it also provides **context**, it gives you a - Working tree comparison against any chosen branch, tag, or commit -- **Compare GitHub Pull Requests** - Enter a PR URL to quickly view and compare PR changes without leaving VS Code +- Compare GitHub Pull Requests - Switch between tree and list view @@ -50,9 +50,7 @@ You can quickly view GitHub PR changes directly in VS Code using the **Compare G - Compare it against the PR's base branch - Display all changes in the tree view -This feature works with both PRs from the same repository and PRs from forks. If you have uncommitted changes, you'll be prompted to confirm before checking out the PR. - -**Note:** This will checkout the PR in detached HEAD state. You can switch back to your previous branch using the Source Control view or by running `git checkout ` in the terminal. +This feature works with both PRs from the same repository and PRs from forks. ## Settings From 4f1a95e64e965279c02e52bfc8f4b01fa42c19a1 Mon Sep 17 00:00:00 2001 From: Maik Riechert Date: Sun, 25 Jan 2026 16:49:07 +0000 Subject: [PATCH 11/12] detail --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5932d0e..cfe75df 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ You can quickly view GitHub PR changes directly in VS Code using the **Compare G 4. Authenticate with GitHub if prompted (uses VS Code's built-in GitHub authentication) 5. The extension will: - Fetch the PR's head commit - - Checkout the PR branch + - Checkout the PR branch as `pr///` - Compare it against the PR's base branch - Display all changes in the tree view From 7ed9e1242099c8f913a5f05ce200bdfbbdb7c85d Mon Sep 17 00:00:00 2001 From: Maik Riechert Date: Sun, 25 Jan 2026 16:57:55 +0000 Subject: [PATCH 12/12] fix stale ref --- src/treeProvider.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/treeProvider.ts b/src/treeProvider.ts index 49eb8e9..fd4cca6 100644 --- a/src/treeProvider.ts +++ b/src/treeProvider.ts @@ -1394,7 +1394,8 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos // Set upstream to origin/ if the branch exists there try { - await repository.exec(['rev-parse', '--verify', `origin/${headRef}`]); + // Fetch the actual head ref to update the remote tracking branch + await repository.fetch({ remote: 'origin', ref: headRef }); await repository.exec(['branch', '--set-upstream-to', `origin/${headRef}`, localBranchName]); this.log(`Created local branch ${localBranchName} tracking origin/${headRef}`); } catch {