main
js 369 lines 11 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { fetchApi } from "/js/api.js";
3
4 const model = {
5 // --- State ---------------------------------------------------------------
6 editor: null,
7 editTarget: null,
8 editFileName: "",
9 editOriginalName: "",
10 editContent: "",
11 editOriginalContent: "",
12 editMimeType: "text/plain",
13 editIsNew: false,
14 isEditLoading: false,
15 isSaving: false,
16 editError: null,
17 editSaveError: null,
18 editClosePromise: null,
19
20 // Context
21 currentPath: "", // Required for new file creation
22 existingNames: [],
23 onSaveSuccess: null, // Callback to refresh parent list
24
25 // --- Public API ----------------------------------------------------------
26
27 /**
28 * Open the editor for an existing file
29 * @param {object} file - The file object { name, path, ... }
30 * @param {function} onSaveSuccess - Callback when save completes
31 */
32 async openFile(file, onSaveSuccess) {
33 if (this.isEditLoading) return;
34 this.resetEditState();
35
36 this.editTarget = file;
37 this.editFileName = file?.name || "";
38 this.editOriginalName = this.editFileName;
39 this.editIsNew = false;
40 this.isEditLoading = true;
41 this.editError = null;
42 this.editSaveError = null;
43 this.onSaveSuccess = onSaveSuccess;
44
45 this.editClosePromise = window.openModal(
46 "modals/file-editor/file-edit-modal.html",
47 () => this.beforeCloseFileEditor()
48 );
49
50 if (this.editClosePromise && typeof this.editClosePromise.then === "function") {
51 this.editClosePromise.then(() => this.resetEditState());
52 }
53
54 try {
55 const resp = await fetchApi(
56 `/edit_work_dir_file?path=${encodeURIComponent(file.path)}`
57 );
58 const data = await resp.json().catch(() => ({}));
59
60 if (!resp.ok || data.error) {
61 throw new Error(data.error || "Failed to load file");
62 }
63
64 const payload = data.data || {};
65
66 this.editContent = payload.content || "";
67 this.editFileName = payload.name || this.editFileName;
68 this.editOriginalName = this.editFileName;
69 this.editOriginalContent = this.editContent;
70 this.editMimeType = payload.mime_type || "text/plain";
71 this.isEditLoading = false;
72 this.scheduleEditorInit();
73 } catch (error) {
74 let message = error?.message || "Failed to load file";
75 message = this._extractErrorMessage(message);
76 this.editError = message;
77 this.isEditLoading = false;
78 window.toastFrontendError(message, "File Edit Error");
79 }
80 },
81
82 /**
83 * Open the editor for a new file
84 * @param {string} currentPath - The directory where the file will be created
85 * @param {string[]} existingNames - Names that already exist in the directory (for UX duplicate checks)
86 * @param {function} onSaveSuccess - Callback when save completes
87 */
88 async openNewFile(currentPath, existingNames, onSaveSuccess) {
89 this.resetEditState();
90
91 this.currentPath = currentPath;
92 this.existingNames = Array.isArray(existingNames) ? existingNames : [];
93 this.editIsNew = true;
94 this.editFileName = "";
95 this.editOriginalName = "";
96 this.editContent = "";
97 this.editOriginalContent = "";
98 this.editMimeType = "text/plain";
99 this.isEditLoading = false;
100 this.editError = null;
101 this.editSaveError = null;
102 this.onSaveSuccess = onSaveSuccess;
103
104 this.editClosePromise = window.openModal(
105 "modals/file-editor/file-edit-modal.html",
106 () => this.beforeCloseFileEditor()
107 );
108
109 if (this.editClosePromise && typeof this.editClosePromise.then === "function") {
110 this.editClosePromise.then(() => this.resetEditState());
111 }
112
113 this.scheduleEditorInit();
114 },
115
116 // --- Actions -------------------------------------------------------------
117
118 async saveFileEdits() {
119 if (this.isSaving || this.isEditLoading || this.editError) return;
120 this.editSaveError = null;
121
122 const fileName = this.editFileName.trim();
123 if (!fileName) {
124 this.editSaveError = "File name is required.";
125 return;
126 }
127 if (fileName === "." || fileName === "..") {
128 this.editSaveError = "File name cannot be '.' or '..'.";
129 return;
130 }
131 if (fileName.includes("/") || fileName.includes("\\")) {
132 this.editSaveError = "File name cannot include path separators.";
133 return;
134 }
135 if (this.editIsNew && (this.existingNames || []).includes(fileName)) {
136 this.editSaveError = `An item named "${fileName}" already exists.`;
137 return;
138 }
139
140 const content = this.editor ? this.editor.getValue() : this.editContent;
141 const targetPath = this.editIsNew
142 ? this.buildChildPath(fileName)
143 : this.editTarget?.path || ""; // Note: was normalizePath(this.editTarget?.path) but path should be absolute from API
144
145 if (!targetPath) {
146 window.toastFrontendError("File path is missing.", "Save File");
147 return;
148 }
149
150 this.isSaving = true;
151
152 try {
153 const resp = await fetchApi("/edit_work_dir_file", {
154 method: "POST",
155 headers: { "Content-Type": "application/json" },
156 body: JSON.stringify({ path: targetPath, content }),
157 });
158
159 const data = await resp.json().catch(() => ({}));
160
161 if (!resp.ok || data.error) {
162 throw new Error(data.error || "Failed to save file");
163 }
164
165 this.editOriginalContent = content;
166 this.editOriginalName = fileName;
167 this.editIsNew = false;
168 if (this.editTarget) {
169 this.editTarget.name = fileName;
170 this.editTarget.path = targetPath;
171 }
172
173 // Trigger success callback
174 if (typeof this.onSaveSuccess === 'function') {
175 await this.onSaveSuccess();
176 }
177
178 // Reset isSaving before closing so beforeCloseFileEditor() allows it
179 this.isSaving = false;
180 this.closeFileEditor();
181 } catch (error) {
182 const message = error?.message || "Failed to save file";
183 this.editSaveError = message;
184 window.toastFrontendError(message, "Save File Error");
185 this.isSaving = false;
186 }
187 },
188
189 closeFileEditor() {
190 window.closeModal("modals/file-editor/file-edit-modal.html");
191 },
192
193 beforeCloseFileEditor() {
194 if (this.isSaving) return false;
195 if (!this.editor) return true;
196 if (!this.hasEditChanges()) return true;
197 return confirm("You have unsaved changes. Close without saving?");
198 },
199
200 // --- Helpers -------------------------------------------------------------
201
202 _extractErrorMessage(msg) {
203 if (typeof msg !== 'string') return msg;
204 // Extract clean error from traceback strings
205 const lines = msg.split('\n');
206 for (let i = lines.length - 1; i >= 0; i--) {
207 const line = lines[i].trim();
208 if (line.includes(': ') && /Exception|Error/.test(line)) {
209 return line.split(': ').slice(1).join(': ').trim();
210 }
211 }
212 return msg;
213 },
214
215 resetEditState() {
216 if (this.editor?.destroy) {
217 this.editor.destroy();
218 }
219 this.editor = null;
220 this.editTarget = null;
221 this.editFileName = "";
222 this.editOriginalName = "";
223 this.editContent = "";
224 this.editOriginalContent = "";
225 this.editMimeType = "text/plain";
226 this.editIsNew = false;
227 this.isEditLoading = false;
228 this.isSaving = false;
229 this.editError = null;
230 this.editSaveError = null;
231 this.editClosePromise = null;
232 this.existingNames = [];
233 this.onSaveSuccess = null;
234 },
235
236 normalizePath(path) {
237 if (!path) return "";
238 return path.startsWith("/") ? path : `/${path}`;
239 },
240
241 buildChildPath(name) {
242 const base = this.normalizePath(this.currentPath || "");
243 const trimmedBase = base.replace(/\/$/, "");
244 if (!trimmedBase) return `/${name}`;
245 return `${trimmedBase}/${name}`;
246 },
247
248 hasEditChanges() {
249 const currentValue = this.editor ? this.editor.getValue() : this.editContent;
250 const nameChanged = (this.editFileName || "") !== (this.editOriginalName || "");
251 if (this.editIsNew) {
252 return Boolean((this.editFileName || "").trim() || currentValue);
253 }
254 return currentValue !== this.editOriginalContent || nameChanged;
255 },
256
257 editPathLabel() {
258 if (this.editIsNew) {
259 const name = this.editFileName?.trim();
260 return name ? this.buildChildPath(name) : this.normalizePath(this.currentPath || "");
261 }
262 return this.editTarget?.path ? this.normalizePath(this.editTarget.path) : "";
263 },
264
265 // --- Editor (ACE) integration --------------------------------------------
266
267 scheduleEditorInit() {
268 window.requestAnimationFrame(() => {
269 if (this.isEditLoading || this.editError) return;
270 window.requestAnimationFrame(() => this.initEditor());
271 });
272 },
273
274 initEditor() {
275 const container = document.getElementById("file-editor-container");
276 if (!container) {
277 // It's possible the modal hasn't fully rendered yet
278 console.warn("File editor container not found, deferring editor init");
279 return;
280 }
281
282 if (this.editor?.destroy) {
283 this.editor.destroy();
284 }
285
286 if (!window.ace?.edit) {
287 console.error("ACE editor not available");
288 this.editError = "Editor library not loaded";
289 return;
290 }
291
292 const editorInstance = window.ace.edit("file-editor-container");
293 if (!editorInstance) {
294 console.error("Failed to create ACE editor instance");
295 return;
296 }
297
298 this.editor = editorInstance;
299
300 const darkMode = window.localStorage?.getItem("darkMode");
301 const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/tomorrow";
302 this.editor.setTheme(theme);
303 this.applyEditorMode();
304 this.editor.setValue(this.editContent || "", -1);
305 this.editor.clearSelection();
306 },
307
308 updateEditorMode() {
309 this.applyEditorMode();
310 },
311
312 applyEditorMode() {
313 if (!this.editor?.session) return;
314 const mode = this.resolveAceMode(this.editMimeType, this.editFileName);
315 this.editor.session.setMode(mode);
316 },
317
318 resolveAceMode(mimeType, fileName) {
319 const mime = (mimeType || "").toLowerCase();
320 const ext = (fileName || "").split(".").pop()?.toLowerCase();
321 const mimeMap = {
322 "application/json": "json",
323 "application/xml": "xml",
324 "application/javascript": "javascript",
325 "application/typescript": "typescript",
326 "application/x-yaml": "yaml",
327 "text/plain": "text",
328 "text/markdown": "markdown",
329 "text/html": "html",
330 "text/css": "css",
331 "text/javascript": "javascript",
332 "text/typescript": "typescript",
333 "text/x-python": "python",
334 "text/x-shellscript": "sh",
335 "text/x-yaml": "yaml",
336 "text/xml": "xml",
337 "text/x-toml": "toml",
338 };
339 const extMap = {
340 js: "javascript",
341 jsx: "javascript",
342 ts: "typescript",
343 tsx: "typescript",
344 json: "json",
345 md: "markdown",
346 markdown: "markdown",
347 html: "html",
348 htm: "html",
349 css: "css",
350 py: "python",
351 sh: "sh",
352 bash: "sh",
353 zsh: "sh",
354 yaml: "yaml",
355 yml: "yaml",
356 toml: "toml",
357 xml: "xml",
358 txt: "text",
359 csv: "text",
360 ini: "ini",
361 };
362 const mimeMode = mimeMap[mime];
363 const extMode = extMap[ext];
364 const mode = (mime && mime !== "text/plain" ? mimeMode : null) || extMode || mimeMode || "text";
365 return `ace/mode/${mode}`;
366 },
367 };
368
369 export const store = createStore("fileEditor", model);