aboutsummaryrefslogtreecommitdiffstats
diff options
authorDaniel Smith <daniel.smith@qt.io>2025-04-08 14:25:08 +0200
committerDaniel Smith <daniel.smith@qt.io>2025-04-08 12:35:01 +0000
commit8c90d5c1589e50e6ada1cccef8f01ba810d14f18 (patch)
tree7b0685af2fa365359f329e2c2091f2d8703b6ce4
parent53de62b0ec2622e8c56db75f5f8f1cd2fd18d708 (diff)
Move everything in the js frontend to the plugin.install callbackHEADdev
Since we need to use the plugin's restApi instance, we need access to it from the main class; Instead of setting the plugin instance as a global variable (which can conflict with other plugins), nest the main class inside the plugin installation callback so we have direct access to the plugin object. Change-Id: Ifddfa61aed3d63e0d11558114254ab1c5d1faaec Reviewed-by: Jukka Jokiniva <jukka.jokiniva@qt.io>
-rw-r--r--src/main/resources/static/git-config-admin.js1453
1 files changed, 726 insertions, 727 deletions
diff --git a/src/main/resources/static/git-config-admin.js b/src/main/resources/static/git-config-admin.js
index aac0374..846eae0 100644
--- a/src/main/resources/static/git-config-admin.js
+++ b/src/main/resources/static/git-config-admin.js
@@ -2,819 +2,818 @@
// Copyright (C) 2025 The Qt Company
//
-let pluginInstance = null;
-
-class RepoPicker extends HTMLElement {
- constructor() {
- super();
- this.attachShadow({ mode: 'open' });
- this._repos = [];
- this._searchQuery = '';
- this._showResults = false;
- this._debounceTimeout = null;
-
- this.shadowRoot.innerHTML = `
- <style>
- :host {
- display: block;
- position: relative;
- margin-bottom: 1em;
- }
- .results {
- width: 100%;
- max-height: 300px;
- overflow-y: auto;
- position: absolute;
- z-index: 1000;
- box-shadow: 0 2px 5px rgba(0,0,0,0.2);
- margin-top: 4px;
- background: white;
- display: none;
- }
- .result-item {
- padding: 8px;
- cursor: pointer;
- }
- .result-item:hover {
- background-color: #f0f0f0;
- }
- input {
- width: 100%;
- padding: 8px;
- border: 1px solid #ccc;
- border-radius: 4px;
- }
- </style>
- <input type="text" placeholder="Search repository">
- <div class="results"></div>
- `;
- }
-
- 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 pluginInstance.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 => `<div class="result-item">${repo}</div>`)
- .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 = `
- <style>
- :host {
- display: block;
- padding: 1em;
- max-width: 800px;
- }
- .tab-buttons {
- display: flex;
- margin-bottom: 1em;
- }
- .tab-button {
- padding: 10px 20px;
- cursor: pointer;
- border: 1px solid #ccc;
- background: #f0f0f0;
- }
- .tab-button.active {
- background: #fff;
- border-bottom: 1px solid transparent;
- }
- .error-message {
- color: #721c24;
- background-color: #f8d7da;
- padding: 1em;
- margin: 1em 0;
- border-radius: 4px;
- display: none;
- }
- .error-visible {
- display: block;
- }
- </style>
- <div class="tab-buttons">
- <button class="tab-button active" data-view="global">Global Config</button>
- <button class="tab-button" data-view="repo">Repository Config</button>
- </div>
- <div class="error-message"></div>
- <div id="globalView" class="view">
- <git-config-editor></git-config-editor>
- </div>
- <div id="repoView" class="view" style="display: none;">
- <repo-picker></repo-picker>
- <git-config-editor></git-config-editor>
- </div>
- `;
- }
-
- 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;
- this.repoPicker.input.value = '';
- 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);
- }
- }
+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");
- async _handleSaveConfig(config) {
- if (!config?.trim()) {
- this._showError('Empty configuration content');
- return;
- }
+ class RepoPicker extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: 'open' });
+ this._repos = [];
+ this._searchQuery = '';
+ this._showResults = false;
+ this._debounceTimeout = null;
- 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 pluginInstance.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 = `
+ this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
position: relative;
- margin: 1em 0;
+ margin-bottom: 1em;
}
- .editor-container {
- border: 1px solid #ccc;
- border-radius: 4px;
- padding: 1em;
- background: #f8f9fa;
- }
- .section {
- margin-bottom: 2em;
- border: 1px solid #dee2e6;
- border-radius: 4px;
+ .results {
+ width: 100%;
+ max-height: 300px;
+ overflow-y: auto;
+ position: absolute;
+ z-index: 1000;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
+ margin-top: 4px;
background: white;
+ display: none;
}
- .section-header {
- padding: 12px;
- background: #e9ecef;
- border-bottom: 1px solid #dee2e6;
- cursor: pointer;
- display: flex;
- align-items: center;
- font-weight: bold;
- }
- .section-title {
- flex-grow: 1;
- color: #2c3e50;
- }
- .toggle-icon {
- font-size: 1.2em;
- margin-left: 8px;
- }
- .variable-container {
- padding: 12px;
- }
- .variable-row {
- display: flex;
- align-items: center;
- margin-bottom: 8px;
+ .result-item {
padding: 8px;
- background: #f8f9fa;
- border-radius: 4px;
- }
- .variable-label {
- flex: 0 0 300px;
- font-family: monospace;
- color: #6c757d;
- padding-right: 16px;
- border-right: 1px solid #dee2e6;
+ cursor: pointer;
}
- .variable-value {
- flex-grow: 1;
- padding-left: 16px;
+ .result-item:hover {
+ background-color: #f0f0f0;
}
input {
width: 100%;
- padding: 6px 12px;
- border: 1px solid #ced4da;
- border-radius: 4px;
- font-family: monospace;
- }
- button {
- background: #4CAF50;
- color: white;
- border: none;
- padding: 8px 16px;
+ padding: 8px;
+ border: 1px solid #ccc;
border-radius: 4px;
- cursor: pointer;
- margin-top: 16px;
- }
- .section-controls {
- display: flex;
- gap: 8px;
- align-items: center;
}
+ </style>
+ <input type="text" placeholder="Search repository">
+ <div class="results"></div>
+ `;
+ }
- .icon-btn {
- background: none;
- border: none;
- cursor: pointer;
- padding: 4px;
- font-size: 16px;
- opacity: 0.7;
- transition: opacity 0.2s;
- }
+ connectedCallback() {
+ this.input = this.shadowRoot.querySelector('input');
+ this.resultsContainer = this.shadowRoot.querySelector('.results');
- .icon-btn:hover {
- opacity: 1;
- }
+ this.input.addEventListener('input', this._handleInput.bind(this));
+ this._loadAllRepos();
+ }
- .variable-key {
- width: 90%;
- background: transparent;
- border: 1px solid #ddd;
- padding: 4px;
- }
+ 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);
+ }
+ }
- .add-section-btn {
- width: 100%;
- padding: 12px;
- margin-top: 16px;
- background:rgb(41, 100, 163);
- border: 1px solid #ced4da;
- cursor: pointer;
- transition: background 0.2s;
- }
+ _handleInput(e) {
+ this._searchQuery = e.target.value;
+ this._filterResults();
+ this._showResults = true;
+ this._renderResults();
+ }
- .add-section-btn:hover {
- background:rgb(35, 83, 135);
- }
+ _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 => `<div class="result-item">${repo}</div>`)
+ .join('');
- .variable-value {
+ 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 = `
+ <style>
+ :host {
+ display: block;
+ padding: 1em;
+ max-width: 800px;
+ }
+ .tab-buttons {
display: flex;
- gap: 8px;
- align-items: center;
+ margin-bottom: 1em;
}
- .section-name {
+ .tab-button {
+ padding: 10px 20px;
cursor: pointer;
- }
- .section-edit {
- font-family: monospace;
- width: 80%;
- padding: 2px 4px;
border: 1px solid #ccc;
- border-radius: 3px;
+ background: #f0f0f0;
}
- .edit-section {
- order: -1; /* Move edit button first in controls */
+ .tab-button.active {
+ background: #fff;
+ border-bottom: 1px solid transparent;
}
- .confirm-dialog {
- position: fixed;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- background: white;
- padding: 2em;
- border-radius: 8px;
- box-shadow: 0 0 20px rgba(0,0,0,0.2);
- z-index: 10000;
- }
- .dialog-buttons {
- margin-top: 1em;
- display: flex;
- gap: 1em;
- justify-content: flex-end;
- }
- .dialog-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0,0,0,0.5);
- z-index: 9999;
- }
- .save-container {
- display: flex;
- align-items: center;
- gap: 1rem;
- margin-top: 16px;
- }
-
- .success-message {
- animation: fadeIn 0.5s;
- color: #155724;
- background-color: #d4edda;
- width: 100%;
- text-align: center;
- padding: 0.5em 1em;
+ .error-message {
+ color: #721c24;
+ background-color: #f8d7da;
+ padding: 1em;
+ margin: 1em 0;
border-radius: 4px;
display: none;
- margin: 0; /* Remove previous margin */
- font-size: 0.9em;
- }
-
- .success-visible {
- display: inline-block; /* Changed from block */
}
-
- /* Remove the margin from the button */
- #saveButton {
- min-width: 20%;
- margin: 0;
- }
- @keyframes fadeIn {
- 0% { opacity: 0; }
- 100% { opacity: 1; }
- }
- @keyframes fadeOut {
- 0% { opacity: 1; }
- 100% { opacity: 0; }
+ .error-visible {
+ display: block;
}
</style>
- <div class="editor-container">
- <div class="sections-container"></div>
- <div class="confirm-dialog" style="display: none;">
- <p class="dialog-message"></p>
- <div class="dialog-buttons">
- <button class="cancel-btn">Cancel</button>
- <button class="confirm-btn">Delete</button>
- </div>
+ <div class="tab-buttons">
+ <button class="tab-button active" data-view="global">Global Config</button>
+ <button class="tab-button" data-view="repo">Repository Config</button>
+ </div>
+ <div class="error-message"></div>
+ <div id="globalView" class="view">
+ <git-config-editor></git-config-editor>
</div>
- <div class="dialog-overlay" style="display: none;"></div>
- <div class="save-container">
- <button id="saveButton" type="button">Save Changes</button>
- <div class="success-message"></div>
+ <div id="repoView" class="view" style="display: none;">
+ <repo-picker></repo-picker>
+ <git-config-editor></git-config-editor>
</div>
- </div>
`;
- }
+ }
- connectedCallback() {
- this.sectionsContainer = this.shadowRoot.querySelector('.sections-container');
- this.saveButton = this.shadowRoot.getElementById('saveButton');
- this.saveButton.addEventListener('click', this._save.bind(this));
- }
+ 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();
- set content(value) {
- this._content = value;
- this._sections = this._parseConfig();
- this._renderSections();
- }
+ this.tabButtons.forEach(button => {
+ button.addEventListener('click', () => this._switchView(button.dataset.view));
+ });
- get content() {
- return this._sectionsToConfig();
- }
+ this.repoPicker.addEventListener('repo-selected', (e) => {
+ this.selectedRepo = e.detail.value;
+ this._loadConfig();
+ });
- _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;
- }
+ this.editors.forEach(editor => {
+ editor.addEventListener('config-save', (e) => this._handleSaveConfig(e.detail.config));
+ });
+ }
- 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
- });
- }
+ _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;
}
- continuation = hasContinuation;
- });
+ this._loadConfig();
+ }
- return sections;
- }
+ async _loadConfig() {
+ const url = `/a/config/server/gitconfig${this._view === 'repo' && this.selectedRepo
+ ? `?repo=${encodeURIComponent(this.selectedRepo)}`
+ : ''
+ }`;
- 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);
- });
- }
+ 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);
+ }
+ }
- showSuccess(message) {
- const successEl = this.shadowRoot.querySelector('.success-message');
- successEl.textContent = message;
- successEl.classList.add('success-visible');
+ async _handleSaveConfig(config) {
+ if (!config?.trim()) {
+ this._showError('Empty configuration content');
+ return;
+ }
- clearTimeout(this._successTimeout);
- this._successTimeout = setTimeout(() => {
- successEl.classList.remove('success-visible');
- }, 3000);
+ 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');
+ }
+ }
}
- _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 = `
- <div class="section-title">
- <span class="section-name">[${section.main}${section.subsection ? ` "${section.subsection.replace(/"/g, '\\"')}"` : ''}]</span>
- <button class="icon-btn edit-section" title="Rename section">✏️</button>
- <div class="section-edit" style="display: none">
- <div class="edit-controls">
- <input type="text" class="section-main" value="${this._escapeHtml(section.main || '')}" placeholder="Section">
- <input type="text" class="section-sub" value="${this._escapeHtml(section.subsection || '')}" placeholder="Subsection (optional)">
- <div class="edit-buttons">
- </div>
- <button class="icon-btn confirm-edit" title="Save changes">✅</button>
- <button class="icon-btn cancel-edit" title="Cancel edit">❌</button>
+ 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 = `
+ <style>
+ :host {
+ display: block;
+ position: relative;
+ margin: 1em 0;
+ }
+ .editor-container {
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ padding: 1em;
+ background: #f8f9fa;
+ }
+ .section {
+ margin-bottom: 2em;
+ border: 1px solid #dee2e6;
+ border-radius: 4px;
+ background: white;
+ }
+ .section-header {
+ padding: 12px;
+ background: #e9ecef;
+ border-bottom: 1px solid #dee2e6;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ font-weight: bold;
+ }
+ .section-title {
+ flex-grow: 1;
+ color: #2c3e50;
+ }
+ .toggle-icon {
+ font-size: 1.2em;
+ margin-left: 8px;
+ }
+ .variable-container {
+ padding: 12px;
+ }
+ .variable-row {
+ display: flex;
+ align-items: center;
+ margin-bottom: 8px;
+ padding: 8px;
+ background: #f8f9fa;
+ border-radius: 4px;
+ }
+ .variable-label {
+ flex: 0 0 300px;
+ font-family: monospace;
+ color: #6c757d;
+ padding-right: 16px;
+ border-right: 1px solid #dee2e6;
+ }
+ .variable-value {
+ flex-grow: 1;
+ padding-left: 16px;
+ }
+ input {
+ width: 100%;
+ padding: 6px 12px;
+ border: 1px solid #ced4da;
+ border-radius: 4px;
+ font-family: monospace;
+ }
+ button {
+ background: #4CAF50;
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 4px;
+ cursor: pointer;
+ margin-top: 16px;
+ }
+ .section-controls {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ }
+
+ .icon-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 4px;
+ font-size: 16px;
+ opacity: 0.7;
+ transition: opacity 0.2s;
+ }
+
+ .icon-btn:hover {
+ opacity: 1;
+ }
+
+ .variable-key {
+ width: 90%;
+ background: transparent;
+ border: 1px solid #ddd;
+ padding: 4px;
+ }
+
+ .add-section-btn {
+ width: 100%;
+ padding: 12px;
+ margin-top: 16px;
+ background:rgb(41, 100, 163);
+ border: 1px solid #ced4da;
+ cursor: pointer;
+ transition: background 0.2s;
+ }
+
+ .add-section-btn:hover {
+ background:rgb(35, 83, 135);
+ }
+
+ .variable-value {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ }
+ .section-name {
+ cursor: pointer;
+ }
+ .section-edit {
+ font-family: monospace;
+ width: 80%;
+ padding: 2px 4px;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+ }
+ .edit-section {
+ order: -1; /* Move edit button first in controls */
+ }
+ .confirm-dialog {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: white;
+ padding: 2em;
+ border-radius: 8px;
+ box-shadow: 0 0 20px rgba(0,0,0,0.2);
+ z-index: 10000;
+ }
+ .dialog-buttons {
+ margin-top: 1em;
+ display: flex;
+ gap: 1em;
+ justify-content: flex-end;
+ }
+ .dialog-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0,0,0,0.5);
+ z-index: 9999;
+ }
+ .save-container {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-top: 16px;
+ }
+
+ .success-message {
+ animation: fadeIn 0.5s;
+ color: #155724;
+ background-color: #d4edda;
+ width: 100%;
+ text-align: center;
+ padding: 0.5em 1em;
+ border-radius: 4px;
+ display: none;
+ margin: 0; /* Remove previous margin */
+ font-size: 0.9em;
+ }
+
+ .success-visible {
+ display: inline-block; /* Changed from block */
+ }
+
+ /* Remove the margin from the button */
+ #saveButton {
+ min-width: 20%;
+ margin: 0;
+ }
+ @keyframes fadeIn {
+ 0% { opacity: 0; }
+ 100% { opacity: 1; }
+ }
+ @keyframes fadeOut {
+ 0% { opacity: 1; }
+ 100% { opacity: 0; }
+ }
+ </style>
+ <div class="editor-container">
+ <div class="sections-container"></div>
+ <div class="confirm-dialog" style="display: none;">
+ <p class="dialog-message"></p>
+ <div class="dialog-buttons">
+ <button class="cancel-btn">Cancel</button>
+ <button class="confirm-btn">Delete</button>
</div>
</div>
- </div>
- <div class="section-controls">
- <button class="icon-btn edit-section" title="Rename section" style="display: none">✏️</button>
- <button class="icon-btn delete-section" title="Delete section">🗑️</button>
- <button class="icon-btn add-variable" title="Add variable">➕</button>
- <span class="toggle-icon">${section.collapsed ? '+' : '-'}</span>
- </div>
- `;
-
- 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
- });
+ <div class="dialog-overlay" style="display: none;"></div>
+ <div class="save-container">
+ <button id="saveButton" type="button">Save Changes</button>
+ <div class="success-message"></div>
+ </div>
+ </div>
+ `;
+ }
- const confirmEdit = header.querySelector('.confirm-edit');
- const cancelEdit = header.querySelector('.cancel-edit');
+ connectedCallback() {
+ this.sectionsContainer = this.shadowRoot.querySelector('.sections-container');
+ this.saveButton = this.shadowRoot.getElementById('saveButton');
+ this.saveButton.addEventListener('click', this._save.bind(this));
+ }
- confirmEdit.addEventListener('click', (e) => {
- e.stopPropagation();
- saveSectionName();
- header.querySelector('.edit-section').style.display = 'inline-block';
- });
+ set content(value) {
+ this._content = value;
+ this._sections = this._parseConfig();
+ this._renderSections();
+ }
- cancelEdit.addEventListener('click', (e) => {
- e.stopPropagation();
- nameDisplay.style.display = 'inline-block';
- nameInput.style.display = 'none';
- header.querySelector('.edit-section').style.display = 'inline-block';
- });
+ get content() {
+ return this._sectionsToConfig();
+ }
- 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
+ _parseConfig() {
+ const sections = [];
+ let currentSection = null;
+ let continuation = false;
- 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();
+ 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;
}
- 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();
+ 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;
});
- header.querySelector('.add-variable').addEventListener('click', (e) => {
- e.stopPropagation();
- section.variables.push({ key: '', value: '' });
- this._content = this._sectionsToConfig();
- this._renderSections();
- });
+ 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';
+ };
- header.addEventListener('click', () => {
- section.collapsed = !section.collapsed;
- variablesContainer.style.display = section.collapsed ? 'none' : 'block';
- header.querySelector('.toggle-icon').textContent = section.collapsed ? '+' : '-';
+ 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);
});
+ }
- const variablesContainer = document.createElement('div');
- variablesContainer.className = 'variable-container';
+ showSuccess(message) {
+ const successEl = this.shadowRoot.querySelector('.success-message');
+ successEl.textContent = message;
+ successEl.classList.add('success-visible');
- section.variables.forEach((variable, varIndex) => {
- const row = document.createElement('div');
- row.className = 'variable-row';
- row.setAttribute('data-line', variable.lineNumber);
+ clearTimeout(this._successTimeout);
+ this._successTimeout = setTimeout(() => {
+ successEl.classList.remove('success-visible');
+ }, 3000);
+ }
- row.innerHTML = `
- <div class="variable-label">
- <input type="text" value="${this._escapeHtml(variable.key)}"
- placeholder="Variable name" class="variable-key">
+ _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 = `
+ <div class="section-title">
+ <span class="section-name">[${section.main}${section.subsection ? ` "${section.subsection.replace(/"/g, '\\"')}"` : ''}]</span>
+ <button class="icon-btn edit-section" title="Rename section">✏️</button>
+ <div class="section-edit" style="display: none">
+ <div class="edit-controls">
+ <input type="text" class="section-main" value="${this._escapeHtml(section.main || '')}" placeholder="Section">
+ <input type="text" class="section-sub" value="${this._escapeHtml(section.subsection || '')}" placeholder="Subsection (optional)">
+ <div class="edit-buttons">
+ </div>
+ <button class="icon-btn confirm-edit" title="Save changes">✅</button>
+ <button class="icon-btn cancel-edit" title="Cancel edit">❌</button>
+ </div>
</div>
- <div class="variable-value">
- <input type="text" value="${this._escapeHtml(variable.value)}">
- <button class="icon-btn delete-variable" title="Delete variable">🗑️</button>
- </div>
- `;
+ </div>
+ <div class="section-controls">
+ <button class="icon-btn edit-section" title="Rename section" style="display: none">✏️</button>
+ <button class="icon-btn delete-section" title="Delete section">🗑️</button>
+ <button class="icon-btn add-variable" title="Add variable">➕</button>
+ <span class="toggle-icon">${section.collapsed ? '+' : '-'}</span>
+ </div>
+ `;
- const keyInput = row.querySelector('.variable-key');
- keyInput.addEventListener('input', (e) => {
- variable.key = e.target.value.trim();
- this._content = this._sectionsToConfig();
+ 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 valueInput = row.querySelector('input:not(.variable-key)');
- valueInput.addEventListener('input', (e) => {
- variable.value = e.target.value.trim();
- this._content = this._sectionsToConfig();
+ 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';
});
- row.querySelector('.delete-variable').addEventListener('click', async () => {
- const confirmed = await this.confirmModal(`Delete variable "${variable.key}" from [${section.name}]?`);
+ 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) {
- section.variables.splice(varIndex, 1);
+ this._sections.splice(sectionIndex, 1);
this._content = this._sectionsToConfig();
this._renderSections();
}
});
- variablesContainer.appendChild(row);
- });
+ 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 = `
+ <div class="variable-label">
+ <input type="text" value="${this._escapeHtml(variable.key)}"
+ placeholder="Variable name" class="variable-key">
+ </div>
+ <div class="variable-value">
+ <input type="text" value="${this._escapeHtml(variable.value)}">
+ <button class="icon-btn delete-variable" title="Delete variable">🗑️</button>
+ </div>
+ `;
+
+ const keyInput = row.querySelector('.variable-key');
+ keyInput.addEventListener('input', (e) => {
+ variable.key = e.target.value.trim();
+ this._content = this._sectionsToConfig();
+ });
- 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
+ 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);
});
- this._content = this._sectionsToConfig();
- this._renderSections();
- });
- this.sectionsContainer.appendChild(addSectionBtn);
- }
- _escapeHtml(value) {
- return value.replace(/&/g, '&amp;')
- .replace(/</g, '&lt;')
- .replace(/>/g, '&gt;')
- .replace(/"/g, '&quot;')
- .replace(/'/g, '&#039;');
- }
+ 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);
+ }
- _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\n') + "\n";
- }
+ _escapeHtml(value) {
+ return value.replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#039;');
+ }
- _isAbsolutePath(path) {
- const cleanPath = path.replace(/^"+|"+$/g, '');
- return cleanPath.startsWith('/') ||
- /^[a-zA-Z]:[\\/]/.test(cleanPath) ||
- cleanPath.startsWith('\\\\');
- }
+ _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');
+ }
- _isUnsafeIncludePath(path) {
- const cleanPath = path.replace(/^"+|"+$/g, '');
- return cleanPath.includes('..') ||
- /[<>|]/.test(cleanPath) ||
- this._isAbsolutePath(cleanPath);
- }
+ _isAbsolutePath(path) {
+ const cleanPath = path.replace(/^"+|"+$/g, '');
+ return cleanPath.startsWith('/') ||
+ /^[a-zA-Z]:[\\/]/.test(cleanPath) ||
+ cleanPath.startsWith('\\\\');
+ }
- _save() {
- console.log('Saving config');
- if (this._validationError) {
- return;
+ _isUnsafeIncludePath(path) {
+ const cleanPath = path.replace(/^"+|"+$/g, '');
+ return cleanPath.includes('..') ||
+ /[<>|]/.test(cleanPath) ||
+ this._isAbsolutePath(cleanPath);
}
- this.dispatchEvent(new CustomEvent('config-save', {
- detail: { config: this._content }
- }));
- }
-}
+ _save() {
+ console.log('Saving config');
+ if (this._validationError) {
+ return;
+ }
-customElements.define('git-config-editor', GitConfigEditor);
+ this.dispatchEvent(new CustomEvent('config-save', {
+ detail: { config: this._content }
+ }));
+ }
+ }
-console.log('Git Config Admin plugin started');
-Gerrit.install(plugin => {
- pluginInstance = plugin;
- const admin = plugin.admin();
- admin.addMenuLink("Git Config", "/x/gerrit-plugin-gitconfig/gitconfig", "gerrit-plugin-gitconfig-manageGitConfig");
- plugin.screen("gitconfig", "git-config-main");
+ customElements.define('git-config-editor', GitConfigEditor);
});