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..0811c5a0a0 100644 --- a/packages/extension/src/extension/graphView/files/navigation.ts +++ b/packages/extension/src/extension/graphView/files/navigation.ts @@ -28,11 +28,23 @@ export interface GraphViewClipboardHandlers { writeText(text: string): PromiseLike; } -export interface GraphViewExplorerHandlers { +export interface GraphViewCommandHandlers { workspaceFolder?: GraphViewWorkspaceFolderRef; executeCommand(command: string, ...args: unknown[]): PromiseLike; } +export async function compareGraphViewFiles( + paths: readonly [string, string], + handlers: GraphViewCommandHandlers, +): 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, @@ -64,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/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/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/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/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/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 78ad5ae32f..dcb3a737bf 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,37 @@ 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(['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' }, + ])).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/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', 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..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, @@ -28,6 +29,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 +38,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 +56,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 () => { @@ -81,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 {