Files
fourdst/electron/renderer.js
Emily Boudreaux 45de795920 feat(electron): implement inline metadata editing with save options modal
- Remove Edit button from header, add pencil icons next to editable fields
- Add inline editing for Version, Author, and Comment metadata fields
- Implement save options modal with overwrite/save-as-new functionality
- Add file dialog integration for save-as-new option
- Fix backend MANIFEST_FILENAME error by using correct string literals
- Add CSS styling for editable fields and save options modal
- Clean up debug logging code

BREAKING CHANGE: Edit button removed from UI in favor of inline editing
2025-08-09 20:16:07 -04:00

745 lines
24 KiB
JavaScript

const { ipcRenderer } = require('electron');
const path = require('path');
// --- STATE ---
let currentBundle = null;
// --- DOM ELEMENTS ---
// Views
const welcomeScreen = document.getElementById('welcome-screen');
const bundleView = document.getElementById('bundle-view');
const createBundleForm = document.getElementById('create-bundle-form'); // This will be a modal later
// Sidebar buttons
const openBundleBtn = document.getElementById('open-bundle-btn');
const createBundleBtn = document.getElementById('create-bundle-btn');
// Bundle action buttons
const signBundleBtn = document.getElementById('sign-bundle-btn');
const validateBundleBtn = document.getElementById('validate-bundle-btn');
const fillBundleBtn = document.getElementById('fill-bundle-btn');
const clearBundleBtn = document.getElementById('clear-bundle-btn');
const saveMetadataBtn = document.getElementById('save-metadata-btn');
// Save options modal elements
const saveOptionsModal = document.getElementById('save-options-modal');
const overwriteBundleBtn = document.getElementById('overwrite-bundle-btn');
const saveAsNewBtn = document.getElementById('save-as-new-btn');
// Bundle display
const bundleTitle = document.getElementById('bundle-title');
const manifestDetails = document.getElementById('manifest-details');
const pluginsList = document.getElementById('plugins-list');
const validationResults = document.getElementById('validation-results');
// Tabs
const tabLinks = document.querySelectorAll('.tab-link');
const tabPanes = document.querySelectorAll('.tab-pane');
const validationTabLink = document.querySelector('button[data-tab="validation-tab"]');
// Modal
const modal = document.getElementById('modal');
const modalTitle = document.getElementById('modal-title');
const modalMessage = document.getElementById('modal-message');
const modalCloseBtn = document.getElementById('modal-close-btn');
// Spinner
const spinner = document.getElementById('spinner');
// Fill Modal elements
const fillModal = document.getElementById('fill-modal');
const closeFillModalButton = document.querySelector('.close-fill-modal-button');
const fillModalTitle = document.getElementById('fill-modal-title');
const fillModalBody = document.getElementById('fill-modal-body');
const fillTargetsList = document.getElementById('fill-targets-list');
const startFillButton = document.getElementById('start-fill-button');
const fillProgressView = document.getElementById('fill-progress-view');
const fillProgressList = document.getElementById('fill-progress-list');
let currentBundlePath = null;
let hasUnsavedChanges = false;
let originalMetadata = {};
// --- INITIALIZATION ---
document.addEventListener('DOMContentLoaded', async () => {
// Set initial view
showView('welcome-screen');
// Set initial theme
const isDarkMode = await ipcRenderer.invoke('get-dark-mode');
document.body.classList.toggle('dark-mode', isDarkMode);
// Setup event listeners
setupEventListeners();
});
// --- EVENT LISTENERS ---
function setupEventListeners() {
// Theme updates
ipcRenderer.on('theme-updated', (event, { shouldUseDarkColors }) => {
document.body.classList.toggle('dark-mode', shouldUseDarkColors);
});
// Sidebar navigation
openBundleBtn.addEventListener('click', handleOpenBundle);
createBundleBtn.addEventListener('click', () => {
// TODO: Replace with modal
showView('create-bundle-form');
showModal('Not Implemented', 'The create bundle form will be moved to a modal dialog.');
});
// Tab navigation
tabLinks.forEach(link => {
link.addEventListener('click', () => switchTab(link.dataset.tab));
});
// Modal close button
modalCloseBtn.addEventListener('click', hideModal);
// Bundle actions
signBundleBtn.addEventListener('click', handleSignBundle);
validateBundleBtn.addEventListener('click', handleValidateBundle);
clearBundleBtn.addEventListener('click', handleClearBundle);
saveMetadataBtn.addEventListener('click', showSaveOptionsModal);
overwriteBundleBtn.addEventListener('click', () => handleSaveMetadata(false));
saveAsNewBtn.addEventListener('click', () => handleSaveMetadata(true));
fillBundleBtn.addEventListener('click', async () => {
if (!currentBundlePath) {
showModal('Error', 'No bundle is currently open.');
return;
}
showSpinner();
const result = await ipcRenderer.invoke('get-fillable-targets', currentBundlePath);
hideSpinner();
if (!result.success) {
showModal('Error', `Failed to get fillable targets: ${result.error}`);
return;
}
const targets = result.data;
if (Object.keys(targets).length === 0) {
showModal('Info', 'The bundle is already full. No new targets to build.');
return;
}
populateFillTargetsList(targets);
fillModal.style.display = 'block';
});
closeFillModalButton.addEventListener('click', () => {
fillModal.style.display = 'none';
});
function populateFillTargetsList(plugins) {
fillTargetsList.innerHTML = '';
for (const [pluginName, targets] of Object.entries(plugins)) {
if (targets.length > 0) {
const pluginHeader = document.createElement('h4');
pluginHeader.textContent = `Plugin: ${pluginName}`;
fillTargetsList.appendChild(pluginHeader);
targets.forEach(target => {
const item = document.createElement('div');
item.className = 'fill-target-item';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = true;
checkbox.id = `target-${pluginName}-${target.triplet}`;
checkbox.dataset.pluginName = pluginName;
checkbox.dataset.targetTriplet = target.triplet;
checkbox.dataset.targetInfo = JSON.stringify(target);
const label = document.createElement('label');
label.htmlFor = checkbox.id;
label.textContent = `${target.triplet} (${target.type})`;
item.appendChild(checkbox);
item.appendChild(label);
fillTargetsList.appendChild(item);
});
}
}
// Reset view
fillModalBody.style.display = 'block';
fillProgressView.style.display = 'none';
}
startFillButton.addEventListener('click', async () => {
const selectedTargets = {};
const checkboxes = fillTargetsList.querySelectorAll('input[type="checkbox"]:checked');
if (checkboxes.length === 0) {
showModal('Info', 'No targets selected to fill.');
return;
}
checkboxes.forEach(cb => {
const pluginName = cb.dataset.pluginName;
if (!selectedTargets[pluginName]) {
selectedTargets[pluginName] = [];
}
selectedTargets[pluginName].push(JSON.parse(cb.dataset.targetInfo));
});
fillModalBody.style.display = 'none';
fillProgressView.style.display = 'block';
fillModalTitle.textContent = 'Filling Bundle...';
populateFillProgressList(selectedTargets);
const result = await ipcRenderer.invoke('fill-bundle', {
bundlePath: currentBundlePath,
targetsToBuild: selectedTargets
});
fillModalTitle.textContent = 'Fill Complete';
if (!result.success) {
// A final error message if the whole process fails.
const p = document.createElement('p');
p.style.color = 'var(--error-color)';
p.textContent = `Error: ${result.error}`;
fillProgressList.appendChild(p);
}
});
function populateFillProgressList(plugins) {
fillProgressList.innerHTML = '';
for (const [pluginName, targets] of Object.entries(plugins)) {
targets.forEach(target => {
const item = document.createElement('div');
item.className = 'fill-target-item';
item.id = `progress-${pluginName}-${target.triplet}`;
const indicator = document.createElement('div');
indicator.className = 'progress-indicator';
const label = document.createElement('span');
label.textContent = `${pluginName} - ${target.triplet}`;
item.appendChild(indicator);
item.appendChild(label);
fillProgressList.appendChild(item);
});
}
}
ipcRenderer.on('fill-bundle-progress', (event, progress) => {
console.log('Progress update:', progress);
if (typeof progress === 'object' && progress.status) {
const { status, plugin, target, message } = progress;
const progressItem = document.getElementById(`progress-${plugin}-${target}`);
if (progressItem) {
const indicator = progressItem.querySelector('.progress-indicator');
indicator.className = 'progress-indicator'; // Reset classes
switch (status) {
case 'building':
indicator.classList.add('spinner-icon');
break;
case 'success':
indicator.classList.add('success-icon');
break;
case 'failure':
indicator.classList.add('failure-icon');
break;
}
const label = progressItem.querySelector('span');
if (message) {
label.textContent = `${plugin} - ${target}: ${message}`;
}
}
} else if (typeof progress === 'object' && progress.message) {
// Handle final completion message
if (progress.message.includes('✅')) {
fillModalTitle.textContent = 'Fill Complete!';
}
} else {
// Handle simple string progress messages
const p = document.createElement('p');
p.textContent = progress;
fillProgressList.appendChild(p);
}
});
}
// --- VIEW AND UI LOGIC ---
function showView(viewId) {
[welcomeScreen, bundleView, createBundleForm].forEach(view => {
view.classList.toggle('hidden', view.id !== viewId);
});
}
function switchTab(tabId) {
tabPanes.forEach(pane => {
pane.classList.toggle('active', pane.id === tabId);
});
tabLinks.forEach(link => {
link.classList.toggle('active', link.dataset.tab === tabId);
});
}
function showSpinner() {
spinner.classList.remove('hidden');
}
function hideSpinner() {
spinner.classList.add('hidden');
}
function showModal(title, message, type = 'info') {
modalTitle.textContent = title;
modalMessage.innerHTML = message; // Use innerHTML to allow for formatted messages
modal.classList.remove('hidden');
}
function hideModal() {
modal.classList.add('hidden');
}
// --- BUNDLE ACTIONS HANDLERS ---
async function handleOpenBundle() {
const bundlePath = await ipcRenderer.invoke('select-file');
if (!bundlePath) return;
showSpinner();
showModal('Opening...', `Opening bundle: ${path.basename(bundlePath)}`);
const result = await ipcRenderer.invoke('open-bundle', bundlePath);
hideSpinner();
if (result.success) {
currentBundle = result;
currentBundlePath = bundlePath;
displayBundleInfo(result.report);
showView('bundle-view');
hideModal();
} else {
showModal('Error Opening Bundle', `Failed to open bundle: ${result ? result.error : 'Unknown error'}`);
}
}
async function handleSignBundle() {
if (!currentBundlePath) return;
const result = await ipcRenderer.invoke('select-private-key');
if (result.canceled || !result.filePaths.length) {
return; // User canceled the dialog
}
const privateKeyPath = result.filePaths[0];
showSpinner();
const signResult = await ipcRenderer.invoke('sign-bundle', { bundlePath: currentBundlePath, privateKey: privateKeyPath });
hideSpinner();
if (signResult.success) {
showModal('Success', 'Bundle signed successfully. Reloading...');
await reloadCurrentBundle();
hideModal();
} else {
showModal('Sign Error', `Failed to sign bundle: ${signResult.error}`);
}
}
async function handleValidateBundle() {
if (!currentBundlePath) return;
showSpinner();
const result = await ipcRenderer.invoke('validate-bundle', currentBundlePath);
hideSpinner();
if (result.success) {
// With the new JSON architecture, validation data is directly in result
const errors = result.errors || [];
const warnings = result.warnings || [];
const validationIssues = errors.concat(warnings);
if (validationIssues.length > 0) {
validationResults.textContent = validationIssues.join('\n');
validationTabLink.classList.remove('hidden');
} else {
validationResults.textContent = 'Bundle is valid.';
validationTabLink.classList.add('hidden');
}
// Switch to the validation tab to show the results
switchTab('validation-tab');
// Show summary in modal
const summary = result.summary || { errors: errors.length, warnings: warnings.length };
const message = `Validation finished with ${summary.errors} errors and ${summary.warnings} warnings.`;
showModal('Validation Complete', message);
} else {
showModal('Validation Error', `Failed to validate bundle: ${result.error}`);
}
}
async function handleClearBundle() {
if (!currentBundlePath) return;
showSpinner();
const result = await ipcRenderer.invoke('clear-bundle', currentBundlePath);
hideSpinner();
if (result.success) {
showModal('Success', 'All binaries have been cleared. Reloading...');
await reloadCurrentBundle();
hideModal();
} else {
showModal('Clear Error', `Failed to clear binaries: ${result.error}`);
}
}
async function handleFillBundle() {
if (!currentBundle) return showModal('Action Canceled', 'Please open a bundle first.');
showSpinner();
showModal('Filling Bundle...', 'Adding local binaries to bundle.');
const result = await ipcRenderer.invoke('fill-bundle', currentBundle.bundlePath);
hideSpinner();
if (result.success) {
showModal('Success', 'Binaries filled successfully. Reloading...');
await reloadCurrentBundle();
hideModal();
} else {
showModal('Fill Error', `Failed to fill bundle: ${result.error}`);
}
}
// --- DATA DISPLAY ---
async function reloadCurrentBundle() {
if (!currentBundle) return;
const reloadResult = await ipcRenderer.invoke('open-bundle', currentBundle.bundlePath);
if (reloadResult.success) {
currentBundle = reloadResult;
displayBundleInfo(reloadResult.report);
} else {
showModal('Reload Error', `Failed to reload bundle details: ${reloadResult.error}`);
}
}
function displayBundleInfo(report) {
if (!report) {
showModal('Display Error', 'Could not load bundle information.');
return;
}
const { manifest, signature, validation, plugins } = report;
// Store original metadata for comparison
originalMetadata = {
bundleVersion: manifest.bundleVersion || '',
bundleAuthor: manifest.bundleAuthor || '',
bundleComment: manifest.bundleComment || ''
};
hasUnsavedChanges = false;
updateSaveButtonVisibility();
// Set bundle title
bundleTitle.textContent = manifest.bundleName || 'Untitled Bundle';
// --- Overview Tab ---
const trustStatus = signature.status || 'UNSIGNED';
const trustColorClass = {
'TRUSTED': 'trusted',
'UNTRUSTED': 'untrusted',
'INVALID': 'untrusted',
'TAMPERED': 'untrusted',
'UNSIGNED': 'unsigned',
'ERROR': 'untrusted',
'UNSUPPORTED': 'warning'
}[trustStatus] || 'unsigned';
manifestDetails.innerHTML = `
<div class="card">
<div class="card-header">
<h3>Trust Status</h3>
<div class="trust-indicator-container">
<div class="trust-indicator ${trustColorClass}"></div>
<span>${trustStatus}</span>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3>Manifest Details</h3></div>
<div class="card-content">
${createEditableField('Version', 'bundleVersion', manifest.bundleVersion || 'N/A')}
${createEditableField('Author', 'bundleAuthor', manifest.bundleAuthor || 'N/A')}
<p><strong>Bundled On:</strong> ${manifest.bundledOn || 'N/A'}</p>
${createEditableField('Comment', 'bundleComment', manifest.bundleComment || 'N/A')}
${manifest.bundleAuthorKeyFingerprint ? `<p><strong>Author Key:</strong> ${manifest.bundleAuthorKeyFingerprint}</p>` : ''}
${manifest.bundleSignature ? `<p><strong>Signature:</strong> <span class="signature">${manifest.bundleSignature}</span></p>` : ''}
</div>
</div>
`;
// Add event listeners for edit functionality
setupEditableFieldListeners();
// --- Plugins Tab ---
pluginsList.innerHTML = '';
if (plugins && Object.keys(plugins).length > 0) {
Object.entries(plugins).forEach(([pluginName, pluginData]) => {
const binariesInfo = pluginData.binaries.map(b => {
const compatClass = b.is_compatible ? 'compatible' : 'incompatible';
const compatText = b.is_compatible ? 'Compatible' : 'Incompatible';
const platformTriplet = b.platform && b.platform.triplet ? `(${b.platform.triplet})` : '';
return `<li class="binary-info ${compatClass}"><strong>${b.path}</strong> ${platformTriplet} - ${compatText}</li>`;
}).join('');
const pluginCard = document.createElement('div');
pluginCard.className = 'card';
pluginCard.innerHTML = `
<div class="card-header"><h4>${pluginName}</h4></div>
<div class="card-content">
<p><strong>Source:</strong> ${pluginData.sdist_path}</p>
<p><strong>Binaries:</strong></p>
<ul>${binariesInfo.length > 0 ? binariesInfo : '<li>No binaries found.</li>'}</ul>
</div>
`;
pluginsList.appendChild(pluginCard);
});
} else {
pluginsList.innerHTML = '<div class="card"><div class="card-content"><p>No plugins found in this bundle.</p></div></div>';
}
// --- Validation Tab ---
const validationIssues = validation.errors.concat(validation.warnings);
if (validationIssues.length > 0) {
validationResults.textContent = validationIssues.join('\n');
validationTabLink.classList.remove('hidden');
} else {
validationResults.textContent = 'Bundle is valid.';
validationTabLink.classList.add('hidden');
}
// Reset to overview tab by default
switchTab('overview-tab');
}
// Helper function to create editable fields with pencil icons
function createEditableField(label, fieldName, value) {
const displayValue = value === 'N/A' ? '' : value;
return `
<p class="editable-field">
<strong>${label}:</strong>
<span class="field-display" data-field="${fieldName}">${value}</span>
<span class="field-edit hidden" data-field="${fieldName}">
<input type="text" class="field-input" data-field="${fieldName}" value="${displayValue}">
</span>
<button class="edit-icon" data-field="${fieldName}" title="Edit ${label}">✏️</button>
</p>
`;
}
// Setup event listeners for editable fields
function setupEditableFieldListeners() {
const editIcons = document.querySelectorAll('.edit-icon');
const fieldInputs = document.querySelectorAll('.field-input');
editIcons.forEach(icon => {
icon.addEventListener('click', (e) => {
const fieldName = e.target.dataset.field;
toggleFieldEdit(fieldName, true);
});
});
fieldInputs.forEach(input => {
input.addEventListener('blur', (e) => {
const fieldName = e.target.dataset.field;
saveFieldEdit(fieldName);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const fieldName = e.target.dataset.field;
saveFieldEdit(fieldName);
} else if (e.key === 'Escape') {
const fieldName = e.target.dataset.field;
cancelFieldEdit(fieldName);
}
});
input.addEventListener('input', () => {
checkForChanges();
});
});
}
// Toggle between display and edit mode for a field
function toggleFieldEdit(fieldName, editMode) {
const displaySpan = document.querySelector(`.field-display[data-field="${fieldName}"]`);
const editSpan = document.querySelector(`.field-edit[data-field="${fieldName}"]`);
const input = document.querySelector(`.field-input[data-field="${fieldName}"]`);
const icon = document.querySelector(`.edit-icon[data-field="${fieldName}"]`);
if (editMode) {
displaySpan.classList.add('hidden');
editSpan.classList.remove('hidden');
icon.textContent = '✅';
icon.title = 'Save';
input.focus();
input.select();
} else {
displaySpan.classList.remove('hidden');
editSpan.classList.add('hidden');
icon.textContent = '✏️';
icon.title = `Edit ${fieldName}`;
}
}
// Save field edit and update display
function saveFieldEdit(fieldName) {
const input = document.querySelector(`.field-input[data-field="${fieldName}"]`);
const displaySpan = document.querySelector(`.field-display[data-field="${fieldName}"]`);
const newValue = input.value.trim();
const displayValue = newValue || 'N/A';
displaySpan.textContent = displayValue;
toggleFieldEdit(fieldName, false);
checkForChanges();
}
// Cancel field edit and restore original value
function cancelFieldEdit(fieldName) {
const input = document.querySelector(`.field-input[data-field="${fieldName}"]`);
const originalValue = originalMetadata[fieldName] || '';
input.value = originalValue;
toggleFieldEdit(fieldName, false);
}
// Check if any fields have been modified
function checkForChanges() {
const inputs = document.querySelectorAll('.field-input');
let hasChanges = false;
inputs.forEach(input => {
const fieldName = input.dataset.field;
const currentValue = input.value.trim();
const originalValue = originalMetadata[fieldName] || '';
if (currentValue !== originalValue) {
hasChanges = true;
}
});
hasUnsavedChanges = hasChanges;
updateSaveButtonVisibility();
}
// Show/hide save button based on changes
function updateSaveButtonVisibility() {
if (hasUnsavedChanges) {
saveMetadataBtn.classList.remove('hidden');
} else {
saveMetadataBtn.classList.add('hidden');
}
}
// Show save options modal
function showSaveOptionsModal() {
if (!currentBundlePath || !hasUnsavedChanges) return;
saveOptionsModal.classList.remove('hidden');
}
// Hide save options modal
function hideSaveOptionsModal() {
saveOptionsModal.classList.add('hidden');
}
// Handle save metadata with option for save as new
async function handleSaveMetadata(saveAsNew = false) {
if (!currentBundlePath || !hasUnsavedChanges) return;
// Hide the modal first
hideSaveOptionsModal();
const inputs = document.querySelectorAll('.field-input');
const updatedMetadata = {};
inputs.forEach(input => {
const fieldName = input.dataset.field;
const value = input.value.trim();
if (value !== originalMetadata[fieldName]) {
// Convert camelCase to snake_case for backend
const backendFieldName = fieldName.replace(/([A-Z])/g, '_$1').toLowerCase();
updatedMetadata[backendFieldName] = value;
}
});
if (Object.keys(updatedMetadata).length === 0) {
hasUnsavedChanges = false;
updateSaveButtonVisibility();
return;
}
let targetPath = currentBundlePath;
if (saveAsNew) {
// Show file save dialog for new bundle
const result = await ipcRenderer.invoke('show-save-dialog', {
defaultPath: currentBundlePath.replace('.fbundle', '_edited.fbundle'),
filters: [{ name: 'Bundle Files', extensions: ['fbundle'] }]
});
if (result.canceled) {
return; // User canceled the save dialog
}
targetPath = result.filePath;
// Copy the original bundle to the new location first
try {
await ipcRenderer.invoke('copy-file', {
source: currentBundlePath,
destination: targetPath
});
} catch (error) {
showModal('Copy Error', `Failed to copy bundle: ${error.message}`);
return;
}
}
showSpinner();
const result = await ipcRenderer.invoke('edit-bundle', {
bundlePath: targetPath,
updatedManifest: updatedMetadata
});
hideSpinner();
if (result.success) {
// Update original metadata to reflect saved changes
Object.keys(updatedMetadata).forEach(backendKey => {
const frontendKey = backendKey.replace(/_([a-z])/g, (match, letter) => letter.toUpperCase());
originalMetadata[frontendKey] = updatedMetadata[backendKey];
});
hasUnsavedChanges = false;
updateSaveButtonVisibility();
if (saveAsNew) {
showModal('Success', `New bundle created successfully at: ${targetPath}`);
// Open the new bundle
currentBundlePath = targetPath;
await reloadCurrentBundle();
} else {
showModal('Success', 'Metadata updated successfully!');
// Reload current bundle to reflect changes
await reloadCurrentBundle();
}
} else {
showModal('Save Error', `Failed to save metadata: ${result.error}`);
}
}
// Helper function to reload current bundle
async function reloadCurrentBundle() {
if (!currentBundlePath) return;
const result = await ipcRenderer.invoke('open-bundle', currentBundlePath);
if (result.success) {
displayBundleInfo(result.report);
}
}