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
10 changes: 10 additions & 0 deletions apps/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@
"command": "codev.forwardCurrentHunkToBuilder",
"title": "Codev: Forward Current Hunk to Builder"
},
{
"command": "codev.forwardCursorContextToBuilder",
"title": "Codev: Forward Symbol / Hunk at Cursor to Builder"
},
{
"command": "codev.openArchitectTerminal",
"title": "Codev: Open Architect Terminal"
Expand Down Expand Up @@ -919,6 +923,12 @@
"mac": "cmd+k b",
"when": "codev.activeEditorIsBuilderFile && editorHasSelection"
},
{
"command": "codev.forwardCursorContextToBuilder",
"key": "ctrl+k h",
"mac": "cmd+k h",
"when": "codev.activeEditorIsBuilderFile && editorTextFocus"
},
{
"command": "codev.openIssueById",
"key": "ctrl+k i",
Expand Down
70 changes: 70 additions & 0 deletions apps/vscode/src/__tests__/diff-inject-ref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
buildSymbolLensDescriptors,
buildAllLensDescriptors,
parseHunkRanges,
resolveCursorRef,
type SymbolNode,
} from '../diff-inject-ref.js';

Expand Down Expand Up @@ -179,3 +180,72 @@ describe('buildAllLensDescriptors (symbol + change lenses)', () => {
]);
});
});

describe('resolveCursorRef (symbol → hunk → file)', () => {
it('resolves the cursor to its enclosing top-level symbol', () => {
const symbols = [sym(K.Function, 4, 9)]; // L5-L10
// Cursor on the body (line 7, 1-based) → the function range.
expect(resolveCursorRef('a/b.ts', symbols, [], 7)).toEqual({
kind: 'symbol',
refText: 'a/b.ts:L5-L10 ',
range: { start: 5, end: 10 },
});
});

it('resolves the declaration line and the body line to the same symbol', () => {
const symbols = [sym(K.Function, 4, 9)]; // L5-L10
expect(resolveCursorRef('a/b.ts', symbols, [], 5).refText).toBe('a/b.ts:L5-L10 '); // decl line
expect(resolveCursorRef('a/b.ts', symbols, [], 9).refText).toBe('a/b.ts:L5-L10 '); // last line
});

it('picks the most specific symbol: a method inside a class beats the class', () => {
const cls = sym(K.Class, 3, 40, [
sym(K.Method, 10, 20), // L11-L21
]);
// Cursor at line 15 is inside both the class (L4-L41) and the method (L11-L21).
expect(resolveCursorRef('a/b.ts', [cls], [], 15)).toEqual({
kind: 'symbol',
refText: 'a/b.ts:L11-L21 ',
range: { start: 11, end: 21 },
});
// Cursor at line 5 is in the class but outside the method → the class.
expect(resolveCursorRef('a/b.ts', [cls], [], 5).refText).toBe('a/b.ts:L4-L41 ');
});

it('falls back to the containing hunk when no symbol covers the cursor', () => {
// No forwardable symbol at the cursor; a changed range does cover it.
expect(resolveCursorRef('a/b.ts', [], [{ start: 30, end: 42 }], 35)).toEqual({
kind: 'hunk',
refText: 'a/b.ts:L30-L42 ',
range: { start: 30, end: 42 },
});
});

it('prefers the symbol over the hunk when both cover the cursor (order)', () => {
const symbols = [sym(K.Function, 4, 9)]; // L5-L10
// A hunk also spans the cursor line, but symbol resolution wins.
expect(resolveCursorRef('a/b.ts', symbols, [{ start: 1, end: 20 }], 7)).toEqual({
kind: 'symbol',
refText: 'a/b.ts:L5-L10 ',
range: { start: 5, end: 10 },
});
});

it('falls back to the bare file path when neither a symbol nor a hunk covers the cursor', () => {
const symbols = [sym(K.Function, 4, 9)]; // L5-L10
// Cursor on an unchanged context line outside every symbol and hunk.
expect(resolveCursorRef('a/b.ts', symbols, [{ start: 30, end: 42 }], 25)).toEqual({
kind: 'file',
refText: 'a/b.ts ',
});
});

it('resolves a symbol on a new-file diff (symbols present, no hunks)', () => {
const symbols = [sym(K.Function, 4, 9)]; // L5-L10
expect(resolveCursorRef('a/b.ts', symbols, [], 6)).toEqual({
kind: 'symbol',
refText: 'a/b.ts:L5-L10 ',
range: { start: 5, end: 10 },
});
});
});
5 changes: 3 additions & 2 deletions apps/vscode/src/diff-inject-codelens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ export interface DiffInjectSessionEntry {
hunks: ChangedRange[];
}

