file editor component

3clyp50 committed Jan 25, 2026 at 16:46 UTC a2bd2750e3ff512633ce99bfbe93f521fd8d5d02
2 files changed +522
webui/components/modals/file-editor/file-edit-modal.html new
+168
@@ -0,0 +1,168 @@
1 +<html>
2 +<head>
3 + <title>File Editor</title>
4 + <script type="module">
5 + import { store } from "/components/modals/file-editor/file-editor-store.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.fileEditor">
11 + <div class="file-edit-modal-root">
12 + <template x-if="$store.fileEditor.isEditLoading">
13 + <div class="loading-state">
14 + <div class="loading-spinner"></div>
15 + <p>Loading file…</p>
16 + </div>
17 + </template>
18 +
19 + <template x-if="$store.fileEditor.editError">
20 + <div class="error-state">
21 + <p class="error-message" x-text="$store.fileEditor.editError"></p>
22 + </div>
23 + </template>
24 +
25 + <template x-if="!$store.fileEditor.isEditLoading && !$store.fileEditor.editError">
26 + <div class="file-edit-content">
27 + <div class="file-edit-meta">
28 + <div class="file-edit-field">
29 + <label>File name</label>
30 + <input
31 + type="text"
32 + x-model="$store.fileEditor.editFileName"
33 + @input="$store.fileEditor.updateEditorMode()"
34 + :disabled="!$store.fileEditor.editIsNew"
35 + />
36 + <template x-if="$store.fileEditor.editSaveError">
37 + <p class="file-edit-inline-error" x-text="$store.fileEditor.editSaveError"></p>
38 + </template>
39 + </div>
40 + <div class="file-edit-path" x-text="$store.fileEditor.editPathLabel()"></div>
41 + </div>
42 + <div id="file-editor-container"></div>
43 + </div>
44 + </template>
45 + </div>
46 + </template>
47 + </div>
48 +
49 + <template x-if="$store.fileEditor">
50 + <div class="modal-footer" data-modal-footer>
51 + <button class="btn btn-cancel" @click="$store.fileEditor.closeFileEditor()" :disabled="$store.fileEditor.isSaving">Cancel</button>
52 + <button
53 + class="btn btn-ok"
54 + @click="$store.fileEditor.saveFileEdits()"
55 + :disabled="$store.fileEditor.isSaving || $store.fileEditor.isEditLoading || $store.fileEditor.editError"
56 + x-text="$store.fileEditor.isSaving ? 'Saving…' : 'Save'"
57 + ></button>
58 + </div>
59 + </template>
60 +
61 + <style>
62 + .file-edit-modal-root {
63 + display: flex;
64 + flex-direction: column;
65 + width: 100%;
66 + min-height: 400px;
67 + }
68 +
69 + .file-edit-content {
70 + display: flex;
71 + flex-direction: column;
72 + gap: var(--spacing-md);
73 + margin: 1rem;
74 + }
75 +
76 + .file-edit-meta {
77 + display: flex;
78 + flex-direction: column;
79 + gap: var(--spacing-xs);
80 + }
81 +
82 + .file-edit-field {
83 + display: flex;
84 + flex-direction: column;
85 + gap: 6px;
86 + }
87 +
88 + .file-edit-field label {
89 + font-weight: 600;
90 + color: var(--color-text);
91 + }
92 +
93 + .file-edit-field input {
94 + padding: 8px 12px;
95 + border-radius: 6px;
96 + border: 1px solid var(--color-border);
97 + background: var(--color-background);
98 + color: var(--color-text);
99 + }
100 +
101 + .file-edit-field input:disabled {
102 + opacity: 0.7;
103 + cursor: not-allowed;
104 + }
105 +
106 + .file-edit-inline-error {
107 + margin: 0;
108 + color: var(--color-error);
109 + font-weight: 500;
110 + font-size: 0.85rem;
111 + }
112 +
113 + .file-edit-path {
114 + font-size: 0.85rem;
115 + color: var(--color-text-secondary);
116 + word-break: break-all;
117 + }
118 +
119 + #file-editor-container {
120 + width: 100%;
121 + min-height: 55vh;
122 + border-radius: 0.4rem;
123 + overflow: auto;
124 + scrollbar-gutter: stable;
125 + scrollbar-width: thin;
126 + }
127 +
128 + #file-editor-container::-webkit-scrollbar {
129 + width: 8px;
130 + }
131 +
132 + .ace_scrollbar-v {
133 + overflow-y: auto;
134 + }
135 +
136 + .loading-state,
137 + .error-state {
138 + display: flex;
139 + flex-direction: column;
140 + align-items: center;
141 + justify-content: center;
142 + padding: var(--spacing-lg);
143 + min-height: 200px;
144 + }
145 +
146 + .loading-spinner {
147 + width: 40px;
148 + height: 40px;
149 + border: 3px solid var(--color-border);
150 + border-top: 3px solid var(--color-primary);
151 + border-radius: 50%;
152 + animation: spin 1s linear infinite;
153 + margin-bottom: var(--spacing-md);
154 + }
155 +
156 + @keyframes spin {
157 + 0% { transform: rotate(0deg); }
158 + 100% { transform: rotate(360deg); }
159 + }
160 +
161 + .error-message {
162 + color: var(--color-error);
163 + font-weight: 500;
164 + margin: 0;
165 + }
166 + </style>
167 +</body>
168 +</html>
webui/components/modals/file-editor/file-editor-store.js new
+354
@@ -0,0 +1,354 @@
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 + const message = error?.message || "Failed to load file";
75 + this.editError = message;
76 + this.isEditLoading = false;
77 + window.toastFrontendError(message, "File Edit Error");
78 + }
79 + },
80 +
81 + /**
82 + * Open the editor for a new file
83 + * @param {string} currentPath - The directory where the file will be created
84 + * @param {string[]} existingNames - Names that already exist in the directory (for UX duplicate checks)
85 + * @param {function} onSaveSuccess - Callback when save completes
86 + */
87 + async openNewFile(currentPath, existingNames, onSaveSuccess) {
88 + this.resetEditState();
89 +
90 + this.currentPath = currentPath;
91 + this.existingNames = Array.isArray(existingNames) ? existingNames : [];
92 + this.editIsNew = true;
93 + this.editFileName = "";
94 + this.editOriginalName = "";
95 + this.editContent = "";
96 + this.editOriginalContent = "";
97 + this.editMimeType = "text/plain";
98 + this.isEditLoading = false;
99 + this.editError = null;
100 + this.editSaveError = null;
101 + this.onSaveSuccess = onSaveSuccess;
102 +
103 + this.editClosePromise = window.openModal(
104 + "modals/file-editor/file-edit-modal.html",
105 + () => this.beforeCloseFileEditor()
106 + );
107 +
108 + if (this.editClosePromise && typeof this.editClosePromise.then === "function") {
109 + this.editClosePromise.then(() => this.resetEditState());
110 + }
111 +
112 + this.scheduleEditorInit();
113 + },
114 +
115 + // --- Actions -------------------------------------------------------------
116 +
117 + async saveFileEdits() {
118 + if (this.isSaving || this.isEditLoading || this.editError) return;
119 + this.editSaveError = null;
120 +
121 + const fileName = this.editFileName.trim();
122 + if (!fileName) {
123 + this.editSaveError = "File name is required.";
124 + return;
125 + }
126 + if (fileName === "." || fileName === "..") {
127 + this.editSaveError = "File name cannot be '.' or '..'.";
128 + return;
129 + }
130 + if (fileName.includes("/") || fileName.includes("\\")) {
131 + this.editSaveError = "File name cannot include path separators.";
132 + return;
133 + }
134 + if (this.editIsNew && (this.existingNames || []).includes(fileName)) {
135 + this.editSaveError = `An item named "${fileName}" already exists.`;
136 + return;
137 + }
138 +
139 + const content = this.editor ? this.editor.getValue() : this.editContent;
140 + const targetPath = this.editIsNew
141 + ? this.buildChildPath(fileName)
142 + : this.editTarget?.path || ""; // Note: was normalizePath(this.editTarget?.path) but path should be absolute from API
143 +
144 + if (!targetPath) {
145 + window.toastFrontendError("File path is missing.", "Save File");
146 + return;
147 + }
148 +
149 + this.isSaving = true;
150 +
151 + try {
152 + const resp = await fetchApi("/edit_work_dir_file", {
153 + method: "POST",
154 + headers: { "Content-Type": "application/json" },
155 + body: JSON.stringify({ path: targetPath, content }),
156 + });
157 +
158 + const data = await resp.json().catch(() => ({}));
159 +
160 + if (!resp.ok || data.error) {
161 + throw new Error(data.error || "Failed to save file");
162 + }
163 +
164 + this.editOriginalContent = content;
165 + this.editOriginalName = fileName;
166 + this.editIsNew = false;
167 + if (this.editTarget) {
168 + this.editTarget.name = fileName;
169 + this.editTarget.path = targetPath;
170 + }
171 +
172 + // Trigger success callback
173 + if (typeof this.onSaveSuccess === 'function') {
174 + await this.onSaveSuccess();
175 + }
176 +
177 + this.closeFileEditor();
178 + } catch (error) {
179 + const message = error?.message || "Failed to save file";
180 + this.editSaveError = message;
181 + window.toastFrontendError(message, "Save File Error");
182 + } finally {
183 + this.isSaving = false;
184 + }
185 + },
186 +
187 + closeFileEditor() {
188 + window.closeModal("modals/file-editor/file-edit-modal.html");
189 + },
190 +
191 + beforeCloseFileEditor() {
192 + if (this.isSaving) return false;
193 + if (!this.editor) return true;
194 + if (!this.hasEditChanges()) return true;
195 + return confirm("You have unsaved changes. Close without saving?");
196 + },
197 +
198 + // --- Helpers -------------------------------------------------------------
199 +
200 + resetEditState() {
201 + if (this.editor?.destroy) {
202 + this.editor.destroy();
203 + }
204 + this.editor = null;
205 + this.editTarget = null;
206 + this.editFileName = "";
207 + this.editOriginalName = "";
208 + this.editContent = "";
209 + this.editOriginalContent = "";
210 + this.editMimeType = "text/plain";
211 + this.editIsNew = false;
212 + this.isEditLoading = false;
213 + this.isSaving = false;
214 + this.editError = null;
215 + this.editSaveError = null;
216 + this.editClosePromise = null;
217 + this.existingNames = [];
218 + this.onSaveSuccess = null;
219 + },
220 +
221 + normalizePath(path) {
222 + if (!path) return "";
223 + return path.startsWith("/") ? path : `/${path}`;
224 + },
225 +
226 + buildChildPath(name) {
227 + const base = this.normalizePath(this.currentPath || "");
228 + const trimmedBase = base.replace(/\/$/, "");
229 + if (!trimmedBase) return `/${name}`;
230 + return `${trimmedBase}/${name}`;
231 + },
232 +
233 + hasEditChanges() {
234 + const currentValue = this.editor ? this.editor.getValue() : this.editContent;
235 + const nameChanged = (this.editFileName || "") !== (this.editOriginalName || "");
236 + if (this.editIsNew) {
237 + return Boolean((this.editFileName || "").trim() || currentValue);
238 + }
239 + return currentValue !== this.editOriginalContent || nameChanged;
240 + },
241 +
242 + editPathLabel() {
243 + if (this.editIsNew) {
244 + const name = this.editFileName?.trim();
245 + return name ? this.buildChildPath(name) : this.normalizePath(this.currentPath || "");
246 + }
247 + return this.editTarget?.path ? this.normalizePath(this.editTarget.path) : "";
248 + },
249 +
250 + // --- Editor (ACE) integration --------------------------------------------
251 +
252 + scheduleEditorInit() {
253 + window.requestAnimationFrame(() => {
254 + if (this.isEditLoading || this.editError) return;
255 + window.requestAnimationFrame(() => this.initEditor());
256 + });
257 + },
258 +
259 + initEditor() {
260 + const container = document.getElementById("file-editor-container");
261 + if (!container) {
262 + // It's possible the modal hasn't fully rendered yet
263 + console.warn("File editor container not found, deferring editor init");
264 + return;
265 + }
266 +
267 + if (this.editor?.destroy) {
268 + this.editor.destroy();
269 + }
270 +
271 + if (!window.ace?.edit) {
272 + console.error("ACE editor not available");
273 + this.editError = "Editor library not loaded";
274 + return;
275 + }
276 +
277 + const editorInstance = window.ace.edit("file-editor-container");
278 + if (!editorInstance) {
279 + console.error("Failed to create ACE editor instance");
280 + return;
281 + }
282 +
283 + this.editor = editorInstance;
284 +
285 + const darkMode = window.localStorage?.getItem("darkMode");
286 + const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/tomorrow";
287 + this.editor.setTheme(theme);
288 + this.applyEditorMode();
289 + this.editor.setValue(this.editContent || "", -1);
290 + this.editor.clearSelection();
291 + },
292 +
293 + updateEditorMode() {
294 + this.applyEditorMode();
295 + },
296 +
297 + applyEditorMode() {
298 + if (!this.editor?.session) return;
299 + const mode = this.resolveAceMode(this.editMimeType, this.editFileName);
300 + this.editor.session.setMode(mode);
301 + },
302 +
303 + resolveAceMode(mimeType, fileName) {
304 + const mime = (mimeType || "").toLowerCase();
305 + const ext = (fileName || "").split(".").pop()?.toLowerCase();
306 + const mimeMap = {
307 + "application/json": "json",
308 + "application/xml": "xml",
309 + "application/javascript": "javascript",
310 + "application/typescript": "typescript",
311 + "application/x-yaml": "yaml",
312 + "text/plain": "text",
313 + "text/markdown": "markdown",
314 + "text/html": "html",
315 + "text/css": "css",
316 + "text/javascript": "javascript",
317 + "text/typescript": "typescript",
318 + "text/x-python": "python",
319 + "text/x-shellscript": "sh",
320 + "text/x-yaml": "yaml",
321 + "text/xml": "xml",
322 + "text/x-toml": "toml",
323 + };
324 + const extMap = {
325 + js: "javascript",
326 + jsx: "javascript",
327 + ts: "typescript",
328 + tsx: "typescript",
329 + json: "json",
330 + md: "markdown",
331 + markdown: "markdown",
332 + html: "html",
333 + htm: "html",
334 + css: "css",
335 + py: "python",
336 + sh: "sh",
337 + bash: "sh",
338 + zsh: "sh",
339 + yaml: "yaml",
340 + yml: "yaml",
341 + toml: "toml",
342 + xml: "xml",
343 + txt: "text",
344 + csv: "text",
345 + ini: "ini",
346 + };
347 + const mimeMode = mimeMap[mime];
348 + const extMode = extMap[ext];
349 + const mode = (mime && mime !== "text/plain" ? mimeMode : null) || extMode || mimeMode || "text";
350 + return `ace/mode/${mode}`;
351 + },
352 +};
353 +
354 +export const store = createStore("fileEditor", model);