Bundles a stretch of in-progress work across the SPA tools so the
tree returns to a coherent shippable state ahead of cutting a new
zddc-server stable image:
- landing: substantial rework of the project picker (sortable/filterable
table, presets refactor, ?projects= filter, ?v= channel propagation,
loading/error states)
- archive: presets cleanup, source.js refactor, filtering/url-state
alignment with the landing page
- mdedit: file-system module split, resizer, file-tree improvements,
base/toc styling tweaks
- transmittal/classifier: small template touch-ups for shared chrome
- shared: build-lib.sh helpers, new favicon.svg
- bootstrap, build.sh: pick up the channel-aware install/track zip
generation
- tests: new landing.spec.js, expanded archive/mdedit/build-label specs
- docs: CLAUDE.md picks up the zddc-server section and freshens the
alpha-build exception note
- regenerated artifacts: install.zip, track-{alpha,beta,stable}.zip,
*_alpha.html — these are produced by `sh build.sh` and per project
convention are committed alongside the source changes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
739 lines
25 KiB
JavaScript
739 lines
25 KiB
JavaScript
/**
|
|
* File system operations using File System Access API
|
|
*/
|
|
|
|
/**
|
|
* Open the scratchpad editor
|
|
*/
|
|
function openScratchpad() {
|
|
// Check if scratchpad already exists
|
|
if (editorInstances.has(SCRATCHPAD_ID)) {
|
|
// Just show it
|
|
const instance = editorInstances.get(SCRATCHPAD_ID);
|
|
document.getElementById('welcome-screen').classList.add('hidden');
|
|
document.getElementById('content-container').classList.remove('hidden');
|
|
|
|
// Hide all other editors, show scratchpad
|
|
editorInstances.forEach((data, path) => {
|
|
if (data.fileViewContainer) {
|
|
data.fileViewContainer.style.display = path === SCRATCHPAD_ID ? 'flex' : 'none';
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Hide welcome screen, show content container
|
|
document.getElementById('welcome-screen').classList.add('hidden');
|
|
document.getElementById('content-container').classList.remove('hidden');
|
|
|
|
// Initialize editor with the welcome text seeded as the starting content.
|
|
initializeEditor(SCRATCHPAD_WELCOME, true, SCRATCHPAD_ID, 'Scratchpad', null, null);
|
|
|
|
// Mark as scratchpad
|
|
const instance = editorInstances.get(SCRATCHPAD_ID);
|
|
if (instance) {
|
|
instance.isScratchpad = true;
|
|
}
|
|
|
|
// Reflect non-empty starting content on the scratchpad row's download button.
|
|
updateScratchpadDownloadState();
|
|
|
|
if (DEBUG) console.log('Opened scratchpad');
|
|
}
|
|
|
|
/**
|
|
* Enable/disable the scratchpad-row download button based on whether the
|
|
* scratchpad currently holds any content. Idempotent — safe to call from
|
|
* editor change listeners.
|
|
*/
|
|
function updateScratchpadDownloadState() {
|
|
const btn = document.getElementById('scratchpad-download-btn');
|
|
if (!btn) return;
|
|
const instance = editorInstances.get(SCRATCHPAD_ID);
|
|
let hasContent = false;
|
|
if (instance && instance.editor) {
|
|
try {
|
|
hasContent = (instance.editor.getMarkdown() || '').trim().length > 0;
|
|
} catch (_) { /* editor may not be ready yet */ }
|
|
}
|
|
btn.disabled = !hasContent;
|
|
btn.classList.toggle('is-disabled', !hasContent);
|
|
}
|
|
|
|
/**
|
|
* Trigger a browser download of the current scratchpad markdown.
|
|
* No-op if the scratchpad has no content.
|
|
*/
|
|
function downloadScratchpad() {
|
|
const instance = editorInstances.get(SCRATCHPAD_ID);
|
|
if (!instance || !instance.editor) return;
|
|
let content = '';
|
|
try { content = instance.editor.getMarkdown() || ''; } catch (_) { return; }
|
|
|
|
// Pull front matter from the textarea if any
|
|
if (instance.frontMatterTextarea) {
|
|
const fmText = instance.frontMatterTextarea.value.trim();
|
|
if (fmText) content = `---\n${fmText}\n---\n${content}`;
|
|
}
|
|
|
|
if (!content.trim()) return;
|
|
|
|
// Suggest a filename derived from the first H1 if present
|
|
let suggested = 'scratchpad.md';
|
|
const h1 = content.match(/^#\s+(.+)$/m);
|
|
if (h1) {
|
|
const slug = h1[1].trim().toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, '')
|
|
.replace(/\s+/g, '-')
|
|
.substring(0, 60);
|
|
if (slug) suggested = `${slug}.md`;
|
|
}
|
|
|
|
saveFileAs(content, suggested);
|
|
}
|
|
|
|
/**
|
|
* Save file using Save As dialog (for scratchpads or new saves)
|
|
* @param {string} content - Content to save
|
|
* @param {string} suggestedName - Suggested filename
|
|
* @returns {Promise<FileSystemFileHandle|null>} File handle if saved, null otherwise
|
|
*/
|
|
async function saveFileAs(content, suggestedName = 'untitled.md') {
|
|
if (hasFileSystemAccess) {
|
|
try {
|
|
const fileHandle = await window.showSaveFilePicker({
|
|
suggestedName: suggestedName,
|
|
types: [{
|
|
description: 'Markdown files',
|
|
accept: { 'text/markdown': ['.md', '.markdown'] }
|
|
}]
|
|
});
|
|
|
|
const writable = await fileHandle.createWritable();
|
|
await writable.write(content);
|
|
await writable.close();
|
|
|
|
if (DEBUG) console.log(`File saved as: ${fileHandle.name}`);
|
|
return fileHandle;
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') {
|
|
if (DEBUG) console.log('Save cancelled by user');
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
} else {
|
|
// Fallback: download as blob
|
|
const blob = new Blob([content], { type: 'text/markdown' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = suggestedName;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
if (DEBUG) console.log(`File downloaded as: ${suggestedName}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open directory picker and handle selection
|
|
*/
|
|
async function openDirectory() {
|
|
try {
|
|
if (!('showDirectoryPicker' in window)) {
|
|
throw new Error('The File System API is not supported in this browser.');
|
|
}
|
|
|
|
directoryHandle = await window.showDirectoryPicker();
|
|
if (DEBUG) console.log('Directory selected:', directoryHandle.name);
|
|
|
|
updateDirectoryStatus(directoryHandle.name);
|
|
await readDirectory(directoryHandle);
|
|
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') {
|
|
if (DEBUG) console.log('User cancelled the directory selection');
|
|
} else {
|
|
console.error('Error selecting directory:', error);
|
|
alert(`Error: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update UI to show selected directory
|
|
* @param {string} directoryName - Name of the selected directory
|
|
*/
|
|
function updateDirectoryStatus(directoryName) {
|
|
const selectDirectoryBtn = document.getElementById('select-directory');
|
|
if (selectDirectoryBtn) {
|
|
selectDirectoryBtn.textContent = `Directory: ${directoryName}`;
|
|
}
|
|
|
|
const refreshBtn = document.getElementById('refresh-directory');
|
|
if (refreshBtn) {
|
|
refreshBtn.classList.remove('hidden');
|
|
}
|
|
|
|
// Show new file button when directory is selected
|
|
const newFileRootBtn = document.getElementById('new-file-root');
|
|
if (newFileRootBtn) {
|
|
newFileRootBtn.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read directory contents and build tree structure
|
|
* @param {FileSystemDirectoryHandle} dirHandle - Directory handle
|
|
* @param {Object} parentNode - Parent node in tree (for recursion)
|
|
* @returns {Object} Statistics about the directory
|
|
*/
|
|
async function readDirectory(dirHandle, parentNode = null) {
|
|
if (parentNode === null) {
|
|
fileTree = {
|
|
name: dirHandle.name,
|
|
type: 'directory',
|
|
handle: dirHandle,
|
|
entries: {}
|
|
};
|
|
|
|
const fileTreeElement = document.getElementById('file-tree');
|
|
if (fileTreeElement) {
|
|
fileTreeElement.innerHTML = '';
|
|
}
|
|
|
|
parentNode = fileTree;
|
|
}
|
|
|
|
try {
|
|
let stats = { folderCount: 0, fileCount: 0 };
|
|
|
|
for await (const entry of dirHandle.values()) {
|
|
if (entry.kind === 'file' && !entry.name.startsWith('_')) {
|
|
parentNode.entries[entry.name] = {
|
|
name: entry.name,
|
|
type: 'file',
|
|
handle: entry
|
|
};
|
|
stats.fileCount++;
|
|
} else if (entry.kind === 'directory' && !entry.name.startsWith('_')) {
|
|
const dirNode = {
|
|
name: entry.name,
|
|
type: 'directory',
|
|
handle: entry,
|
|
entries: {}
|
|
};
|
|
|
|
parentNode.entries[entry.name] = dirNode;
|
|
|
|
const subStats = await readDirectory(entry, dirNode);
|
|
stats.folderCount += subStats.folderCount + 1;
|
|
stats.fileCount += subStats.fileCount;
|
|
}
|
|
}
|
|
|
|
if (parentNode === fileTree) {
|
|
renderFileTree();
|
|
updateStatusCounts(stats.folderCount, stats.fileCount);
|
|
}
|
|
|
|
return stats;
|
|
} catch (error) {
|
|
console.error('Error reading directory:', error);
|
|
return { folderCount: 0, fileCount: 0 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save a file by its path
|
|
* @param {string} filePath - Path of the file to save
|
|
* @returns {Promise<boolean>} Whether save was successful
|
|
*/
|
|
async function saveFile(filePath) {
|
|
if (!filePath && currentFileHandle) {
|
|
filePath = currentFileHandle.name;
|
|
} else if (!filePath) {
|
|
alert('No file is currently open');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const editorInstance = editorInstances.get(filePath);
|
|
if (!editorInstance) {
|
|
throw new Error('No editor instance found for this file');
|
|
}
|
|
|
|
if (!editorInstance.isDirty) {
|
|
if (DEBUG) console.log(`File ${filePath} is not dirty, skipping save`);
|
|
return true;
|
|
}
|
|
|
|
const fileHandle = editorInstance.fileHandle;
|
|
if (!fileHandle) {
|
|
throw new Error('No file handle available for this file');
|
|
}
|
|
|
|
// Check for external modifications
|
|
const file = await fileHandle.getFile();
|
|
const currentLastModified = file.lastModified;
|
|
const storedLastModified = editorInstance.lastModified;
|
|
|
|
if (storedLastModified && currentLastModified !== storedLastModified) {
|
|
const confirmSave = confirm(
|
|
'Warning: This file has been modified outside of the application since you opened it. ' +
|
|
'Saving will overwrite those changes. Do you want to continue?'
|
|
);
|
|
|
|
if (!confirmSave) {
|
|
if (DEBUG) console.log('Save aborted by user due to external file modifications');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Get markdown content from editor
|
|
const markdownContent = editorInstance.editor.getMarkdown();
|
|
|
|
// Get front matter from textarea
|
|
let frontMatterData = {};
|
|
if (editorInstance.frontMatterTextarea) {
|
|
const frontMatterText = editorInstance.frontMatterTextarea.value.trim();
|
|
if (frontMatterText) {
|
|
try {
|
|
const yamlContent = `---\n${frontMatterText}\n---\n`;
|
|
const parsed = parseFrontMatter(yamlContent);
|
|
frontMatterData = parsed.data;
|
|
} catch (error) {
|
|
console.error('Error parsing front matter:', error);
|
|
throw new Error(`Invalid YAML front matter: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply before save hooks
|
|
frontMatterData = await applyBeforeSaveHooks(frontMatterData, markdownContent, fileHandle);
|
|
|
|
// Combine front matter with markdown
|
|
const finalContent = frontMatterData && Object.keys(frontMatterData).length > 0
|
|
? stringifyFrontMatter(markdownContent, frontMatterData)
|
|
: markdownContent;
|
|
|
|
// Server-mode files are read-only: fall back to a Save-As download.
|
|
if (typeof fileHandle.createWritable !== 'function') {
|
|
const downloadName = (fileHandle.name || filePath.split('/').pop() || 'untitled.md');
|
|
await saveFileAs(finalContent, downloadName);
|
|
editorInstance.isDirty = false;
|
|
updateFileDirtyStatus(filePath, false);
|
|
updateUnsavedCount();
|
|
if (editorInstance.saveButton) editorInstance.saveButton.disabled = true;
|
|
return true;
|
|
}
|
|
|
|
// Write to file
|
|
const writable = await fileHandle.createWritable();
|
|
await writable.write(finalContent);
|
|
await writable.close();
|
|
|
|
// Update state
|
|
const updatedFile = await fileHandle.getFile();
|
|
editorInstance.lastModified = updatedFile.lastModified;
|
|
editorInstance.isDirty = false;
|
|
updateFileDirtyStatus(filePath, false);
|
|
updateUnsavedCount();
|
|
|
|
if (editorInstance.saveButton) {
|
|
editorInstance.saveButton.disabled = true;
|
|
}
|
|
|
|
if (DEBUG) console.log(`File ${filePath} saved successfully!`);
|
|
|
|
await applyAfterSaveHooks(frontMatterData, markdownContent, fileHandle);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`Error saving file ${filePath}:`, error);
|
|
alert(`Error saving file: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save all files with unsaved changes
|
|
* @returns {Promise<{saved: number, failed: number}>}
|
|
*/
|
|
async function saveAllFiles() {
|
|
let saved = 0;
|
|
let failed = 0;
|
|
|
|
const dirtyFiles = [];
|
|
editorInstances.forEach((instance, filePath) => {
|
|
if (instance.isDirty) {
|
|
dirtyFiles.push(filePath);
|
|
}
|
|
});
|
|
|
|
if (dirtyFiles.length === 0) {
|
|
if (DEBUG) console.log('No files with unsaved changes');
|
|
return { saved, failed };
|
|
}
|
|
|
|
for (const filePath of dirtyFiles) {
|
|
try {
|
|
const success = await saveFile(filePath);
|
|
if (success) {
|
|
saved++;
|
|
} else {
|
|
failed++;
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error saving file ${filePath}:`, error);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
if (failed === 0) {
|
|
if (DEBUG) console.log(`All ${saved} files saved successfully`);
|
|
} else {
|
|
if (DEBUG) console.log(`Saved ${saved} files, ${failed} files failed to save`);
|
|
}
|
|
|
|
return { saved, failed };
|
|
}
|
|
|
|
/**
|
|
* Reload file from disk
|
|
* @param {string} filePath - Path of file to reload
|
|
* @returns {Promise<boolean>} Whether reload was successful
|
|
*/
|
|
async function reloadFileFromDisk(filePath) {
|
|
try {
|
|
const editorInstance = editorInstances.get(filePath);
|
|
if (!editorInstance) {
|
|
throw new Error('No editor instance found for this file');
|
|
}
|
|
|
|
if (editorInstance.isDirty) {
|
|
const confirmReload = confirm(
|
|
'This file has unsaved changes. Reloading will discard all changes. ' +
|
|
'Do you want to continue?'
|
|
);
|
|
|
|
if (!confirmReload) {
|
|
if (DEBUG) console.log('Reload cancelled by user');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const fileHandle = editorInstance.fileHandle;
|
|
if (!fileHandle) {
|
|
throw new Error('No file handle available for this file');
|
|
}
|
|
|
|
const file = await fileHandle.getFile();
|
|
const fileContent = await file.text();
|
|
|
|
editorInstance.lastModified = file.lastModified;
|
|
|
|
if (filePath.endsWith('.md') || filePath.endsWith('.markdown')) {
|
|
const parsed = parseFrontMatter(fileContent);
|
|
|
|
if (editorInstance.frontMatterTextarea) {
|
|
const frontMatterYaml = stringifyFrontMatterToTextarea(parsed.data);
|
|
editorInstance.frontMatterTextarea.value = frontMatterYaml;
|
|
}
|
|
|
|
let currentScrollTop = 0;
|
|
try {
|
|
currentScrollTop = editorInstance.editor.getScrollTop();
|
|
} catch (error) {
|
|
if (DEBUG) console.debug('Could not get scroll position:', error);
|
|
}
|
|
|
|
editorInstance.editor.setMarkdown(parsed.content);
|
|
|
|
setTimeout(() => {
|
|
try {
|
|
editorInstance.editor.setScrollTop(currentScrollTop);
|
|
} catch (error) {
|
|
if (DEBUG) console.debug('Could not restore scroll position:', error);
|
|
}
|
|
}, 100);
|
|
|
|
if (editorInstance.tocContainer && window.updateToc) {
|
|
try {
|
|
window.updateToc(parsed.content, editorInstance.tocContainer, editorInstance.editor, tocMaxDepth);
|
|
} catch (error) {
|
|
console.error('Error updating TOC during reload:', error);
|
|
}
|
|
}
|
|
} else {
|
|
editorInstance.editor.setMarkdown(fileContent);
|
|
}
|
|
|
|
editorInstance.isDirty = false;
|
|
updateFileDirtyStatus(filePath, false);
|
|
updateUnsavedCount();
|
|
|
|
if (editorInstance.saveButton) {
|
|
editorInstance.saveButton.disabled = true;
|
|
}
|
|
|
|
if (DEBUG) console.log(`File ${filePath} reloaded successfully from disk!`);
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`Error reloading file ${filePath}:`, error);
|
|
alert(`Error reloading file: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Before save hook - apply modifications before saving
|
|
*/
|
|
async function applyBeforeSaveHooks(frontMatter, markdownContent, fileHandle) {
|
|
frontMatter.lastModified = new Date().toISOString();
|
|
|
|
if (!frontMatter.title) {
|
|
const firstHeading = markdownContent.match(/^#\s+(.+)$/m);
|
|
if (firstHeading) {
|
|
frontMatter.title = firstHeading[1];
|
|
}
|
|
}
|
|
|
|
const customTags = (markdownContent.match(/<(deliverable|meeting|report|trkno)>/g) || []).length;
|
|
if (customTags > 0) {
|
|
frontMatter.customTagCount = customTags;
|
|
}
|
|
|
|
return frontMatter;
|
|
}
|
|
|
|
/**
|
|
* After save hook - perform actions after saving
|
|
*/
|
|
async function applyAfterSaveHooks(frontMatter, markdownContent, fileHandle) {
|
|
const tags = ['deliverable', 'meeting', 'report', 'trkno'];
|
|
const preservedTags = tags.filter(tag => markdownContent.includes(`<${tag}>`));
|
|
if (preservedTags.length > 0) {
|
|
if (DEBUG) console.log(`Preserved custom tags: ${preservedTags.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh directory from disk without losing unsaved work
|
|
*/
|
|
async function refreshDirectory() {
|
|
if (serverSourceMode) {
|
|
await loadServerDirectory();
|
|
return;
|
|
}
|
|
if (!directoryHandle) {
|
|
if (DEBUG) console.log('No directory selected, cannot refresh');
|
|
return;
|
|
}
|
|
|
|
// Get active file path from DOM before refresh
|
|
const activeFileEl = document.querySelector('.file-item.active-file');
|
|
const activeFilePath = activeFileEl ? activeFileEl.dataset.path : null;
|
|
|
|
// Get dirty files from editorInstances
|
|
const dirtyFiles = new Set();
|
|
editorInstances.forEach((instance, filePath) => {
|
|
if (instance.isDirty) {
|
|
dirtyFiles.add(filePath);
|
|
}
|
|
});
|
|
|
|
// Re-read directory (calls renderFileTree at the end)
|
|
await readDirectory(directoryHandle);
|
|
|
|
// Restore active file state
|
|
if (activeFilePath) {
|
|
const activeElement = document.querySelector(`.file-item[data-path="${activeFilePath}"]`);
|
|
if (activeElement) {
|
|
activeElement.classList.add('active-file');
|
|
}
|
|
}
|
|
|
|
// Restore dirty indicators
|
|
dirtyFiles.forEach(filePath => {
|
|
updateFileDirtyStatus(filePath, true);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build a synthetic, read-only "file handle" backed by a URL.
|
|
* Implements `getFile()` so the rest of the app (which only needs to read)
|
|
* works without changes. Lacks `createWritable()` — saveFile detects this
|
|
* and routes to a Save-As download.
|
|
*/
|
|
function createServerFileHandle(name, url) {
|
|
let cached = null;
|
|
return {
|
|
kind: 'file',
|
|
name,
|
|
_serverUrl: url,
|
|
_readOnly: true,
|
|
async getFile() {
|
|
if (cached) return cached;
|
|
const resp = await fetch(url, { cache: 'no-cache' });
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching ${url}`);
|
|
const lastMod = resp.headers.get('Last-Modified');
|
|
const lastModified = lastMod ? Date.parse(lastMod) : Date.now();
|
|
const blob = await resp.blob();
|
|
cached = new File([blob], name, { type: blob.type, lastModified });
|
|
return cached;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a synthetic directory handle (read-only) backed by a server URL.
|
|
* Returned for nested entries so existing code paths that probe for `.handle`
|
|
* still work; not currently used for traversal.
|
|
*/
|
|
function createServerDirectoryHandle(name, url) {
|
|
return {
|
|
kind: 'directory',
|
|
name,
|
|
_serverUrl: url,
|
|
_readOnly: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Recursively fetch the JSON directory listing for `dirUrl` and populate
|
|
* `parentNode.entries` with synthetic handles. Returns folder/file counts.
|
|
* Uses the same Caddy/zddc-server JSON shape archive's source.js consumes.
|
|
*/
|
|
async function readServerDirectory(dirUrl, parentNode, depth) {
|
|
if (depth > 10) return { folderCount: 0, fileCount: 0 };
|
|
|
|
let items;
|
|
try {
|
|
const resp = await fetch(dirUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
items = await resp.json();
|
|
if (!Array.isArray(items)) throw new Error('Expected JSON array');
|
|
} catch (err) {
|
|
if (DEBUG) console.warn(`Server listing failed for ${dirUrl}:`, err);
|
|
return { folderCount: 0, fileCount: 0 };
|
|
}
|
|
|
|
const stats = { folderCount: 0, fileCount: 0 };
|
|
const subdirPromises = [];
|
|
|
|
for (const item of items) {
|
|
const rawName = item.name.endsWith('/') ? item.name.slice(0, -1) : item.name;
|
|
if (rawName.startsWith('.') || rawName.startsWith('_')) continue;
|
|
|
|
const base = dirUrl.endsWith('/') ? dirUrl : dirUrl + '/';
|
|
const childUrl = base + encodeURIComponent(rawName) + (item.is_dir ? '/' : '');
|
|
|
|
if (item.is_dir) {
|
|
const dirNode = {
|
|
name: rawName,
|
|
type: 'directory',
|
|
handle: createServerDirectoryHandle(rawName, childUrl),
|
|
entries: {},
|
|
};
|
|
parentNode.entries[rawName] = dirNode;
|
|
stats.folderCount++;
|
|
subdirPromises.push(
|
|
readServerDirectory(childUrl, dirNode, depth + 1).then((s) => {
|
|
stats.folderCount += s.folderCount;
|
|
stats.fileCount += s.fileCount;
|
|
})
|
|
);
|
|
} else {
|
|
parentNode.entries[rawName] = {
|
|
name: rawName,
|
|
type: 'file',
|
|
handle: createServerFileHandle(rawName, childUrl),
|
|
};
|
|
stats.fileCount++;
|
|
}
|
|
}
|
|
|
|
await Promise.all(subdirPromises);
|
|
return stats;
|
|
}
|
|
|
|
/**
|
|
* Detect HTTP context, fetch the directory the page lives under, and render
|
|
* the resulting subtree in the file pane. Idempotent — safe to re-call.
|
|
*/
|
|
async function loadServerDirectory() {
|
|
if (!(location.protocol === 'http:' || location.protocol === 'https:')) return;
|
|
serverSourceMode = true;
|
|
|
|
let href = window.location.href.split('?')[0].split('#')[0];
|
|
const lastSlash = href.lastIndexOf('/');
|
|
const baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/';
|
|
|
|
const rootName = (() => {
|
|
const path = baseUrl.replace(/\/$/, '');
|
|
const seg = path.substring(path.lastIndexOf('/') + 1);
|
|
return seg || baseUrl;
|
|
})();
|
|
|
|
fileTree = {
|
|
name: rootName,
|
|
type: 'directory',
|
|
handle: createServerDirectoryHandle(rootName, baseUrl),
|
|
entries: {},
|
|
};
|
|
|
|
// Surface refresh, hide write-only controls
|
|
const refreshBtn = document.getElementById('refresh-directory');
|
|
if (refreshBtn) refreshBtn.classList.remove('hidden');
|
|
const newFileRootBtn = document.getElementById('new-file-root');
|
|
if (newFileRootBtn) newFileRootBtn.classList.add('hidden');
|
|
const selectDirBtn = document.getElementById('select-directory');
|
|
if (selectDirBtn) {
|
|
selectDirBtn.classList.add('hidden');
|
|
}
|
|
|
|
const stats = await readServerDirectory(baseUrl, fileTree, 0);
|
|
renderFileTree();
|
|
updateStatusCounts(stats.folderCount, stats.fileCount);
|
|
}
|
|
|
|
/**
|
|
* Start monitoring files for external changes
|
|
*/
|
|
function startFileChangeMonitoring() {
|
|
setInterval(async () => {
|
|
for (const [filePath, editorInstance] of editorInstances) {
|
|
try {
|
|
const fileHandle = editorInstance.fileHandle;
|
|
if (!fileHandle) continue;
|
|
if (fileHandle._readOnly) continue;
|
|
|
|
const file = await fileHandle.getFile();
|
|
const currentLastModified = file.lastModified;
|
|
const storedLastModified = editorInstance.lastModified;
|
|
|
|
if (storedLastModified && currentLastModified !== storedLastModified) {
|
|
if (DEBUG) console.log(`File ${filePath} changed externally`);
|
|
|
|
const action = confirm(
|
|
`File "${filePath}" has been modified by another application.\n\n` +
|
|
'Click OK to reload from disk (discards unsaved changes)\n' +
|
|
'Click Cancel to keep current version'
|
|
);
|
|
|
|
if (action) {
|
|
await reloadFileFromDisk(filePath);
|
|
} else {
|
|
editorInstance.lastModified = currentLastModified;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (DEBUG) console.debug(`Error checking file ${filePath}:`, error.message);
|
|
}
|
|
}
|
|
}, 3000);
|
|
}
|