Skip to content

Commit 655d009

Browse files
allanhaggettclaude
andcommitted
Add in-Moodle file editor for browsing, editing, and committing to GitHub
Adds a two-panel AJAX-driven editor UI that lets course developers browse repo files, edit content, and commit changes back to GitHub without leaving Moodle. Includes file tree with expand/collapse, dirty detection, SHA-based conflict handling, Tab/Ctrl+S shortcuts, and clear error messaging for PAT permission issues. New files: editor.php, editor.mustache, styles.css, db/services.php, 3 external functions (get_file_tree, get_file_content, update_file), 2 AMD modules (editor, repository). Modified: client.php (write API methods), lib.php (nav link), config.php (editor button), lang strings, version bump to 0.6.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent be4dfb4 commit 655d009

File tree

16 files changed

+1775
-11
lines changed

16 files changed

+1775
-11
lines changed

amd/build/editor.min.js

Lines changed: 388 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,388 @@
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

Comments
 (0)