Skip to content

Commit 82ce179

Browse files
CB2Moonselfint
andauthored
Add multi-cursor support to block mode (#188)
When multiple cursors or selections exist, each is processed independently, merges selections on overlap. Changes: - selectBlock: Maps over all selections to find blocks at each cursor - updateSelection: Updates each selection independently (parent/child/next/prev) - moveSelection: Orders moves to prevent interference, handles single vs multi - navigate: Collects navigation targets for all cursors Co-authored-by: CB2Moon <cbwang404@foxmail.com>, Tom Selfin <selfint@gmail.com>
1 parent b72b8c8 commit 82ce179

45 files changed

Lines changed: 526260 additions & 518550 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package.json

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@
285285
"(([ (attribute_item) (line_comment) ] @header . [ (attribute_item) (line_comment) ]* @header )? . (struct_item) @item)",
286286
"(([ (attribute_item) (line_comment) ] @header . [ (attribute_item) (line_comment) ]* @header )? . (impl_item) @item)",
287287
"(([ (attribute_item) (line_comment) ] @header . [ (attribute_item) (line_comment) ]* @header )? . (enum_item) @item)",
288+
"(([ (attribute_item) (line_comment) ] @header . [ (attribute_item) (line_comment) ]* @header )? . (field_declaration) @item)",
288289
"(match_arm) @arm"
289290
]
290291
},
@@ -352,7 +353,7 @@
352353
}
353354
},
354355
"scripts": {
355-
"install:all": "yarn install && cd webview-ui && yarn install",
356+
"install:all": "yarn install --frozen-lockfile && cd webview-ui && yarn install --frozen-lockfile",
356357
"start:webview": "cd webview-ui && yarn run dev",
357358
"build:webview": "cd webview-ui && yarn run build && yarn run check",
358359
"vscode:prepublish": "yarn run esbuild-base --minify",
@@ -366,26 +367,26 @@
366367
"lint": "tsc --noEmit && eslint src --ext ts"
367368
},
368369
"devDependencies": {
369-
"@types/chai": "^4.3.5",
370-
"@types/glob": "^8.0.1",
371-
"@types/mocha": "^10.0.1",
372-
"@types/node": "^20.3.3",
373-
"@types/tar": "^6.1.5",
374-
"@types/vscode": "^1.79.0",
375-
"@types/which": "^3.0.0",
370+
"@types/chai": "5.2.3",
371+
"@types/glob": "8.0.1",
372+
"@types/mocha": "10.0.10",
373+
"@types/node": "24.9.2",
374+
"@types/tar": "6.1.13",
375+
"@types/vscode": "1.105.0",
376+
"@types/which": "3.0.4",
376377
"@typescript-eslint/eslint-plugin": "^5.49.0",
377378
"@typescript-eslint/parser": "^5.49.0",
378-
"@vscode/test-electron": "^2.2.2",
379-
"chai": "^4.3.7",
380-
"esbuild": "^0.18.11",
379+
"@vscode/test-electron": "2.5.2",
380+
"chai": "6.2.0",
381+
"esbuild": "0.25.11",
381382
"eslint": "^8.44.0",
382-
"glob": "^8.1.0",
383-
"mocha": "^10.2.0",
384-
"typescript": "^5.1.6"
383+
"glob": "8.1.0",
384+
"mocha": "11.7.4",
385+
"typescript": "5.9.3"
385386
},
386387
"dependencies": {
387-
"tar": "^6.1.15",
388-
"tree-sitter": "^0.22.0",
389-
"which": "^3.0.1"
388+
"tar": "7.5.2",
389+
"tree-sitter": "0.25.0",
390+
"which": "5.0.0"
390391
}
391392
}

src/BlockMode.ts

Lines changed: 216 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,30 @@ function selectBlock(): void {
4343
return;
4444
}
4545

46-
const cursorIndex = activeEditor.document.offsetAt(activeEditor.selection.active);
47-
const selection = fileTree.selectBlock(cursorIndex);
48-
if (selection !== undefined) {
49-
activeEditor.selection = selection.toVscodeSelection();
50-
activeEditor.revealRange(
51-
activeEditor.selection,
52-
vscode.TextEditorRevealType.InCenterIfOutsideViewport
53-
);
46+
const bases = activeEditor.selections.length ? activeEditor.selections : [activeEditor.selection];
47+
const nextSelections = bases
48+
.map((s) => {
49+
const idx = activeEditor.document.offsetAt(s.active);
50+
const sel = fileTree.selectBlock(idx);
51+
return sel?.toVscodeSelection();
52+
})
53+
.filter((s): s is vscode.Selection => !!s);
54+
55+
if (nextSelections.length === 0) {
56+
return;
57+
}
58+
59+
const merged = mergeSelections(nextSelections);
60+
if (merged.length === 1) {
61+
activeEditor.selection = merged[0];
62+
} else {
63+
activeEditor.selections = merged;
5464
}
65+
66+
activeEditor.revealRange(
67+
merged[0] ?? activeEditor.selection,
68+
vscode.TextEditorRevealType.InCenterIfOutsideViewport
69+
);
5570
}
5671

