|
| 1 | +// This file is part of Moodle - http://moodle.org/ |
| 2 | +// |
| 3 | +// Moodle is free software: you can redistribute it and/or modify |
| 4 | +// it under the terms of the GNU General Public License as published by |
| 5 | +// the Free Software Foundation, either version 3 of the License, or |
| 6 | +// (at your option) any later version. |
| 7 | +// |
| 8 | +// Moodle is distributed in the hope that it will be useful, |
| 9 | +// but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 10 | +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 11 | +// GNU General Public License for more details. |
| 12 | +// |
| 13 | +// You should have received a copy of the GNU General Public License |
| 14 | +// along with Moodle. If not, see <http://www.gnu.org/licenses/>. |
| 15 | + |
| 16 | +/** |
| 17 | + * Main editor logic for the GitHub Sync file editor. |
| 18 | + * |
| 19 | + * @module local_githubsync/editor |
| 20 | + * @copyright 2026 Allan Haggett |
| 21 | + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later |
| 22 | + */ |
| 23 | +define(['local_githubsync/repository', 'core/notification', 'core/str'], function(Repository, Notification, Str) { |
| 24 | + |
| 25 | + /** @type {Object} Editor state */ |
| 26 | + var state = { |
| 27 | + courseid: 0, |
| 28 | + currentPath: null, |
| 29 | + currentSha: null, |
| 30 | + originalContent: null, |
| 31 | + treeData: [], |
| 32 | + expandedDirs: {}, |
| 33 | + }; |
| 34 | + |
| 35 | + /** @type {Object} DOM element references */ |
| 36 | + var dom = {}; |
| 37 | + |
| 38 | + /** |
| 39 | + * Check if the editor has unsaved changes. |
| 40 | + * |
| 41 | + * @returns {Boolean} |
| 42 | + */ |
| 43 | + var isDirty = function() { |
| 44 | + if (state.originalContent === null) { |
| 45 | + return false; |
| 46 | + } |
| 47 | + return dom.textarea.value !== state.originalContent; |
| 48 | + }; |
| 49 | + |
| 50 | + /** |
| 51 | + * Update save button enabled state. |
| 52 | + */ |
| 53 | + var updateSaveButton = function() { |
| 54 | + var hasMessage = dom.commitMessage.value.trim().length > 0; |
| 55 | + var dirty = isDirty(); |
| 56 | + dom.saveBtn.disabled = !dirty || !hasMessage; |
| 57 | + }; |
| 58 | + |
| 59 | + /** |
| 60 | + * Update the dirty indicator. |
| 61 | + */ |
| 62 | + var checkDirty = function() { |
| 63 | + var dirty = isDirty(); |
| 64 | + if (dirty) { |
| 65 | + dom.unsavedBadge.classList.remove('d-none'); |
| 66 | + } else { |
| 67 | + dom.unsavedBadge.classList.add('d-none'); |
| 68 | + } |
| 69 | + updateSaveButton(); |
| 70 | + }; |
| 71 | + |
| 72 | + /** |
| 73 | + * Build a <ul> element for a tree node. |
| 74 | + * |
| 75 | + * @param {Object} node |
| 76 | + * @param {String} pathPrefix |
| 77 | + * @returns {HTMLElement} |
| 78 | + */ |
| 79 | + var buildTreeUl = function(node, pathPrefix) { |
| 80 | + var ul = document.createElement('ul'); |
| 81 | + |
| 82 | + // Sort directories alphabetically. |
| 83 | + var dirNames = Object.keys(node.children).sort(); |
| 84 | + |
| 85 | + dirNames.forEach(function(name) { |
| 86 | + var dirPath = pathPrefix ? pathPrefix + '/' + name : name; |
| 87 | + var li = document.createElement('li'); |
| 88 | + li.className = 'githubsync-tree-dir'; |
| 89 | + |
| 90 | + if (state.expandedDirs[dirPath]) { |
| 91 | + li.classList.add('expanded'); |
| 92 | + } |
| 93 | + |
| 94 | + var span = document.createElement('span'); |
| 95 | + span.className = 'githubsync-tree-item'; |
| 96 | + span.textContent = name; |
| 97 | + span.addEventListener('click', function(e) { |
| 98 | + e.stopPropagation(); |
| 99 | + li.classList.toggle('expanded'); |
| 100 | + if (li.classList.contains('expanded')) { |
| 101 | + state.expandedDirs[dirPath] = true; |
| 102 | + } else { |
| 103 | + delete state.expandedDirs[dirPath]; |
| 104 | + } |
| 105 | + }); |
| 106 | + |
| 107 | + li.appendChild(span); |
| 108 | + li.appendChild(buildTreeUl(node.children[name], dirPath)); |
| 109 | + ul.appendChild(li); |
| 110 | + }); |
| 111 | + |
| 112 | + // Sort files alphabetically. |
| 113 | + var sortedFiles = node.files.slice().sort(function(a, b) { |
| 114 | + return a.name.localeCompare(b.name); |
| 115 | + }); |
| 116 | + |
| 117 | + sortedFiles.forEach(function(file) { |
| 118 | + var li = document.createElement('li'); |
| 119 | + li.className = 'githubsync-tree-file'; |
| 120 | + |
| 121 | + if (file.isbinary) { |
| 122 | + li.classList.add('binary'); |
| 123 | + } |
| 124 | + if (file.path === state.currentPath) { |
| 125 | + li.classList.add('active'); |
| 126 | + } |
| 127 | + |
| 128 | + var span = document.createElement('span'); |
| 129 | + span.className = 'githubsync-tree-item'; |
| 130 | + span.textContent = file.name; |
| 131 | + |
| 132 | + if (!file.isbinary) { |
| 133 | + span.addEventListener('click', function(e) { |
| 134 | + e.stopPropagation(); |
| 135 | + handleFileClick(file.path); |
| 136 | + }); |
| 137 | + } |
| 138 | + |
| 139 | + li.appendChild(span); |
| 140 | + ul.appendChild(li); |
| 141 | + }); |
| 142 | + |
| 143 | + return ul; |
| 144 | + }; |
| 145 | + |
| 146 | + /** |
| 147 | + * Render the file tree from state.treeData. |
| 148 | + */ |
| 149 | + var renderTree = function() { |
| 150 | + // Build a nested structure from the flat list. |
| 151 | + var root = {children: {}, files: []}; |
| 152 | + |
| 153 | + state.treeData.forEach(function(item) { |
| 154 | + var parts = item.path.split('/'); |
| 155 | + var node = root; |
| 156 | + |
| 157 | + if (item.type === 'dir') { |
| 158 | + parts.forEach(function(part) { |
| 159 | + if (!node.children[part]) { |
| 160 | + node.children[part] = {children: {}, files: []}; |
| 161 | + } |
| 162 | + node = node.children[part]; |
| 163 | + }); |
| 164 | + } else { |
| 165 | + // Navigate to parent directory. |
| 166 | + var dirParts = parts.slice(0, -1); |
| 167 | + dirParts.forEach(function(part) { |
| 168 | + if (!node.children[part]) { |
| 169 | + node.children[part] = {children: {}, files: []}; |
| 170 | + } |
| 171 | + node = node.children[part]; |
| 172 | + }); |
| 173 | + node.files.push(item); |
| 174 | + } |
| 175 | + }); |
| 176 | + |
| 177 | + var ul = buildTreeUl(root, ''); |
| 178 | + dom.tree.innerHTML = ''; |
| 179 | + dom.tree.appendChild(ul); |
| 180 | + }; |
| 181 | + |
| 182 | + /** |
| 183 | + * Load a file's content into the editor. |
| 184 | + * |
| 185 | + * @param {String} filepath |
| 186 | + * @returns {Promise} |
| 187 | + */ |
| 188 | + var loadFile = function(filepath) { |
| 189 | + // Show loading state. |
| 190 | + dom.placeholder.classList.add('d-none'); |
| 191 | + dom.editorContent.classList.remove('d-none'); |
| 192 | + dom.textarea.value = ''; |
| 193 | + dom.textarea.disabled = true; |
| 194 | + dom.filepath.textContent = filepath; |
| 195 | + dom.unsavedBadge.classList.add('d-none'); |
| 196 | + dom.saveBtn.disabled = true; |
| 197 | + |
| 198 | + return Str.get_string('editor_loading', 'local_githubsync').then(function(loadingStr) { |
| 199 | + dom.status.textContent = loadingStr; |
| 200 | + return Repository.getFileContent(state.courseid, filepath); |
| 201 | + }).then(function(result) { |
| 202 | + state.currentPath = filepath; |
| 203 | + state.currentSha = result.sha; |
| 204 | + state.originalContent = result.content; |
| 205 | + dom.textarea.value = result.content; |
| 206 | + dom.textarea.disabled = false; |
| 207 | + dom.status.textContent = ''; |
| 208 | + |
| 209 | + renderTree(); |
| 210 | + }).catch(function(err) { |
| 211 | + Str.get_string('editor_loadfailed', 'local_githubsync').then(function(msg) { |
| 212 | + dom.status.textContent = msg; |
| 213 | + }); |
| 214 | + Notification.exception(err); |
| 215 | + }); |
| 216 | + }; |
| 217 | + |
| 218 | + /** |
| 219 | + * Handle clicking on a file in the tree. |
| 220 | + * |
| 221 | + * @param {String} filepath |
| 222 | + */ |
| 223 | + var handleFileClick = function(filepath) { |
| 224 | + if (filepath === state.currentPath) { |
| 225 | + return; |
| 226 | + } |
| 227 | + |
| 228 | + // Confirm if dirty. |
| 229 | + if (isDirty()) { |
| 230 | + Str.get_string('editor_unsaved_confirm', 'local_githubsync').then(function(msg) { |
| 231 | + if (window.confirm(msg)) { |
| 232 | + loadFile(filepath); |
| 233 | + } |
| 234 | + }); |
| 235 | + return; |
| 236 | + } |
| 237 | + |
| 238 | + loadFile(filepath); |
| 239 | + }; |
| 240 | + |
| 241 | + /** |
| 242 | + * Load the file tree from the server. |
| 243 | + */ |
| 244 | + var loadTree = function() { |
| 245 | + Str.get_string('editor_loading', 'local_githubsync').then(function(loadingStr) { |
| 246 | + dom.tree.innerHTML = '<div class="p-3 text-center text-muted">' + |
| 247 | + '<div class="spinner-border spinner-border-sm" role="status"></div> ' + |
| 248 | + loadingStr + '</div>'; |
| 249 | + |
| 250 | + return Repository.getFileTree(state.courseid); |
| 251 | + }).then(function(result) { |
| 252 | + state.treeData = result.files; |
| 253 | + renderTree(); |
| 254 | + }).catch(function(err) { |
| 255 | + Str.get_string('editor_treefailed', 'local_githubsync').then(function(msg) { |
| 256 | + dom.tree.innerHTML = '<div class="p-3 text-danger">' + msg + '</div>'; |
| 257 | + }); |
| 258 | + Notification.exception(err); |
| 259 | + }); |
| 260 | + }; |
| 261 | + |
| 262 | + /** |
| 263 | + * Handle the save button click. |
| 264 | + */ |
| 265 | + var handleSave = function() { |
| 266 | + var message = dom.commitMessage.value.trim(); |
| 267 | + if (!message) { |
| 268 | + Str.get_string('editor_empty_message', 'local_githubsync').then(function(msg) { |
| 269 | + Notification.addNotification({message: msg, type: 'warning'}); |
| 270 | + }); |
| 271 | + return; |
| 272 | + } |
| 273 | + |
| 274 | + // Show saving state. |
| 275 | + dom.saveBtn.disabled = true; |
| 276 | + Str.get_string('editor_saving', 'local_githubsync').then(function(savingText) { |
| 277 | + dom.saveBtn.textContent = savingText; |
| 278 | + dom.status.textContent = savingText; |
| 279 | + |
| 280 | + return Repository.updateFile( |
| 281 | + state.courseid, |
| 282 | + state.currentPath, |
| 283 | + dom.textarea.value, |
| 284 | + state.currentSha, |
| 285 | + message |
| 286 | + ); |
| 287 | + }).then(function(result) { |
| 288 | + if (result.conflict) { |
| 289 | + return Str.get_string('editor_conflict', 'local_githubsync').then(function(conflictMsg) { |
| 290 | + Notification.addNotification({message: conflictMsg, type: 'error'}); |
| 291 | + return Str.get_string('editor_reload', 'local_githubsync'); |
| 292 | + }).then(function(reloadMsg) { |
| 293 | + dom.status.innerHTML = '<a href="#" id="githubsync-reload-link">' + reloadMsg + '</a>'; |
| 294 | + document.getElementById('githubsync-reload-link').addEventListener('click', function(e) { |
| 295 | + e.preventDefault(); |
| 296 | + loadFile(state.currentPath); |
| 297 | + }); |
| 298 | + }); |
| 299 | + } else if (result.success) { |
| 300 | + state.currentSha = result.newsha; |
| 301 | + state.originalContent = dom.textarea.value; |
| 302 | + dom.commitMessage.value = ''; |
| 303 | + |
| 304 | + var shortsha = result.commitsha.substring(0, 7); |
| 305 | + return Str.get_string('editor_saved', 'local_githubsync', shortsha).then(function(savedMsg) { |
| 306 | + Notification.addNotification({message: savedMsg, type: 'success'}); |
| 307 | + dom.status.textContent = ''; |
| 308 | + checkDirty(); |
| 309 | + }); |
| 310 | + } |
| 311 | + }).catch(function(err) { |
| 312 | + Notification.exception(err); |
| 313 | + }).then(function() { |
| 314 | + // Restore button regardless of outcome. |
| 315 | + Str.get_string('editor_save', 'local_githubsync').then(function(saveText) { |
| 316 | + dom.saveBtn.innerHTML = saveText; |
| 317 | + updateSaveButton(); |
| 318 | + }); |
| 319 | + }); |
| 320 | + }; |
| 321 | + |
| 322 | + return { |
| 323 | + /** |
| 324 | + * Initialise the editor. |
| 325 | + * |
| 326 | + * @param {Number} courseid |
| 327 | + */ |
| 328 | + init: function(courseid) { |
| 329 | + state.courseid = courseid; |
| 330 | + |
| 331 | + // Cache DOM references. |
| 332 | + dom = { |
| 333 | + tree: document.getElementById('githubsync-file-tree'), |
| 334 | + filepath: document.getElementById('githubsync-filepath'), |
| 335 | + unsavedBadge: document.getElementById('githubsync-unsaved-badge'), |
| 336 | + placeholder: document.getElementById('githubsync-editor-placeholder'), |
| 337 | + editorContent: document.getElementById('githubsync-editor-content'), |
| 338 | + textarea: document.getElementById('githubsync-textarea'), |
| 339 | + commitMessage: document.getElementById('githubsync-commit-message'), |
| 340 | + saveBtn: document.getElementById('githubsync-save-btn'), |
| 341 | + refreshBtn: document.getElementById('githubsync-refresh'), |
| 342 | + status: document.getElementById('githubsync-status'), |
| 343 | + }; |
| 344 | + |
| 345 | + // Bind events. |
| 346 | + dom.saveBtn.addEventListener('click', handleSave); |
| 347 | + dom.refreshBtn.addEventListener('click', function() { |
| 348 | + loadTree(); |
| 349 | + }); |
| 350 | + dom.textarea.addEventListener('input', checkDirty); |
| 351 | + dom.commitMessage.addEventListener('input', updateSaveButton); |
| 352 | + |
| 353 | + // Tab key inserts spaces. |
| 354 | + dom.textarea.addEventListener('keydown', function(e) { |
| 355 | + if (e.key === 'Tab') { |
| 356 | + e.preventDefault(); |
| 357 | + var start = dom.textarea.selectionStart; |
| 358 | + var end = dom.textarea.selectionEnd; |
| 359 | + var value = dom.textarea.value; |
| 360 | + dom.textarea.value = value.substring(0, start) + ' ' + value.substring(end); |
| 361 | + dom.textarea.selectionStart = dom.textarea.selectionEnd = start + 4; |
| 362 | + checkDirty(); |
| 363 | + } |
| 364 | + }); |
| 365 | + |
| 366 | + // Ctrl+S to save. |
| 367 | + document.addEventListener('keydown', function(e) { |
| 368 | + if ((e.ctrlKey || e.metaKey) && e.key === 's') { |
| 369 | + e.preventDefault(); |
| 370 | + if (!dom.saveBtn.disabled) { |
| 371 | + handleSave(); |
| 372 | + } |
| 373 | + } |
| 374 | + }); |
| 375 | + |
| 376 | + // Unsaved changes warning. |
| 377 | + window.addEventListener('beforeunload', function(e) { |
| 378 | + if (isDirty()) { |
| 379 | + e.preventDefault(); |
| 380 | + e.returnValue = ''; |
| 381 | + } |
| 382 | + }); |
| 383 | + |
| 384 | + // Load the tree. |
| 385 | + loadTree(); |
| 386 | + }, |
| 387 | + }; |
| 388 | +}); |
0 commit comments