Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-files-compare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codegraphy-dev/extension": patch
---

Add a Compare Selected action for exactly two selected File Nodes.
16 changes: 14 additions & 2 deletions packages/extension/src/extension/graphView/files/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,23 @@ export interface GraphViewClipboardHandlers {
writeText(text: string): PromiseLike<void>;
}

export interface GraphViewExplorerHandlers {
export interface GraphViewCommandHandlers {
workspaceFolder?: GraphViewWorkspaceFolderRef;
executeCommand(command: string, ...args: unknown[]): PromiseLike<unknown>;
}

export async function compareGraphViewFiles(
paths: readonly [string, string],
handlers: GraphViewCommandHandlers,
): Promise<void> {
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,
Expand Down Expand Up @@ -64,7 +76,7 @@ export async function openGraphViewFile(

export async function revealGraphViewFileInExplorer(
filePath: string,
handlers: GraphViewExplorerHandlers,
handlers: GraphViewCommandHandlers,
): Promise<void> {
if (!handlers.workspaceFolder) return;

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { WebviewToExtensionMessage } from '../../../../shared/protocol/webviewToExtension';

export interface GraphViewNodeFileNavigationHandlers {
compareFiles(paths: readonly [string, string]): Promise<void>;
revealInExplorer(filePath: string): Promise<void>;
copyToClipboard(text: string): Promise<void>;
indexGraph(): Promise<void>;
Expand All @@ -13,6 +14,10 @@ export async function applyNodeFileNavigationMessage(
handlers: GraphViewNodeFileNavigationHandlers,
): Promise<boolean> {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { GraphContextActionContext } from '../context';
import type { GraphContextEffect } from '../effects';
import {
createClipboardEffects,
createCompareFilesEffects,
createCreateFileEffects,
createCreateFolderEffects,
createOptionalClipboardEffects,
Expand All @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<GraphContextMenuDecision, { kind: 'background' | 'edge' | 'emptyNodeSelection' | 'singleFolderNode' | 'singleSymbolNode' }>): readonly string[] {
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ type GraphViewContextMenuContribution = ExtensionGraphViewContributionSet['conte

export type BuiltInContextMenuAction =
| 'open'
| 'compare'
| 'openEdgeSource'
| 'openEdgeTarget'
| 'reveal'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface GraphContextMenuEffectRuntime {

const NODE_ACTIONS = new Set<BuiltInContextMenuAction>([
'open',
'compare',
'reveal',
'copyRelative',
'copyAbsolute',
Expand Down
Original file line number Diff line number Diff line change
@@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ function createHandlers(
overrides: Partial<GraphViewNodeFileNavigationHandlers> = {},
): GraphViewNodeFileNavigationHandlers {
return {
compareFiles: vi.fn(() => Promise.resolve()),
revealInExplorer: vi.fn(() => Promise.resolve()),
copyToClipboard: vi.fn(() => Promise.resolve()),
indexGraph: vi.fn(() => Promise.resolve()),
Expand All @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,48 @@ 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',
'Delete 2 Files',
]);
});

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'])),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -136,6 +137,7 @@ describe('built-in graph context menus', () => {
});
expect(builtInActions(multiLive)).toEqual([
'open',
'compare',
'copyRelative',
'toggleFavorite',
'addToFilter',
Expand Down
Loading