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
35 changes: 18 additions & 17 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@
"(([ (attribute_item) (line_comment) ] @header . [ (attribute_item) (line_comment) ]* @header )? . (struct_item) @item)",
"(([ (attribute_item) (line_comment) ] @header . [ (attribute_item) (line_comment) ]* @header )? . (impl_item) @item)",
"(([ (attribute_item) (line_comment) ] @header . [ (attribute_item) (line_comment) ]* @header )? . (enum_item) @item)",
"(([ (attribute_item) (line_comment) ] @header . [ (attribute_item) (line_comment) ]* @header )? . (field_declaration) @item)",
"(match_arm) @arm"
]
},
Expand Down Expand Up @@ -352,7 +353,7 @@
}
},
"scripts": {
"install:all": "yarn install && cd webview-ui && yarn install",
"install:all": "yarn install --frozen-lockfile && cd webview-ui && yarn install --frozen-lockfile",
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --frozen-lockfile flag is a good addition for CI/CD environments, but this change may cause local development issues if developers need to update dependencies. Consider documenting this behavior change or creating separate scripts for CI vs local development (e.g., install:all:ci for frozen installs).

Suggested change
"install:all": "yarn install --frozen-lockfile && cd webview-ui && yarn install --frozen-lockfile",
// For local development, use 'install:all'. For CI/CD, use 'install:all:ci'.
"install:all": "yarn install && cd webview-ui && yarn install",
"install:all:ci": "yarn install --frozen-lockfile && cd webview-ui && yarn install --frozen-lockfile",

Copilot uses AI. Check for mistakes.
"start:webview": "cd webview-ui && yarn run dev",
"build:webview": "cd webview-ui && yarn run build && yarn run check",
"vscode:prepublish": "yarn run esbuild-base --minify",
Expand All @@ -366,26 +367,26 @@
"lint": "tsc --noEmit && eslint src --ext ts"
},
"devDependencies": {
"@types/chai": "^4.3.5",
"@types/glob": "^8.0.1",
"@types/mocha": "^10.0.1",
"@types/node": "^20.3.3",
"@types/tar": "^6.1.5",
"@types/vscode": "^1.79.0",
"@types/which": "^3.0.0",
"@types/chai": "5.2.3",
"@types/glob": "8.0.1",
"@types/mocha": "10.0.10",
"@types/node": "24.9.2",
"@types/tar": "6.1.13",
"@types/vscode": "1.105.0",
"@types/which": "3.0.4",
"@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.49.0",
"@vscode/test-electron": "^2.2.2",
"chai": "^4.3.7",
"esbuild": "^0.18.11",
"@vscode/test-electron": "2.5.2",
"chai": "6.2.0",
"esbuild": "0.25.11",
"eslint": "^8.44.0",
"glob": "^8.1.0",
"mocha": "^10.2.0",
"typescript": "^5.1.6"
"glob": "8.1.0",
"mocha": "11.7.4",
"typescript": "5.9.3"
},
"dependencies": {
"tar": "^6.1.15",
"tree-sitter": "^0.22.0",
"which": "^3.0.1"
"tar": "7.5.2",
"tree-sitter": "0.25.0",
"which": "5.0.0"
}
}
276 changes: 216 additions & 60 deletions src/BlockMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,30 @@ function selectBlock(): void {
return;
}

const cursorIndex = activeEditor.document.offsetAt(activeEditor.selection.active);
const selection = fileTree.selectBlock(cursorIndex);
if (selection !== undefined) {
activeEditor.selection = selection.toVscodeSelection();
activeEditor.revealRange(
activeEditor.selection,
vscode.TextEditorRevealType.InCenterIfOutsideViewport
);
const bases = activeEditor.selections.length ? activeEditor.selections : [activeEditor.selection];
const nextSelections = bases
.map((s) => {
const idx = activeEditor.document.offsetAt(s.active);
const sel = fileTree.selectBlock(idx);
return sel?.toVscodeSelection();
})
.filter((s): s is vscode.Selection => !!s);

if (nextSelections.length === 0) {
return;
}

const merged = mergeSelections(nextSelections);
if (merged.length === 1) {
activeEditor.selection = merged[0];
} else {
activeEditor.selections = merged;
}

activeEditor.revealRange(
merged[0] ?? activeEditor.selection,
vscode.TextEditorRevealType.InCenterIfOutsideViewport
);
}

