ZDDC/mdedit/js/file-system.js
ZDDC 3115e388fc feat(server): authenticated CRUD + verb-based RBAC with WORM archive folders
Replaces the binary acl.allow/deny model with five permission verbs
(r/w/c/d/a) and first-class roles, and adds an authenticated file API
(PUT/DELETE/POST move/mkdir) so the HTML tools can edit-in-place over
HTTP. Closes the AC-3(7) and AC-6 federal-readiness gaps.

File API (zddc/internal/handler/fileapi.go)
  - PUT <new>      → action c
  - PUT <existing> → action w
  - PUT <.zddc>    → action a (CanEditZddc strict-ancestor rule)
  - DELETE         → action d
  - POST mkdir     → action c (auto-writes creator-owned .zddc when the
                     parent is Incoming/Working/Staging)
  - POST move      → action w on src + c on dst, atomic via os.Rename
  - Optional If-Match for optimistic concurrency, --max-write-bytes cap,
    audit log emits a structured file_write event per operation.

Permission model (zddc/internal/zddc/{acl,file,roles,cascade_mode}.go)
  - acl.permissions: { principal → verb-set } map; principals are email
    patterns or role names. Empty verb set is an explicit deny.
  - roles: { name → members } definitions, available at the level they
    declare and all descendants. Closer-to-leaf shadows ancestor.
  - Legacy acl.allow/deny still work; they fold into permissions at
    parse time (allow → "rwcd", deny → "").
  - Cascade walks leaf→root; first level with any matching entry wins;
    the union of matching verb sets at that level decides.
  - --cascade-mode=strict adds a root→leaf ancestor-deny pre-pass so an
    ancestor explicit-deny is absolute (NIST AC-6). Default delegated
    preserves the existing commercial behavior.

Special folders (zddc/internal/zddc/special.go)
  - Incoming / Working / Staging: mkdir auto-writes a .zddc into the new
    subdir granting created_by + that email rwcda directly. Same form
    operators write by hand; creator can edit it later to add others.
  - Issued / Received: server-enforced WORM split. Cascade grants
    inherited from above the WORM folder are masked to r only; grants
    placed at-or-below the WORM folder retain r,c. Operators grant
    write-once (cr) to the doc controller via an explicit .zddc at the
    Issued/Received folder. Admins exempt — only escape hatch.

Browser polyfill (shared/zddc-source.js)
  - HttpDirectoryHandle + HttpFileHandle implement the FS Access API
    surface (values, getFileHandle, createWritable, removeEntry,
    queryPermission/requestPermission) over zddc-server's listing JSON
    and file API. Existing tools written against showDirectoryPicker
    work unchanged.
  - detectServerRoot() returns { handle, status }: tools auto-load on
    HTTP, surface a clear "no permission to list" message on 403, and
    fall back to the welcome screen on 0.
  - classifier renames take the atomic POST move path on HTTP-backed
    handles; mdedit and transmittal route reads/writes through the
    polyfill so prior FS-API code paths cover both modes.

Tests
  - zddc/internal/zddc/{cascade_mode,roles,special,acl}_test.go cover
    delegated vs strict, role membership / shadowing / legacy fallback,
    WORM split semantics, verb-set parser round-trip.
  - zddc/internal/handler/fileapi_test.go now also covers role-based
    vendor scenarios, WORM blocking vendor & doc controller writes,
    explicit Issued .zddc unlocking the cr drop-box, admin bypass,
    auto-ownership on mkdir, and strict-mode lockouts.

Docs
  - ARCHITECTURE.md + zddc/README.md document the verb model, role
    syntax, special-folder behaviors, cascade-mode flag, and full file
    API surface. Federal-readiness gap analysis strikes AC-3(7) and
    AC-6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:58:04 -05:00

779 lines
28 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);
// Local picker wins over any active server-source mode.
serverSourceMode = false;
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) {
// Standardized header pattern (across all ZDDC tools): the button
// keeps the label "Add Local Directory"; de-emphasize it once a
// directory is loaded (the user can still click to pick another)
// by applying the shared btn--subtle variant. The directory name
// is shown in the file-nav pane, not on the button.
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
if (selectDirectoryBtn) {
selectDirectoryBtn.classList.remove('btn-primary');
selectDirectoryBtn.classList.add('btn--subtle');
selectDirectoryBtn.title = `Loaded: ${directoryName} — click to switch`;
}
const refreshBtn = document.getElementById('refreshHeaderBtn');
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);
});
}
/**
* Surface a clear "no permission to list this directory" message in
* the file tree pane when the server returns 403 on the initial
* listing. Distinct from "host doesn't serve JSON" so the user
* understands why the tree is empty.
*/
function showServerForbiddenMessage() {
const treeEl = document.getElementById('file-tree');
if (!treeEl) return;
treeEl.innerHTML =
'<div class="server-forbidden-message" style="padding: 1rem; color: var(--text-muted, #555); font-size: 0.875rem;">' +
'<strong>No permission to list this directory.</strong>' +
'<p style="margin: 0.5rem 0 0;">Your account does not have read access here. ' +
'Contact the document controller if you believe this is wrong.</p>' +
'</div>';
}
/**
* Build a CRUD-capable file handle backed by a URL — uses the shared
* HTTP polyfill from window.zddc.source. The polyfill's getFile() does
* a GET, and createWritable() PUTs bytes back (file API on zddc-server).
*
* Adds `_serverUrl` for legacy code paths that still expect that field.
* Marks `_readOnly: false` so editor.js leaves save buttons enabled.
*/
function createServerFileHandle(name, url) {
const handle = new window.zddc.source.HttpFileHandle(url, name);
handle._serverUrl = url;
handle._readOnly = false;
return handle;
}
/**
* Build a CRUD-capable directory handle backed by a server URL — uses
* the shared HTTP polyfill. Supports values()/entries(), getFileHandle,
* getDirectoryHandle({create}), and removeEntry() against the server
* file API. _serverUrl/_readOnly are kept for legacy probes.
*/
function createServerDirectoryHandle(name, url) {
const handle = new window.zddc.source.HttpDirectoryHandle(url, name);
handle._serverUrl = url;
handle._readOnly = false;
return handle;
}
/**
* 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;
let href = window.location.href.split('?')[0].split('#')[0];
const lastSlash = href.lastIndexOf('/');
const baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/';
// Only enter server-source mode if the host actually serves JSON directory
// listings (zddc-server / Caddy). On a plain static host the probe fails
// and we must leave "Add Local Directory" visible so the user can still
// load local files.
//
// 403 means the host is a zddc-server but the user lacks `r` on this
// directory (a "no list" permission posture). Show a clear message so
// the user understands why the tree is empty.
try {
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
if (resp.status === 403) {
showServerForbiddenMessage();
return;
}
if (!resp.ok) return;
const items = await resp.json();
if (!Array.isArray(items)) return;
} catch (_) {
return;
}
serverSourceMode = true;
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. The server now exposes a CRUD file API, so write
// controls (new file, save, delete) stay enabled — the polyfill
// routes their writes through PUT/DELETE/POST. "Add Local Directory"
// is de-emphasized so the user can still load a local folder if they
// want, but server-mode is now the default working mode.
const refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) refreshBtn.classList.remove('hidden');
const addDirBtn = document.getElementById('addDirectoryBtn');
if (addDirBtn) {
addDirBtn.classList.remove('btn-primary');
addDirBtn.classList.add('btn--subtle');
}
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);
}