//
// Copyright (C) 2025 The Qt Company
//
console.log('Git Config Admin plugin started');
Gerrit.install(plugin => {
const admin = plugin.admin();
admin.addMenuLink("Git Config", "/x/gerrit-plugin-gitconfig/gitconfig", "gerrit-plugin-gitconfig-manageGitConfig");
plugin.screen("gitconfig", "git-config-main");
class RepoPicker extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._repos = [];
this._searchQuery = '';
this._showResults = false;
this._debounceTimeout = null;
this.shadowRoot.innerHTML = `
`;
}
connectedCallback() {
this.input = this.shadowRoot.querySelector('input');
this.resultsContainer = this.shadowRoot.querySelector('.results');
this.input.addEventListener('input', this._handleInput.bind(this));
this._loadAllRepos();
}
async _loadAllRepos() {
try {
const data = await plugin.restApi().get('/projects/?n=1000');
this._repos = Object.keys(data).filter(name => !name.startsWith('All-'));
this._filterResults();
} catch (error) {
console.error('Failed to load repositories:', error);
}
}
_handleInput(e) {
this._searchQuery = e.target.value;
this._filterResults();
this._showResults = true;
this._renderResults();
}
_filterResults() {
const query = this._searchQuery.toLowerCase().trim();
this.filteredRepos = this._repos.filter(name =>
name.toLowerCase().includes(query)
);
}
_renderResults() {
this.resultsContainer.innerHTML = this.filteredRepos
.map(repo => `${repo}
`)
.join('');
this.resultsContainer.style.display = this._showResults &&
this.filteredRepos.length > 0 ? 'block' : 'none';
this.resultsContainer.querySelectorAll('.result-item').forEach(item => {
item.addEventListener('mousedown', (e) => this._selectRepo(e.target.textContent));
});
}
_selectRepo(repo) {
this.input.value = repo;
this._showResults = false;
this._renderResults();
this.dispatchEvent(new CustomEvent('repo-selected', {
detail: { value: repo }
}));
}
}
customElements.define('repo-picker', RepoPicker);
class GitConfigMain extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._view = 'global';
this.selectedRepo = null;
this.errorMessage = null;
this.shadowRoot.innerHTML = `
`;
}
connectedCallback() {
this.tabButtons = this.shadowRoot.querySelectorAll('.tab-button');
this.repoPicker = this.shadowRoot.querySelector('repo-picker');
this.editors = this.shadowRoot.querySelectorAll('git-config-editor');
this._loadConfig();
this.tabButtons.forEach(button => {
button.addEventListener('click', () => this._switchView(button.dataset.view));
});
this.repoPicker.addEventListener('repo-selected', (e) => {
this.selectedRepo = e.detail.value;
this._loadConfig();
});
this.editors.forEach(editor => {
editor.addEventListener('config-save', (e) => this._handleSaveConfig(e.detail.config));
});
}
_showError(message) {
const tabEl = this.shadowRoot.querySelector('.tab-button');
const errorEl = this.shadowRoot.querySelector('.error-message');
errorEl.textContent = message;
errorEl.classList.add('error-visible');
// Scroll to top element
tabEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Auto-hide after 10 seconds
clearTimeout(this._errorTimeout);
this._errorTimeout = setTimeout(() => {
errorEl.classList.remove('error-visible');
}, 10000);
}
_switchView(view) {
this._view = view;
this.tabButtons.forEach(button => {
const active = button.dataset.view === view;
button.classList.toggle('active', active);
});
this.shadowRoot.getElementById('globalView').style.display =
view === 'global' ? 'block' : 'none';
this.shadowRoot.getElementById('repoView').style.display =
view === 'repo' ? 'block' : 'none';
// If switched to repo view, clear selected repo
if (view === 'repo') {
this.selectedRepo = null;
// clear the repo picker input
this.repoPicker.input.value = '';
// clear editor content
this.editors.forEach(editor => editor.content = '');
// Hide the editor by default
this.editors.forEach(editor => editor.style.display = 'none');
return;
}
this._loadConfig();
}
async _loadConfig() {
const url = `/a/config/server/gitconfig${this._view === 'repo' && this.selectedRepo
? `?repo=${encodeURIComponent(this.selectedRepo)}`
: ''
}`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load config');
const content = await response.text();
// decode base64 content
const decodedContent = atob(content);
this.editors.forEach(editor => editor.content = decodedContent);
this.editors.forEach(editor => editor.style.display = 'block');
} catch (error) {
this._showError(error.message);
}
}
async _handleSaveConfig(config) {
if (!config?.trim()) {
this._showError('Empty configuration content');
return;
}
try {
const url = `/config/server/gitconfig${this._view === 'repo' && this.selectedRepo
? `?repo=${encodeURIComponent(this.selectedRepo)}`
: ''
}`;
// encode base64 content
const encodedConfig = btoa(config);
const response = await plugin.restApi().put(url, encodedConfig);
const successText = await response;
this.editors.forEach(editor => editor.showSuccess(successText));
} catch (error) {
this._showError(error.message || 'Error saving configuration');
}
}
}
customElements.define('git-config-main', GitConfigMain);
// Load Prism.js for syntax highlighting
const prismLoader = document.createElement('script');
prismLoader.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js';
prismLoader.onload = () => {
const gitGrammar = document.createElement('script');
gitGrammar.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-git.min.js';
document.head.appendChild(gitGrammar);
};
document.head.appendChild(prismLoader);
class GitConfigEditor extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._content = '';
this._sections = [];
this._validationError = null;
this._highlightTimeout = null;
this.shadowRoot.innerHTML = `
`;
}
connectedCallback() {
this.sectionsContainer = this.shadowRoot.querySelector('.sections-container');
this.saveButton = this.shadowRoot.getElementById('saveButton');
this.saveButton.addEventListener('click', this._save.bind(this));
}
set content(value) {
this._content = value;
this._sections = this._parseConfig();
this._renderSections();
}
get content() {
return this._sectionsToConfig();
}
_parseConfig() {
const sections = [];
let currentSection = null;
let continuation = false;
this._content.split('\n').forEach((line, index) => {
const hasContinuation = line.endsWith('\\');
line = line.replace(/\\$/, '').trim();
if (continuation) {
if (currentSection?.variables.length) {
currentSection.variables[currentSection.variables.length - 1].value += ' ' + line;
}
continuation = hasContinuation;
return;
}
const sectionMatch = line.match(/^\s*\[([^ "\]]+)(?: +"((?:[^"\\]|\\.)*)")?\]\s*$/i);
if (sectionMatch) {
currentSection = {
main: sectionMatch[1],
subsection: sectionMatch[2] ? sectionMatch[2].replace(/\\(.)/g, '$1') : null, // Unescape quotes
rawName: line.trim(),
variables: [],
collapsed: false,
lineNumber: index + 1
};
sections.push(currentSection);
} else if (currentSection) {
const variableMatch = line.match(/^\s*([^=;#]+?)\s*=\s*(.*?)\s*$/);
if (variableMatch) {
currentSection.variables.push({
key: variableMatch[1].trim(),
value: variableMatch[2].trim(),
lineNumber: index + 1
});
}
}
continuation = hasContinuation;
});
return sections;
}
confirmModal(message) {
return new Promise((resolve) => {
const dialog = this.shadowRoot.querySelector('.confirm-dialog');
const overlay = this.shadowRoot.querySelector('.dialog-overlay');
const messageEl = dialog.querySelector('.dialog-message');
const cancelBtn = dialog.querySelector('.cancel-btn');
const confirmBtn = dialog.querySelector('.confirm-btn');
messageEl.textContent = message;
dialog.style.display = 'block';
overlay.style.display = 'block';
const cleanUp = () => {
dialog.style.display = 'none';
overlay.style.display = 'none';
};
const handleConfirm = () => {
cleanUp();
resolve(true);
};
const handleCancel = () => {
cleanUp();
resolve(false);
};
confirmBtn.addEventListener('click', handleConfirm);
cancelBtn.addEventListener('click', handleCancel);
// One-time listeners to prevent multiple registrations
const onceOptions = { once: true };
overlay.addEventListener('click', handleCancel, onceOptions);
});
}
showSuccess(message) {
const successEl = this.shadowRoot.querySelector('.success-message');
successEl.textContent = message;
successEl.classList.add('success-visible');
clearTimeout(this._successTimeout);
this._successTimeout = setTimeout(() => {
successEl.classList.remove('success-visible');
}, 3000);
}
_renderSections() {
this.sectionsContainer.innerHTML = '';
this._sections.forEach((section, sectionIndex) => {
const sectionEl = document.createElement('div');
sectionEl.className = 'section';
sectionEl.setAttribute('data-line', section.lineNumber);
const header = document.createElement('div');
header.className = 'section-header';
header.innerHTML = `
[${section.main}${section.subsection ? ` "${section.subsection.replace(/"/g, '\\"')}"` : ''}]
${section.collapsed ? '+' : '-'}
`;
const nameDisplay = header.querySelector('.section-name');
const nameInput = header.querySelector('.section-edit');
const editBtn = header.querySelector('.edit-section');
editBtn.addEventListener('click', (e) => {
e.stopPropagation();
nameDisplay.style.display = 'none';
nameInput.style.display = 'block';
header.querySelector('.edit-section').style.display = 'none'; // Hide edit button
});
const confirmEdit = header.querySelector('.confirm-edit');
const cancelEdit = header.querySelector('.cancel-edit');
confirmEdit.addEventListener('click', (e) => {
e.stopPropagation();
saveSectionName();
header.querySelector('.edit-section').style.display = 'inline-block';
});
cancelEdit.addEventListener('click', (e) => {
e.stopPropagation();
nameDisplay.style.display = 'inline-block';
nameInput.style.display = 'none';
header.querySelector('.edit-section').style.display = 'inline-block';
});
const saveSectionName = () => {
const mainInput = header.querySelector('.section-main');
const subInput = header.querySelector('.section-sub');
const newMain = mainInput.value.trim();
const newSub = subInput.value.trim().replace(/"/g, ''); // Prevent manual quoting
if (newMain && (newMain !== section.main || newSub !== (section.subsection || ''))) {
section.main = newMain;
section.subsection = newSub || null;
section.rawName = `[${section.main}${section.subsection ? ` "${section.subsection.replace(/(["\\])/g, '\\$1')}"` : ''}]`;
this._content = this._sectionsToConfig();
this._renderSections();
}
nameDisplay.style.display = 'inline-block';
nameInput.style.display = 'none';
};
header.querySelector('.delete-section').addEventListener('click', async (e) => {
e.stopPropagation();
const confirmed = await this.confirmModal(`Are you sure you want to delete the entire [${section.main}${section.subsection ? '.' + section.subsection : ''}] section?`);
if (confirmed) {
this._sections.splice(sectionIndex, 1);
this._content = this._sectionsToConfig();
this._renderSections();
}
});
header.querySelector('.add-variable').addEventListener('click', (e) => {
e.stopPropagation();
section.variables.push({ key: '', value: '' });
this._content = this._sectionsToConfig();
this._renderSections();
});
header.addEventListener('click', () => {
section.collapsed = !section.collapsed;
variablesContainer.style.display = section.collapsed ? 'none' : 'block';
header.querySelector('.toggle-icon').textContent = section.collapsed ? '+' : '-';
});
const variablesContainer = document.createElement('div');
variablesContainer.className = 'variable-container';
section.variables.forEach((variable, varIndex) => {
const row = document.createElement('div');
row.className = 'variable-row';
row.setAttribute('data-line', variable.lineNumber);
row.innerHTML = `
`;
const keyInput = row.querySelector('.variable-key');
keyInput.addEventListener('input', (e) => {
variable.key = e.target.value.trim();
this._content = this._sectionsToConfig();
});
const valueInput = row.querySelector('input:not(.variable-key)');
valueInput.addEventListener('input', (e) => {
variable.value = e.target.value.trim();
this._content = this._sectionsToConfig();
});
row.querySelector('.delete-variable').addEventListener('click', async () => {
const confirmed = await this.confirmModal(`Delete variable "${variable.key}" from [${section.name}]?`);
if (confirmed) {
section.variables.splice(varIndex, 1);
this._content = this._sectionsToConfig();
this._renderSections();
}
});
variablesContainer.appendChild(row);
});
sectionEl.appendChild(header);
sectionEl.appendChild(variablesContainer);
this.sectionsContainer.appendChild(sectionEl);
});
const addSectionBtn = document.createElement('button');
addSectionBtn.className = 'add-section-btn';
addSectionBtn.textContent = '+ Add New Section';
addSectionBtn.addEventListener('click', () => {
this._sections.push({
name: 'new-section',
variables: [],
collapsed: false,
lineNumber: this._content.split('\n').length + 1
});
this._content = this._sectionsToConfig();
this._renderSections();
});
this.sectionsContainer.appendChild(addSectionBtn);
}
_escapeHtml(value) {
return value.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
_sectionsToConfig() {
return this._sections.map(section =>
`[${section.main}${section.subsection ? ` "${section.subsection.replace(/(["\\])/g, '\\$1')}"` : ''}]\n${section.variables.map(v => `\t${v.key} = ${v.value}`).join('\n')
}`
).join('\n');
}
_isAbsolutePath(path) {
const cleanPath = path.replace(/^"+|"+$/g, '');
return cleanPath.startsWith('/') ||
/^[a-zA-Z]:[\\/]/.test(cleanPath) ||
cleanPath.startsWith('\\\\');
}
_isUnsafeIncludePath(path) {
const cleanPath = path.replace(/^"+|"+$/g, '');
return cleanPath.includes('..') ||
/[<>|]/.test(cleanPath) ||
this._isAbsolutePath(cleanPath);
}
_save() {
console.log('Saving config');
if (this._validationError) {
return;
}
this.dispatchEvent(new CustomEvent('config-save', {
detail: { config: this._content }
}));
}
}
customElements.define('git-config-editor', GitConfigEditor);
});