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
This commit is contained in:
2025-08-09 20:16:07 -04:00
parent 15c9755611
commit 45de795920
5 changed files with 397 additions and 10 deletions

View File

@@ -31,11 +31,11 @@
<header class="content-header">
<h2 id="bundle-title"></h2>
<div class="action-buttons">
<button id="edit-bundle-btn">Edit</button>
<button id="sign-bundle-btn">Sign</button>
<button id="validate-bundle-btn">Validate</button>
<button id="fill-bundle-btn">Fill</button>
<button id="clear-bundle-btn">Clear</button>
<button id="save-metadata-btn" class="hidden">Save Changes</button>
</div>
</header>
@@ -73,6 +73,35 @@
</div>
</div>
<!-- Spinner Overlay -->
<div id="spinner-overlay" class="spinner-overlay hidden">
<div class="spinner"></div>
<p>Processing...</p>
</div>
<!-- Save Options Modal -->
<div id="save-options-modal" class="modal-container hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Save Metadata Changes</h3>
<button class="modal-close" onclick="hideSaveOptionsModal()">&times;</button>
</div>
<div class="modal-body">
<p>How would you like to save your metadata changes?</p>
<div class="save-options">
<button id="overwrite-bundle-btn" class="save-option-btn primary">
<strong>Overwrite Current Bundle</strong>
<small>Update the existing bundle file</small>
</button>
<button id="save-as-new-btn" class="save-option-btn secondary">
<strong>Save As New Bundle</strong>
<small>Create a new bundle file with changes</small>
</button>
</div>
</div>
</div>
</div>
<!-- Fill Modal -->
<div id="fill-modal" class="modal">
<div class="modal-content">

View File

@@ -297,3 +297,19 @@ ipcMain.handle('open-bundle', async (event, bundlePath) => {
// Return error as-is since it's already in the correct format
return result || { success: false, error: 'An unknown error occurred while opening the bundle.' };
});
// Handle show save dialog
ipcMain.handle('show-save-dialog', async (event, options) => {
const result = await dialog.showSaveDialog(BrowserWindow.fromWebContents(event.sender), options);
return result;
});
// Handle file copying
ipcMain.handle('copy-file', async (event, { source, destination }) => {
try {
await fs.copy(source, destination);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});

View File

@@ -15,11 +15,16 @@ const openBundleBtn = document.getElementById('open-bundle-btn');
const createBundleBtn = document.getElementById('create-bundle-btn');
// Bundle action buttons
const editBundleBtn = document.getElementById('edit-bundle-btn');
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');
@@ -52,6 +57,8 @@ 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 () => {
@@ -93,6 +100,9 @@ function setupEventListeners() {
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.');
@@ -415,6 +425,15 @@ function displayBundleInfo(report) {
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';
@@ -443,16 +462,19 @@ function displayBundleInfo(report) {
<div class="card">
<div class="card-header"><h3>Manifest Details</h3></div>
<div class="card-content">
<p><strong>Version:</strong> ${manifest.bundleVersion || 'N/A'}</p>
<p><strong>Author:</strong> ${manifest.bundleAuthor || 'N/A'}</p>
${createEditableField('Version', 'bundleVersion', manifest.bundleVersion || 'N/A')}
${createEditableField('Author', 'bundleAuthor', manifest.bundleAuthor || 'N/A')}
<p><strong>Bundled On:</strong> ${manifest.bundledOn || 'N/A'}</p>
<p><strong>Comment:</strong> ${manifest.bundleComment || '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) {
@@ -493,3 +515,230 @@ function displayBundleInfo(report) {
// 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);
}
}

View File

@@ -370,6 +370,99 @@ body {
vertical-align: middle;
}
/* Inline Editing Styles */
.editable-field {
position: relative;
display: flex;
align-items: center;
gap: 8px;
}
.edit-icon {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
padding: 4px;
border-radius: 4px;
transition: background-color 0.2s;
opacity: 0.6;
}
.edit-icon:hover {
background-color: var(--border-color);
opacity: 1;
}
.field-input {
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 14px;
font-family: var(--font-family);
background-color: white;
min-width: 200px;
}
.field-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
.field-display {
flex: 1;
}
.hidden {
display: none !important;
}
/* Save Options Modal */
.save-options {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 20px;
}
.save-option-btn {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 16px;
border: 2px solid var(--border-color);
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.save-option-btn:hover {
border-color: var(--primary-color);
background-color: rgba(52, 152, 219, 0.05);
}
.save-option-btn.primary {
border-color: var(--primary-color);
}
.save-option-btn.secondary {
border-color: var(--text-light);
}
.save-option-btn strong {
font-size: 16px;
margin-bottom: 4px;
color: var(--text-color);
}
.save-option-btn small {
font-size: 14px;
color: var(--text-light);
}
.progress-indicator.spinner-icon {
border: 2px solid var(--text-color-light);
border-top: 2px solid var(--primary-color);

View File

@@ -283,10 +283,10 @@ def edit_bundle_metadata(bundle_path: Path, metadata: dict, progress_callback: O
raise FileNotFoundError("Bundle is not a valid zip file.")
with zipfile.ZipFile(bundle_path, 'a') as zf:
if MANIFEST_FILENAME not in zf.namelist():
raise FileNotFoundError(f"{MANIFEST_FILENAME} not found in bundle.")
if "manifest.yaml" not in zf.namelist():
raise FileNotFoundError("manifest.yaml not found in bundle.")
with zf.open(MANIFEST_FILENAME, 'r') as f:
with zf.open("manifest.yaml", 'r') as f:
manifest = yaml.safe_load(f)
_progress("Updating manifest...")
@@ -305,14 +305,14 @@ def edit_bundle_metadata(bundle_path: Path, metadata: dict, progress_callback: O
temp_bundle_path = bundle_path.with_suffix('.zip.tmp')
with zipfile.ZipFile(temp_bundle_path, 'w', zipfile.ZIP_DEFLATED) as temp_zf:
for item in zf.infolist():
if item.filename == MANIFEST_FILENAME:
if item.filename == "manifest.yaml":
continue # Skip old manifest
buffer = zf.read(item.filename)
temp_zf.writestr(item, buffer)
# Write the updated manifest
new_manifest_content = yaml.dump(manifest, Dumper=yaml.SafeDumper)
temp_zf.writestr(MANIFEST_FILENAME, new_manifest_content)
temp_zf.writestr("manifest.yaml", new_manifest_content)
# Replace the original bundle with the updated one
shutil.move(temp_bundle_path, bundle_path)