ZDDC/mdedit/js/file-ops.js
ZDDC ea385b5366 Initial commit
ZDDC — Zero Day Document Control. A file-naming convention plus five
single-file HTML tools (archive, transmittal, classifier, mdedit,
landing) and an optional Go HTTP server (zddc-server) with ACL and a
virtual archive index. Self-contained, offline-capable, dependency-free.

See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the
build/release/architecture detail, bootstrap/README.md for the
two-level deployment install pattern, and zddc/README.md for the
HTTP server.
2026-04-27 11:05:47 -05:00

400 lines
14 KiB
JavaScript

/**
* File management operations (create, rename, delete)
* Plain functions, no module wrapper
*/
/**
* Resolve a node in fileTree by filePath
* @param {string} filePath - Path like 'subdir/file.md' or ''
* @returns {Object|null} The node object or null if not found
*/
function resolveNode(filePath) {
if (!filePath) return fileTree;
const parts = filePath.split('/');
let node = fileTree;
for (const part of parts) {
if (!node.entries || !node.entries[part]) return null;
node = node.entries[part];
}
return node;
}
/**
* Resolve the parent directory handle for a given file path
* @param {string} filePath - Full path like 'subdir/file.md'
* @returns {FileSystemDirectoryHandle|null} Parent directory handle or null
*/
function resolveParentDirHandle(filePath) {
const parts = filePath.split('/');
if (parts.length === 1) return directoryHandle;
let node = fileTree;
for (let i = 0; i < parts.length - 1; i++) {
node = node.entries[parts[i]];
if (!node) return null;
}
return node.handle;
}
/**
* Create a new file
* @param {string} parentDirPath - '' for root, or 'subdir', 'a/b/c'
*/
async function createNewFile(parentDirPath) {
// Resolve parent directory handle first (no user activation needed for reads)
let parentHandle;
if (parentDirPath === '') {
parentHandle = directoryHandle;
} else {
const node = resolveNode(parentDirPath);
if (!node || !node.handle) {
alert('Could not locate parent directory.');
return;
}
parentHandle = node.handle;
}
// Show in-page modal and wait for user to confirm or cancel.
// Returns the filename string, or null if cancelled.
const name = await new Promise((resolve) => {
const modal = document.getElementById('new-file-modal');
const input = document.getElementById('new-file-input');
const confirmBtn = document.getElementById('new-file-confirm');
const cancelBtn = document.getElementById('new-file-cancel');
input.value = 'untitled.md';
modal.classList.remove('hidden');
input.focus();
input.select();
function cleanup() {
modal.classList.add('hidden');
confirmBtn.removeEventListener('click', onConfirm);
cancelBtn.removeEventListener('click', onCancel);
input.removeEventListener('keydown', onKey);
}
function onConfirm() {
const val = input.value.trim();
cleanup();
resolve(val || null);
}
function onCancel() {
cleanup();
resolve(null);
}
function onKey(e) {
if (e.key === 'Enter') onConfirm();
if (e.key === 'Escape') onCancel();
}
confirmBtn.addEventListener('click', onConfirm);
cancelBtn.addEventListener('click', onCancel);
input.addEventListener('keydown', onKey);
});
if (!name) {
if (DEBUG) console.log('New file creation cancelled');
return;
}
// Validate name
if (name.includes('/') || name.includes('\\')) {
alert('Invalid filename: cannot contain / or \\.');
return;
}
// Check if file already exists
try {
await parentHandle.getFileHandle(name);
const overwrite = window.confirm('A file named "' + name + '" already exists. Overwrite it?');
if (!overwrite) return;
} catch (e) {
if (e.name !== 'NotFoundError') throw e;
}
// Create the file — this must happen after the modal's button click
// which is the user activation token.
try {
const newHandle = await parentHandle.getFileHandle(name, { create: true });
const writable = await newHandle.createWritable();
await writable.write('');
await writable.close();
if (DEBUG) console.log(`Created new file: ${parentDirPath ? parentDirPath + '/' : ''}${name}`);
await refreshDirectory();
const newFilePath = parentDirPath ? parentDirPath + '/' + name : name;
const element = document.querySelector('.file-item[data-path="' + CSS.escape(newFilePath) + '"]');
if (element) {
handleFileClick(newHandle, newFilePath, element);
}
} catch (error) {
console.error('Error creating new file:', error);
alert('Error creating file: ' + error.message);
}
}
/**
* Rename a file or directory
* @param {string} filePath - Full path like 'subdir/file.md'
* @param {boolean} isDirectory - true if renaming a directory (not supported on Chrome)
*/
async function renameEntry(filePath, isDirectory) {
const currentName = filePath.split('/').pop();
const newName = window.prompt('Rename to:', currentName);
if (newName === null || newName === currentName) {
if (DEBUG) console.log('Rename cancelled or unchanged');
return;
}
// Validate name
if (newName.includes('/') || newName.includes('\\') || newName.trim() === '') {
alert('Invalid filename: cannot contain / or \\ and must not be empty.');
return;
}
// Resolve parent directory handle
const parentHandle = resolveParentDirHandle(filePath);
if (!parentHandle) {
alert('Could not locate parent directory.');
return;
}
// For files: rename via File System Access API
if (!isDirectory) {
try {
// Check if new name already exists (file or directory)
try {
const existing = await parentHandle.getFileHandle(newName);
// A file with that name exists
const overwrite = window.confirm('A file named "' + newName + '" already exists. Overwrite?');
if (!overwrite) return;
} catch (fileErr) {
if (fileErr.name === 'TypeMismatchError') {
// A directory with that name exists
window.alert('A folder named "' + newName + '" already exists. Choose a different name.');
return;
}
if (fileErr.name !== 'NotFoundError') throw fileErr;
// NotFoundError = safe to create
}
const oldHandle = resolveNode(filePath);
if (!oldHandle || !oldHandle.handle) {
alert('Could not find file to rename.');
return;
}
const file = await oldHandle.handle.getFile();
const content = await file.text();
const newHandle = await parentHandle.getFileHandle(newName, { create: true });
const writable = await newHandle.createWritable();
await writable.write(content);
await writable.close();
const newFile = await newHandle.getFile();
await parentHandle.removeEntry(currentName);
// Update editor instances
if (editorInstances.has(filePath)) {
const instance = editorInstances.get(filePath);
const newFilePath = filePath.substring(0, filePath.length - currentName.length) + newName;
// Remove old instance
const data = editorInstances.get(filePath);
if (data.fileViewContainer) {
data.fileViewContainer.classList.add('hidden');
}
editorInstances.delete(filePath);
// Re-add with new path
editorInstances.set(newFilePath, { ...data, fileHandle: newHandle, lastModified: newFile.lastModified });
// Update active state
if (instance.fileViewContainer) {
instance.fileViewContainer.classList.remove('hidden');
instance.fileViewContainer.dataset.path = newFilePath;
}
// Update fileTree entries
const parts = filePath.split('/');
const fileName = parts.pop();
const dirPath = parts.join('/');
let targetEntries = fileTree.entries;
if (dirPath) {
const dirParts = dirPath.split('/');
let current = fileTree;
for (const part of dirParts) {
current = current.entries[part];
}
targetEntries = current.entries;
}
if (targetEntries && targetEntries[currentName]) {
delete targetEntries[currentName];
targetEntries[newName] = {
name: newName,
type: 'file',
handle: newHandle
};
}
renderFileTree();
restoreActiveFile(newFilePath);
} else {
renderFileTree();
}
} catch (error) {
console.error('Error renaming file:', error);
alert('Error renaming file: ' + error.message);
}
} else {
// For directories: not supported by browser API
alert('Directory rename is not supported by the browser File System API. Please rename the folder in your OS file manager and refresh.');
}
}
/**
* Delete a file or directory
* @param {string} filePath - Full path like 'subdir/file.md' or 'subdir'
* @param {boolean} isDirectory - true if deleting a directory
*/
async function deleteEntry(filePath, isDirectory) {
const name = filePath.split('/').pop();
const message = isDirectory
? 'Delete folder "' + name + '" and all its contents?'
: 'Delete "' + name + '"?';
const ok = window.confirm(message);
if (!ok) {
if (DEBUG) console.log('Delete cancelled by user');
return;
}
// Resolve parent directory handle
const parentHandle = resolveParentDirHandle(filePath);
if (!parentHandle) {
alert('Could not locate parent directory.');
return;
}
let deleted = false;
try {
await parentHandle.removeEntry(name, { recursive: isDirectory });
deleted = true;
} catch (error) {
if (error.name === 'NotFoundError') {
// Already gone — treat as success for cleanup purposes
deleted = true;
} else {
console.error('Error deleting entry:', error);
alert('Error deleting entry: ' + error.message);
}
}
if (deleted) {
// Close editor if open
if (!isDirectory && editorInstances.has(filePath)) {
closeEditorInstance(filePath);
} else if (isDirectory) {
// Close any editors under this directory
const dirsToClose = [];
editorInstances.forEach(function(instance, key) {
if (key === filePath || key.startsWith(filePath + '/')) {
dirsToClose.push(key);
}
});
dirsToClose.forEach(function(key) {
closeEditorInstance(key);
});
}
// Remove from fileTree entries
const parts = filePath.split('/');
const entryName = parts.pop();
const dirPath = parts.join('/');
let targetEntries = fileTree.entries;
if (dirPath) {
const dirParts = dirPath.split('/');
let current = fileTree;
for (const part of dirParts) {
current = current.entries[part];
}
targetEntries = current.entries;
}
if (targetEntries && targetEntries[entryName]) {
delete targetEntries[entryName];
}
renderFileTree();
updateStatusCountsFromTree();
}
}
/**
* Close an editor instance and show welcome screen if no files open
* @param {string} filePath - Path of file to close
*/
function closeEditorInstance(filePath) {
const instance = editorInstances.get(filePath);
if (!instance) return;
if (instance.fileViewContainer) {
instance.fileViewContainer.classList.add('hidden');
}
editorInstances.delete(filePath);
// Check if any visible file-view-container children remain
const contentContainer = document.getElementById('content-container');
if (contentContainer) {
const visibleChildren = Array.from(contentContainer.querySelectorAll('.file-view-container'))
.filter(function(el) { return !el.classList.contains('hidden'); });
if (visibleChildren.length === 0) {
document.getElementById('welcome-screen').classList.remove('hidden');
contentContainer.classList.add('hidden');
}
}
}
/**
* Restore active file state after rename
* @param {string} newFilePath - New path of the file
*/
function restoreActiveFile(newFilePath) {
const element = document.querySelector('.file-item[data-path="' + CSS.escape(newFilePath) + '"]');
if (element) {
element.classList.add('active-file');
element.style.backgroundColor = '';
element.style.color = '';
}
}
/**
* Update status counts from fileTree
*/
function updateStatusCountsFromTree() {
let folderCount = 0;
let fileCount = 0;
function countEntries(entries) {
if (!entries) return;
for (const [name, item] of Object.entries(entries)) {
if (item.type === 'directory') {
folderCount++;
countEntries(item.entries);
} else if (item.type === 'file') {
fileCount++;
}
}
}
countEntries(fileTree.entries);
updateStatusCounts(folderCount, fileCount);
}