| 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); |