UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

1,041 lines (1,040 loc) 45.9 kB
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, }; }