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