5772
function updateSelection(direction: UpdateSelectionDirection): void {
@@ -62,15 +77,34 @@ function updateSelection(direction: UpdateSelectionDirection): void {
6277
return;
6378
}
6479

65-
const selection = fileTree.resolveVscodeSelection(activeEditor.selection);
66-
if (selection !== undefined) {
80+
const bases = activeEditor.selections.length ? activeEditor.selections : [activeEditor.selection];
81+
const updatedSelections: vscode.Selection[] = [];
82+
83+
for (const base of bases) {
84+
const selection = fileTree.resolveVscodeSelection(base);
85+
if (selection === undefined) {
86+
continue;
87+
}
88+
6789
selection.update(direction, fileTree.blocks);
68-
activeEditor.selection = selection.toVscodeSelection();
69-
activeEditor.revealRange(
70-
activeEditor.selection,
71-
vscode.TextEditorRevealType.InCenterIfOutsideViewport
72-
);
90+
updatedSelections.push(selection.toVscodeSelection());
7391
}
92+
93+
if (updatedSelections.length === 0) {
94+
return;
95+
}
96+
97+
const merged = mergeSelections(updatedSelections);
98+
if (merged.length === 1) {
99+
activeEditor.selection = merged[0];
100+
} else {
101+
activeEditor.selections = merged;
102+
}
103+
104+
activeEditor.revealRange(
105+
merged[0] ?? activeEditor.selection,
106+
vscode.TextEditorRevealType.InCenterIfOutsideViewport
107+
);
74108
}
75109

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

83-
const selection = fileTree.resolveVscodeSelection(activeEditor.selection);
84-
if (selection === undefined) {
85-
return;
86-
}
117+
const bases = activeEditor.selections.length ? activeEditor.selections : [activeEditor.selection];
87118

88-
const result = await fileTree.moveSelection(selection, direction);
89-
switch (result.status) {
90-
case "ok":
119+
// Single-selection: preserve existing UX
120+
if (bases.length === 1) {
121+
const selection = fileTree.resolveVscodeSelection(bases[0]);
122+
if (selection === undefined) {
123+
return;
124+
}
125+
126+
const result = await fileTree.moveSelection(selection, direction);
127+
if (result.status === "ok") {
91128
activeEditor.selection = result.result;
92129
activeEditor.revealRange(result.result, vscode.TextEditorRevealType.InCenterIfOutsideViewport);
93-
break;
94-
95-
case "err":
96-
// TODO: add this as a text box above the cursor (can vscode do that?)
130+
} else {
97131
getLogger().log(result.result);
132+
}
98133

99-
break;
134+
return;
135+
}
136+
137+
// Multi-selection: order moves to reduce interference
138+
const order = bases.map((_, i) => i);
139+
order.sort((i, j) => {
140+
const a = bases[i].start;
141+
const b = bases[j].start;
142+
const cmp = a.line - b.line || a.character - b.character;
143+
return direction === "swap-next" ? -cmp : cmp; // down: bottom->top, up: top->bottom
144+
});
145+
146+
const results: (vscode.Selection | undefined)[] = bases.slice();
147+
for (const i of order) {
148+
const current = results[i] ?? bases[i];
149+
const selection = fileTree.resolveVscodeSelection(current);
150+
if (selection === undefined) {
151+
continue;
152+
}
153+
154+
const res = await fileTree.moveSelection(selection, direction);
155+
if (res.status === "ok") {
156+
results[i] = res.result;
157+
} else {
158+
getLogger().log(res.result);
159+
}
160+
}
161+
162+
const finalSelections = results.filter((s): s is vscode.Selection => !!s);
163+
if (finalSelections.length) {
164+
activeEditor.selections = finalSelections;
165+
activeEditor.revealRange(finalSelections[0], vscode.TextEditorRevealType.InCenterIfOutsideViewport);
100166
}
101167
}
102168

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

111-
const selection = fileTree.resolveVscodeSelection(activeEditor.selection);
177+
const bases = activeEditor.selections.length ? activeEditor.selections : [activeEditor.selection];
112178
const blocks = fileTree.blocks;
113-
const parent = selection?.getParent(blocks);
114-
const previous = selection?.getPrevious(blocks);
115-
const next = selection?.getNext(blocks);
116-
117-
let newPosition;
118-
switch (direction) {
119-
case "up":
120-
if (parent) {
121-
newPosition = parent.toVscodeSelection().start;
122-
}
123-
break;
124-
case "down":
125-
if (parent) {
126-
newPosition = parent.toVscodeSelection().end;
127-
}
128-
break;
129-
case "left":
130-
if (previous) {
131-
newPosition = previous.toVscodeSelection().start;
132-
}
133-
break;
134-
case "right":
135-
if (next) {
136-
newPosition = next.toVscodeSelection().start;
137-
}
138-
break;
179+
const nextCursors: vscode.Selection[] = [];
180+
181+
for (const base of bases) {
182+
const selection = fileTree.resolveVscodeSelection(base);
183+
if (selection === undefined) {
184+
continue;
185+
}
186+
187+
const parent = selection.getParent(blocks);
188+
const previous = selection.getPrevious(blocks);
189+
const next = selection.getNext(blocks);
190+
191+
let newPosition: vscode.Position | undefined;
192+
switch (direction) {
193+
case "up":
194+
if (parent) {
195+
newPosition = parent.toVscodeSelection().start;
196+
}
197+
break;
198+
case "down":
199+
if (parent) {
200+
newPosition = parent.toVscodeSelection().end;
201+
}
202+
break;
203+
case "left":
204+
if (previous) {
205+
newPosition = previous.toVscodeSelection().start;
206+
}
207+
break;
208+
case "right":
209+
if (next) {
210+
newPosition = next.toVscodeSelection().start;
211+
}
212+
break;
213+
}
214+
215+
if (newPosition) {
216+
nextCursors.push(new vscode.Selection(newPosition, newPosition));
217+
}
218+
}
219+
220+
if (nextCursors.length === 0) {
221+
return;
222+
}
223+
224+
const deduped = dedupeSelections(nextCursors);
225+
activeEditor.selections = deduped;
226+
activeEditor.revealRange(deduped[0], vscode.TextEditorRevealType.InCenterIfOutsideViewport);
227+
}
228+
229+
/**
230+
* Merge overlapping or touching selections (used to keep UX tidy).
231+
*/
232+
function mergeSelections(selections: vscode.Selection[]): vscode.Selection[] {
233+
if (selections.length <= 1) {
234+
return selections;
235+
}
236+
237+
const ranges = selections.map((s) => new vscode.Range(s.start, s.end));
238+
ranges.sort((a, b) => {
239+
if (a.start.isBefore(b.start)) {
240+
return -1;
241+
}
242+
if (a.start.isAfter(b.start)) {
243+
return 1;
244+
}
245+
if (a.end.isBefore(b.end)) {
246+
return -1;
247+
}
248+
if (a.end.isAfter(b.end)) {
249+
return 1;
250+
}
251+
return 0;
252+
});
253+
254+
const merged: vscode.Range[] = [];
255+
for (const r of ranges) {
256+
const last = merged.at(-1);
257+
if (last === undefined) {
258+
merged.push(r);
259+
} else if (!r.start.isAfter(last.end)) {
260+
const end = r.end.isAfter(last.end) ? r.end : last.end;
261+
merged[merged.length - 1] = new vscode.Range(last.start, end);
262+
} else {
263+
merged.push(r);
264+
}
139265
}
140266

141-
if (newPosition) {
142-
activeEditor.selection = new vscode.Selection(newPosition, newPosition);
143-
activeEditor.revealRange(
144-
activeEditor.selection,
145-
vscode.TextEditorRevealType.InCenterIfOutsideViewport
146-
);
267+
return merged.map((r) => new vscode.Selection(r.start, r.end));
268+
}
269+
270+
/**
271+
* De-duplicate selections while preserving order.
272+
*/
273+
function dedupeSelections(selections: vscode.Selection[]): vscode.Selection[] {
274+
if (selections.length <= 1) {
275+
return selections;
276+
}
277+
278+
selections.sort((a, b) => {
279+
if (a.start.isBefore(b.start)) {
280+
return -1;
281+
}
282+
if (a.start.isAfter(b.start)) {
283+
return 1;
284+
}
285+
if (a.end.isBefore(b.end)) {
286+
return -1;
287+
}
288+
if (a.end.isAfter(b.end)) {
289+
return 1;
290+
}
291+
return 0;
292+
});
293+
294+
const seen = new Set<string>();
295+
const out: vscode.Selection[] = [];
296+
for (const s of selections) {
297+
const key = `${s.start.line}:${s.start.character}-${s.end.line}:${s.end.character}`;
298+
if (!seen.has(key)) {
299+
seen.add(key);
300+
out.push(s);
301+
}
147302
}
303+
return out;
148304
}
149305

150306
function updateTargetHighlights(editor: vscode.TextEditor, vscodeSelection: vscode.Selection): void {

src/FileTree.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export class FileTree implements vscode.Disposable {
4848

4949
const queryStrings = getLanguageConfig(document.languageId).queries;
5050
if (queryStrings !== undefined) {
51-
const language = parser.getLanguage() as Language;
51+
const language = parser.getLanguage();
5252
this.queries = queryStrings.map((q) => new Query(language, q));
5353
this.blocks = getQueryBlocks(this.tree.rootNode, this.queries);
5454
}

0 commit comments

Comments
 (0)