Skip to content

Commit d26e6de

Browse files
committed
feat: add code folding support via Tree-sitter queries
Implement code folding across the editor with per-language fold detection using Tree-sitter syntax queries. Users can collapse and expand foldable regions (functions, classes, blocks, etc.) by clicking toggle buttons in a new gutter column. Core changes: - Add FoldRange type and getFoldRanges() to Code, computing foldable regions from Tree-sitter captures on every reparse - Add foldsQuery to the Lang interface so each language declares its own foldable node types - Add foldsQuery definitions for all 18 supported languages (TypeScript, JavaScript, Rust, Python, Go, C, C++, C#, Java, Kotlin, Lua, Bash, CSS, HTML, JSON, TOML, YAML, Zig) - Track collapsed fold state (collapsedFoldStarts) in AnycodeEditor and pass it through EditorState to the renderer - Integrate fold visibility into the VisualRow pipeline — folded lines are excluded from visual rows, interacting correctly with focused diff separators - Add a new .folds gutter column with expand/collapse toggle buttons, including CSS hover/opacity animations - Extend LineRenderer.createLineElements to produce fold indicator cells - Extend DiffRenderer to handle the fold column (ghost lines, gap rows, separator logic aware of folded regions) - Refactor mouse.ts to handle clicks on the new fold column via a unified .closest('.bt, .ln, .fd') lookup - Adjust selection rendering to clamp offsets that fall within fold gaps Performance: - Pre-build a Set<number> of all hidden line indices in updateCollapsedMap for O(1) isHiddenByFold lookups instead of iterating over collapsed intervals per line Cleanup: - Remove Lua-specific hack from foldRangeFromNode; use function_body instead of function_statement in Lua foldsQuery for correct ranges - Fix Lua comment prefix from '#' to '--' - Add yaml/yml extension mappings Tests: - Add folding.test.ts with coverage for 10 languages - Add DiffRenderer test cases for fold-aware separator insertion
1 parent 9c051ce commit d26e6de

32 files changed

Lines changed: 1815 additions & 90 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ csharp-test-project
1212
test.py
1313

1414
.zed
15+
anycode-react/dist/Component.js

anycode-base/src/code.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ export interface Patch {
7373
replace: string;
7474
}
7575

76+
export interface FoldRange {
77+
startLine: number;
78+
endLine: number;
79+
kind: string;
80+
}
81+
7682
var langsCache: Map<string, Parser.Language> = new Map();
7783
var pendingLangsCache: Map<string, Promise<Parser.Language>> = new Map();
7884

@@ -83,7 +89,9 @@ export class Code {
8389
private parser: Parser | undefined
8490
private tree: Parser.Tree | undefined
8591
private query: Parser.Query | undefined
92+
private foldsQuery: Parser.Query | undefined
8693
private runnablesQuery: Parser.Query | undefined
94+
private foldRanges: FoldRange[] = []
8795

8896
public runnables: Map<number, any> = new Map()
8997

@@ -121,7 +129,9 @@ export class Code {
121129
this.parser = undefined;
122130
this.tree = undefined;
123131
this.query = undefined;
132+
this.foldsQuery = undefined;
124133
this.runnablesQuery = undefined;
134+
this.foldRanges = [];
125135
return;
126136
}
127137

@@ -154,6 +164,9 @@ export class Code {
154164
if (this.language) {
155165
let q = this.getQuery();
156166
if (q) this.query = lang.query(q);
167+
const foldsQ = this.getFoldsQuery();
168+
if (foldsQ) this.foldsQuery = lang.query(foldsQ);
169+
this.updateFoldRanges();
157170
if (this.query) await this.initInjections();
158171
// let tq = this.getRunnablesQuery();
159172
// if (tq) this.runnablesQuery = lang.query(tq);
@@ -360,6 +373,7 @@ export class Code {
360373
this.buffer = pieceTree;
361374

362375
if (this.parser) this.tree = this.parser.parse(this.input) || undefined;
376+
this.updateFoldRanges();
363377
}
364378

365379
public insert(text: string, offset: number, addHistory: boolean = false) {
@@ -464,6 +478,7 @@ export class Code {
464478
const newTree = this.parser!.parse(this.input, old);
465479
this.tree!.delete();
466480
this.tree = newTree || undefined;
481+
this.updateFoldRanges();
467482
}
468483

469484
tx() {
@@ -601,6 +616,59 @@ export class Code {
601616
return language?.runnablesQuery || null;
602617
}
603618

619+
getFoldsQuery(): string | null {
620+
if (!this.language) return null;
621+
622+
const language = this.getLang(this.language);
623+
return language?.foldsQuery || null;
624+
}
625+
626+
private updateFoldRanges() {
627+
if (!this.tree || !this.foldsQuery) {
628+
this.foldRanges = [];
629+
return;
630+
}
631+
632+
const ranges: FoldRange[] = [];
633+
const seen = new Set<string>();
634+
const captures = this.foldsQuery.captures(this.tree.rootNode);
635+
636+
for (const capture of captures) {
637+
if (capture.name !== 'fold') continue;
638+
639+
const range = this.foldRangeFromNode(capture.node);
640+
if (!range) continue;
641+
642+
const { startLine, endLine } = range;
643+
644+
const key = `${startLine}:${endLine}`;
645+
if (seen.has(key)) continue;
646+
seen.add(key);
647+
648+
ranges.push({
649+
startLine,
650+
endLine,
651+
kind: capture.node.type,
652+
});
653+
}
654+
655+
ranges.sort((a, b) => a.startLine - b.startLine || a.endLine - b.endLine);
656+
this.foldRanges = ranges;
657+
}
658+
659+
public getFoldRanges(): FoldRange[] {
660+
return this.foldRanges;
661+
}
662+
663+
private foldRangeFromNode(node: Parser.SyntaxNode):
664+
{ startLine: number; endLine: number } | null {
665+
const startLine = node.startPosition.row;
666+
const endLine = node.endPosition.row;
667+
668+
if (endLine <= startLine) return null;
669+
return { startLine, endLine };
670+
}
671+
604672
getIndent(): Lang["indent"] | null {
605673
if (!this.language) return null;
606674

anycode-base/src/editor.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Code, Change, Position, Operation } from "./code";
1+
import { Code, Change, Position, Operation, type FoldRange } from "./code";
22
import { Renderer } from './renderer/Renderer';
33
import { getPosFromMouse } from './mouse';
44
import { Selection, hasDiagnosticSelection } from "./selection";
@@ -38,6 +38,7 @@ export interface EditorOptions {
3838
readOnly?: boolean;
3939
focusedDiffEnabled?: boolean;
4040
focusedDiffContextLines?: number;
41+
codeFoldingEnabled?: boolean;
4142
}
4243

4344
export interface EditorState {
@@ -50,6 +51,9 @@ export interface EditorState {
5051
settings: EditorSettings;
5152
diffs?: Map<number, DiffInfo>;
5253
readOnly?: boolean;
54+
foldRanges: FoldRange[];
55+
collapsedFoldStarts: Set<number>;
56+
codeFoldingEnabled: boolean;
5357
}
5458

5559
export class AnycodeEditor {
@@ -60,6 +64,7 @@ export class AnycodeEditor {
6064
private container!: HTMLDivElement;
6165
private buttonsColumn!: HTMLDivElement;
6266
private gutter!: HTMLDivElement;
67+
private foldsColumn!: HTMLDivElement;
6368
private codeContent!: HTMLDivElement;
6469

6570
private isMouseSelecting: boolean = false;
@@ -97,6 +102,8 @@ export class AnycodeEditor {
97102
private originalCode?: Code;
98103
private diffs?: Map<number, DiffInfo>;
99104
private readonly readOnly: boolean;
105+
private collapsedFoldStarts: Set<number> = new Set();
106+
private codeFoldingEnabled: boolean;
100107

101108
constructor(
102109
initialText = '',
@@ -108,6 +115,7 @@ export class AnycodeEditor {
108115
this.readOnly = options.readOnly ?? false;
109116
this.focusedDiffEnabled = options.focusedDiffEnabled ?? false;
110117
this.focusedDiffContextLines = Math.max(0, options.focusedDiffContextLines ?? 3);
118+
this.codeFoldingEnabled = options.codeFoldingEnabled ?? true;
111119
// Set initial cursor position
112120
if (options.line !== undefined && options.column !== undefined) {
113121
this.offset = this.code.getOffset(options.line, options.column);
@@ -123,7 +131,7 @@ export class AnycodeEditor {
123131
addCssToDocument(css, 'anyeditor-theme');
124132
}
125133
this.createDomElements();
126-
this.renderer = new Renderer(this.container, this.buttonsColumn, this.gutter, this.codeContent);
134+
this.renderer = new Renderer(this.container, this.buttonsColumn, this.gutter, this.foldsColumn, this.codeContent);
127135
this.renderer.setFocusedDiffMode(this.focusedDiffEnabled, this.focusedDiffContextLines);
128136
}
129137

@@ -137,6 +145,9 @@ export class AnycodeEditor {
137145
this.gutter = document.createElement('div');
138146
this.gutter.className = 'gutter';
139147

148+
this.foldsColumn = document.createElement('div');
149+
this.foldsColumn.className = 'folds';
150+
140151
this.codeContent = document.createElement('div');
141152
this.codeContent.className = 'code';
142153
this.codeContent.setAttribute("contentEditable", this.readOnly ? "false" : "true");
@@ -146,9 +157,13 @@ export class AnycodeEditor {
146157
if (this.readOnly) {
147158
this.container.classList.add('readonly');
148159
}
160+
if (!this.codeFoldingEnabled) {
161+
this.container.classList.add('no-folding');
162+
}
149163

150164
this.container.appendChild(this.buttonsColumn);
151165
this.container.appendChild(this.gutter);
166+
this.container.appendChild(this.foldsColumn);
152167
this.container.appendChild(this.codeContent);
153168
}
154169

@@ -247,6 +262,10 @@ export class AnycodeEditor {
247262
return this.code.getContentLength();
248263
}
249264

265+
public getFoldRanges(): FoldRange[] {
266+
return this.code.getFoldRanges();
267+
}
268+
250269
public async init() {
251270
await this.code.init();
252271
if (this.readOnly) {
@@ -417,6 +436,7 @@ export class AnycodeEditor {
417436
this.handleClick = this.handleClick.bind(this);
418437
this.codeContent.addEventListener('click', this.handleClick);
419438
this.gutter.addEventListener('click', this.handleClick);
439+
this.foldsColumn.addEventListener('click', this.handleClick);
420440

421441
this.handleKeydown = this.handleKeydown.bind(this);
422442
this.codeContent.addEventListener('keydown', this.handleKeydown);
@@ -450,6 +470,7 @@ export class AnycodeEditor {
450470
this.container.removeEventListener("scroll", this.handleScroll);
451471
this.codeContent.removeEventListener('click', this.handleClick);
452472
this.gutter.removeEventListener('click', this.handleClick);
473+
this.foldsColumn.removeEventListener('click', this.handleClick);
453474
this.codeContent.removeEventListener('keydown', this.handleKeydown);
454475
this.codeContent.removeEventListener('paste', this.handlePasteEvent);
455476
this.container.removeEventListener('beforeinput', this.handleBeforeInput);
@@ -505,9 +526,20 @@ export class AnycodeEditor {
505526
},
506527
diffs: this.diffs,
507528
readOnly: this.readOnly,
529+
foldRanges: this.code.getFoldRanges(),
530+
collapsedFoldStarts: this.collapsedFoldStarts,
531+
codeFoldingEnabled: this.codeFoldingEnabled,
508532
};
509533
}
510534

535+
private toggleFoldAtLine(line: number) {
536+
if (this.collapsedFoldStarts.has(line)) {
537+
this.collapsedFoldStarts.delete(line);
538+
} else {
539+
this.collapsedFoldStarts.add(line);
540+
}
541+
}
542+
511543
public render() {
512544
this.renderer.render(this.getEditorState(), this.search);
513545
}
@@ -564,6 +596,22 @@ export class AnycodeEditor {
564596
return;
565597
}
566598

599+
const foldToggle = this.codeFoldingEnabled ? (e.target as HTMLElement | null)?.closest('.fold-toggle') as HTMLElement | null : null;
600+
if (foldToggle) {
601+
const line = Number.parseInt(foldToggle.dataset.line ?? '-1', 10);
602+
if (line >= 0) {
603+
e.preventDefault();
604+
e.stopPropagation();
605+
this.toggleFoldAtLine(line);
606+
this.renderer.render(this.getEditorState(), this.search);
607+
if (!this.readOnly) {
608+
this.codeContent.focus({ preventScroll: true });
609+
this.renderer.renderCursorOrSelection(this.getEditorState(), false);
610+
}
611+
}
612+
return;
613+
}
614+
567615
const oldCursor = this.code.getPosition(this.offset);
568616

569617
if (this.selection && this.selection.nonEmpty()) { return; }
@@ -1020,7 +1068,7 @@ export class AnycodeEditor {
10201068
}
10211069

10221070
private adjustFocusedDiffNavigationOffset(result: ActionResult, action: Action): void {
1023-
if (!this.focusedDiffEnabled) return;
1071+
if (!this.focusedDiffEnabled && this.collapsedFoldStarts.size === 0) return;
10241072
if (
10251073
action !== Action.ARROW_LEFT
10261074
&& action !== Action.ARROW_RIGHT

anycode-base/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export { AnycodeEditor } from './editor';
2-
export { type Edit, type Change } from './code';
2+
export { type Edit, type Change, type FoldRange } from './code';
33
export { Operation } from './code';
44
export { setWasmBasePath } from './utils';

anycode-base/src/lang.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export interface Lang {
22
query: string;
3+
foldsQuery?: string;
34
indent: {
45
width: number;
56
unit: string;

anycode-base/src/langs/bash.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,24 @@ const query = `
7575
let indent = { width: 4, unit: " " };
7676
let comment = "#";
7777

78+
let foldsQuery = `
79+
[
80+
(function_definition)
81+
(if_statement)
82+
(for_statement)
83+
(while_statement)
84+
(case_statement)
85+
(case_item)
86+
(subshell)
87+
(compound_statement)
88+
(heredoc_body)
89+
(comment)
90+
] @fold
91+
`
92+
7893
let executable = true;
7994
let cmd = "./{file}";
8095

81-
8296
export default {
83-
query, executable, cmd, indent, comment
97+
query, foldsQuery, executable, cmd, indent, comment
8498
} satisfies Lang

anycode-base/src/langs/c.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,29 @@ const query = `
9696
(comment) @comment
9797
`
9898

99+
let foldsQuery = `
100+
[
101+
(compound_statement)
102+
(initializer_list)
103+
(field_declaration_list)
104+
(comment)
105+
] @fold
106+
107+
[
108+
(struct_specifier)
109+
(union_specifier)
110+
(enum_specifier)
111+
(function_definition)
112+
(preproc_if)
113+
(preproc_ifdef)
114+
(preproc_elif)
115+
(preproc_else)
116+
] @fold
117+
`
118+
99119
let indent = { width: 4, unit: " " };
100120
let comment = "//";
101121

102122
export default {
103-
query, indent, comment
123+
query, foldsQuery, indent, comment
104124
} satisfies Lang

anycode-base/src/langs/cpp.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,33 @@ const query = `
166166
"operator" @function
167167
`
168168

169+
let foldsQuery = `
170+
[
171+
(compound_statement)
172+
(initializer_list)
173+
(field_declaration_list)
174+
(declaration_list)
175+
(comment)
176+
] @fold
177+
178+
[
179+
(namespace_definition)
180+
(class_specifier)
181+
(struct_specifier)
182+
(union_specifier)
183+
(enum_specifier)
184+
(template_declaration)
185+
(function_definition)
186+
(preproc_if)
187+
(preproc_ifdef)
188+
(preproc_elif)
189+
(preproc_else)
190+
] @fold
191+
`
192+
169193
let indent = { width: 4, unit: " " };
170194
let comment = "//";
171195

172196
export default {
173-
query, indent, comment
197+
query, foldsQuery, indent, comment
174198
} satisfies Lang

0 commit comments

Comments
 (0)