Skip to main content

Auto-Save & Conflict Detection

Markdown-OS provides intelligent auto-save functionality with conflict detection to prevent data loss. The system uses debounced saves, file locks, WebSocket notifications, and a conflict resolution dialog to handle concurrent edits safely.

Auto-Save System

Changes are automatically saved after a brief pause in editing:
1

User Edits

User types in the WYSIWYG editor, triggering change events
2

Debounce Timer

A 1-second timer starts (or resets) on each edit
3

Automatic Save

After 1 second of inactivity, content is saved to disk via POST /api/save
4

Save Status Update

UI shows “Saving…”, then “Saved” or error status
The auto-save delay is configurable via AUTOSAVE_DELAY_MS constant (default: 1000ms).

Implementation

Editor Integration

function onEditorInput() {
  const markdown = currentMarkdown();
  
  if (editorState.mode === "folder" && window.fileTabs?.isEnabled()) {
    const activePath = window.fileTabs.getActiveTabPath();
    const tabData = window.fileTabs.getTabData(activePath);
    tabData.content = markdown;
    const isDirty = window.fileTabs.updateTabDirtyState(activePath);
    if (isDirty) {
      setSaveStatus("Unsaved changes");
      window.fileTabs.queueTabAutosave(activePath);
    }
    queueTOCUpdate();
    return;
  }
  
  if (markdown !== editorState.lastSavedContent) {
    setSaveStatus("Unsaved changes");
    queueAutosave();
  }
  
  queueTOCUpdate();
}

function queueAutosave() {
  if (editorState.saveTimeout) {
    window.clearTimeout(editorState.saveTimeout);
  }
  
  editorState.saveTimeout = window.setTimeout(() => {
    if (editorState.mode === "folder" && !editorState.currentFilePath) {
      return;
    }
    saveContent();
  }, AUTOSAVE_DELAY_MS);
}

Save Request

