@wonderwhy-er/desktop-commander
Version:
MCP server for terminal operations and file editing
1,041 lines (1,040 loc) • 45.9 kB
JavaScript
import { attachDocumentOutline, renderDocumentOutline } from '../document-outline.js';
import { getDocumentFullscreenAvailability, parseReadRange, shouldAutoLoadDocumentOnEnterFullscreen, stripReadStatusLine } from '../document-workspace.js';
import { assertSuccessfulEditBlockResult, extractRenderPayload, extractToolText } from '../payload-utils.js';
import { getAncestorDirectories, getParentDirectory, toPosixRelativePath } from '../path-utils.js';
import { mountMarkdownEditor, renderMarkdownEditorShell } from './editor.js';
import { resolveMarkdownLink } from './linking.js';
import { extractMarkdownOutline } from './outline.js';
import { getRenderedMarkdownCopyText } from './preview.js';
import { slugifyMarkdownHeading } from './slugify.js';
import { getFileExtensionForAnalytics } from '../payload-utils.js';
const MAX_EDIT_BLOCK_LINES = 40;
function areOutlineItemsEqual(left, right) {
if (left.length !== right.length) {
return false;
}
return left.every((item, index) => {
const other = right[index];
return item.id === other.id
&& item.text === other.text
&& item.level === other.level
&& item.line === other.line;
});
}
function splitListingLines(text) {
return text.split('\n').map((line) => line.trim()).filter(Boolean);
}
function parseFileSearchResults(text) {
return text.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('📁 '))
.map((line) => line.slice(3).trim());
}
function stripMarkdownExtension(filePath) {
return filePath.replace(/\.md$/i, '');
}
function computeDiffHunks(oldLines, newLines) {
const oldLength = oldLines.length;
const newLength = newLines.length;
const dp = Array.from({ length: oldLength + 1 }, () => Array(newLength + 1).fill(0));
for (let i = 1; i <= oldLength; i += 1) {
for (let j = 1; j <= newLength; j += 1) {
dp[i][j] = oldLines[i - 1] === newLines[j - 1]
? dp[i - 1][j - 1] + 1
: Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
const matches = [];
let oldIndex = oldLength;
let newIndex = newLength;
while (oldIndex > 0 && newIndex > 0) {
if (oldLines[oldIndex - 1] === newLines[newIndex - 1]) {
matches.unshift([oldIndex - 1, newIndex - 1]);
oldIndex -= 1;
newIndex -= 1;
}
else if (dp[oldIndex - 1][newIndex] >= dp[oldIndex][newIndex - 1]) {
oldIndex -= 1;
}
else {
newIndex -= 1;
}
}
const hunks = [];
let previousOld = 0;
let previousNew = 0;
for (const [matchOld, matchNew] of matches) {
if (matchOld > previousOld || matchNew > previousNew) {
hunks.push({ oldStart: previousOld, oldEnd: matchOld, newStart: previousNew, newEnd: matchNew });
}
previousOld = matchOld + 1;
previousNew = matchNew + 1;
}
if (previousOld < oldLength || previousNew < newLength) {
hunks.push({ oldStart: previousOld, oldEnd: oldLength, newStart: previousNew, newEnd: newLength });
}
return hunks;
}
function mergeCloseHunks(hunks, minGap) {
if (hunks.length <= 1) {
return hunks;
}
const merged = [{ ...hunks[0] }];
for (let index = 1; index < hunks.length; index += 1) {
const previous = merged[merged.length - 1];
const current = hunks[index];
if (current.oldStart - previous.oldEnd < minGap) {
previous.oldEnd = current.oldEnd;
previous.newEnd = current.newEnd;
continue;
}
merged.push({ ...current });
}
return merged;
}
function mergeLineRanges(ranges) {
const sorted = ranges
.map((range) => ({ fromLine: Math.max(1, Math.floor(range.fromLine)), toLine: Math.max(1, Math.floor(range.toLine)) }))
.sort((left, right) => left.fromLine - right.fromLine || left.toLine - right.toLine);
const merged = [];
for (const range of sorted) {
const normalized = {
fromLine: Math.min(range.fromLine, range.toLine),
toLine: Math.max(range.fromLine, range.toLine),
};
const previous = merged[merged.length - 1];
if (previous && normalized.fromLine <= previous.toLine + 1) {
previous.toLine = Math.max(previous.toLine, normalized.toLine);
}
else {
merged.push(normalized);
}
}
return merged;
}
function hunkIntersectsRanges(hunk, ranges) {
if (ranges.length === 0) {
return true;
}
const fromLine = Math.min(hunk.oldStart, hunk.newStart) + 1;
const toLine = Math.max(hunk.oldEnd, hunk.newEnd) + 1;
return ranges.some((range) => fromLine <= range.toLine && toLine >= range.fromLine);
}
function computeLineByLineHunks(oldLines, newLines) {
return computeAnchoredDiffHunks(oldLines, newLines, 0, oldLines.length, 0, newLines.length);
}
function computeAnchoredDiffHunks(oldLines, newLines, oldStart, oldEnd, newStart, newEnd) {
while (oldStart < oldEnd && newStart < newEnd && oldLines[oldStart] === newLines[newStart]) {
oldStart++;
newStart++;
}
while (oldStart < oldEnd && newStart < newEnd && oldLines[oldEnd - 1] === newLines[newEnd - 1]) {
oldEnd--;
newEnd--;
}
if (oldStart === oldEnd && newStart === newEnd) {
return [];
}
const oldLineCounts = new Map();
const newLineCounts = new Map();
for (let index = oldStart; index < oldEnd; index += 1) {
const current = oldLineCounts.get(oldLines[index]);
oldLineCounts.set(oldLines[index], { count: (current?.count ?? 0) + 1, index });
}
for (let index = newStart; index < newEnd; index += 1) {
const current = newLineCounts.get(newLines[index]);
newLineCounts.set(newLines[index], { count: (current?.count ?? 0) + 1, index });
}
for (let oldIndex = oldStart; oldIndex < oldEnd; oldIndex += 1) {
const oldEntry = oldLineCounts.get(oldLines[oldIndex]);
const newEntry = newLineCounts.get(oldLines[oldIndex]);
if (oldEntry?.count === 1 && newEntry?.count === 1) {
return [
...computeAnchoredDiffHunks(oldLines, newLines, oldStart, oldIndex, newStart, newEntry.index),
...computeAnchoredDiffHunks(oldLines, newLines, oldIndex + 1, oldEnd, newEntry.index + 1, newEnd),
];
}
}
return [{ oldStart, oldEnd, newStart, newEnd }];
}
function splitOversizedEditBlock(oldText, newText) {
const oldLines = oldText.split('\n');
const newLines = newText.split('\n');
const blockCount = Math.ceil(Math.max(oldLines.length, newLines.length) / MAX_EDIT_BLOCK_LINES);
const blocks = [];
for (let blockIndex = 0; blockIndex < blockCount; blockIndex += 1) {
const oldStart = Math.floor((blockIndex * oldLines.length) / blockCount);
const oldEnd = Math.floor(((blockIndex + 1) * oldLines.length) / blockCount);
const newStart = Math.floor((blockIndex * newLines.length) / blockCount);
const newEnd = Math.floor(((blockIndex + 1) * newLines.length) / blockCount);
const old_string = oldLines.slice(oldStart, oldEnd).join('\n');
const new_string = newLines.slice(newStart, newEnd).join('\n');
if (old_string !== new_string) {
blocks.push({ old_string, new_string });
}
}
return blocks;
}
function splitOversizedEditBlocks(blocks) {
return blocks.flatMap((block) => {
const lineCount = Math.max(block.old_string.split('\n').length, block.new_string.split('\n').length);
return lineCount > MAX_EDIT_BLOCK_LINES
? splitOversizedEditBlock(block.old_string, block.new_string)
: [block];
});
}
export function computeEditBlocks(oldText, newText, changedRanges = []) {
if (oldText === newText) {
return [];
}
const oldLines = oldText.split('\n');
const newLines = newText.split('\n');
const hunks = oldLines.length * newLines.length > 1000000
? computeLineByLineHunks(oldLines, newLines)
: computeDiffHunks(oldLines, newLines);
if (hunks.length === 0) {
return [];
}
const context = 3;
const normalizedRanges = mergeLineRanges(changedRanges);
const merged = mergeCloseHunks(hunks, context * 2 + 1).filter((hunk) => hunkIntersectsRanges(hunk, normalizedRanges));
const blocks = merged.map((hunk) => {
const contextBefore = Math.max(0, hunk.oldStart - context);
const contextAfter = Math.min(oldLines.length, hunk.oldEnd + context);
const oldBlock = oldLines.slice(contextBefore, contextAfter).join('\n');
const newBlock = [
...oldLines.slice(contextBefore, hunk.oldStart),
...newLines.slice(hunk.newStart, hunk.newEnd),
...oldLines.slice(hunk.oldEnd, contextAfter),
].join('\n');
return { old_string: oldBlock, new_string: newBlock };
}).filter((block) => block.old_string !== block.new_string);
if (blocks.length === 1 && blocks[0].old_string === oldText && blocks[0].new_string === newText) {
return splitOversizedEditBlock(oldText, newText);
}
return splitOversizedEditBlocks(blocks);
}
function applyEditBlocksToText(text, blocks) {
return blocks.reduce((current, block) => current.replace(block.old_string, block.new_string), text);
}
function isToolErrorResult(value) {
return typeof value === 'object' && value !== null;
}
function isMissingFileErrorResult(result) {
if (!isToolErrorResult(result) || result.isError !== true) {
return false;
}
const message = extractToolText(result)?.toLowerCase() ?? '';
return message.includes('not found')
|| message.includes('no such file')
|| message.includes('enoent');
}
export function createMarkdownController(dependencies) {
let workspaceState;
let markdownEditorHandle;
let markdownTocHandle;
let autosaveTimer = null;
const AUTOSAVE_DEBOUNCE_MS = 1000;
function scheduleAutosave() {
if (autosaveTimer !== null) {
clearTimeout(autosaveTimer);
}
autosaveTimer = setTimeout(() => {
autosaveTimer = null;
void saveDocument();
}, AUTOSAVE_DEBOUNCE_MS);
}
function cancelAutosave() {
if (autosaveTimer !== null) {
clearTimeout(autosaveTimer);
autosaveTimer = null;
}
}
function disposeHandles() {
cancelAutosave();
markdownEditorHandle?.destroy();
markdownEditorHandle = undefined;
markdownTocHandle?.dispose();
markdownTocHandle = undefined;
}
function clear() {
workspaceState = undefined;
disposeHandles();
}
function readPayloadContent(payload) {
return stripReadStatusLine(payload.content);
}
function syncStateFromContent(state, content, options = {}) {
const nextDraftContent = options.keepDraft ? state.draftContent : content;
state.sourceContent = content;
state.fullDocumentContent = content;
state.draftContent = nextDraftContent;
state.outline = extractMarkdownOutline(content);
state.dirty = nextDraftContent !== content;
state.dirtyLineRanges = [];
state.fileDeleted = false;
if (!state.outline.some((item) => item.id === state.activeHeadingId)) {
state.activeHeadingId = state.outline[0]?.id ?? null;
}
}
async function callReadFile(filePath, length, offset) {
const rawResult = await dependencies.callTool?.('read_file', {
path: filePath,
...(typeof length === 'number' ? { offset: offset ?? 0, length } : {}),
});
return { rawResult, payload: extractRenderPayload(rawResult) ?? null };
}
async function readPayload(filePath, length, offset) {
return (await callReadFile(filePath, length, offset)).payload;
}
async function ensureCompletePayload(payload) {
const range = parseReadRange(payload.content);
if (!range?.isPartial) {
return payload;
}
return (await readPayload(payload.filePath, range.totalLines)) ?? payload;
}
async function readCompletePayload(filePath) {
const payload = await readPayload(filePath);
if (!payload) {
return null;
}
return ensureCompletePayload(payload);
}
function getState(payload) {
const cleanedContent = stripReadStatusLine(payload.content);
if (!workspaceState || workspaceState.filePath !== payload.filePath || workspaceState.sourceContent !== cleanedContent) {
const outline = extractMarkdownOutline(cleanedContent);
workspaceState = {
filePath: payload.filePath,
sourceContent: cleanedContent,
fullDocumentContent: cleanedContent,
draftContent: cleanedContent,
outline,
mode: 'edit',
dirty: false,
dirtyLineRanges: [],
activeHeadingId: outline[0]?.id ?? null,
pendingAnchor: null,
notice: null,
error: null,
saving: false,
loadingDocument: false,
editorView: 'markdown',
editorScrollTop: 0,
saveIndicator: 'idle',
fileDeleted: false,
};
}
return workspaceState;
}
function isUndoAvailable(state) {
return state.draftContent !== state.fullDocumentContent;
}
function buildBody(payload) {
const state = getState(payload);
const outline = state.outline;
const isFullscreen = dependencies.getCurrentDisplayMode() === 'fullscreen';
const tocHtml = isFullscreen ? renderDocumentOutline(outline, state.activeHeadingId) : '';
if (!state.activeHeadingId && outline.length > 0) {
state.activeHeadingId = outline[0].id;
}
const notice = [state.error, state.notice]
.find((value) => typeof value === 'string' && value.trim().length > 0);
return {
notice,
html: `
<div class="panel-content markdown-content markdown-content--workspace">
<div class="markdown-workspace markdown-workspace--edit${tocHtml ? ' markdown-workspace--with-toc' : ''}">
${tocHtml}
<section class="markdown-workspace-main markdown-workspace-main--editor">
${renderMarkdownEditorShell({ view: state.editorView })}
</section>
</div>
</div>
`,
};
}
async function resolveLinkSearchRoot(filePath) {
const ancestors = getAncestorDirectories(filePath);
const markers = new Set(['[DIR] .git', '[DIR] .obsidian', '[FILE] package.json', '[FILE] pnpm-workspace.yaml', '[FILE] turbo.json']);
for (const ancestor of ancestors) {
try {
const result = await dependencies.callTool?.('list_directory', { path: ancestor, depth: 1 });
const text = extractToolText(result) ?? '';
const entries = splitListingLines(text);
if (entries.some((entry) => markers.has(entry))) {
return ancestor;
}
}
catch {
// Ignore and continue up the tree.
}
}
return getParentDirectory(filePath);
}
async function searchLinkTargets(filePath, query) {
const trimmedQuery = query.trim();
if (trimmedQuery.length === 0) {
return [];
}
const rootPath = await resolveLinkSearchRoot(filePath);
const result = await dependencies.callTool?.('start_search', {
path: rootPath,
pattern: trimmedQuery,
searchType: 'files',
filePattern: '*.md',
maxResults: 20,
earlyTermination: false,
literalSearch: true,
});
const text = extractToolText(result) ?? '';
const filePaths = parseFileSearchResults(text);
const currentDirectory = getParentDirectory(filePath);
return filePaths.map((targetPath) => {
const normalized = targetPath.replace(/\\/g, '/');
const fileName = normalized.split('/').pop() ?? normalized;
const title = stripMarkdownExtension(fileName);
const relativePath = toPosixRelativePath(currentDirectory, normalized);
const wikiPath = stripMarkdownExtension(relativePath.startsWith('./') ? relativePath.slice(2) : relativePath);
return {
path: normalized,
title,
wikiPath,
relativePath,
};
});
}
async function loadLinkHeadings(currentPayloadPath, targetPath) {
if (targetPath === currentPayloadPath && workspaceState) {
return workspaceState.outline.map((item) => ({ id: item.id, text: item.text }));
}
const payload = await readCompletePayload(targetPath);
if (!payload) {
return [];
}
return extractMarkdownOutline(readPayloadContent(payload)).map((item) => ({ id: item.id, text: item.text }));
}
function findHeading(anchor) {
const trimmedAnchor = anchor.trim();
if (!trimmedAnchor) {
return null;
}
return document.getElementById(trimmedAnchor) ?? document.getElementById(slugifyMarkdownHeading(trimmedAnchor));
}
function scrollHeadingIntoView(anchor) {
const heading = findHeading(anchor);
if (!heading) {
return false;
}
const scrollParents = [];
let current = heading.parentElement;
while (current) {
const style = window.getComputedStyle(current);
const overflowY = style.overflowY;
const isScrollable = (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay')
&& current.scrollHeight > current.clientHeight;
if (isScrollable) {
scrollParents.push(current);
}
current = current.parentElement;
}
heading.scrollIntoView({ block: 'start', inline: 'nearest' });
for (const parent of scrollParents) {
const parentRect = parent.getBoundingClientRect();
const headingRect = heading.getBoundingClientRect();
const nextTop = Math.max(parent.scrollTop + (headingRect.top - parentRect.top) - 24, 0);
parent.scrollTop = nextTop;
}
const rootScroller = document.scrollingElement;
if (rootScroller) {
const rootRectTop = heading.getBoundingClientRect().top;
const nextRootTop = Math.max(rootScroller.scrollTop + rootRectTop - 24, 0);
rootScroller.scrollTop = nextRootTop;
}
heading.setAttribute('tabindex', '-1');
heading.focus({ preventScroll: true });
if (workspaceState) {
workspaceState.activeHeadingId = heading.id || slugifyMarkdownHeading(anchor);
}
return true;
}
function applyPendingAnchor() {
const pendingAnchor = workspaceState?.pendingAnchor;
if (!workspaceState || !pendingAnchor) {
return;
}
workspaceState.pendingAnchor = null;
if (!scrollHeadingIntoView(pendingAnchor)) {
workspaceState.error = `Heading not found: ${pendingAnchor}`;
dependencies.rerender();
}
}
function flashSaveStatus(label, statusClass, timeoutMs, beforeClear) {
dependencies.updateSaveStatus(label, statusClass);
window.setTimeout(() => {
if (beforeClear && !beforeClear()) {
return;
}
dependencies.updateSaveStatus('', '');
}, timeoutMs);
}
async function refreshFromDisk(payload) {
try {
const range = parseReadRange(payload.content);
const { rawResult, payload: freshPayload } = range?.isPartial
? await callReadFile(payload.filePath, range.toLine - range.fromLine + 1, range.readOffset)
: await callReadFile(payload.filePath);
if (!freshPayload) {
if (isMissingFileErrorResult(rawResult)) {
if (workspaceState) {
workspaceState.fileDeleted = true;
}
dependencies.updateSaveStatus('File deleted', 'saved');
}
return;
}
const freshContent = readPayloadContent(freshPayload);
const currentContent = readPayloadContent(payload);
if (freshContent === currentContent) {
return;
}
// refreshFromDisk only runs at mount (no file watcher in this app),
// so disk-vs-payload mismatch means the host sent a stale cached
// payload — trust the disk read and reload silently.
dependencies.storePayloadOverride(freshPayload);
workspaceState = undefined;
dependencies.rerender();
}
catch {
// Silently fall back to host payload.
}
}
async function loadFullDocument(payload, options = {}) {
const state = getState(payload);
const range = parseReadRange(payload.content);
if (!range?.isPartial) {
if (options.keepEditMode) {
state.mode = 'edit';
state.editorView = 'markdown';
state.notice = null;
state.error = null;
state.draftContent = state.sourceContent;
state.dirty = false;
dependencies.rerender();
}
return;
}
state.loadingDocument = true;
state.notice = 'Loading full document…';
state.error = null;
dependencies.rerender();
try {
const nextPayload = await readPayload(payload.filePath, range.totalLines);
if (!nextPayload) {
state.error = 'Failed to load the full document.';
state.notice = null;
state.loadingDocument = false;
dependencies.rerender();
return;
}
dependencies.syncPayload?.(nextPayload);
const nextState = getState(nextPayload);
nextState.loadingDocument = false;
nextState.notice = null;
nextState.error = null;
syncStateFromContent(nextState, nextState.sourceContent);
if (options.keepEditMode) {
nextState.mode = 'edit';
nextState.editorView = 'markdown';
dependencies.rerender();
}
}
catch {
state.loadingDocument = false;
state.notice = null;
state.error = 'Failed to load the full document.';
dependencies.rerender();
}
}
async function navigateLink(payload, href) {
const state = getState(payload);
if (state.dirty) {
const shouldDiscard = window.confirm('Discard unsaved changes and follow this link?');
if (!shouldDiscard) {
return;
}
}
const resolvedLink = resolveMarkdownLink(payload.filePath, href);
state.notice = null;
state.error = null;
if (resolvedLink.kind === 'external' && resolvedLink.url) {
const opened = await dependencies.openExternalLink?.(resolvedLink.url);
if (!opened) {
try {
window.open(resolvedLink.url, '_blank', 'noopener');
}
catch { /* sandbox may block */ }
}
return;
}
if (resolvedLink.kind === 'anchor' && resolvedLink.anchor) {
if (!scrollHeadingIntoView(resolvedLink.anchor) && workspaceState) {
workspaceState.error = `Heading not found: ${resolvedLink.anchor}`;
dependencies.rerender();
}
return;
}
if (resolvedLink.kind === 'file' && resolvedLink.targetPath) {
const hostHandled = await dependencies.openExternalLink?.(resolvedLink.targetPath);
if (hostHandled) {
return;
}
const nextPayload = await readPayload(resolvedLink.targetPath);
if (!nextPayload) {
if (workspaceState) {
workspaceState.error = `Unable to open ${resolvedLink.targetPath}.`;
dependencies.rerender();
}
return;
}
dependencies.syncPayload?.(nextPayload);
const nextState = getState(nextPayload);
nextState.pendingAnchor = resolvedLink.anchor ?? null;
nextState.error = null;
nextState.notice = null;
dependencies.rerender();
}
}
async function requestEditMode(payload) {
const state = getState(payload);
state.error = null;
state.notice = null;
if (shouldAutoLoadDocumentOnEnterFullscreen(payload.content)) {
await loadFullDocument(payload, { keepEditMode: true });
return;
}
state.mode = 'edit';
state.draftContent = state.fullDocumentContent;
state.dirty = false;
state.editorView = 'markdown';
dependencies.setExpanded(true);
dependencies.rerender();
}
async function requestFullscreen() {
const fullscreenAvailability = getDocumentFullscreenAvailability({
availableDisplayModes: dependencies.getAvailableDisplayModes(),
});
if (!fullscreenAvailability.canFullscreen) {
return false;
}
const nextMode = await dependencies.requestDisplayMode?.('fullscreen');
return nextMode === 'fullscreen';
}
function revertEditing() {
if (!workspaceState) {
return;
}
const filePath = workspaceState.filePath;
workspaceState.draftContent = workspaceState.fullDocumentContent;
workspaceState.dirty = false;
workspaceState.dirtyLineRanges = [];
workspaceState.error = null;
workspaceState.notice = null;
dependencies.rerender();
flashSaveStatus('Reverted', 'saved', 1500);
dependencies.trackUiEvent?.('markdown_reverted', {
file_extension: getFileExtensionForAnalytics(filePath),
});
}
async function saveDocument() {
if (!workspaceState || workspaceState.saving || !workspaceState.dirty || workspaceState.fileDeleted) {
return;
}
cancelAutosave();
const state = workspaceState;
state.saving = true;
state.saveIndicator = 'saving';
state.error = null;
state.notice = null;
try {
const blocks = computeEditBlocks(state.fullDocumentContent, state.draftContent, state.dirtyLineRanges);
if (blocks.length === 0) {
state.saving = false;
state.saveIndicator = 'idle';
state.dirty = false;
state.dirtyLineRanges = [];
return;
}
// Try each hunk independently. Previously the loop threw on the
// first soft-failure, which left earlier hunks already written to
// disk while the UI claimed "Save failed" — making it look like the
// editor had silently overwritten external changes. Now we track
// per-hunk outcomes so we can give the user an honest accounting.
let appliedCount = 0;
const skippedHunks = [];
let lastHardError = null;
for (const block of blocks) {
try {
const editResult = await dependencies.callTool?.('edit_block', {
file_path: state.filePath,
old_string: block.old_string,
new_string: block.new_string,
expected_replacements: 1,
});
assertSuccessfulEditBlockResult(editResult);
appliedCount++;
}
catch (hunkError) {
// A per-hunk failure is almost always "old_string not on
// disk" because the file changed there. Record it and keep
// going so other hunks still get their chance.
skippedHunks.push(block);
lastHardError = hunkError;
}
}
if (skippedHunks.length > 0) {
// Partial (or total) failure. Let the catch branch take it —
// throw an error carrying counts so the catch can decide how
// to communicate and how to resync baseline vs. disk.
const err = new Error(`${appliedCount} of ${blocks.length} edit${blocks.length === 1 ? '' : 's'} applied; ` +
`${skippedHunks.length} could not land because the text changed on disk.`);
err.appliedCount = appliedCount;
err.skippedCount = skippedHunks.length;
err.totalCount = blocks.length;
err.underlyingError = lastHardError;
throw err;
}
const savedContent = applyEditBlocksToText(state.fullDocumentContent, blocks);
state.fullDocumentContent = savedContent;
state.sourceContent = savedContent;
state.draftContent = savedContent;
state.outline = extractMarkdownOutline(state.sourceContent);
state.dirty = false;
state.dirtyLineRanges = [];
state.saving = false;
state.saveIndicator = 'saved';
if (!state.outline.some((item) => item.id === state.activeHeadingId)) {
state.activeHeadingId = state.outline[0]?.id ?? null;
}
const currentPayload = dependencies.getCurrentPayload();
if (currentPayload) {
const statusLineMatch = currentPayload.content.match(/^(\[Reading [^\]]+\]\r?\n(?:\r?\n)?)/);
const statusLine = statusLineMatch?.[1] ?? '';
dependencies.storePayloadOverride({ ...currentPayload, content: statusLine + savedContent });
}
const revert = document.getElementById('revert-markdown');
if (revert) {
revert.disabled = !isUndoAvailable(state);
}
flashSaveStatus('Saved', 'saved', 1800, () => {
if (!state.dirty && !state.saving) {
state.saveIndicator = 'idle';
return true;
}
return false;
});
dependencies.trackUiEvent?.('markdown_saved', {
file_extension: getFileExtensionForAnalytics(state.filePath),
blocks: blocks.length,
});
}
catch (error) {
state.saving = false;
state.saveIndicator = 'idle';
// Pull per-hunk counts from the synthetic error thrown by the save
// loop, when this catch is reached via a partial/total hunk failure.
const errWithCounts = error;
const appliedCount = typeof errWithCounts.appliedCount === 'number' ? errWithCounts.appliedCount : 0;
const skippedCount = typeof errWithCounts.skippedCount === 'number' ? errWithCounts.skippedCount : 0;
const totalCount = typeof errWithCounts.totalCount === 'number' ? errWithCounts.totalCount : 0;
const isPartialSuccess = appliedCount > 0 && skippedCount > 0;
const isTotalFailure = appliedCount === 0 && skippedCount > 0;
const freshPayload = await readCompletePayload(state.filePath).catch(() => null);
let reloadedFromDisk = false;
let freshContentForDialog = null;
if (freshPayload) {
const freshContent = readPayloadContent(freshPayload);
if (freshContent !== state.fullDocumentContent) {
syncStateFromContent(state, freshContent, { keepDraft: true });
dependencies.storePayloadOverride(freshPayload);
reloadedFromDisk = true;
freshContentForDialog = freshContent;
}
}
state.notice = null;
if (isPartialSuccess) {
// Some hunks landed on disk, some didn't. The user would be
// misled by a "Save failed" message — and would be misled by
// a conflict dialog claiming nothing was saved. Tell them the
// truth: N saved, M skipped, with the skipped lines preserved
// in their draft so they can re-try or edit around the
// external change.
state.error = (`${appliedCount} of ${totalCount} edit${totalCount === 1 ? '' : 's'} saved. ` +
`${skippedCount} ${skippedCount === 1 ? 'edit' : 'edits'} did not apply because that text changed on disk — ` +
`your draft still has them; save again to merge.`);
dependencies.rerender();
flashSaveStatus('Saved (partial)', 'saved', 3000);
dependencies.trackUiEvent?.('markdown_save_partial', {
file_extension: getFileExtensionForAnalytics(state.filePath),
applied: appliedCount,
skipped: skippedCount,
total: totalCount,
});
}
else if (isTotalFailure && reloadedFromDisk && dependencies.showConflictDialog && freshContentForDialog !== null) {
// No hunks landed and disk has changed. Genuine conflict — show
// the modal so the user can pick keep-mine / use-disk.
const savedFreshContent = freshContentForDialog;
const normalized = state.filePath.replace(/\\/g, '/');
const displayName = normalized.split('/').pop() || state.filePath;
state.error = null;
dependencies.rerender();
dependencies.trackUiEvent?.('markdown_save_conflict_shown', {
file_extension: getFileExtensionForAnalytics(state.filePath),
});
dependencies.showConflictDialog({
fileName: displayName,
onUseDiskVersion: () => {
if (workspaceState === state) {
syncStateFromContent(state, savedFreshContent);
dependencies.rerender();
}
dependencies.trackUiEvent?.('markdown_save_conflict_resolved', {
file_extension: getFileExtensionForAnalytics(state.filePath),
action: 'use_disk',
});
},
onSaveMyChanges: () => {
dependencies.trackUiEvent?.('markdown_save_conflict_resolved', {
file_extension: getFileExtensionForAnalytics(state.filePath),
action: 'save_mine',
});
// Re-run saveDocument. computeEditBlocks will diff against
// the fresh sourceContent that keepDraft: true left in place,
// so hunks the user actually modified win over disk on
// those specific lines and disk-only changes elsewhere are
// preserved.
void saveDocument();
},
onCancel: () => {
if (workspaceState === state) {
state.error = 'File changed on disk. Save again to merge your edits, or reopen to discard them.';
dependencies.rerender();
}
dependencies.trackUiEvent?.('markdown_save_conflict_resolved', {
file_extension: getFileExtensionForAnalytics(state.filePath),
action: 'dismissed',
});
},
});
}
else {
// Fallback: unexpected error that isn't a per-hunk soft-fail
// (e.g. read_file failure during resync, or an exception before
// the save loop started). Use a generic inline message.
state.error = reloadedFromDisk
? 'File changed on disk. Save again to merge your edits, or reopen the file to discard them.'
: error instanceof Error ? error.message : 'Save failed.';
dependencies.rerender();
flashSaveStatus('Save failed', 'saving', 3000);
}
dependencies.trackUiEvent?.('markdown_save_failed', {
file_extension: getFileExtensionForAnalytics(state.filePath),
reloaded_from_disk: reloadedFromDisk,
applied: appliedCount,
skipped: skippedCount,
total: totalCount,
});
}
}
function setEditorView(payload, view) {
const state = getState(payload);
const wrapper = document.querySelector('.panel-content-wrapper');
state.editorScrollTop = wrapper?.scrollTop ?? 0;
const previousView = state.editorView;
state.editorView = view;
state.notice = null;
state.error = null;
dependencies.rerender();
if (previousView !== view) {
dependencies.trackUiEvent?.('markdown_view_toggled', {
file_extension: getFileExtensionForAnalytics(payload.filePath),
view,
});
}
if (typeof state.editorScrollTop === 'number') {
window.requestAnimationFrame(() => {
const nextWrapper = document.querySelector('.panel-content-wrapper');
if (nextWrapper) {
nextWrapper.scrollTop = state.editorScrollTop;
}
});
}
}
function attachHandlers(payload) {
const state = getState(payload);
const wrapper = document.querySelector('.panel-content-wrapper');
const outline = state.outline;
const fileExtension = getFileExtensionForAnalytics(payload.filePath);
let editStartedFired = false;
{
const editorRoot = document.getElementById('markdown-editor-root');
if (editorRoot) {
markdownEditorHandle = mountMarkdownEditor({
target: editorRoot,
value: state.draftContent,
view: state.editorView,
initialScrollTop: state.editorScrollTop,
currentFilePath: payload.filePath,
searchLinks: (query) => searchLinkTargets(payload.filePath, query),
loadHeadings: (targetPath) => loadLinkHeadings(payload.filePath, targetPath),
onChange: (value, editRanges) => {
if (value === state.draftContent) {
return;
}
state.draftContent = value;
state.dirty = value !== state.fullDocumentContent;
if (state.dirty) {
const nextRanges = editRanges && editRanges.length > 0
? editRanges
: [{ fromLine: 1, toLine: value.split('\n').length }];
state.dirtyLineRanges = mergeLineRanges([
...state.dirtyLineRanges,
...nextRanges,
]);
}
else {
state.dirtyLineRanges = [];
}
if (state.dirty && !editStartedFired) {
editStartedFired = true;
dependencies.trackUiEvent?.('markdown_edit_started', {
file_extension: fileExtension,
view: state.editorView,
});
}
if (state.dirty) {
scheduleAutosave();
}
const nextOutline = extractMarkdownOutline(value);
if (!areOutlineItemsEqual(state.outline, nextOutline)) {
state.outline = nextOutline;
if (!state.outline.some((item) => item.id === state.activeHeadingId)) {
state.activeHeadingId = state.outline[0]?.id ?? null;
}
markdownTocHandle?.refresh(state.outline, state.activeHeadingId);
}
if (state.dirty && state.saveIndicator === 'saved') {
state.saveIndicator = 'idle';
}
const revert = document.getElementById('revert-markdown');
if (revert) {
revert.disabled = !isUndoAvailable(state);
}
},
onBlur: () => {
if (!state.dirty) {
return;
}
cancelAutosave();
void saveDocument();
},
});
markdownEditorHandle.focus();
}
const revertButton = document.getElementById('revert-markdown');
revertButton?.addEventListener('click', () => {
revertEditing();
});
const rawModeButton = document.getElementById('markdown-mode-raw');
rawModeButton?.addEventListener('click', () => {
setEditorView(payload, 'raw');
});
const previewModeButton = document.getElementById('markdown-mode-markdown');
previewModeButton?.addEventListener('click', () => {
setEditorView(payload, 'markdown');
});
}
const expandButton = document.getElementById('expand-fullscreen');
expandButton?.addEventListener('click', () => {
void requestFullscreen();
});
if (wrapper) {
wrapper.addEventListener('click', (event) => {
const target = event.target;
const link = target?.closest('a[href]');
if (!link || !link.closest('.markdown-doc')) {
return;
}
const href = link.getAttribute('href');
if (!href) {
return;
}
event.preventDefault();
void navigateLink(payload, href);
});
}
const tocShell = document.querySelector('.document-outline-shell');
if (tocShell && wrapper) {
markdownTocHandle = attachDocumentOutline({
shell: tocShell,
outline,
scrollContainer: wrapper,
onSelect: (headingId) => {
const selectedHeading = state.outline.find((item) => item.id === headingId);
if (selectedHeading && typeof selectedHeading.line === 'number') {
markdownEditorHandle?.revealLine(selectedHeading.line, selectedHeading.id);
state.activeHeadingId = selectedHeading.id;
}
},
}) ?? undefined;
}
window.setTimeout(() => {
applyPendingAnchor();
}, 0);
}
function getCopyText(payload) {
const state = getState(payload);
const source = state.draftContent;
return state.editorView === 'raw'
? source
: (getRenderedMarkdownCopyText(source) || source);
}
async function handleInlineExitFromFullscreen(originalPayload) {
const wasDirty = workspaceState?.saveIndicator === 'saved' || workspaceState?.dirty;
if (workspaceState) {
workspaceState.notice = null;
workspaceState.editorView = 'markdown';
}
if (wasDirty && originalPayload) {
const range = parseReadRange(originalPayload.content);
if (range?.isPartial) {
const freshPayload = await readPayload(originalPayload.filePath, range.toLine - range.fromLine + 1, range.readOffset);
if (freshPayload) {
return freshPayload;
}
}
}
return undefined;
}
return {
attachHandlers,
buildBody,
clear,
disposeHandles,
ensureCompletePayload,
getCopyText,
getState,
handleInlineExitFromFullscreen,
isUndoAvailable,
readCompletePayload,
readPayload,
readPayloadContent,
refreshFromDisk,
requestEditMode,
requestFullscreen,
saveDocument,
setEditorView,
};
}