From 35be4181a689210d3cf00b0f78988ea12cc2992b Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 6 Aug 2026 10:16:25 -0700 Subject: [PATCH 1/4] Add Compare Selected for two file nodes --- .changeset/calm-files-compare.md | 5 ++++ .../extension/graphView/files/navigation.ts | 17 +++++++++++ .../graphView/provider/file/actions.ts | 5 ++++ .../graphView/provider/file/contracts.ts | 2 ++ .../graphView/provider/file/dependencies.ts | 2 ++ .../graphView/provider/file/navigation.ts | 19 ++++++++++++ .../provider/source/delegates/file.ts | 2 ++ .../graphView/webview/dispatch/primary.ts | 1 + .../graphView/webview/nodeFile/navigation.ts | 5 ++++ .../webview/providerMessages/listener.ts | 1 + .../primaryActions/fileActions.ts | 5 ++++ .../providerMessages/primaryActions/types.ts | 1 + .../src/shared/protocol/webviewToExtension.ts | 1 + .../graph/contextActions/builtin/effects.ts | 2 ++ .../graph/contextActions/messages.ts | 8 +++++ .../graph/contextMenu/build/baseEntries.ts | 7 ++++- .../components/graph/contextMenu/contracts.ts | 1 + .../graph/contextMenuRuntime/effects.ts | 1 + .../graphView/files/navigation.test.ts | 24 +++++++++++++++ .../graphView/provider/file/actions.test.ts | 8 +++++ .../provider/file/navigation.test.ts | 4 +++ .../graphView/webview/dispatch/context.ts | 1 + .../webview/nodeFile/navigation.test.ts | 13 ++++++++ .../graphView/webview/nodeFile/router.test.ts | 1 + .../graph/contextActions/effects.test.ts | 16 ++++++++++ .../graph/contextMenu/build/node.test.ts | 30 ++++++++++++++++++- .../node/view/selectionActions.test.tsx | 10 ++++++- 27 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 .changeset/calm-files-compare.md diff --git a/.changeset/calm-files-compare.md b/.changeset/calm-files-compare.md new file mode 100644 index 0000000000..319f68a6ac --- /dev/null +++ b/.changeset/calm-files-compare.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Add a Compare Selected action for exactly two selected File Nodes. diff --git a/packages/extension/src/extension/graphView/files/navigation.ts b/packages/extension/src/extension/graphView/files/navigation.ts index 0f16325c1e..1b755cbbbd 100644 --- a/packages/extension/src/extension/graphView/files/navigation.ts +++ b/packages/extension/src/extension/graphView/files/navigation.ts @@ -33,6 +33,23 @@ export interface GraphViewExplorerHandlers { executeCommand(command: string, ...args: unknown[]): PromiseLike; } +export interface GraphViewCompareHandlers { + workspaceFolder?: GraphViewWorkspaceFolderRef; + executeCommand(command: string, ...args: unknown[]): PromiseLike; +} + +export async function compareGraphViewFiles( + paths: readonly [string, string], + handlers: GraphViewCompareHandlers, +): Promise { + if (!handlers.workspaceFolder) return; + + const [leftPath, rightPath] = paths; + const leftUri = vscode.Uri.joinPath(handlers.workspaceFolder.uri, leftPath); + const rightUri = vscode.Uri.joinPath(handlers.workspaceFolder.uri, rightPath); + await handlers.executeCommand('vscode.diff', leftUri, rightUri); +} + export async function openGraphViewFile( filePath: string, handlers: GraphViewFileNavigationHandlers, diff --git a/packages/extension/src/extension/graphView/provider/file/actions.ts b/packages/extension/src/extension/graphView/provider/file/actions.ts index 2cbd867ee0..f12510b810 100644 --- a/packages/extension/src/extension/graphView/provider/file/actions.ts +++ b/packages/extension/src/extension/graphView/provider/file/actions.ts @@ -38,6 +38,10 @@ export function createGraphViewProviderFileActionMethods( await dependencies.revealFile(filePath); }; + const _compareFiles = async (paths: readonly [string, string]): Promise => { + await dependencies.compareFiles(paths); + }; + const _copyToClipboard = async (text: string): Promise => { await dependencies.copyText(text); }; @@ -159,6 +163,7 @@ export function createGraphViewProviderFileActionMethods( _openSelectedNode, _activateNode, _openFile, + _compareFiles, _revealInExplorer, _copyToClipboard, _deleteFiles, diff --git a/packages/extension/src/extension/graphView/provider/file/contracts.ts b/packages/extension/src/extension/graphView/provider/file/contracts.ts index 485b56aa05..2fb806c6c6 100644 --- a/packages/extension/src/extension/graphView/provider/file/contracts.ts +++ b/packages/extension/src/extension/graphView/provider/file/contracts.ts @@ -16,6 +16,7 @@ export interface GraphViewProviderFileActionMethods { _openSelectedNode(nodeId: string): Promise; _activateNode(nodeId: string): Promise; _openFile(filePath: string, behavior?: EditorOpenBehavior): Promise; + _compareFiles(paths: readonly [string, string]): Promise; _revealInExplorer(filePath: string): Promise; _copyToClipboard(text: string): Promise; _deleteFiles(paths: string[]): Promise; @@ -27,6 +28,7 @@ export interface GraphViewProviderFileActionMethods { export interface GraphViewProviderFileActionMethodDependencies { openFile: typeof import('./navigation').openGraphViewProviderFile; + compareFiles: typeof import('./navigation').compareGraphViewProviderFiles; revealFile: typeof import('./navigation').revealGraphViewProviderFileInExplorer; copyText: typeof import('./navigation').copyGraphViewProviderTextToClipboard; deleteFiles: typeof import('../../files/actions').deleteGraphViewFiles; diff --git a/packages/extension/src/extension/graphView/provider/file/dependencies.ts b/packages/extension/src/extension/graphView/provider/file/dependencies.ts index 1b21f3c8c0..d5f74d4592 100644 --- a/packages/extension/src/extension/graphView/provider/file/dependencies.ts +++ b/packages/extension/src/extension/graphView/provider/file/dependencies.ts @@ -9,6 +9,7 @@ import { createGraphViewFile, createGraphViewFolder, deleteGraphViewFiles } from import { renameGraphViewFile } from '../../files/rename'; import { toggleGraphViewFavorites } from '../../favorites'; import { + compareGraphViewProviderFiles, copyGraphViewProviderTextToClipboard, openGraphViewProviderFile, revealGraphViewProviderFileInExplorer, @@ -25,6 +26,7 @@ function getCurrentWorkspaceFolder(): vscode.WorkspaceFolder | undefined { export const DEFAULT_GRAPH_VIEW_FILE_ACTION_DEPENDENCIES: GraphViewProviderFileActionMethodDependencies = { openFile: openGraphViewProviderFile, + compareFiles: compareGraphViewProviderFiles, revealFile: revealGraphViewProviderFileInExplorer, copyText: copyGraphViewProviderTextToClipboard, deleteFiles: deleteGraphViewFiles, diff --git a/packages/extension/src/extension/graphView/provider/file/navigation.ts b/packages/extension/src/extension/graphView/provider/file/navigation.ts index e160c5d473..0cf44d71ba 100644 --- a/packages/extension/src/extension/graphView/provider/file/navigation.ts +++ b/packages/extension/src/extension/graphView/provider/file/navigation.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { + compareGraphViewFiles, copyGraphViewTextToClipboard, openGraphViewFile, revealGraphViewFileInExplorer, @@ -25,6 +26,7 @@ export interface GraphViewProviderFileNavigationDependencies { revealFile: typeof revealGraphViewFileInExplorer; writeText(text: string): PromiseLike; copyText: typeof copyGraphViewTextToClipboard; + compareFiles: typeof compareGraphViewFiles; logError(label: string, error: unknown): void; } @@ -44,12 +46,29 @@ function createDefaultGraphViewProviderFileNavigationDependencies(): GraphViewPr revealFile: revealGraphViewFileInExplorer, writeText: text => vscode.env.clipboard.writeText(text), copyText: copyGraphViewTextToClipboard, + compareFiles: compareGraphViewFiles, logError: (label, error) => { console.error(label, error); }, }; } +export async function compareGraphViewProviderFiles( + paths: readonly [string, string], + dependencies?: Pick< + GraphViewProviderFileNavigationDependencies, + 'getWorkspaceFolder' | 'compareFiles' + >, +): Promise { + const resolvedDependencies = + dependencies ?? createDefaultGraphViewProviderFileNavigationDependencies(); + + await resolvedDependencies.compareFiles(paths, { + workspaceFolder: resolvedDependencies.getWorkspaceFolder(), + executeCommand: (command, ...args) => vscode.commands.executeCommand(command, ...args), + }); +} + export async function openGraphViewProviderFile( source: GraphViewProviderFileNavigationSource, filePath: string, diff --git a/packages/extension/src/extension/graphView/provider/source/delegates/file.ts b/packages/extension/src/extension/graphView/provider/source/delegates/file.ts index 3901fae879..79d0a7a090 100644 --- a/packages/extension/src/extension/graphView/provider/source/delegates/file.ts +++ b/packages/extension/src/extension/graphView/provider/source/delegates/file.ts @@ -11,6 +11,7 @@ export function createGraphViewProviderFileMethodDelegates( ): Pick< GraphViewProviderMethodSource, | '_openFile' + | '_compareFiles' | '_openSelectedNode' | '_activateNode' | '_revealInExplorer' @@ -28,6 +29,7 @@ export function createGraphViewProviderFileMethodDelegates( return { _openFile: (filePath, behavior?: EditorOpenBehavior) => owner._methodContainers.fileAction._openFile(filePath, behavior), + _compareFiles: paths => owner._methodContainers.fileAction._compareFiles(paths), _openSelectedNode: nodeId => owner._methodContainers.fileAction._openSelectedNode(nodeId), _activateNode: nodeId => owner._methodContainers.fileAction._activateNode(nodeId), _revealInExplorer: filePath => owner._methodContainers.fileAction._revealInExplorer(filePath), diff --git a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts index 4b7db3611a..e7aeb5f880 100644 --- a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts +++ b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts @@ -29,6 +29,7 @@ export interface GraphViewPrimaryMessageContext { canOpenPath(filePath: string): boolean; setFocusedFile(filePath: string | undefined): void; openFile(filePath: string): Promise; + compareFiles(paths: readonly [string, string]): Promise; openInEditor(): void; revealInExplorer(filePath: string): Promise; copyToClipboard(text: string): Promise; diff --git a/packages/extension/src/extension/graphView/webview/nodeFile/navigation.ts b/packages/extension/src/extension/graphView/webview/nodeFile/navigation.ts index 83b755eaf8..d352521a72 100644 --- a/packages/extension/src/extension/graphView/webview/nodeFile/navigation.ts +++ b/packages/extension/src/extension/graphView/webview/nodeFile/navigation.ts @@ -1,6 +1,7 @@ import type { WebviewToExtensionMessage } from '../../../../shared/protocol/webviewToExtension'; export interface GraphViewNodeFileNavigationHandlers { + compareFiles(paths: readonly [string, string]): Promise; revealInExplorer(filePath: string): Promise; copyToClipboard(text: string): Promise; indexGraph(): Promise; @@ -13,6 +14,10 @@ export async function applyNodeFileNavigationMessage( handlers: GraphViewNodeFileNavigationHandlers, ): Promise { switch (message.type) { + case 'COMPARE_FILES': + await handlers.compareFiles(message.payload.paths); + return true; + case 'REVEAL_IN_EXPLORER': void handlers.revealInExplorer(message.payload.path); return true; diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts b/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts index 9e59da1429..eaa7769a89 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts @@ -109,6 +109,7 @@ export interface GraphViewProviderMessageListenerSource { _activateNode(nodeId: string): Promise; setFocusedFile(filePath: string | undefined): void; _openFile(filePath: string): Promise; + _compareFiles(paths: readonly [string, string]): Promise; _revealInExplorer(filePath: string): Promise; _copyToClipboard(text: string): Promise; _deleteFiles(paths: string[]): Promise; diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/fileActions.ts b/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/fileActions.ts index d6242f7af9..604df084aa 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/fileActions.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/fileActions.ts @@ -13,6 +13,7 @@ type FileActions = Pick< | 'canOpenPath' | 'setFocusedFile' | 'openFile' + | 'compareFiles' | 'revealInExplorer' | 'copyToClipboard' | 'deleteFiles' @@ -31,6 +32,10 @@ export function createFileActions(source: GraphViewProviderMessageListenerSource canOpenPath: filePath => canOpenGraphPath(source, filePath), setFocusedFile: filePath => source.setFocusedFile(filePath), openFile: filePath => source._openFile(resolveGraphOpenPath(source, filePath)), + compareFiles: paths => source._compareFiles([ + resolveGraphOpenPath(source, paths[0]), + resolveGraphOpenPath(source, paths[1]), + ]), revealInExplorer: filePath => source._revealInExplorer(resolveGraphOpenPath(source, filePath)), copyToClipboard: text => source._copyToClipboard(text), deleteFiles: paths => source._deleteFiles(paths), diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/types.ts b/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/types.ts index 3039459a1e..6fab511e77 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/types.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/types.ts @@ -7,6 +7,7 @@ export type GraphViewProviderPrimaryActions = Pick< | 'canOpenPath' | 'setFocusedFile' | 'openFile' + | 'compareFiles' | 'openInEditor' | 'revealInExplorer' | 'copyToClipboard' diff --git a/packages/extension/src/shared/protocol/webviewToExtension.ts b/packages/extension/src/shared/protocol/webviewToExtension.ts index 54132609f3..f5713dcbeb 100644 --- a/packages/extension/src/shared/protocol/webviewToExtension.ts +++ b/packages/extension/src/shared/protocol/webviewToExtension.ts @@ -26,6 +26,7 @@ export type WebviewToExtensionMessage = | { type: 'CLEAR_FOCUSED_FILE' } | { type: 'WEBVIEW_READY'; payload: WebviewReadyPayload | null } | { type: 'OPEN_FILE'; payload: { path: string } } + | { type: 'COMPARE_FILES'; payload: { paths: [string, string] } } | { type: 'OPEN_IN_EDITOR' } | { type: 'REVEAL_IN_EXPLORER'; payload: { path: string } } | { type: 'COPY_TO_CLIPBOARD'; payload: { text: string } } diff --git a/packages/extension/src/webview/components/graph/contextActions/builtin/effects.ts b/packages/extension/src/webview/components/graph/contextActions/builtin/effects.ts index 43a30eafa0..fcf3a1ec82 100644 --- a/packages/extension/src/webview/components/graph/contextActions/builtin/effects.ts +++ b/packages/extension/src/webview/components/graph/contextActions/builtin/effects.ts @@ -3,6 +3,7 @@ import type { GraphContextActionContext } from '../context'; import type { GraphContextEffect } from '../effects'; import { createClipboardEffects, + createCompareFilesEffects, createCreateFileEffects, createCreateFolderEffects, createOptionalClipboardEffects, @@ -20,6 +21,7 @@ import { const BUILT_IN_CONTEXT_ACTION_EFFECTS = { open: (context: GraphContextActionContext) => createOpenFileEffects(context.targetIds), + compare: (context: GraphContextActionContext) => createCompareFilesEffects(context.targetIds), openEdgeSource: (context: GraphContextActionContext) => createOpenFileEffects(context.edgeSourceId ? [context.edgeSourceId] : []), openEdgeTarget: (context: GraphContextActionContext) => diff --git a/packages/extension/src/webview/components/graph/contextActions/messages.ts b/packages/extension/src/webview/components/graph/contextActions/messages.ts index f073e38514..d5c2056e61 100644 --- a/packages/extension/src/webview/components/graph/contextActions/messages.ts +++ b/packages/extension/src/webview/components/graph/contextActions/messages.ts @@ -19,6 +19,14 @@ export function createClipboardEffects(text: string): GraphContextEffect[] { return [createPostMessageEffect({ type: 'COPY_TO_CLIPBOARD', payload: { text } })]; } +export function createCompareFilesEffects(paths: readonly string[]): GraphContextEffect[] { + if (paths.length !== 2) return []; + return [createPostMessageEffect({ + type: 'COMPARE_FILES', + payload: { paths: [paths[0], paths[1]] }, + })]; +} + export function createOptionalClipboardEffects( path: string | undefined, transform: (value: string) => string = (value) => value, diff --git a/packages/extension/src/webview/components/graph/contextMenu/build/baseEntries.ts b/packages/extension/src/webview/components/graph/contextMenu/build/baseEntries.ts index a0d9a5fa8e..685be2dcd2 100644 --- a/packages/extension/src/webview/components/graph/contextMenu/build/baseEntries.ts +++ b/packages/extension/src/webview/components/graph/contextMenu/build/baseEntries.ts @@ -2,6 +2,7 @@ import { buildBackgroundEntries } from '../background/entries'; import type { BuildGraphContextMenuOptions, GraphContextMenuEntry } from '../contracts'; import type { GraphContextMenuDecision } from '../decision/model'; import { buildEdgeEntries } from '../edge/entries'; +import { builtInItem } from '../common/entryFactories'; import { buildNodeEntries, buildSingleFolderNodeEntries, buildSinglePluginNodeEntries, buildSingleSymbolNodeEntries } from '../node/entries'; function nodeTargetIds(decision: Exclude): readonly string[] { @@ -15,5 +16,9 @@ export function buildBaseGraphContextMenuEntries(decision: GraphContextMenuDecis if (decision.kind === 'singlePluginNode') return buildSinglePluginNodeEntries(); if (decision.kind === 'edge') return buildEdgeEntries(decision.targets); if (decision.kind === 'emptyNodeSelection') return []; - return buildNodeEntries(nodeTargetIds(decision), options.favorites); + const entries = buildNodeEntries(nodeTargetIds(decision), options.favorites); + if (decision.kind === 'multiFileNodes' && decision.targets.length === 2) { + entries.splice(1, 0, builtInItem('node-compare-selected', 'Compare Selected', 'compare')); + } + return entries; } diff --git a/packages/extension/src/webview/components/graph/contextMenu/contracts.ts b/packages/extension/src/webview/components/graph/contextMenu/contracts.ts index 00c1e5a077..24faa7b4a4 100644 --- a/packages/extension/src/webview/components/graph/contextMenu/contracts.ts +++ b/packages/extension/src/webview/components/graph/contextMenu/contracts.ts @@ -5,6 +5,7 @@ type GraphViewContextMenuContribution = ExtensionGraphViewContributionSet['conte export type BuiltInContextMenuAction = | 'open' + | 'compare' | 'openEdgeSource' | 'openEdgeTarget' | 'reveal' diff --git a/packages/extension/src/webview/components/graph/contextMenuRuntime/effects.ts b/packages/extension/src/webview/components/graph/contextMenuRuntime/effects.ts index 24c8c494e4..77fe1074f8 100644 --- a/packages/extension/src/webview/components/graph/contextMenuRuntime/effects.ts +++ b/packages/extension/src/webview/components/graph/contextMenuRuntime/effects.ts @@ -25,6 +25,7 @@ export interface GraphContextMenuEffectRuntime { const NODE_ACTIONS = new Set([ 'open', + 'compare', 'reveal', 'copyRelative', 'copyAbsolute', diff --git a/packages/extension/tests/extension/graphView/files/navigation.test.ts b/packages/extension/tests/extension/graphView/files/navigation.test.ts index 8467edbb5b..85e2b42974 100644 --- a/packages/extension/tests/extension/graphView/files/navigation.test.ts +++ b/packages/extension/tests/extension/graphView/files/navigation.test.ts @@ -1,12 +1,36 @@ import { describe, expect, it, vi } from 'vitest'; import * as vscode from 'vscode'; import { + compareGraphViewFiles, copyGraphViewTextToClipboard, openGraphViewFile, revealGraphViewFileInExplorer, } from '../../../../src/extension/graphView/files/navigation'; describe('graphView/files/navigation', () => { + it('dispatches VS Code diff with the first selected file on the left', async () => { + const executeCommand = vi.fn(async () => undefined); + + await compareGraphViewFiles(['src/first.ts', 'src/second.ts'], { + workspaceFolder: { uri: vscode.Uri.file('/workspace') }, + executeCommand, + }); + + expect(executeCommand).toHaveBeenCalledWith( + 'vscode.diff', + vscode.Uri.file('/workspace/src/first.ts'), + vscode.Uri.file('/workspace/src/second.ts'), + ); + }); + + it('does not dispatch a diff when no workspace exists', async () => { + const executeCommand = vi.fn(async () => undefined); + + await compareGraphViewFiles(['src/first.ts', 'src/second.ts'], { executeCommand }); + + expect(executeCommand).not.toHaveBeenCalled(); + }); + it('shows a mock-file message when no workspace folder exists', async () => { const showInformationMessage = vi.fn(); diff --git a/packages/extension/tests/extension/graphView/provider/file/actions.test.ts b/packages/extension/tests/extension/graphView/provider/file/actions.test.ts index b8f9ee5551..ace9f689a6 100644 --- a/packages/extension/tests/extension/graphView/provider/file/actions.test.ts +++ b/packages/extension/tests/extension/graphView/provider/file/actions.test.ts @@ -36,6 +36,7 @@ describe('graphView/provider/file/actions', () => { it('delegates file navigation helpers through their extracted provider wrappers', async () => { const openFile = vi.fn(async () => undefined); + const compareFiles = vi.fn(async () => undefined); const revealFile = vi.fn(async () => undefined); const copyText = vi.fn(async () => undefined); const source = { @@ -45,6 +46,7 @@ describe('graphView/provider/file/actions', () => { }; const methods = createGraphViewProviderFileActionMethods(source as never, { openFile, + compareFiles, revealFile, copyText, deleteFiles: vi.fn(async () => undefined), @@ -65,6 +67,7 @@ describe('graphView/provider/file/actions', () => { }); await methods._openFile('src/app.ts', { preview: true, preserveFocus: false }); + await methods._compareFiles(['src/app.ts', 'src/utils.ts']); await methods._revealInExplorer('src/app.ts'); await methods._copyToClipboard('hello'); @@ -78,6 +81,7 @@ describe('graphView/provider/file/actions', () => { }, ); expect(revealFile).toHaveBeenCalledWith('src/app.ts'); + expect(compareFiles).toHaveBeenCalledWith(['src/app.ts', 'src/utils.ts']); expect(copyText).toHaveBeenCalledWith('hello'); }); @@ -106,6 +110,7 @@ describe('graphView/provider/file/actions', () => { }; const methods = createGraphViewProviderFileActionMethods(source as never, { openFile: vi.fn(async () => undefined), + compareFiles: vi.fn(async () => undefined), revealFile: vi.fn(async () => undefined), copyText: vi.fn(async () => undefined), deleteFiles, @@ -170,6 +175,7 @@ describe('graphView/provider/file/actions', () => { }; const methods = createGraphViewProviderFileActionMethods(source as never, { openFile: vi.fn(async () => undefined), + compareFiles: vi.fn(async () => undefined), revealFile: vi.fn(async () => undefined), copyText: vi.fn(async () => undefined), deleteFiles: vi.fn(async () => undefined), @@ -222,6 +228,7 @@ describe('graphView/provider/file/actions', () => { }; const methods = createGraphViewProviderFileActionMethods(source as never, { openFile: vi.fn(async () => undefined), + compareFiles: vi.fn(async () => undefined), revealFile: vi.fn(async () => undefined), copyText: vi.fn(async () => undefined), deleteFiles: vi.fn(async () => undefined), @@ -280,6 +287,7 @@ describe('graphView/provider/file/actions', () => { }; const methods = createGraphViewProviderFileActionMethods(source as never, { openFile: vi.fn(async () => undefined), + compareFiles: vi.fn(async () => undefined), revealFile: vi.fn(async () => undefined), copyText: vi.fn(async () => undefined), deleteFiles: vi.fn(async () => undefined), diff --git a/packages/extension/tests/extension/graphView/provider/file/navigation.test.ts b/packages/extension/tests/extension/graphView/provider/file/navigation.test.ts index 4fd81e5266..80466c39ea 100644 --- a/packages/extension/tests/extension/graphView/provider/file/navigation.test.ts +++ b/packages/extension/tests/extension/graphView/provider/file/navigation.test.ts @@ -27,6 +27,7 @@ describe('graphView/provider/file/navigation', () => { openFile: vi.fn(async (_filePath, handlers) => { await handlers.didOpenFile?.('src/index.ts'); }), + compareFiles: vi.fn(), revealFile: vi.fn(), writeText: vi.fn(() => Promise.resolve()), copyText: vi.fn(), @@ -57,6 +58,7 @@ describe('graphView/provider/file/navigation', () => { openFile: vi.fn(async (_filePath, handlers) => { await handlers.didOpenFile?.('src/index.ts'); }), + compareFiles: vi.fn(), revealFile: vi.fn(), writeText: vi.fn(() => Promise.resolve()), copyText: vi.fn(), @@ -86,6 +88,7 @@ describe('graphView/provider/file/navigation', () => { openTextDocument: vi.fn(), showTextDocument: vi.fn(), openFile, + compareFiles: vi.fn(), logError: vi.fn(), } as never, ); @@ -129,6 +132,7 @@ describe('graphView/provider/file/navigation', () => { await handlers.showTextDocument(document, { preview: true, preserveFocus: false }); handlers.logError('open failed', 'boom'); }), + compareFiles: vi.fn(), revealFile: vi.fn(), writeText: vi.fn(() => Promise.resolve()), copyText: vi.fn(), diff --git a/packages/extension/tests/extension/graphView/webview/dispatch/context.ts b/packages/extension/tests/extension/graphView/webview/dispatch/context.ts index 908e834985..cbd718bdc1 100644 --- a/packages/extension/tests/extension/graphView/webview/dispatch/context.ts +++ b/packages/extension/tests/extension/graphView/webview/dispatch/context.ts @@ -16,6 +16,7 @@ export function createPrimaryMessageContext( activateNode: vi.fn(() => Promise.resolve()), setFocusedFile: vi.fn(), openFile: vi.fn(() => Promise.resolve()), + compareFiles: vi.fn(() => Promise.resolve()), revealInExplorer: vi.fn(() => Promise.resolve()), copyToClipboard: vi.fn(() => Promise.resolve()), deleteFiles: vi.fn(() => Promise.resolve()), diff --git a/packages/extension/tests/extension/graphView/webview/nodeFile/navigation.test.ts b/packages/extension/tests/extension/graphView/webview/nodeFile/navigation.test.ts index 4a65c08f56..dbcd6c092c 100644 --- a/packages/extension/tests/extension/graphView/webview/nodeFile/navigation.test.ts +++ b/packages/extension/tests/extension/graphView/webview/nodeFile/navigation.test.ts @@ -8,6 +8,7 @@ function createHandlers( overrides: Partial = {}, ): GraphViewNodeFileNavigationHandlers { return { + compareFiles: vi.fn(() => Promise.resolve()), revealInExplorer: vi.fn(() => Promise.resolve()), copyToClipboard: vi.fn(() => Promise.resolve()), indexGraph: vi.fn(() => Promise.resolve()), @@ -18,6 +19,18 @@ function createHandlers( } describe('graph view node/file navigation message', () => { + it('dispatches both paths to the compare handler in order', async () => { + const handlers = createHandlers(); + + const handled = await applyNodeFileNavigationMessage( + { type: 'COMPARE_FILES', payload: { paths: ['src/first.ts', 'src/second.ts'] } }, + handlers, + ); + + expect(handled).toBe(true); + expect(handlers.compareFiles).toHaveBeenCalledWith(['src/first.ts', 'src/second.ts']); + }); + it('reveals a file in the explorer', async () => { const handlers = createHandlers(); diff --git a/packages/extension/tests/extension/graphView/webview/nodeFile/router.test.ts b/packages/extension/tests/extension/graphView/webview/nodeFile/router.test.ts index 9cb67e42e0..ff0e260125 100644 --- a/packages/extension/tests/extension/graphView/webview/nodeFile/router.test.ts +++ b/packages/extension/tests/extension/graphView/webview/nodeFile/router.test.ts @@ -12,6 +12,7 @@ function createHandlers( openSelectedNode: vi.fn(() => Promise.resolve()), activateNode: vi.fn(() => Promise.resolve()), openFile: vi.fn(() => Promise.resolve()), + compareFiles: vi.fn(() => Promise.resolve()), revealInExplorer: vi.fn(() => Promise.resolve()), copyToClipboard: vi.fn(() => Promise.resolve()), deleteFiles: vi.fn(() => Promise.resolve()), diff --git a/packages/extension/tests/webview/graph/contextActions/effects.test.ts b/packages/extension/tests/webview/graph/contextActions/effects.test.ts index 757fbb9b98..ecfffa37e0 100644 --- a/packages/extension/tests/webview/graph/contextActions/effects.test.ts +++ b/packages/extension/tests/webview/graph/contextActions/effects.test.ts @@ -20,6 +20,22 @@ describe('graph/contextActions/effects', () => { ]); }); + it('preserves selection order in the compare-files message', () => { + expect(getBuiltInContextActionEffects('compare', nodeContext(['src/first.ts', 'src/second.ts']))).toEqual([ + { + kind: 'postMessage', + message: { + type: 'COMPARE_FILES', + payload: { paths: ['src/first.ts', 'src/second.ts'] }, + }, + }, + ]); + }); + + it('does not create a compare effect for a selection other than two paths', () => { + expect(getBuiltInContextActionEffects('compare', nodeContext(['src/only.ts']))).toEqual([]); + }); + it('uses the first selected path for reveal actions', () => { expect(getBuiltInContextActionEffects('reveal', nodeContext(['src/app.ts', 'src/utils.ts']))).toEqual([ { diff --git a/packages/extension/tests/webview/graph/contextMenu/build/node.test.ts b/packages/extension/tests/webview/graph/contextMenu/build/node.test.ts index 78ad5ae32f..ca3bd44f0b 100644 --- a/packages/extension/tests/webview/graph/contextMenu/build/node.test.ts +++ b/packages/extension/tests/webview/graph/contextMenu/build/node.test.ts @@ -90,9 +90,10 @@ describe('graph/contextMenu/build/node', () => { kind: 'header', header: { kind: 'multiNode', count: 2 }, }); - expect(itemLabels(entries)).toHaveLength(5); + expect(itemLabels(entries)).toHaveLength(6); expect(itemLabels(entries)).toEqual([ 'Open 2 Files', + 'Compare Selected', 'Copy Relative Paths', 'Add All to Favorites', 'Add Filter Patterns', @@ -100,6 +101,33 @@ describe('graph/contextMenu/build/node', () => { ]); }); + it('shows Compare Selected only for exactly two File Nodes', () => { + const labelsFor = ( + targets: string[], + nodes?: Array<{ id: string; nodeType?: string; symbol?: { id: string; name: string; filePath: string } }>, + ): string[] => itemLabels(buildGraphContextMenuEntries({ + selection: { kind: 'node', targets }, + favorites: new Set(), + nodes, + })); + + expect(labelsFor(['src/a.ts', 'src/b.ts'])).toContain('Compare Selected'); + expect(labelsFor(['src/a.ts'])).not.toContain('Compare Selected'); + expect(labelsFor(['src/a.ts', 'src/b.ts', 'src/c.ts'])).not.toContain('Compare Selected'); + expect(labelsFor(['src', 'tests'], [ + { id: 'src', nodeType: 'folder' }, + { id: 'tests', nodeType: 'folder' }, + ])).not.toContain('Compare Selected'); + expect(labelsFor(['src/a.ts#run', 'src/b.ts#run'], [ + { id: 'src/a.ts#run', nodeType: 'symbol' }, + { id: 'src/b.ts#run', nodeType: 'symbol' }, + ])).not.toContain('Compare Selected'); + expect(labelsFor(['src/a.ts', 'src'], [ + { id: 'src/a.ts', nodeType: 'file' }, + { id: 'src', nodeType: 'folder' }, + ])).not.toContain('Compare Selected'); + }); + it('keeps mixed selections with plugin nodes on generic public actions', () => { const entries = buildGraphContextMenuEntries({ selection: makeNodeContextSelection('src/app.ts', new Set(['src/app.ts', 'src', 'plugin-node'])), diff --git a/packages/extension/tests/webview/graph/contextMenu/node/view/selectionActions.test.tsx b/packages/extension/tests/webview/graph/contextMenu/node/view/selectionActions.test.tsx index ffbf6c852c..d60a982242 100644 --- a/packages/extension/tests/webview/graph/contextMenu/node/view/selectionActions.test.tsx +++ b/packages/extension/tests/webview/graph/contextMenu/node/view/selectionActions.test.tsx @@ -28,6 +28,7 @@ describe('Graph node context menu selection actions', () => { expect(screen.getByText('2 Nodes selected')).toBeInTheDocument(); expect(screen.getByRole('menu')).toHaveAccessibleName('2 Nodes selected'); + expect(screen.getByText('Compare Selected')).toBeInTheDocument(); expect(screen.getByText('Copy Relative Paths')).toBeInTheDocument(); expect(screen.getByText('Add All to Favorites')).toBeInTheDocument(); expect(screen.getByText('Add Filter Patterns')).toBeInTheDocument(); @@ -36,7 +37,7 @@ describe('Graph node context menu selection actions', () => { expect(screen.queryByText('Rename')).not.toBeInTheDocument(); }); - it('posts Open, Copy, Favorite, Filter, and Delete actions for all selected nodes', async () => { + it('posts Open, Compare, Copy, Favorite, Filter, and Delete actions for all selected nodes', async () => { const onAddFilterRequested = vi.fn(); const { container } = render( , @@ -54,6 +55,13 @@ describe('Graph node context menu selection actions', () => { const openMessages = getSentMessages().filter(msg => msg.type === 'OPEN_FILE'); expect(openMessages.map(msg => msg.payload.path)).toEqual(['nodeA.ts', 'nodeB.ts']); + await selectTwoNodesForMultiMenu(graphContainer); + clearSentMessages(); + await act(async () => { + fireEvent.click(screen.getByText('Compare Selected')); + }); + expect(findMessage('COMPARE_FILES')?.payload.paths).toEqual(['nodeA.ts', 'nodeB.ts']); + await selectTwoNodesForMultiMenu(graphContainer); clearSentMessages(); await act(async () => { From 81d9c7662d3c415f34f036d1cba093f51982c3b7 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 6 Aug 2026 10:22:50 -0700 Subject: [PATCH 2/4] Cover non-file compare selections --- .../graphView/provider/file/actions.test.ts | 3 +++ .../webview/graph/contextMenu/build/node.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/extension/tests/extension/graphView/provider/file/actions.test.ts b/packages/extension/tests/extension/graphView/provider/file/actions.test.ts index ace9f689a6..1dcc42c2e1 100644 --- a/packages/extension/tests/extension/graphView/provider/file/actions.test.ts +++ b/packages/extension/tests/extension/graphView/provider/file/actions.test.ts @@ -455,6 +455,7 @@ async function createDefaultDependencyHarness( vi.resetModules(); const openFile = vi.fn(async () => undefined); + const compareFiles = vi.fn(async () => undefined); const revealFile = vi.fn(async () => undefined); const copyText = vi.fn(async () => undefined); const deleteFiles = vi.fn(async (_paths: string[], handlers: { @@ -589,6 +590,7 @@ async function createDefaultDependencyHarness( })); vi.doMock('../../../../../src/extension/graphView/provider/file/navigation', () => ({ openGraphViewProviderFile: openFile, + compareGraphViewProviderFiles: compareFiles, revealGraphViewProviderFileInExplorer: revealFile, copyGraphViewProviderTextToClipboard: copyText, })); @@ -638,6 +640,7 @@ async function createDefaultDependencyHarness( source, methods: createMethods(source as never), openFile, + compareFiles, revealFile, copyText, deleteFiles, diff --git a/packages/extension/tests/webview/graph/contextMenu/build/node.test.ts b/packages/extension/tests/webview/graph/contextMenu/build/node.test.ts index ca3bd44f0b..b18ff30ddb 100644 --- a/packages/extension/tests/webview/graph/contextMenu/build/node.test.ts +++ b/packages/extension/tests/webview/graph/contextMenu/build/node.test.ts @@ -118,6 +118,10 @@ describe('graph/contextMenu/build/node', () => { { id: 'src', nodeType: 'folder' }, { id: 'tests', nodeType: 'folder' }, ])).not.toContain('Compare Selected'); + expect(labelsFor(['pkg:react', 'pkg:vitest'], [ + { id: 'pkg:react', nodeType: 'package' }, + { id: 'pkg:vitest', nodeType: 'package' }, + ])).not.toContain('Compare Selected'); expect(labelsFor(['src/a.ts#run', 'src/b.ts#run'], [ { id: 'src/a.ts#run', nodeType: 'symbol' }, { id: 'src/b.ts#run', nodeType: 'symbol' }, @@ -126,6 +130,14 @@ describe('graph/contextMenu/build/node', () => { { id: 'src/a.ts', nodeType: 'file' }, { id: 'src', nodeType: 'folder' }, ])).not.toContain('Compare Selected'); + expect(itemLabels(buildGraphContextMenuEntries({ + selection: { + kind: 'edge', + edgeId: 'src/a.ts->src/b.ts', + targets: ['src/a.ts', 'src/b.ts'], + }, + favorites: new Set(), + }))).not.toContain('Compare Selected'); }); it('keeps mixed selections with plugin nodes on generic public actions', () => { From a95c09664404bd533fb71db02cbfc482910896aa Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 6 Aug 2026 10:29:04 -0700 Subject: [PATCH 3/4] Simplify Compare Selected dispatch --- .../extension/graphView/files/navigation.ts | 11 +++----- .../graphView/provider/file/actions.ts | 5 ---- .../graphView/provider/file/contracts.ts | 2 -- .../graphView/provider/file/dependencies.ts | 2 -- .../graphView/provider/file/navigation.ts | 19 ------------- .../provider/source/delegates/file.ts | 2 -- .../graphView/webview/dispatch/primary.ts | 1 - .../webview/dispatch/primaryState.ts | 6 +++++ .../webview/providerMessages/listener.ts | 1 - .../primaryActions/fileActions.ts | 5 ---- .../providerMessages/primaryActions/types.ts | 1 - .../graphView/provider/file/actions.test.ts | 11 -------- .../provider/file/navigation.test.ts | 4 --- .../graphView/webview/dispatch/context.ts | 1 - .../graph/contextMenu/build/edge.test.ts | 12 +++++++++ .../graph/contextMenu/build/node.test.ts | 8 ------ .../node/view/selectionActions.test.tsx | 27 +++++++++++++++++++ 17 files changed, 48 insertions(+), 70 deletions(-) diff --git a/packages/extension/src/extension/graphView/files/navigation.ts b/packages/extension/src/extension/graphView/files/navigation.ts index 1b755cbbbd..0811c5a0a0 100644 --- a/packages/extension/src/extension/graphView/files/navigation.ts +++ b/packages/extension/src/extension/graphView/files/navigation.ts @@ -28,19 +28,14 @@ export interface GraphViewClipboardHandlers { writeText(text: string): PromiseLike; } -export interface GraphViewExplorerHandlers { - workspaceFolder?: GraphViewWorkspaceFolderRef; - executeCommand(command: string, ...args: unknown[]): PromiseLike; -} - -export interface GraphViewCompareHandlers { +export interface GraphViewCommandHandlers { workspaceFolder?: GraphViewWorkspaceFolderRef; executeCommand(command: string, ...args: unknown[]): PromiseLike; } export async function compareGraphViewFiles( paths: readonly [string, string], - handlers: GraphViewCompareHandlers, + handlers: GraphViewCommandHandlers, ): Promise { if (!handlers.workspaceFolder) return; @@ -81,7 +76,7 @@ export async function openGraphViewFile( export async function revealGraphViewFileInExplorer( filePath: string, - handlers: GraphViewExplorerHandlers, + handlers: GraphViewCommandHandlers, ): Promise { if (!handlers.workspaceFolder) return; diff --git a/packages/extension/src/extension/graphView/provider/file/actions.ts b/packages/extension/src/extension/graphView/provider/file/actions.ts index f12510b810..2cbd867ee0 100644 --- a/packages/extension/src/extension/graphView/provider/file/actions.ts +++ b/packages/extension/src/extension/graphView/provider/file/actions.ts @@ -38,10 +38,6 @@ export function createGraphViewProviderFileActionMethods( await dependencies.revealFile(filePath); }; - const _compareFiles = async (paths: readonly [string, string]): Promise => { - await dependencies.compareFiles(paths); - }; - const _copyToClipboard = async (text: string): Promise => { await dependencies.copyText(text); }; @@ -163,7 +159,6 @@ export function createGraphViewProviderFileActionMethods( _openSelectedNode, _activateNode, _openFile, - _compareFiles, _revealInExplorer, _copyToClipboard, _deleteFiles, diff --git a/packages/extension/src/extension/graphView/provider/file/contracts.ts b/packages/extension/src/extension/graphView/provider/file/contracts.ts index 2fb806c6c6..485b56aa05 100644 --- a/packages/extension/src/extension/graphView/provider/file/contracts.ts +++ b/packages/extension/src/extension/graphView/provider/file/contracts.ts @@ -16,7 +16,6 @@ export interface GraphViewProviderFileActionMethods { _openSelectedNode(nodeId: string): Promise; _activateNode(nodeId: string): Promise; _openFile(filePath: string, behavior?: EditorOpenBehavior): Promise; - _compareFiles(paths: readonly [string, string]): Promise; _revealInExplorer(filePath: string): Promise; _copyToClipboard(text: string): Promise; _deleteFiles(paths: string[]): Promise; @@ -28,7 +27,6 @@ export interface GraphViewProviderFileActionMethods { export interface GraphViewProviderFileActionMethodDependencies { openFile: typeof import('./navigation').openGraphViewProviderFile; - compareFiles: typeof import('./navigation').compareGraphViewProviderFiles; revealFile: typeof import('./navigation').revealGraphViewProviderFileInExplorer; copyText: typeof import('./navigation').copyGraphViewProviderTextToClipboard; deleteFiles: typeof import('../../files/actions').deleteGraphViewFiles; diff --git a/packages/extension/src/extension/graphView/provider/file/dependencies.ts b/packages/extension/src/extension/graphView/provider/file/dependencies.ts index d5f74d4592..1b21f3c8c0 100644 --- a/packages/extension/src/extension/graphView/provider/file/dependencies.ts +++ b/packages/extension/src/extension/graphView/provider/file/dependencies.ts @@ -9,7 +9,6 @@ import { createGraphViewFile, createGraphViewFolder, deleteGraphViewFiles } from import { renameGraphViewFile } from '../../files/rename'; import { toggleGraphViewFavorites } from '../../favorites'; import { - compareGraphViewProviderFiles, copyGraphViewProviderTextToClipboard, openGraphViewProviderFile, revealGraphViewProviderFileInExplorer, @@ -26,7 +25,6 @@ function getCurrentWorkspaceFolder(): vscode.WorkspaceFolder | undefined { export const DEFAULT_GRAPH_VIEW_FILE_ACTION_DEPENDENCIES: GraphViewProviderFileActionMethodDependencies = { openFile: openGraphViewProviderFile, - compareFiles: compareGraphViewProviderFiles, revealFile: revealGraphViewProviderFileInExplorer, copyText: copyGraphViewProviderTextToClipboard, deleteFiles: deleteGraphViewFiles, diff --git a/packages/extension/src/extension/graphView/provider/file/navigation.ts b/packages/extension/src/extension/graphView/provider/file/navigation.ts index 0cf44d71ba..e160c5d473 100644 --- a/packages/extension/src/extension/graphView/provider/file/navigation.ts +++ b/packages/extension/src/extension/graphView/provider/file/navigation.ts @@ -1,6 +1,5 @@ import * as vscode from 'vscode'; import { - compareGraphViewFiles, copyGraphViewTextToClipboard, openGraphViewFile, revealGraphViewFileInExplorer, @@ -26,7 +25,6 @@ export interface GraphViewProviderFileNavigationDependencies { revealFile: typeof revealGraphViewFileInExplorer; writeText(text: string): PromiseLike; copyText: typeof copyGraphViewTextToClipboard; - compareFiles: typeof compareGraphViewFiles; logError(label: string, error: unknown): void; } @@ -46,29 +44,12 @@ function createDefaultGraphViewProviderFileNavigationDependencies(): GraphViewPr revealFile: revealGraphViewFileInExplorer, writeText: text => vscode.env.clipboard.writeText(text), copyText: copyGraphViewTextToClipboard, - compareFiles: compareGraphViewFiles, logError: (label, error) => { console.error(label, error); }, }; } -export async function compareGraphViewProviderFiles( - paths: readonly [string, string], - dependencies?: Pick< - GraphViewProviderFileNavigationDependencies, - 'getWorkspaceFolder' | 'compareFiles' - >, -): Promise { - const resolvedDependencies = - dependencies ?? createDefaultGraphViewProviderFileNavigationDependencies(); - - await resolvedDependencies.compareFiles(paths, { - workspaceFolder: resolvedDependencies.getWorkspaceFolder(), - executeCommand: (command, ...args) => vscode.commands.executeCommand(command, ...args), - }); -} - export async function openGraphViewProviderFile( source: GraphViewProviderFileNavigationSource, filePath: string, diff --git a/packages/extension/src/extension/graphView/provider/source/delegates/file.ts b/packages/extension/src/extension/graphView/provider/source/delegates/file.ts index 79d0a7a090..3901fae879 100644 --- a/packages/extension/src/extension/graphView/provider/source/delegates/file.ts +++ b/packages/extension/src/extension/graphView/provider/source/delegates/file.ts @@ -11,7 +11,6 @@ export function createGraphViewProviderFileMethodDelegates( ): Pick< GraphViewProviderMethodSource, | '_openFile' - | '_compareFiles' | '_openSelectedNode' | '_activateNode' | '_revealInExplorer' @@ -29,7 +28,6 @@ export function createGraphViewProviderFileMethodDelegates( return { _openFile: (filePath, behavior?: EditorOpenBehavior) => owner._methodContainers.fileAction._openFile(filePath, behavior), - _compareFiles: paths => owner._methodContainers.fileAction._compareFiles(paths), _openSelectedNode: nodeId => owner._methodContainers.fileAction._openSelectedNode(nodeId), _activateNode: nodeId => owner._methodContainers.fileAction._activateNode(nodeId), _revealInExplorer: filePath => owner._methodContainers.fileAction._revealInExplorer(filePath), diff --git a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts index e7aeb5f880..4b7db3611a 100644 --- a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts +++ b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts @@ -29,7 +29,6 @@ export interface GraphViewPrimaryMessageContext { canOpenPath(filePath: string): boolean; setFocusedFile(filePath: string | undefined): void; openFile(filePath: string): Promise; - compareFiles(paths: readonly [string, string]): Promise; openInEditor(): void; revealInExplorer(filePath: string): Promise; copyToClipboard(text: string): Promise; diff --git a/packages/extension/src/extension/graphView/webview/dispatch/primaryState.ts b/packages/extension/src/extension/graphView/webview/dispatch/primaryState.ts index 17549d4ece..70b1b5db84 100644 --- a/packages/extension/src/extension/graphView/webview/dispatch/primaryState.ts +++ b/packages/extension/src/extension/graphView/webview/dispatch/primaryState.ts @@ -1,7 +1,9 @@ +import * as vscode from 'vscode'; import type { GraphViewPrimaryMessageContext } from './primary'; import type { GraphViewLegendMessageState } from '../messages/legends'; import type { GraphViewNodeFileHandlers } from '../nodeFile/router'; import type { GraphViewSettingsMessageState } from '../settingsMessages/router'; +import { compareGraphViewFiles } from '../../files/navigation'; export function createGraphViewPrimaryLegendMessageState( context: GraphViewPrimaryMessageContext, @@ -16,6 +18,10 @@ export function createGraphViewPrimaryNodeFileHandlers( ): GraphViewNodeFileHandlers { return { ...context, + compareFiles: paths => compareGraphViewFiles(paths, { + workspaceFolder: context.workspaceFolder, + executeCommand: (command, ...args) => vscode.commands.executeCommand(command, ...args), + }), indexGraph: async () => { context.cancelScheduledPluginGraphWork?.(); await context.indexAndSendData(); diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts b/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts index eaa7769a89..9e59da1429 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts @@ -109,7 +109,6 @@ export interface GraphViewProviderMessageListenerSource { _activateNode(nodeId: string): Promise; setFocusedFile(filePath: string | undefined): void; _openFile(filePath: string): Promise; - _compareFiles(paths: readonly [string, string]): Promise; _revealInExplorer(filePath: string): Promise; _copyToClipboard(text: string): Promise; _deleteFiles(paths: string[]): Promise; diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/fileActions.ts b/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/fileActions.ts index 604df084aa..d6242f7af9 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/fileActions.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/fileActions.ts @@ -13,7 +13,6 @@ type FileActions = Pick< | 'canOpenPath' | 'setFocusedFile' | 'openFile' - | 'compareFiles' | 'revealInExplorer' | 'copyToClipboard' | 'deleteFiles' @@ -32,10 +31,6 @@ export function createFileActions(source: GraphViewProviderMessageListenerSource canOpenPath: filePath => canOpenGraphPath(source, filePath), setFocusedFile: filePath => source.setFocusedFile(filePath), openFile: filePath => source._openFile(resolveGraphOpenPath(source, filePath)), - compareFiles: paths => source._compareFiles([ - resolveGraphOpenPath(source, paths[0]), - resolveGraphOpenPath(source, paths[1]), - ]), revealInExplorer: filePath => source._revealInExplorer(resolveGraphOpenPath(source, filePath)), copyToClipboard: text => source._copyToClipboard(text), deleteFiles: paths => source._deleteFiles(paths), diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/types.ts b/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/types.ts index 6fab511e77..3039459a1e 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/types.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/primaryActions/types.ts @@ -7,7 +7,6 @@ export type GraphViewProviderPrimaryActions = Pick< | 'canOpenPath' | 'setFocusedFile' | 'openFile' - | 'compareFiles' | 'openInEditor' | 'revealInExplorer' | 'copyToClipboard' diff --git a/packages/extension/tests/extension/graphView/provider/file/actions.test.ts b/packages/extension/tests/extension/graphView/provider/file/actions.test.ts index 1dcc42c2e1..b8f9ee5551 100644 --- a/packages/extension/tests/extension/graphView/provider/file/actions.test.ts +++ b/packages/extension/tests/extension/graphView/provider/file/actions.test.ts @@ -36,7 +36,6 @@ describe('graphView/provider/file/actions', () => { it('delegates file navigation helpers through their extracted provider wrappers', async () => { const openFile = vi.fn(async () => undefined); - const compareFiles = vi.fn(async () => undefined); const revealFile = vi.fn(async () => undefined); const copyText = vi.fn(async () => undefined); const source = { @@ -46,7 +45,6 @@ describe('graphView/provider/file/actions', () => { }; const methods = createGraphViewProviderFileActionMethods(source as never, { openFile, - compareFiles, revealFile, copyText, deleteFiles: vi.fn(async () => undefined), @@ -67,7 +65,6 @@ describe('graphView/provider/file/actions', () => { }); await methods._openFile('src/app.ts', { preview: true, preserveFocus: false }); - await methods._compareFiles(['src/app.ts', 'src/utils.ts']); await methods._revealInExplorer('src/app.ts'); await methods._copyToClipboard('hello'); @@ -81,7 +78,6 @@ describe('graphView/provider/file/actions', () => { }, ); expect(revealFile).toHaveBeenCalledWith('src/app.ts'); - expect(compareFiles).toHaveBeenCalledWith(['src/app.ts', 'src/utils.ts']); expect(copyText).toHaveBeenCalledWith('hello'); }); @@ -110,7 +106,6 @@ describe('graphView/provider/file/actions', () => { }; const methods = createGraphViewProviderFileActionMethods(source as never, { openFile: vi.fn(async () => undefined), - compareFiles: vi.fn(async () => undefined), revealFile: vi.fn(async () => undefined), copyText: vi.fn(async () => undefined), deleteFiles, @@ -175,7 +170,6 @@ describe('graphView/provider/file/actions', () => { }; const methods = createGraphViewProviderFileActionMethods(source as never, { openFile: vi.fn(async () => undefined), - compareFiles: vi.fn(async () => undefined), revealFile: vi.fn(async () => undefined), copyText: vi.fn(async () => undefined), deleteFiles: vi.fn(async () => undefined), @@ -228,7 +222,6 @@ describe('graphView/provider/file/actions', () => { }; const methods = createGraphViewProviderFileActionMethods(source as never, { openFile: vi.fn(async () => undefined), - compareFiles: vi.fn(async () => undefined), revealFile: vi.fn(async () => undefined), copyText: vi.fn(async () => undefined), deleteFiles: vi.fn(async () => undefined), @@ -287,7 +280,6 @@ describe('graphView/provider/file/actions', () => { }; const methods = createGraphViewProviderFileActionMethods(source as never, { openFile: vi.fn(async () => undefined), - compareFiles: vi.fn(async () => undefined), revealFile: vi.fn(async () => undefined), copyText: vi.fn(async () => undefined), deleteFiles: vi.fn(async () => undefined), @@ -455,7 +447,6 @@ async function createDefaultDependencyHarness( vi.resetModules(); const openFile = vi.fn(async () => undefined); - const compareFiles = vi.fn(async () => undefined); const revealFile = vi.fn(async () => undefined); const copyText = vi.fn(async () => undefined); const deleteFiles = vi.fn(async (_paths: string[], handlers: { @@ -590,7 +581,6 @@ async function createDefaultDependencyHarness( })); vi.doMock('../../../../../src/extension/graphView/provider/file/navigation', () => ({ openGraphViewProviderFile: openFile, - compareGraphViewProviderFiles: compareFiles, revealGraphViewProviderFileInExplorer: revealFile, copyGraphViewProviderTextToClipboard: copyText, })); @@ -640,7 +630,6 @@ async function createDefaultDependencyHarness( source, methods: createMethods(source as never), openFile, - compareFiles, revealFile, copyText, deleteFiles, diff --git a/packages/extension/tests/extension/graphView/provider/file/navigation.test.ts b/packages/extension/tests/extension/graphView/provider/file/navigation.test.ts index 80466c39ea..4fd81e5266 100644 --- a/packages/extension/tests/extension/graphView/provider/file/navigation.test.ts +++ b/packages/extension/tests/extension/graphView/provider/file/navigation.test.ts @@ -27,7 +27,6 @@ describe('graphView/provider/file/navigation', () => { openFile: vi.fn(async (_filePath, handlers) => { await handlers.didOpenFile?.('src/index.ts'); }), - compareFiles: vi.fn(), revealFile: vi.fn(), writeText: vi.fn(() => Promise.resolve()), copyText: vi.fn(), @@ -58,7 +57,6 @@ describe('graphView/provider/file/navigation', () => { openFile: vi.fn(async (_filePath, handlers) => { await handlers.didOpenFile?.('src/index.ts'); }), - compareFiles: vi.fn(), revealFile: vi.fn(), writeText: vi.fn(() => Promise.resolve()), copyText: vi.fn(), @@ -88,7 +86,6 @@ describe('graphView/provider/file/navigation', () => { openTextDocument: vi.fn(), showTextDocument: vi.fn(), openFile, - compareFiles: vi.fn(), logError: vi.fn(), } as never, ); @@ -132,7 +129,6 @@ describe('graphView/provider/file/navigation', () => { await handlers.showTextDocument(document, { preview: true, preserveFocus: false }); handlers.logError('open failed', 'boom'); }), - compareFiles: vi.fn(), revealFile: vi.fn(), writeText: vi.fn(() => Promise.resolve()), copyText: vi.fn(), diff --git a/packages/extension/tests/extension/graphView/webview/dispatch/context.ts b/packages/extension/tests/extension/graphView/webview/dispatch/context.ts index cbd718bdc1..908e834985 100644 --- a/packages/extension/tests/extension/graphView/webview/dispatch/context.ts +++ b/packages/extension/tests/extension/graphView/webview/dispatch/context.ts @@ -16,7 +16,6 @@ export function createPrimaryMessageContext( activateNode: vi.fn(() => Promise.resolve()), setFocusedFile: vi.fn(), openFile: vi.fn(() => Promise.resolve()), - compareFiles: vi.fn(() => Promise.resolve()), revealInExplorer: vi.fn(() => Promise.resolve()), copyToClipboard: vi.fn(() => Promise.resolve()), deleteFiles: vi.fn(() => Promise.resolve()), diff --git a/packages/extension/tests/webview/graph/contextMenu/build/edge.test.ts b/packages/extension/tests/webview/graph/contextMenu/build/edge.test.ts index cea1408751..9590329958 100644 --- a/packages/extension/tests/webview/graph/contextMenu/build/edge.test.ts +++ b/packages/extension/tests/webview/graph/contextMenu/build/edge.test.ts @@ -5,6 +5,18 @@ import { } from '../../../../../src/webview/components/graph/contextMenu/selection'; describe('graph/contextMenu/build/edge', () => { + it('does not show Compare Selected for a Relationship context', () => { + const entries = buildGraphContextMenuEntries({ + selection: makeEdgeContextSelection('src/a.ts->src/b.ts', 'src/a.ts', 'src/b.ts'), + favorites: new Set(), + }); + + expect(entries).not.toContainEqual(expect.objectContaining({ + kind: 'item', + label: 'Compare Selected', + })); + }); + it('builds edge open and copy actions', () => { const entries = buildGraphContextMenuEntries({ selection: makeEdgeContextSelection('src/a.ts->src/b.ts', 'src/a.ts', 'src/b.ts'), diff --git a/packages/extension/tests/webview/graph/contextMenu/build/node.test.ts b/packages/extension/tests/webview/graph/contextMenu/build/node.test.ts index b18ff30ddb..dcb3a737bf 100644 --- a/packages/extension/tests/webview/graph/contextMenu/build/node.test.ts +++ b/packages/extension/tests/webview/graph/contextMenu/build/node.test.ts @@ -130,14 +130,6 @@ describe('graph/contextMenu/build/node', () => { { id: 'src/a.ts', nodeType: 'file' }, { id: 'src', nodeType: 'folder' }, ])).not.toContain('Compare Selected'); - expect(itemLabels(buildGraphContextMenuEntries({ - selection: { - kind: 'edge', - edgeId: 'src/a.ts->src/b.ts', - targets: ['src/a.ts', 'src/b.ts'], - }, - favorites: new Set(), - }))).not.toContain('Compare Selected'); }); it('keeps mixed selections with plugin nodes on generic public actions', () => { diff --git a/packages/extension/tests/webview/graph/contextMenu/node/view/selectionActions.test.tsx b/packages/extension/tests/webview/graph/contextMenu/node/view/selectionActions.test.tsx index d60a982242..cb31d361d9 100644 --- a/packages/extension/tests/webview/graph/contextMenu/node/view/selectionActions.test.tsx +++ b/packages/extension/tests/webview/graph/contextMenu/node/view/selectionActions.test.tsx @@ -7,6 +7,7 @@ import { getGraphContainer, getSentMessages, Graph, + OwnedGraphSurface, render, screen, selectionData, @@ -89,6 +90,32 @@ describe('Graph node context menu selection actions', () => { }); expect(findMessage('DELETE_FILES')?.payload.paths).toEqual(['nodeA.ts', 'nodeB.ts']); }); + + it('keeps A then B selection order when the menu opens on B', async () => { + const graphContainer = renderSelectionGraph(); + + await act(async () => { + OwnedGraphSurface.simulateNodeClick({ id: 'nodeA.ts' }); + OwnedGraphSurface.simulateNodeClick( + { id: 'nodeB.ts' }, + { button: 0, ctrlKey: true }, + ); + }); + await act(async () => { + OwnedGraphSurface.simulateNodeRightClick({ id: 'nodeB.ts' }); + fireEvent.contextMenu(graphContainer, { clientX: 180, clientY: 160 }); + }); + await waitFor(() => { + expect(screen.getByText('Compare Selected')).toBeInTheDocument(); + }); + + clearSentMessages(); + await act(async () => { + fireEvent.click(screen.getByText('Compare Selected')); + }); + + expect(findMessage('COMPARE_FILES')?.payload.paths).toEqual(['nodeA.ts', 'nodeB.ts']); + }); }); function renderSelectionGraph(): HTMLElement { From 974b29374fe56ee7cd62813e0788971026f49bb0 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 7 Aug 2026 07:55:00 -0700 Subject: [PATCH 4/4] Update Compare Selected menu contract --- .../tests/webview/graph/contextMenu/model.builtins.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/extension/tests/webview/graph/contextMenu/model.builtins.test.ts b/packages/extension/tests/webview/graph/contextMenu/model.builtins.test.ts index 91ad48c109..88ba8d15d0 100644 --- a/packages/extension/tests/webview/graph/contextMenu/model.builtins.test.ts +++ b/packages/extension/tests/webview/graph/contextMenu/model.builtins.test.ts @@ -94,6 +94,7 @@ describe('built-in graph context menus', () => { }); expect(menuLabels(entries)).toEqual([ 'Open 2 Files', + 'Compare Selected', 'Copy Relative Paths', 'Add All to Favorites', 'Add Filter Patterns', @@ -136,6 +137,7 @@ describe('built-in graph context menus', () => { }); expect(builtInActions(multiLive)).toEqual([ 'open', + 'compare', 'copyRelative', 'toggleFavorite', 'addToFilter',