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