Skip to content

Commit 3c9b637

Browse files
committed
feat: Implement Ace API compatibility for editor sessions
1 parent cd2d746 commit 3c9b637

File tree

2 files changed

+224
-17
lines changed

2 files changed

+224
-17
lines changed

src/lib/editorFile.js

Lines changed: 215 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,189 @@ import run from "./run";
2525
import saveFile from "./saveFile";
2626
import appSettings from "./settings";
2727

28+
/**
29+
* Creates a Proxy around an EditorState that provides Ace-compatible methods.
30+
* @param {EditorState} state - The raw CodeMirror EditorState
31+
* @param {EditorFile} file - The parent EditorFile instance
32+
* @returns {Proxy} Proxied state with Ace-compatible methods
33+
*/
34+
function createSessionProxy(state, file) {
35+
if (!state) return null;
36+
37+
/**
38+
* Convert Ace position {row, column} to CodeMirror offset
39+
*/
40+
function positionToOffset(pos, doc) {
41+
if (!pos || !doc) return 0;
42+
try {
43+
const lineNum = Math.max(1, Math.min((pos.row ?? 0) + 1, doc.lines));
44+
const line = doc.line(lineNum);
45+
const col = Math.max(0, Math.min(pos.column ?? 0, line.length));
46+
return line.from + col;
47+
} catch (_) {
48+
return 0;
49+
}
50+
}
51+
52+
/**
53+
* Convert CodeMirror offset to Ace position {row, column}
54+
*/
55+
function offsetToPosition(offset, doc) {
56+
if (!doc) return { row: 0, column: 0 };
57+
try {
58+
const line = doc.lineAt(offset);
59+
return { row: line.number - 1, column: offset - line.from };
60+
} catch (_) {
61+
return { row: 0, column: 0 };
62+
}
63+
}
64+
65+
return new Proxy(state, {
66+
get(target, prop) {
67+
// Ace-compatible method: getValue()
68+
if (prop === "getValue") {
69+
return () => target.doc.toString();
70+
}
71+
72+
// Ace-compatible method: setValue(text)
73+
if (prop === "setValue") {
74+
return (text) => {
75+
const newText = String(text ?? "");
76+
const { activeFile, editor } = editorManager;
77+
if (activeFile?.id === file.id && editor) {
78+
// Active file: dispatch to live EditorView
79+
editor.dispatch({
80+
changes: {
81+
from: 0,
82+
to: editor.state.doc.length,
83+
insert: newText,
84+
},
85+
});
86+
} else {
87+
// Inactive file: update stored state
88+
file._setRawSession(
89+
target.update({
90+
changes: { from: 0, to: target.doc.length, insert: newText },
91+
}).state,
92+
);
93+
}
94+
};
95+
}
96+
97+
// Ace-compatible method: getLine(row)
98+
if (prop === "getLine") {
99+
return (row) => {
100+
try {
101+
return target.doc.line(row + 1).text;
102+
} catch (_) {
103+
return "";
104+
}
105+
};
106+
}
107+
108+
// Ace-compatible method: getLength()
109+
if (prop === "getLength") {
110+
return () => target.doc.lines;
111+
}
112+
113+
// Ace-compatible method: getTextRange(range)
114+
if (prop === "getTextRange") {
115+
return (range) => {
116+
if (!range) return "";
117+
try {
118+
const from = positionToOffset(range.start, target.doc);
119+
const to = positionToOffset(range.end, target.doc);
120+
return target.doc.sliceString(from, to);
121+
} catch (_) {
122+
return "";
123+
}
124+
};
125+
}
126+
127+
// Ace-compatible method: insert(position, text)
128+
if (prop === "insert") {
129+
return (position, text) => {
130+
const { activeFile, editor } = editorManager;
131+
const offset = positionToOffset(position, target.doc);
132+
if (activeFile?.id === file.id && editor) {
133+
editor.dispatch({
134+
changes: { from: offset, insert: String(text ?? "") },
135+
});
136+
} else {
137+
file._setRawSession(
138+
target.update({
139+
changes: { from: offset, insert: String(text ?? "") },
140+
}).state,
141+
);
142+
}
143+
};
144+
}
145+
146+
// Ace-compatible method: remove(range)
147+
if (prop === "remove") {
148+
return (range) => {
149+
if (!range) return "";
150+
const from = positionToOffset(range.start, target.doc);
151+
const to = positionToOffset(range.end, target.doc);
152+
const removed = target.doc.sliceString(from, to);
153+
const { activeFile, editor } = editorManager;
154+
if (activeFile?.id === file.id && editor) {
155+
editor.dispatch({ changes: { from, to, insert: "" } });
156+
} else {
157+
file._setRawSession(
158+
target.update({ changes: { from, to, insert: "" } }).state,
159+
);
160+
}
161+
return removed;
162+
};
163+
}
164+
165+
// Ace-compatible method: replace(range, text)
166+
if (prop === "replace") {
167+
return (range, text) => {
168+
if (!range) return;
169+
const from = positionToOffset(range.start, target.doc);
170+
const to = positionToOffset(range.end, target.doc);
171+
const { activeFile, editor } = editorManager;
172+
if (activeFile?.id === file.id && editor) {
173+
editor.dispatch({
174+
changes: { from, to, insert: String(text ?? "") },
175+
});
176+
} else {
177+
file._setRawSession(
178+
target.update({
179+
changes: { from, to, insert: String(text ?? "") },
180+
}).state,
181+
);
182+
}
183+
};
184+
}
185+
186+
// Ace-compatible method: getWordRange(row, column)
187+
if (prop === "getWordRange") {
188+
return (row, column) => {
189+
const offset = positionToOffset({ row, column }, target.doc);
190+
const word = target.wordAt(offset);
191+
if (word) {
192+
return {
193+
start: offsetToPosition(word.from, target.doc),
194+
end: offsetToPosition(word.to, target.doc),
195+
};
196+
}
197+
return { start: { row, column }, end: { row, column } };
198+
};
199+
}
200+
201+
// Pass through all other properties to the real EditorState
202+
const value = target[prop];
203+
if (typeof value === "function") {
204+
return value.bind(target);
205+
}
206+
return value;
207+
},
208+
});
209+
}
210+
28211
/**
29212
* @typedef {'run'|'save'|'change'|'focus'|'blur'|'close'|'rename'|'load'|'loadError'|'loadStart'|'loadEnd'|'changeMode'|'changeEncoding'|'changeReadOnly'} FileEvents
30213
*/
@@ -99,10 +282,10 @@ export default class EditorFile {
99282
*/
100283
deletedFile = false;
101284
/**
102-
* CodeMirror document state for the file
285+
* Raw CodeMirror EditorState. Use session getter to access with Ace-compatible methods.
103286
* @type {EditorState}
104287
*/
105-
session = null;
288+
#rawSession = null;
106289
/**
107290
* Encoding of the text e.g. 'gbk'
108291
* @type {string}
@@ -346,7 +529,7 @@ export default class EditorFile {
346529
editorManager.emit("new-file", this);
347530

348531
if (this.#type === "editor") {
349-
this.session = EditorState.create({
532+
this.#rawSession = EditorState.create({
350533
doc: options?.text || "",
351534
});
352535
this.setMode();
@@ -368,6 +551,32 @@ export default class EditorFile {
368551
return this.#content;
369552
}
370553

554+
/**
555+
* Session with Ace-compatible methods
556+
* Returns a Proxy over the raw EditorState.
557+
* @returns {Proxy<EditorState>}
558+
*/
559+
get session() {
560+
return createSessionProxy(this.#rawSession, this);
561+
}
562+
563+
/**
564+
* Set the session
565+
* @param {EditorState} value
566+
*/
567+
set session(value) {
568+
this.#rawSession = value;
569+
}
570+
571+
/**
572+
* Internal method to update the raw session state.
573+
* Used by the Proxy for inactive file updates.
574+
* @param {EditorState} state
575+
*/
576+
_setRawSession(state) {
577+
this.#rawSession = state;
578+
}
579+
371580
/**
372581
* File unique id.
373582
*/
@@ -517,9 +726,7 @@ export default class EditorFile {
517726
}
518727

519728
// Update the document in the session
520-
this.session = this.session.update({
521-
changes: { from: 0, to: this.session.doc.length, insert: text },
522-
}).state;
729+
this.session.setValue(text);
523730
}
524731

525732
/**
@@ -1074,13 +1281,7 @@ export default class EditorFile {
10741281
this.loading = true;
10751282
this.markChanged = false;
10761283
this.#emit("loadstart", createFileEvent(this));
1077-
this.session = this.session.update({
1078-
changes: {
1079-
from: 0,
1080-
to: this.session.doc.length,
1081-
insert: strings["loading..."],
1082-
},
1083-
}).state;
1284+
this.session.setValue(strings["loading..."]);
10841285

10851286
// Immediately reflect "loading..." in the visible editor if this tab is active
10861287
try {
@@ -1113,9 +1314,7 @@ export default class EditorFile {
11131314
}
11141315

11151316
this.markChanged = false;
1116-
this.session = this.session.update({
1117-
changes: { from: 0, to: this.session.doc.length, insert: value },
1118-
}).state;
1317+
this.session.setValue(value);
11191318
this.loaded = true;
11201319
this.loading = false;
11211320

src/lib/editorManager.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,14 @@ async function EditorManager($header, $body) {
673673
},
674674
});
675675

676+
// Provide editor.session for Ace API compatibility
677+
// Returns the active file's session (Proxy with Ace-like methods)
678+
Object.defineProperty(editor, "session", {
679+
get() {
680+
return manager.activeFile?.session ?? null;
681+
},
682+
});
683+
676684
// Provide minimal Ace-like API compatibility used by plugins
677685
/**
678686
* Insert text at the current selection/cursor in the editor
@@ -977,7 +985,7 @@ async function EditorManager($header, $body) {
977985

978986
const doc = prevState ? prevState.doc.toString() : "";
979987
const state = EditorState.create({ doc, extensions: exts });
980-
file.session = state; // keep file.session in sync
988+
file.session = state;
981989
editor.setState(state);
982990
// Re-apply selected theme after state replacement
983991
const desiredTheme = appSettings?.value?.editorTheme;

0 commit comments

Comments
 (0)