/** Map a `vscode.DocumentSymbol` tree to the pure `SymbolNode` shape. */
function toSymbolNode(s: vscode.DocumentSymbol): SymbolNode {
/** Map a `vscode.DocumentSymbol` tree to the pure `SymbolNode` shape. Exported
* so the cursor-context forward command (#1073) can reuse the same mapper. */
export function toSymbolNode(s: vscode.DocumentSymbol): SymbolNode {
return {
kind: s.kind as number,
startLine: s.range.start.line,
Expand Down
52 changes: 52 additions & 0 deletions apps/vscode/src/diff-inject-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,55 @@ export function buildAllLensDescriptors(
}
return lenses;
}

/**
* The reference resolved for a cursor sitting on a given line — the keyboard
* equivalent of clicking a "Forward to Builder" lens (#1073). `kind` records
* which resolution step fired so the command handler can surface a status-bar
* note on the bare-file fallback.
*/
export type CursorRef =
| { kind: 'symbol' | 'hunk'; refText: string; range: ChangedRange }
| { kind: 'file'; refText: string };

/**
* Resolve the reference to forward for a cursor on `cursorLine` (1-based,
* new-side). Resolution order (locked by #1073):
*
* 1. **Symbol** — the most specific *forwardable* symbol whose range contains
* the cursor. "Forwardable" is exactly the symbol set the codelens exposes
* (`buildSymbolLensDescriptors`), so the keyboard lands on the same range a
* lens click would; among overlapping candidates the smallest span wins (a
* method inside a class beats the class).
* 2. **Hunk** — the changed range containing the cursor (the registry entry's
* new-side 1-based ranges).
* 3. **File** — the bare file path, when neither covers the cursor.
*/
export function resolveCursorRef(
relPath: string,
symbols: SymbolNode[],
hunks: ChangedRange[],
cursorLine: number,
): CursorRef {
let best: ChangedRange | undefined;
// `buildSymbolLensDescriptors` skips a symbol anchored on line 0 (it collides
// with the file-level lens), so a declaration starting on file line 1 has no
// symbol candidate here and falls through to the hunk/file steps — the same
// "keyboard == codelens click" gap the lens itself has.
for (const lens of buildSymbolLensDescriptors(relPath, symbols)) {
const range = lens.range;
if (!range) { continue; } // the file-level lens has no range
if (cursorLine < range.start || cursorLine > range.end) { continue; }
if (!best || range.end - range.start < best.end - best.start) { best = range; }
}
if (best) {
return { kind: 'symbol', refText: buildBuilderRangeRef(relPath, best.start, best.end), range: best };
}

const hunk = hunks.find(h => cursorLine >= h.start && cursorLine <= h.end);
if (hunk) {
return { kind: 'hunk', refText: buildBuilderRangeRef(relPath, hunk.start, hunk.end), range: hunk };
}

return { kind: 'file', refText: buildBuilderFileRef(relPath) };
}
30 changes: 28 additions & 2 deletions apps/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import { cleanupBuilder } from './commands/cleanup.js';
import { openWorktreeWindow } from './commands/open-worktree-window.js';
import { viewDiff, activateDiffView, openBuilderFileDiff } from './commands/view-diff.js';
import { navigateDiff, navigateDiffToFirst, navigateBuilderDiffToFirst, diffFirstHunk, recordDiffNavPosition } from './commands/diff-nav.js';
import { activateDiffInjectCodeLens, getDiffInjectEntry, onDidChangeDiffInjectRegistry } from './diff-inject-codelens.js';
import { activateDiffInjectCodeLens, getDiffInjectEntry, onDidChangeDiffInjectRegistry, toSymbolNode } from './diff-inject-codelens.js';
import { isStandaloneTextTab } from './diff-tab-input.js';
import { buildBuilderRangeRef, buildBuilderFileRef } from './diff-inject-ref.js';
import { buildBuilderRangeRef, buildBuilderFileRef, resolveCursorRef } from './diff-inject-ref.js';
import { runWorktreeDev } from './commands/run-worktree-dev.js';
import { stopWorktreeDev } from './commands/stop-worktree-dev.js';
import { runWorkspaceDev, stopWorkspaceDev } from './commands/run-workspace-dev.js';
Expand Down Expand Up @@ -1238,6 +1238,32 @@ export async function activate(context: vscode.ExtensionContext) {
await vscode.commands.executeCommand(
'codev.forwardToBuilder', entry.builderId, buildBuilderRangeRef(entry.relPath, hunk.start, hunk.end));
}),
// Keyboard equivalent of a codelens click (#1073): forward whatever covers
// the cursor — the most specific enclosing symbol first, else the changed
// hunk, else the bare file path. Bound to Cmd/Ctrl+K H; `when` scopes it to
// builder-diff files with `editorTextFocus` (cursor only, no selection).
// All resolution lives in the pure `resolveCursorRef`; this handler only
// fetches the live symbols and reuses the shared `forwardToBuilder` inject
// path (no Enter, focus stays on the diff editor).
reg('codev.forwardCursorContextToBuilder', async () => {
const editor = vscode.window.activeTextEditor;
if (!editor) { return; }
const entry = getDiffInjectEntry(editor.document.uri.fsPath);
if (!entry) { return; }
const cursorLine = editor.selection.active.line + 1; // 1-based new-side
let symbols: vscode.DocumentSymbol[] = [];
try {
symbols = (await vscode.commands.executeCommand<vscode.DocumentSymbol[]>(
'vscode.executeDocumentSymbolProvider', editor.document.uri)) ?? [];
} catch {
symbols = [];
}
const resolved = resolveCursorRef(entry.relPath, symbols.map(toSymbolNode), entry.hunks, cursorLine);
if (resolved.kind === 'file') {
vscode.window.setStatusBarMessage('Codev: forwarded file path (no symbol or hunk at cursor)', 3000);
}
await vscode.commands.executeCommand('codev.forwardToBuilder', entry.builderId, resolved.refText);
}),
reg('codev.openBuilderFileDiff', async (arg: unknown) => {
if (!(arg instanceof BuilderFileTreeItem)) { return; }
await openBuilderFileDiff(context, {
Expand Down
Loading
Loading