async function saveContent() {
  if (editorState.isSaving) {
    return false;
  }
  
  if (editorState.mode === "folder" && !editorState.currentFilePath) {
    setSaveStatus("Select a file", "error");
    return false;
  }
  
  editorState.isSaving = true;
  const content = currentMarkdown();
  setSaveStatus("Saving...", "saving");
  
  try {
    const payload = { content };
    if (editorState.mode === "folder") {
      payload.file = editorState.currentFilePath;
    }
    
    const response = await fetch("/api/save", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
    
    if (!response.ok) {
      throw new Error(`Save failed (${response.status})`);
    }
    
    const responsePayload = await response.json();
    editorState.lastSavedContent = content;
    setSaveStatus("Saved", "saved");
    return true;
  } catch (error) {
    console.error("Failed to save markdown content.", error);
    setSaveStatus("Save failed", "error");
    return false;
  } finally {
    editorState.isSaving = false;
  }
}
The isSaving flag prevents concurrent save requests, ensuring only one save operation runs at a time.

File Locking

The backend uses POSIX file locks to coordinate concurrent access:
@contextmanager
def _acquire_lock(self, exclusive: bool) -> Iterator[None]:
    self._lock_path.parent.mkdir(parents=True, exist_ok=True)
    with self._lock_path.open("a+", encoding="utf-8") as lock_file:
        self._lock_created = True
        lock_flags = portalocker.LOCK_EX if exclusive else portalocker.LOCK_SH
        try:
            portalocker.lock(lock_file, lock_flags)
        except portalocker.LockException as exc:
            raise FileWriteError(f"Failed to acquire lock: {self._lock_path}") from exc
        try:
            yield
        finally:
            portalocker.unlock(lock_file)

Lock Semantics

  • Shared locks (LOCK_SH): Multiple readers can access the file simultaneously
  • Exclusive locks (LOCK_EX): Only one writer can access the file at a time
  • Lock files: Stored as <filename>.md.lock adjacent to the markdown file
Lock files are automatically cleaned up when the handler instance is destroyed.

Atomic Writes

Saves use atomic file replacement to prevent corruption:
1

Write to Temp File

Content is written to .{filename}.tmp in the same directory
2

Fsync

File descriptor is fsynced to ensure data is on disk
3

Atomic Replace

os.replace() atomically replaces the original file
4

Cleanup

Temp file is removed on any failure
def write(self, content: str) -> bool:
    with self._acquire_lock(exclusive=True):
        temp_path = self._write_temporary_file(content)
        try:
            os.replace(temp_path, self._filepath)
        except OSError as exc:
            self._safe_remove(temp_path)
            raise FileWriteError(f"Failed to replace file: {self._filepath}") from exc
        return True

def _write_temporary_file(self, content: str) -> Path:
    file_descriptor, temp_name = tempfile.mkstemp(
        prefix=f".{self._filepath.name}.",
        suffix=".tmp",
        dir=str(self._filepath.parent),
    )
    with os.fdopen(file_descriptor, "w", encoding="utf-8") as temp_file:
        temp_file.write(content)
        temp_file.flush()
        os.fsync(temp_file.fileno())
    return Path(temp_name)
Atomic replacement ensures that the file is never in a partially-written state, even if the process crashes during the write.

External Change Detection

The editor detects when files are modified externally using Watchdog:

File Watcher Setup

@asynccontextmanager
async def lifespan(app: FastAPI):
    observer = Observer()
    loop = asyncio.get_running_loop()
    
    def notify_external_change(changed_path: Path) -> None:
        loop.call_soon_threadsafe(
            lambda: asyncio.create_task(
                _broadcast_external_change(app, changed_path)
            )
        )
    
    if app.state.mode == "file":
        event_handler = MarkdownPathEventHandler(
            target_file=file_handler.filepath,
            notify_callback=notify_external_change,
            should_ignore=lambda: _should_ignore_watcher_event(app),
        )
        watch_path = str(file_handler.filepath.parent)
        recursive = False
    else:
        event_handler = MarkdownPathEventHandler(
            root_directory=directory_handler.directory,
            notify_callback=notify_external_change,
            should_ignore=lambda: _should_ignore_watcher_event(app),
        )
        watch_path = str(directory_handler.directory)
        recursive = True
    
    observer.schedule(event_handler, path=watch_path, recursive=recursive)
    observer.start()
    
    try:
        yield
    finally:
        observer.stop()
        observer.join(timeout=3)

Event Filtering

def _handle_event(self, event: FileSystemEvent) -> None:
    if event.is_directory:
        return
    
    event_path_value = getattr(event, "dest_path", None) or event.src_path
    try:
        resolved_event_path = Path(event_path_value).resolve()
    except OSError:
        return
    
    if not self._is_relevant_path(resolved_event_path):
        return
    if self._should_ignore():
        return
    
    # Throttle to max one notification per 0.2s
    now = time.monotonic()
    if now - self._last_notified_at < 0.2:
        return
    
    self._last_notified_at = now
    self._notify_callback(resolved_event_path)
Events are throttled to one notification per 200ms to prevent flooding from rapid successive changes.

WebSocket Notifications

External changes are broadcast to connected clients via WebSocket:
async def _broadcast_external_change(app: FastAPI, changed_path: Path) -> None:
    if app.state.mode == "file":
        file_handler = _require_file_handler(app)
        try:
            content = file_handler.read()
        except FileReadError:
            return
        
        await app.state.websocket_hub.broadcast_json(
            {"type": "file_changed", "content": content}
        )
        return
    
    directory_handler = _require_directory_handler(app)
    try:
        relative_path = changed_path.relative_to(directory_handler.directory).as_posix()
    except ValueError:
        return
    
    payload: dict[str, str] = {"type": "file_changed", "file": relative_path}
    if directory_handler.validate_file_path(relative_path):
        try:
            file_handler = directory_handler.get_file_handler(relative_path)
            payload["content"] = file_handler.read()
        except (FileReadError, FileNotFoundError, ValueError):
            pass
    
    await app.state.websocket_hub.broadcast_json(payload)

Client-Side Handling

async function handleExternalChange(detail) {
  if (!detail || typeof detail.content !== "string") {
    return;
  }
  
  if (editorState.mode === "folder" && detail.file !== editorState.currentFilePath) {
    return;
  }
  
  const content = currentMarkdown();
  if (detail.content === content) {
    return;
  }
  
  const hasUnsavedChanges = content !== editorState.lastSavedContent;
  if (!hasUnsavedChanges) {
    // No conflict: reload automatically
    await setMarkdown(detail.content, { silent: true });
    editorState.lastSavedContent = detail.content;
    if (typeof window.generateTOC === "function") {
      window.generateTOC();
    }
    setSaveStatus("Reloaded from disk", "saved");
    return;
  }
  
  // Conflict: show dialog
  const shouldReload = await window.markdownDialogs?.confirm?.({
    title: "External Change Detected",
    message: "This file was changed externally and you have unsaved changes. Reload and discard your changes?",
    confirmText: "Reload",
    cancelText: "Keep mine",
    confirmVariant: "danger",
  });
  
  if (!shouldReload) {
    setSaveStatus("External change ignored");
    return;
  }
  
  await setMarkdown(detail.content, { silent: true });
  editorState.lastSavedContent = detail.content;
  setSaveStatus("Reloaded from disk", "saved");
}
If there are no unsaved changes, the editor reloads automatically. If there are unsaved changes, a confirmation dialog is shown.

Conflict Detection

When switching files or receiving external changes, conflicts are detected:

Detection Logic

async function checkForExternalChanges(filePath = null) {
  const requestUrl = buildContentUrl(filePath);
  if (!requestUrl) {
    return false;
  }
  
  try {
    const response = await fetch(requestUrl);
    if (!response.ok) {
      return false;
    }
    
    const payload = await response.json();
    const diskContent = payload.content || "";
    return diskContent !== editorState.lastSavedContent;
  } catch (error) {
    console.error("Failed to check for external changes.", error);
    return false;
  }
}

Conflict Dialog

When a conflict is detected, a 3-button modal is shown:
async function showConflictDialog() {
  return new Promise((resolve) => {
    const modal = document.getElementById("conflict-modal");
    const overlay = document.getElementById("conflict-overlay");
    const saveButton = document.getElementById("conflict-save");
    const discardButton = document.getElementById("conflict-discard");
    const cancelButton = document.getElementById("conflict-cancel");
    
    const previousFocus = document.activeElement;
    const previousScrollTop = window.wysiwyg?.getScrollTop?.() ?? null;
    let choiceMade = false;
    
    const choose = (choice) => {
      if (choiceMade) return;
      choiceMade = true;
      modal.classList.add("hidden");
      overlay.classList.add("hidden");
      cleanup();
      focusWithoutScroll(previousFocus);
      if (Number.isFinite(previousScrollTop)) {
        window.requestAnimationFrame(() => {
          window.wysiwyg?.setScrollTop?.(previousScrollTop);
        });
      }
      resolve(choice);
    };
    
    saveButton.onclick = () => choose("save");
    discardButton.onclick = () => choose("discard");
    cancelButton.onclick = () => choose("cancel");
    overlay.onclick = () => choose("cancel");
    
    modal.classList.remove("hidden");
    overlay.classList.remove("hidden");
    focusWithoutScroll(saveButton);
  });
}
1

Save My Changes

Overwrites the external changes with your local changes
2

Discard My Changes

Reloads the file from disk, losing your local changes
3

Cancel

Closes the dialog and keeps your local changes without switching files
Choosing “Save My Changes” will overwrite any external edits. Review the changes carefully before deciding.

Internal Write Tracking

The server tracks internal writes to ignore self-triggered watcher events:
def _should_ignore_watcher_event(app: FastAPI) -> bool:
    return (time.monotonic() - app.state.last_internal_write_at) < 0.5

@app.post("/api/save")
async def save_content(payload: SaveRequest) -> dict[str, object]:
    file_handler.write(payload.content)
    app.state.last_internal_write_at = time.monotonic()
    # ...
Events within 500ms of an internal write are ignored, preventing the editor from reloading its own changes.

Save Status Indicator

The UI displays the current save state:
  • Loaded: Initial state after loading a file
  • Unsaved changes: Content has been edited but not saved
  • Saving…: Save request is in progress
  • Saved: Content successfully saved to disk
  • Save failed: Error occurred during save
  • Reloaded from disk: External changes were reloaded
  • External change ignored: User chose to keep local changes
  • Select a file: No file is currently active (folder mode)
function setSaveStatus(message, variant = "default") {
  const indicator = document.getElementById("save-status");
  if (!indicator) return;
  
  indicator.textContent = message;
  indicator.className = `save-status save-status-${variant}`;
}

Multi-File Tab Support

In folder mode with tabs enabled, auto-save tracks per-tab state:
if (editorState.mode === "folder" && window.fileTabs?.isEnabled()) {
  const activePath = window.fileTabs.getActiveTabPath();
  if (!activePath) {
    setSaveStatus("Select a file", "error");
    return;
  }
  
  const tabData = window.fileTabs.getTabData(activePath);
  tabData.content = markdown;
  const isDirty = window.fileTabs.updateTabDirtyState(activePath);
  if (isDirty) {
    setSaveStatus("Unsaved changes");
    window.fileTabs.queueTabAutosave(activePath);
  }
  queueTOCUpdate();
  return;
}
Each tab has its own dirty state, last saved content, and auto-save timer, allowing independent tracking of multiple files.

Best Practices

Wait for save confirmation: The save status indicator shows “Saved” when changes are persisted. Wait for this before closing the browser.
Don’t edit externally during active sessions: While conflict detection protects against data loss, it’s best to avoid concurrent edits from multiple sources.
Browser refresh: Unsaved changes are lost on browser refresh. Always wait for the auto-save to complete.
Manual save: You can trigger a manual save by clicking away from the editor or switching to another tab (in folder mode).

Build docs developers (and LLMs) love