function updateSelection(direction: UpdateSelectionDirection): void {
Expand All @@ -62,15 +77,34 @@ function updateSelection(direction: UpdateSelectionDirection): void {
return;
}

const selection = fileTree.resolveVscodeSelection(activeEditor.selection);
if (selection !== undefined) {
const bases = activeEditor.selections.length ? activeEditor.selections : [activeEditor.selection];
const updatedSelections: vscode.Selection[] = [];

for (const base of bases) {
const selection = fileTree.resolveVscodeSelection(base);
if (selection === undefined) {
continue;
}

selection.update(direction, fileTree.blocks);
activeEditor.selection = selection.toVscodeSelection();
activeEditor.revealRange(
activeEditor.selection,
vscode.TextEditorRevealType.InCenterIfOutsideViewport
);
updatedSelections.push(selection.toVscodeSelection());
}

if (updatedSelections.length === 0) {
return;
}

const merged = mergeSelections(updatedSelections);
if (merged.length === 1) {
activeEditor.selection = merged[0];
} else {
activeEditor.selections = merged;
}

activeEditor.revealRange(
merged[0] ?? activeEditor.selection,
vscode.TextEditorRevealType.InCenterIfOutsideViewport
);
}

async function moveSelection(direction: MoveSelectionDirection): Promise<void> {
Expand All @@ -80,23 +114,55 @@ async function moveSelection(direction: MoveSelectionDirection): Promise<void> {
return;
}

const selection = fileTree.resolveVscodeSelection(activeEditor.selection);
if (selection === undefined) {
return;
}
const bases = activeEditor.selections.length ? activeEditor.selections : [activeEditor.selection];

const result = await fileTree.moveSelection(selection, direction);
switch (result.status) {
case "ok":
// Single-selection: preserve existing UX
if (bases.length === 1) {
const selection = fileTree.resolveVscodeSelection(bases[0]);
if (selection === undefined) {
return;
}

const result = await fileTree.moveSelection(selection, direction);
if (result.status === "ok") {
activeEditor.selection = result.result;
activeEditor.revealRange(result.result, vscode.TextEditorRevealType.InCenterIfOutsideViewport);
break;

case "err":
// TODO: add this as a text box above the cursor (can vscode do that?)
} else {
getLogger().log(result.result);
}

break;
return;
}

// Multi-selection: order moves to reduce interference
const order = bases.map((_, i) => i);
order.sort((i, j) => {
const a = bases[i].start;
const b = bases[j].start;
const cmp = a.line - b.line || a.character - b.character;
return direction === "swap-next" ? -cmp : cmp; // down: bottom->top, up: top->bottom
});

const results: (vscode.Selection | undefined)[] = bases.slice();
for (const i of order) {
const current = results[i] ?? bases[i];
const selection = fileTree.resolveVscodeSelection(current);
if (selection === undefined) {
continue;
}

const res = await fileTree.moveSelection(selection, direction);
if (res.status === "ok") {
results[i] = res.result;
} else {
getLogger().log(res.result);
}
}

const finalSelections = results.filter((s): s is vscode.Selection => !!s);
if (finalSelections.length) {
activeEditor.selections = finalSelections;
activeEditor.revealRange(finalSelections[0], vscode.TextEditorRevealType.InCenterIfOutsideViewport);
}
}

Expand All @@ -108,43 +174,133 @@ function navigate(direction: "up" | "down" | "left" | "right"): void {
return;
}

const selection = fileTree.resolveVscodeSelection(activeEditor.selection);
const bases = activeEditor.selections.length ? activeEditor.selections : [activeEditor.selection];
const blocks = fileTree.blocks;
const parent = selection?.getParent(blocks);
const previous = selection?.getPrevious(blocks);
const next = selection?.getNext(blocks);

let newPosition;
switch (direction) {
case "up":
if (parent) {
newPosition = parent.toVscodeSelection().start;
}
break;
case "down":
if (parent) {
newPosition = parent.toVscodeSelection().end;
}
break;
case "left":
if (previous) {
newPosition = previous.toVscodeSelection().start;
}
break;
case "right":
if (next) {
newPosition = next.toVscodeSelection().start;
}
break;
const nextCursors: vscode.Selection[] = [];

for (const base of bases) {
const selection = fileTree.resolveVscodeSelection(base);
if (selection === undefined) {
continue;
}

const parent = selection.getParent(blocks);
const previous = selection.getPrevious(blocks);
const next = selection.getNext(blocks);

let newPosition: vscode.Position | undefined;
switch (direction) {
case "up":
if (parent) {
newPosition = parent.toVscodeSelection().start;
}
break;
case "down":
if (parent) {
newPosition = parent.toVscodeSelection().end;
}
break;
case "left":
if (previous) {
newPosition = previous.toVscodeSelection().start;
}
break;
case "right":
if (next) {
newPosition = next.toVscodeSelection().start;
}
break;
}

if (newPosition) {
nextCursors.push(new vscode.Selection(newPosition, newPosition));
}
}

if (nextCursors.length === 0) {
return;
}

const deduped = dedupeSelections(nextCursors);
activeEditor.selections = deduped;
activeEditor.revealRange(deduped[0], vscode.TextEditorRevealType.InCenterIfOutsideViewport);
}

/**
* Merge overlapping or touching selections (used to keep UX tidy).
*/
function mergeSelections(selections: vscode.Selection[]): vscode.Selection[] {
if (selections.length <= 1) {
return selections;
}

const ranges = selections.map((s) => new vscode.Range(s.start, s.end));
ranges.sort((a, b) => {
if (a.start.isBefore(b.start)) {
return -1;
}
if (a.start.isAfter(b.start)) {
return 1;
}
if (a.end.isBefore(b.end)) {
return -1;
}
if (a.end.isAfter(b.end)) {
return 1;
}
return 0;
});

const merged: vscode.Range[] = [];
for (const r of ranges) {
const last = merged.at(-1);
if (last === undefined) {
merged.push(r);
} else if (!r.start.isAfter(last.end)) {
const end = r.end.isAfter(last.end) ? r.end : last.end;
merged[merged.length - 1] = new vscode.Range(last.start, end);
} else {
merged.push(r);
}
}

if (newPosition) {
activeEditor.selection = new vscode.Selection(newPosition, newPosition);
activeEditor.revealRange(
activeEditor.selection,
vscode.TextEditorRevealType.InCenterIfOutsideViewport
);
return merged.map((r) => new vscode.Selection(r.start, r.end));
}

/**
* De-duplicate selections while preserving order.
*/
function dedupeSelections(selections: vscode.Selection[]): vscode.Selection[] {
if (selections.length <= 1) {
return selections;
}

selections.sort((a, b) => {
if (a.start.isBefore(b.start)) {
return -1;
}
if (a.start.isAfter(b.start)) {
return 1;
}
if (a.end.isBefore(b.end)) {
return -1;
}
if (a.end.isAfter(b.end)) {
return 1;
}
return 0;
});

const seen = new Set<string>();
const out: vscode.Selection[] = [];
for (const s of selections) {
const key = `${s.start.line}:${s.start.character}-${s.end.line}:${s.end.character}`;
if (!seen.has(key)) {
seen.add(key);
out.push(s);
}
}
return out;
}

function updateTargetHighlights(editor: vscode.TextEditor, vscodeSelection: vscode.Selection): void {
Expand Down
2 changes: 1 addition & 1 deletion src/FileTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export class FileTree implements vscode.Disposable {

const queryStrings = getLanguageConfig(document.languageId).queries;
if (queryStrings !== undefined) {
const language = parser.getLanguage() as Language;
const language = parser.getLanguage();
this.queries = queryStrings.map((q) => new Query(language, q));
this.blocks = getQueryBlocks(this.tree.rootNode, this.queries);
}
Expand Down
Loading
Loading