From 384ca972fa3cddbb8356e4a59277e5dc577c0173 Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Tue, 24 Mar 2026 14:46:15 +0200 Subject: [PATCH 01/22] feat(file-preview): add markdown workspace editing Let markdown previews open linked notes, follow headings, and switch into a fullscreen editing workspace so note updates can happen without leaving the preview. --- src/ui/file-preview/src/app.ts | 1000 ++++++++++++++++- .../src/components/markdown-renderer.ts | 77 +- .../src/markdown-workspace/editor.ts | 652 +++++++++++ .../src/markdown-workspace/linking.ts | 257 +++++ .../src/markdown-workspace/outline.ts | 81 ++ .../src/markdown-workspace/preview.ts | 43 + .../src/markdown-workspace/slugify.ts | 35 + .../src/markdown-workspace/toc.ts | 99 ++ .../workspace-controller.ts | 66 ++ src/ui/shared/widget-state.ts | 29 +- src/ui/styles/apps/file-preview.css | 509 +++++++++ test/test-file-handlers.js | 29 + test/test-markdown-workspace-links.js | 260 +++++ 13 files changed, 3081 insertions(+), 56 deletions(-) create mode 100644 src/ui/file-preview/src/markdown-workspace/editor.ts create mode 100644 src/ui/file-preview/src/markdown-workspace/linking.ts create mode 100644 src/ui/file-preview/src/markdown-workspace/outline.ts create mode 100644 src/ui/file-preview/src/markdown-workspace/preview.ts create mode 100644 src/ui/file-preview/src/markdown-workspace/slugify.ts create mode 100644 src/ui/file-preview/src/markdown-workspace/toc.ts create mode 100644 src/ui/file-preview/src/markdown-workspace/workspace-controller.ts create mode 100644 test/test-markdown-workspace-links.js diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index c66c1fa3..16424dbf 100644 --- a/src/ui/file-preview/src/app.ts +++ b/src/ui/file-preview/src/app.ts @@ -3,9 +3,15 @@ */ import { formatJsonIfPossible, inferLanguageFromPath, renderCodeViewer } from './components/code-viewer.js'; import { renderHtmlPreview } from './components/html-renderer.js'; -import { renderMarkdown } from './components/markdown-renderer.js'; import { escapeHtml } from './components/highlighting.js'; import { isAllowedImageMimeType, normalizeImageMimeType } from './image-preview.js'; +import { mountMarkdownEditor, renderMarkdownCopyButton, renderMarkdownEditorShell, renderMarkdownModeToggle, type MarkdownEditorHandle, type MarkdownEditorView, type MarkdownLinkHeading, type MarkdownLinkSearchItem } from './markdown-workspace/editor.js'; +import { resolveMarkdownLink } from './markdown-workspace/linking.js'; +import { extractMarkdownOutline } from './markdown-workspace/outline.js'; +import { getRenderedMarkdownCopyText, renderMarkdownWorkspacePreview } from './markdown-workspace/preview.js'; +import { slugifyMarkdownHeading } from './markdown-workspace/slugify.js'; +import { attachMarkdownToc, renderMarkdownToc, type MarkdownTocHandle } from './markdown-workspace/toc.js'; +import { getMarkdownEditAvailability, getMarkdownFullscreenAvailability, parseReadRange, shouldAutoLoadMarkdownOnEnterFullscreen, stripReadStatusLine } from './markdown-workspace/workspace-controller.js'; import type { FilePreviewStructuredContent } from '../../../types.js'; import type { HtmlPreviewMode } from './types.js'; import { createCompactRowShellController, type ToolShellController } from '../../shared/tool-shell.js'; @@ -22,7 +28,37 @@ let onRender: (() => void) | undefined; let trackUiEvent: ((event: string, params?: Record) => void) | undefined; let rpcCallTool: ((name: string, args: Record) => Promise) | undefined; let rpcUpdateContext: ((text: string) => void) | undefined; +let openExternalLink: ((url: string) => Promise) | undefined; +let requestDisplayMode: ((mode: 'inline' | 'fullscreen') => Promise) | undefined; let shellController: ToolShellController | undefined; +let currentPayload: RenderPayload | undefined; +let currentHtmlMode: HtmlPreviewMode = 'rendered'; +let currentHostContext: Record | undefined; +let rerenderCurrent: (() => void) | undefined; +let syncPayload: ((payload?: RenderPayload) => void) | undefined; +let markdownEditorHandle: MarkdownEditorHandle | undefined; +let markdownTocHandle: MarkdownTocHandle | undefined; +let localPayloadOverride: RenderPayload | undefined; + +interface MarkdownWorkspaceState { + filePath: string; + sourceContent: string; + fullDocumentContent: string; + draftContent: string; + mode: 'preview' | 'edit'; + dirty: boolean; + activeHeadingId: string | null; + pendingAnchor: string | null; + notice: string | null; + error: string | null; + saving: boolean; + loadingDocument: boolean; + editorView: MarkdownEditorView; + editorScrollTop: number; + saveIndicator: 'idle' | 'saving' | 'saved'; +} + +let markdownWorkspaceState: MarkdownWorkspaceState | undefined; function getFileExtensionForAnalytics(filePath: string): string { const normalizedPath = filePath.trim().replace(/\\/g, '/'); @@ -111,6 +147,114 @@ function getParentDirectory(filePath: string): string { return normalized.slice(0, lastSlash); } +function getAncestorDirectories(filePath: string): string[] { + const normalized = filePath.replace(/\\/g, '/'); + const parts = normalized.split('/').filter(Boolean); + const ancestors: string[] = []; + for (let index = parts.length - 1; index > 0; index -= 1) { + const prefix = normalized.startsWith('/') ? '/' : ''; + ancestors.push(`${prefix}${parts.slice(0, index).join('/')}`); + } + return ancestors; +} + +function parseDirectoryEntries(text: string): string[] { + return text.split('\n').map((line) => line.trim()).filter(Boolean); +} + +function parseFileSearchResults(text: string): string[] { + return text.split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('πŸ“ ')) + .map((line) => line.slice(3).trim()); +} + +function toPosixRelativePath(fromDirectory: string, targetPath: string): string { + const fromParts = fromDirectory.replace(/\\/g, '/').split('/').filter(Boolean); + const targetParts = targetPath.replace(/\\/g, '/').split('/').filter(Boolean); + let shared = 0; + while (shared < fromParts.length && shared < targetParts.length && fromParts[shared] === targetParts[shared]) { + shared += 1; + } + const up = new Array(Math.max(fromParts.length - shared, 0)).fill('..'); + const down = targetParts.slice(shared); + const joined = [...up, ...down].join('/'); + return joined.length > 0 ? joined : '.'; +} + +function stripMarkdownExtension(filePath: string): string { + return filePath.replace(/\.md$/i, ''); +} + +async function resolveMarkdownLinkSearchRoot(filePath: string): Promise { + const ancestors = getAncestorDirectories(filePath); + const markers = ['.git/', '.obsidian/', 'package.json', 'pnpm-workspace.yaml', 'turbo.json']; + + for (const ancestor of ancestors) { + try { + const result = await rpcCallTool?.('list_directory', { path: ancestor, depth: 1 }); + const text = extractToolText(result) ?? ''; + const entries = parseDirectoryEntries(text); + if (markers.some((marker) => entries.some((entry) => entry.includes(marker)))) { + return ancestor; + } + } catch { + // Ignore and continue up the tree. + } + } + + return getParentDirectory(filePath); +} + +async function searchMarkdownLinkTargets(filePath: string, query: string): Promise { + const trimmedQuery = query.trim(); + if (trimmedQuery.length === 0) { + return []; + } + + const rootPath = await resolveMarkdownLinkSearchRoot(filePath); + const result = await rpcCallTool?.('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 loadMarkdownLinkHeadings(currentPayloadPath: string, targetPath: string): Promise { + if (targetPath === currentPayloadPath && markdownWorkspaceState) { + return extractMarkdownOutline(markdownWorkspaceState.sourceContent).map((item) => ({ id: item.id, text: item.text })); + } + + const result = await rpcCallTool?.('read_file', { + path: targetPath, + offset: 0, + length: 5000, + }); + const text = extractToolText(result) ?? ''; + return extractMarkdownOutline(stripReadStatusLine(text)).map((item) => ({ id: item.id, text: item.text })); +} + function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } @@ -149,13 +293,27 @@ function buildOpenInFolderCommand(filePath: string): string | undefined { return `xdg-open ${shellQuote(getParentDirectory(trimmedPath))}`; } -function renderRawFallback(source: string): string { - return `
${escapeHtml(source)}
`; +function buildOpenInEditorCommand(filePath: string): string | undefined { + const trimmedPath = filePath.trim(); + if (!trimmedPath || isLikelyUrl(trimmedPath)) { + return undefined; + } + + const userAgent = navigator.userAgent.toLowerCase(); + if (userAgent.includes('win')) { + const escapedForPowerShell = trimmedPath.replace(/'/g, "''"); + const script = `Start-Process -FilePath '${escapedForPowerShell}'`; + return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodePowerShellCommand(script)}`; + } + if (userAgent.includes('mac')) { + return `open ${shellQuote(trimmedPath)}`; + } + + return `xdg-open ${shellQuote(trimmedPath)}`; } -function stripReadStatusLine(content: string): string { - // Remove the synthetic read status header shown by read_file pagination. - return content.replace(/^\[Reading [^\]]+\]\r?\n?/, ''); +function renderRawFallback(source: string): string { + return `
${escapeHtml(source)}
`; } function renderImageBody(payload: RenderPayload): { html: string; notice?: string } { @@ -187,26 +345,122 @@ function countContentLines(content: string): number { return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length; } -interface ReadRange { - fromLine: number; - toLine: number; - totalLines: number; - isPartial: boolean; +function disposeMarkdownWorkspaceHandles(): void { + markdownEditorHandle?.destroy(); + markdownEditorHandle = undefined; + markdownTocHandle?.dispose(); + markdownTocHandle = undefined; +} + +function getAvailableDisplayModes(): string[] { + const rawModes = currentHostContext?.availableDisplayModes; + if (!Array.isArray(rawModes)) { + return []; + } + + return rawModes.filter((mode): mode is string => typeof mode === 'string'); +} + +function getCurrentDisplayMode(): string | null { + return typeof currentHostContext?.displayMode === 'string' + ? currentHostContext.displayMode + : null; } -function parseReadRange(content: string): ReadRange | undefined { - // Parse "[Reading N lines from line M (total: T lines, R remaining)]" - // or "[Reading N lines from start (total: T lines, R remaining)]" - const match = content.match(/^\[Reading (\d+) lines from (?:line )?(\d+|start) \(total: (\d+) lines/); - if (!match) return undefined; - const count = parseInt(match[1], 10); - const from = match[2] === 'start' ? 1 : parseInt(match[2], 10); - const total = parseInt(match[3], 10); +function getMarkdownWorkspaceState(payload: RenderPayload): MarkdownWorkspaceState { + const cleanedContent = stripReadStatusLine(payload.content); + + if (!markdownWorkspaceState || markdownWorkspaceState.filePath !== payload.filePath || markdownWorkspaceState.sourceContent !== cleanedContent) { + const outline = extractMarkdownOutline(cleanedContent); + markdownWorkspaceState = { + filePath: payload.filePath, + sourceContent: cleanedContent, + fullDocumentContent: cleanedContent, + draftContent: cleanedContent, + mode: 'preview', + dirty: false, + activeHeadingId: outline[0]?.id ?? null, + pendingAnchor: null, + notice: null, + error: null, + saving: false, + loadingDocument: false, + editorView: 'markdown', + editorScrollTop: 0, + saveIndicator: 'idle', + }; + } + + return markdownWorkspaceState; +} + +function updateCurrentPayload(payload: RenderPayload): void { + currentPayload = payload; +} + +function getEffectiveIncomingPayload(payload: RenderPayload): RenderPayload { + if (!localPayloadOverride) { + return payload; + } + + if (localPayloadOverride.filePath !== payload.filePath) { + localPayloadOverride = undefined; + return payload; + } + + const incomingContent = stripReadStatusLine(payload.content); + const overriddenContent = stripReadStatusLine(localPayloadOverride.content); + if (incomingContent === overriddenContent) { + return payload; + } + + return localPayloadOverride; +} + +function buildMarkdownWorkspaceBody(payload: RenderPayload): { html: string; notice?: string } { + const workspaceState = getMarkdownWorkspaceState(payload); + const outline = extractMarkdownOutline(workspaceState.sourceContent); + const isFullscreen = getCurrentDisplayMode() === 'fullscreen'; + const tocHtml = isFullscreen ? renderMarkdownToc(outline, workspaceState.activeHeadingId) : ''; + if (!workspaceState.activeHeadingId && outline.length > 0) { + workspaceState.activeHeadingId = outline[0].id; + } + + const messages = [workspaceState.error, workspaceState.notice]; + + const notice = messages.find((value): value is string => typeof value === 'string' && value.trim().length > 0); + + if (workspaceState.mode === 'edit') { + const lineCount = countContentLines(workspaceState.draftContent); + const wordCount = workspaceState.draftContent.trim().length > 0 + ? workspaceState.draftContent.trim().split(/\s+/).length + : 0; + return { + notice, + html: ` +
+
+ ${tocHtml} +
+ ${renderMarkdownEditorShell({ + content: workspaceState.draftContent, + view: workspaceState.editorView, + })} +
+
+
+ `, + }; + } + return { - fromLine: from, - toLine: from + count - 1, - totalLines: total, - isPartial: count < total + notice, + html: `
${renderMarkdownWorkspacePreview({ + content: workspaceState.sourceContent, + outline, + activeHeadingId: workspaceState.activeHeadingId, + showToc: isFullscreen, + })}
`, }; } @@ -238,9 +492,7 @@ function renderBody(payload: RenderPayload, htmlMode: HtmlPreviewMode, startLine } try { - return { - html: `
${renderMarkdown(cleanedContent)}
` - }; + return buildMarkdownWorkspaceBody(payload); } catch { return { notice: 'Markdown renderer failed. Showing raw source instead.', @@ -250,11 +502,6 @@ function renderBody(payload: RenderPayload, htmlMode: HtmlPreviewMode, startLine } function attachCopyHandler(payload: RenderPayload): void { - const copyButton = document.getElementById('copy-source'); - if (!copyButton) { - return; - } - const fallbackCopy = (text: string): boolean => { const textArea = document.createElement('textarea'); textArea.value = text; @@ -268,15 +515,28 @@ function attachCopyHandler(payload: RenderPayload): void { return success; }; - const setButtonState = (label: string, revertMs?: number): void => { - copyButton.setAttribute('title', label); - copyButton.setAttribute('aria-label', label); - copyButton.textContent = label; + const setButtonState = (button: HTMLElement, label: string, fallbackLabel: string, revertMs?: number): void => { + button.setAttribute('title', label); + button.setAttribute('aria-label', label); + button.textContent = label; + if (revertMs) { + setTimeout(() => { + button.textContent = fallbackLabel; + button.setAttribute('title', fallbackLabel); + button.setAttribute('aria-label', fallbackLabel); + }, revertMs); + } + }; + + const setIconButtonState = (button: HTMLElement, label: string, fallbackLabel: string, revertMs?: number): void => { + button.setAttribute('title', label); + button.setAttribute('aria-label', label); + button.dataset.status = label; if (revertMs) { setTimeout(() => { - copyButton.textContent = 'Copy'; - copyButton.setAttribute('title', 'Copy source'); - copyButton.setAttribute('aria-label', 'Copy source'); + button.setAttribute('title', fallbackLabel); + button.setAttribute('aria-label', fallbackLabel); + delete button.dataset.status; }, revertMs); } }; @@ -293,7 +553,8 @@ function attachCopyHandler(payload: RenderPayload): void { } }; - copyButton.addEventListener('click', async () => { + const copyButton = document.getElementById('copy-source'); + copyButton?.addEventListener('click', async () => { trackUiEvent?.('copy_clicked', { file_type: payload.fileType, file_extension: getFileExtensionForAnalytics(payload.filePath) @@ -302,10 +563,45 @@ function attachCopyHandler(payload: RenderPayload): void { const cleanedContent = stripReadStatusLine(payload.content); const copied = await copyTextData(cleanedContent); - setButtonState(copied ? 'Copied!' : 'Copy failed', 1500); + setButtonState(copyButton, copied ? 'Copied!' : 'Copy failed', 'Copy', 1500); + }); + + const activeCopyButton = document.getElementById('copy-active-markdown'); + activeCopyButton?.addEventListener('click', async () => { + const workspaceState = payload.fileType === 'markdown' ? getMarkdownWorkspaceState(payload) : undefined; + if (!workspaceState) { + return; + } + + const source = workspaceState.mode === 'edit' + ? workspaceState.draftContent + : stripReadStatusLine(payload.content); + const textToCopy = workspaceState.editorView === 'raw' + ? source + : (getRenderedMarkdownCopyText(source) || source); + const copied = await copyTextData(textToCopy); + setIconButtonState(activeCopyButton, copied ? 'Copied!' : 'Copy failed', 'Copy', 1500); }); } +function setMarkdownEditorView(payload: RenderPayload, view: MarkdownEditorView): void { + const workspaceState = getMarkdownWorkspaceState(payload); + const wrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; + workspaceState.editorScrollTop = wrapper?.scrollTop ?? 0; + workspaceState.editorView = view; + workspaceState.notice = null; + workspaceState.error = null; + rerenderCurrent?.(); + if (typeof workspaceState.editorScrollTop === 'number') { + window.requestAnimationFrame(() => { + const nextWrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; + if (nextWrapper) { + nextWrapper.scrollTop = workspaceState.editorScrollTop; + } + }); + } +} + function attachHtmlToggleHandler(container: HTMLElement, payload: RenderPayload, htmlMode: HtmlPreviewMode): void { const toggleButton = document.getElementById('toggle-html-mode'); if (!toggleButton || payload.fileType !== 'html') { @@ -350,6 +646,35 @@ function attachOpenInFolderHandler(payload: RenderPayload): void { }); } +function attachOpenInEditorHandler(payload: RenderPayload): void { + const openButton = document.getElementById('open-in-editor') as HTMLButtonElement | null; + if (!openButton) { + return; + } + + const command = buildOpenInEditorCommand(payload.filePath); + if (!command) { + openButton.disabled = true; + return; + } + + openButton.addEventListener('click', async () => { + trackUiEvent?.('open_in_editor', { + file_type: payload.fileType, + file_extension: getFileExtensionForAnalytics(payload.filePath) + }); + + try { + await rpcCallTool?.('start_process', { + command, + timeout_ms: 12000 + }); + } catch { + // Keep UI stable if opening editor fails. + } + }); +} + function attachLoadAllHandler( container: HTMLElement, payload: RenderPayload, @@ -423,6 +748,440 @@ function attachLoadAllHandler( afterBtn?.addEventListener('click', () => void loadLines(afterBtn, 'after')); } +function findMarkdownHeading(anchor: string): HTMLElement | null { + const trimmedAnchor = anchor.trim(); + if (!trimmedAnchor) { + return null; + } + + return document.getElementById(trimmedAnchor) ?? document.getElementById(slugifyMarkdownHeading(trimmedAnchor)); +} + +function scrollMarkdownHeadingIntoView(anchor: string): boolean { + const heading = findMarkdownHeading(anchor); + if (!heading) { + return false; + } + + const scrollParents: HTMLElement[] = []; + let current: HTMLElement | null = 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 as HTMLElement | null; + 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 (markdownWorkspaceState) { + markdownWorkspaceState.activeHeadingId = heading.id || slugifyMarkdownHeading(anchor); + } + return true; +} + +function applyPendingMarkdownAnchor(): void { + const workspaceState = markdownWorkspaceState; + const pendingAnchor = workspaceState?.pendingAnchor; + if (!workspaceState || !pendingAnchor) { + return; + } + + workspaceState.pendingAnchor = null; + if (!scrollMarkdownHeadingIntoView(pendingAnchor)) { + workspaceState.error = `Heading not found: ${pendingAnchor}`; + rerenderCurrent?.(); + } +} + +async function readMarkdownPayload(filePath: string, length?: number): Promise { + const result = await rpcCallTool?.('read_file', { + path: filePath, + ...(typeof length === 'number' ? { offset: 0, length } : {}), + }); + return extractRenderPayload(result) ?? null; +} + +async function loadFullMarkdownDocument(payload: RenderPayload, options: { keepEditMode?: boolean } = {}): Promise { + const workspaceState = getMarkdownWorkspaceState(payload); + const range = parseReadRange(payload.content); + if (!range?.isPartial) { + if (options.keepEditMode) { + workspaceState.mode = 'edit'; + workspaceState.editorView = 'markdown'; + workspaceState.notice = null; + workspaceState.error = null; + workspaceState.draftContent = workspaceState.sourceContent; + workspaceState.dirty = false; + rerenderCurrent?.(); + } + return; + } + + workspaceState.loadingDocument = true; + workspaceState.notice = 'Loading full document…'; + workspaceState.error = null; + rerenderCurrent?.(); + + try { + const nextPayload = await readMarkdownPayload(payload.filePath, range.totalLines); + if (!nextPayload) { + workspaceState.error = 'Failed to load the full document.'; + workspaceState.notice = null; + workspaceState.loadingDocument = false; + rerenderCurrent?.(); + return; + } + + syncPayload?.(nextPayload); + const nextState = getMarkdownWorkspaceState(nextPayload); + nextState.loadingDocument = false; + nextState.notice = null; + nextState.error = null; + if (options.keepEditMode) { + nextState.mode = 'edit'; + nextState.editorView = 'markdown'; + nextState.draftContent = nextState.sourceContent; + nextState.dirty = false; + rerenderCurrent?.(); + } + } catch { + workspaceState.loadingDocument = false; + workspaceState.notice = null; + workspaceState.error = 'Failed to load the full document.'; + rerenderCurrent?.(); + } +} + +async function navigateMarkdownLink(payload: RenderPayload, href: string): Promise { + const workspaceState = getMarkdownWorkspaceState(payload); + if (workspaceState.mode === 'edit' && workspaceState.dirty) { + const shouldDiscard = window.confirm('Discard unsaved changes and follow this link?'); + if (!shouldDiscard) { + return; + } + } + + const resolvedLink = resolveMarkdownLink(payload.filePath, href); + workspaceState.notice = null; + workspaceState.error = null; + + if (resolvedLink.kind === 'external' && resolvedLink.url) { + const opened = await openExternalLink?.(resolvedLink.url); + if (!opened && markdownWorkspaceState) { + markdownWorkspaceState.error = 'The host blocked that external link.'; + rerenderCurrent?.(); + } + return; + } + + if (resolvedLink.kind === 'anchor' && resolvedLink.anchor) { + if (!scrollMarkdownHeadingIntoView(resolvedLink.anchor) && markdownWorkspaceState) { + markdownWorkspaceState.error = `Heading not found: ${resolvedLink.anchor}`; + rerenderCurrent?.(); + } + return; + } + + if (resolvedLink.kind === 'file' && resolvedLink.targetPath) { + const nextPayload = await readMarkdownPayload(resolvedLink.targetPath); + if (!nextPayload) { + if (markdownWorkspaceState) { + markdownWorkspaceState.error = `Unable to open ${resolvedLink.targetPath}.`; + rerenderCurrent?.(); + } + return; + } + + syncPayload?.(nextPayload); + const nextState = getMarkdownWorkspaceState(nextPayload); + nextState.pendingAnchor = resolvedLink.anchor ?? null; + nextState.error = null; + nextState.notice = null; + rerenderCurrent?.(); + } +} + +async function requestMarkdownEditMode(payload: RenderPayload): Promise { + const workspaceState = getMarkdownWorkspaceState(payload); + const fullscreenAvailability = getMarkdownFullscreenAvailability({ + availableDisplayModes: getAvailableDisplayModes(), + }); + + if (!fullscreenAvailability.canFullscreen) { + workspaceState.error = fullscreenAvailability.reason; + workspaceState.notice = null; + rerenderCurrent?.(); + return; + } + + workspaceState.error = null; + workspaceState.notice = null; + const nextMode = await requestDisplayMode?.('fullscreen'); + if (nextMode !== 'fullscreen') { + workspaceState.error = 'Fullscreen mode is unavailable in this host.'; + rerenderCurrent?.(); + return; + } + + if (shouldAutoLoadMarkdownOnEnterFullscreen(payload.content)) { + await loadFullMarkdownDocument(payload, { keepEditMode: true }); + return; + } + + const editAvailability = getMarkdownEditAvailability({ + content: payload.content, + availableDisplayModes: getAvailableDisplayModes(), + }); + if (!editAvailability.canEdit) { + workspaceState.error = editAvailability.reason; + rerenderCurrent?.(); + return; + } + + workspaceState.mode = 'edit'; + workspaceState.draftContent = workspaceState.fullDocumentContent; + workspaceState.dirty = false; + workspaceState.editorView = 'markdown'; + isExpanded = true; + rerenderCurrent?.(); +} + +function revertMarkdownEditing(payload: RenderPayload): void { + const workspaceState = getMarkdownWorkspaceState(payload); + workspaceState.draftContent = workspaceState.fullDocumentContent; + workspaceState.dirty = false; + workspaceState.error = null; + workspaceState.notice = 'Reverted to the last loaded version.'; + rerenderCurrent?.(); +} + +function cancelMarkdownEditing(payload: RenderPayload): void { + const workspaceState = getMarkdownWorkspaceState(payload); + if (workspaceState.dirty) { + const shouldDiscard = window.confirm('Discard unsaved changes?'); + if (!shouldDiscard) { + return; + } + } + + workspaceState.mode = 'preview'; + workspaceState.dirty = false; + workspaceState.draftContent = workspaceState.fullDocumentContent; + workspaceState.notice = null; + workspaceState.error = null; + rerenderCurrent?.(); +} + +function isSuccessfulEditResult(result: unknown): boolean { + const message = extractToolText(result); + return typeof message === 'string' && message.startsWith('Successfully applied'); +} + +async function saveMarkdownDocument(payload: RenderPayload): Promise { + const workspaceState = getMarkdownWorkspaceState(payload); + if (workspaceState.saving || !workspaceState.dirty) { + return; + } + workspaceState.saving = true; + workspaceState.saveIndicator = 'saving'; + workspaceState.error = null; + workspaceState.notice = null; + rerenderCurrent?.(); + + try { + const result = await rpcCallTool?.('edit_block', { + file_path: payload.filePath, + old_string: workspaceState.fullDocumentContent, + new_string: workspaceState.draftContent, + expected_replacements: 1, + }); + + if (!isSuccessfulEditResult(result)) { + workspaceState.saving = false; + workspaceState.saveIndicator = 'idle'; + workspaceState.error = 'File changed on disk. Reload before saving again.'; + rerenderCurrent?.(); + return; + } + + let nextPayload: RenderPayload = { + ...payload, + content: workspaceState.draftContent, + }; + + try { + const refreshedResult = await rpcCallTool?.('read_file', { + path: payload.filePath, + offset: 0, + length: 5000, + }); + const refreshedPayload = extractRenderPayload(refreshedResult); + if (refreshedPayload) { + nextPayload = refreshedPayload; + } + } catch { + // Fall back to local draft content if refresh fails. + } + + syncPayload?.(nextPayload); + localPayloadOverride = nextPayload; + const nextState = getMarkdownWorkspaceState(nextPayload); + nextState.mode = 'edit'; + nextState.draftContent = nextState.sourceContent; + nextState.fullDocumentContent = nextState.sourceContent; + nextState.dirty = false; + nextState.saving = false; + nextState.saveIndicator = 'saved'; + nextState.notice = null; + nextState.error = null; + rerenderCurrent?.(); + window.setTimeout(() => { + if (markdownWorkspaceState?.filePath === nextState.filePath && !markdownWorkspaceState.dirty && !markdownWorkspaceState.saving) { + markdownWorkspaceState.saveIndicator = 'idle'; + rerenderCurrent?.(); + } + }, 1800); + } catch { + workspaceState.saving = false; + workspaceState.saveIndicator = 'idle'; + workspaceState.error = 'Saving failed. Reload the file and try again.'; + rerenderCurrent?.(); + } +} + +function maybeAutosaveMarkdownDocument(payload: RenderPayload): void { + const workspaceState = getMarkdownWorkspaceState(payload); + if (!workspaceState.dirty || workspaceState.saving) { + return; + } + + void saveMarkdownDocument(payload); +} + +function attachMarkdownWorkspaceHandlers(payload: RenderPayload): void { + if (payload.fileType !== 'markdown') { + return; + } + + const workspaceState = getMarkdownWorkspaceState(payload); + const wrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; + const markdownDoc = document.querySelector('.markdown-doc') as HTMLElement | null; + const outline = extractMarkdownOutline(workspaceState.sourceContent); + + const editButton = document.getElementById('edit-markdown') as HTMLButtonElement | null; + editButton?.addEventListener('click', () => { + void requestMarkdownEditMode(payload); + }); + + if (workspaceState.mode === 'edit') { + const editorRoot = document.getElementById('markdown-editor-root'); + if (editorRoot) { + markdownEditorHandle = mountMarkdownEditor({ + target: editorRoot, + value: workspaceState.draftContent, + view: workspaceState.editorView, + initialScrollTop: workspaceState.editorScrollTop, + currentFilePath: payload.filePath, + searchLinks: (query) => searchMarkdownLinkTargets(payload.filePath, query), + loadHeadings: (targetPath) => loadMarkdownLinkHeadings(payload.filePath, targetPath), + onChange: (value) => { + workspaceState.draftContent = value; + workspaceState.dirty = value !== workspaceState.fullDocumentContent; + if (workspaceState.dirty && workspaceState.saveIndicator === 'saved') { + workspaceState.saveIndicator = 'idle'; + } + }, + onBlur: () => { + maybeAutosaveMarkdownDocument(payload); + }, + }); + markdownEditorHandle.focus(); + } + + const revertButton = document.getElementById('revert-markdown') as HTMLButtonElement | null; + revertButton?.addEventListener('click', () => { + revertMarkdownEditing(payload); + }); + + const rawModeButton = document.getElementById('markdown-mode-raw') as HTMLButtonElement | null; + rawModeButton?.addEventListener('click', () => { + setMarkdownEditorView(payload, 'raw'); + }); + + const previewModeButton = document.getElementById('markdown-mode-markdown') as HTMLButtonElement | null; + previewModeButton?.addEventListener('click', () => { + setMarkdownEditorView(payload, 'markdown'); + }); + } + + if (markdownDoc) { + markdownDoc.addEventListener('click', (event) => { + const target = event.target as HTMLElement | null; + const link = target?.closest('a[href]'); + const href = link?.getAttribute('href'); + if (!href) { + return; + } + + if (workspaceState.mode === 'edit' && workspaceState.editorView === 'markdown') { + const mouseEvent = event as MouseEvent; + if (!(mouseEvent.metaKey || mouseEvent.ctrlKey)) { + return; + } + } + + event.preventDefault(); + void navigateMarkdownLink(payload, href); + }); + } + + const tocShell = document.querySelector('.markdown-toc-shell') as HTMLElement | null; + if (tocShell && wrapper) { + markdownTocHandle = attachMarkdownToc({ + shell: tocShell, + outline, + scrollContainer: wrapper, + onSelect: (headingId) => { + const selectedHeading = outline.find((item) => item.id === headingId); + if (workspaceState.mode === 'edit') { + if (selectedHeading) { + markdownEditorHandle?.revealLine(selectedHeading.line, selectedHeading.id); + workspaceState.activeHeadingId = selectedHeading.id; + } + return; + } + + scrollMarkdownHeadingIntoView(headingId); + }, + }) ?? undefined; + } + + window.setTimeout(() => { + applyPendingMarkdownAnchor(); + }, 0); +} + /** * Tracks native text selection and pushes it to the host via ui/update-model-context. * @@ -441,6 +1200,14 @@ function attachLoadAllHandler( let selectionAbortController: AbortController | null = null; function attachTextSelectionHandler(payload: RenderPayload): void { + if (payload.fileType === 'markdown' && getMarkdownWorkspaceState(payload).mode === 'edit') { + if (selectionAbortController) { + selectionAbortController.abort(); + selectionAbortController = null; + } + return; + } + const contentWrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; if (!contentWrapper) return; @@ -577,15 +1344,32 @@ export function renderApp( expandedState = false ): void { isExpanded = expandedState; + currentHtmlMode = htmlMode; shellController?.dispose(); shellController = undefined; + disposeMarkdownWorkspaceHandles(); if (!payload) { + currentPayload = undefined; renderStatusState(container, 'No preview available for this response.'); onRender?.(); return; } + updateCurrentPayload(payload); + + if (payload.fileType !== 'markdown') { + markdownWorkspaceState = undefined; + } + + const markdownWorkspace = payload.fileType === 'markdown' ? getMarkdownWorkspaceState(payload) : undefined; + const markdownEditAvailability = payload.fileType === 'markdown' + ? getMarkdownEditAvailability({ + content: payload.content, + availableDisplayModes: getAvailableDisplayModes(), + }) + : undefined; + const canCopy = payload.fileType !== 'unsupported' && payload.fileType !== 'image'; const canOpenInFolder = !isLikelyUrl(payload.filePath); const fileExtension = getFileExtensionForAnalytics(payload.filePath); @@ -611,16 +1395,53 @@ export function renderApp( const compactLabel = range?.isPartial ? `View lines ${range.fromLine}–${range.toLine}` : 'View file'; - const footerLabel = range?.isPartial + let footerLabel = range?.isPartial ? `${escapeHtml(fileTypeLabel)} β€’ LINES ${range.fromLine}–${range.toLine} OF ${range.totalLines}` : `${escapeHtml(fileTypeLabel)} β€’ ${lineCount} LINE${lineCount !== 1 ? 'S' : ''}`; + const markdownWordCount = payload.fileType === 'markdown' + ? (stripReadStatusLine(markdownWorkspace?.mode === 'edit' ? markdownWorkspace.draftContent : payload.content).trim().split(/\s+/).filter(Boolean).length) + : 0; + + if (markdownWorkspace?.mode === 'edit') { + if (markdownWorkspace.saving) { + footerLabel = `${escapeHtml(fileTypeLabel)} β€’ EDIT MODE β€’ ${lineCount} LINES β€’ ${markdownWordCount} WORDS β€’ SAVING`; + } else if (markdownWorkspace.dirty) { + footerLabel = `${escapeHtml(fileTypeLabel)} β€’ EDIT MODE β€’ ${lineCount} LINES β€’ ${markdownWordCount} WORDS β€’ UNSAVED`; + } else { + footerLabel = `${escapeHtml(fileTypeLabel)} β€’ EDIT MODE β€’ ${lineCount} LINES β€’ ${markdownWordCount} WORDS`; + } + } const htmlToggle = payload.fileType === 'html' ? `` : ''; + let markdownActions = ''; + if (payload.fileType === 'markdown' && markdownWorkspace) { + const saveStatusLabel = markdownWorkspace.saving + ? 'Saving…' + : markdownWorkspace.saveIndicator === 'saved' + ? 'Saved' + : markdownWorkspace.dirty + ? 'Unsaved' + : ''; + if (markdownWorkspace.mode === 'edit') { + markdownActions = ` + ${saveStatusLabel ? `${saveStatusLabel}` : ''} + ${renderMarkdownModeToggle(markdownWorkspace.editorView)} + ${renderMarkdownCopyButton()} + + `; + } else { + if (getMarkdownFullscreenAvailability({ availableDisplayModes: getAvailableDisplayModes() }).canFullscreen) { + markdownActions += ''; + } + } + } + const copyIcon = ``; const folderIcon = ``; + const editorIcon = ``; // Content-area banners for missing lines const hasMissingBefore = range?.isPartial && range.fromLine > 1; @@ -632,16 +1453,20 @@ export function renderApp( ? `` : ''; + const effectiveExpanded = isExpanded || getCurrentDisplayMode() === 'fullscreen' || markdownWorkspace?.mode === 'edit'; + container.innerHTML = ` -
- ${renderCompactRow({ id: 'compact-toggle', label: compactLabel, filename: payload.fileName, variant: 'ready', expandable: true, expanded: isExpanded, interactive: true })} +
+ ${markdownWorkspace?.mode === 'edit' || getCurrentDisplayMode() === 'fullscreen' ? '' : renderCompactRow({ id: 'compact-toggle', label: compactLabel, filename: payload.fileName, variant: 'ready', expandable: true, expanded: isExpanded, interactive: true })}
${breadcrumb} - ${htmlToggle} - ${canOpenInFolder ? `` : ''} - ${canCopy && supportsPreview ? `` : ''} + ${markdownActions} + ${htmlToggle} + ${canOpenInFolder && payload.fileType === 'markdown' && markdownWorkspace?.mode === 'edit' ? `` : ''} + ${canOpenInFolder && !(payload.fileType === 'markdown' && markdownWorkspace?.mode === 'edit') ? `` : ''} + ${canCopy && supportsPreview && payload.fileType !== 'markdown' ? `` : ''}
${notice} @@ -660,7 +1485,9 @@ export function renderApp( attachCopyHandler(payload); attachHtmlToggleHandler(container, payload, htmlMode); attachOpenInFolderHandler(payload); + attachOpenInEditorHandler(payload); attachLoadAllHandler(container, payload, htmlMode); + attachMarkdownWorkspaceHandlers(payload); attachTextSelectionHandler(payload); const compactRow = document.getElementById('compact-toggle') as HTMLElement | null; @@ -668,7 +1495,7 @@ export function renderApp( shellController = createCompactRowShellController({ shell: document.getElementById('tool-shell'), compactRow, - initialExpanded: isExpanded, + initialExpanded: effectiveExpanded, onToggle: (expanded) => { isExpanded = expanded; trackUiEvent?.(expanded ? 'expand' : 'collapse', { @@ -729,6 +1556,26 @@ export function bootstrapApp(): void { } renderApp(container, payload, 'rendered', isExpanded); }; + const syncFromPersistedWidgetState = (): void => { + const persistedPayload = widgetState.read(); + if (!persistedPayload) { + return; + } + + if ( + currentPayload + && currentPayload.filePath === persistedPayload.filePath + && stripReadStatusLine(currentPayload.content) === stripReadStatusLine(persistedPayload.content) + ) { + return; + } + + renderAndSync(persistedPayload); + }; + syncPayload = renderAndSync; + rerenderCurrent = () => { + renderApp(container, currentPayload, currentHtmlMode, isExpanded); + }; let initialStateResolved = false; const resolveInitialState = (payload?: RenderPayload, message?: string): void => { @@ -738,6 +1585,9 @@ export function bootstrapApp(): void { initialStateResolved = true; if (payload) { renderAndSync(payload); + if (payload.fileType === 'markdown' && getCurrentDisplayMode() === 'fullscreen') { + void requestMarkdownEditMode(payload); + } return; } renderStatusState(container, message ?? 'No preview available for this response.'); @@ -762,6 +1612,16 @@ export function bootstrapApp(): void { }); }; + openExternalLink = async (url: string): Promise => { + const result = await app.openLink({ url }); + return result.isError !== true; + }; + + requestDisplayMode = async (mode: 'inline' | 'fullscreen'): Promise => { + const result = await app.requestDisplayMode({ mode }); + return typeof result.mode === 'string' ? result.mode : null; + }; + trackUiEvent = createUiEventTracker( (name, args) => app.callServerTool({ name, arguments: args }), { @@ -773,6 +1633,7 @@ export function bootstrapApp(): void { // Register ALL handlers BEFORE connect app.onteardown = async () => { shellController?.dispose(); + disposeMarkdownWorkspaceHandles(); return {}; }; @@ -787,9 +1648,10 @@ export function bootstrapApp(): void { const message = extractToolText(result as unknown as Record); if (!initialStateResolved) { if (payload) { + const effectivePayload = getEffectiveIncomingPayload(payload); renderLoadingState(container); onRender?.(); - window.setTimeout(() => resolveInitialState(payload), 120); + window.setTimeout(() => resolveInitialState(effectivePayload), 120); return; } if (message) { @@ -798,7 +1660,8 @@ export function bootstrapApp(): void { return; } if (payload) { - renderAndSync(payload); + const effectivePayload = getEffectiveIncomingPayload(payload); + renderAndSync(effectivePayload); } else if (message) { renderStatusState(container, message); onRender?.(); @@ -813,8 +1676,29 @@ export function bootstrapApp(): void { void connectWithSharedHostContext({ app, chrome, - onContextApplied: syncChromeState, + onContextApplied: () => { + const previousDisplayMode = getCurrentDisplayMode(); + syncChromeState(); + currentHostContext = app.getHostContext() as Record | undefined; + const nextDisplayMode = getCurrentDisplayMode(); + if ( + previousDisplayMode === 'fullscreen' + && nextDisplayMode === 'inline' + && currentPayload?.fileType === 'markdown' + ) { + isExpanded = true; + chrome.expanded = true; + if (markdownWorkspaceState) { + markdownWorkspaceState.mode = 'preview'; + markdownWorkspaceState.notice = null; + } + } + if (initialStateResolved) { + rerenderCurrent?.(); + } + }, onConnected: () => { + currentHostContext = app.getHostContext() as Record | undefined; // Try to restore from persisted widget state (survives refresh on some hosts) const cachedPayload = widgetState.read(); if (cachedPayload) { @@ -836,7 +1720,23 @@ export function bootstrapApp(): void { onRender?.(); }); + const handleVisibilitySync = (): void => { + if (document.visibilityState === 'visible') { + syncFromPersistedWidgetState(); + } + }; + + const handleFocusSync = (): void => { + syncFromPersistedWidgetState(); + }; + + document.addEventListener('visibilitychange', handleVisibilitySync); + window.addEventListener('focus', handleFocusSync); + window.addEventListener('beforeunload', () => { shellController?.dispose(); + disposeMarkdownWorkspaceHandles(); + document.removeEventListener('visibilitychange', handleVisibilitySync); + window.removeEventListener('focus', handleFocusSync); }, { once: true }); } diff --git a/src/ui/file-preview/src/components/markdown-renderer.ts b/src/ui/file-preview/src/components/markdown-renderer.ts index 0eaa648c..35723415 100644 --- a/src/ui/file-preview/src/components/markdown-renderer.ts +++ b/src/ui/file-preview/src/components/markdown-renderer.ts @@ -5,9 +5,14 @@ // @ts-expect-error markdown-it does not provide local TypeScript typings in this setup. import MarkdownIt from 'markdown-it'; import { highlightSource } from './highlighting.js'; +import { rewriteWikiLinks } from '../markdown-workspace/linking.js'; +import { createSlugTracker } from '../markdown-workspace/slugify.js'; interface MarkdownRenderer { - render: (source: string) => string; + render: (source: string, env?: Record) => string; + renderer: { + rules: Record string>; + }; } type MarkdownItConstructor = new (options?: { @@ -19,6 +24,24 @@ type MarkdownItConstructor = new (options?: { const MarkdownItCtor = MarkdownIt as unknown as MarkdownItConstructor; +function extractInlineText(token: Record | undefined): string { + if (!token) { + return ''; + } + + const children = Array.isArray(token.children) ? token.children : []; + if (children.length === 0) { + return typeof token.content === 'string' ? token.content : ''; + } + + return children.map((child) => { + if (typeof child.content === 'string') { + return child.content; + } + return ''; + }).join(''); +} + const markdown = new MarkdownItCtor({ html: false, linkify: true, @@ -30,6 +53,56 @@ const markdown = new MarkdownItCtor({ } }); +const renderHeadingOpen = markdown.renderer.rules.heading_open; +markdown.renderer.rules.heading_open = (...args: unknown[]): string => { + const tokens = args[0] as Array>; + const index = args[1] as number; + const options = args[2] as unknown; + const environment = (args[3] as Record | undefined) ?? {}; + const self = args[4] as { renderToken: (tokens: Array>, index: number, options: unknown) => string }; + const nextSlug = typeof environment.nextSlug === 'function' + ? environment.nextSlug as (text: string) => string + : createSlugTracker(); + environment.nextSlug = nextSlug; + + const inlineToken = tokens[index + 1]; + const headingText = extractInlineText(inlineToken).trim(); + const headingId = nextSlug(headingText || 'section'); + const token = tokens[index] as { attrSet?: (name: string, value: string) => void }; + token.attrSet?.('id', headingId); + token.attrSet?.('data-heading-id', headingId); + + if (typeof renderHeadingOpen === 'function') { + return renderHeadingOpen(...args); + } + + return self.renderToken(tokens, index, options); +}; + +const renderLinkOpen = markdown.renderer.rules.link_open; +markdown.renderer.rules.link_open = (...args: unknown[]): string => { + const tokens = args[0] as Array>; + const index = args[1] as number; + const options = args[2] as unknown; + const self = args[4] as { renderToken: (tokens: Array>, index: number, options: unknown) => string }; + const token = tokens[index] as { attrSet?: (name: string, value: string) => void; attrGet?: (name: string) => string | null; attrs?: Array<[string, string]> }; + token.attrSet?.('data-markdown-link', 'true'); + const title = token.attrGet?.('title'); + if (title?.startsWith('mcp-wiki:')) { + const rawWikiLink = decodeURIComponent(title.slice('mcp-wiki:'.length)); + token.attrSet?.('data-wiki-link', rawWikiLink); + if (Array.isArray(token.attrs)) { + token.attrs = token.attrs.filter(([name]) => name !== 'title'); + } + } + + if (typeof renderLinkOpen === 'function') { + return renderLinkOpen(...args); + } + + return self.renderToken(tokens, index, options); +}; + export function renderMarkdown(content: string): string { - return markdown.render(content); + return markdown.render(rewriteWikiLinks(content), { nextSlug: createSlugTracker() }); } diff --git a/src/ui/file-preview/src/markdown-workspace/editor.ts b/src/ui/file-preview/src/markdown-workspace/editor.ts new file mode 100644 index 00000000..317f3c1a --- /dev/null +++ b/src/ui/file-preview/src/markdown-workspace/editor.ts @@ -0,0 +1,652 @@ +import { renderMarkdown } from '../components/markdown-renderer.js'; + +export type MarkdownEditorView = 'raw' | 'markdown'; + +export interface MarkdownLinkSearchItem { + path: string; + title: string; + wikiPath: string; + relativePath: string; +} + +export interface MarkdownLinkHeading { + id: string; + text: string; +} + +export interface MarkdownEditorHandle { + destroy: () => void; + focus: () => void; + getValue: () => string; + setValue: (value: string) => void; + revealLine: (lineNumber: number, headingId?: string) => void; + setScrollTop: (scrollTop: number) => void; +} + +function renderFormattingButtons(): string { + return ` + + + + + + + + + + + `; +} + +function renderModeToggleIcon(view: MarkdownEditorView): string { + if (view === 'raw') { + return ''; + } + + return ''; +} + +export function renderMarkdownCopyButton(): string { + return ``; +} + +export function renderMarkdownModeToggle(view: MarkdownEditorView): string { + return ` +
+ + + +
+ `; +} + +export function renderMarkdownEditorShell(options: { + content: string; + view: MarkdownEditorView; +}): string { + const isMarkdownView = options.view === 'markdown'; + + return ` +
+
+ ${isMarkdownView ? `` : ''} +
+
+
+ `; +} + +function collapseWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +function serializeNode(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent ?? ''; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return ''; + } + + const element = node as HTMLElement; + const tag = element.tagName.toLowerCase(); + const children = Array.from(element.childNodes).map(serializeNode).join(''); + + switch (tag) { + case 'strong': + case 'b': + return `**${children}**`; + case 'em': + case 'i': + return `*${children}*`; + case 'u': + return `${children}`; + case 's': + case 'strike': + return `~~${children}~~`; + case 'code': + return `\`${children}\``; + case 'a': { + const wikiLink = element.getAttribute('data-wiki-link'); + if (wikiLink) { + return wikiLink; + } + const href = element.getAttribute('href') ?? 'https://example.com'; + return `[${children || href}](${href})`; + } + case 'span': { + const color = element.style.color; + const fontSize = element.style.fontSize; + if (color || fontSize) { + const styleParts = [color ? `color:${color}` : '', fontSize ? `font-size:${fontSize}` : ''].filter(Boolean).join(';'); + return `${children}`; + } + return children; + } + case 'font': { + const color = element.getAttribute('color'); + const size = element.getAttribute('size'); + const styleParts = [color ? `color:${color}` : '', size ? `font-size:${size}` : ''].filter(Boolean).join(';'); + return styleParts ? `${children}` : children; + } + case 'br': + return '\n'; + case 'p': + return `${children.trim()}\n\n`; + case 'h1': + return `# ${collapseWhitespace(children)}\n\n`; + case 'h2': + return `## ${collapseWhitespace(children)}\n\n`; + case 'h3': + return `### ${collapseWhitespace(children)}\n\n`; + case 'h4': + return `#### ${collapseWhitespace(children)}\n\n`; + case 'h5': + return `##### ${collapseWhitespace(children)}\n\n`; + case 'h6': + return `###### ${collapseWhitespace(children)}\n\n`; + case 'blockquote': + return `${children.trim().split('\n').map((line) => `> ${line}`).join('\n')}\n\n`; + case 'ul': + return `${Array.from(element.children).map((child) => `- ${collapseWhitespace(serializeNode(child))}`).join('\n')}\n\n`; + case 'ol': + return `${Array.from(element.children).map((child, index) => `${index + 1}. ${collapseWhitespace(serializeNode(child))}`).join('\n')}\n\n`; + case 'li': + return collapseWhitespace(children); + case 'div': + return `${children}${children.endsWith('\n') ? '' : '\n'}`; + default: + return children; + } +} + +function htmlToMarkdown(html: string): string { + const parser = new DOMParser(); + const documentNode = parser.parseFromString(`
${html}
`, 'text/html'); + const root = documentNode.body.firstElementChild; + if (!root) { + return ''; + } + + return Array.from(root.childNodes) + .map(serializeNode) + .join('') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function applyRawTab(textarea: HTMLTextAreaElement): void { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const nextValue = `${textarea.value.slice(0, start)}\t${textarea.value.slice(end)}`; + textarea.value = nextValue; + textarea.selectionStart = start + 1; + textarea.selectionEnd = start + 1; +} + +function wrapSelectionWithInlineStyle(style: { color?: string; fontSize?: string }): void { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + return; + } + + const range = selection.getRangeAt(0); + const span = document.createElement('span'); + if (style.color) span.style.color = style.color; + if (style.fontSize) span.style.fontSize = style.fontSize; + span.appendChild(range.extractContents()); + range.insertNode(span); + selection.removeAllRanges(); + const nextRange = document.createRange(); + nextRange.selectNodeContents(span); + selection.addRange(nextRange); +} + +function applyMarkdownFormat(format: string, value?: string): void { + switch (format) { + case 'bold': + document.execCommand('bold'); + break; + case 'italic': + document.execCommand('italic'); + break; + case 'strike': + document.execCommand('strikeThrough'); + break; + case 'quote': + document.execCommand('formatBlock', false, 'blockquote'); + break; + case 'list': + document.execCommand('insertUnorderedList'); + break; + case 'link': { + if (value?.trim()) { + document.execCommand('createLink', false, value.trim()); + } + break; + } + case 'block-style': + if (value) { + document.execCommand('formatBlock', false, value); + } + break; + case 'code': + document.execCommand('insertHTML', false, `${window.getSelection()?.toString() || 'code'}`); + break; + default: + break; + } +} + +export function mountMarkdownEditor(options: { + target: HTMLElement; + value: string; + view: MarkdownEditorView; + initialScrollTop?: number; + currentFilePath: string; + searchLinks?: (query: string) => Promise; + loadHeadings?: (filePath: string) => Promise; + onChange: (value: string) => void; + onBlur?: () => void; +}): MarkdownEditorHandle { + const shell = options.target.closest('.markdown-editor-shell'); + const contextMenu = shell?.querySelector('#markdown-editor-context-menu') as HTMLElement | null; + const formatButtons = shell ? Array.from(shell.querySelectorAll('[data-format]')) : []; + const blockStyleSelect = shell?.querySelector('#markdown-block-style') as HTMLSelectElement | null; + const linkModal = shell?.querySelector('#markdown-link-modal') as HTMLElement | null; + const linkModeFile = shell?.querySelector('#markdown-link-mode-file') as HTMLButtonElement | null; + const linkModeUrl = shell?.querySelector('#markdown-link-mode-url') as HTMLButtonElement | null; + const linkFileFields = shell?.querySelector('#markdown-link-file-fields') as HTMLElement | null; + const linkUrlFields = shell?.querySelector('#markdown-link-url-fields') as HTMLElement | null; + const linkSearchInput = shell?.querySelector('#markdown-link-search') as HTMLInputElement | null; + const linkResults = shell?.querySelector('#markdown-link-results') as HTMLElement | null; + const linkHeadingSelect = shell?.querySelector('#markdown-link-heading') as HTMLSelectElement | null; + const linkAliasInput = shell?.querySelector('#markdown-link-alias') as HTMLInputElement | null; + const linkInput = shell?.querySelector('#markdown-link-input') as HTMLInputElement | null; + const linkLabelInput = shell?.querySelector('#markdown-link-label') as HTMLInputElement | null; + const linkApply = shell?.querySelector('#markdown-link-apply') as HTMLButtonElement | null; + const linkCancel = shell?.querySelector('#markdown-link-cancel') as HTMLButtonElement | null; + let savedRange: Range | null = null; + let linkMode: 'file' | 'url' = 'file'; + let linkSearchResults: MarkdownLinkSearchItem[] = []; + let selectedLinkItem: MarkdownLinkSearchItem | null = null; + + if (options.view === 'markdown') { + const editor = document.createElement('div'); + editor.className = 'markdown-editor-surface markdown-editor-surface--markdown markdown markdown-doc'; + editor.contentEditable = 'true'; + editor.setAttribute('role', 'textbox'); + editor.setAttribute('aria-multiline', 'true'); + editor.innerHTML = renderMarkdown(options.value); + options.target.replaceChildren(editor); + + const syncFromEditor = (): void => { + options.onChange(htmlToMarkdown(editor.innerHTML)); + if (contextMenu) { + const selection = window.getSelection(); + const hasSelection = !!selection && !selection.isCollapsed && editor.contains(selection.anchorNode); + contextMenu.hidden = !hasSelection; + if (hasSelection && selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + savedRange = range.cloneRange(); + const rect = range.getBoundingClientRect(); + const shellRect = (shell as HTMLElement).getBoundingClientRect(); + const left = Math.max(12, rect.left - shellRect.left + rect.width / 2 - contextMenu.offsetWidth / 2); + const top = Math.max(12, rect.top - shellRect.top - contextMenu.offsetHeight - 10); + contextMenu.style.left = `${left}px`; + contextMenu.style.top = `${top}px`; + } + } + }; + + const restoreSelection = (): void => { + if (!savedRange) { + return; + } + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(savedRange); + }; + + const renderLinkResults = (): void => { + if (!linkResults) { + return; + } + + if (linkSearchResults.length === 0) { + linkResults.innerHTML = ''; + return; + } + + linkResults.innerHTML = linkSearchResults.map((item) => ` + + `).join(''); + + const buttons = Array.from(linkResults.querySelectorAll('[data-link-path]')); + for (const button of buttons) { + button.addEventListener('click', async () => { + const nextItem = linkSearchResults.find((item) => item.path === button.dataset.linkPath); + if (!nextItem) { + return; + } + + selectedLinkItem = nextItem; + renderLinkResults(); + if (!linkHeadingSelect) { + return; + } + + linkHeadingSelect.innerHTML = ''; + const headings = await options.loadHeadings?.(nextItem.path) ?? []; + linkHeadingSelect.innerHTML = `${headings.map((heading) => ``).join('')}`; + }); + } + }; + + const updateLinkMode = (mode: 'file' | 'url'): void => { + linkMode = mode; + linkModeFile?.classList.toggle('is-active', mode === 'file'); + linkModeUrl?.classList.toggle('is-active', mode === 'url'); + if (linkFileFields) { + linkFileFields.hidden = mode !== 'file'; + } + if (linkUrlFields) { + linkUrlFields.hidden = mode !== 'url'; + } + }; + + const runLinkSearch = async (): Promise => { + if (!linkSearchInput || !options.searchLinks) { + return; + } + + const query = linkSearchInput.value.trim(); + if (query.length === 0) { + linkSearchResults = []; + selectedLinkItem = null; + if (linkHeadingSelect) { + linkHeadingSelect.innerHTML = ''; + } + renderLinkResults(); + return; + } + + linkSearchResults = await options.searchLinks(query); + selectedLinkItem = linkSearchResults[0] ?? null; + renderLinkResults(); + if (selectedLinkItem && linkHeadingSelect) { + const headings = await options.loadHeadings?.(selectedLinkItem.path) ?? []; + linkHeadingSelect.innerHTML = `${headings.map((heading) => ``).join('')}`; + } + }; + + const handleInput = (): void => { + syncFromEditor(); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Tab') { + event.preventDefault(); + document.execCommand('insertText', false, ' '); + syncFromEditor(); + } + }; + + const handleSelectionChange = (): void => { + syncFromEditor(); + }; + + const handleFocusOut = (event: FocusEvent): void => { + const nextTarget = event.relatedTarget as Node | null; + const widgetShell = shell?.closest('.tool-shell'); + if (nextTarget && (shell?.contains(nextTarget) || widgetShell?.contains(nextTarget))) { + return; + } + options.onBlur?.(); + }; + + const handleFormatClick = (event: Event): void => { + const target = event.currentTarget as HTMLButtonElement; + const format = target.dataset.format; + if (!format) { + return; + } + + editor.focus(); + restoreSelection(); + if (format === 'link') { + const selectedText = window.getSelection()?.toString().trim() ?? ''; + linkModal?.removeAttribute('hidden'); + updateLinkMode('file'); + if (linkAliasInput) { + linkAliasInput.value = selectedText; + } + if (linkLabelInput) { + linkLabelInput.value = selectedText; + } + if (linkSearchInput) { + linkSearchInput.value = ''; + linkSearchInput.focus(); + } + linkSearchResults = []; + selectedLinkItem = null; + if (linkHeadingSelect) { + linkHeadingSelect.innerHTML = ''; + } + renderLinkResults(); + return; + } + applyMarkdownFormat(format); + syncFromEditor(); + }; + + const handleBlockStyleChange = (): void => { + if (!blockStyleSelect?.value) { + return; + } + editor.focus(); + restoreSelection(); + applyMarkdownFormat('block-style', blockStyleSelect.value); + syncFromEditor(); + }; + + const closeLinkModal = (): void => { + linkModal?.setAttribute('hidden', ''); + if (linkInput) { + linkInput.value = ''; + } + if (linkLabelInput) { + linkLabelInput.value = ''; + } + if (linkAliasInput) { + linkAliasInput.value = ''; + } + if (linkSearchInput) { + linkSearchInput.value = ''; + } + if (linkHeadingSelect) { + linkHeadingSelect.innerHTML = ''; + } + linkSearchResults = []; + selectedLinkItem = null; + renderLinkResults(); + }; + + const handleLinkApply = (): void => { + editor.focus(); + restoreSelection(); + if (linkMode === 'url') { + const href = linkInput?.value?.trim(); + const label = linkLabelInput?.value?.trim() || window.getSelection()?.toString().trim() || href || 'link'; + if (href) { + document.execCommand('insertHTML', false, `${label}`); + syncFromEditor(); + } + } else if (selectedLinkItem) { + const selectedHeading = linkHeadingSelect?.value?.trim(); + const alias = linkAliasInput?.value?.trim(); + const pathPart = selectedLinkItem.path === options.currentFilePath ? '' : selectedLinkItem.wikiPath; + const wikiLink = `[[${pathPart}${selectedHeading ? `#${selectedHeading}` : ''}${alias ? `|${alias}` : ''}]]`; + const href = `${selectedLinkItem.relativePath}${selectedHeading ? `#${selectedHeading}` : ''}`; + const label = alias || selectedHeading || selectedLinkItem.title; + document.execCommand('insertHTML', false, `${label}`); + syncFromEditor(); + } + closeLinkModal(); + }; + + editor.addEventListener('input', handleInput); + editor.addEventListener('keydown', handleKeyDown); + editor.addEventListener('focusout', handleFocusOut); + document.addEventListener('selectionchange', handleSelectionChange); + formatButtons.forEach((button) => button.addEventListener('click', handleFormatClick)); + blockStyleSelect?.addEventListener('change', handleBlockStyleChange); + linkModeFile?.addEventListener('click', () => updateLinkMode('file')); + linkModeUrl?.addEventListener('click', () => { + updateLinkMode('url'); + linkInput?.focus(); + }); + const handleSearchInput = (): void => { void runLinkSearch(); }; + linkSearchInput?.addEventListener('input', handleSearchInput); + linkApply?.addEventListener('click', handleLinkApply); + linkCancel?.addEventListener('click', closeLinkModal); + syncFromEditor(); + renderLinkResults(); + if (typeof options.initialScrollTop === 'number') { + editor.scrollTop = options.initialScrollTop; + } + + return { + destroy: () => { + editor.removeEventListener('input', handleInput); + editor.removeEventListener('keydown', handleKeyDown); + editor.removeEventListener('focusout', handleFocusOut); + document.removeEventListener('selectionchange', handleSelectionChange); + formatButtons.forEach((button) => button.removeEventListener('click', handleFormatClick)); + blockStyleSelect?.removeEventListener('change', handleBlockStyleChange); + linkSearchInput?.removeEventListener('input', handleSearchInput); + linkApply?.removeEventListener('click', handleLinkApply); + linkCancel?.removeEventListener('click', closeLinkModal); + options.target.replaceChildren(); + }, + focus: () => { + editor.focus(); + }, + getValue: () => htmlToMarkdown(editor.innerHTML), + setValue: (value: string) => { + editor.innerHTML = renderMarkdown(value); + syncFromEditor(); + }, + revealLine: (_lineNumber: number, headingId?: string) => { + if (headingId) { + const heading = editor.querySelector(`#${CSS.escape(headingId)}`); + if (heading) { + heading.scrollIntoView({ block: 'start', inline: 'nearest' }); + editor.scrollTop = Math.max(editor.scrollTop - 24, 0); + heading.setAttribute('tabindex', '-1'); + heading.focus({ preventScroll: true }); + return; + } + } + + editor.focus(); + }, + setScrollTop: (scrollTop: number) => { + editor.scrollTop = Math.max(0, scrollTop); + }, + }; + } + + const textarea = document.createElement('textarea'); + textarea.className = 'markdown-editor-textarea markdown-editor-textarea--raw'; + textarea.spellcheck = false; + textarea.setAttribute('autocomplete', 'off'); + textarea.setAttribute('autocorrect', 'off'); + textarea.setAttribute('autocapitalize', 'off'); + textarea.placeholder = 'Edit raw markdown...'; + textarea.value = options.value; + options.target.replaceChildren(textarea); + + const autosize = (): void => { + textarea.style.height = 'auto'; + textarea.style.height = `${Math.max(textarea.scrollHeight, 640)}px`; + }; + + const handleInput = (): void => { + autosize(); + options.onChange(textarea.value); + }; + + const handleFocusOut = (event: FocusEvent): void => { + const nextTarget = event.relatedTarget as Node | null; + const widgetShell = shell?.closest('.tool-shell'); + if (nextTarget && (shell?.contains(nextTarget) || widgetShell?.contains(nextTarget))) { + return; + } + options.onBlur?.(); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Tab') { + return; + } + + event.preventDefault(); + applyRawTab(textarea); + autosize(); + options.onChange(textarea.value); + }; + + textarea.addEventListener('input', handleInput); + textarea.addEventListener('keydown', handleKeyDown); + textarea.addEventListener('focusout', handleFocusOut); + autosize(); + if (typeof options.initialScrollTop === 'number') { + textarea.scrollTop = options.initialScrollTop; + } + + return { + destroy: () => { + textarea.removeEventListener('input', handleInput); + textarea.removeEventListener('keydown', handleKeyDown); + textarea.removeEventListener('focusout', handleFocusOut); + options.target.replaceChildren(); + }, + focus: () => { + textarea.focus(); + }, + getValue: () => textarea.value, + setValue: (value: string) => { + textarea.value = value; + autosize(); + }, + revealLine: (lineNumber: number) => { + const targetLine = Math.max(1, Math.floor(lineNumber)); + const lines = textarea.value.split('\n'); + let index = 0; + for (let currentLine = 1; currentLine < targetLine && currentLine <= lines.length; currentLine += 1) { + index += lines[currentLine - 1].length + 1; + } + + textarea.focus(); + textarea.setSelectionRange(index, index); + + const lineHeight = Number.parseFloat(window.getComputedStyle(textarea).lineHeight || '20') || 20; + textarea.scrollTop = Math.max(0, (targetLine - 1) * lineHeight - lineHeight * 2); + }, + setScrollTop: (scrollTop: number) => { + textarea.scrollTop = Math.max(0, scrollTop); + }, + }; +} diff --git a/src/ui/file-preview/src/markdown-workspace/linking.ts b/src/ui/file-preview/src/markdown-workspace/linking.ts new file mode 100644 index 00000000..7f056321 --- /dev/null +++ b/src/ui/file-preview/src/markdown-workspace/linking.ts @@ -0,0 +1,257 @@ +import { slugifyMarkdownHeading } from './slugify.js'; + +export interface ResolvedMarkdownLink { + kind: 'external' | 'anchor' | 'file'; + href: string; + url?: string; + targetPath?: string; + anchor?: string; +} + +interface ParsedWikiLink { + path: string; + anchor?: string; + alias?: string; +} + +const WIKI_LINK_PATTERN = /\[\[([^\]|#]*)(?:#([^\]|]+))?(?:\|([^\]]+))?\]\]/g; +const FENCE_PATTERN = /^(`{3,}|~{3,})/; + +function isWindowsAbsolutePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value); +} + +function normalizePathSeparators(value: string): string { + return value.replace(/\\/g, '/'); +} + +function normalizeFilePath(value: string): string { + const normalized = normalizePathSeparators(value); + return normalized.replace(/\/+/g, '/'); +} + +function encodeLinkPath(pathValue: string): string { + return encodeURI(normalizePathSeparators(pathValue)); +} + +function parseWikiLink(rawHref: string): ParsedWikiLink | null { + const match = rawHref.match(/^\[\[([^\]|#]*)(?:#([^\]|]+))?(?:\|([^\]]+))?\]\]$/); + if (!match) { + return null; + } + + return { + path: (match[1] ?? '').trim(), + anchor: match[2]?.trim(), + alias: match[3]?.trim(), + }; +} + +function buildWikiDisplayText(link: ParsedWikiLink): string { + if (link.alias && link.alias.length > 0) { + return link.alias; + } + + if (link.path && link.anchor) { + return `${link.path}#${link.anchor}`; + } + + if (link.path) { + return link.path; + } + + return link.anchor ?? ''; +} + +function appendMarkdownExtension(pathValue: string): string { + if (/\.[A-Za-z0-9_-]+$/.test(pathValue)) { + return pathValue; + } + + return `${pathValue}.md`; +} + +function buildWikiHref(link: ParsedWikiLink): string { + if (!link.path) { + if (!link.anchor) { + return '#'; + } + + return `#${slugifyMarkdownHeading(link.anchor)}`; + } + + const normalizedPath = appendMarkdownExtension(normalizePathSeparators(link.path)); + const prefixedPath = normalizedPath.startsWith('./') + || normalizedPath.startsWith('../') + || normalizedPath.startsWith('/') + || isWindowsAbsolutePath(normalizedPath) + ? normalizedPath + : `./${normalizedPath}`; + + const encodedPath = encodeLinkPath(prefixedPath); + if (!link.anchor) { + return encodedPath; + } + + return `${encodedPath}#${slugifyMarkdownHeading(link.anchor)}`; +} + +function replaceWikiLinksOutsideInlineCode(line: string): string { + const segments = line.split(/(`[^`]*`)/g); + return segments.map((segment) => { + if (segment.startsWith('`') && segment.endsWith('`')) { + return segment; + } + + return segment.replace(WIKI_LINK_PATTERN, (match) => { + const parsed = parseWikiLink(match); + if (!parsed) { + return match; + } + + const displayText = buildWikiDisplayText(parsed); + const href = buildWikiHref(parsed); + return `[${displayText}](${href} "mcp-wiki:${encodeURIComponent(match)}")`; + }); + }).join(''); +} + +function decodeAnchorFragment(fragment: string | undefined): string | undefined { + if (!fragment || fragment.length === 0) { + return undefined; + } + + return decodeURIComponent(fragment); +} + +function splitHref(rawHref: string): { pathPart: string; anchorPart?: string } { + const hashIndex = rawHref.indexOf('#'); + if (hashIndex === -1) { + return { pathPart: rawHref }; + } + + return { + pathPart: rawHref.slice(0, hashIndex), + anchorPart: rawHref.slice(hashIndex + 1), + }; +} + +function getDirectoryPath(filePath: string): string { + const normalized = normalizeFilePath(filePath); + const lastSlashIndex = normalized.lastIndexOf('/'); + if (lastSlashIndex < 0) { + return normalized; + } + + return normalized.slice(0, lastSlashIndex); +} + +function toDirectoryFileUrl(directoryPath: string): URL { + const normalized = normalizeFilePath(directoryPath); + const withTrailingSlash = normalized.endsWith('/') ? normalized : `${normalized}/`; + + if (isWindowsAbsolutePath(withTrailingSlash)) { + return new URL(`file:///${encodeLinkPath(withTrailingSlash)}`); + } + + if (withTrailingSlash.startsWith('/')) { + return new URL(`file://${encodeLinkPath(withTrailingSlash)}`); + } + + return new URL(`file:///${encodeLinkPath(withTrailingSlash)}`); +} + +function fromFileUrl(url: URL): string { + const decodedPath = decodeURIComponent(url.pathname); + if (/^\/[A-Za-z]:\//.test(decodedPath)) { + return decodedPath.slice(1); + } + + return decodedPath; +} + +function isExternalHref(rawHref: string): boolean { + return /^[A-Za-z][A-Za-z0-9+.-]*:/.test(rawHref) && !isWindowsAbsolutePath(rawHref); +} + +function resolveFileTargetPath(currentPath: string, rawPath: string): string { + const normalizedRawPath = normalizePathSeparators(decodeURIComponent(rawPath)); + if (normalizedRawPath.startsWith('/') || isWindowsAbsolutePath(normalizedRawPath)) { + return normalizeFilePath(normalizedRawPath); + } + + const baseDirectory = getDirectoryPath(currentPath); + const resolvedUrl = new URL(encodeURI(normalizedRawPath), toDirectoryFileUrl(baseDirectory)); + return normalizeFilePath(fromFileUrl(resolvedUrl)); +} + +export function rewriteWikiLinks(source: string): string { + const lines = source.split('\n'); + let activeFence: string | null = null; + + return lines.map((line) => { + const trimmedStart = line.trimStart(); + const fenceMatch = trimmedStart.match(FENCE_PATTERN); + if (fenceMatch) { + const marker = fenceMatch[1]; + if (!activeFence) { + activeFence = marker[0].repeat(marker.length); + } else if (trimmedStart.startsWith(activeFence[0].repeat(3))) { + activeFence = null; + } + return line; + } + + if (activeFence) { + return line; + } + + return replaceWikiLinksOutsideInlineCode(line); + }).join('\n'); +} + +export function resolveMarkdownLink(currentPath: string, rawHref: string): ResolvedMarkdownLink { + const wikiLink = parseWikiLink(rawHref); + if (wikiLink) { + const href = buildWikiHref(wikiLink); + if (href.startsWith('#')) { + return { + kind: 'anchor', + href: rawHref, + anchor: decodeAnchorFragment(href.slice(1)), + }; + } + + const [pathPart, anchorPart] = href.split('#'); + return { + kind: 'file', + href: rawHref, + targetPath: resolveFileTargetPath(currentPath, pathPart), + ...(anchorPart ? { anchor: decodeAnchorFragment(anchorPart) } : {}), + }; + } + + if (isExternalHref(rawHref)) { + return { + kind: 'external', + href: rawHref, + url: rawHref, + }; + } + + if (rawHref.startsWith('#')) { + return { + kind: 'anchor', + href: rawHref, + anchor: decodeAnchorFragment(rawHref.slice(1)), + }; + } + + const { pathPart, anchorPart } = splitHref(rawHref); + return { + kind: 'file', + href: rawHref, + targetPath: resolveFileTargetPath(currentPath, pathPart), + ...(anchorPart ? { anchor: decodeAnchorFragment(anchorPart) } : {}), + }; +} diff --git a/src/ui/file-preview/src/markdown-workspace/outline.ts b/src/ui/file-preview/src/markdown-workspace/outline.ts new file mode 100644 index 00000000..7838b82e --- /dev/null +++ b/src/ui/file-preview/src/markdown-workspace/outline.ts @@ -0,0 +1,81 @@ +// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations. +// @ts-expect-error markdown-it does not provide local TypeScript typings in this setup. +import MarkdownIt from 'markdown-it'; +import { rewriteWikiLinks } from './linking.js'; +import { createSlugTracker } from './slugify.js'; + +interface MarkdownOutlineParser { + parse: (source: string, env?: Record) => Array>; +} + +type MarkdownItConstructor = new (options?: { + html?: boolean; + linkify?: boolean; + typographer?: boolean; +}) => MarkdownOutlineParser; + +const MarkdownItCtor = MarkdownIt as unknown as MarkdownItConstructor; +const outlineParser = new MarkdownItCtor({ + html: false, + linkify: true, + typographer: false, +}); + +export interface MarkdownOutlineItem { + id: string; + text: string; + level: number; + line: number; +} + +function extractInlineText(token: Record | undefined): string { + if (!token) { + return ''; + } + + const children = Array.isArray(token.children) ? token.children : []; + if (children.length === 0) { + return typeof token.content === 'string' ? token.content : ''; + } + + return children.map((child) => { + if (typeof child.content === 'string') { + return child.content; + } + return ''; + }).join(''); +} + +export function extractMarkdownOutline(source: string): MarkdownOutlineItem[] { + const tokens = outlineParser.parse(rewriteWikiLinks(source), {}); + const nextSlug = createSlugTracker(); + const outline: MarkdownOutlineItem[] = []; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token.type !== 'heading_open' || typeof token.tag !== 'string') { + continue; + } + + const level = Number.parseInt(token.tag.replace(/^h/i, ''), 10); + if (!Number.isFinite(level)) { + continue; + } + + const inlineToken = tokens[index + 1]; + const text = extractInlineText(inlineToken).trim(); + if (!text) { + continue; + } + + const lineMap = Array.isArray(token.map) ? token.map : undefined; + outline.push({ + id: nextSlug(text), + text, + level, + line: Array.isArray(lineMap) && typeof lineMap[0] === 'number' ? lineMap[0] + 1 : outline.length + 1, + }); + } + + return outline; +} diff --git a/src/ui/file-preview/src/markdown-workspace/preview.ts b/src/ui/file-preview/src/markdown-workspace/preview.ts new file mode 100644 index 00000000..f1352054 --- /dev/null +++ b/src/ui/file-preview/src/markdown-workspace/preview.ts @@ -0,0 +1,43 @@ +import { renderMarkdown } from '../components/markdown-renderer.js'; +import type { MarkdownOutlineItem } from './outline.js'; +import { renderMarkdownToc } from './toc.js'; + +export function getRenderedMarkdownCopyText(content: string): string { + const html = renderMarkdown(content); + const normalizedHtml = html + .replace(/<\s*br\s*\/?>/gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/<\/h[1-6]>/gi, '\n\n') + .replace(/<\/li>/gi, '\n') + .replace(/
  • /gi, '- ') + .replace(/<[^>]+>/g, ''); + + return normalizedHtml + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export function renderMarkdownWorkspacePreview(options: { + content: string; + outline: MarkdownOutlineItem[]; + activeHeadingId?: string | null; + showToc?: boolean; +}): string { + const tocHtml = options.showToc ? renderMarkdownToc(options.outline, options.activeHeadingId) : ''; + const hasToc = tocHtml.length > 0; + + return ` +
    + ${tocHtml} +
    +
    ${renderMarkdown(options.content)}
    +
    +
    + `; +} diff --git a/src/ui/file-preview/src/markdown-workspace/slugify.ts b/src/ui/file-preview/src/markdown-workspace/slugify.ts new file mode 100644 index 00000000..a78e607c --- /dev/null +++ b/src/ui/file-preview/src/markdown-workspace/slugify.ts @@ -0,0 +1,35 @@ +export type MarkdownSlugTracker = (text: string) => string; + +function sanitizeSlugPart(text: string): string { + const normalized = text + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, ' ') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + + return normalized.length > 0 ? normalized : 'section'; +} + +export function slugifyMarkdownHeading(text: string): string { + return sanitizeSlugPart(text); +} + +export function createSlugTracker(): MarkdownSlugTracker { + const counts = new Map(); + + return (text: string): string => { + const baseSlug = slugifyMarkdownHeading(text); + const nextCount = (counts.get(baseSlug) ?? 0) + 1; + counts.set(baseSlug, nextCount); + + if (nextCount === 1) { + return baseSlug; + } + + return `${baseSlug}-${nextCount}`; + }; +} diff --git a/src/ui/file-preview/src/markdown-workspace/toc.ts b/src/ui/file-preview/src/markdown-workspace/toc.ts new file mode 100644 index 00000000..3cd742e7 --- /dev/null +++ b/src/ui/file-preview/src/markdown-workspace/toc.ts @@ -0,0 +1,99 @@ +import type { MarkdownOutlineItem } from './outline.js'; + +export interface MarkdownTocHandle { + dispose: () => void; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function setActiveItem(nav: HTMLElement, activeId: string | null): void { + const buttons = Array.from(nav.querySelectorAll('[data-toc-id]')); + buttons.forEach((button) => { + const isActive = button.dataset.tocId === activeId; + button.classList.toggle('is-active', isActive); + button.setAttribute('aria-current', isActive ? 'location' : 'false'); + }); +} + +export function renderMarkdownToc(outline: MarkdownOutlineItem[], activeHeadingId?: string | null): string { + if (outline.length === 0) { + return ''; + } + + const items = outline.map((item) => { + const activeClass = item.id === activeHeadingId ? ' is-active' : ''; + return ``; + }).join(''); + + return ` + + `; +} + +export function attachMarkdownToc(options: { + shell: HTMLElement; + outline: MarkdownOutlineItem[]; + scrollContainer: HTMLElement; + onSelect: (headingId: string) => void; +}): MarkdownTocHandle | null { + const nav = options.shell.querySelector('.markdown-toc-nav') as HTMLElement | null; + if (!nav) { + return null; + } + + const handleClick = (event: Event): void => { + const target = event.target as HTMLElement | null; + const button = target?.closest('[data-toc-id]'); + const headingId = button?.dataset.tocId; + if (!headingId) { + return; + } + + options.onSelect(headingId); + setActiveItem(nav, headingId); + }; + + const updateActiveHeading = (): void => { + const headings = options.outline + .map((item) => { + const element = document.getElementById(item.id); + return element ? { item, element } : null; + }) + .filter((entry): entry is { item: MarkdownOutlineItem; element: HTMLElement } => entry !== null); + + if (headings.length === 0) { + return; + } + + const scrollTop = options.scrollContainer.scrollTop; + const nextActive = headings.reduce((activeId, current) => { + if (current.element.offsetTop - scrollTop <= 96) { + return current.item.id; + } + return activeId; + }, headings[0].item.id); + + setActiveItem(nav, nextActive); + }; + + nav.addEventListener('click', handleClick); + options.scrollContainer.addEventListener('scroll', updateActiveHeading, { passive: true }); + updateActiveHeading(); + + return { + dispose: () => { + nav.removeEventListener('click', handleClick); + options.scrollContainer.removeEventListener('scroll', updateActiveHeading); + }, + }; +} diff --git a/src/ui/file-preview/src/markdown-workspace/workspace-controller.ts b/src/ui/file-preview/src/markdown-workspace/workspace-controller.ts new file mode 100644 index 00000000..25f875f7 --- /dev/null +++ b/src/ui/file-preview/src/markdown-workspace/workspace-controller.ts @@ -0,0 +1,66 @@ +export interface ReadRange { + fromLine: number; + toLine: number; + totalLines: number; + isPartial: boolean; +} + +export function stripReadStatusLine(content: string): string { + return content.replace(/^\[Reading [^\]]+\]\r?\n(?:\r?\n)?/, ''); +} + +export function parseReadRange(content: string): ReadRange | undefined { + const match = content.match(/^\[Reading (\d+) lines from (?:line )?(\d+|start) \(total: (\d+) lines/); + if (!match) { + return undefined; + } + + const count = Number.parseInt(match[1], 10); + const fromLine = match[2] === 'start' ? 1 : Number.parseInt(match[2], 10); + const totalLines = Number.parseInt(match[3], 10); + return { + fromLine, + toLine: fromLine + count - 1, + totalLines, + isPartial: count < totalLines, + }; +} + +export function getMarkdownEditAvailability(options: { + content: string; + availableDisplayModes?: string[]; +}): { canEdit: true } | { canEdit: false; reason: string } { + const readRange = parseReadRange(options.content); + if (readRange?.isPartial) { + return { + canEdit: false, + reason: 'Load the full document before editing.', + }; + } + + if (!options.availableDisplayModes?.includes('fullscreen')) { + return { + canEdit: false, + reason: 'Fullscreen editing is unavailable in this host.', + }; + } + + return { canEdit: true }; +} + +export function getMarkdownFullscreenAvailability(options: { + availableDisplayModes?: string[]; +}): { canFullscreen: true } | { canFullscreen: false; reason: string } { + if (!options.availableDisplayModes?.includes('fullscreen')) { + return { + canFullscreen: false, + reason: 'Fullscreen editing is unavailable in this host.', + }; + } + + return { canFullscreen: true }; +} + +export function shouldAutoLoadMarkdownOnEnterFullscreen(content: string): boolean { + return parseReadRange(content)?.isPartial === true; +} diff --git a/src/ui/shared/widget-state.ts b/src/ui/shared/widget-state.ts index 74d5b5b2..5550e0b3 100644 --- a/src/ui/shared/widget-state.ts +++ b/src/ui/shared/widget-state.ts @@ -17,6 +17,8 @@ export interface WidgetStateStorage { write(state: T): void; } +const FALLBACK_WIDGET_STATE_KEY = 'desktop-commander:file-preview:widget-state'; + /** * Check if we're running in ChatGPT (has special widget state API) */ @@ -34,12 +36,31 @@ export function isChatGPT(): boolean { export function createWidgetStateStorage( validator?: (state: unknown) => boolean ): WidgetStateStorage { - if (!isChatGPT()) { - // Other hosts don't have widget state persistence - return no-op + const storage = typeof window !== 'undefined' ? window.sessionStorage : undefined; return { - read: () => undefined, - write: () => {} + read(): T | undefined { + if (!storage) return undefined; + try { + const raw = storage.getItem(FALLBACK_WIDGET_STATE_KEY); + if (!raw) return undefined; + const parsed = JSON.parse(raw); + const payload = parsed?.payload; + if (payload === undefined) return undefined; + if (validator && !validator(payload)) return undefined; + return payload as T; + } catch { + return undefined; + } + }, + write(state: T): void { + if (!storage) return; + try { + storage.setItem(FALLBACK_WIDGET_STATE_KEY, JSON.stringify({ payload: state })); + } catch { + // Ignore storage failures + } + } }; } diff --git a/src/ui/styles/apps/file-preview.css b/src/ui/styles/apps/file-preview.css index 2db567b5..fc27dacd 100644 --- a/src/ui/styles/apps/file-preview.css +++ b/src/ui/styles/apps/file-preview.css @@ -111,6 +111,15 @@ background: var(--panel-subtle); } +.panel-action--primary { + color: var(--text); + background: color-mix(in srgb, var(--panel-subtle) 72%, var(--border) 28%); +} + +.panel-action--primary:hover { + background: color-mix(in srgb, var(--panel-subtle) 58%, var(--border) 42%); +} + .panel-action:disabled { opacity: 0.35; cursor: not-allowed; @@ -174,6 +183,473 @@ .html-content .html-rendered-frame { min-height: 300px; height: var(--content-height); } .markdown-content { overflow: auto; padding: 0 4px 0 0; } +.markdown-content--workspace { + padding: 0; + overflow: visible; +} + +.markdown-workspace { + display: flex; + min-height: min(70vh, 880px); + background: transparent; +} + +.markdown-workspace--with-toc { + align-items: stretch; +} + +.markdown-workspace-main { + flex: 1 1 auto; + min-width: 0; +} + +.markdown-workspace-main--editor { + display: flex; + min-height: min(70vh, 880px); +} + +.markdown-editor-shell { + display: grid; + gap: 14px; + width: 100%; + padding: 20px; + position: relative; +} + +.markdown-editor-mode-toggle { + position: relative; + display: inline-grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: center; + gap: 4px; + padding: 3px; + border-radius: 999px; + background: color-mix(in srgb, var(--panel-subtle) 72%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 45%, transparent); +} + +.markdown-editor-mode-toggle-indicator { + position: absolute; + top: 3px; + bottom: 3px; + left: 3px; + width: calc(50% - 2px); + border-radius: 999px; + background: color-mix(in srgb, var(--panel) 96%, transparent); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); + transition: transform 180ms ease; + pointer-events: none; +} + +.markdown-editor-mode-toggle-indicator--markdown { + transform: translateX(calc(100% + 1px)); +} + +.markdown-editor-mode-option { + position: relative; + z-index: 1; + border: none; + background: transparent; + color: var(--text-secondary); + min-width: 0; + height: 32px; + padding: 0 12px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + font: inherit; + font-size: 12px; + cursor: pointer; + transition: color 150ms ease; +} + +.markdown-editor-mode-option.is-active { + color: var(--text); +} + +.markdown-editor-mode-option span { + white-space: nowrap; +} + +.markdown-editor-pane { + display: flex; + flex-direction: column; + min-height: min(70vh, 840px); + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + border-radius: 16px; + overflow: hidden; + background: color-mix(in srgb, var(--panel) 92%, transparent); +} + +.markdown-editor-pane--raw { + max-width: none; +} + +.markdown-editor-pane--markdown { + max-width: none; +} + +.markdown-editor-context-menu { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 6px; + background: #171717; + border: 1px solid color-mix(in srgb, var(--border) 34%, transparent); + border-radius: 10px; + position: absolute; + z-index: 20; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); +} + +.markdown-editor-context-menu[hidden] { + display: none; +} + +.markdown-format-button { + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.9); + border-radius: 6px; + width: 32px; + height: 32px; + padding: 0; + font: inherit; + font-size: 13px; + cursor: pointer; + white-space: nowrap; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.markdown-format-button:hover { + background: rgba(255, 255, 255, 0.14); +} + +.markdown-format-button--swatch { + padding: 0; +} + +.markdown-format-size { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + border-radius: 6px; + overflow: hidden; + background: rgba(255, 255, 255, 0.08); +} + +.markdown-format-size select { + height: 32px; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.9); + padding: 0 8px; + font: inherit; + font-size: 12px; + outline: none; +} + +.markdown-format-sep { + width: 1px; + height: 20px; + background: rgba(255, 255, 255, 0.14); +} + +.markdown-link-modal { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(15, 23, 42, 0.22); + z-index: 30; +} + +.markdown-link-modal[hidden] { + display: none; +} + +.markdown-link-modal-card { + width: min(420px, calc(100% - 32px)); + background: var(--panel); + border: 1px solid color-mix(in srgb, var(--border) 45%, transparent); + border-radius: 14px; + padding: 14px; + box-shadow: 0 18px 44px rgba(15, 23, 42, 0.22); +} + +.markdown-link-mode-tabs { + display: inline-flex; + gap: 6px; + margin-bottom: 12px; +} + +.markdown-link-mode-tab { + border: 1px solid color-mix(in srgb, var(--border) 55%, transparent); + background: transparent; + color: var(--text-secondary); + border-radius: 999px; + padding: 7px 12px; + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.markdown-link-mode-tab.is-active { + color: var(--text); + background: color-mix(in srgb, var(--panel-subtle) 72%, var(--border) 28%); +} + +.markdown-link-modal-label { + display: block; + margin-bottom: 8px; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + +.markdown-link-modal-input { + width: 100%; + border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + border-radius: 10px; + padding: 10px 12px; + font: inherit; + font-size: 13px; + color: var(--text); + background: color-mix(in srgb, var(--panel-subtle) 68%, transparent); +} + +.markdown-link-modal-select { + appearance: none; +} + +.markdown-link-results { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 160px; + overflow: auto; + margin: 10px 0 12px; +} + +.markdown-link-results-empty { + padding: 10px 12px; + font-size: 12px; + color: var(--text-secondary); + border: 1px dashed color-mix(in srgb, var(--border) 45%, transparent); + border-radius: 10px; +} + +.markdown-link-result { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + width: 100%; + border: 1px solid color-mix(in srgb, var(--border) 45%, transparent); + background: transparent; + border-radius: 10px; + padding: 10px 12px; + cursor: pointer; + text-align: left; +} + +.markdown-link-result.is-active { + background: color-mix(in srgb, var(--panel-subtle) 72%, var(--border) 28%); +} + +.markdown-link-result-title { + font-size: 13px; + color: var(--text); +} + +.markdown-link-result-path { + font-size: 11px; + color: var(--text-secondary); +} + +.markdown-link-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} + +.markdown-link-modal-button { + border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + background: transparent; + color: var(--text-secondary); + border-radius: 10px; + padding: 8px 12px; + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.markdown-link-modal-button--primary { + color: var(--text); + background: color-mix(in srgb, var(--panel-subtle) 72%, var(--border) 28%); +} + +.markdown-editor-copy-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 44px; + height: 32px; + border: 1px solid color-mix(in srgb, var(--border) 55%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--panel) 92%, transparent); + color: var(--text-secondary); + cursor: pointer; +} + +.markdown-editor-copy-button:hover { + color: var(--text); + background: color-mix(in srgb, var(--panel-subtle) 82%, transparent); +} + +.markdown-editor-copy-button[data-status="Copied!"] { + color: var(--text); + background: color-mix(in srgb, var(--panel-subtle) 82%, var(--border) 18%); +} + +.markdown-editor-copy-button[data-status="Copy failed"] { + color: #b91c1c; +} + +.panel-save-status { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + color: var(--muted); + background: color-mix(in srgb, var(--panel-subtle) 70%, transparent); +} + +.panel-save-status--saving { + color: var(--text); +} + +.panel-save-status--saved { + color: color-mix(in srgb, var(--text) 82%, var(--muted) 18%); +} + +.panel-save-status--pending { + color: var(--muted); +} + +.panel-topbar .markdown-editor-mode-toggle { + margin-left: 2px; +} + +.markdown-toc-shell { + flex: 0 0 220px; + width: 220px; + border-right: 1px solid color-mix(in srgb, var(--border) 45%, transparent); + background: color-mix(in srgb, var(--panel-subtle) 82%, transparent); + padding: 18px 14px 18px 16px; + display: flex; + flex-direction: column; + align-self: flex-start; + position: sticky; + top: 12px; + max-height: calc(100vh - 120px); + overflow: hidden; +} + +.markdown-toc-title { + margin-bottom: 12px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-secondary); +} + +.markdown-toc-nav { + display: flex; + flex-direction: column; + gap: 4px; + overflow: auto; + min-height: 0; +} + +.markdown-toc-link { + border: none; + background: transparent; + color: var(--text-secondary); + text-align: left; + font: inherit; + font-size: 13px; + line-height: 1.35; + padding: 6px 8px; + border-radius: 8px; + cursor: pointer; +} + +.markdown-toc-link[data-level="2"] { padding-left: 16px; } +.markdown-toc-link[data-level="3"] { padding-left: 24px; } +.markdown-toc-link[data-level="4"] { padding-left: 32px; } +.markdown-toc-link[data-level="5"] { padding-left: 40px; } +.markdown-toc-link[data-level="6"] { padding-left: 48px; } + +.markdown-toc-link:hover { + color: var(--text); + background: color-mix(in srgb, var(--panel) 72%, transparent); +} + +.markdown-toc-link.is-active { + color: var(--text); + background: color-mix(in srgb, var(--panel) 82%, var(--border) 18%); +} + +.markdown-editor-root { + flex: 1 1 auto; + min-height: 0; + padding: 0; +} + +.markdown-editor-surface { + flex: 1 1 auto; + min-height: min(70vh, 760px); + outline: none; +} + +.markdown-editor-surface--markdown { + max-width: none; + margin: 0; + padding: 22px 24px 28px; +} + +.markdown-editor-textarea { + width: 100%; + min-height: min(70vh, 760px); + border: 0; + border-radius: 0; + background: transparent; + color: var(--text); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 14px; + line-height: 1.65; + padding: 18px 20px 24px; + resize: none; + overflow: hidden; + outline: none; +} + +.markdown-editor-textarea:focus { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-secondary) 18%, transparent); +} + .image-content { display: flex; align-items: center; @@ -428,6 +904,17 @@ border-radius: 10px; } +.markdown-doc a { + color: inherit; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--text-secondary) 45%, transparent); + text-underline-offset: 0.18em; +} + +.markdown-doc a:hover { + text-decoration-color: currentColor; +} + /* ── HTML frame ── */ .html-rendered-frame { @@ -442,6 +929,28 @@ /* ── Responsive ── */ @media (max-width: 720px) { + .markdown-workspace { + flex-direction: column; + min-height: auto; + } + .markdown-editor-shell { + padding: 12px; + } + .markdown-toc-shell { + width: 100%; + border-right: none; + border-bottom: 1px solid color-mix(in srgb, var(--border) 45%, transparent); + } + .markdown-editor-root { + padding: 0; + } + .markdown-editor-textarea { + min-height: 58vh; + padding: 14px; + } + .markdown-editor-surface--markdown { + padding: 16px; + } .markdown-doc { padding: 16px; } .markdown-doc h1 { font-size: 27px; } .markdown-doc h2 { font-size: 22px; } diff --git a/test/test-file-handlers.js b/test/test-file-handlers.js index 14cb429a..4a379abd 100644 --- a/test/test-file-handlers.js +++ b/test/test-file-handlers.js @@ -19,6 +19,7 @@ import assert from 'assert'; import { readFile, writeFile, getFileInfo } from '../dist/tools/filesystem.js'; import { getFileHandler } from '../dist/utils/files/factory.js'; import { handleReadFile } from '../dist/handlers/filesystem-handlers.js'; +import { handleEditBlock } from '../dist/handlers/edit-search-handlers.js'; // Get directory name const __filename = fileURLToPath(import.meta.url); @@ -345,6 +346,33 @@ async function testReadFilePreviewMetadata() { console.log('βœ“ read_file preview structured content contract works'); } +/** + * Test 10: Markdown exact-match save flow works through edit_block + */ +async function testMarkdownExactMatchSave() { + console.log('\n--- Test 10: markdown exact-match save flow ---'); + + const originalContent = '# Title\n\nOriginal paragraph.\n'; + const updatedContent = '# Title\n\nUpdated paragraph.\n'; + + await fs.writeFile(MD_FILE, originalContent); + + const result = await handleEditBlock({ + file_path: MD_FILE, + old_string: originalContent, + new_string: updatedContent, + expected_replacements: 1, + }); + + assert.ok(Array.isArray(result.content), 'edit_block result should include content array'); + assert.ok(result.content[0].text.includes('Successfully applied 1 edit'), 'edit_block should report exact-match success'); + + const readBack = await fs.readFile(MD_FILE, 'utf8'); + assert.strictEqual(readBack, updatedContent, 'Markdown file should be rewritten with the updated content'); + + console.log('βœ“ markdown exact-match save flow works'); +} + /** * Run all tests */ @@ -360,6 +388,7 @@ async function runAllTests() { await testFileInfo(); await testWriteModes(); await testReadFilePreviewMetadata(); + await testMarkdownExactMatchSave(); console.log('\nβœ… All file handler tests passed!'); } diff --git a/test/test-markdown-workspace-links.js b/test/test-markdown-workspace-links.js new file mode 100644 index 00000000..67b736cf --- /dev/null +++ b/test/test-markdown-workspace-links.js @@ -0,0 +1,260 @@ +import assert from 'assert'; + +import { renderMarkdown } from '../dist/ui/file-preview/src/components/markdown-renderer.js'; +import { resolveMarkdownLink, rewriteWikiLinks } from '../dist/ui/file-preview/src/markdown-workspace/linking.js'; +import { extractMarkdownOutline } from '../dist/ui/file-preview/src/markdown-workspace/outline.js'; +import { getRenderedMarkdownCopyText, renderMarkdownWorkspacePreview } from '../dist/ui/file-preview/src/markdown-workspace/preview.js'; +import { renderMarkdownEditorShell } from '../dist/ui/file-preview/src/markdown-workspace/editor.js'; +import { createSlugTracker, slugifyMarkdownHeading } from '../dist/ui/file-preview/src/markdown-workspace/slugify.js'; +import { getMarkdownEditAvailability, getMarkdownFullscreenAvailability, shouldAutoLoadMarkdownOnEnterFullscreen } from '../dist/ui/file-preview/src/markdown-workspace/workspace-controller.js'; + +async function testSlugGeneration() { + console.log('\n--- Test 1: heading slug generation ---'); + + assert.strictEqual(slugifyMarkdownHeading(' Hello, World! '), 'hello-world'); + + const nextSlug = createSlugTracker(); + assert.strictEqual(nextSlug('Overview'), 'overview'); + assert.strictEqual(nextSlug('Overview'), 'overview-2'); + assert.strictEqual(nextSlug('Overview'), 'overview-3'); + + console.log('βœ“ heading slugs are stable and unique'); +} + +async function testOutlineExtraction() { + console.log('\n--- Test 2: markdown outline extraction ---'); + + const source = [ + '# Title', + '', + '## Details', + '', + '```md', + '# Not a heading', + '```', + '', + '## Details', + '', + '### Linked [Section](#details)', + ].join('\n'); + + const outline = extractMarkdownOutline(source); + assert.deepStrictEqual( + outline.map((item) => ({ id: item.id, text: item.text, level: item.level })), + [ + { id: 'title', text: 'Title', level: 1 }, + { id: 'details', text: 'Details', level: 2 }, + { id: 'details-2', text: 'Details', level: 2 }, + { id: 'linked-section', text: 'Linked Section', level: 3 }, + ], + ); + + console.log('βœ“ outline extraction ignores fenced code and de-duplicates headings'); +} + +async function testLinkResolution() { + console.log('\n--- Test 3: markdown link resolution ---'); + + const currentPath = '/Users/tester/docs/start.md'; + + assert.deepStrictEqual(resolveMarkdownLink(currentPath, '#details'), { + kind: 'anchor', + href: '#details', + anchor: 'details', + }); + + assert.deepStrictEqual(resolveMarkdownLink(currentPath, './guide.md#Install%20Now'), { + kind: 'file', + href: './guide.md#Install%20Now', + targetPath: '/Users/tester/docs/guide.md', + anchor: 'Install Now', + }); + + assert.deepStrictEqual(resolveMarkdownLink(currentPath, '/tmp/reference.md#Intro'), { + kind: 'file', + href: '/tmp/reference.md#Intro', + targetPath: '/tmp/reference.md', + anchor: 'Intro', + }); + + assert.deepStrictEqual(resolveMarkdownLink(currentPath, 'https://example.com/docs'), { + kind: 'external', + href: 'https://example.com/docs', + url: 'https://example.com/docs', + }); + + assert.deepStrictEqual(resolveMarkdownLink(currentPath, '[[Meeting Notes#Action Items|Actions]]'), { + kind: 'file', + href: '[[Meeting Notes#Action Items|Actions]]', + targetPath: '/Users/tester/docs/Meeting Notes.md', + anchor: 'action-items', + }); + + console.log('βœ“ anchors, file links, absolute paths, external URLs, and wiki links resolve correctly'); +} + +async function testWikiRewriteAndRendering() { + console.log('\n--- Test 4: wiki link rewrite and rendering ---'); + + const rewritten = rewriteWikiLinks('See [[Meeting Notes#Action Items|Actions]] and `[[Code]]`.'); + assert.ok(rewritten.includes('[Actions](./Meeting%20Notes.md#action-items "mcp-wiki:'), 'Wiki links should rewrite to markdown links with round-trip metadata'); + assert.ok(rewritten.includes('`[[Code]]`'), 'Inline code should remain untouched'); + + const html = renderMarkdown([ + '# Title', + '## Details', + '## Details', + '', + 'Go to [[Meeting Notes#Action Items|Actions]].', + ].join('\n')); + + assert.ok(html.includes('id="title"'), 'Rendered markdown should include slugged heading ids'); + assert.ok(html.includes('id="details-2"'), 'Duplicate headings should receive unique ids'); + assert.ok(html.includes('href="./Meeting%20Notes.md#action-items"'), 'Rendered markdown should keep rewritten wiki links'); + assert.ok(html.includes('data-wiki-link="[[Meeting Notes#Action Items|Actions]]"'), 'Rendered markdown should preserve original wiki-link syntax for editing'); + + console.log('βœ“ markdown rendering uses workspace heading ids and rewritten wiki links'); +} + +async function testEditAvailability() { + console.log('\n--- Test 5: fullscreen edit availability ---'); + + assert.deepStrictEqual( + getMarkdownEditAvailability({ + content: '# Ready', + availableDisplayModes: ['inline', 'fullscreen'], + }), + { canEdit: true }, + ); + + assert.deepStrictEqual( + getMarkdownEditAvailability({ + content: '[Reading 10 lines from start (total: 20 lines, 10 remaining)]\n# Partial', + availableDisplayModes: ['inline', 'fullscreen'], + }), + { canEdit: false, reason: 'Load the full document before editing.' }, + ); + + assert.deepStrictEqual( + getMarkdownEditAvailability({ + content: '# No fullscreen', + availableDisplayModes: ['inline'], + }), + { canEdit: false, reason: 'Fullscreen editing is unavailable in this host.' }, + ); + + console.log('βœ“ edit mode is gated by full-content availability and fullscreen support'); +} + +async function testFullscreenWorkspaceHelpers() { + console.log('\n--- Test 6: fullscreen workspace helpers ---'); + + assert.deepStrictEqual( + getMarkdownFullscreenAvailability({ + availableDisplayModes: ['inline', 'fullscreen'], + }), + { canFullscreen: true }, + ); + + assert.deepStrictEqual( + getMarkdownFullscreenAvailability({ + availableDisplayModes: ['inline'], + }), + { canFullscreen: false, reason: 'Fullscreen editing is unavailable in this host.' }, + ); + + assert.strictEqual( + shouldAutoLoadMarkdownOnEnterFullscreen('[Reading 10 lines from start (total: 20 lines, 10 remaining)]\n# Partial'), + true, + ); + assert.strictEqual(shouldAutoLoadMarkdownOnEnterFullscreen('# Full'), false); + + console.log('βœ“ fullscreen entry support and partial-read auto-load are detected correctly'); +} + +async function testPreviewTocRendering() { + console.log('\n--- Test 7: TOC only renders when requested ---'); + + const outline = extractMarkdownOutline(['# Title', '## Section'].join('\n')); + const inlineHtml = renderMarkdownWorkspacePreview({ + content: '# Title\n\n## Section', + outline, + activeHeadingId: 'title', + showToc: false, + }); + const fullscreenHtml = renderMarkdownWorkspacePreview({ + content: '# Title\n\n## Section', + outline, + activeHeadingId: 'title', + showToc: true, + }); + + assert.ok(!inlineHtml.includes('markdown-toc-shell'), 'Inline preview should not render a TOC shell'); + assert.ok(fullscreenHtml.includes('markdown-toc-shell'), 'Fullscreen preview should render a TOC shell'); + + console.log('βœ“ preview TOC stays hidden inline and appears when fullscreen layout requests it'); +} + +async function testCopyFormatsAndEditorShell() { + console.log('\n--- Test 8: copy formats and editor shell ---'); + + const renderedCopy = getRenderedMarkdownCopyText('# Title\n\n- First\n- Second\n\n**Bold** text'); + assert.ok(renderedCopy.includes('Title'), 'Rendered copy should preserve heading text'); + assert.ok(renderedCopy.includes('- First'), 'Rendered copy should preserve list text'); + assert.ok(renderedCopy.includes('Bold text'), 'Rendered copy should flatten formatted inline text'); + + const markdownShell = renderMarkdownEditorShell({ + content: '# Title\n\nBody', + view: 'markdown', + }); + assert.ok(!markdownShell.includes('markdown-editor-mode-toggle'), 'Editor shell should not duplicate top-bar mode toggle'); + assert.ok(!markdownShell.includes('agents.md'), 'Editor shell should not duplicate file title header'); + assert.ok(!markdownShell.includes('copy-active-markdown'), 'Editor shell should not duplicate top-bar copy action'); + assert.ok(markdownShell.includes('markdown-editor-context-menu'), 'Markdown mode should include formatting context controls'); + assert.ok(markdownShell.includes('data-format="strike"'), 'Context menu should include strikethrough'); + assert.ok(markdownShell.includes('markdown-block-style'), 'Context menu should include semantic block-style dropdown'); + assert.ok(!markdownShell.includes('data-format="color-blue"'), 'Context menu should not include non-native color styling'); + assert.ok(!markdownShell.includes('data-format="highlight"'), 'Context menu should not include non-native highlight styling'); + assert.ok(markdownShell.includes('markdown-link-modal'), 'Markdown mode should include a link-entry modal'); + + const rawShell = renderMarkdownEditorShell({ + content: '# Title\n\nBody', + view: 'raw', + }); + assert.ok(!rawShell.includes('agents.md'), 'Raw mode should not duplicate the file title'); + assert.ok(!rawShell.includes('markdown-editor-context-menu'), 'Raw mode should not include markdown formatting context controls'); + assert.ok(!rawShell.includes('data-format="bold"'), 'Raw mode should not include formatting buttons'); + + console.log('βœ“ raw/rendered copy support and mode-specific editor shell are wired'); +} + +export default async function runTests() { + try { + await testSlugGeneration(); + await testOutlineExtraction(); + await testLinkResolution(); + await testWikiRewriteAndRendering(); + await testEditAvailability(); + await testFullscreenWorkspaceHelpers(); + await testPreviewTocRendering(); + await testCopyFormatsAndEditorShell(); + console.log('\nβœ… Markdown workspace link tests passed!'); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('❌ Test failed:', message); + if (error instanceof Error && error.stack) { + console.error(error.stack); + } + return false; + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runTests().then((success) => { + process.exit(success ? 0 : 1); + }).catch((error) => { + console.error('❌ Unhandled error:', error); + process.exit(1); + }); +} From fc40ddd0d3ce2d70715f6be84050aa0e85c59765 Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Fri, 27 Mar 2026 08:35:37 +0200 Subject: [PATCH 02/22] fix(file-preview): remove gap below short markdown previews, tune content height, reset view on fullscreen exit - Scope min-height to .markdown-workspace--edit only so preview mode shrinks to content - Set --content-height to 920px inline, 89vh in fullscreen - Reset editorView to 'markdown' when exiting fullscreen so inline doesn't stay in raw mode Co-Authored-By: Claude Sonnet 4.6 --- src/ui/file-preview/src/app.ts | 495 +++++++++++++----- .../src/markdown-workspace/editor.ts | 5 +- .../workspace-controller.ts | 8 - src/ui/styles/apps/file-preview.css | 208 ++++++-- src/ui/styles/base.css | 2 +- test/test-markdown-workspace-links.js | 9 +- 6 files changed, 545 insertions(+), 182 deletions(-) diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index 16424dbf..19ac0f47 100644 --- a/src/ui/file-preview/src/app.ts +++ b/src/ui/file-preview/src/app.ts @@ -36,12 +36,16 @@ let currentHtmlMode: HtmlPreviewMode = 'rendered'; let currentHostContext: Record | undefined; let rerenderCurrent: (() => void) | undefined; let syncPayload: ((payload?: RenderPayload) => void) | undefined; +let persistPayload: ((payload: RenderPayload) => void) | undefined; let markdownEditorHandle: MarkdownEditorHandle | undefined; let markdownTocHandle: MarkdownTocHandle | undefined; let localPayloadOverride: RenderPayload | undefined; +const markdownEditorAppCache = new Map(); +const markdownEditorAppPending = new Set(); interface MarkdownWorkspaceState { filePath: string; + initialContent: string; sourceContent: string; fullDocumentContent: string; draftContent: string; @@ -56,6 +60,7 @@ interface MarkdownWorkspaceState { editorView: MarkdownEditorView; editorScrollTop: number; saveIndicator: 'idle' | 'saving' | 'saved'; + fileDeleted: boolean; } let markdownWorkspaceState: MarkdownWorkspaceState | undefined; @@ -133,9 +138,7 @@ function isLikelyUrl(filePath: string): boolean { function buildBreadcrumb(filePath: string): string { const normalized = filePath.replace(/\\/g, '/'); const parts = normalized.split('/').filter(Boolean); - // Show last 3-4 meaningful segments as breadcrumb - const tail = parts.slice(-4); - return tail.map(p => escapeHtml(p)).join(' β€Ί '); + return parts.map(p => escapeHtml(p)).join(' β€Ί '); } function getParentDirectory(filePath: string): string { @@ -299,6 +302,11 @@ function buildOpenInEditorCommand(filePath: string): string | undefined { return undefined; } + const cachedApp = markdownEditorAppCache.get(trimmedPath); + if (cachedApp?.appPath && navigator.userAgent.toLowerCase().includes('mac')) { + return `open -a ${shellQuote(cachedApp.appPath)} ${shellQuote(trimmedPath)}`; + } + const userAgent = navigator.userAgent.toLowerCase(); if (userAgent.includes('win')) { const escapedForPowerShell = trimmedPath.replace(/'/g, "''"); @@ -312,6 +320,50 @@ function buildOpenInEditorCommand(filePath: string): string | undefined { return `xdg-open ${shellQuote(trimmedPath)}`; } +async function detectDefaultMarkdownEditor(filePath: string): Promise { + const trimmedPath = filePath.trim(); + if (!trimmedPath || markdownEditorAppCache.has(trimmedPath) || markdownEditorAppPending.has(trimmedPath)) { + return; + } + + const userAgent = navigator.userAgent.toLowerCase(); + if (!userAgent.includes('mac')) { + return; + } + + markdownEditorAppPending.add(trimmedPath); + try { + const detectCommand = `osascript -e ${shellQuote(`set appAlias to default application of (info for POSIX file "${trimmedPath.replace(/"/g, '\\"')}") +return (name of (info for appAlias)) & linefeed & POSIX path of appAlias`)}`; + const detectResult = await rpcCallTool?.('start_process', { + command: detectCommand, + timeout_ms: 12000, + }); + const text = extractToolText(detectResult) ?? ''; + if (!text || text.toLowerCase().includes('error') || text.toLowerCase().includes('execution')) { + return; + } + const lines = text.split('\n').map((line) => line.trim()).filter(Boolean); + const appName = lines[lines.length - 2]?.replace(/\.app$/i, '') ?? ''; + const appPath = lines[lines.length - 1] ?? ''; + if (appName && appPath.startsWith('/')) { + markdownEditorAppCache.set(trimmedPath, { + appName, + appPath, + }); + rerenderCurrent?.(); + } + } catch { + // Fall back to generic editor label. + } finally { + markdownEditorAppPending.delete(trimmedPath); + } +} + +function renderMarkdownEditorAppIcon(): string { + return ''; +} + function renderRawFallback(source: string): string { return `
    ${escapeHtml(source)}
    `; } @@ -372,12 +424,17 @@ function getMarkdownWorkspaceState(payload: RenderPayload): MarkdownWorkspaceSta if (!markdownWorkspaceState || markdownWorkspaceState.filePath !== payload.filePath || markdownWorkspaceState.sourceContent !== cleanedContent) { const outline = extractMarkdownOutline(cleanedContent); + const isPartial = parseReadRange(payload.content)?.isPartial === true; + const prevInitial = markdownWorkspaceState?.filePath === payload.filePath + ? markdownWorkspaceState.initialContent + : undefined; markdownWorkspaceState = { filePath: payload.filePath, + initialContent: prevInitial ?? cleanedContent, sourceContent: cleanedContent, fullDocumentContent: cleanedContent, draftContent: cleanedContent, - mode: 'preview', + mode: isPartial ? 'preview' : 'edit', dirty: false, activeHeadingId: outline[0]?.id ?? null, pendingAnchor: null, @@ -388,6 +445,7 @@ function getMarkdownWorkspaceState(payload: RenderPayload): MarkdownWorkspaceSta editorView: 'markdown', editorScrollTop: 0, saveIndicator: 'idle', + fileDeleted: false, }; } @@ -580,6 +638,10 @@ function attachCopyHandler(payload: RenderPayload): void { ? source : (getRenderedMarkdownCopyText(source) || source); const copied = await copyTextData(textToCopy); + if (copied) { + updateSaveStatusDOM('Copied', 'saved'); + window.setTimeout(() => updateSaveStatusDOM('', ''), 1500); + } setIconButtonState(activeCopyButton, copied ? 'Copied!' : 'Copy failed', 'Copy', 1500); }); } @@ -814,6 +876,41 @@ function applyPendingMarkdownAnchor(): void { } } +async function refreshMarkdownFromDisk(payload: RenderPayload): Promise { + try { + const freshResult = await rpcCallTool?.('read_file', { path: payload.filePath }); + const resultText = extractToolText(freshResult) ?? ''; + + if (resultText.toLowerCase().includes('error') && (resultText.toLowerCase().includes('not found') || resultText.toLowerCase().includes('no such file') || resultText.toLowerCase().includes('enoent'))) { + if (markdownWorkspaceState) { + markdownWorkspaceState.fileDeleted = true; + } + updateSaveStatusDOM('File deleted', 'saved'); + // Disable non-applicable buttons via DOM + const revert = document.getElementById('revert-markdown') as HTMLButtonElement | null; + if (revert) revert.disabled = true; + const openFolder = document.getElementById('open-in-folder') as HTMLButtonElement | null; + if (openFolder) openFolder.disabled = true; + const openEditor = document.getElementById('open-in-editor') as HTMLButtonElement | null; + if (openEditor) openEditor.disabled = true; + return; + } + + const freshPayload = extractRenderPayload(freshResult) ?? null; + if (!freshPayload) return; + const freshContent = stripReadStatusLine(freshPayload.content); + const currentContent = stripReadStatusLine(payload.content); + if (freshContent === currentContent) return; + + syncPayload?.(freshPayload); + localPayloadOverride = freshPayload; + markdownWorkspaceState = undefined; + rerenderCurrent?.(); + } catch { + // Silently fall back to host payload + } +} + async function readMarkdownPayload(filePath: string, length?: number): Promise { const result = await rpcCallTool?.('read_file', { path: filePath, @@ -904,6 +1001,14 @@ async function navigateMarkdownLink(payload: RenderPayload, href: string): Promi } if (resolvedLink.kind === 'file' && resolvedLink.targetPath) { + // Delegate file navigation to the host so it can open the file in its own + // viewer (e.g. dc-app file preview modal with back/forward navigation). + // Fall back to in-app reading if the host doesn't handle the link. + const hostHandled = await openExternalLink?.(resolvedLink.targetPath); + if (hostHandled) { + return; + } + const nextPayload = await readMarkdownPayload(resolvedLink.targetPath); if (!nextPayload) { if (markdownWorkspaceState) { @@ -924,25 +1029,9 @@ async function navigateMarkdownLink(payload: RenderPayload, href: string): Promi async function requestMarkdownEditMode(payload: RenderPayload): Promise { const workspaceState = getMarkdownWorkspaceState(payload); - const fullscreenAvailability = getMarkdownFullscreenAvailability({ - availableDisplayModes: getAvailableDisplayModes(), - }); - - if (!fullscreenAvailability.canFullscreen) { - workspaceState.error = fullscreenAvailability.reason; - workspaceState.notice = null; - rerenderCurrent?.(); - return; - } workspaceState.error = null; workspaceState.notice = null; - const nextMode = await requestDisplayMode?.('fullscreen'); - if (nextMode !== 'fullscreen') { - workspaceState.error = 'Fullscreen mode is unavailable in this host.'; - rerenderCurrent?.(); - return; - } if (shouldAutoLoadMarkdownOnEnterFullscreen(payload.content)) { await loadFullMarkdownDocument(payload, { keepEditMode: true }); @@ -951,7 +1040,6 @@ async function requestMarkdownEditMode(payload: RenderPayload): Promise { const editAvailability = getMarkdownEditAvailability({ content: payload.content, - availableDisplayModes: getAvailableDisplayModes(), }); if (!editAvailability.canEdit) { workspaceState.error = editAvailability.reason; @@ -967,13 +1055,32 @@ async function requestMarkdownEditMode(payload: RenderPayload): Promise { rerenderCurrent?.(); } -function revertMarkdownEditing(payload: RenderPayload): void { - const workspaceState = getMarkdownWorkspaceState(payload); - workspaceState.draftContent = workspaceState.fullDocumentContent; - workspaceState.dirty = false; - workspaceState.error = null; - workspaceState.notice = 'Reverted to the last loaded version.'; +async function requestMarkdownFullscreen(): Promise { + const fullscreenAvailability = getMarkdownFullscreenAvailability({ + availableDisplayModes: getAvailableDisplayModes(), + }); + if (!fullscreenAvailability.canFullscreen) { + return false; + } + const nextMode = await requestDisplayMode?.('fullscreen'); + return nextMode === 'fullscreen'; +} + +function revertMarkdownEditing(): void { + const ws = markdownWorkspaceState; + if (!ws) return; + ws.draftContent = ws.initialContent; + ws.sourceContent = ws.initialContent; + ws.dirty = ws.initialContent !== ws.fullDocumentContent; + ws.error = null; + ws.notice = null; + // Update currentPayload to match so re-render doesn't recreate state + if (currentPayload) { + currentPayload = { ...currentPayload, content: ws.initialContent }; + } rerenderCurrent?.(); + updateSaveStatusDOM('Reverted', 'saved'); + window.setTimeout(() => updateSaveStatusDOM('', ''), 1500); } function cancelMarkdownEditing(payload: RenderPayload): void { @@ -993,90 +1100,198 @@ function cancelMarkdownEditing(payload: RenderPayload): void { rerenderCurrent?.(); } -function isSuccessfulEditResult(result: unknown): boolean { - const message = extractToolText(result); - return typeof message === 'string' && message.startsWith('Successfully applied'); +interface DiffHunk { + oldStart: number; + oldEnd: number; + newStart: number; + newEnd: number; } -async function saveMarkdownDocument(payload: RenderPayload): Promise { - const workspaceState = getMarkdownWorkspaceState(payload); - if (workspaceState.saving || !workspaceState.dirty) { +function computeDiffHunks(oldLines: string[], newLines: string[]): DiffHunk[] { + const m = oldLines.length; + const n = newLines.length; + + // For very large files, treat as single change + if (m * n > 1_000_000) { + return [{ oldStart: 0, oldEnd: m, newStart: 0, newEnd: n }]; + } + + // LCS via DP + const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0) as number[]); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + 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]); + } + } + + // Trace back to find matching line pairs + const matches: Array<[number, number]> = []; + let i = m; + let j = n; + while (i > 0 && j > 0) { + if (oldLines[i - 1] === newLines[j - 1]) { + matches.unshift([i - 1, j - 1]); + i--; + j--; + } else if (dp[i - 1][j] >= dp[i][j - 1]) { + i--; + } else { + j--; + } + } + + // Gaps between matches are change hunks + const hunks: DiffHunk[] = []; + let prevOld = 0; + let prevNew = 0; + for (const [oi, ni] of matches) { + if (oi > prevOld || ni > prevNew) { + hunks.push({ oldStart: prevOld, oldEnd: oi, newStart: prevNew, newEnd: ni }); + } + prevOld = oi + 1; + prevNew = ni + 1; + } + if (prevOld < m || prevNew < n) { + hunks.push({ oldStart: prevOld, oldEnd: m, newStart: prevNew, newEnd: n }); + } + + return hunks; +} + +function mergeCloseHunks(hunks: DiffHunk[], minGap: number): DiffHunk[] { + if (hunks.length <= 1) return hunks; + const merged: DiffHunk[] = [{ ...hunks[0] }]; + for (let i = 1; i < hunks.length; i++) { + const prev = merged[merged.length - 1]; + const curr = hunks[i]; + if (curr.oldStart - prev.oldEnd < minGap) { + prev.oldEnd = curr.oldEnd; + prev.newEnd = curr.newEnd; + } else { + merged.push({ ...curr }); + } + } + return merged; +} + +function computeEditBlocks(oldText: string, newText: string): Array<{ old_string: string; new_string: string }> { + if (oldText === newText) return []; + + const oldLines = oldText.split('\n'); + const newLines = newText.split('\n'); + const hunks = computeDiffHunks(oldLines, newLines); + if (hunks.length === 0) return []; + + const CONTEXT = 3; + const merged = mergeCloseHunks(hunks, CONTEXT * 2 + 1); + + // If changes cover most of the file, single full edit + const totalChanged = merged.reduce((sum, h) => sum + (h.oldEnd - h.oldStart), 0); + if (totalChanged > oldLines.length * 0.7) { + return [{ old_string: oldText, new_string: newText }]; + } + + return merged.map((hunk) => { + const ctxBefore = Math.max(0, hunk.oldStart - CONTEXT); + const ctxAfter = Math.min(oldLines.length, hunk.oldEnd + CONTEXT); + + const oldBlock = oldLines.slice(ctxBefore, ctxAfter).join('\n'); + const newBlock = [ + ...oldLines.slice(ctxBefore, hunk.oldStart), + ...newLines.slice(hunk.newStart, hunk.newEnd), + ...oldLines.slice(hunk.oldEnd, ctxAfter), + ].join('\n'); + + return { old_string: oldBlock, new_string: newBlock }; + }).filter((block) => block.old_string !== block.new_string); +} + +function updateSaveStatusDOM(label: string, statusClass: string): void { + const existing = document.querySelector('.panel-save-status') as HTMLElement | null; + if (label) { + if (existing) { + existing.textContent = label; + existing.className = `panel-save-status panel-save-status--${statusClass}`; + } else { + const actions = document.querySelector('.panel-topbar-actions') as HTMLElement | null; + if (actions) { + const span = document.createElement('span'); + span.className = `panel-save-status panel-save-status--${statusClass}`; + span.textContent = label; + actions.prepend(span); + } + } + } else if (existing) { + existing.remove(); + } +} + +async function saveMarkdownDocument(): Promise { + const ws = markdownWorkspaceState; + if (!ws || ws.saving || !ws.dirty || ws.fileDeleted) { return; } - workspaceState.saving = true; - workspaceState.saveIndicator = 'saving'; - workspaceState.error = null; - workspaceState.notice = null; - rerenderCurrent?.(); + ws.saving = true; + ws.saveIndicator = 'saving'; + ws.error = null; + ws.notice = null; try { - const result = await rpcCallTool?.('edit_block', { - file_path: payload.filePath, - old_string: workspaceState.fullDocumentContent, - new_string: workspaceState.draftContent, - expected_replacements: 1, - }); - - if (!isSuccessfulEditResult(result)) { - workspaceState.saving = false; - workspaceState.saveIndicator = 'idle'; - workspaceState.error = 'File changed on disk. Reload before saving again.'; - rerenderCurrent?.(); + const blocks = computeEditBlocks(ws.fullDocumentContent, ws.draftContent); + if (blocks.length === 0) { + ws.saving = false; + ws.saveIndicator = 'idle'; + ws.dirty = false; return; } - let nextPayload: RenderPayload = { - ...payload, - content: workspaceState.draftContent, - }; - - try { - const refreshedResult = await rpcCallTool?.('read_file', { - path: payload.filePath, - offset: 0, - length: 5000, + for (const block of blocks) { + await rpcCallTool?.('edit_block', { + file_path: ws.filePath, + old_string: block.old_string, + new_string: block.new_string, + expected_replacements: 1, }); - const refreshedPayload = extractRenderPayload(refreshedResult); - if (refreshedPayload) { - nextPayload = refreshedPayload; - } - } catch { - // Fall back to local draft content if refresh fails. } - syncPayload?.(nextPayload); - localPayloadOverride = nextPayload; - const nextState = getMarkdownWorkspaceState(nextPayload); - nextState.mode = 'edit'; - nextState.draftContent = nextState.sourceContent; - nextState.fullDocumentContent = nextState.sourceContent; - nextState.dirty = false; - nextState.saving = false; - nextState.saveIndicator = 'saved'; - nextState.notice = null; - nextState.error = null; - rerenderCurrent?.(); + ws.fullDocumentContent = ws.draftContent; + ws.sourceContent = ws.draftContent; + ws.dirty = false; + ws.saving = false; + ws.saveIndicator = 'saved'; + + // Update payloads so re-renders and refreshes use saved content (no re-render here) + if (currentPayload) { + const savedPayload: RenderPayload = { ...currentPayload, content: ws.draftContent }; + localPayloadOverride = savedPayload; + currentPayload = savedPayload; + persistPayload?.(savedPayload); + } + + updateSaveStatusDOM('Saved', 'saved'); + const revert = document.getElementById('revert-markdown') as HTMLButtonElement | null; + if (revert) revert.disabled = ws.draftContent === ws.initialContent; window.setTimeout(() => { - if (markdownWorkspaceState?.filePath === nextState.filePath && !markdownWorkspaceState.dirty && !markdownWorkspaceState.saving) { + if (markdownWorkspaceState?.filePath === ws.filePath && !markdownWorkspaceState.dirty && !markdownWorkspaceState.saving) { markdownWorkspaceState.saveIndicator = 'idle'; - rerenderCurrent?.(); + updateSaveStatusDOM('', ''); } }, 1800); } catch { - workspaceState.saving = false; - workspaceState.saveIndicator = 'idle'; - workspaceState.error = 'Saving failed. Reload the file and try again.'; - rerenderCurrent?.(); + ws.saving = false; + ws.saveIndicator = 'idle'; + updateSaveStatusDOM('Save failed', 'saving'); + window.setTimeout(() => updateSaveStatusDOM('', ''), 3000); } } -function maybeAutosaveMarkdownDocument(payload: RenderPayload): void { - const workspaceState = getMarkdownWorkspaceState(payload); - if (!workspaceState.dirty || workspaceState.saving) { +function maybeAutosaveMarkdownDocument(): void { + if (!markdownWorkspaceState?.dirty || markdownWorkspaceState.saving) { return; } - - void saveMarkdownDocument(payload); + void saveMarkdownDocument(); } function attachMarkdownWorkspaceHandlers(payload: RenderPayload): void { @@ -1089,10 +1304,6 @@ function attachMarkdownWorkspaceHandlers(payload: RenderPayload): void { const markdownDoc = document.querySelector('.markdown-doc') as HTMLElement | null; const outline = extractMarkdownOutline(workspaceState.sourceContent); - const editButton = document.getElementById('edit-markdown') as HTMLButtonElement | null; - editButton?.addEventListener('click', () => { - void requestMarkdownEditMode(payload); - }); if (workspaceState.mode === 'edit') { const editorRoot = document.getElementById('markdown-editor-root'); @@ -1111,9 +1322,13 @@ function attachMarkdownWorkspaceHandlers(payload: RenderPayload): void { if (workspaceState.dirty && workspaceState.saveIndicator === 'saved') { workspaceState.saveIndicator = 'idle'; } + const revert = document.getElementById('revert-markdown') as HTMLButtonElement | null; + if (revert) { + revert.disabled = value === workspaceState.initialContent; + } }, onBlur: () => { - maybeAutosaveMarkdownDocument(payload); + maybeAutosaveMarkdownDocument(); }, }); markdownEditorHandle.focus(); @@ -1121,7 +1336,12 @@ function attachMarkdownWorkspaceHandlers(payload: RenderPayload): void { const revertButton = document.getElementById('revert-markdown') as HTMLButtonElement | null; revertButton?.addEventListener('click', () => { - revertMarkdownEditing(payload); + revertMarkdownEditing(); + }); + + const expandButton = document.getElementById('expand-fullscreen') as HTMLButtonElement | null; + expandButton?.addEventListener('click', () => { + void requestMarkdownFullscreen(); }); const rawModeButton = document.getElementById('markdown-mode-raw') as HTMLButtonElement | null; @@ -1363,13 +1583,6 @@ export function renderApp( } const markdownWorkspace = payload.fileType === 'markdown' ? getMarkdownWorkspaceState(payload) : undefined; - const markdownEditAvailability = payload.fileType === 'markdown' - ? getMarkdownEditAvailability({ - content: payload.content, - availableDisplayModes: getAvailableDisplayModes(), - }) - : undefined; - const canCopy = payload.fileType !== 'unsupported' && payload.fileType !== 'image'; const canOpenInFolder = !isLikelyUrl(payload.filePath); const fileExtension = getFileExtensionForAnalytics(payload.filePath); @@ -1401,47 +1614,57 @@ export function renderApp( const markdownWordCount = payload.fileType === 'markdown' ? (stripReadStatusLine(markdownWorkspace?.mode === 'edit' ? markdownWorkspace.draftContent : payload.content).trim().split(/\s+/).filter(Boolean).length) : 0; + const markdownLineCount = payload.fileType === 'markdown' + ? countContentLines(stripReadStatusLine(markdownWorkspace?.mode === 'edit' ? markdownWorkspace.draftContent : payload.content)) + : lineCount; if (markdownWorkspace?.mode === 'edit') { - if (markdownWorkspace.saving) { - footerLabel = `${escapeHtml(fileTypeLabel)} β€’ EDIT MODE β€’ ${lineCount} LINES β€’ ${markdownWordCount} WORDS β€’ SAVING`; - } else if (markdownWorkspace.dirty) { - footerLabel = `${escapeHtml(fileTypeLabel)} β€’ EDIT MODE β€’ ${lineCount} LINES β€’ ${markdownWordCount} WORDS β€’ UNSAVED`; - } else { - footerLabel = `${escapeHtml(fileTypeLabel)} β€’ EDIT MODE β€’ ${lineCount} LINES β€’ ${markdownWordCount} WORDS`; - } + footerLabel = `${escapeHtml(fileTypeLabel)} β€’ EDIT MODE β€’ ${markdownLineCount} LINES β€’ ${markdownWordCount} WORDS`; } const htmlToggle = payload.fileType === 'html' ? `` : ''; + const copyIcon = ``; + const folderIcon = ``; + const undoIcon = ``; + const expandIcon = ``; + const isFullscreen = getCurrentDisplayMode() === 'fullscreen'; + const canGoFullscreen = !isFullscreen && getMarkdownFullscreenAvailability({ availableDisplayModes: getAvailableDisplayModes() }).canFullscreen; let markdownActions = ''; if (payload.fileType === 'markdown' && markdownWorkspace) { - const saveStatusLabel = markdownWorkspace.saving - ? 'Saving…' - : markdownWorkspace.saveIndicator === 'saved' - ? 'Saved' - : markdownWorkspace.dirty - ? 'Unsaved' - : ''; + const saveStatusLabel = markdownWorkspace.saveIndicator === 'saved' + ? 'Saved' + : ''; if (markdownWorkspace.mode === 'edit') { - markdownActions = ` - ${saveStatusLabel ? `${saveStatusLabel}` : ''} - ${renderMarkdownModeToggle(markdownWorkspace.editorView)} - ${renderMarkdownCopyButton()} - - `; - } else { - if (getMarkdownFullscreenAvailability({ availableDisplayModes: getAvailableDisplayModes() }).canFullscreen) { - markdownActions += ''; + const deleted = markdownWorkspace.fileDeleted; + const revertDisabled = deleted || markdownWorkspace.loadingDocument || markdownWorkspace.draftContent === markdownWorkspace.initialContent; + if (isFullscreen) { + markdownActions = ` + ${deleted ? 'File deleted' : ''} + ${!deleted && saveStatusLabel ? `${saveStatusLabel}` : ''} + ${renderMarkdownModeToggle(markdownWorkspace.editorView)} + ${renderMarkdownCopyButton()} + + `; + } else { + markdownActions = ` + ${deleted ? 'File deleted' : ''} + ${!deleted && saveStatusLabel ? `${saveStatusLabel}` : ''} + ${canGoFullscreen ? `` : ''} + + + `; } } } - const copyIcon = ``; - const folderIcon = ``; const editorIcon = ``; + const defaultMarkdownEditor = payload.fileType === 'markdown' ? markdownEditorAppCache.get(payload.filePath) : undefined; + if (payload.fileType === 'markdown' && !defaultMarkdownEditor) { + void detectDefaultMarkdownEditor(payload.filePath); + } // Content-area banners for missing lines const hasMissingBefore = range?.isPartial && range.fromLine > 1; @@ -1453,18 +1676,19 @@ export function renderApp( ? `` : ''; - const effectiveExpanded = isExpanded || getCurrentDisplayMode() === 'fullscreen' || markdownWorkspace?.mode === 'edit'; + const effectiveExpanded = isExpanded || getCurrentDisplayMode() === 'fullscreen'; container.innerHTML = ` -
    - ${markdownWorkspace?.mode === 'edit' || getCurrentDisplayMode() === 'fullscreen' ? '' : renderCompactRow({ id: 'compact-toggle', label: compactLabel, filename: payload.fileName, variant: 'ready', expandable: true, expanded: isExpanded, interactive: true })} +
    + ${getCurrentDisplayMode() === 'fullscreen' ? '' : renderCompactRow({ id: 'compact-toggle', label: compactLabel, filename: payload.fileName, variant: 'ready', expandable: true, expanded: isExpanded, interactive: true })}
    - ${breadcrumb} + ${hideSummaryRow ? '' : `${breadcrumb}`} ${markdownActions} ${htmlToggle} - ${canOpenInFolder && payload.fileType === 'markdown' && markdownWorkspace?.mode === 'edit' ? `` : ''} + ${canOpenInFolder && payload.fileType === 'markdown' && markdownWorkspace?.mode === 'edit' && isFullscreen ? `` : ''} + ${canOpenInFolder && payload.fileType === 'markdown' && markdownWorkspace?.mode === 'edit' && !isFullscreen ? `` : ''} ${canOpenInFolder && !(payload.fileType === 'markdown' && markdownWorkspace?.mode === 'edit') ? `` : ''} ${canCopy && supportsPreview && payload.fileType !== 'markdown' ? `` : ''} @@ -1573,6 +1797,7 @@ export function bootstrapApp(): void { renderAndSync(persistedPayload); }; syncPayload = renderAndSync; + persistPayload = (payload: RenderPayload) => { widgetState.write(payload); }; rerenderCurrent = () => { renderApp(container, currentPayload, currentHtmlMode, isExpanded); }; @@ -1588,6 +1813,10 @@ export function bootstrapApp(): void { if (payload.fileType === 'markdown' && getCurrentDisplayMode() === 'fullscreen') { void requestMarkdownEditMode(payload); } + // Re-read markdown from disk to pick up any saves from a previous session + if (payload.fileType === 'markdown') { + void refreshMarkdownFromDisk(payload); + } return; } renderStatusState(container, message ?? 'No preview available for this response.'); @@ -1689,8 +1918,8 @@ export function bootstrapApp(): void { isExpanded = true; chrome.expanded = true; if (markdownWorkspaceState) { - markdownWorkspaceState.mode = 'preview'; markdownWorkspaceState.notice = null; + markdownWorkspaceState.editorView = 'markdown'; } } if (initialStateResolved) { diff --git a/src/ui/file-preview/src/markdown-workspace/editor.ts b/src/ui/file-preview/src/markdown-workspace/editor.ts index 317f3c1a..f3778ea3 100644 --- a/src/ui/file-preview/src/markdown-workspace/editor.ts +++ b/src/ui/file-preview/src/markdown-workspace/editor.ts @@ -54,7 +54,7 @@ function renderModeToggleIcon(view: MarkdownEditorView): string { } export function renderMarkdownCopyButton(): string { - return ``; + return ``; } export function renderMarkdownModeToggle(view: MarkdownEditorView): string { @@ -413,6 +413,9 @@ export function mountMarkdownEditor(options: { if (nextTarget && (shell?.contains(nextTarget) || widgetShell?.contains(nextTarget))) { return; } + if (contextMenu) { + contextMenu.hidden = true; + } options.onBlur?.(); }; diff --git a/src/ui/file-preview/src/markdown-workspace/workspace-controller.ts b/src/ui/file-preview/src/markdown-workspace/workspace-controller.ts index 25f875f7..8842f762 100644 --- a/src/ui/file-preview/src/markdown-workspace/workspace-controller.ts +++ b/src/ui/file-preview/src/markdown-workspace/workspace-controller.ts @@ -28,7 +28,6 @@ export function parseReadRange(content: string): ReadRange | undefined { export function getMarkdownEditAvailability(options: { content: string; - availableDisplayModes?: string[]; }): { canEdit: true } | { canEdit: false; reason: string } { const readRange = parseReadRange(options.content); if (readRange?.isPartial) { @@ -38,13 +37,6 @@ export function getMarkdownEditAvailability(options: { }; } - if (!options.availableDisplayModes?.includes('fullscreen')) { - return { - canEdit: false, - reason: 'Fullscreen editing is unavailable in this host.', - }; - } - return { canEdit: true }; } diff --git a/src/ui/styles/apps/file-preview.css b/src/ui/styles/apps/file-preview.css index fc27dacd..56aed431 100644 --- a/src/ui/styles/apps/file-preview.css +++ b/src/ui/styles/apps/file-preview.css @@ -12,7 +12,7 @@ --hljs-attr: var(--color-text-info, light-dark(#2563eb, #60a5fa)); --hljs-built-in: light-dark(#6366f1, #818cf8); --hljs-tag: var(--color-text-info, light-dark(#0ea5a8, #67e8f9)); - --content-height: min(82vh, 920px); + --content-height: 920px; --markdown-text: var(--text); --markdown-muted: var(--text-secondary); --inline-code-bg: var(--panel-subtle); @@ -21,6 +21,7 @@ --notice-bg: var(--panel-subtle); --notice-border: var(--border); --notice-text: var(--text-secondary); + --toc-rail-width: 250px; } /* ── Panel (Claude-style card) ── */ @@ -40,6 +41,45 @@ /* When the host hides the summary row (hideSummaryRow: true), it means the host provides its own outer frame/card. Strip inner chrome so only content remains. */ +html:has(.fullscreen) { + height: 100%; +} + +html:has(.fullscreen) body { + height: 100%; + display: flex; + flex-direction: column; +} + +html:has(.fullscreen) #app { + flex: 1; + display: flex; + flex-direction: column; +} + +.tool-shell.fullscreen { + flex: 1; + display: flex; + flex-direction: column; +} + +.tool-shell.fullscreen .panel { + margin-top: 0; + border: none; + border-radius: 0; + flex: 1; + display: flex; + flex-direction: column; +} + +.tool-shell.fullscreen { + --content-height: 89vh; +} + +.tool-shell.fullscreen .panel-content-wrapper { + flex: 1; +} + .tool-shell.host-framed .compact-row { display: none; } .tool-shell.host-framed .panel { margin-top: 0; @@ -47,8 +87,30 @@ border-radius: 0; background: transparent; } -.tool-shell.host-framed .panel-topbar { display: none; } +.tool-shell.host-framed .panel-breadcrumb { display: none; } .tool-shell.host-framed .panel-footer { display: none; } +.tool-shell.host-framed .panel { position: relative; } +.tool-shell.host-framed .panel-topbar { + position: absolute; + top: 0; + right: 0; + z-index: 10; + width: auto; + opacity: 0; + transition: opacity 0.2s ease 1.5s; + pointer-events: none; + padding: 4px 8px; + gap: 6px; + background: var(--color-bg-primary, #fff); + border-radius: 0 0 0 var(--border-radius-md, 6px); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); +} +.tool-shell.host-framed .panel:hover .panel-topbar, +.tool-shell.host-framed .panel:focus-within .panel-topbar { + opacity: 1; + pointer-events: auto; + transition: opacity 0.2s ease; +} .tool-shell.host-framed .panel-content-wrapper { max-height: none; overflow: hidden; } .tool-shell.host-framed .image-content { background: transparent; } @@ -61,16 +123,19 @@ gap: 12px; padding: 10px 16px; border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent); + overflow: visible; } .panel-breadcrumb { font-size: 13px; color: var(--text-secondary); + direction: rtl; + text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - flex: 1 1 0; - width: 0; + flex: 0 1 auto; + max-width: 50%; min-width: 0; } @@ -126,6 +191,34 @@ } .panel-action svg { flex-shrink: 0; opacity: 0.7; } + +.panel-action[title] { + position: relative; +} + +.panel-action[title]::after { + content: attr(title); + position: absolute; + bottom: -28px; + left: 50%; + transform: translateX(-50%); + padding: 3px 8px; + border-radius: 6px; + background: var(--panel); + color: var(--text); + font-size: 11px; + font-weight: 400; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease; + z-index: 10; + box-shadow: 0 2px 8px rgba(0,0,0,0.25); +} + +.panel-action[title]:hover::after { + opacity: 1; +} .panel-action:hover svg { opacity: 1; } /* ── Footer ── */ @@ -190,10 +283,23 @@ .markdown-workspace { display: flex; - min-height: min(70vh, 880px); background: transparent; } +.markdown-workspace--edit { + min-height: min(70vh, 880px); +} + +.markdown-workspace--with-toc { + background: linear-gradient( + to right, + color-mix(in srgb, var(--panel-subtle) 90%, transparent) 0, + color-mix(in srgb, var(--panel-subtle) 90%, transparent) var(--toc-rail-width), + transparent var(--toc-rail-width), + transparent 100% + ); +} + .markdown-workspace--with-toc { align-items: stretch; } @@ -201,6 +307,7 @@ .markdown-workspace-main { flex: 1 1 auto; min-width: 0; + padding-left: 18px; } .markdown-workspace-main--editor { @@ -222,7 +329,7 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: center; gap: 4px; - padding: 3px; + padding: 3px 4.5px 3px 3px; border-radius: 999px; background: color-mix(in srgb, var(--panel-subtle) 72%, transparent); border: 1px solid color-mix(in srgb, var(--border) 45%, transparent); @@ -233,16 +340,16 @@ top: 3px; bottom: 3px; left: 3px; - width: calc(50% - 2px); + width: calc(50% - 3.5px); border-radius: 999px; background: color-mix(in srgb, var(--panel) 96%, transparent); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); - transition: transform 180ms ease; + transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 220ms ease, background-color 220ms ease; pointer-events: none; } .markdown-editor-mode-toggle-indicator--markdown { - transform: translateX(calc(100% + 1px)); + transform: translateX(calc(100% + 2.5px)); } .markdown-editor-mode-option { @@ -262,25 +369,34 @@ font: inherit; font-size: 12px; cursor: pointer; - transition: color 150ms ease; + transition: color 180ms ease, opacity 180ms ease, transform 220ms cubic-bezier(0.22, 1, 0.36, 1); } .markdown-editor-mode-option.is-active { color: var(--text); + transform: translateY(-0.5px); } .markdown-editor-mode-option span { white-space: nowrap; + transition: opacity 180ms ease; +} + +.markdown-editor-mode-option:not(.is-active) { + opacity: 0.82; +} + +.markdown-editor-mode-option.is-active svg, +.markdown-editor-mode-option.is-active span { + opacity: 1; } .markdown-editor-pane { display: flex; flex-direction: column; min-height: min(70vh, 840px); - border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); - border-radius: 16px; - overflow: hidden; - background: color-mix(in srgb, var(--panel) 92%, transparent); + overflow: visible; + background: transparent; } .markdown-editor-pane--raw { @@ -500,24 +616,31 @@ .markdown-editor-copy-button { display: inline-flex; align-items: center; - justify-content: center; - min-width: 44px; - height: 32px; - border: 1px solid color-mix(in srgb, var(--border) 55%, transparent); - border-radius: 10px; - background: color-mix(in srgb, var(--panel) 92%, transparent); - color: var(--text-secondary); + gap: 5px; + background: none; + border: none; + border-radius: 8px; + padding: 5px 12px; + font-size: 13px; + font-weight: 500; + color: var(--muted); cursor: pointer; + white-space: nowrap; + transition: color 150ms ease, background 150ms ease; + line-height: 1.4; + font-family: inherit; +} + +.markdown-editor-copy-button span { + white-space: nowrap; } .markdown-editor-copy-button:hover { color: var(--text); - background: color-mix(in srgb, var(--panel-subtle) 82%, transparent); } .markdown-editor-copy-button[data-status="Copied!"] { color: var(--text); - background: color-mix(in srgb, var(--panel-subtle) 82%, var(--border) 18%); } .markdown-editor-copy-button[data-status="Copy failed"] { @@ -525,14 +648,15 @@ } .panel-save-status { - display: inline-flex; - align-items: center; - min-height: 28px; padding: 4px 10px; border-radius: 999px; font-size: 12px; + font-weight: 400; color: var(--muted); - background: color-mix(in srgb, var(--panel-subtle) 70%, transparent); + background: none; + white-space: nowrap; + pointer-events: none; + line-height: 1; } .panel-save-status--saving { @@ -540,7 +664,8 @@ } .panel-save-status--saved { - color: color-mix(in srgb, var(--text) 82%, var(--muted) 18%); + color: var(--muted); + opacity: 0.7; } .panel-save-status--pending { @@ -552,10 +677,10 @@ } .markdown-toc-shell { - flex: 0 0 220px; - width: 220px; - border-right: 1px solid color-mix(in srgb, var(--border) 45%, transparent); - background: color-mix(in srgb, var(--panel-subtle) 82%, transparent); + flex: 0 0 var(--toc-rail-width); + width: var(--toc-rail-width); + border-right: 1px solid color-mix(in srgb, var(--border) 18%, transparent); + background: transparent; padding: 18px 14px 18px 16px; display: flex; flex-direction: column; @@ -647,7 +772,7 @@ } .markdown-editor-textarea:focus { - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-secondary) 18%, transparent); + box-shadow: none; } .image-content { @@ -931,16 +1056,33 @@ @media (max-width: 720px) { .markdown-workspace { flex-direction: column; + } + .markdown-workspace--edit { min-height: auto; } + .markdown-workspace--with-toc { + background: transparent; + } .markdown-editor-shell { padding: 12px; } .markdown-toc-shell { + position: static; + top: auto; + max-height: none; + overflow: visible; + align-self: stretch; width: 100%; + flex-basis: auto; border-right: none; border-bottom: 1px solid color-mix(in srgb, var(--border) 45%, transparent); } + .markdown-workspace-main { + padding-left: 0; + } + .markdown-toc-nav { + max-height: 40vh; + } .markdown-editor-root { padding: 0; } diff --git a/src/ui/styles/base.css b/src/ui/styles/base.css index 1b7277ac..5b20bcfc 100644 --- a/src/ui/styles/base.css +++ b/src/ui/styles/base.css @@ -50,7 +50,7 @@ body { body.dc-ready { max-height: none; - overflow: visible; + overflow: clip; } #app { diff --git a/test/test-markdown-workspace-links.js b/test/test-markdown-workspace-links.js index 67b736cf..680aefe0 100644 --- a/test/test-markdown-workspace-links.js +++ b/test/test-markdown-workspace-links.js @@ -122,7 +122,6 @@ async function testEditAvailability() { assert.deepStrictEqual( getMarkdownEditAvailability({ content: '# Ready', - availableDisplayModes: ['inline', 'fullscreen'], }), { canEdit: true }, ); @@ -130,20 +129,18 @@ async function testEditAvailability() { assert.deepStrictEqual( getMarkdownEditAvailability({ content: '[Reading 10 lines from start (total: 20 lines, 10 remaining)]\n# Partial', - availableDisplayModes: ['inline', 'fullscreen'], }), { canEdit: false, reason: 'Load the full document before editing.' }, ); assert.deepStrictEqual( getMarkdownEditAvailability({ - content: '# No fullscreen', - availableDisplayModes: ['inline'], + content: '# Inline only', }), - { canEdit: false, reason: 'Fullscreen editing is unavailable in this host.' }, + { canEdit: true }, ); - console.log('βœ“ edit mode is gated by full-content availability and fullscreen support'); + console.log('βœ“ edit mode is gated by full-content availability'); } async function testFullscreenWorkspaceHelpers() { From cf10d0ee1bfc0b857e3fa16b4b6ede73818d7622 Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Tue, 7 Apr 2026 16:28:17 +0300 Subject: [PATCH 03/22] fix(file-preview): make markdown links clickable in edit mode and stop stale state leaking across files Three related fixes for the markdown workspace inside the file preview: 1. Markdown link clicks were dead in edit mode. The old handler queried `.markdown-doc` synchronously, but in edit mode that element is created lazily inside `mountMarkdownEditor`, so the query returned null and the handler never attached. Switch to event delegation on the stable `.panel-content-wrapper` and scope to `.markdown-doc` via `closest`. The Cmd/Ctrl gate is no longer needed because preventDefault plus delegation handles contentEditable cleanly. 2. The 120ms artificial delay before the first render was a leftover from a removed cache-restore race. Render fresh `tool_result` payloads immediately so opens stop flashing a loading state. 3. Sidebar file switches showed the previous file's content because the sessionStorage cache (shared across all preview iframes that sit on the same parent origin, e.g. dc-app) was eagerly restored on init and beat the host's fresh `tool_result`. Replace the eager restore with a targeted `ontoolinput` match: stash the cached payload at `onConnected`, then in `ontoolinput` only render it if the host's announced file path matches the cache. Fresh `tool_result` always wins. Reopens of the same document still feel instant; switches to a different file no longer flash stale content. The 8s "Preview unavailable" fallback continues to surface an error if the host never re-sends `tool_result`. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/file-preview/src/app.ts | 61 +++++++++++++++++++++------------- src/ui/shared/widget-state.ts | 13 ++++++-- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index dae364a1..35221d09 100644 --- a/src/ui/file-preview/src/app.ts +++ b/src/ui/file-preview/src/app.ts @@ -162,7 +162,7 @@ function getAncestorDirectories(filePath: string): string[] { return ancestors; } -function parseDirectoryEntries(text: string): string[] { +function splitListingLines(text: string): string[] { return text.split('\n').map((line) => line.trim()).filter(Boolean); } @@ -198,7 +198,7 @@ async function resolveMarkdownLinkSearchRoot(filePath: string): Promise try { const result = await rpcCallTool?.('list_directory', { path: ancestor, depth: 1 }); const text = extractToolText(result) ?? ''; - const entries = parseDirectoryEntries(text); + const entries = splitListingLines(text); if (markers.some((marker) => entries.some((entry) => entry.includes(marker)))) { return ancestor; } @@ -1526,7 +1526,6 @@ function attachMarkdownWorkspaceHandlers(payload: RenderPayload): void { const workspaceState = getMarkdownWorkspaceState(payload); const wrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; - const markdownDoc = document.querySelector('.markdown-doc') as HTMLElement | null; const outline = extractMarkdownOutline(workspaceState.sourceContent); @@ -1580,20 +1579,16 @@ function attachMarkdownWorkspaceHandlers(payload: RenderPayload): void { }); } - if (markdownDoc) { - markdownDoc.addEventListener('click', (event) => { + if (wrapper) { + wrapper.addEventListener('click', (event) => { const target = event.target as HTMLElement | null; const link = target?.closest('a[href]'); - const href = link?.getAttribute('href'); - if (!href) { + if (!link || !link.closest('.markdown-doc')) { return; } - - if (workspaceState.mode === 'edit' && workspaceState.editorView === 'markdown') { - const mouseEvent = event as MouseEvent; - if (!(mouseEvent.metaKey || mouseEvent.ctrlKey)) { - return; - } + const href = link.getAttribute('href'); + if (!href) { + return; } event.preventDefault(); @@ -2049,6 +2044,11 @@ export function bootstrapApp(): void { renderApp(container, currentPayload, currentHtmlMode, isExpanded); }; + // Cached payload from a previous session, stashed at onConnected. Used when + // the host's ontoolinput announces the same file path so we can show the + // last-known content instantly instead of flashing a loading state on reopen. + // Fresh tool_result still wins and replaces the cached render when it arrives. + let pendingCachedPayload: RenderPayload | undefined; let initialStateResolved = false; const resolveInitialState = (payload?: RenderPayload, message?: string): void => { if (initialStateResolved) { @@ -2113,21 +2113,36 @@ export function bootstrapApp(): void { return {}; }; - app.ontoolinput = (_params) => { + app.ontoolinput = (params) => { + // If we have a cached payload from a previous session for the file the + // host is now asking us to preview, render it immediately so reopening + // the same document feels instant. Fresh tool_result will replace it. + const requestedPath = typeof params.arguments?.path === 'string' ? params.arguments.path : undefined; + if ( + !initialStateResolved + && pendingCachedPayload + && requestedPath + && pendingCachedPayload.filePath === requestedPath + ) { + const cached = pendingCachedPayload; + pendingCachedPayload = undefined; + resolveInitialState(cached); + return; + } + // Tool is executing – show loading state renderLoadingState(container); onRender?.(); }; app.ontoolresult = (result) => { + // Fresh data wins; discard any cache hint we held for the optimistic render path. + pendingCachedPayload = undefined; const payload = extractRenderPayload(result); const message = extractToolText(result as unknown as Record); if (!initialStateResolved) { if (payload) { - const effectivePayload = getEffectiveIncomingPayload(payload); - renderLoadingState(container); - onRender?.(); - window.setTimeout(() => resolveInitialState(effectivePayload), 120); + resolveInitialState(getEffectiveIncomingPayload(payload)); return; } if (message) { @@ -2175,11 +2190,11 @@ export function bootstrapApp(): void { }, onConnected: () => { currentHostContext = app.getHostContext() as Record | undefined; - // Try to restore from persisted widget state (survives refresh on some hosts) - const cachedPayload = widgetState.read(); - if (cachedPayload) { - window.setTimeout(() => resolveInitialState(cachedPayload), 50); - } + // Stash any persisted payload so ontoolinput can show it instantly + // when the host announces the same file path. Fresh tool_result still + // wins. If the host never sends ontoolresult, the 8s fallback below + // surfaces an error so the user doesn't see stale or empty content. + pendingCachedPayload = widgetState.read() ?? undefined; // Fallback: if no tool data arrives, show a helpful status message window.setTimeout(() => { diff --git a/src/ui/shared/widget-state.ts b/src/ui/shared/widget-state.ts index 5550e0b3..68c410fc 100644 --- a/src/ui/shared/widget-state.ts +++ b/src/ui/shared/widget-state.ts @@ -23,15 +23,22 @@ const FALLBACK_WIDGET_STATE_KEY = 'desktop-commander:file-preview:widget-state'; * Check if we're running in ChatGPT (has special widget state API) */ export function isChatGPT(): boolean { - return typeof window !== 'undefined' && + return typeof window !== 'undefined' && typeof (window as any).openai?.setWidgetState === 'function'; } /** * Create a widget state storage adapter. - * + * * On ChatGPT: Uses window.openai.widgetState for persistence - * On other hosts: Returns no-op adapter (state comes from ui/notifications/tool-result) + * On other hosts: Uses sessionStorage as a fallback so the preview can survive + * transient interruptions (page refresh on hosts that don't re-send tool_result, + * visibility/focus loss, etc.). + * + * Note: when iframes share a parent origin (e.g. dc-app's same-origin sandbox), + * they all read/write the same sessionStorage key. The init-time read in app.ts + * must therefore defer to fresh tool_result before falling back to the cache, + * otherwise stale state can leak across file switches. */ export function createWidgetStateStorage( validator?: (state: unknown) => boolean From 670ea7908489f1383041111fad113c21ac96c89a Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Wed, 8 Apr 2026 09:36:40 +0300 Subject: [PATCH 04/22] chore: drop unrelated package-lock churn --- package-lock.json | 54 +++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index faded638..a5bfd8ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2494,7 +2494,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2875,6 +2874,7 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", + "peer": true, "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -2889,7 +2889,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2937,7 +2936,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3224,7 +3222,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/array-union": { "version": "2.1.0", @@ -3486,6 +3485,7 @@ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "license": "MIT", + "peer": true, "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", @@ -3510,6 +3510,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -3519,6 +3520,7 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -3530,13 +3532,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/body-parser/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", + "peer": true, "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", @@ -3588,7 +3592,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4301,7 +4304,8 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/core-util-is": { "version": "1.0.3", @@ -4879,6 +4883,7 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -4910,8 +4915,7 @@ "version": "0.0.1534754", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/diff": { "version": "4.0.2", @@ -5610,6 +5614,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -5671,6 +5676,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -5679,7 +5685,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ext-list": { "version": "2.2.2", @@ -5968,6 +5975,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -5986,6 +5994,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -5994,7 +6003,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/find-up": { "version": "4.1.0", @@ -6090,6 +6100,7 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -7551,7 +7562,6 @@ "resolved": "https://registry.npmjs.org/listr/-/listr-0.14.3.tgz", "integrity": "sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA==", "license": "MIT", - "peer": true, "dependencies": { "@samverschueren/stream-to-observable": "^0.3.0", "is-observable": "^1.1.0", @@ -8367,6 +8377,7 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -8376,6 +8387,7 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -8412,6 +8424,7 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -8998,6 +9011,7 @@ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "license": "MIT", + "peer": true, "bin": { "mime": "cli.js" }, @@ -9189,6 +9203,7 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -9971,7 +9986,8 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/path-type": { "version": "4.0.0", @@ -10924,6 +10940,7 @@ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -10948,6 +10965,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -10956,7 +10974,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/serialize-javascript": { "version": "6.0.2", @@ -11064,6 +11083,7 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", + "peer": true, "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", @@ -12234,6 +12254,7 @@ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", + "peer": true, "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -12269,7 +12290,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12552,6 +12572,7 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -12651,7 +12672,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -12701,7 +12721,6 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -13214,7 +13233,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From ee0f9b1b21769f731c0d081772548bb4d67e73b0 Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Thu, 9 Apr 2026 19:40:12 +0300 Subject: [PATCH 05/22] refactor(file-preview): restructure markdown editor, fix toolbar consistency and partial read handling Major restructuring of the file preview UI: - Refactor markdown workspace into modular files under markdown/ directory - Extract document-layout, panel-actions, file-type-handlers, model, and payload-utils into standalone modules - Unify toolbar into a single consistent bar with conditional button visibility instead of separate code paths for markdown vs non-markdown files - Make all action bar buttons icon-only with tooltips for consistency - Fix button ordering: copy before open-in-folder across all file types Fix partial markdown read handling: - Partial reads show preview mode with expand/copy/open-in-folder buttons - Expanding to fullscreen loads full document and enters edit mode - Exiting fullscreen restores the original line range view - Re-reads the same line range from disk on exit so edits are reflected - refreshFromDisk now respects the original line range instead of always loading the full file (which was overwriting partial reads) Fix fullscreen/inline transitions: - Entering fullscreen from partial read triggers requestEditMode to load full document - Exiting fullscreen from full file read preserves saved content instead of reverting to original tool result - refreshFromDisk on initial load ensures content reflects disk state after page refresh for both partial and full reads Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 589 ++--- scripts/build-ui-runtime.cjs | 1 - src/ui/file-preview/src/app.ts | 2136 ++--------------- .../src/components/markdown-renderer.ts | 62 +- src/ui/file-preview/src/components/toolbar.ts | 95 - .../file-preview/src/directory-controller.ts | 272 +++ src/ui/file-preview/src/document-layout.ts | 140 ++ .../toc.ts => document-outline.ts} | 58 +- ...ce-controller.ts => document-workspace.ts} | 6 +- src/ui/file-preview/src/file-type-handlers.ts | 123 + .../file-preview/src/host/external-actions.ts | 117 + .../src/host/selection-context.ts | 131 + .../src/markdown-workspace/outline.ts | 81 - .../file-preview/src/markdown/controller.ts | 942 ++++++++ .../editor.ts | 268 ++- .../linking.ts | 100 +- src/ui/file-preview/src/markdown/outline.ts | 21 + src/ui/file-preview/src/markdown/parser.ts | 86 + .../preview.ts | 7 +- .../slugify.ts | 14 +- src/ui/file-preview/src/markdown/utils.ts | 17 + src/ui/file-preview/src/model.ts | 37 + src/ui/file-preview/src/panel-actions.ts | 216 ++ src/ui/file-preview/src/path-utils.ts | 81 + src/ui/file-preview/src/payload-utils.ts | 102 + src/ui/shared/tool-header.ts | 35 - src/ui/shared/widget-state.ts | 90 +- src/ui/styles/apps/file-preview.css | 4 +- src/ui/styles/base.css | 1 + src/ui/styles/components/tool-header.css | 204 -- test/test-file-handlers.js | 2 +- test/test-file-preview-directory-runtime.js | 50 + test/test-markdown-preview.js | 592 +++++ test/test-markdown-workspace-links.js | 257 -- test/test-widget-state-runtime.js | 93 + 35 files changed, 3944 insertions(+), 3086 deletions(-) delete mode 100644 src/ui/file-preview/src/components/toolbar.ts create mode 100644 src/ui/file-preview/src/directory-controller.ts create mode 100644 src/ui/file-preview/src/document-layout.ts rename src/ui/file-preview/src/{markdown-workspace/toc.ts => document-outline.ts} (55%) rename src/ui/file-preview/src/{markdown-workspace/workspace-controller.ts => document-workspace.ts} (89%) create mode 100644 src/ui/file-preview/src/file-type-handlers.ts create mode 100644 src/ui/file-preview/src/host/external-actions.ts create mode 100644 src/ui/file-preview/src/host/selection-context.ts delete mode 100644 src/ui/file-preview/src/markdown-workspace/outline.ts create mode 100644 src/ui/file-preview/src/markdown/controller.ts rename src/ui/file-preview/src/{markdown-workspace => markdown}/editor.ts (75%) rename src/ui/file-preview/src/{markdown-workspace => markdown}/linking.ts (73%) create mode 100644 src/ui/file-preview/src/markdown/outline.ts create mode 100644 src/ui/file-preview/src/markdown/parser.ts rename src/ui/file-preview/src/{markdown-workspace => markdown}/preview.ts (83%) rename src/ui/file-preview/src/{markdown-workspace => markdown}/slugify.ts (65%) create mode 100644 src/ui/file-preview/src/markdown/utils.ts create mode 100644 src/ui/file-preview/src/model.ts create mode 100644 src/ui/file-preview/src/panel-actions.ts create mode 100644 src/ui/file-preview/src/path-utils.ts create mode 100644 src/ui/file-preview/src/payload-utils.ts delete mode 100644 src/ui/shared/tool-header.ts delete mode 100644 src/ui/styles/components/tool-header.css create mode 100644 test/test-file-preview-directory-runtime.js create mode 100644 test/test-markdown-preview.js delete mode 100644 test/test-markdown-workspace-links.js create mode 100644 test/test-widget-state-runtime.js diff --git a/README.md b/README.md index c7b0df94..7ec36562 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,22 @@ # Desktop Commander MCP -### Search, update, manage files and run terminal commands with AI - -[![npm downloads](https://img.shields.io/npm/dw/@wonderwhy-er/desktop-commander)](https://www.npmjs.com/package/@wonderwhy-er/desktop-commander) -[![AgentAudit Verified](https://agentaudit.dev/api/badge/desktop-commander)](https://agentaudit.dev/skills/desktop-commander) -[![Trust Score](https://archestra.ai/mcp-catalog/api/badge/quality/wonderwhy-er/DesktopCommanderMCP)](https://archestra.ai/mcp-catalog/wonderwhy-er__desktopcommandermcp) -[![smithery badge](https://smithery.ai/badge/@wonderwhy-er/desktop-commander)](https://smithery.ai/server/@wonderwhy-er/desktop-commander) -[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support-yellow.svg)](https://www.buymeacoffee.com/wonderwhyer) +### Search, update, manage files and run terminal commands with AI -[![Discord](https://img.shields.io/badge/Join%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/kQ27sNnZr7) +[https://www.npmjs.com/package/@wonderwhy-er/desktop-commander](https://www.npmjs.com/package/@wonderwhy-er/desktop-commander) +[https://agentaudit.dev/skills/desktop-commander](https://agentaudit.dev/skills/desktop-commander) +[https://archestra.ai/mcp-catalog/wonderwhy-er__desktopcommandermcp](https://archestra.ai/mcp-catalog/wonderwhy-er__desktopcommandermcp) +[https://smithery.ai/server/@wonderwhy-er/desktop-commander](https://smithery.ai/server/@wonderwhy-er/desktop-commander) +[https://www.buymeacoffee.com/wonderwhyer](https://www.buymeacoffee.com/wonderwhyer) +[https://discord.gg/kQ27sNnZr7](https://discord.gg/kQ27sNnZr7) Work with code and text, run processes, and automate tasks, going far beyond other AI editors - while using host client subscriptions instead of API token costs. - - Desktop Commander MCP + +Desktop Commander MCP -## πŸ‘‹ We’re hiring β€” come build with us: https://desktopcommander.app/careers/ +## πŸ‘‹ We’re hiring β€” come build with us: [https://desktopcommander.app/careers/](https://desktopcommander.app/careers/) ## πŸ–₯️ Try the Desktop Commander App (Beta) @@ -33,6 +32,7 @@ Work with code and text, run processes, and automate tasks, going far beyond oth > The MCP server below still works great with Claude Desktop and other MCP clients β€” the app is for those who want a dedicated, polished experience. ## Table of Contents + - [Features](#features) - [How to install](#how-to-install) - [Getting Started](#getting-started) @@ -67,33 +67,11 @@ Execute long-running terminal commands on your computer and manage processes thr - Process management (list and kill processes) - Session management for long-running commands - **Process output pagination** - Read terminal output with offset/length controls to prevent context overflow -- Server configuration management: - - Get/set configuration values - - Update multiple settings at once - - Dynamic configuration changes without server restart -- Full filesystem operations: - - Read/write files (text, Excel, PDF, DOCX) - - Create/list directories - - **Recursive directory listing** with configurable depth and context overflow protection for large folders - - Move files/directories - - Search files and content (including Excel content) - - Get file metadata - - **Negative offset file reading**: Read from end of files using negative offset values (like Unix tail) -- Code editing capabilities: - - Surgical text replacements for small changes - - Full file rewrites for major changes - - Multiple file support - - Pattern-based replacements - - vscode-ripgrep based recursive code or text search in folders -- Comprehensive audit logging: - - All tool calls are automatically logged - - Log rotation with 10MB size limit - - Detailed timestamps and arguments -- Security hardening: - - Symlink traversal prevention on file operations - - Command blocklist with bypass protection - - [Docker isolation](#option-6-docker-installation--auto-updates-no-nodejs-required) for full sandboxing - - See [SECURITY.md](SECURITY.md) for details +- Server configuration management: - Get/set configuration values - Update multiple settings at once - Dynamic configuration changes without server restart +- Full filesystem operations: - Read/write files (text, Excel, PDF, DOCX) - Create/list directories - **Recursive directory listing** with configurable depth and context overflow protection for large folders - Move files/directories - Search files and content (including Excel content) - Get file metadata - **Negative offset file reading**: Read from end of files using negative offset values (like Unix tail) +- Code editing capabilities: - Surgical text replacements for small changes - Full file rewrites for major changes - Multiple file support - Pattern-based replacements - vscode-ripgrep based recursive code or text search in folders +- Comprehensive audit logging: - All tool calls are automatically logged - Log rotation with 10MB size limit - Detailed timestamps and arguments +- Security hardening: - Symlink traversal prevention on file operations - Command blocklist with bypass protection - [Docker isolation](#option-6-docker-installation--auto-updates-no-nodejs-required) for full sandboxing - See [SECURITY.md](SECURITY.md) for details ## How to install @@ -107,23 +85,24 @@ Desktop Commander offers multiple installation methods for Claude Desktop. Option 1: Install through npx ⭐ Auto-Updates (Requires Node.js) Just run this in terminal: -``` -npx @wonderwhy-er/desktop-commander@latest setup -``` +`npx @wonderwhy-er/desktop-commander@latest setup +` For debugging mode (allows Node.js inspector connection): -``` -npx @wonderwhy-er/desktop-commander@latest setup --debug -``` +`npx @wonderwhy-er/desktop-commander@latest setup --debug +` **Command line options during setup:** + - `--debug`: Enable debugging mode for Node.js inspector - `--no-onboarding`: Disable onboarding prompts for new users Restart Claude if running. -**βœ… Auto-Updates:** Yes - automatically updates when you restart Claude -**πŸ”„ Manual Update:** Run the setup command again +**βœ… Auto-Updates:** Yes - automatically updates when you restart Claude + +**πŸ”„ Manual Update:** Run the setup command again + **πŸ—‘οΈ Uninstall:** Run `npx @wonderwhy-er/desktop-commander@latest remove` @@ -131,13 +110,14 @@ Restart Claude if running.
    Option 2: Using bash script installer (macOS) ⭐ Auto-Updates (Installs Node.js if needed) -``` -curl -fsSL https://raw.githubusercontent.com/wonderwhy-er/DesktopCommanderMCP/refs/heads/main/install.sh | bash -``` +`curl -fsSL https://raw.githubusercontent.com/wonderwhy-er/DesktopCommanderMCP/refs/heads/main/install.sh | bash +` This script handles all dependencies and configuration automatically. -**βœ… Auto-Updates:** Yes -**πŸ”„ Manual Update:** Re-run the bash installer command above +**βœ… Auto-Updates:** Yes + +**πŸ”„ Manual Update:** Re-run the bash installer command above + **πŸ—‘οΈ Uninstall:** Run `npx @wonderwhy-er/desktop-commander@latest remove`
    @@ -145,14 +125,15 @@ This script handles all dependencies and configuration automatically.
    Option 3: Installing via Smithery ⭐ Auto-Updates (Requires Node.js) -1. **Visit:** https://smithery.ai/server/@wonderwhy-er/desktop-commander +1. **Visit:** [https://smithery.ai/server/@wonderwhy-er/desktop-commander](https://smithery.ai/server/@wonderwhy-er/desktop-commander) 2. **Login to Smithery** if you haven't already 3. **Select your client** (Claude Desktop) on the right side 4. **Install with the provided key** that appears after selecting your client 5. **Restart Claude Desktop** -**βœ… Auto-Updates:** Yes - automatically updates when you restart Claude -**πŸ”„ Manual Update:** Visit the Smithery page and reinstall +**βœ… Auto-Updates:** Yes - automatically updates when you restart Claude + +**πŸ”„ Manual Update:** Visit the Smithery page and reinstall
    @@ -165,8 +146,7 @@ Add this entry to your claude_desktop_config.json: - On Windows: `%APPDATA%\Claude\claude_desktop_config.json` - On Linux: `~/.config/Claude/claude_desktop_config.json` -```json -{ +`{ "mcpServers": { "desktop-commander": { "command": "npx", @@ -177,11 +157,13 @@ Add this entry to your claude_desktop_config.json: } } } -``` +` Restart Claude if running. -**βœ… Auto-Updates:** Yes - automatically updates when you restart Claude -**πŸ”„ Manual Update:** Run the setup command again +**βœ… Auto-Updates:** Yes - automatically updates when you restart Claude + +**πŸ”„ Manual Update:** Run the setup command again + **πŸ—‘οΈ Uninstall:** Run `npx @wonderwhy-er/desktop-commander@latest remove` or remove the entry from your claude_desktop_config.json @@ -189,17 +171,18 @@ Restart Claude if running.
    Option 5: Checkout locally ❌ Manual Updates (Requires Node.js) -```bash -git clone https://github.com/wonderwhy-er/DesktopCommanderMCP.git +`git clone https://github.com/wonderwhy-er/DesktopCommanderMCP.git cd DesktopCommanderMCP npm run setup -``` +` Restart Claude if running. The setup command will install dependencies, build the server, and configure Claude's desktop app. -**❌ Auto-Updates:** No - requires manual git updates -**πŸ”„ Manual Update:** `cd DesktopCommanderMCP && git pull && npm run setup` +**❌ Auto-Updates:** No - requires manual git updates + +**πŸ”„ Manual Update:** `cd DesktopCommanderMCP && git pull && npm run setup` + **πŸ—‘οΈ Uninstall:** Run `npx @wonderwhy-er/desktop-commander@latest remove` or remove the cloned directory and MCP server entry from Claude config
    @@ -212,15 +195,13 @@ Perfect for users who want isolation or don't have Node.js installed. Runs in a **Prerequisites:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed **and running**, Claude Desktop app installed. **macOS/Linux:** -```bash -bash <(curl -fsSL https://raw.githubusercontent.com/wonderwhy-er/DesktopCommanderMCP/refs/heads/main/install-docker.sh) -``` +`bash <(curl -fsSL https://raw.githubusercontent.com/wonderwhy-er/DesktopCommanderMCP/refs/heads/main/install-docker.sh) +` **Windows PowerShell:** -```powershell -iex ((New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/wonderwhy-er/DesktopCommanderMCP/refs/heads/main/install-docker.ps1')) -``` +`iex ((New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/wonderwhy-er/DesktopCommanderMCP/refs/heads/main/install-docker.ps1')) +` The installer will check Docker, pull the image, prompt for folder mounting, and configure Claude Desktop. **Docker persistence:** Your tools, configs, work files, and package caches all survive restarts. @@ -229,8 +210,8 @@ The installer will check Docker, pull the image, prompt for folder mounting, and Manual Docker Configuration **Basic setup (no file access):** -```json -{ + +`{ "mcpServers": { "desktop-commander-in-docker": { "command": "docker", @@ -238,11 +219,10 @@ The installer will check Docker, pull the image, prompt for folder mounting, and } } } -``` - +` **With folder mounting:** -```json -{ + +`{ "mcpServers": { "desktop-commander-in-docker": { "command": "docker", @@ -255,11 +235,10 @@ The installer will check Docker, pull the image, prompt for folder mounting, and } } } -``` - +` **Advanced folder mounting:** -```json -{ + +`{ "mcpServers": { "desktop-commander-in-docker": { "command": "docker", @@ -276,25 +255,23 @@ The installer will check Docker, pull the image, prompt for folder mounting, and } } } -``` - +`
    Docker Management Commands **macOS/Linux:** -```bash -# Check status + +`# Check status bash <(curl -fsSL https://raw.githubusercontent.com/wonderwhy-er/DesktopCommanderMCP/refs/heads/main/install-docker.sh) --status # Reset all persistent data bash <(curl -fsSL https://raw.githubusercontent.com/wonderwhy-er/DesktopCommanderMCP/refs/heads/main/install-docker.sh) --reset -``` - +` **Windows PowerShell:** -```powershell -# Check status + +`# Check status $script = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/wonderwhy-er/DesktopCommanderMCP/refs/heads/main/install-docker.ps1'); & ([ScriptBlock]::Create("$script")) -Status # Reset all data @@ -302,17 +279,16 @@ $script = (New-Object System.Net.WebClient).DownloadString('https://raw.githubus # Show help $script = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/wonderwhy-er/DesktopCommanderMCP/refs/heads/main/install-docker.ps1'); & ([ScriptBlock]::Create("$script")) -Help -``` - +` **Troubleshooting:** Reset and reinstall from scratch: -```bash -bash <(curl -fsSL https://raw.githubusercontent.com/wonderwhy-er/DesktopCommanderMCP/refs/heads/main/install-docker.sh) --reset && bash <(curl -fsSL https://raw.githubusercontent.com/wonderwhy-er/DesktopCommanderMCP/refs/heads/main/install-docker.sh) -``` +`bash <(curl -fsSL https://raw.githubusercontent.com/wonderwhy-er/DesktopCommanderMCP/refs/heads/main/install-docker.sh) --reset && bash <(curl -fsSL https://raw.githubusercontent.com/wonderwhy-er/DesktopCommanderMCP/refs/heads/main/install-docker.sh) +`
    -**βœ… Auto-Updates:** Yes - `latest` tag automatically gets newer versions -**πŸ”„ Manual Update:** `docker pull mcp/desktop-commander:latest` then restart Claude +**βœ… Auto-Updates:** Yes - `latest` tag automatically gets newer versions + +**πŸ”„ Manual Update:** `docker pull mcp/desktop-commander:latest` then restart Claude @@ -320,8 +296,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/wonderwhy-er/DesktopCommande Desktop Commander works with any MCP-compatible client. The standard JSON configuration is: -```json -{ +`{ "mcpServers": { "desktop-commander": { "command": "npx", @@ -329,8 +304,7 @@ Desktop Commander works with any MCP-compatible client. The standard JSON config } } } -``` - +` Add this to your client's MCP configuration file at the locations below:
    @@ -377,10 +351,8 @@ Add to your Roo Code MCP configuration file. See [Roo Code MCP docs](https://doc
    Claude Code -```sh -claude mcp add --scope user desktop-commander -- npx -y @wonderwhy-er/desktop-commander@latest -``` - +`claude mcp add --scope user desktop-commander -- npx -y @wonderwhy-er/desktop-commander@latest +` Remove `--scope user` to install for the current project only. See [Claude Code MCP docs](https://docs.anthropic.com/en/docs/claude-code/mcp) for more info.
    @@ -404,18 +376,14 @@ Navigate to `Kiro` > `MCP Servers`, click `+ Add`, and paste the JSON configurat Codex uses TOML configuration. Run this command to add Desktop Commander: -```sh -codex mcp add desktop-commander -- npx -y @wonderwhy-er/desktop-commander@latest -``` - +`codex mcp add desktop-commander -- npx -y @wonderwhy-er/desktop-commander@latest +` Or manually add to `~/.codex/config.toml`: -```toml -[mcp_servers.desktop-commander] +`[mcp_servers.desktop-commander] command = "npx" args = ["-y", "@wonderwhy-er/desktop-commander@latest"] -``` - +` See [Codex MCP docs](https://developers.openai.com/codex/mcp/) for more info.
    @@ -432,8 +400,7 @@ In JetBrains IDEs, go to **Settings β†’ Tools β†’ AI Assistant β†’ Model Context Add to `~/.gemini/settings.json`: -```json -{ +`{ "mcpServers": { "desktop-commander": { "command": "npx", @@ -441,8 +408,7 @@ Add to `~/.gemini/settings.json`: } } } -``` - +` See [Gemini CLI docs](https://github.com/google-gemini/gemini-cli) for more info. @@ -459,10 +425,8 @@ Press `Cmd/Ctrl+Shift+P`, open the Augment panel, and add a new MCP server named Run this command to add Desktop Commander: -```sh -qwen mcp add desktop-commander -- npx -y @wonderwhy-er/desktop-commander@latest -``` - +`qwen mcp add desktop-commander -- npx -y @wonderwhy-er/desktop-commander@latest +` Or add to `.qwen/settings.json` (project) or `~/.qwen/settings.json` (global). See [Qwen Code MCP docs](https://qwenlm.github.io/qwen-code-docs/en/developers/tools/mcp-server/) for more info. @@ -475,6 +439,7 @@ Use Desktop Commander from **ChatGPT**, **Claude web**, and other AI services vi **πŸ‘‰ [Get started at mcp.desktopcommander.app](https://mcp.desktopcommander.app)** How it works: + 1. You run a lightweight **Remote Device** on your computer 2. It connects securely to the cloud Remote MCP service 3. Your AI sends commands through the cloud to your device @@ -492,21 +457,23 @@ How it works: ## Updating & Uninstalling Desktop Commander ### Automatic Updates (Options 1, 2, 3, 4 & 6) + **Options 1 (npx), Option 2 (bash installer), 3 (Smithery), 4 (manual config), and 6 (Docker)** automatically update to the latest version whenever you restart Claude. No manual intervention needed. ### Manual Updates (Option 5) + - **Option 5 (local checkout):** `cd DesktopCommanderMCP && git pull && npm run setup` ### Uninstalling Desktop Commander + #### πŸ€– Automatic Uninstallation (Recommended) The easiest way to completely remove Desktop Commander: -```bash -npx @wonderwhy-er/desktop-commander@latest remove -``` - +`npx @wonderwhy-er/desktop-commander@latest remove +` This automatic uninstaller will: + - βœ… Remove Desktop Commander from Claude's MCP server configuration - βœ… Create a backup of your Claude config before making changes - βœ… Provide guidance for complete package removal @@ -519,38 +486,42 @@ If the automatic uninstaller doesn't work or you prefer manual removal: ##### Remove from Claude Configuration 1. **Locate your Claude Desktop config file:** - - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` - - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` - - **Linux:** `~/.config/Claude/claude_desktop_config.json` - -2. **Edit the config file:** - - Open the file in a text editor - - Find and remove the `"desktop-commander"` entry from the `"mcpServers"` section - - Save the file - - **Example - Remove this section:** - ```json - { - "desktop-commander": { - "command": "npx", - "args": ["@wonderwhy-er/desktop-commander@latest"] - } - } - ``` +- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` +- **Linux:** `~/.config/Claude/claude_desktop_config.json` + +1. **Edit the config file:** + +- Open the file in a text editor +- Find and remove the `"desktop-commander"` entry from the `"mcpServers"` section +- Save the file + +**Example - Remove this section:** + +`{ + "desktop-commander": { + "command": "npx", + "args": ["@wonderwhy-er/desktop-commander@latest"] + } +} +` Close and restart Claude Desktop to complete the removal. #### πŸ†˜ Troubleshooting **If automatic uninstallation fails:** + - Use manual uninstallation as a fallback **If Claude won't start after uninstalling:** + - Restore the backup config file created by the uninstaller - Or manually fix the JSON syntax in your claude_desktop_config.json **Need help?** -- Join our Discord community: https://discord.com/invite/kQ27sNnZr7 + +- Join our Discord community: [https://discord.com/invite/kQ27sNnZr7](https://discord.com/invite/kQ27sNnZr7) ## Getting Started @@ -563,11 +534,13 @@ Desktop Commander includes intelligent onboarding to help you discover what's po **For New Users:** When you're just getting started (fewer than 10 successful commands), Claude will automatically offer helpful getting-started guidance and practical tutorials after you use Desktop Commander successfully. **Request Help Anytime:** You can ask for onboarding assistance at any time by simply saying: + - *"Help me get started with Desktop Commander"* -- *"Show me Desktop Commander examples"* +- *"Show me Desktop Commander examples"* - *"What can I do with Desktop Commander?"* Claude will then show you beginner-friendly tutorials and examples, including: + - πŸ“ Organizing your Downloads folder automatically - πŸ“Š Analyzing CSV/Excel files with Python - βš™οΈ Setting up GitHub Actions CI/CD @@ -580,73 +553,124 @@ The server provides a comprehensive set of tools organized into several categori ### Available Tools -| Category | Tool | Description | -|----------|------|-------------| -| **Configuration** | `get_config` | Get the complete server configuration as JSON (includes blockedCommands, defaultShell, allowedDirectories, fileReadLineLimit, fileWriteLineLimit, telemetryEnabled) | -| | `set_config_value` | Set a specific configuration value by key. Available settings:
    β€’ `blockedCommands`: Array of shell commands that cannot be executed
    β€’ `defaultShell`: Shell to use for commands (e.g., bash, zsh, powershell)
    β€’ `allowedDirectories`: Array of filesystem paths the server can access for file operations (⚠️ terminal commands can still access files outside these directories)
    β€’ `fileReadLineLimit`: Maximum lines to read at once (default: 1000)
    β€’ `fileWriteLineLimit`: Maximum lines to write at once (default: 50)
    β€’ `telemetryEnabled`: Enable/disable telemetry (boolean) | -| **Terminal** | `start_process` | Start programs with smart detection of when they're ready for input | -| | `interact_with_process` | Send commands to running programs and get responses | -| | `read_process_output` | Read output from running processes | -| | `force_terminate` | Force terminate a running terminal session | -| | `list_sessions` | List all active terminal sessions | -| | `list_processes` | List all running processes with detailed information | -| | `kill_process` | Terminate a running process by PID | -| **Filesystem** | `read_file` | Read contents from local filesystem, URLs, Excel files (.xlsx, .xls, .xlsm), and PDFs with line/page-based pagination | -| | `read_multiple_files` | Read multiple files simultaneously | -| | `write_file` | Write file contents with options for rewrite or append mode. Supports Excel files (JSON 2D array format). For PDFs, use `write_pdf` | -| | `write_pdf` | Create new PDF files from markdown or modify existing PDFs (insert/delete pages). Supports HTML/CSS styling and SVG graphics | -| | `create_directory` | Create a new directory or ensure it exists | -| | `list_directory` | Get detailed recursive listing of files and directories (supports depth parameter, default depth=2) | -| | `move_file` | Move or rename files and directories | -| | `start_search` | Start streaming search for files by name or content patterns (searches text files and Excel content) | -| | `get_more_search_results` | Get paginated results from active search with offset support | -| | `stop_search` | Stop an active search gracefully | -| | `list_searches` | List all active search sessions | -| | `get_file_info` | Retrieve detailed metadata about a file or directory (includes sheet info for Excel files) | -| **Text Editing** | `edit_block` | Apply targeted text replacements for text files, or range-based cell updates for Excel files | -| **Analytics** | `get_usage_stats` | Get usage statistics for your own insight | -| | `get_recent_tool_calls` | Get recent tool call history with arguments and outputs for debugging and context recovery | -| | `give_feedback_to_desktop_commander` | Open feedback form in browser to provide feedback to Desktop Commander Team | +Category +Tool +Description + +**Configuration** +`get_config` +Get the complete server configuration as JSON (includes blockedCommands, defaultShell, allowedDirectories, fileReadLineLimit, fileWriteLineLimit, telemetryEnabled) + +`set_config_value` +Set a specific configuration value by key. Available settings:
    β€’ `blockedCommands`: Array of shell commands that cannot be executed
    β€’ `defaultShell`: Shell to use for commands (e.g., bash, zsh, powershell)
    β€’ `allowedDirectories`: Array of filesystem paths the server can access for file operations (⚠️ terminal commands can still access files outside these directories)
    β€’ `fileReadLineLimit`: Maximum lines to read at once (default: 1000)
    β€’ `fileWriteLineLimit`: Maximum lines to write at once (default: 50)
    β€’ `telemetryEnabled`: Enable/disable telemetry (boolean) + +**Terminal** +`start_process` +Start programs with smart detection of when they're ready for input + +`interact_with_process` +Send commands to running programs and get responses + +`read_process_output` +Read output from running processes + +`force_terminate` +Force terminate a running terminal session + +`list_sessions` +List all active terminal sessions + +`list_processes` +List all running processes with detailed information + +`kill_process` +Terminate a running process by PID + +**Filesystem** +`read_file` +Read contents from local filesystem, URLs, Excel files (.xlsx, .xls, .xlsm), and PDFs with line/page-based pagination + +`read_multiple_files` +Read multiple files simultaneously + +`write_file` +Write file contents with options for rewrite or append mode. Supports Excel files (JSON 2D array format). For PDFs, use `write_pdf` + +`write_pdf` +Create new PDF files from markdown or modify existing PDFs (insert/delete pages). Supports HTML/CSS styling and SVG graphics + +`create_directory` +Create a new directory or ensure it exists + +`list_directory` +Get detailed recursive listing of files and directories (supports depth parameter, default depth=2) + +`move_file` +Move or rename files and directories + +`start_search` +Start streaming search for files by name or content patterns (searches text files and Excel content) + +`get_more_search_results` +Get paginated results from active search with offset support + +`stop_search` +Stop an active search gracefully + +`list_searches` +List all active search sessions + +`get_file_info` +Retrieve detailed metadata about a file or directory (includes sheet info for Excel files) + +**Text Editing** +`edit_block` +Apply targeted text replacements for text files, or range-based cell updates for Excel files + +**Analytics** +`get_usage_stats` +Get usage statistics for your own insight + +`get_recent_tool_calls` +Get recent tool call history with arguments and outputs for debugging and context recovery + +`give_feedback_to_desktop_commander` +Open feedback form in browser to provide feedback to Desktop Commander Team ### Quick Examples **Data Analysis:** -``` -"Analyze sales.csv and show top customers" β†’ Claude runs Python code in memory -``` +`"Analyze sales.csv and show top customers" β†’ Claude runs Python code in memory +` **Remote Access:** -``` -"SSH to my server and check disk space" β†’ Claude maintains SSH session -``` +`"SSH to my server and check disk space" β†’ Claude maintains SSH session +` **Development:** -``` -"Start Node.js and test this API" β†’ Claude runs interactive Node session -``` +`"Start Node.js and test this API" β†’ Claude runs interactive Node session +` ### Tool Usage Examples Search/Replace Block Format: -``` -filepath.ext + +`filepath.ext <<<<<<< SEARCH content to find ======= new content >>>>>>> REPLACE -``` - +` Example: -``` -src/main.js + +`src/main.js <<<<<<< SEARCH console.log("old message"); ======= console.log("new message"); >>>>>>> REPLACE -``` - +` ### Enhanced Edit Block Features The `edit_block` tool includes several enhancements for better reliability: @@ -667,26 +691,19 @@ Desktop Commander can be run in Docker containers for **complete isolation from ### Installation Instructions -1. **Install Docker for Windows/Mac** - - Download and install Docker Desktop from [docker.com](https://www.docker.com/products/docker-desktop/) - -2. **Get Desktop Commander Docker Configuration** - - Visit: https://hub.docker.com/mcp/server/desktop-commander/manual - - **Option A:** Use the provided terminal command for automated setup - - **Option B:** Click "Standalone" to get the config JSON and add it manually to your Claude Desktop config - ![docker-config.png](screenshots/docker-config.png) - -3. **Mount Your Machine Folders (Coming Soon)** - - Instructions on how to mount your local directories into the Docker container will be provided soon - - This will allow you to work with your files while maintaining complete isolation +1. **Install Docker for Windows/Mac** - Download and install Docker Desktop from [docker.com](https://www.docker.com/products/docker-desktop/) +2. **Get Desktop Commander Docker Configuration** - Visit: [https://hub.docker.com/mcp/server/desktop-commander/manual](https://hub.docker.com/mcp/server/desktop-commander/manual) - **Option A:** Use the provided terminal command for automated setup - **Option B:** Click "Standalone" to get the config JSON and add it manually to your Claude Desktop config +3. **Mount Your Machine Folders (Coming Soon)** - Instructions on how to mount your local directories into the Docker container will be provided soon - This will allow you to work with your files while maintaining complete isolation ### Benefits of Docker Usage + - **Complete isolation** from your host system - **Consistent environment** across different machines - **Easy cleanup** - just remove the container when done - **Perfect for testing** new features or configurations ## URL Support + - `read_file` can now fetch content from both local files and URLs - Example: `read_file` with `isUrl: true` parameter to read from web resources - Handles both text and image content from remote sources @@ -698,8 +715,7 @@ Desktop Commander can be run in Docker containers for **complete isolation from The fuzzy search logging system includes convenient npm scripts for analyzing logs outside of the MCP environment: -```bash -# View recent fuzzy search logs +`# View recent fuzzy search logs npm run logs:view -- --count 20 # Analyze patterns and performance @@ -710,8 +726,7 @@ npm run logs:export -- --format json --output analysis.json # Clear all logs (with confirmation) npm run logs:clear -``` - +` For detailed documentation on these scripts, see [scripts/README.md](scripts/README.md). ## Fuzzy Search Logs @@ -721,6 +736,7 @@ Desktop Commander includes comprehensive logging for fuzzy search operations in ### What Gets Logged Every fuzzy search operation logs: + - **Search and found text**: The text you're looking for vs. what was found - **Similarity score**: How close the match is (0-100%) - **Execution time**: How long the search took @@ -731,12 +747,14 @@ Every fuzzy search operation logs: ### Log Location Logs are automatically saved to: + - **macOS/Linux**: `~/.claude-server-commander-logs/fuzzy-search.log` - **Windows**: `%USERPROFILE%\.claude-server-commander-logs\fuzzy-search.log` ### What You'll Learn The fuzzy search logs help you understand: + 1. **Why exact matches fail**: Common issues like whitespace differences, line endings, or character encoding 2. **Performance patterns**: How search complexity affects execution time 3. **File type issues**: Which file extensions commonly have matching problems @@ -747,11 +765,14 @@ The fuzzy search logs help you understand: Desktop Commander now includes comprehensive logging for all tool calls: ### What Gets Logged + - Every tool call is logged with timestamp, tool name, and arguments (sanitized for privacy) - Logs are rotated automatically when they reach 10MB in size ### Log Location + Logs are saved to: + - **macOS/Linux**: `~/.claude-server-commander/claude_tool_call.log` - **Windows**: `%USERPROFILE%\.claude-server-commander\claude_tool_call.log` @@ -768,19 +789,15 @@ For commands that may take a while: > **For comprehensive security information and vulnerability reporting**: See [SECURITY.md](SECURITY.md) 1. **Known security limitations**: Directory restrictions and command blocking can be bypassed through various methods including symlinks, command substitution, and absolute paths or code execution - 2. **Always change configuration in a separate chat window** from where you're doing your actual work. Claude may sometimes attempt to modify configuration settings (like `allowedDirectories`) if it encounters filesystem access restrictions. - 3. **The `allowedDirectories` setting currently only restricts filesystem operations**, not terminal commands. Terminal commands can still access files outside allowed directories. - -4. **For production security**: Use the [Docker installation](#option-6-docker-installation-🐳-⭐-auto-updates-no-nodejs-required) which provides complete isolation from your host system. +4. **For production security**: Use the [Docker installation](#option-6-docker-installation-%F0%9F%90%B3-%E2%AD%90-auto-updates-no-nodejs-required) which provides complete isolation from your host system. ### Configuration Tools You can manage server configuration using the provided tools: -```javascript -// Get the entire config +`// Get the entire config get_config({}) // Set a specific config value @@ -789,8 +806,7 @@ set_config_value({ "key": "defaultShell", "value": "/bin/zsh" }) // Set multiple config values using separate calls set_config_value({ "key": "defaultShell", "value": "/bin/bash" }) set_config_value({ "key": "allowedDirectories", "value": ["/Users/username/projects"] }) -``` - +` The configuration is saved to `config.json` in the server's working directory and persists between server restarts. #### Understanding fileWriteLineLimit @@ -798,21 +814,22 @@ The configuration is saved to `config.json` in the server's working directory an The `fileWriteLineLimit` setting controls how many lines can be written in a single `write_file` operation (default: 50 lines). This limit exists for several important reasons: **Why the limit exists:** + - **AIs are wasteful with tokens**: Instead of doing two small edits in a file, AIs may decide to rewrite the whole thing. We're trying to force AIs to do things in smaller changes as it saves time and tokens - **Claude UX message limits**: There are limits within one message and hitting "Continue" does not really work. What we're trying here is to make AI work in smaller chunks so when you hit that limit, multiple chunks have succeeded and that work is not lost - it just needs to restart from the last chunk **Setting the limit:** -```javascript -// You can set it to thousands if you want + +`// You can set it to thousands if you want set_config_value({ "key": "fileWriteLineLimit", "value": 1000 }) // Or keep it smaller to force more efficient behavior set_config_value({ "key": "fileWriteLineLimit", "value": 25 }) -``` - +` **Maximum value**: You can set it to thousands if you want - there's no technical restriction. **Best practices**: + - Keep the default (50) to encourage efficient AI behavior and avoid token waste - The system automatically suggests chunking when limits are exceeded - Smaller chunks mean less work lost when Claude hits message limits @@ -820,11 +837,8 @@ set_config_value({ "key": "fileWriteLineLimit", "value": 25 }) ### Best Practices 1. **Create a dedicated chat for configuration changes**: Make all your config changes in one chat, then start a new chat for your actual work. - 2. **Be careful with empty `allowedDirectories`**: Setting this to an empty array (`[]`) grants access to your entire filesystem for file operations. - 3. **Use specific paths**: Instead of using broad paths like `/`, specify exact directories you want to access. - 4. **Always verify configuration after changes**: Use `get_config({})` to confirm your changes were applied correctly. ## Command Line Options @@ -835,8 +849,7 @@ Desktop Commander supports several command line options for customizing behavior By default, Desktop Commander shows helpful onboarding prompts to new users (those with fewer than 10 tool calls). You can disable this behavior: -```bash -# Disable onboarding for this session +`# Disable onboarding for this session node dist/index.js --no-onboarding # Or if using npm scripts @@ -855,9 +868,9 @@ npm run start:no-onboarding } } } -``` - +` **When onboarding is automatically disabled:** + - When the MCP client name is set to "desktop-commander" - When using the `--no-onboarding` flag - After users have used onboarding prompts or made 10+ tool calls @@ -869,8 +882,7 @@ The server will log when onboarding is disabled: `"Onboarding disabled via --no- You can specify which shell to use for command execution: -```javascript -// Using default shell (bash or system default) +`// Using default shell (bash or system default) execute_command({ "command": "echo $SHELL" }) // Using zsh specifically @@ -878,8 +890,7 @@ execute_command({ "command": "echo $SHELL", "shell": "/bin/zsh" }) // Using bash specifically execute_command({ "command": "echo $SHELL", "shell": "/bin/bash" }) -``` - +` This allows you to use shell-specific features or maintain consistent environments across commands. 1. `execute_command` returns after timeout with initial output @@ -891,32 +902,34 @@ This allows you to use shell-specific features or maintain consistent environmen If you need to debug the server, you can install it in debug mode: -```bash -# Using npx +`# Using npx npx @wonderwhy-er/desktop-commander@latest setup --debug # Or if installed locally npm run setup:debug -``` - +` This will: + 1. Configure Claude to use a separate "desktop-commander" server 2. Enable Node.js inspector protocol with `--inspect-brk=9229` flag 3. Pause execution at the start until a debugger connects 4. Enable additional debugging environment variables To connect a debugger: + - In Chrome, visit `chrome://inspect` and look for the Node.js instance - In VS Code, use the "Attach to Node Process" debug configuration - Other IDEs/tools may have similar "attach" options for Node.js debugging Important debugging notes: + - The server will pause on startup until a debugger connects (due to the `--inspect-brk` flag) - If you don't see activity during debugging, ensure you're connected to the correct Node.js process - Multiple Node processes may be running; connect to the one on port 9229 - The debug server is identified as "desktop-commander-debug" in Claude's MCP server list Troubleshooting: + - If Claude times out while trying to use the debug server, your debugger might not be properly connected - When properly connected, the process will continue execution after hitting the first breakpoint - You can add additional breakpoints in your IDE once connected @@ -924,32 +937,32 @@ Troubleshooting: ## Model Context Protocol Integration This project extends the MCP Filesystem Server to enable: + - Local server support in Claude Desktop - Full system command execution - Process management - File operations - Code editing with search/replace blocks -Created as part of exploring Claude MCPs: https://youtube.com/live/TlbjFDbl5Us +Created as part of exploring Claude MCPs: [https://youtube.com/live/TlbjFDbl5Us](https://youtube.com/live/TlbjFDbl5Us) ## Support Desktop Commander
    -

    πŸ“’ SUPPORT THIS PROJECT

    -

    Desktop Commander MCP is free and open source, but needs your support to thrive!

    - -
    -

    Our philosophy is simple: we don't want you to pay for it if you're not successful. But if Desktop Commander contributes to your success, please consider contributing to ours.

    -

    Ways to support:

    - -
    +

    πŸ“’ SUPPORT THIS PROJECT

    +

    Desktop Commander MCP is free and open source, but needs your support to thrive!

    + +
    +

    Our philosophy is simple: we don't want you to pay for it if you're not successful. But if Desktop Commander contributes to your success, please consider contributing to ours.

    +

    Ways to support:

    + +
    - ### ❀️ Supporters Hall of Fame @@ -957,35 +970,35 @@ Generous supporters are featured here. Thank you for helping make this project p
    - Why your support matters -

    Your support allows us to:

    -
      -
    • Continue active development and maintenance
    • -
    • Add new features and integrations
    • -
    • Improve compatibility across platforms
    • -
    • Provide better documentation and examples
    • -
    • Build a stronger community around the project
    • -
    +Why your support matters +

    Your support allows us to:

    +
      +
    • Continue active development and maintenance
    • +
    • Add new features and integrations
    • +
    • Improve compatibility across platforms
    • +
    • Provide better documentation and examples
    • +
    • Build a stronger community around the project
    • +
    ## Website @@ -997,39 +1010,37 @@ Visit our official website at [https://desktopcommander.app/](https://desktopcom Learn more about this project through these resources: ### Article + [Claude with MCPs replaced Cursor & Windsurf. How did that happen?](https://wonderwhy-er.medium.com/claude-with-mcps-replaced-cursor-windsurf-how-did-that-happen-c1d1e2795e96) - A detailed exploration of how Claude with Model Context Protocol capabilities is changing developer workflows. ### Video + [Claude Desktop Commander Video Tutorial](https://www.youtube.com/watch?v=ly3bed99Dy8) - Watch how to set up and use the Commander effectively. ### Publication at AnalyticsIndiaMag -[![analyticsindiamag.png](testemonials%2Fanalyticsindiamag.png) + +[ This Developer Ditched Windsurf, Cursor Using Claude with MCPs](https://analyticsindiamag.com/ai-features/this-developer-ditched-windsurf-cursor-using-claude-with-mcps/) ### Community + Join our [Discord server](https://discord.gg/kQ27sNnZr7) to get help, share feedback, and connect with other users. ## Testimonials -[![It's a life saver! I paid Claude + Cursor currently which I always feel it's kind of duplicated. This solves the problem ultimately. I am so happy. Thanks so much. Plus today Claude has added the web search support. With this MCP + Internet search, it writes the code with the latest updates. It's so good when Cursor doesn't work sometimes or all the fast requests are used.](https://raw.githubusercontent.com/wonderwhy-er/ClaudeComputerCommander/main/testemonials/img.png) https://www.youtube.com/watch?v=ly3bed99Dy8&lc=UgyyBt6_ShdDX_rIOad4AaABAg -](https://www.youtube.com/watch?v=ly3bed99Dy8&lc=UgyyBt6_ShdDX_rIOad4AaABAg -) +[ https://www.youtube.com/watch?v=ly3bed99Dy8&lc=UgyyBt6_ShdDX_rIOad4AaABAg +](https://www.youtube.com/watch?v=ly3bed99Dy8&lc=UgyyBt6_ShdDX_rIOad4AaABAg) -[![This is the first comment I've ever left on a youtube video, THANK YOU! I've been struggling to update an old Flutter app in Cursor from an old pre null-safety version to a current version and implemented null-safety using Claude 3.7. I got most of the way but had critical BLE errors that I spent days trying to resolve with no luck. I tried Augment Code but it didn't get it either. I implemented your MCP in Claude desktop and was able to compare the old and new codebase fully, accounting for the updates in the code, and fix the issues in a couple of hours. A word of advice to people trying this, be sure to stage changes and commit when appropriate to be able to undo unwanted changes. Amazing!](https://raw.githubusercontent.com/wonderwhy-er/ClaudeComputerCommander/main/testemonials/img_1.png) -https://www.youtube.com/watch?v=ly3bed99Dy8&lc=UgztdHvDMqTb9jiqnf54AaABAg](https://www.youtube.com/watch?v=ly3bed99Dy8&lc=UgztdHvDMqTb9jiqnf54AaABAg -) +[ +https://www.youtube.com/watch?v=ly3bed99Dy8&lc=UgztdHvDMqTb9jiqnf54AaABAg](https://www.youtube.com/watch?v=ly3bed99Dy8&lc=UgztdHvDMqTb9jiqnf54AaABAg) -[![Great! I just used Windsurf, bought license a week ago, for upgrading old fullstack socket project and it works many times good or ok but also many times runs away in cascade and have to revert all changes losing hundereds of cascade tokens. In just a week down to less than 100 tokens and do not want to buy only 300 tokens for 10$. This Claude MCP ,bought claude Pro finally needed but wanted very good reason to also have next to ChatGPT, and now can code as much as I want not worrying about token cost. -Also this is much more than code editing it is much more thank you for great video!](https://raw.githubusercontent.com/wonderwhy-er/ClaudeComputerCommander/main/testemonials/img_2.png) +[ https://www.youtube.com/watch?v=ly3bed99Dy8&lc=UgyQFTmYLJ4VBwIlmql4AaABAg](https://www.youtube.com/watch?v=ly3bed99Dy8&lc=UgyQFTmYLJ4VBwIlmql4AaABAg) -[![it is a great tool, thank you, I like using it, as it gives claude an ability to do surgical edits, making it more like a human developer.](https://raw.githubusercontent.com/wonderwhy-er/ClaudeComputerCommander/main/testemonials/img_3.png) +[ https://www.youtube.com/watch?v=ly3bed99Dy8&lc=Ugy4-exy166_Ma7TH-h4AaABAg](https://www.youtube.com/watch?v=ly3bed99Dy8&lc=Ugy4-exy166_Ma7TH-h4AaABAg) -[![You sir are my hero. You've pretty much summed up and described my experiences of late, much better than I could have. Cursor and Windsurf both had me frustrated to the point where I was almost yelling at my computer screen. Out of whimsy, I thought to myself why not just ask Claude directly, and haven't looked back since. -Claude first to keep my sanity in check, then if necessary, engage with other IDEs, frameworks, etc. I thought I was the only one, glad to see I'm not lol. -33 -1](https://raw.githubusercontent.com/wonderwhy-er/ClaudeComputerCommander/main/testemonials/img_4.png) +[ https://medium.com/@pharmx/you-sir-are-my-hero-62cff5836a3e](https://medium.com/@pharmx/you-sir-are-my-hero-62cff5836a3e) If you find this project useful, please consider giving it a ⭐ star on GitHub! This helps others discover the project and encourages further development. @@ -1050,18 +1061,23 @@ If you find this tool valuable for your workflow, please consider [supporting th Here are answers to some common questions. For a more comprehensive FAQ, see our [detailed FAQ document](FAQ.md). ### What is Desktop Commander? + It's an MCP tool that enables Claude Desktop to access your file system and terminal, turning Claude into a versatile assistant for coding, automation, codebase exploration, and more. ### How is this different from Cursor/Windsurf? + Unlike IDE-focused tools, Claude Desktop Commander provides a solution-centric approach that works with your entire OS, not just within a coding environment. Claude reads files in full rather than chunking them, can work across multiple projects simultaneously, and executes changes in one go rather than requiring constant review. ### Do I need to pay for API credits? + No. This tool works with Claude Desktop's standard Pro subscription ($20/month), not with API calls, so you won't incur additional costs beyond the subscription fee. ### Does Desktop Commander automatically update? + Yes, when installed through npx or Smithery, Desktop Commander automatically updates to the latest version when you restart Claude. No manual update process is needed. ### What are the most common use cases? + - Exploring and understanding complex codebases - Generating diagrams and documentation - Automating tasks across your system @@ -1069,9 +1085,11 @@ Yes, when installed through npx or Smithery, Desktop Commander automatically upd - Making surgical code changes with precise control ### I'm having trouble installing or using the tool. Where can I get help? + Join our [Discord server](https://discord.gg/kQ27sNnZr7) for community support, check the [GitHub issues](https://github.com/wonderwhy-er/DesktopCommanderMCP/issues) for known problems, or review the [full FAQ](FAQ.md) for troubleshooting tips. You can also visit our [website FAQ section](https://desktopcommander.app#faq) for a more user-friendly experience. If you encounter a new issue, please consider [opening a GitHub issue](https://github.com/wonderwhy-er/DesktopCommanderMCP/issues/new) with details about your problem. ### How do I report security vulnerabilities? + Please create a [GitHub Issue](https://github.com/wonderwhy-er/DesktopCommanderMCP/issues) with detailed information about any security vulnerabilities you discover. See our [Security Policy](SECURITY.md) for complete guidelines on responsible disclosure. ## Data Collection & Privacy @@ -1083,7 +1101,8 @@ Desktop Commander collects limited, pseudonymous telemetry to improve the tool. For complete details, see our [Privacy Policy](PRIVACY.md). ## Verifications -[![Verified on MseeP](https://mseep.ai/badge.svg)](https://mseep.ai/app/25ff7a06-58bc-40b8-bd79-ebb715140f1a) + +[https://mseep.ai/app/25ff7a06-58bc-40b8-bd79-ebb715140f1a](https://mseep.ai/app/25ff7a06-58bc-40b8-bd79-ebb715140f1a) ## License diff --git a/scripts/build-ui-runtime.cjs b/scripts/build-ui-runtime.cjs index 9a7fafe4..dbdefc1d 100644 --- a/scripts/build-ui-runtime.cjs +++ b/scripts/build-ui-runtime.cjs @@ -16,7 +16,6 @@ const TARGETS = { styleLayers: [ 'src/ui/styles/base.css', 'src/ui/styles/components/compact-row.css', - 'src/ui/styles/components/tool-header.css', 'src/ui/styles/apps/file-preview.css' ] }, diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index 35221d09..a68155ca 100644 --- a/src/ui/file-preview/src/app.ts +++ b/src/ui/file-preview/src/app.ts @@ -1,25 +1,23 @@ /** - * Top-level controller for the File Preview app. It routes structured content into the appropriate renderer, handles host events, and coordinates user-facing state changes. + * Composition root for the File Preview app. It wires host services, file-type handlers, and specialized controllers together without owning feature logic inline. */ -import { formatJsonIfPossible, inferLanguageFromPath, renderCodeViewer } from './components/code-viewer.js'; -import { renderHtmlPreview } from './components/html-renderer.js'; -import { escapeHtml } from './components/highlighting.js'; -import { isAllowedImageMimeType, normalizeImageMimeType } from './image-preview.js'; -import { mountMarkdownEditor, renderMarkdownCopyButton, renderMarkdownEditorShell, renderMarkdownModeToggle, type MarkdownEditorHandle, type MarkdownEditorView, type MarkdownLinkHeading, type MarkdownLinkSearchItem } from './markdown-workspace/editor.js'; -import { resolveMarkdownLink } from './markdown-workspace/linking.js'; -import { extractMarkdownOutline } from './markdown-workspace/outline.js'; -import { getRenderedMarkdownCopyText, renderMarkdownWorkspacePreview } from './markdown-workspace/preview.js'; -import { slugifyMarkdownHeading } from './markdown-workspace/slugify.js'; -import { attachMarkdownToc, renderMarkdownToc, type MarkdownTocHandle } from './markdown-workspace/toc.js'; -import { getMarkdownEditAvailability, getMarkdownFullscreenAvailability, parseReadRange, shouldAutoLoadMarkdownOnEnterFullscreen, stripReadStatusLine } from './markdown-workspace/workspace-controller.js'; -import type { FilePreviewStructuredContent } from '../../../types.js'; -import type { HtmlPreviewMode } from './types.js'; +import { App } from '@modelcontextprotocol/ext-apps'; import { createCompactRowShellController, type ToolShellController } from '../../shared/tool-shell.js'; import { createWidgetStateStorage } from '../../shared/widget-state.js'; import { renderCompactRow } from '../../shared/compact-row.js'; -import { connectWithSharedHostContext, isObjectRecord, type UiChromeState } from '../../shared/host-context.js'; +import { connectWithSharedHostContext, type UiChromeState } from '../../shared/host-context.js'; import { createUiEventTracker } from '../../shared/ui-event-tracker.js'; -import { App } from '@modelcontextprotocol/ext-apps'; +import { attachDirectoryHandlers } from './directory-controller.js'; +import { buildDocumentLayout } from './document-layout.js'; +import { getDocumentFullscreenAvailability, parseReadRange, stripReadStatusLine } from './document-workspace.js'; +import { getFileTypeCapabilities, renderPayloadBody } from './file-type-handlers.js'; +import { buildOpenInEditorCommand, buildOpenInFolderCommand, detectDefaultMarkdownEditor, renderMarkdownEditorAppIcon } from './host/external-actions.js'; +import { attachSelectionContext } from './host/selection-context.js'; +import { createMarkdownController } from './markdown/controller.js'; +import type { RenderPayload } from './model.js'; +import { attachPanelActions } from './panel-actions.js'; +import { extractRenderPayload, extractToolText, getFileExtensionForAnalytics, isLikelyUrl, isPreviewStructuredContent } from './payload-utils.js'; +import type { HtmlPreviewMode } from './types.js'; let isExpanded = false; let hideSummaryRow = false; @@ -31,1406 +29,61 @@ let rpcUpdateContext: ((text: string) => void) | undefined; let openExternalLink: ((url: string) => Promise) | undefined; let requestDisplayMode: ((mode: 'inline' | 'fullscreen') => Promise) | undefined; let shellController: ToolShellController | undefined; -let currentPayload: RenderPayload | undefined; -let currentHtmlMode: HtmlPreviewMode = 'rendered'; -let currentHostContext: Record | undefined; -let rerenderCurrent: (() => void) | undefined; -let syncPayload: ((payload?: RenderPayload) => void) | undefined; -let persistPayload: ((payload: RenderPayload) => void) | undefined; -let markdownEditorHandle: MarkdownEditorHandle | undefined; -let markdownTocHandle: MarkdownTocHandle | undefined; -let localPayloadOverride: RenderPayload | undefined; -let directoryBackPayload: RenderPayload | undefined; -const markdownEditorAppCache = new Map(); -const markdownEditorAppPending = new Set(); - -interface MarkdownWorkspaceState { - filePath: string; - initialContent: string; - sourceContent: string; - fullDocumentContent: string; - draftContent: string; - mode: 'preview' | 'edit'; - dirty: boolean; - activeHeadingId: string | null; - pendingAnchor: string | null; - notice: string | null; - error: string | null; - saving: boolean; - loadingDocument: boolean; - editorView: MarkdownEditorView; - editorScrollTop: number; - saveIndicator: 'idle' | 'saving' | 'saved'; - fileDeleted: boolean; -} - -let markdownWorkspaceState: MarkdownWorkspaceState | undefined; - -function getFileExtensionForAnalytics(filePath: string): string { - const normalizedPath = filePath.trim().replace(/\\/g, '/'); - const fileName = normalizedPath.split('/').pop() ?? normalizedPath; - const dotIndex = fileName.lastIndexOf('.'); - if (dotIndex <= 0 || dotIndex === fileName.length - 1) { - return 'none'; - } - return fileName.slice(dotIndex + 1).toLowerCase(); -} - -// Internal type used only for rendering β€” extends the public type with the -// text content sourced from the MCP content array (not structuredContent). -type RenderPayload = FilePreviewStructuredContent & { content: string }; - -function isPreviewStructuredContent(value: unknown): value is FilePreviewStructuredContent { - if (!isObjectRecord(value)) { - return false; - } - - return ( - typeof value.fileName === 'string' && - typeof value.filePath === 'string' && - typeof value.fileType === 'string' - ); -} - -function buildRenderPayload( - meta: FilePreviewStructuredContent, - text: string -): RenderPayload { - return { ...meta, content: text }; -} - -function extractRenderPayload(value: unknown): RenderPayload | undefined { - if (!isObjectRecord(value)) { - return undefined; - } - const meta = isPreviewStructuredContent(value.structuredContent) - ? value.structuredContent - : isPreviewStructuredContent(value) - ? value - : null; - if (!meta) return undefined; - const text = extractToolText(value) ?? extractToolText(value.structuredContent) ?? ''; - return buildRenderPayload(meta, text); -} - -function extractToolText(value: unknown): string | undefined { - if (!isObjectRecord(value)) { - return undefined; - } - const content = value.content; - if (!Array.isArray(content)) { - return undefined; - } - for (const item of content) { - if (!isObjectRecord(item)) { - continue; - } - if (item.type === 'text' && typeof item.text === 'string' && item.text.trim().length > 0) { - return item.text; - } - } - return undefined; -} - -function isLikelyUrl(filePath: string): boolean { - return /^https?:\/\//i.test(filePath); -} - -function buildBreadcrumb(filePath: string): string { - const normalized = filePath.replace(/\\/g, '/'); - const parts = normalized.split('/').filter(Boolean); - return parts.map(p => escapeHtml(p)).join(' β€Ί '); -} - -function getParentDirectory(filePath: string): string { - const normalized = filePath.replace(/\\/g, '/'); - const lastSlash = normalized.lastIndexOf('/'); - if (lastSlash <= 0) { - return filePath; - } - return normalized.slice(0, lastSlash); -} - -function getAncestorDirectories(filePath: string): string[] { - const normalized = filePath.replace(/\\/g, '/'); - const parts = normalized.split('/').filter(Boolean); - const ancestors: string[] = []; - for (let index = parts.length - 1; index > 0; index -= 1) { - const prefix = normalized.startsWith('/') ? '/' : ''; - ancestors.push(`${prefix}${parts.slice(0, index).join('/')}`); - } - return ancestors; -} - -function splitListingLines(text: string): string[] { - return text.split('\n').map((line) => line.trim()).filter(Boolean); -} - -function parseFileSearchResults(text: string): string[] { - return text.split('\n') - .map((line) => line.trim()) - .filter((line) => line.startsWith('πŸ“ ')) - .map((line) => line.slice(3).trim()); -} - -function toPosixRelativePath(fromDirectory: string, targetPath: string): string { - const fromParts = fromDirectory.replace(/\\/g, '/').split('/').filter(Boolean); - const targetParts = targetPath.replace(/\\/g, '/').split('/').filter(Boolean); - let shared = 0; - while (shared < fromParts.length && shared < targetParts.length && fromParts[shared] === targetParts[shared]) { - shared += 1; - } - const up = new Array(Math.max(fromParts.length - shared, 0)).fill('..'); - const down = targetParts.slice(shared); - const joined = [...up, ...down].join('/'); - return joined.length > 0 ? joined : '.'; -} - -function stripMarkdownExtension(filePath: string): string { - return filePath.replace(/\.md$/i, ''); -} - -async function resolveMarkdownLinkSearchRoot(filePath: string): Promise { - const ancestors = getAncestorDirectories(filePath); - const markers = ['.git/', '.obsidian/', 'package.json', 'pnpm-workspace.yaml', 'turbo.json']; - - for (const ancestor of ancestors) { - try { - const result = await rpcCallTool?.('list_directory', { path: ancestor, depth: 1 }); - const text = extractToolText(result) ?? ''; - const entries = splitListingLines(text); - if (markers.some((marker) => entries.some((entry) => entry.includes(marker)))) { - return ancestor; - } - } catch { - // Ignore and continue up the tree. - } - } - - return getParentDirectory(filePath); -} - -async function searchMarkdownLinkTargets(filePath: string, query: string): Promise { - const trimmedQuery = query.trim(); - if (trimmedQuery.length === 0) { - return []; - } - - const rootPath = await resolveMarkdownLinkSearchRoot(filePath); - const result = await rpcCallTool?.('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 loadMarkdownLinkHeadings(currentPayloadPath: string, targetPath: string): Promise { - if (targetPath === currentPayloadPath && markdownWorkspaceState) { - return extractMarkdownOutline(markdownWorkspaceState.sourceContent).map((item) => ({ id: item.id, text: item.text })); - } - - const result = await rpcCallTool?.('read_file', { - path: targetPath, - offset: 0, - length: 5000, - }); - const text = extractToolText(result) ?? ''; - return extractMarkdownOutline(stripReadStatusLine(text)).map((item) => ({ id: item.id, text: item.text })); -} - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; -} - -function encodePowerShellCommand(script: string): string { - // PowerShell -EncodedCommand expects UTF-16LE bytes. - const utf16leBytes: number[] = []; - for (let index = 0; index < script.length; index += 1) { - const codeUnit = script.charCodeAt(index); - utf16leBytes.push(codeUnit & 0xff, codeUnit >> 8); - } - - let binary = ''; - for (const byte of utf16leBytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary); -} - -function buildOpenInFolderCommand(filePath: string): string | undefined { - const trimmedPath = filePath.trim(); - if (!trimmedPath || isLikelyUrl(trimmedPath)) { - return undefined; - } - - const userAgent = navigator.userAgent.toLowerCase(); - if (userAgent.includes('win')) { - const escapedForPowerShell = trimmedPath.replace(/'/g, "''"); - const script = `Start-Process -FilePath explorer.exe -ArgumentList @('/select,','${escapedForPowerShell}')`; - return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodePowerShellCommand(script)}`; - } - if (userAgent.includes('mac')) { - return `open -R ${shellQuote(trimmedPath)}`; - } - - return `xdg-open ${shellQuote(getParentDirectory(trimmedPath))}`; -} - -function buildOpenInEditorCommand(filePath: string): string | undefined { - const trimmedPath = filePath.trim(); - if (!trimmedPath || isLikelyUrl(trimmedPath)) { - return undefined; - } - - const cachedApp = markdownEditorAppCache.get(trimmedPath); - if (cachedApp?.appPath && navigator.userAgent.toLowerCase().includes('mac')) { - return `open -a ${shellQuote(cachedApp.appPath)} ${shellQuote(trimmedPath)}`; - } - - const userAgent = navigator.userAgent.toLowerCase(); - if (userAgent.includes('win')) { - const escapedForPowerShell = trimmedPath.replace(/'/g, "''"); - const script = `Start-Process -FilePath '${escapedForPowerShell}'`; - return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodePowerShellCommand(script)}`; - } - if (userAgent.includes('mac')) { - return `open ${shellQuote(trimmedPath)}`; - } - - return `xdg-open ${shellQuote(trimmedPath)}`; -} - -async function detectDefaultMarkdownEditor(filePath: string): Promise { - const trimmedPath = filePath.trim(); - if (!trimmedPath || markdownEditorAppCache.has(trimmedPath) || markdownEditorAppPending.has(trimmedPath)) { - return; - } - - const userAgent = navigator.userAgent.toLowerCase(); - if (!userAgent.includes('mac')) { - return; - } - - markdownEditorAppPending.add(trimmedPath); - try { - const detectCommand = `osascript -e ${shellQuote(`set appAlias to default application of (info for POSIX file "${trimmedPath.replace(/"/g, '\\"')}") -return (name of (info for appAlias)) & linefeed & POSIX path of appAlias`)}`; - const detectResult = await rpcCallTool?.('start_process', { - command: detectCommand, - timeout_ms: 12000, - }); - const text = extractToolText(detectResult) ?? ''; - if (!text || text.toLowerCase().includes('error') || text.toLowerCase().includes('execution')) { - return; - } - const lines = text.split('\n').map((line) => line.trim()).filter(Boolean); - const appName = lines[lines.length - 2]?.replace(/\.app$/i, '') ?? ''; - const appPath = lines[lines.length - 1] ?? ''; - if (appName && appPath.startsWith('/')) { - markdownEditorAppCache.set(trimmedPath, { - appName, - appPath, - }); - rerenderCurrent?.(); - } - } catch { - // Fall back to generic editor label. - } finally { - markdownEditorAppPending.delete(trimmedPath); - } -} - -function renderMarkdownEditorAppIcon(): string { - return ''; -} - -function renderRawFallback(source: string): string { - return `
    ${escapeHtml(source)}
    `; -} - -interface DirEntry { - name: string; - isDir: boolean; - isDenied: boolean; - isWarning: boolean; - warningText: string; - depth: number; - children: DirEntry[]; - relativePath: string; -} - -function parseDirectoryEntries(content: string): { hint: string; entries: DirEntry[] } { - const lines = content.split('\n'); - // First line(s) before listing are the hint message - const hintLines: string[] = []; - const entryLines: string[] = []; - for (const line of lines) { - if (/^\[(DIR|FILE|DENIED|WARNING)\]/.test(line.trim())) { - entryLines.push(line.trim()); - } else if (entryLines.length === 0) { - hintLines.push(line); - } - } - - // Build flat list - const flat: { name: string; fullPath: string; isDir: boolean; isDenied: boolean; isWarning: boolean; warningText: string; depth: number }[] = []; - for (const line of entryLines) { - if (line.startsWith('[WARNING]')) { - // Format: [WARNING] dirName: N items hidden (showing first M of T total) - const warnBody = line.replace(/^\[WARNING\]\s*/, ''); - const colonIdx = warnBody.indexOf(':'); - const dirName = colonIdx >= 0 ? warnBody.slice(0, colonIdx).trim() : ''; - const msg = colonIdx >= 0 ? warnBody.slice(colonIdx + 1).trim() : warnBody; - // Depth matches the directory it belongs to β€” infer from dirName path segments - const parts = dirName.replace(/\\/g, '/').split('/').filter(Boolean); - const depth = parts.length; // warning sits inside the dir, so same depth as children - flat.push({ name: dirName, fullPath: dirName, isDir: false, isDenied: false, isWarning: true, warningText: msg, depth }); - continue; - } - const isDir = line.startsWith('[DIR]'); - const isDenied = line.startsWith('[DENIED]'); - const name = line.replace(/^\[(DIR|FILE|DENIED)\]\s*/, ''); - const parts = name.replace(/\\/g, '/').split('/'); - flat.push({ name, fullPath: name, isDir, isDenied, isWarning: false, warningText: '', depth: parts.length - 1 }); - } - - // Build tree from flat list - const root: DirEntry[] = []; - const stack: DirEntry[][] = [root]; - - for (const item of flat) { - const baseName = item.fullPath.replace(/\\/g, '/').split('/').pop() ?? item.fullPath; - const entry: DirEntry = { name: baseName, isDir: item.isDir, isDenied: item.isDenied, isWarning: item.isWarning, warningText: item.warningText, depth: item.depth, children: [], relativePath: item.fullPath }; - - // Adjust stack to match depth - while (stack.length > item.depth + 1) stack.pop(); - - const parent = stack[stack.length - 1]; - parent.push(entry); - - if (item.isDir) { - stack.push(entry.children); - } - } - - return { hint: hintLines.join('\n').trim(), entries: root }; -} - -let dirEntryIdCounter = 0; - -function renderDirTree(entries: DirEntry[], rootPath: string): string { - if (entries.length === 0) return '
    Empty directory
    '; - - function renderEntries(items: DirEntry[]): string { - return items.map(item => { - const id = `de-${dirEntryIdCounter++}`; - const fullPath = rootPath + '/' + item.relativePath.replace(/\\/g, '/'); - const ep = escapeHtml(fullPath); - - if (item.isWarning) { - const parentPath = rootPath + '/' + item.relativePath.replace(/\\/g, '/'); - const epp = escapeHtml(parentPath); - return `
    `; - } - if (item.isDenied) { - return `
    🚫 ${escapeHtml(item.name)}
    `; - } - if (item.isDir) { - const has = item.children.length > 0; - const chev = `${has ? 'β–Ό' : 'β–Ά'}`; - const openBtn = ``; - const ch = has ? `
    ${renderEntries(item.children)}
    ` : ''; - return `
    ${chev} πŸ“ ${escapeHtml(item.name)}${openBtn}
    ${ch}
    `; - } - return `
    πŸ“„ ${escapeHtml(item.name)}
    `; - }).join(''); - } - - return `
    ${renderEntries(entries)}
    `; -} - -function renderDirectoryBody(content: string, rootPath: string): { html: string; notice?: string } { - dirEntryIdCounter = 0; - const { hint, entries } = parseDirectoryEntries(content); - const treeHtml = renderDirTree(entries, rootPath); - return { - notice: hint || undefined, - html: `
    ${treeHtml}
    ` - }; -} - -function attachDirectoryHandlers(container: HTMLElement, rootPayload: RenderPayload): void { - const tree = container.querySelector('.dir-tree'); - if (!tree) return; - - tree.addEventListener('click', async (e) => { - // Handle "open in finder" button β€” stop propagation so folder doesn't toggle - const openBtn = (e.target as HTMLElement).closest('.dir-open-btn') as HTMLElement | null; - if (openBtn) { - e.stopPropagation(); - const openPath = openBtn.dataset.openpath; - if (!openPath) return; - const cmd = buildOpenInFolderCommand(openPath); - if (cmd) { - try { await rpcCallTool?.('start_process', { command: cmd, timeout_ms: 12000 }); } catch {} - } - return; - } - - // Handle "load more" warning button β€” reload parent directory fully - const loadMoreBtn = (e.target as HTMLElement).closest('.dir-load-more') as HTMLElement | null; - if (loadMoreBtn) { - e.stopPropagation(); - const loadPath = loadMoreBtn.dataset.loadpath; - if (!loadPath) return; - loadMoreBtn.querySelector('.dir-warning-text')!.textContent = 'Loading…'; - (loadMoreBtn as HTMLButtonElement).disabled = true; - try { - const result = await rpcCallTool?.('list_directory', { path: loadPath, depth: 1 }); - const text = (result as any)?.content?.[0]?.text; - if (text && typeof text === 'string') { - const parsed = parseDirectoryEntries(text); - const html = renderDirTree(parsed.entries, loadPath); - // Replace the parent .dir-children container contents - const parentChildren = loadMoreBtn.closest('.dir-children'); - if (parentChildren) { - const temp = document.createElement('div'); - temp.innerHTML = html; - const inner = temp.querySelector('.dir-tree'); - parentChildren.innerHTML = inner ? inner.innerHTML : ''; - } - } - } catch { - loadMoreBtn.querySelector('.dir-warning-text')!.textContent = 'Failed to load'; - (loadMoreBtn as HTMLButtonElement).disabled = false; - } - return; - } - - const target = (e.target as HTMLElement).closest('.dir-row') as HTMLElement | null; - if (!target) return; - const fullPath = target.dataset.path; - if (!fullPath) return; - - if (target.classList.contains('dir-row-folder')) { - const eid = target.dataset.eid; - if (!eid) return; - const childrenEl = document.getElementById(`${eid}-ch`); - const chevron = target.querySelector('.dir-chevron'); - - if (childrenEl) { - const hidden = childrenEl.classList.toggle('dir-collapsed'); - chevron?.classList.toggle('expanded', !hidden); - if (chevron) chevron.textContent = hidden ? 'β–Ά' : 'β–Ό'; - } else { - if (target.dataset.loaded === 'true') return; - if (chevron) chevron.textContent = '⏳'; - try { - const result = await rpcCallTool?.('list_directory', { path: fullPath, depth: 2 }); - const text = (result as any)?.content?.[0]?.text; - if (text && typeof text === 'string') { - target.dataset.loaded = 'true'; - const parsed = parseDirectoryEntries(text); - const html = renderDirTree(parsed.entries, fullPath); - const wrapper = document.createElement('div'); - wrapper.className = 'dir-children'; - wrapper.id = `${eid}-ch`; - const temp = document.createElement('div'); - temp.innerHTML = html; - const inner = temp.querySelector('.dir-tree'); - wrapper.innerHTML = inner ? inner.innerHTML : 'Empty'; - target.parentElement?.appendChild(wrapper); - chevron?.classList.add('expanded'); - if (chevron) chevron.textContent = 'β–Ό'; - } - } catch { - if (chevron) chevron.textContent = '⚠'; - } - } - return; - } - - if (target.classList.contains('dir-row-file')) { - target.classList.add('dir-loading'); - try { - const result = await rpcCallTool?.('read_file', { path: fullPath }); - const r = result as any; - if (r?.structuredContent) { - directoryBackPayload = rootPayload; - const text = r.content?.[0]?.text ?? ''; - const newPayload = buildRenderPayload(r.structuredContent, text); - renderApp(container.closest('#app') as HTMLElement, newPayload, 'rendered', true); - } - } catch { - target.classList.remove('dir-loading'); - } - } - }); -} - -function renderImageBody(payload: RenderPayload): { html: string; notice?: string } { - const mimeType = normalizeImageMimeType(payload.mimeType); - if (!isAllowedImageMimeType(mimeType)) { - return { - notice: 'Preview is unavailable for this image format.', - html: '
    ' - }; - } - - if (!payload.imageData || payload.imageData.trim().length === 0) { - return { - notice: 'Preview is unavailable because image data is missing.', - html: '
    ' - }; - } - - const src = `data:${mimeType};base64,${payload.imageData}`; - return { - html: `
    ${escapeHtml(payload.fileName)}
    ` - }; -} - -function countContentLines(content: string): number { - const cleaned = stripReadStatusLine(content); - if (cleaned === '') return 0; - const lines = cleaned.split('\n'); - return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length; -} - -function disposeMarkdownWorkspaceHandles(): void { - markdownEditorHandle?.destroy(); - markdownEditorHandle = undefined; - markdownTocHandle?.dispose(); - markdownTocHandle = undefined; -} - -function getAvailableDisplayModes(): string[] { - const rawModes = currentHostContext?.availableDisplayModes; - if (!Array.isArray(rawModes)) { - return []; - } - - return rawModes.filter((mode): mode is string => typeof mode === 'string'); -} - -function getCurrentDisplayMode(): string | null { - return typeof currentHostContext?.displayMode === 'string' - ? currentHostContext.displayMode - : null; -} - -function getMarkdownWorkspaceState(payload: RenderPayload): MarkdownWorkspaceState { - const cleanedContent = stripReadStatusLine(payload.content); - - if (!markdownWorkspaceState || markdownWorkspaceState.filePath !== payload.filePath || markdownWorkspaceState.sourceContent !== cleanedContent) { - const outline = extractMarkdownOutline(cleanedContent); - const isPartial = parseReadRange(payload.content)?.isPartial === true; - const prevInitial = markdownWorkspaceState?.filePath === payload.filePath - ? markdownWorkspaceState.initialContent - : undefined; - markdownWorkspaceState = { - filePath: payload.filePath, - initialContent: prevInitial ?? cleanedContent, - sourceContent: cleanedContent, - fullDocumentContent: cleanedContent, - draftContent: cleanedContent, - mode: isPartial ? 'preview' : 'edit', - dirty: false, - activeHeadingId: outline[0]?.id ?? null, - pendingAnchor: null, - notice: null, - error: null, - saving: false, - loadingDocument: false, - editorView: 'markdown', - editorScrollTop: 0, - saveIndicator: 'idle', - fileDeleted: false, - }; - } - - return markdownWorkspaceState; -} - -function updateCurrentPayload(payload: RenderPayload): void { - currentPayload = payload; -} - -function getEffectiveIncomingPayload(payload: RenderPayload): RenderPayload { - if (!localPayloadOverride) { - return payload; - } - - if (localPayloadOverride.filePath !== payload.filePath) { - localPayloadOverride = undefined; - return payload; - } - - const incomingContent = stripReadStatusLine(payload.content); - const overriddenContent = stripReadStatusLine(localPayloadOverride.content); - if (incomingContent === overriddenContent) { - return payload; - } - - return localPayloadOverride; -} - -function buildMarkdownWorkspaceBody(payload: RenderPayload): { html: string; notice?: string } { - const workspaceState = getMarkdownWorkspaceState(payload); - const outline = extractMarkdownOutline(workspaceState.sourceContent); - const isFullscreen = getCurrentDisplayMode() === 'fullscreen'; - const tocHtml = isFullscreen ? renderMarkdownToc(outline, workspaceState.activeHeadingId) : ''; - if (!workspaceState.activeHeadingId && outline.length > 0) { - workspaceState.activeHeadingId = outline[0].id; - } - - const messages = [workspaceState.error, workspaceState.notice]; - - const notice = messages.find((value): value is string => typeof value === 'string' && value.trim().length > 0); - - if (workspaceState.mode === 'edit') { - const lineCount = countContentLines(workspaceState.draftContent); - const wordCount = workspaceState.draftContent.trim().length > 0 - ? workspaceState.draftContent.trim().split(/\s+/).length - : 0; - return { - notice, - html: ` -
    -
    - ${tocHtml} -
    - ${renderMarkdownEditorShell({ - content: workspaceState.draftContent, - view: workspaceState.editorView, - })} -
    -
    -
    - `, - }; - } - - return { - notice, - html: `
    ${renderMarkdownWorkspacePreview({ - content: workspaceState.sourceContent, - outline, - activeHeadingId: workspaceState.activeHeadingId, - showToc: isFullscreen, - })}
    `, - }; -} - -function renderBody(payload: RenderPayload, htmlMode: HtmlPreviewMode, startLine = 1): { html: string; notice?: string } { - const cleanedContent = stripReadStatusLine(payload.content); - - if (payload.fileType === 'image') { - return renderImageBody(payload); - } - - if (payload.fileType === 'directory') { - return renderDirectoryBody(cleanedContent, payload.filePath); - } - - if (payload.fileType === 'unsupported') { - return { - notice: 'Preview is not available for this file type.', - html: '
    ' - }; - } - - if (payload.fileType === 'html') { - return renderHtmlPreview(cleanedContent, htmlMode); - } - - if (payload.fileType !== 'markdown') { - const detectedLanguage = inferLanguageFromPath(payload.filePath); - const formatted = formatJsonIfPossible(cleanedContent, payload.filePath); - return { - notice: formatted.notice, - html: `
    ${renderCodeViewer(formatted.content, detectedLanguage, startLine)}
    ` - }; - } - - try { - return buildMarkdownWorkspaceBody(payload); - } catch { - return { - notice: 'Markdown renderer failed. Showing raw source instead.', - html: `
    ${renderRawFallback(cleanedContent)}
    ` - }; - } -} - -function attachCopyHandler(payload: RenderPayload): void { - const fallbackCopy = (text: string): boolean => { - const textArea = document.createElement('textarea'); - textArea.value = text; - textArea.setAttribute('readonly', ''); - textArea.style.position = 'fixed'; - textArea.style.top = '-9999px'; - document.body.appendChild(textArea); - textArea.select(); - const success = document.execCommand('copy'); - document.body.removeChild(textArea); - return success; - }; - - const setButtonState = (button: HTMLElement, label: string, fallbackLabel: string, revertMs?: number): void => { - button.setAttribute('title', label); - button.setAttribute('aria-label', label); - button.textContent = label; - if (revertMs) { - setTimeout(() => { - button.textContent = fallbackLabel; - button.setAttribute('title', fallbackLabel); - button.setAttribute('aria-label', fallbackLabel); - }, revertMs); - } - }; - - const setIconButtonState = (button: HTMLElement, label: string, fallbackLabel: string, revertMs?: number): void => { - button.setAttribute('title', label); - button.setAttribute('aria-label', label); - button.dataset.status = label; - if (revertMs) { - setTimeout(() => { - button.setAttribute('title', fallbackLabel); - button.setAttribute('aria-label', fallbackLabel); - delete button.dataset.status; - }, revertMs); - } - }; - - const copyTextData = async (text: string): Promise => { - try { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - return true; - } - return fallbackCopy(text); - } catch { - return fallbackCopy(text); - } - }; - - const copyButton = document.getElementById('copy-source'); - copyButton?.addEventListener('click', async () => { - trackUiEvent?.('copy_clicked', { - file_type: payload.fileType, - file_extension: getFileExtensionForAnalytics(payload.filePath) - }); - - const cleanedContent = stripReadStatusLine(payload.content); - - const copied = await copyTextData(cleanedContent); - setButtonState(copyButton, copied ? 'Copied!' : 'Copy failed', 'Copy', 1500); - }); - - const activeCopyButton = document.getElementById('copy-active-markdown'); - activeCopyButton?.addEventListener('click', async () => { - const workspaceState = payload.fileType === 'markdown' ? getMarkdownWorkspaceState(payload) : undefined; - if (!workspaceState) { - return; - } - - const source = workspaceState.mode === 'edit' - ? workspaceState.draftContent - : stripReadStatusLine(payload.content); - const textToCopy = workspaceState.editorView === 'raw' - ? source - : (getRenderedMarkdownCopyText(source) || source); - const copied = await copyTextData(textToCopy); - if (copied) { - updateSaveStatusDOM('Copied', 'saved'); - window.setTimeout(() => updateSaveStatusDOM('', ''), 1500); - } - setIconButtonState(activeCopyButton, copied ? 'Copied!' : 'Copy failed', 'Copy', 1500); - }); -} - -function setMarkdownEditorView(payload: RenderPayload, view: MarkdownEditorView): void { - const workspaceState = getMarkdownWorkspaceState(payload); - const wrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; - workspaceState.editorScrollTop = wrapper?.scrollTop ?? 0; - workspaceState.editorView = view; - workspaceState.notice = null; - workspaceState.error = null; - rerenderCurrent?.(); - if (typeof workspaceState.editorScrollTop === 'number') { - window.requestAnimationFrame(() => { - const nextWrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; - if (nextWrapper) { - nextWrapper.scrollTop = workspaceState.editorScrollTop; - } - }); - } -} - -function attachHtmlToggleHandler(container: HTMLElement, payload: RenderPayload, htmlMode: HtmlPreviewMode): void { - const toggleButton = document.getElementById('toggle-html-mode'); - if (!toggleButton || payload.fileType !== 'html') { - return; - } - toggleButton.addEventListener('click', () => { - const nextMode: HtmlPreviewMode = htmlMode === 'rendered' ? 'source' : 'rendered'; - trackUiEvent?.('html_view_toggled', { - file_type: payload.fileType, - file_extension: getFileExtensionForAnalytics(payload.filePath) - }); - renderApp(container, payload, nextMode, isExpanded); - }); -} - -function attachOpenInFolderHandler(payload: RenderPayload): void { - const openButton = document.getElementById('open-in-folder') as HTMLButtonElement | null; - if (!openButton) { - return; - } - - const command = buildOpenInFolderCommand(payload.filePath); - if (!command) { - openButton.disabled = true; - return; - } - - openButton.addEventListener('click', async () => { - trackUiEvent?.('open_in_folder', { - file_type: payload.fileType, - file_extension: getFileExtensionForAnalytics(payload.filePath) - }); - - try { - await rpcCallTool?.('start_process', { - command, - timeout_ms: 12000 - }); - } catch { - // Keep UI stable if opening folder fails. - } - }); -} - -function attachOpenInEditorHandler(payload: RenderPayload): void { - const openButton = document.getElementById('open-in-editor') as HTMLButtonElement | null; - if (!openButton) { - return; - } - - const command = buildOpenInEditorCommand(payload.filePath); - if (!command) { - openButton.disabled = true; - return; - } - - openButton.addEventListener('click', async () => { - trackUiEvent?.('open_in_editor', { - file_type: payload.fileType, - file_extension: getFileExtensionForAnalytics(payload.filePath) - }); - - try { - await rpcCallTool?.('start_process', { - command, - timeout_ms: 12000 - }); - } catch { - // Keep UI stable if opening editor fails. - } - }); -} - -function attachLoadAllHandler( - container: HTMLElement, - payload: RenderPayload, - htmlMode: HtmlPreviewMode -): void { - const beforeBtn = document.getElementById('load-before') as HTMLButtonElement | null; - const afterBtn = document.getElementById('load-after') as HTMLButtonElement | null; - if (!beforeBtn && !afterBtn) { - return; - } - - const range = parseReadRange(payload.content); - if (!range?.isPartial) return; - - const currentContent = stripReadStatusLine(payload.content); - - const loadLines = async (btn: HTMLButtonElement, direction: 'before' | 'after'): Promise => { - const originalText = btn.textContent; - btn.textContent = 'Loading…'; - btn.disabled = true; - - trackUiEvent?.(direction === 'before' ? 'load_lines_before' : 'load_lines_after', { - file_type: payload.fileType, - file_extension: getFileExtensionForAnalytics(payload.filePath) - }); - - try { - // Load only the missing portion - const readArgs = direction === 'before' - ? { path: payload.filePath, offset: 0, length: range.fromLine - 1 } - : { path: payload.filePath, offset: range.toLine }; - - const result = await rpcCallTool?.('read_file', readArgs); - const resultObj = result as { content?: Array<{ text?: string }> } | undefined; - const newText = resultObj?.content?.[0]?.text; - - if (newText && typeof newText === 'string') { - const cleanNew = stripReadStatusLine(newText); - - // Merge: prepend or append the new lines - const merged = direction === 'before' - ? cleanNew + (cleanNew.endsWith('\n') ? '' : '\n') + currentContent - : currentContent + (currentContent.endsWith('\n') ? '' : '\n') + cleanNew; - - // Build updated status line reflecting the new range - const newFrom = direction === 'before' ? 1 : range.fromLine; - const newTo = direction === 'after' ? range.totalLines : range.toLine; - const lineCount = newTo - newFrom + 1; - const remaining = range.totalLines - newTo; - const isStillPartial = newFrom > 1 || newTo < range.totalLines; - const statusLine = isStillPartial - ? `[Reading ${lineCount} lines from ${newFrom === 1 ? 'start' : `line ${newFrom}`} (total: ${range.totalLines} lines, ${remaining} remaining)]\n` - : ''; - - const mergedPayload: RenderPayload = { - ...payload, - content: statusLine + merged - }; - renderApp(container, mergedPayload, htmlMode, isExpanded); - } else { - btn.textContent = 'Failed to load'; - setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 2000); - } - } catch { - btn.textContent = 'Failed to load'; - setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 2000); - } - }; - - beforeBtn?.addEventListener('click', () => void loadLines(beforeBtn, 'before')); - afterBtn?.addEventListener('click', () => void loadLines(afterBtn, 'after')); -} - -function findMarkdownHeading(anchor: string): HTMLElement | null { - const trimmedAnchor = anchor.trim(); - if (!trimmedAnchor) { - return null; - } - - return document.getElementById(trimmedAnchor) ?? document.getElementById(slugifyMarkdownHeading(trimmedAnchor)); -} - -function scrollMarkdownHeadingIntoView(anchor: string): boolean { - const heading = findMarkdownHeading(anchor); - if (!heading) { - return false; - } - - const scrollParents: HTMLElement[] = []; - let current: HTMLElement | null = 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 as HTMLElement | null; - 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 (markdownWorkspaceState) { - markdownWorkspaceState.activeHeadingId = heading.id || slugifyMarkdownHeading(anchor); - } - return true; -} - -function applyPendingMarkdownAnchor(): void { - const workspaceState = markdownWorkspaceState; - const pendingAnchor = workspaceState?.pendingAnchor; - if (!workspaceState || !pendingAnchor) { - return; - } - - workspaceState.pendingAnchor = null; - if (!scrollMarkdownHeadingIntoView(pendingAnchor)) { - workspaceState.error = `Heading not found: ${pendingAnchor}`; - rerenderCurrent?.(); - } -} - -async function refreshMarkdownFromDisk(payload: RenderPayload): Promise { - try { - const freshResult = await rpcCallTool?.('read_file', { path: payload.filePath }); - const resultText = extractToolText(freshResult) ?? ''; - - if (resultText.toLowerCase().includes('error') && (resultText.toLowerCase().includes('not found') || resultText.toLowerCase().includes('no such file') || resultText.toLowerCase().includes('enoent'))) { - if (markdownWorkspaceState) { - markdownWorkspaceState.fileDeleted = true; - } - updateSaveStatusDOM('File deleted', 'saved'); - // Disable non-applicable buttons via DOM - const revert = document.getElementById('revert-markdown') as HTMLButtonElement | null; - if (revert) revert.disabled = true; - const openFolder = document.getElementById('open-in-folder') as HTMLButtonElement | null; - if (openFolder) openFolder.disabled = true; - const openEditor = document.getElementById('open-in-editor') as HTMLButtonElement | null; - if (openEditor) openEditor.disabled = true; - return; - } - - const freshPayload = extractRenderPayload(freshResult) ?? null; - if (!freshPayload) return; - const freshContent = stripReadStatusLine(freshPayload.content); - const currentContent = stripReadStatusLine(payload.content); - if (freshContent === currentContent) return; - - syncPayload?.(freshPayload); - localPayloadOverride = freshPayload; - markdownWorkspaceState = undefined; - rerenderCurrent?.(); - } catch { - // Silently fall back to host payload - } -} - -async function readMarkdownPayload(filePath: string, length?: number): Promise { - const result = await rpcCallTool?.('read_file', { - path: filePath, - ...(typeof length === 'number' ? { offset: 0, length } : {}), - }); - return extractRenderPayload(result) ?? null; -} - -async function loadFullMarkdownDocument(payload: RenderPayload, options: { keepEditMode?: boolean } = {}): Promise { - const workspaceState = getMarkdownWorkspaceState(payload); - const range = parseReadRange(payload.content); - if (!range?.isPartial) { - if (options.keepEditMode) { - workspaceState.mode = 'edit'; - workspaceState.editorView = 'markdown'; - workspaceState.notice = null; - workspaceState.error = null; - workspaceState.draftContent = workspaceState.sourceContent; - workspaceState.dirty = false; - rerenderCurrent?.(); - } - return; - } - - workspaceState.loadingDocument = true; - workspaceState.notice = 'Loading full document…'; - workspaceState.error = null; - rerenderCurrent?.(); - - try { - const nextPayload = await readMarkdownPayload(payload.filePath, range.totalLines); - if (!nextPayload) { - workspaceState.error = 'Failed to load the full document.'; - workspaceState.notice = null; - workspaceState.loadingDocument = false; - rerenderCurrent?.(); - return; - } - - syncPayload?.(nextPayload); - const nextState = getMarkdownWorkspaceState(nextPayload); - nextState.loadingDocument = false; - nextState.notice = null; - nextState.error = null; - if (options.keepEditMode) { - nextState.mode = 'edit'; - nextState.editorView = 'markdown'; - nextState.draftContent = nextState.sourceContent; - nextState.dirty = false; - rerenderCurrent?.(); - } - } catch { - workspaceState.loadingDocument = false; - workspaceState.notice = null; - workspaceState.error = 'Failed to load the full document.'; - rerenderCurrent?.(); - } -} - -async function navigateMarkdownLink(payload: RenderPayload, href: string): Promise { - const workspaceState = getMarkdownWorkspaceState(payload); - if (workspaceState.mode === 'edit' && workspaceState.dirty) { - const shouldDiscard = window.confirm('Discard unsaved changes and follow this link?'); - if (!shouldDiscard) { - return; - } - } - - const resolvedLink = resolveMarkdownLink(payload.filePath, href); - workspaceState.notice = null; - workspaceState.error = null; - - if (resolvedLink.kind === 'external' && resolvedLink.url) { - const opened = await openExternalLink?.(resolvedLink.url); - if (!opened && markdownWorkspaceState) { - markdownWorkspaceState.error = 'The host blocked that external link.'; - rerenderCurrent?.(); - } - return; - } - - if (resolvedLink.kind === 'anchor' && resolvedLink.anchor) { - if (!scrollMarkdownHeadingIntoView(resolvedLink.anchor) && markdownWorkspaceState) { - markdownWorkspaceState.error = `Heading not found: ${resolvedLink.anchor}`; - rerenderCurrent?.(); - } - return; - } - - if (resolvedLink.kind === 'file' && resolvedLink.targetPath) { - // Delegate file navigation to the host so it can open the file in its own - // viewer (e.g. dc-app file preview modal with back/forward navigation). - // Fall back to in-app reading if the host doesn't handle the link. - const hostHandled = await openExternalLink?.(resolvedLink.targetPath); - if (hostHandled) { - return; - } - - const nextPayload = await readMarkdownPayload(resolvedLink.targetPath); - if (!nextPayload) { - if (markdownWorkspaceState) { - markdownWorkspaceState.error = `Unable to open ${resolvedLink.targetPath}.`; - rerenderCurrent?.(); - } - return; - } - - syncPayload?.(nextPayload); - const nextState = getMarkdownWorkspaceState(nextPayload); - nextState.pendingAnchor = resolvedLink.anchor ?? null; - nextState.error = null; - nextState.notice = null; - rerenderCurrent?.(); - } -} - -async function requestMarkdownEditMode(payload: RenderPayload): Promise { - const workspaceState = getMarkdownWorkspaceState(payload); - - workspaceState.error = null; - workspaceState.notice = null; - - if (shouldAutoLoadMarkdownOnEnterFullscreen(payload.content)) { - await loadFullMarkdownDocument(payload, { keepEditMode: true }); - return; - } - - const editAvailability = getMarkdownEditAvailability({ - content: payload.content, - }); - if (!editAvailability.canEdit) { - workspaceState.error = editAvailability.reason; - rerenderCurrent?.(); - return; - } +let currentPayload: RenderPayload | undefined; +let currentHtmlMode: HtmlPreviewMode = 'rendered'; +let currentHostContext: Record | undefined; +let rerenderCurrent: (() => void) | undefined; +let syncPayload: ((payload?: RenderPayload) => void) | undefined; +let persistPayload: ((payload: RenderPayload) => void) | undefined; +let localPayloadOverride: RenderPayload | undefined; +let hostPayload: RenderPayload | undefined; +let directoryBackPayload: RenderPayload | undefined; +let selectionAbortController: AbortController | null = null; +const markdownEditorAppCache = new Map(); +const markdownEditorAppPending = new Set(); - workspaceState.mode = 'edit'; - workspaceState.draftContent = workspaceState.fullDocumentContent; - workspaceState.dirty = false; - workspaceState.editorView = 'markdown'; - isExpanded = true; - rerenderCurrent?.(); +async function callToolIfReady(name: string, args: Record): Promise { + return rpcCallTool ? rpcCallTool(name, args) : undefined; } -async function requestMarkdownFullscreen(): Promise { - const fullscreenAvailability = getMarkdownFullscreenAvailability({ - availableDisplayModes: getAvailableDisplayModes(), - }); - if (!fullscreenAvailability.canFullscreen) { - return false; +function getAvailableDisplayModes(): string[] { + const rawModes = currentHostContext?.availableDisplayModes; + if (!Array.isArray(rawModes)) { + return []; } - const nextMode = await requestDisplayMode?.('fullscreen'); - return nextMode === 'fullscreen'; -} -function revertMarkdownEditing(): void { - const ws = markdownWorkspaceState; - if (!ws) return; - ws.draftContent = ws.initialContent; - ws.sourceContent = ws.initialContent; - ws.dirty = ws.initialContent !== ws.fullDocumentContent; - ws.error = null; - ws.notice = null; - // Update currentPayload to match so re-render doesn't recreate state - if (currentPayload) { - currentPayload = { ...currentPayload, content: ws.initialContent }; - } - rerenderCurrent?.(); - updateSaveStatusDOM('Reverted', 'saved'); - window.setTimeout(() => updateSaveStatusDOM('', ''), 1500); + return rawModes.filter((mode): mode is string => typeof mode === 'string'); } -function cancelMarkdownEditing(payload: RenderPayload): void { - const workspaceState = getMarkdownWorkspaceState(payload); - if (workspaceState.dirty) { - const shouldDiscard = window.confirm('Discard unsaved changes?'); - if (!shouldDiscard) { - return; - } - } - - workspaceState.mode = 'preview'; - workspaceState.dirty = false; - workspaceState.draftContent = workspaceState.fullDocumentContent; - workspaceState.notice = null; - workspaceState.error = null; - rerenderCurrent?.(); +function getCurrentDisplayMode(): string | null { + return typeof currentHostContext?.displayMode === 'string' + ? currentHostContext.displayMode + : null; } -interface DiffHunk { - oldStart: number; - oldEnd: number; - newStart: number; - newEnd: number; +function storePayloadOverride(payload: RenderPayload): void { + localPayloadOverride = payload; + currentPayload = payload; + persistPayload?.(payload); } -function computeDiffHunks(oldLines: string[], newLines: string[]): DiffHunk[] { - const m = oldLines.length; - const n = newLines.length; - - // For very large files, treat as single change - if (m * n > 1_000_000) { - return [{ oldStart: 0, oldEnd: m, newStart: 0, newEnd: n }]; - } - - // LCS via DP - const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0) as number[]); - for (let i = 1; i <= m; i++) { - for (let j = 1; j <= n; j++) { - 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]); - } - } - - // Trace back to find matching line pairs - const matches: Array<[number, number]> = []; - let i = m; - let j = n; - while (i > 0 && j > 0) { - if (oldLines[i - 1] === newLines[j - 1]) { - matches.unshift([i - 1, j - 1]); - i--; - j--; - } else if (dp[i - 1][j] >= dp[i][j - 1]) { - i--; - } else { - j--; - } - } - - // Gaps between matches are change hunks - const hunks: DiffHunk[] = []; - let prevOld = 0; - let prevNew = 0; - for (const [oi, ni] of matches) { - if (oi > prevOld || ni > prevNew) { - hunks.push({ oldStart: prevOld, oldEnd: oi, newStart: prevNew, newEnd: ni }); - } - prevOld = oi + 1; - prevNew = ni + 1; - } - if (prevOld < m || prevNew < n) { - hunks.push({ oldStart: prevOld, oldEnd: m, newStart: prevNew, newEnd: n }); +function getEffectiveIncomingPayload(payload: RenderPayload): RenderPayload { + if (!localPayloadOverride) { + return payload; } - return hunks; -} - -function mergeCloseHunks(hunks: DiffHunk[], minGap: number): DiffHunk[] { - if (hunks.length <= 1) return hunks; - const merged: DiffHunk[] = [{ ...hunks[0] }]; - for (let i = 1; i < hunks.length; i++) { - const prev = merged[merged.length - 1]; - const curr = hunks[i]; - if (curr.oldStart - prev.oldEnd < minGap) { - prev.oldEnd = curr.oldEnd; - prev.newEnd = curr.newEnd; - } else { - merged.push({ ...curr }); - } + if (localPayloadOverride.filePath !== payload.filePath) { + localPayloadOverride = undefined; + return payload; } - return merged; -} -function computeEditBlocks(oldText: string, newText: string): Array<{ old_string: string; new_string: string }> { - if (oldText === newText) return []; - - const oldLines = oldText.split('\n'); - const newLines = newText.split('\n'); - const hunks = computeDiffHunks(oldLines, newLines); - if (hunks.length === 0) return []; - - const CONTEXT = 3; - const merged = mergeCloseHunks(hunks, CONTEXT * 2 + 1); - - // If changes cover most of the file, single full edit - const totalChanged = merged.reduce((sum, h) => sum + (h.oldEnd - h.oldStart), 0); - if (totalChanged > oldLines.length * 0.7) { - return [{ old_string: oldText, new_string: newText }]; + const incomingContent = stripReadStatusLine(payload.content); + const overriddenContent = stripReadStatusLine(localPayloadOverride.content); + if (incomingContent === overriddenContent) { + return payload; } - return merged.map((hunk) => { - const ctxBefore = Math.max(0, hunk.oldStart - CONTEXT); - const ctxAfter = Math.min(oldLines.length, hunk.oldEnd + CONTEXT); - - const oldBlock = oldLines.slice(ctxBefore, ctxAfter).join('\n'); - const newBlock = [ - ...oldLines.slice(ctxBefore, hunk.oldStart), - ...newLines.slice(hunk.newStart, hunk.newEnd), - ...oldLines.slice(hunk.oldEnd, ctxAfter), - ].join('\n'); - - return { old_string: oldBlock, new_string: newBlock }; - }).filter((block) => block.old_string !== block.new_string); + return localPayloadOverride; } function updateSaveStatusDOM(label: string, statusClass: string): void { @@ -1453,311 +106,23 @@ function updateSaveStatusDOM(label: string, statusClass: string): void { } } -async function saveMarkdownDocument(): Promise { - const ws = markdownWorkspaceState; - if (!ws || ws.saving || !ws.dirty || ws.fileDeleted) { - return; - } - ws.saving = true; - ws.saveIndicator = 'saving'; - ws.error = null; - ws.notice = null; - - try { - const blocks = computeEditBlocks(ws.fullDocumentContent, ws.draftContent); - if (blocks.length === 0) { - ws.saving = false; - ws.saveIndicator = 'idle'; - ws.dirty = false; - return; - } - - for (const block of blocks) { - await rpcCallTool?.('edit_block', { - file_path: ws.filePath, - old_string: block.old_string, - new_string: block.new_string, - expected_replacements: 1, - }); - } - - ws.fullDocumentContent = ws.draftContent; - ws.sourceContent = ws.draftContent; - ws.dirty = false; - ws.saving = false; - ws.saveIndicator = 'saved'; - - // Update payloads so re-renders and refreshes use saved content (no re-render here) - if (currentPayload) { - const savedPayload: RenderPayload = { ...currentPayload, content: ws.draftContent }; - localPayloadOverride = savedPayload; - currentPayload = savedPayload; - persistPayload?.(savedPayload); - } - - updateSaveStatusDOM('Saved', 'saved'); - const revert = document.getElementById('revert-markdown') as HTMLButtonElement | null; - if (revert) revert.disabled = ws.draftContent === ws.initialContent; - window.setTimeout(() => { - if (markdownWorkspaceState?.filePath === ws.filePath && !markdownWorkspaceState.dirty && !markdownWorkspaceState.saving) { - markdownWorkspaceState.saveIndicator = 'idle'; - updateSaveStatusDOM('', ''); - } - }, 1800); - } catch { - ws.saving = false; - ws.saveIndicator = 'idle'; - updateSaveStatusDOM('Save failed', 'saving'); - window.setTimeout(() => updateSaveStatusDOM('', ''), 3000); - } -} - -function maybeAutosaveMarkdownDocument(): void { - if (!markdownWorkspaceState?.dirty || markdownWorkspaceState.saving) { - return; - } - void saveMarkdownDocument(); -} - -function attachMarkdownWorkspaceHandlers(payload: RenderPayload): void { - if (payload.fileType !== 'markdown') { - return; - } - - const workspaceState = getMarkdownWorkspaceState(payload); - const wrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; - const outline = extractMarkdownOutline(workspaceState.sourceContent); - - - if (workspaceState.mode === 'edit') { - const editorRoot = document.getElementById('markdown-editor-root'); - if (editorRoot) { - markdownEditorHandle = mountMarkdownEditor({ - target: editorRoot, - value: workspaceState.draftContent, - view: workspaceState.editorView, - initialScrollTop: workspaceState.editorScrollTop, - currentFilePath: payload.filePath, - searchLinks: (query) => searchMarkdownLinkTargets(payload.filePath, query), - loadHeadings: (targetPath) => loadMarkdownLinkHeadings(payload.filePath, targetPath), - onChange: (value) => { - workspaceState.draftContent = value; - workspaceState.dirty = value !== workspaceState.fullDocumentContent; - if (workspaceState.dirty && workspaceState.saveIndicator === 'saved') { - workspaceState.saveIndicator = 'idle'; - } - const revert = document.getElementById('revert-markdown') as HTMLButtonElement | null; - if (revert) { - revert.disabled = value === workspaceState.initialContent; - } - }, - onBlur: () => { - maybeAutosaveMarkdownDocument(); - }, - }); - markdownEditorHandle.focus(); - } - - const revertButton = document.getElementById('revert-markdown') as HTMLButtonElement | null; - revertButton?.addEventListener('click', () => { - revertMarkdownEditing(); - }); - - const expandButton = document.getElementById('expand-fullscreen') as HTMLButtonElement | null; - expandButton?.addEventListener('click', () => { - void requestMarkdownFullscreen(); - }); - - const rawModeButton = document.getElementById('markdown-mode-raw') as HTMLButtonElement | null; - rawModeButton?.addEventListener('click', () => { - setMarkdownEditorView(payload, 'raw'); - }); - - const previewModeButton = document.getElementById('markdown-mode-markdown') as HTMLButtonElement | null; - previewModeButton?.addEventListener('click', () => { - setMarkdownEditorView(payload, 'markdown'); - }); - } - - if (wrapper) { - wrapper.addEventListener('click', (event) => { - const target = event.target as HTMLElement | null; - const link = target?.closest('a[href]'); - if (!link || !link.closest('.markdown-doc')) { - return; - } - const href = link.getAttribute('href'); - if (!href) { - return; - } - - event.preventDefault(); - void navigateMarkdownLink(payload, href); - }); - } - - const tocShell = document.querySelector('.markdown-toc-shell') as HTMLElement | null; - if (tocShell && wrapper) { - markdownTocHandle = attachMarkdownToc({ - shell: tocShell, - outline, - scrollContainer: wrapper, - onSelect: (headingId) => { - const selectedHeading = outline.find((item) => item.id === headingId); - if (workspaceState.mode === 'edit') { - if (selectedHeading) { - markdownEditorHandle?.revealLine(selectedHeading.line, selectedHeading.id); - workspaceState.activeHeadingId = selectedHeading.id; - } - return; - } - - scrollMarkdownHeadingIntoView(headingId); - }, - }) ?? undefined; - } - - window.setTimeout(() => { - applyPendingMarkdownAnchor(); - }, 0); -} - -/** - * Tracks native text selection and pushes it to the host via ui/update-model-context. - * - * How it works: - * 1. User drags to select text anywhere in the preview (markdown, code, HTML). - * 2. The selectionchange event fires; we extract the selected string. - * 3. We call rpcUpdateContext() which sends a ui/update-model-context JSON-RPC - * request to the host with the selected text + file path (+ line numbers for code). - * 4. The host stores this as widget context. - * 5. The LLM can access it by calling read_widget_context(tool_name="desktop-commander:read_file"). - * - * Note: as of Feb 2025, Claude does NOT auto-inject ui/update-model-context into - * the LLM's context window. The LLM must actively call read_widget_context to see - * the selection. A floating tooltip near the selection tells the user this is working. - */ -let selectionAbortController: AbortController | null = null; - -function attachTextSelectionHandler(payload: RenderPayload): void { - if (payload.fileType === 'markdown' && getMarkdownWorkspaceState(payload).mode === 'edit') { - if (selectionAbortController) { - selectionAbortController.abort(); - selectionAbortController = null; - } - return; - } - - const contentWrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null; - if (!contentWrapper) return; - - // Abort any previous selectionchange listener to avoid leaking listeners/closures - if (selectionAbortController) { - selectionAbortController.abort(); - selectionAbortController = null; - } - selectionAbortController = new AbortController(); - - let hintEl: HTMLElement | null = null; - let lastSelectedText = ''; - let hideTimer: ReturnType | null = null; - - function positionHint(selection: Selection): void { - if (!hintEl) return; - const range = selection.getRangeAt(0); - const rect = range.getBoundingClientRect(); - const wrapperRect = contentWrapper!.getBoundingClientRect(); - - // Position above the selection, centered horizontally - let left = rect.left + rect.width / 2 - wrapperRect.left; - let top = rect.top - wrapperRect.top + contentWrapper!.scrollTop - 32; - - // Clamp within wrapper bounds - const hintWidth = hintEl.offsetWidth || 200; - left = Math.max(8, Math.min(left - hintWidth / 2, contentWrapper!.clientWidth - hintWidth - 8)); - top = Math.max(4, top); - - hintEl.style.left = `${left}px`; - hintEl.style.top = `${top}px`; - } - - function showHint(selection: Selection): void { - if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } - - if (!hintEl) { - hintEl = document.createElement('div'); - hintEl.className = 'selection-hint'; - hintEl.textContent = 'AI can see your selection'; - contentWrapper!.appendChild(hintEl); - } - hintEl.classList.add('visible'); - positionHint(selection); - } - - function hideHint(): void { - if (!hintEl) return; - hintEl.classList.remove('visible'); - hideTimer = setTimeout(() => { hintEl?.remove(); hintEl = null; }, 200); - } - - function getLineInfo(selection: Selection): string { - const anchorRow = selection.anchorNode?.parentElement?.closest('.code-line') as HTMLElement | null; - const focusRow = selection.focusNode?.parentElement?.closest('.code-line') as HTMLElement | null; - if (anchorRow && focusRow) { - const a = parseInt(anchorRow.dataset.line ?? '', 10); - const f = parseInt(focusRow.dataset.line ?? '', 10); - if (!isNaN(a) && !isNaN(f)) { - const low = Math.min(a, f); - const high = Math.max(a, f); - return low === high ? `line ${low}` : `lines ${low}–${high}`; - } - } - return ''; - } - - document.addEventListener('selectionchange', () => { - const selection = document.getSelection(); - if (!selection || selection.isCollapsed) { - if (lastSelectedText) { - lastSelectedText = ''; - rpcUpdateContext?.(''); - hideHint(); - } - return; - } - - const text = selection.toString().trim(); - if (!text || text === lastSelectedText) return; - - // Only act on selections within our content area - const anchorInContent = contentWrapper!.contains(selection.anchorNode); - const focusInContent = contentWrapper!.contains(selection.focusNode); - if (!anchorInContent && !focusInContent) { - if (lastSelectedText) { - lastSelectedText = ''; - rpcUpdateContext?.(''); - hideHint(); - } - return; - } - - lastSelectedText = text; - - const lineInfo = getLineInfo(selection); - const locationPart = lineInfo ? ` (${lineInfo})` : ''; - const context = `User selected text from file ${payload.filePath}${locationPart}:\n\`\`\`\n${text}\n\`\`\``; - - rpcUpdateContext?.(context); - showHint(selection); - - trackUiEvent?.('text_selected', { - file_type: payload.fileType, - file_extension: getFileExtensionForAnalytics(payload.filePath), - char_count: text.length - }); - }, { signal: selectionAbortController!.signal }); -} - +const markdownController = createMarkdownController({ + callTool: callToolIfReady, + openExternalLink: async (url) => (openExternalLink ? openExternalLink(url) : undefined), + requestDisplayMode: async (mode) => (requestDisplayMode ? requestDisplayMode(mode) : undefined), + getAvailableDisplayModes, + getCurrentDisplayMode, + getCurrentPayload: () => currentPayload, + setExpanded: (expanded) => { + isExpanded = expanded; + }, + syncPayload: (payload) => syncPayload?.(payload), + storePayloadOverride, + rerender: () => { + rerenderCurrent?.(); + }, + updateSaveStatus: updateSaveStatusDOM, +}); function renderStatusState(container: HTMLElement, message: string): void { container.innerHTML = ` @@ -1787,162 +152,120 @@ export function renderApp( currentHtmlMode = htmlMode; shellController?.dispose(); shellController = undefined; - disposeMarkdownWorkspaceHandles(); + + if (!payload || payload.fileType !== 'markdown') { + markdownController.clear(); + } else { + markdownController.disposeHandles(); + } if (!payload) { + selectionAbortController?.abort(); + selectionAbortController = null; currentPayload = undefined; renderStatusState(container, 'No preview available for this response.'); onRender?.(); return; } - updateCurrentPayload(payload); - - if (payload.fileType !== 'markdown') { - markdownWorkspaceState = undefined; + currentPayload = payload; + const capabilities = getFileTypeCapabilities(payload); + if (!capabilities.supportsPreview && hideSummaryRow) { + isExpanded = false; } - const markdownWorkspace = payload.fileType === 'markdown' ? getMarkdownWorkspaceState(payload) : undefined; - const canCopy = payload.fileType !== 'unsupported' && payload.fileType !== 'image'; - const canOpenInFolder = !isLikelyUrl(payload.filePath); + const range = parseReadRange(payload.content); + const body = renderPayloadBody({ + payload, + htmlMode, + startLine: range?.fromLine ?? 1, + markdownController, + }); + const markdownWorkspace = payload.fileType === 'markdown' ? markdownController.getState(payload) : undefined; const fileExtension = getFileExtensionForAnalytics(payload.filePath); - const supportsPreview = payload.fileType !== 'unsupported'; + const isFullscreen = getCurrentDisplayMode() === 'fullscreen'; + const canGoFullscreen = !isFullscreen && getDocumentFullscreenAvailability({ + availableDisplayModes: getAvailableDisplayModes(), + }).canFullscreen; - // In DC app (hideSummaryRow), no reason to auto-expand when there's nothing to preview β€” - // the host header already shows the file name and path. - if (!supportsPreview && hideSummaryRow) { - isExpanded = false; + const defaultMarkdownEditor = payload.fileType === 'markdown' + ? markdownEditorAppCache.get(payload.filePath) + : undefined; + if (payload.fileType === 'markdown' && !defaultMarkdownEditor) { + void detectDefaultMarkdownEditor({ + filePath: payload.filePath, + editorAppCache: markdownEditorAppCache, + editorAppPending: markdownEditorAppPending, + callTool: callToolIfReady, + extractToolText, + onDetected: () => { + rerenderCurrent?.(); + }, + }); } - const range = parseReadRange(payload.content); - const body = renderBody(payload, htmlMode, range?.fromLine ?? 1); - const notice = body.notice ? `
    ${body.notice}
    ` : ''; - - const breadcrumb = buildBreadcrumb(payload.filePath); - const lineCount = range ? range.toLine - range.fromLine + 1 : countContentLines(payload.content); - const fileTypeLabel = payload.fileType === 'markdown' ? 'MARKDOWN' - : payload.fileType === 'html' ? 'HTML' - : payload.fileType === 'image' ? 'IMAGE' - : payload.fileType === 'directory' ? 'DIRECTORY' - : fileExtension !== 'none' ? fileExtension.toUpperCase() - : 'TEXT'; - const compactLabel = range?.isPartial - ? `View lines ${range.fromLine}–${range.toLine}` - : payload.fileType === 'directory' ? 'View directory' - : 'View file'; - let footerLabel = range?.isPartial - ? `${escapeHtml(fileTypeLabel)} β€’ LINES ${range.fromLine}–${range.toLine} OF ${range.totalLines}` - : `${escapeHtml(fileTypeLabel)} β€’ ${lineCount} LINE${lineCount !== 1 ? 'S' : ''}`; - const markdownWordCount = payload.fileType === 'markdown' - ? (stripReadStatusLine(markdownWorkspace?.mode === 'edit' ? markdownWorkspace.draftContent : payload.content).trim().split(/\s+/).filter(Boolean).length) - : 0; - const markdownLineCount = payload.fileType === 'markdown' - ? countContentLines(stripReadStatusLine(markdownWorkspace?.mode === 'edit' ? markdownWorkspace.draftContent : payload.content)) - : lineCount; - - if (markdownWorkspace?.mode === 'edit') { - footerLabel = `${escapeHtml(fileTypeLabel)} β€’ EDIT MODE β€’ ${markdownLineCount} LINES β€’ ${markdownWordCount} WORDS`; - } + const layout = buildDocumentLayout({ + payload, + body, + capabilities, + fileExtension, + htmlMode, + currentDisplayMode: getCurrentDisplayMode(), + isExpanded, + hideSummaryRow, + markdownWorkspace, + canGoFullscreen, + isMarkdownUndoAvailable: markdownWorkspace ? markdownController.isUndoAvailable(markdownWorkspace) : false, + defaultMarkdownEditorName: defaultMarkdownEditor?.appName, + markdownEditorAppIcon: renderMarkdownEditorAppIcon(), + hasDirectoryBackButton: Boolean(directoryBackPayload), + }); - const htmlToggle = payload.fileType === 'html' - ? `` - : ''; + container.innerHTML = layout.html; + document.body.classList.add('dc-ready'); - const copyIcon = ``; - const folderIcon = ``; - const undoIcon = ``; - const expandIcon = ``; - const isFullscreen = getCurrentDisplayMode() === 'fullscreen'; - const canGoFullscreen = !isFullscreen && getMarkdownFullscreenAvailability({ availableDisplayModes: getAvailableDisplayModes() }).canFullscreen; - let markdownActions = ''; - if (payload.fileType === 'markdown' && markdownWorkspace) { - const saveStatusLabel = markdownWorkspace.saveIndicator === 'saved' - ? 'Saved' - : ''; - if (markdownWorkspace.mode === 'edit') { - const deleted = markdownWorkspace.fileDeleted; - const revertDisabled = deleted || markdownWorkspace.loadingDocument || markdownWorkspace.draftContent === markdownWorkspace.initialContent; - if (isFullscreen) { - markdownActions = ` - ${deleted ? 'File deleted' : ''} - ${!deleted && saveStatusLabel ? `${saveStatusLabel}` : ''} - ${renderMarkdownModeToggle(markdownWorkspace.editorView)} - ${renderMarkdownCopyButton()} - - `; - } else { - markdownActions = ` - ${deleted ? 'File deleted' : ''} - ${!deleted && saveStatusLabel ? `${saveStatusLabel}` : ''} - ${canGoFullscreen ? `` : ''} - - - `; - } - } - } + attachPanelActions({ + container, + payload, + htmlMode, + getIsExpanded: () => isExpanded, + callTool: callToolIfReady, + trackUiEvent, + getFileExtensionForAnalytics, + buildOpenInFolderCommand: (filePath) => buildOpenInFolderCommand(filePath, isLikelyUrl), + buildOpenInEditorCommand: (filePath) => buildOpenInEditorCommand(filePath, isLikelyUrl, markdownEditorAppCache), + render: (nextPayload, nextHtmlMode = 'rendered', nextExpanded = isExpanded) => { + renderApp(container, nextPayload, nextHtmlMode, nextExpanded); + }, + updateSaveStatus: updateSaveStatusDOM, + markdownController, + }); - const editorIcon = ``; - const defaultMarkdownEditor = payload.fileType === 'markdown' ? markdownEditorAppCache.get(payload.filePath) : undefined; - if (payload.fileType === 'markdown' && !defaultMarkdownEditor) { - void detectDefaultMarkdownEditor(payload.filePath); + if (payload.fileType === 'markdown') { + markdownController.attachHandlers(payload); } - - // Content-area banners for missing lines - const hasMissingBefore = range?.isPartial && range.fromLine > 1; - const hasMissingAfter = range?.isPartial && range.toLine < range.totalLines && (range.totalLines - range.toLine) > 1; - const loadBeforeBanner = hasMissingBefore - ? `` - : ''; - const loadAfterBanner = hasMissingAfter - ? `` - : ''; - const effectiveExpanded = isExpanded || getCurrentDisplayMode() === 'fullscreen'; - const backButton = (directoryBackPayload && payload.fileType !== 'directory') - ? `` - : ''; + selectionAbortController = attachSelectionContext({ + payload, + isMarkdownEditing: payload.fileType === 'markdown' && markdownWorkspace?.mode === 'edit', + updateContext: rpcUpdateContext, + trackUiEvent, + getFileExtensionForAnalytics, + previousAbortController: selectionAbortController, + }); - container.innerHTML = ` -
    - ${getCurrentDisplayMode() === 'fullscreen' ? '' : renderCompactRow({ id: 'compact-toggle', label: compactLabel, filename: payload.fileName, variant: 'ready', expandable: true, expanded: isExpanded, interactive: true })} -
    -
    - ${backButton} - ${hideSummaryRow ? '' : `${breadcrumb}`} - - ${markdownActions} - ${htmlToggle} - ${canOpenInFolder && payload.fileType === 'markdown' && markdownWorkspace?.mode === 'edit' && isFullscreen ? `` : ''} - ${canOpenInFolder && payload.fileType === 'markdown' && markdownWorkspace?.mode === 'edit' && !isFullscreen ? `` : ''} - ${canOpenInFolder && !(payload.fileType === 'markdown' && markdownWorkspace?.mode === 'edit') ? `` : ''} - ${canCopy && supportsPreview && payload.fileType !== 'markdown' ? `` : ''} - -
    - ${notice} -
    - ${loadBeforeBanner} - ${body.html} - ${loadAfterBanner} -
    - -
    -
    - `; - document.body.classList.add('dc-ready'); - attachCopyHandler(payload); - attachHtmlToggleHandler(container, payload, htmlMode); - attachOpenInFolderHandler(payload); - attachOpenInEditorHandler(payload); - attachLoadAllHandler(container, payload, htmlMode); - attachMarkdownWorkspaceHandlers(payload); - attachTextSelectionHandler(payload); if (payload.fileType === 'directory') { - attachDirectoryHandlers(container, payload); + attachDirectoryHandlers({ + container, + callTool: callToolIfReady, + buildOpenInFolderCommand: (filePath) => buildOpenInFolderCommand(filePath, isLikelyUrl), + onOpenPayload: (nextPayload) => { + directoryBackPayload = payload; + renderApp(container, nextPayload, 'rendered', true); + }, + }); } - // Back to directory navigation + const backBtn = document.getElementById('dir-back'); if (backBtn && directoryBackPayload) { const savedPayload = directoryBackPayload; @@ -1951,38 +274,36 @@ export function renderApp( renderApp(container, savedPayload, 'rendered', true); }); } - // Clear back state when showing a directory if (payload.fileType === 'directory') { directoryBackPayload = undefined; } const compactRow = document.getElementById('compact-toggle') as HTMLElement | null; - shellController = createCompactRowShellController({ shell: document.getElementById('tool-shell'), compactRow, - initialExpanded: effectiveExpanded, + initialExpanded: layout.effectiveExpanded, onToggle: (expanded) => { isExpanded = expanded; trackUiEvent?.(expanded ? 'expand' : 'collapse', { file_type: payload.fileType, - file_extension: fileExtension + file_extension: fileExtension, }); }, onScrollAfterExpand: () => { trackUiEvent?.('scroll_after_expand', { file_type: payload.fileType, - file_extension: fileExtension + file_extension: fileExtension, }); }, - onRender + onRender, }); onRender?.(); if (!previewShownFired) { previewShownFired = true; trackUiEvent?.('preview_shown', { file_type: payload.fileType, - file_extension: fileExtension + file_extension: fileExtension, }); } } @@ -1994,8 +315,6 @@ export function bootstrapApp(): void { } renderLoadingState(container); - // Use the official App class – it connects to the host via PostMessageTransport - // (window.parent by default) and speaks standard MCP JSON-RPC 2.0 over postMessage. const app = new App( { name: 'Desktop Commander File Preview', version: '1.0.0' }, { updateModelContext: { text: {} } }, @@ -2011,9 +330,8 @@ export function bootstrapApp(): void { hideSummaryRow = chrome.hideSummaryRow; }; - // Widget state for cross-host persistence (survives page refresh) const widgetState = createWidgetStateStorage( - (v): v is RenderPayload => isPreviewStructuredContent(v) && typeof (v as any).content === 'string' + (value): value is RenderPayload => isPreviewStructuredContent(value) && typeof (value as any).content === 'string' ); const renderAndSync = (payload?: RenderPayload): void => { @@ -2038,16 +356,15 @@ export function bootstrapApp(): void { renderAndSync(persistedPayload); }; + syncPayload = renderAndSync; - persistPayload = (payload: RenderPayload) => { widgetState.write(payload); }; + persistPayload = (payload: RenderPayload) => { + widgetState.write(payload); + }; rerenderCurrent = () => { renderApp(container, currentPayload, currentHtmlMode, isExpanded); }; - // Cached payload from a previous session, stashed at onConnected. Used when - // the host's ontoolinput announces the same file path so we can show the - // last-known content instantly instead of flashing a loading state on reopen. - // Fresh tool_result still wins and replaces the cached render when it arrives. let pendingCachedPayload: RenderPayload | undefined; let initialStateResolved = false; const resolveInitialState = (payload?: RenderPayload, message?: string): void => { @@ -2056,13 +373,13 @@ export function bootstrapApp(): void { } initialStateResolved = true; if (payload) { + hostPayload = payload; renderAndSync(payload); if (payload.fileType === 'markdown' && getCurrentDisplayMode() === 'fullscreen') { - void requestMarkdownEditMode(payload); + void markdownController.requestEditMode(payload); } - // Re-read markdown from disk to pick up any saves from a previous session if (payload.fileType === 'markdown') { - void refreshMarkdownFromDisk(payload); + void markdownController.refreshFromDisk(payload); } return; } @@ -2070,34 +387,27 @@ export function bootstrapApp(): void { onRender?.(); }; - // autoResize handles size reporting; onRender can be a no-op onRender = () => {}; - // Wire rpcCallTool through the App's callServerTool proxy rpcCallTool = (name: string, args: Record): Promise => ( app.callServerTool({ name, arguments: args }) ); - - // Wire rpcUpdateContext through the App's updateModelContext rpcUpdateContext = (text: string): void => { const params = text ? { content: [{ type: 'text' as const, text }] } : { content: [] as [] }; app.updateModelContext(params).catch(() => { - // Host may not support updateModelContext + // Host may not support updateModelContext. }); }; - openExternalLink = async (url: string): Promise => { const result = await app.openLink({ url }); return result.isError !== true; }; - requestDisplayMode = async (mode: 'inline' | 'fullscreen'): Promise => { const result = await app.requestDisplayMode({ mode }); return typeof result.mode === 'string' ? result.mode : null; }; - trackUiEvent = createUiEventTracker( (name, args) => app.callServerTool({ name, arguments: args }), { @@ -2106,17 +416,7 @@ export function bootstrapApp(): void { } ); - // Register ALL handlers BEFORE connect - app.onteardown = async () => { - shellController?.dispose(); - disposeMarkdownWorkspaceHandles(); - return {}; - }; - app.ontoolinput = (params) => { - // If we have a cached payload from a previous session for the file the - // host is now asking us to preview, render it immediately so reopening - // the same document feels instant. Fresh tool_result will replace it. const requestedPath = typeof params.arguments?.path === 'string' ? params.arguments.path : undefined; if ( !initialStateResolved @@ -2130,13 +430,11 @@ export function bootstrapApp(): void { return; } - // Tool is executing – show loading state renderLoadingState(container); onRender?.(); }; app.ontoolresult = (result) => { - // Fresh data wins; discard any cache hint we held for the optimistic render path. pendingCachedPayload = undefined; const payload = extractRenderPayload(result); const message = extractToolText(result as unknown as Record); @@ -2150,9 +448,9 @@ export function bootstrapApp(): void { } return; } + if (payload) { - const effectivePayload = getEffectiveIncomingPayload(payload); - renderAndSync(effectivePayload); + renderAndSync(getEffectiveIncomingPayload(payload)); } else if (message) { renderStatusState(container, message); onRender?.(); @@ -2163,7 +461,33 @@ export function bootstrapApp(): void { resolveInitialState(undefined, params.reason ?? 'Tool was cancelled.'); }; - // Connect to the host (defaults to window.parent via PostMessageTransport) + const handleVisibilitySync = (): void => { + if (document.visibilityState === 'visible') { + syncFromPersistedWidgetState(); + } + }; + const handleFocusSync = (): void => { + syncFromPersistedWidgetState(); + }; + + const teardown = (): void => { + shellController?.dispose(); + shellController = undefined; + markdownController.disposeHandles(); + selectionAbortController?.abort(); + selectionAbortController = null; + document.removeEventListener('visibilitychange', handleVisibilitySync); + window.removeEventListener('focus', handleFocusSync); + }; + + document.addEventListener('visibilitychange', handleVisibilitySync); + window.addEventListener('focus', handleFocusSync); + + app.onteardown = async () => { + teardown(); + return {}; + }; + void connectWithSharedHostContext({ app, chrome, @@ -2179,24 +503,42 @@ export function bootstrapApp(): void { ) { isExpanded = true; chrome.expanded = true; - if (markdownWorkspaceState) { - markdownWorkspaceState.notice = null; - markdownWorkspaceState.editorView = 'markdown'; + const hostWasPartial = hostPayload ? parseReadRange(hostPayload.content)?.isPartial === true : false; + if (hostWasPartial) { + localPayloadOverride = undefined; + if (hostPayload) { + currentPayload = hostPayload; + widgetState.write(hostPayload); + } + void markdownController.handleInlineExitFromFullscreen(hostPayload).then((freshPayload) => { + if (freshPayload) { + hostPayload = freshPayload; + currentPayload = freshPayload; + localPayloadOverride = freshPayload; + widgetState.write(freshPayload); + rerenderCurrent?.(); + } + }); + } else { + void markdownController.handleInlineExitFromFullscreen(); } } + if ( + previousDisplayMode !== 'fullscreen' + && nextDisplayMode === 'fullscreen' + && currentPayload?.fileType === 'markdown' + && markdownController.getState(currentPayload).mode !== 'edit' + ) { + void markdownController.requestEditMode(currentPayload); + } if (initialStateResolved) { rerenderCurrent?.(); } }, onConnected: () => { currentHostContext = app.getHostContext() as Record | undefined; - // Stash any persisted payload so ontoolinput can show it instantly - // when the host announces the same file path. Fresh tool_result still - // wins. If the host never sends ontoolresult, the 8s fallback below - // surfaces an error so the user doesn't see stale or empty content. pendingCachedPayload = widgetState.read() ?? undefined; - // Fallback: if no tool data arrives, show a helpful status message window.setTimeout(() => { if (!initialStateResolved) { resolveInitialState( @@ -2211,23 +553,7 @@ export function bootstrapApp(): void { onRender?.(); }); - const handleVisibilitySync = (): void => { - if (document.visibilityState === 'visible') { - syncFromPersistedWidgetState(); - } - }; - - const handleFocusSync = (): void => { - syncFromPersistedWidgetState(); - }; - - document.addEventListener('visibilitychange', handleVisibilitySync); - window.addEventListener('focus', handleFocusSync); - window.addEventListener('beforeunload', () => { - shellController?.dispose(); - disposeMarkdownWorkspaceHandles(); - document.removeEventListener('visibilitychange', handleVisibilitySync); - window.removeEventListener('focus', handleFocusSync); + teardown(); }, { once: true }); } diff --git a/src/ui/file-preview/src/components/markdown-renderer.ts b/src/ui/file-preview/src/components/markdown-renderer.ts index 35723415..5f7d0aa0 100644 --- a/src/ui/file-preview/src/components/markdown-renderer.ts +++ b/src/ui/file-preview/src/components/markdown-renderer.ts @@ -1,51 +1,11 @@ /** * Markdown rendering pipeline for preview mode. It configures markdown-it and highlighting so markdown content is rendered consistently with code block support. */ -// markdown-it is intentionally typed locally here to avoid maintaining global ambient module declarations. -// @ts-expect-error markdown-it does not provide local TypeScript typings in this setup. -import MarkdownIt from 'markdown-it'; import { highlightSource } from './highlighting.js'; -import { rewriteWikiLinks } from '../markdown-workspace/linking.js'; -import { createSlugTracker } from '../markdown-workspace/slugify.js'; +import { createMarkdownIt, prepareMarkdownSource, readHeadingProjection, type MarkdownToken } from '../markdown/parser.js'; +import { createSlugTracker } from '../markdown/slugify.js'; -interface MarkdownRenderer { - render: (source: string, env?: Record) => string; - renderer: { - rules: Record string>; - }; -} - -type MarkdownItConstructor = new (options?: { - html?: boolean; - linkify?: boolean; - typographer?: boolean; - highlight?: (code: string, language: string) => string; -}) => MarkdownRenderer; - -const MarkdownItCtor = MarkdownIt as unknown as MarkdownItConstructor; - -function extractInlineText(token: Record | undefined): string { - if (!token) { - return ''; - } - - const children = Array.isArray(token.children) ? token.children : []; - if (children.length === 0) { - return typeof token.content === 'string' ? token.content : ''; - } - - return children.map((child) => { - if (typeof child.content === 'string') { - return child.content; - } - return ''; - }).join(''); -} - -const markdown = new MarkdownItCtor({ - html: false, - linkify: true, - typographer: false, +const markdown = createMarkdownIt({ highlight(code: string, language: string): string { const normalizedLanguage = (language || 'text').toLowerCase(); const highlighted = highlightSource(code, normalizedLanguage); @@ -55,7 +15,7 @@ const markdown = new MarkdownItCtor({ const renderHeadingOpen = markdown.renderer.rules.heading_open; markdown.renderer.rules.heading_open = (...args: unknown[]): string => { - const tokens = args[0] as Array>; + const tokens = args[0] as MarkdownToken[]; const index = args[1] as number; const options = args[2] as unknown; const environment = (args[3] as Record | undefined) ?? {}; @@ -65,18 +25,18 @@ markdown.renderer.rules.heading_open = (...args: unknown[]): string => { : createSlugTracker(); environment.nextSlug = nextSlug; - const inlineToken = tokens[index + 1]; - const headingText = extractInlineText(inlineToken).trim(); - const headingId = nextSlug(headingText || 'section'); + const heading = readHeadingProjection(tokens, index, nextSlug); const token = tokens[index] as { attrSet?: (name: string, value: string) => void }; - token.attrSet?.('id', headingId); - token.attrSet?.('data-heading-id', headingId); + if (heading) { + token.attrSet?.('id', heading.id); + token.attrSet?.('data-heading-id', heading.id); + } if (typeof renderHeadingOpen === 'function') { return renderHeadingOpen(...args); } - return self.renderToken(tokens, index, options); + return self.renderToken(tokens as Array>, index, options); }; const renderLinkOpen = markdown.renderer.rules.link_open; @@ -104,5 +64,5 @@ markdown.renderer.rules.link_open = (...args: unknown[]): string => { }; export function renderMarkdown(content: string): string { - return markdown.render(rewriteWikiLinks(content), { nextSlug: createSlugTracker() }); + return markdown.render(prepareMarkdownSource(content), { nextSlug: createSlugTracker() }); } diff --git a/src/ui/file-preview/src/components/toolbar.ts b/src/ui/file-preview/src/components/toolbar.ts deleted file mode 100644 index 5298a4bc..00000000 --- a/src/ui/file-preview/src/components/toolbar.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Toolbar component for preview controls (view mode, metadata, actions). It isolates UI control rendering and event plumbing from core preview orchestration. - */ -import type { FilePreviewStructuredContent } from '../../../../types.js'; -import type { HtmlPreviewMode } from '../types.js'; -import { renderToolHeader } from '../../../shared/tool-header.js'; - -function inferFilePill(payload: FilePreviewStructuredContent): { label: string; className: string } { - if (payload.fileType === 'markdown') { - return { label: 'MD', className: 'file-pill--md' }; - } - if (payload.fileType === 'html') { - return { label: 'HTML', className: 'file-pill--html' }; - } - - const extensionMatch = payload.filePath.toLowerCase().match(/\.([a-z0-9]+)$/); - const extension = extensionMatch ? extensionMatch[1] : 'txt'; - - if (extension === 'json') { - return { label: 'JSON', className: 'file-pill--json' }; - } - - return { label: extension.slice(0, 4).toUpperCase(), className: 'file-pill--text' }; -} - -export function renderToolbar( - payload: FilePreviewStructuredContent, - canCopy: boolean, - htmlMode: HtmlPreviewMode, - isExpanded: boolean, - canOpenInFolder: boolean -): string { - const supportsPreview = payload.fileType !== 'unsupported'; - const copyDisabled = canCopy ? '' : 'disabled'; - const copyTitle = canCopy ? 'Copy source' : 'Copy unavailable'; - const copyIcon = ` - - `; - const folderDisabled = canOpenInFolder ? '' : 'disabled'; - const folderTitle = canOpenInFolder ? 'Open in folder' : 'Open in folder unavailable'; - const folderIcon = ` - - `; - const previewIcon = isExpanded - ? `` - : ``; - - const htmlModeButton = payload.fileType === 'html' - ? ` - - ` - : ''; - - const filePill = inferFilePill(payload); - const leadingActions = supportsPreview - ? ` - - ` - : ''; - const trailingActions = supportsPreview - ? ` - ${htmlModeButton} - - ` - : ''; - - return renderToolHeader({ - pillLabel: filePill.label, - pillClassName: filePill.className, - title: payload.fileName, - subtitle: payload.filePath, - badges: [], - actionsHtml: ` - ${leadingActions} - - ${trailingActions} - ` - }); -} diff --git a/src/ui/file-preview/src/directory-controller.ts b/src/ui/file-preview/src/directory-controller.ts new file mode 100644 index 00000000..75feca32 --- /dev/null +++ b/src/ui/file-preview/src/directory-controller.ts @@ -0,0 +1,272 @@ +import { escapeHtml } from './components/highlighting.js'; +import type { RenderBodyResult, RenderPayload } from './model.js'; +import { buildRenderPayload, extractToolText } from './payload-utils.js'; + +interface DirEntry { + name: string; + isDir: boolean; + isDenied: boolean; + isWarning: boolean; + warningText: string; + children: DirEntry[]; + relativePath: string; +} + +function parseDirectoryEntries(content: string): { hint: string; entries: DirEntry[] } { + const lines = content.split('\n'); + const hintLines: string[] = []; + const entryLines: string[] = []; + for (const line of lines) { + if (/^\[(DIR|FILE|DENIED|WARNING)\]/.test(line.trim())) { + entryLines.push(line.trim()); + } else if (entryLines.length === 0) { + hintLines.push(line); + } + } + + const flat: Array<{ + name: string; + fullPath: string; + isDir: boolean; + isDenied: boolean; + isWarning: boolean; + warningText: string; + depth: number; + }> = []; + for (const line of entryLines) { + if (line.startsWith('[WARNING]')) { + const warnBody = line.replace(/^\[WARNING\]\s*/, ''); + const colonIdx = warnBody.indexOf(':'); + const dirName = colonIdx >= 0 ? warnBody.slice(0, colonIdx).trim() : ''; + const msg = colonIdx >= 0 ? warnBody.slice(colonIdx + 1).trim() : warnBody; + const parts = dirName.replace(/\\/g, '/').split('/').filter(Boolean); + flat.push({ + name: dirName, + fullPath: dirName, + isDir: false, + isDenied: false, + isWarning: true, + warningText: msg, + depth: parts.length, + }); + continue; + } + + const isDir = line.startsWith('[DIR]'); + const isDenied = line.startsWith('[DENIED]'); + const name = line.replace(/^\[(DIR|FILE|DENIED)\]\s*/, ''); + const parts = name.replace(/\\/g, '/').split('/'); + flat.push({ + name, + fullPath: name, + isDir, + isDenied, + isWarning: false, + warningText: '', + depth: parts.length - 1, + }); + } + + const root: DirEntry[] = []; + const stack: DirEntry[][] = [root]; + + for (const item of flat) { + const baseName = item.fullPath.replace(/\\/g, '/').split('/').pop() ?? item.fullPath; + const entry: DirEntry = { + name: baseName, + isDir: item.isDir, + isDenied: item.isDenied, + isWarning: item.isWarning, + warningText: item.warningText, + children: [], + relativePath: item.fullPath, + }; + + while (stack.length > item.depth + 1) { + stack.pop(); + } + + const parent = stack[stack.length - 1]; + parent.push(entry); + + if (item.isDir) { + stack.push(entry.children); + } + } + + return { hint: hintLines.join('\n').trim(), entries: root }; +} + +let dirEntryIdCounter = 0; + +function renderDirTree(entries: DirEntry[], rootPath: string): string { + if (entries.length === 0) { + return '
    Empty directory
    '; + } + + function renderEntries(items: DirEntry[]): string { + return items.map((item) => { + const id = `de-${dirEntryIdCounter++}`; + const fullPath = `${rootPath}/${item.relativePath.replace(/\\/g, '/')}`; + const escapedPath = escapeHtml(fullPath); + + if (item.isWarning) { + return `
    `; + } + if (item.isDenied) { + return `
    🚫 ${escapeHtml(item.name)}
    `; + } + if (item.isDir) { + const hasChildren = item.children.length > 0; + const chevron = `${hasChildren ? 'β–Ό' : 'β–Ά'}`; + const openButton = ``; + const childrenHtml = hasChildren ? `
    ${renderEntries(item.children)}
    ` : ''; + return `
    ${chevron} πŸ“ ${escapeHtml(item.name)}${openButton}
    ${childrenHtml}
    `; + } + + return `
    πŸ“„ ${escapeHtml(item.name)}
    `; + }).join(''); + } + + return `
    ${renderEntries(entries)}
    `; +} + +export function renderDirectoryBody(content: string, rootPath: string): RenderBodyResult { + dirEntryIdCounter = 0; + const { hint, entries } = parseDirectoryEntries(content); + return { + notice: hint || undefined, + html: `
    ${renderDirTree(entries, rootPath)}
    `, + }; +} + +export function attachDirectoryHandlers(options: { + container: HTMLElement; + callTool?: (name: string, args: Record) => Promise; + buildOpenInFolderCommand: (filePath: string) => string | undefined; + onOpenPayload: (payload: RenderPayload) => void; +}): void { + const tree = options.container.querySelector('.dir-tree'); + if (!tree) { + return; + } + + tree.addEventListener('click', async (event) => { + const openBtn = (event.target as HTMLElement).closest('.dir-open-btn') as HTMLElement | null; + if (openBtn) { + event.stopPropagation(); + const openPath = openBtn.dataset.openpath; + if (!openPath) { + return; + } + const command = options.buildOpenInFolderCommand(openPath); + if (command) { + try { + await options.callTool?.('start_process', { command, timeout_ms: 12000 }); + } catch { + // Keep UI stable if opening folder fails. + } + } + return; + } + + const loadMoreBtn = (event.target as HTMLElement).closest('.dir-load-more') as HTMLElement | null; + if (loadMoreBtn) { + event.stopPropagation(); + const loadPath = loadMoreBtn.dataset.loadpath; + if (!loadPath) { + return; + } + loadMoreBtn.querySelector('.dir-warning-text')!.textContent = 'Loading…'; + (loadMoreBtn as HTMLButtonElement).disabled = true; + try { + const result = await options.callTool?.('list_directory', { path: loadPath, depth: 1 }); + const text = extractToolText(result) ?? ''; + if (text) { + const parsed = parseDirectoryEntries(text); + const html = renderDirTree(parsed.entries, loadPath); + const parentChildren = loadMoreBtn.closest('.dir-children'); + if (parentChildren) { + const temp = document.createElement('div'); + temp.innerHTML = html; + const inner = temp.querySelector('.dir-tree'); + parentChildren.innerHTML = inner ? inner.innerHTML : ''; + } + } + } catch { + loadMoreBtn.querySelector('.dir-warning-text')!.textContent = 'Failed to load'; + (loadMoreBtn as HTMLButtonElement).disabled = false; + } + return; + } + + const target = (event.target as HTMLElement).closest('.dir-row') as HTMLElement | null; + if (!target) { + return; + } + const fullPath = target.dataset.path; + if (!fullPath) { + return; + } + + if (target.classList.contains('dir-row-folder')) { + const entryId = target.dataset.eid; + if (!entryId) { + return; + } + const childrenEl = document.getElementById(`${entryId}-ch`); + const chevron = target.querySelector('.dir-chevron'); + + if (childrenEl) { + const hidden = childrenEl.classList.toggle('dir-collapsed'); + chevron?.classList.toggle('expanded', !hidden); + if (chevron) chevron.textContent = hidden ? 'β–Ά' : 'β–Ό'; + return; + } + + if (target.dataset.loaded === 'true') { + return; + } + if (chevron) chevron.textContent = '⏳'; + try { + const result = await options.callTool?.('list_directory', { path: fullPath, depth: 2 }); + const text = extractToolText(result) ?? ''; + if (text) { + target.dataset.loaded = 'true'; + const parsed = parseDirectoryEntries(text); + const html = renderDirTree(parsed.entries, fullPath); + const wrapper = document.createElement('div'); + wrapper.className = 'dir-children'; + wrapper.id = `${entryId}-ch`; + const temp = document.createElement('div'); + temp.innerHTML = html; + const inner = temp.querySelector('.dir-tree'); + wrapper.innerHTML = inner ? inner.innerHTML : 'Empty'; + target.parentElement?.appendChild(wrapper); + chevron?.classList.add('expanded'); + if (chevron) chevron.textContent = 'β–Ό'; + } + } catch { + if (chevron) chevron.textContent = '⚠'; + } + return; + } + + if (target.classList.contains('dir-row-file')) { + target.classList.add('dir-loading'); + try { + const result = await options.callTool?.('read_file', { path: fullPath }); + if (!result || typeof result !== 'object' || result === null) { + return; + } + const structuredContent = (result as { structuredContent?: unknown }).structuredContent; + if (structuredContent && typeof structuredContent === 'object') { + const text = extractToolText(result) ?? ''; + options.onOpenPayload(buildRenderPayload(structuredContent as any, text)); + } + } catch { + target.classList.remove('dir-loading'); + } + } + }); +} diff --git a/src/ui/file-preview/src/document-layout.ts b/src/ui/file-preview/src/document-layout.ts new file mode 100644 index 00000000..87c0f50f --- /dev/null +++ b/src/ui/file-preview/src/document-layout.ts @@ -0,0 +1,140 @@ +import { renderCompactRow } from '../../shared/compact-row.js'; +import { escapeHtml } from '../../shared/escape-html.js'; +import { parseReadRange, stripReadStatusLine } from './document-workspace.js'; +import type { FileTypeCapabilities, MarkdownWorkspaceState, RenderBodyResult, RenderPayload } from './model.js'; +import { renderMarkdownCopyButton, renderMarkdownModeToggle } from './markdown/editor.js'; +import { buildBreadcrumb, countContentLines } from './payload-utils.js'; + +function renderCopyIcon(): string { + return ''; +} + +function renderFolderIcon(): string { + return ''; +} + +function renderUndoIcon(): string { + return ''; +} + +function renderExpandIcon(): string { + return ''; +} + +function renderMarkdownSaveStatus(workspace: MarkdownWorkspaceState): string { + if (workspace.fileDeleted) { + return 'File deleted'; + } + + if (workspace.saveIndicator !== 'saved') { + return ''; + } + + const variant = workspace.saving ? 'saving' : 'saved'; + return `Saved`; +} + +export function buildDocumentLayout(options: { + payload: RenderPayload; + body: RenderBodyResult; + capabilities: FileTypeCapabilities; + fileExtension: string; + htmlMode: 'rendered' | 'source'; + currentDisplayMode: string | null; + isExpanded: boolean; + hideSummaryRow: boolean; + markdownWorkspace?: MarkdownWorkspaceState; + canGoFullscreen: boolean; + isMarkdownUndoAvailable: boolean; + defaultMarkdownEditorName?: string; + markdownEditorAppIcon: string; + hasDirectoryBackButton: boolean; +}): { html: string; effectiveExpanded: boolean } { + const range = parseReadRange(options.payload.content); + const notice = options.body.notice ? `
    ${options.body.notice}
    ` : ''; + const breadcrumb = buildBreadcrumb(options.payload.filePath); + const lineCount = range ? range.toLine - range.fromLine + 1 : countContentLines(options.payload.content); + const fileTypeLabel = options.payload.fileType === 'markdown' ? 'MARKDOWN' + : options.payload.fileType === 'html' ? 'HTML' + : options.payload.fileType === 'image' ? 'IMAGE' + : options.payload.fileType === 'directory' ? 'DIRECTORY' + : options.fileExtension !== 'none' ? options.fileExtension.toUpperCase() + : 'TEXT'; + + const compactLabel = range?.isPartial + ? `View lines ${range.fromLine}–${range.toLine}` + : options.payload.fileType === 'directory' ? 'View directory' + : 'View file'; + let footerLabel = range?.isPartial + ? `${fileTypeLabel} β€’ LINES ${range.fromLine}–${range.toLine} OF ${range.totalLines}` + : `${fileTypeLabel} β€’ ${lineCount} LINE${lineCount !== 1 ? 'S' : ''}`; + + if (options.markdownWorkspace?.mode === 'edit') { + const source = stripReadStatusLine(options.markdownWorkspace.draftContent); + const markdownWordCount = source.trim().split(/\s+/).filter(Boolean).length; + const markdownLineCount = countContentLines(source); + footerLabel = `${fileTypeLabel} β€’ EDIT MODE β€’ ${markdownLineCount} LINES β€’ ${markdownWordCount} WORDS`; + } + + const isFullscreen = options.currentDisplayMode === 'fullscreen'; + const htmlToggle = options.payload.fileType === 'html' + ? `` + : ''; + const backButton = options.hasDirectoryBackButton && options.payload.fileType !== 'directory' + ? '' + : ''; + + const isMarkdown = options.payload.fileType === 'markdown'; + const isMarkdownEdit = isMarkdown && options.markdownWorkspace?.mode === 'edit'; + const revertDisabled = isMarkdownEdit && (options.markdownWorkspace!.fileDeleted || options.markdownWorkspace!.loadingDocument || !options.isMarkdownUndoAvailable); + const fileDeleted = isMarkdownEdit && options.markdownWorkspace!.fileDeleted; + + const hasMissingBefore = range?.isPartial && range.fromLine > 1; + const hasMissingAfter = range?.isPartial && range.toLine < range.totalLines && (range.totalLines - range.toLine) > 1; + const loadBeforeBanner = hasMissingBefore + ? `` + : ''; + const loadAfterBanner = hasMissingAfter + ? `` + : ''; + + const effectiveExpanded = options.isExpanded || isFullscreen; + const canOpenInFolder = options.capabilities.canOpenInFolder; + const canCopy = options.capabilities.canCopy; + + return { + effectiveExpanded, + html: ` +
    + ${isFullscreen ? '' : renderCompactRow({ id: 'compact-toggle', label: compactLabel, filename: options.payload.fileName, variant: 'ready', expandable: true, expanded: options.isExpanded, interactive: true })} +
    +
    + ${backButton} + ${options.hideSummaryRow ? '' : `${breadcrumb}`} + + ${isMarkdownEdit ? renderMarkdownSaveStatus(options.markdownWorkspace!) : ''} + ${htmlToggle} + ${isMarkdownEdit && isFullscreen ? renderMarkdownModeToggle(options.markdownWorkspace!.editorView) : ''} + ${isMarkdown && !isFullscreen && options.canGoFullscreen ? `` : ''} + ${isMarkdownEdit ? `` : ''} + ${isMarkdownEdit && isFullscreen ? renderMarkdownCopyButton() : ''} + ${isMarkdown && !isFullscreen ? `` : ''} + ${canCopy && options.capabilities.supportsPreview && !isMarkdown ? `` : ''} + ${canOpenInFolder && isMarkdownEdit && isFullscreen ? `` : ''} + ${canOpenInFolder && !(isMarkdownEdit && isFullscreen) ? `` : ''} + +
    + ${notice} +
    + ${loadBeforeBanner} + ${options.body.html} + ${loadAfterBanner} +
    + +
    +
    + `, + }; +} diff --git a/src/ui/file-preview/src/markdown-workspace/toc.ts b/src/ui/file-preview/src/document-outline.ts similarity index 55% rename from src/ui/file-preview/src/markdown-workspace/toc.ts rename to src/ui/file-preview/src/document-outline.ts index 3cd742e7..7957fe64 100644 --- a/src/ui/file-preview/src/markdown-workspace/toc.ts +++ b/src/ui/file-preview/src/document-outline.ts @@ -1,16 +1,15 @@ -import type { MarkdownOutlineItem } from './outline.js'; +import { escapeHtml } from '../../shared/escape-html.js'; -export interface MarkdownTocHandle { - dispose: () => void; +export interface DocumentOutlineItem { + id: string; + text: string; + level: number; + line?: number; } -function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); +export interface DocumentOutlineHandle { + dispose: () => void; + refresh: (outline: DocumentOutlineItem[], activeHeadingId?: string | null) => void; } function setActiveItem(nav: HTMLElement, activeId: string | null): void { @@ -22,34 +21,37 @@ function setActiveItem(nav: HTMLElement, activeId: string | null): void { }); } -export function renderMarkdownToc(outline: MarkdownOutlineItem[], activeHeadingId?: string | null): string { +function renderDocumentOutlineItems(outline: DocumentOutlineItem[], activeHeadingId?: string | null): string { + return outline.map((item) => { + const activeClass = item.id === activeHeadingId ? ' is-active' : ''; + return ``; + }).join(''); +} + +export function renderDocumentOutline(outline: DocumentOutlineItem[], activeHeadingId?: string | null): string { if (outline.length === 0) { return ''; } - const items = outline.map((item) => { - const activeClass = item.id === activeHeadingId ? ' is-active' : ''; - return ``; - }).join(''); - return ` -
    @@ -522,11 +555,10 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende 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.draftContent = state.fullDocumentContent; state.dirty = false; dependencies.rerender(); } @@ -553,9 +585,8 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende nextState.loadingDocument = false; nextState.notice = null; nextState.error = null; - syncStateFromContent(nextState, nextState.sourceContent); + syncStateFromContent(nextState, nextState.fullDocumentContent); if (options.keepEditMode) { - nextState.mode = 'edit'; nextState.editorView = 'markdown'; dependencies.rerender(); } @@ -631,7 +662,6 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende return; } - state.mode = 'edit'; state.draftContent = state.fullDocumentContent; state.dirty = false; state.editorView = 'markdown'; @@ -678,7 +708,8 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende state.notice = null; try { - const blocks = computeEditBlocks(state.fullDocumentContent, state.draftContent); + const savedContent = state.draftContent; + const blocks = computeEditBlocks(state.fullDocumentContent, savedContent); if (blocks.length === 0) { state.saving = false; state.saveIndicator = 'idle'; @@ -728,17 +759,18 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende throw err; } - state.fullDocumentContent = state.draftContent; - state.sourceContent = state.draftContent; - state.outline = extractMarkdownOutline(state.sourceContent); - state.dirty = false; + const hasNewUnsavedDraft = state.draftContent !== savedContent; + state.fullDocumentContent = savedContent; + if (!hasNewUnsavedDraft) { + state.outline = extractMarkdownOutline(state.fullDocumentContent); + } + state.dirty = hasNewUnsavedDraft; state.saving = false; - state.saveIndicator = 'saved'; + state.saveIndicator = hasNewUnsavedDraft ? 'idle' : 'saved'; if (!state.outline.some((item) => item.id === state.activeHeadingId)) { state.activeHeadingId = state.outline[0]?.id ?? null; } - const savedContent = state.draftContent; const currentPayload = dependencies.getCurrentPayload(); if (currentPayload) { const statusLineMatch = currentPayload.content.match(/^(\[Reading [^\]]+\]\r?\n(?:\r?\n)?)/); @@ -750,13 +782,17 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende if (revert) { revert.disabled = !isUndoAvailable(state); } - flashSaveStatus('Saved', 'saved', 1800, () => { - if (!state.dirty && !state.saving) { - state.saveIndicator = 'idle'; - return true; - } - return false; - }); + if (hasNewUnsavedDraft) { + scheduleAutosave(); + } else { + 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, @@ -838,7 +874,7 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende action: 'save_mine', }); // Re-run saveDocument. computeEditBlocks will diff against - // the fresh sourceContent that keepDraft: true left in place, + // the fresh disk baseline 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. @@ -919,6 +955,9 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende currentFilePath: payload.filePath, searchLinks: (query) => searchLinkTargets(payload.filePath, query), loadHeadings: (targetPath) => loadLinkHeadings(payload.filePath, targetPath), + onOpenLink: (href) => { + void navigateLink(payload, href); + }, onChange: (value) => { state.draftContent = value; state.dirty = value !== state.fullDocumentContent; @@ -932,14 +971,7 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende 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); - } + scheduleOutlineRefresh(state); if (state.dirty && state.saveIndicator === 'saved') { state.saveIndicator = 'idle'; } @@ -977,22 +1009,6 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende void requestFullscreen(); }); - if (wrapper) { - wrapper.addEventListener('click', (event) => { - const target = event.target as HTMLElement | null; - 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') as HTMLElement | null; if (tocShell && wrapper) { @@ -1017,10 +1033,7 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende function getCopyText(payload: RenderPayload): string | null { const state = getState(payload); - const source = state.draftContent; - return state.editorView === 'raw' - ? source - : (getRenderedMarkdownCopyText(source) || source); + return state.draftContent; } async function handleInlineExitFromFullscreen(originalPayload?: RenderPayload): Promise { @@ -1046,7 +1059,6 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende buildBody, clear, disposeHandles, - ensureCompletePayload, getCopyText, getState, handleInlineExitFromFullscreen, @@ -1058,7 +1070,6 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende requestEditMode, requestFullscreen, saveDocument, - setEditorView, }; } diff --git a/src/ui/file-preview/src/markdown/editor.ts b/src/ui/file-preview/src/markdown/editor.ts index 0188c484..63af60a2 100644 --- a/src/ui/file-preview/src/markdown/editor.ts +++ b/src/ui/file-preview/src/markdown/editor.ts @@ -1,9 +1,7 @@ -import { Editor } from '@tiptap/core'; -import StarterKit from '@tiptap/starter-kit'; -import Image from '@tiptap/extension-image'; -import { Markdown } from 'tiptap-markdown'; -import { restoreWikiLinks, rewriteWikiLinks } from './linking.js'; -import { createSlugTracker } from './slugify.js'; +import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; +import { markdown } from '@codemirror/lang-markdown'; +import { EditorSelection, EditorState, RangeSetBuilder } from '@codemirror/state'; +import { Decoration, EditorView, ViewPlugin, WidgetType, keymap, type DecorationSet } from '@codemirror/view'; export type MarkdownEditorView = 'raw' | 'markdown'; @@ -19,6 +17,16 @@ export interface MarkdownLinkHeading { text: string; } +interface MarkdownLinkRange { + from: number; + labelFrom: number; + labelTo: number; + to: number; + label: string; + href: string; + kind: 'markdown' | 'wiki'; +} + export interface MarkdownEditorHandle { destroy: () => void; focus: () => void; @@ -95,7 +103,7 @@ export function renderMarkdownEditorShell(options: { return `
    - ${isMarkdownView ? `` : ''} + ${isMarkdownView ? `` : ''}
    @@ -111,35 +119,254 @@ function applyRawTab(textarea: HTMLTextAreaElement): void { textarea.selectionEnd = start + 1; } -/** - * Walk the prose-mirror DOM and assign slug-based id attributes to headings - * so the outline's revealLine can scroll to them. Re-run after every update; - * no-op writes are skipped so identical ids don't dirty the style engine. - */ -function syncHeadingIds(root: HTMLElement): void { - const nextSlug = createSlugTracker(); - const headings = Array.from(root.querySelectorAll('h1, h2, h3, h4, h5, h6')); - for (const heading of headings) { - const text = heading.textContent?.trim() ?? ''; - if (!text) { - if (heading.hasAttribute('id')) { - heading.removeAttribute('id'); - } - if (heading.hasAttribute('data-heading-id')) { - heading.removeAttribute('data-heading-id'); - } - continue; +function isInlineMarkerAt(source: string, index: number, marker: string): boolean { + if (!source.startsWith(marker, index)) { + return false; + } + if (marker === '*') { + return source[index - 1] !== '*' && source[index + 1] !== '*'; + } + return true; +} + +class BulletWidget extends WidgetType { + eq(other: WidgetType): boolean { + return other instanceof BulletWidget; + } + toDOM(): HTMLElement { + const span = document.createElement('span'); + span.className = 'cm-md-bullet'; + span.textContent = 'β€’'; + return span; + } + // CodeMirror's default is to treat events from inside a widget as belonging + // to it (so clicks won't move the caret). We want clicks on the bullet to + // place the caret at the underlying marker position, like normal text. + ignoreEvent(): boolean { + return false; + } +} + +const SHARED_BULLET_WIDGET = new BulletWidget(); + +const UNORDERED_LIST_PREFIX = /^(\s*)[-*+]\s+/; +const ORDERED_LIST_PREFIX = /^\s*\d+[.)]\s+/; +const BLOCKQUOTE_PREFIX = /^\s*>\s?/; +const HORIZONTAL_RULE_LINE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/; + +// Order matters: longer prefixes first so `**` is matched before `*`. +const SPANNING_WRAPPER_KINDS: ReadonlyArray<{ prefix: string; suffix: string; className: string }> = [ + { prefix: '**', suffix: '**', className: 'cm-md-strong-text' }, + { prefix: '~~', suffix: '~~', className: 'cm-md-strike-text' }, + { prefix: '*', suffix: '*', className: 'cm-md-emphasis-text' }, + { prefix: '`', suffix: '`', className: 'cm-md-inline-code-text' }, +]; + +interface MarkerRange { + from: number; + to: number; + className?: string; + widget?: WidgetType; +} + +function buildMarkdownLineDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const addAbsoluteMark = ( + ranges: MarkerRange[], + from: number, + to: number, + className: string + ): void => { + if (to > from) { + ranges.push({ from, to, className }); } - const headingId = nextSlug(text); - if (heading.id !== headingId) { - heading.id = headingId; + }; + const addMark = ( + ranges: MarkerRange[], + lineFrom: number, + from: number, + to: number, + className: string + ): void => { + if (to > from) { + ranges.push({ from: lineFrom + from, to: lineFrom + to, className }); } - if (heading.getAttribute('data-heading-id') !== headingId) { - heading.setAttribute('data-heading-id', headingId); + }; + + const collectSpanningWrapperRanges = (): MarkerRange[] => { + const source = view.state.doc.toString(); + const ranges: MarkerRange[] = []; + // Single linear scan that recognizes all wrapper kinds at once. Order + // matters only for length tie-breaks (longer markers first); `*` vs `**` + // is already disambiguated by isInlineMarkerAt's neighbor check. + const opens: Array = SPANNING_WRAPPER_KINDS.map(() => null); + let index = 0; + while (index < source.length) { + let consumed = 0; + for (let kind = 0; kind < SPANNING_WRAPPER_KINDS.length; kind += 1) { + const { prefix, suffix, className } = SPANNING_WRAPPER_KINDS[kind]; + const openPos = opens[kind]; + if (openPos === null) { + if (isInlineMarkerAt(source, index, prefix)) { + opens[kind] = index; + consumed = prefix.length; + break; + } + continue; + } + if (isInlineMarkerAt(source, index, suffix)) { + const contentFrom = openPos + prefix.length; + const contentTo = index; + if (source.slice(contentFrom, contentTo).includes('\n')) { + addAbsoluteMark(ranges, openPos, contentFrom, 'cm-md-hidden-marker'); + let line = view.state.doc.lineAt(contentFrom); + while (line.from <= contentTo) { + addAbsoluteMark( + ranges, + Math.max(contentFrom, line.from), + Math.min(contentTo, line.to), + className + ); + if (line.to >= contentTo || line.number >= view.state.doc.lines) { + break; + } + line = view.state.doc.line(line.number + 1); + } + addAbsoluteMark(ranges, contentTo, index + suffix.length, 'cm-md-hidden-marker'); + } + opens[kind] = null; + consumed = suffix.length; + break; + } + } + index += consumed > 0 ? consumed : 1; + } + return ranges; + }; + + const spanningInlineRanges = collectSpanningWrapperRanges(); + + const collectInlineRanges = (text: string, lineFrom: number): MarkerRange[] => { + const ranges: MarkerRange[] = []; + for (const match of text.matchAll(/\[([^\]\n]+)\]\(([^)\n]+)\)/g)) { + const start = match.index ?? 0; + const label = match[1] ?? ''; + if (text[start - 1] === '!' || label.startsWith('![')) { + continue; + } + addMark(ranges, lineFrom, start, start + 1, 'cm-md-hidden-marker'); + addMark(ranges, lineFrom, start + 1, start + 1 + label.length, 'cm-md-link-text'); + addMark(ranges, lineFrom, start + 1 + label.length, start + match[0].length, 'cm-md-hidden-marker'); + } + for (const match of text.matchAll(/\[\[([^\]\n]+)\]\]/g)) { + const start = match.index ?? 0; + const body = match[1] ?? ''; + const pipeIndex = body.lastIndexOf('|'); + const labelStart = pipeIndex >= 0 ? pipeIndex + 1 : 0; + const label = body.slice(labelStart); + addMark(ranges, lineFrom, start, start + 2, 'cm-md-hidden-marker'); + if (labelStart > 0) { + addMark(ranges, lineFrom, start + 2, start + 2 + labelStart, 'cm-md-hidden-marker'); + } + addMark(ranges, lineFrom, start + 2 + labelStart, start + 2 + labelStart + label.length, 'cm-md-link-text'); + addMark(ranges, lineFrom, start + 2 + labelStart + label.length, start + match[0].length, 'cm-md-hidden-marker'); + } + for (const match of text.matchAll(/(`+)([^`\n]+)\1/g)) { + const start = match.index ?? 0; + const ticks = match[1]?.length ?? 1; + addMark(ranges, lineFrom, start, start + ticks, 'cm-md-hidden-marker'); + addMark(ranges, lineFrom, start + ticks, start + match[0].length - ticks, 'cm-md-inline-code-text'); + addMark(ranges, lineFrom, start + match[0].length - ticks, start + match[0].length, 'cm-md-hidden-marker'); + } + for (const match of text.matchAll(/\*\*([^*\n]+)\*\*/g)) { + const start = match.index ?? 0; + addMark(ranges, lineFrom, start, start + 2, 'cm-md-hidden-marker'); + addMark(ranges, lineFrom, start + 2, start + match[0].length - 2, 'cm-md-strong-text'); + addMark(ranges, lineFrom, start + match[0].length - 2, start + match[0].length, 'cm-md-hidden-marker'); + } + for (const match of text.matchAll(/~~([^~\n]+)~~/g)) { + const start = match.index ?? 0; + addMark(ranges, lineFrom, start, start + 2, 'cm-md-hidden-marker'); + addMark(ranges, lineFrom, start + 2, start + match[0].length - 2, 'cm-md-strike-text'); + addMark(ranges, lineFrom, start + match[0].length - 2, start + match[0].length, 'cm-md-hidden-marker'); + } + for (const match of text.matchAll(/(^|[^*])\*([^*\n]+)\*/g)) { + const start = (match.index ?? 0) + (match[1]?.length ?? 0); + addMark(ranges, lineFrom, start, start + 1, 'cm-md-hidden-marker'); + addMark(ranges, lineFrom, start + 1, start + match[0].length - (match[1]?.length ?? 0) - 1, 'cm-md-emphasis-text'); + addMark(ranges, lineFrom, start + match[0].length - (match[1]?.length ?? 0) - 1, start + match[0].length - (match[1]?.length ?? 0), 'cm-md-hidden-marker'); + } + return ranges.sort((left, right) => left.from - right.from || left.to - right.to); + }; + + for (const { from, to } of view.visibleRanges) { + let line = view.state.doc.lineAt(from); + while (line.from <= to) { + const text = line.text; + const headingMatch = /^(#{1,6})\s+/.exec(text); + let className = ''; + const markerRanges: MarkerRange[] = []; + if (headingMatch) { + className = `cm-md-heading cm-md-heading-${headingMatch[1].length}`; + addMark(markerRanges, line.from, 0, headingMatch[0].length, 'cm-md-hidden-marker'); + } else if (BLOCKQUOTE_PREFIX.test(text)) { + className = 'cm-md-quote'; + const marker = text.match(BLOCKQUOTE_PREFIX); + addMark(markerRanges, line.from, 0, marker?.[0].length ?? 0, 'cm-md-hidden-marker'); + } else { + const unorderedMatch = UNORDERED_LIST_PREFIX.exec(text); + if (unorderedMatch) { + className = 'cm-md-list cm-md-list-unordered'; + const markerStart = unorderedMatch[1].length; + markerRanges.push({ + from: line.from + markerStart, + to: line.from + markerStart + 1, + widget: SHARED_BULLET_WIDGET, + }); + } else if (ORDERED_LIST_PREFIX.test(text)) { + className = 'cm-md-list cm-md-list-ordered'; + } else if (HORIZONTAL_RULE_LINE.test(text)) { + className = 'cm-md-rule'; + } + } + + if (className) { + builder.add(line.from, line.from, Decoration.line({ class: className })); + } + markerRanges.push(...spanningInlineRanges.filter((range) => range.to > line.from && range.from < line.to)); + markerRanges.push(...collectInlineRanges(text, line.from)); + for (const range of markerRanges.sort((left, right) => left.from - right.from || left.to - right.to)) { + if (range.widget) { + builder.add(range.from, range.to, Decoration.replace({ widget: range.widget })); + } else if (range.className) { + builder.add(range.from, range.to, Decoration.mark({ class: range.className })); + } + } + if (line.to >= to || line.number >= view.state.doc.lines) { + break; + } + line = view.state.doc.line(line.number + 1); } } + return builder.finish(); } +const markdownLinePreviewPlugin = ViewPlugin.fromClass(class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = buildMarkdownLineDecorations(view); + } + + update(update: { docChanged: boolean; viewportChanged: boolean; view: EditorView }) { + if (update.docChanged || update.viewportChanged) { + this.decorations = buildMarkdownLineDecorations(update.view); + } + } +}, { + decorations: (plugin) => plugin.decorations, +}); + export function mountMarkdownEditor(options: { target: HTMLElement; value: string; @@ -148,6 +375,7 @@ export function mountMarkdownEditor(options: { currentFilePath: string; searchLinks?: (query: string) => Promise; loadHeadings?: (filePath: string) => Promise; + onOpenLink?: (href: string) => void | Promise; onChange: (value: string) => void; onBlur?: () => void; }): MarkdownEditorHandle { @@ -177,86 +405,304 @@ export function mountMarkdownEditor(options: { if (options.view === 'markdown') { options.target.replaceChildren(); + let suppressChange = false; + let positionToolbar: (preferredPoint?: { x: number; y: number }) => void = () => {}; + let activePopoverLink: MarkdownLinkRange | null = null; + let popoverHideTimer: ReturnType | null = null; + const view = new EditorView({ + parent: options.target, + state: EditorState.create({ + doc: options.value, + extensions: [ + history(), + markdown(), + EditorView.lineWrapping, + markdownLinePreviewPlugin, + keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]), + EditorView.updateListener.of((update) => { + if (!suppressChange && update.docChanged) { + options.onChange(update.state.doc.toString()); + } + if (update.selectionSet || update.focusChanged) { + window.requestAnimationFrame(() => positionToolbar()); + } + }), + ], + }), + }); + const linkPopover = document.createElement('div'); + linkPopover.className = 'markdown-link-popover'; + linkPopover.hidden = true; + shell?.appendChild(linkPopover); - const getTiptapMarkdown = (): string => { - const storage = tiptap.storage as { markdown?: { getMarkdown: () => string } }; - return restoreWikiLinks(storage.markdown?.getMarkdown() ?? ''); - }; - - const tiptap = new Editor({ - element: options.target, - extensions: [ - StarterKit.configure({ - heading: { levels: [1, 2, 3, 4, 5, 6] }, - codeBlock: { HTMLAttributes: { class: 'code-viewer' } }, - link: { - openOnClick: false, - autolink: true, - HTMLAttributes: { 'data-markdown-link': 'true' }, - }, - }), - Image.configure({ allowBase64: true, inline: true }), - Markdown.configure({ - html: true, - tightLists: true, - bulletListMarker: '-', - linkify: true, - breaks: false, - transformPastedText: true, - transformCopiedText: false, - }), - ], - content: rewriteWikiLinks(options.value), - editorProps: { - attributes: { - class: 'markdown-editor-surface markdown-editor-surface--markdown markdown markdown-doc', - role: 'textbox', - 'aria-multiline': 'true', - }, - }, - onUpdate: ({ editor }) => { - syncHeadingIds(editor.view.dom as HTMLElement); - options.onChange(getTiptapMarkdown()); - }, - onSelectionUpdate: () => { - updateContextMenu(); - }, - onBlur: ({ event }) => { - if (shouldIgnoreBlur(shell, event as FocusEvent)) { - return; + const getValue = (): string => view.state.doc.toString(); + + const getSelectedText = (): string => { + const selection = view.state.selection.main; + return view.state.doc.sliceString(selection.from, selection.to); + }; + + const insertText = (insert: string, selectFrom?: number, selectTo?: number): void => { + const selection = view.state.selection.main; + if (typeof selectFrom === 'number') { + view.dispatch({ + changes: { from: selection.from, to: selection.to, insert }, + selection: EditorSelection.range(selection.from + selectFrom, selection.from + (selectTo ?? selectFrom)), + userEvent: 'input', + }); + } else { + view.dispatch({ + changes: { from: selection.from, to: selection.to, insert }, + userEvent: 'input', + }); + } + view.focus(); + window.requestAnimationFrame(() => positionToolbar()); + }; + + const findEnclosingWrapper = (prefix: string, suffix: string = prefix): { from: number; contentFrom: number; contentTo: number; to: number } | null => { + const selection = view.state.selection.main; + const line = view.state.doc.lineAt(selection.from); + if (selection.to > line.to) { + return null; + } + const lineText = line.text; + const relativeFrom = selection.from - line.from; + const relativeTo = selection.to - line.from; + let openIndex = lineText.lastIndexOf(prefix, Math.max(0, relativeFrom - 1)); + while (openIndex >= 0) { + const contentFrom = openIndex + prefix.length; + const closeIndex = lineText.indexOf(suffix, Math.max(contentFrom, relativeTo)); + if (closeIndex >= 0 && contentFrom <= relativeFrom && relativeTo <= closeIndex) { + return { + from: line.from + openIndex, + contentFrom: line.from + contentFrom, + contentTo: line.from + closeIndex, + to: line.from + closeIndex + suffix.length, + }; } - if (contextMenu) { - contextMenu.hidden = true; + openIndex = lineText.lastIndexOf(prefix, openIndex - 1); + } + return null; + }; + + const findWrapperRanges = (prefix: string, suffix: string = prefix): Array<{ + from: number; + contentFrom: number; + contentTo: number; + to: number; + }> => { + const source = view.state.doc.toString(); + const ranges: Array<{ from: number; contentFrom: number; contentTo: number; to: number }> = []; + let open: number | null = null; + for (let index = 0; index < source.length;) { + if (open === null && isInlineMarkerAt(source, index, prefix)) { + open = index; + index += prefix.length; + continue; } - options.onBlur?.(); - }, - }); + if (open !== null && isInlineMarkerAt(source, index, suffix)) { + ranges.push({ + from: open, + contentFrom: open + prefix.length, + contentTo: index, + to: index + suffix.length, + }); + open = null; + index += suffix.length; + continue; + } + index += 1; + } + return ranges; + }; + + const selectionTouchesOnlyMarkedText = (prefix: string, suffix: string = prefix): boolean => { + const selection = view.state.selection.main; + if (selection.empty) { + return false; + } + const source = view.state.doc.toString(); + const ranges = findWrapperRanges(prefix, suffix); + let sawContent = false; + + for (let position = selection.from; position < selection.to; position += 1) { + const char = source[position]; + if (!char || /\s/.test(char)) { + continue; + } + const isMarker = ranges.some((range) => ( + (range.from <= position && position < range.contentFrom) + || (range.contentTo <= position && position < range.to) + )); + if (isMarker) { + continue; + } + sawContent = true; + const isCovered = ranges.some((range) => range.contentFrom <= position && position < range.contentTo); + if (!isCovered) { + return false; + } + } + + return sawContent; + }; + + const getBoundaryWrapperState = (prefix: string, suffix: string = prefix): { startsInside: boolean; endsInside: boolean } => { + const selection = view.state.selection.main; + const ranges = findWrapperRanges(prefix, suffix); + return { + startsInside: ranges.some((range) => range.contentFrom <= selection.from && selection.from < range.contentTo), + endsInside: ranges.some((range) => range.contentFrom < selection.to && selection.to <= range.contentTo), + }; + }; + + const removeWrappersTouchingSelection = (prefix: string, suffix: string = prefix): boolean => { + const selection = view.state.selection.main; + const changes = findWrapperRanges(prefix, suffix) + .filter((range) => range.contentTo > selection.from && range.contentFrom < selection.to) + .flatMap((range) => [ + { from: range.from, to: range.contentFrom, insert: '' }, + { from: range.contentTo, to: range.to, insert: '' }, + ]) + .sort((left, right) => left.from - right.from); + + if (changes.length === 0) { + return false; + } - const editorDom = tiptap.view.dom as HTMLElement; - syncHeadingIds(editorDom); + const removedBefore = (position: number): number => changes.reduce((total, change) => ( + change.to <= position ? total + (change.to - change.from) : total + ), 0); + view.dispatch({ + changes, + selection: EditorSelection.range( + Math.max(0, selection.from - removedBefore(selection.from)), + Math.max(0, selection.to - removedBefore(selection.to)) + ), + userEvent: 'input', + }); + view.focus(); + window.requestAnimationFrame(() => positionToolbar()); + return true; + }; + + const toggleWrapper = (prefix: string, suffix: string = prefix, placeholder = 'text'): void => { + const selected = getSelectedText(); + const selection = view.state.selection.main; - const updateContextMenu = (): void => { - if (!contextMenu) { + if (selected.startsWith(prefix) && selected.endsWith(suffix) && selected.length >= prefix.length + suffix.length) { + const body = selected.slice(prefix.length, selected.length - suffix.length); + view.dispatch({ + changes: { from: selection.from, to: selection.to, insert: body }, + selection: EditorSelection.range(selection.from, selection.from + body.length), + userEvent: 'input', + }); + view.focus(); + window.requestAnimationFrame(() => positionToolbar()); return; } - const { from, to, empty } = tiptap.state.selection; - if (empty || !tiptap.isFocused) { - contextMenu.hidden = true; + + const hasSurroundingMarkers = selection.from >= prefix.length + && view.state.doc.sliceString(selection.from - prefix.length, selection.from) === prefix + && view.state.doc.sliceString(selection.to, selection.to + suffix.length) === suffix; + if (hasSurroundingMarkers) { + view.dispatch({ + changes: [ + { from: selection.from - prefix.length, to: selection.from, insert: '' }, + { from: selection.to, to: selection.to + suffix.length, insert: '' }, + ], + selection: EditorSelection.range(selection.from - prefix.length, selection.to - prefix.length), + userEvent: 'input', + }); + view.focus(); + window.requestAnimationFrame(() => positionToolbar()); return; } - const start = tiptap.view.coordsAtPos(from); - const end = tiptap.view.coordsAtPos(to); - const shellEl = shell as HTMLElement | null; - if (!shellEl) { + + if (!selection.empty && selectionTouchesOnlyMarkedText(prefix, suffix)) { + if (removeWrappersTouchingSelection(prefix, suffix)) { + return; + } + } + + const enclosingWrapper = findEnclosingWrapper(prefix, suffix); + if (enclosingWrapper) { + const cursor = selection.empty + ? Math.max(enclosingWrapper.from, selection.from - prefix.length) + : selection.from - prefix.length; + view.dispatch({ + changes: [ + { from: enclosingWrapper.from, to: enclosingWrapper.contentFrom, insert: '' }, + { from: enclosingWrapper.contentTo, to: enclosingWrapper.to, insert: '' }, + ], + selection: EditorSelection.cursor(Math.max(enclosingWrapper.from, cursor)), + userEvent: 'input', + }); + view.focus(); + window.requestAnimationFrame(() => positionToolbar()); return; } - const shellRect = shellEl.getBoundingClientRect(); - const midX = (start.left + end.right) / 2; - contextMenu.hidden = false; - const left = Math.max(12, midX - shellRect.left - contextMenu.offsetWidth / 2); - const top = Math.max(12, start.top - shellRect.top - contextMenu.offsetHeight - 10); - contextMenu.style.left = `${left}px`; - contextMenu.style.top = `${top}px`; + + if (!selection.empty) { + const { startsInside, endsInside } = getBoundaryWrapperState(prefix, suffix); + const body = selected; + const insertPrefix = startsInside ? '' : prefix; + const insertSuffix = endsInside ? '' : suffix; + const insert = `${insertPrefix}${body}${insertSuffix}`; + view.dispatch({ + changes: { from: selection.from, to: selection.to, insert }, + selection: EditorSelection.range( + selection.from + insertPrefix.length, + selection.from + insertPrefix.length + body.length + ), + userEvent: 'input', + }); + view.focus(); + window.requestAnimationFrame(() => positionToolbar()); + return; + } + + const body = selected || placeholder; + insertText(`${prefix}${body}${suffix}`, prefix.length, prefix.length + body.length); + }; + + const updateSelectedLines = (mapLine: (line: string, index: number) => string): void => { + const doc = view.state.doc; + const selection = view.state.selection.main; + const fromLine = doc.lineAt(selection.from); + const toLine = doc.lineAt(selection.to); + const changes: Array<{ from: number; to: number; insert: string }> = []; + for (let lineNumber = fromLine.number; lineNumber <= toLine.number; lineNumber += 1) { + const line = doc.line(lineNumber); + const nextLine = mapLine(line.text, lineNumber - fromLine.number); + if (nextLine !== line.text) { + changes.push({ from: line.from, to: line.to, insert: nextLine }); + } + } + if (changes.length > 0) { + view.dispatch({ changes, userEvent: 'input' }); + } + view.focus(); + window.requestAnimationFrame(() => positionToolbar()); + }; + + const setHeadingLevel = (level: 0 | 1 | 2 | 3 | 4 | 5 | 6): void => { + updateSelectedLines((line) => { + const stripped = line.replace(/^\s{0,3}#{1,6}\s+/, ''); + if (level === 0) { + return stripped; + } + const existing = line.match(/^\s{0,3}(#{1,6})\s+/); + if (existing?.[1].length === level) { + return stripped; + } + return `${'#'.repeat(level)} ${stripped || 'Heading'}`; + }); + }; + + const toggleLinePrefix = (prefix: string, pattern: RegExp): void => { + updateSelectedLines((line) => pattern.test(line) ? line.replace(pattern, '') : `${prefix}${line || 'List item'}`); }; const setLinkHeadingOptions = (headings: MarkdownLinkHeading[] = [], placeholder: string = 'None'): void => { @@ -400,18 +846,92 @@ export function mountMarkdownEditor(options: { renderLinkResults(); }; - const openLinkModalForSelection = (): void => { + const findMarkdownLinkInLine = (line: { from: number; text: string }, relativeFrom: number, relativeTo: number = relativeFrom): MarkdownLinkRange | null => { + for (const match of line.text.matchAll(/\[([^\]\n]+)\]\(([^)\n]+)\)/g)) { + const start = match.index ?? 0; + const label = match[1] ?? ''; + if (line.text[start - 1] === '!' || label.startsWith('![')) { + continue; + } + const end = start + match[0].length; + if (start <= relativeFrom && relativeTo <= end) { + const href = (match[2] ?? '').trim(); + return { + from: line.from + start, + labelFrom: line.from + start + 1, + labelTo: line.from + start + 1 + label.length, + to: line.from + end, + label, + href, + kind: 'markdown', + }; + } + } + + for (const match of line.text.matchAll(/\[\[([^\]\n]+)\]\]/g)) { + const start = match.index ?? 0; + const end = start + match[0].length; + if (start <= relativeFrom && relativeTo <= end) { + const body = match[1] ?? ''; + const pipeIndex = body.lastIndexOf('|'); + const label = pipeIndex >= 0 ? body.slice(pipeIndex + 1) : body; + const labelOffset = pipeIndex >= 0 ? 2 + pipeIndex + 1 : 2; + return { + from: line.from + start, + labelFrom: line.from + start + labelOffset, + labelTo: line.from + start + labelOffset + label.length, + to: line.from + end, + label, + href: match[0], + kind: 'wiki', + }; + } + } + + return null; + }; + + const findMarkdownLinkAtPosition = (position: number): MarkdownLinkRange | null => { + const safePosition = Math.max(0, Math.min(view.state.doc.length, position)); + const line = view.state.doc.lineAt(safePosition); + const relativePosition = safePosition - line.from; + return findMarkdownLinkInLine(line, relativePosition, relativePosition); + }; + + const findEnclosingMarkdownLink = (): MarkdownLinkRange | null => { + const selection = view.state.selection.main; + const line = view.state.doc.lineAt(selection.from); + if (selection.to > line.to) { + return null; + } + return findMarkdownLinkInLine(line, selection.from - line.from, selection.to - line.from); + }; + + const openLinkModalForSelection = (existingLink?: MarkdownLinkRange): void => { if (!linkModal) { return; } - const selectedText = tiptap.state.doc.textBetween(tiptap.state.selection.from, tiptap.state.selection.to, ' ').trim(); + if (existingLink) { + view.dispatch({ selection: EditorSelection.range(existingLink.from, existingLink.to) }); + } + const selectedText = existingLink?.label ?? getSelectedText().trim(); linkModal.removeAttribute('hidden'); - updateLinkMode('url'); + updateLinkMode(existingLink?.kind === 'wiki' ? 'file' : 'url'); if (linkLabelInput) { linkLabelInput.value = selectedText; } if (linkInput) { - linkInput.value = ''; + linkInput.value = existingLink?.kind === 'markdown' ? existingLink.href : ''; + } + if (linkAliasInput) { + linkAliasInput.value = existingLink?.kind === 'wiki' ? existingLink.label : ''; + } + if (linkSearchInput) { + linkSearchInput.value = ''; + } + if (existingLink?.kind === 'wiki') { + linkSearchInput?.focus(); + } else if (linkInput) { linkInput.focus(); } linkSearchResults = []; @@ -429,23 +949,17 @@ export function mountMarkdownEditor(options: { return; } const label = linkLabelInput?.value?.trim() || href; - const { from, to, empty } = tiptap.state.selection; - if (empty) { - tiptap.chain().focus().insertContent({ - type: 'text', - text: label, - marks: [{ type: 'link', attrs: { href } }], - }).run(); + const existingLink = findEnclosingMarkdownLink(); + if (existingLink) { + view.dispatch({ + changes: { from: existingLink.from, to: existingLink.to, insert: `[${label}](${href})` }, + selection: EditorSelection.range(existingLink.from + 1, existingLink.from + 1 + label.length), + userEvent: 'input', + }); + view.focus(); + window.requestAnimationFrame(() => positionToolbar()); } else { - tiptap.chain() - .focus() - .deleteRange({ from, to }) - .insertContent({ - type: 'text', - text: label, - marks: [{ type: 'link', attrs: { href } }], - }) - .run(); + insertText(`[${label}](${href})`, 1, 1 + label.length); } } else if (selectedLinkItem) { const selectedHeadingId = linkHeadingSelect?.value?.trim(); @@ -453,24 +967,19 @@ export function mountMarkdownEditor(options: { const alias = linkAliasInput?.value?.trim(); const pathPart = selectedLinkItem.path === options.currentFilePath ? '' : selectedLinkItem.wikiPath; const wikiLink = `[[${pathPart}${selectedHeadingId ? `#${selectedHeadingId}` : ''}${alias ? `|${alias}` : ''}]]`; - const href = `${selectedLinkItem.relativePath}${selectedHeadingId ? `#${selectedHeadingId}` : ''}`; const label = alias || selectedHeadingText || selectedLinkItem.title; - const { from, to, empty } = tiptap.state.selection; - const insertChain = tiptap.chain().focus(); - if (!empty) { - insertChain.deleteRange({ from, to }); + const existingLink = findEnclosingMarkdownLink(); + if (existingLink) { + view.dispatch({ + changes: { from: existingLink.from, to: existingLink.to, insert: wikiLink }, + selection: EditorSelection.range(existingLink.from + 2, existingLink.from + 2 + label.length), + userEvent: 'input', + }); + view.focus(); + window.requestAnimationFrame(() => positionToolbar()); + } else { + insertText(wikiLink, 2, 2 + label.length); } - insertChain.insertContent({ - type: 'text', - text: label, - marks: [{ - type: 'link', - attrs: { - href, - title: `mcp-wiki:${encodeURIComponent(wikiLink)}`, - }, - }], - }).run(); } closeLinkModal(); }; @@ -483,24 +992,37 @@ export function mountMarkdownEditor(options: { } switch (format) { case 'bold': - tiptap.chain().focus().toggleBold().run(); + toggleWrapper('**', '**', 'bold text'); break; case 'italic': - tiptap.chain().focus().toggleItalic().run(); + toggleWrapper('*', '*', 'italic text'); break; case 'strike': - tiptap.chain().focus().toggleStrike().run(); + toggleWrapper('~~', '~~', 'struck text'); break; case 'quote': - tiptap.chain().focus().toggleBlockquote().run(); + toggleLinePrefix('> ', BLOCKQUOTE_PREFIX); break; case 'list': - tiptap.chain().focus().toggleBulletList().run(); + toggleLinePrefix('- ', UNORDERED_LIST_PREFIX); break; case 'code': - tiptap.chain().focus().toggleCode().run(); + toggleWrapper('`', '`', 'code'); break; case 'link': + { + const existingLink = findEnclosingMarkdownLink(); + if (existingLink) { + view.dispatch({ + changes: { from: existingLink.from, to: existingLink.to, insert: existingLink.label }, + selection: EditorSelection.range(existingLink.from, existingLink.from + existingLink.label.length), + userEvent: 'input', + }); + view.focus(); + window.requestAnimationFrame(() => positionToolbar()); + break; + } + } openLinkModalForSelection(); break; } @@ -512,111 +1034,206 @@ export function mountMarkdownEditor(options: { return; } if (value === 'p') { - tiptap.chain().focus().setParagraph().run(); + setHeadingLevel(0); return; } const match = /^h([1-6])$/.exec(value); if (match) { const level = Number.parseInt(match[1], 10) as 1 | 2 | 3 | 4 | 5 | 6; - tiptap.chain().focus().toggleHeading({ level }).run(); + setHeadingLevel(level); } }; - const linkPopover = document.createElement('div'); - linkPopover.className = 'markdown-link-popover'; - linkPopover.hidden = true; - editorDom.parentElement?.appendChild(linkPopover); - let popoverHideTimer: ReturnType | null = null; - - const showLinkPopover = (anchor: HTMLAnchorElement): void => { - if (popoverHideTimer) { - clearTimeout(popoverHideTimer); - popoverHideTimer = null; + const handleLinkModeFileClick = (): void => updateLinkMode('file'); + const handleLinkModeUrlClick = (): void => { + updateLinkMode('url'); + linkInput?.focus(); + }; + const handleSearchInput = (): void => { void runLinkSearch(); }; + const handleModalBackdropClick = (e: MouseEvent): void => { + if (e.target === linkModal) { + closeLinkModal(); } - const href = anchor.getAttribute('href') ?? ''; - linkPopover.innerHTML = ``; - linkPopover.hidden = false; + }; + const handleFocusOut = (event: FocusEvent): void => { + if (shouldIgnoreBlur(shell, event)) { + return; + } + if (contextMenu) { + contextMenu.hidden = true; + } + options.onBlur?.(); + }; - linkPopover.querySelector('#link-popover-open')?.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - linkPopover.hidden = true; - anchor.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - }, { once: true }); + positionToolbar = (preferredPoint?: { x: number; y: number }): void => { + if (!contextMenu || !shell) { + return; + } - linkPopover.querySelector('#link-popover-edit')?.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - linkPopover.hidden = true; - if (!linkModal) { - return; - } - const pos = tiptap.view.posAtDOM(anchor, 0); - if (pos >= 0) { - const endPos = pos + (anchor.textContent?.length ?? 0); - tiptap.chain().focus().setTextSelection({ from: pos, to: endPos }).run(); - } - const label = anchor.textContent?.trim() ?? ''; - linkModal.removeAttribute('hidden'); - updateLinkMode('url'); - if (linkInput) { linkInput.value = href; } - if (linkLabelInput) { linkLabelInput.value = label; } - }, { once: true }); - - const rect = anchor.getBoundingClientRect(); - const parent = editorDom.parentElement; - if (!parent) { + const activeElement = document.activeElement; + if (!view.hasFocus && activeElement && !shell.contains(activeElement)) { + contextMenu.hidden = true; return; } - const parentRect = parent.getBoundingClientRect(); - linkPopover.style.left = `${Math.max(4, rect.left - parentRect.left)}px`; - linkPopover.style.top = `${rect.bottom - parentRect.top + 4}px`; + + const selection = view.state.selection.main; + const fromCoords = view.coordsAtPos(selection.from); + const toCoords = view.coordsAtPos(selection.to); + const shellRect = (shell as HTMLElement).getBoundingClientRect(); + let clientX = preferredPoint?.x ?? fromCoords?.left ?? shellRect.left + shellRect.width / 2; + let clientY = preferredPoint?.y ?? fromCoords?.top ?? shellRect.top + 24; + + if (!selection.empty && fromCoords && toCoords) { + clientX = (fromCoords.left + toCoords.right) / 2; + clientY = Math.min(fromCoords.top, toCoords.top); + } + + contextMenu.hidden = false; + const toolbarWidth = contextMenu.offsetWidth || 1; + const toolbarHeight = contextMenu.offsetHeight || 1; + const minLeft = 8; + const maxLeft = Math.max(minLeft, shellRect.width - toolbarWidth - 8); + const unclampedLeft = clientX - shellRect.left - toolbarWidth / 2; + const left = Math.min(Math.max(unclampedLeft, minLeft), maxLeft); + let top = clientY - shellRect.top - toolbarHeight - 10; + if (top < 8) { + top = clientY - shellRect.top + 18; + } + const maxTop = Math.max(8, shellRect.height - toolbarHeight - 8); + top = Math.min(Math.max(top, 8), maxTop); + contextMenu.style.left = `${left}px`; + contextMenu.style.top = `${top}px`; }; - const hideLinkPopover = (): void => { + const hideLinkPopover = (delayMs = 180): void => { + if (popoverHideTimer) { + clearTimeout(popoverHideTimer); + } popoverHideTimer = setTimeout(() => { linkPopover.hidden = true; - }, 200); + activePopoverLink = null; + popoverHideTimer = null; + }, delayMs); }; - const handleMouseOver = (e: MouseEvent): void => { - const target = (e.target as HTMLElement)?.closest?.('a[href]') as HTMLAnchorElement | null; - if (target && editorDom.contains(target)) { - showLinkPopover(target); - } - }; - const handleMouseOut = (e: MouseEvent): void => { - const target = (e.target as HTMLElement)?.closest?.('a[href]'); - if (target) { - hideLinkPopover(); + const showLinkPopover = (link: MarkdownLinkRange, clientX: number, clientY: number): void => { + if (!shell) { + return; } - }; - const handlePopoverEnter = (): void => { if (popoverHideTimer) { clearTimeout(popoverHideTimer); popoverHideTimer = null; } + activePopoverLink = link; + linkPopover.innerHTML = ` + + + `; + linkPopover.hidden = false; + + // Position alongside the link rather than below it so the mouse can + // travel from the link to the popover without crossing another line β€” + // moving down would otherwise hover the next line's link and replace + // this popover before the user can click. + const fromCoords = view.coordsAtPos(link.labelFrom); + const toCoords = view.coordsAtPos(link.labelTo); + const shellRect = (shell as HTMLElement).getBoundingClientRect(); + const popoverWidth = linkPopover.offsetWidth || 1; + const popoverHeight = linkPopover.offsetHeight || 1; + const gap = 8; + const linkRight = toCoords?.right ?? clientX; + const linkLeft = fromCoords?.left ?? clientX; + const linkTop = fromCoords?.top ?? clientY; + const linkBottom = fromCoords?.bottom ?? clientY; + const linkMid = (linkTop + linkBottom) / 2; + let anchorLeft = linkRight + gap; + if (anchorLeft + popoverWidth > shellRect.right - gap) { + anchorLeft = linkLeft - popoverWidth - gap; + } + const anchorTop = linkMid - popoverHeight / 2; + const left = Math.min( + Math.max(anchorLeft - shellRect.left, gap), + Math.max(gap, shellRect.width - popoverWidth - gap) + ); + const top = Math.min( + Math.max(anchorTop - shellRect.top, gap), + Math.max(gap, shellRect.height - popoverHeight - gap) + ); + linkPopover.style.left = `${left}px`; + linkPopover.style.top = `${top}px`; }; - const handlePopoverLeave = (): void => { - hideLinkPopover(); + + const handleLinkMouseMove = (event: MouseEvent): void => { + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); + if (typeof pos !== 'number') { + hideLinkPopover(); + return; + } + const link = findMarkdownLinkAtPosition(pos); + if (!link) { + hideLinkPopover(); + return; + } + showLinkPopover(link, event.clientX, event.clientY); }; - const handleLinkModeFileClick = (): void => updateLinkMode('file'); - const handleLinkModeUrlClick = (): void => { - updateLinkMode('url'); - linkInput?.focus(); + + const handleLinkPopoverMouseDown = (event: MouseEvent): void => { + event.preventDefault(); }; - const handleSearchInput = (): void => { void runLinkSearch(); }; - const handleModalBackdropClick = (e: MouseEvent): void => { - if (e.target === linkModal) { - closeLinkModal(); + + const handleLinkPopoverClick = (event: MouseEvent): void => { + const button = (event.target as HTMLElement | null)?.closest('button'); + if (!button || !activePopoverLink) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const link = activePopoverLink; + linkPopover.hidden = true; + activePopoverLink = null; + + if (button.id === 'link-popover-open') { + void options.onOpenLink?.(link.href); + return; + } + + if (button.id === 'link-popover-edit') { + openLinkModalForSelection(link); } }; - editorDom.addEventListener('mouseover', handleMouseOver); - editorDom.addEventListener('mouseout', handleMouseOut); - linkPopover.addEventListener('mouseenter', handlePopoverEnter); - linkPopover.addEventListener('mouseleave', handlePopoverLeave); + const handleEditorMouseUp = (event: MouseEvent): void => { + positionToolbar({ x: event.clientX, y: event.clientY }); + }; + const handleEditorKeyUp = (): void => { + positionToolbar(); + }; + const handleEditorFocusIn = (): void => { + positionToolbar(); + }; + const handleFormatMouseDown = (event: MouseEvent): void => { + event.preventDefault(); + }; + + view.dom.addEventListener('focusout', handleFocusOut); + view.dom.addEventListener('mousemove', handleLinkMouseMove); + view.dom.addEventListener('mouseleave', () => hideLinkPopover()); + view.dom.addEventListener('mouseup', handleEditorMouseUp); + view.dom.addEventListener('keyup', handleEditorKeyUp); + view.dom.addEventListener('focusin', handleEditorFocusIn); + const handleLinkPopoverMouseEnter = (): void => { + if (popoverHideTimer) { + clearTimeout(popoverHideTimer); + popoverHideTimer = null; + } + }; + const handleLinkPopoverMouseLeave = (): void => hideLinkPopover(); + linkPopover.addEventListener('mousedown', handleLinkPopoverMouseDown); + linkPopover.addEventListener('click', handleLinkPopoverClick); + linkPopover.addEventListener('mouseenter', handleLinkPopoverMouseEnter); + linkPopover.addEventListener('mouseleave', handleLinkPopoverMouseLeave); formatButtons.forEach((button) => button.addEventListener('click', handleFormatClick)); + formatButtons.forEach((button) => button.addEventListener('mousedown', handleFormatMouseDown)); blockStyleSelect?.addEventListener('change', handleBlockStyleChange); linkModeFile?.addEventListener('click', handleLinkModeFileClick); linkModeUrl?.addEventListener('click', handleLinkModeUrlClick); @@ -626,17 +1243,23 @@ export function mountMarkdownEditor(options: { linkModal?.addEventListener('click', handleModalBackdropClick); if (typeof options.initialScrollTop === 'number') { - editorDom.scrollTop = options.initialScrollTop; + view.scrollDOM.scrollTop = options.initialScrollTop; } renderLinkResults(); return { destroy: () => { - editorDom.removeEventListener('mouseover', handleMouseOver); - editorDom.removeEventListener('mouseout', handleMouseOut); - linkPopover.removeEventListener('mouseenter', handlePopoverEnter); - linkPopover.removeEventListener('mouseleave', handlePopoverLeave); + view.dom.removeEventListener('focusout', handleFocusOut); + view.dom.removeEventListener('mousemove', handleLinkMouseMove); + view.dom.removeEventListener('mouseup', handleEditorMouseUp); + view.dom.removeEventListener('keyup', handleEditorKeyUp); + view.dom.removeEventListener('focusin', handleEditorFocusIn); + linkPopover.removeEventListener('mousedown', handleLinkPopoverMouseDown); + linkPopover.removeEventListener('click', handleLinkPopoverClick); + linkPopover.removeEventListener('mouseenter', handleLinkPopoverMouseEnter); + linkPopover.removeEventListener('mouseleave', handleLinkPopoverMouseLeave); formatButtons.forEach((button) => button.removeEventListener('click', handleFormatClick)); + formatButtons.forEach((button) => button.removeEventListener('mousedown', handleFormatMouseDown)); blockStyleSelect?.removeEventListener('change', handleBlockStyleChange); linkModeFile?.removeEventListener('click', handleLinkModeFileClick); linkModeUrl?.removeEventListener('click', handleLinkModeUrlClick); @@ -644,34 +1267,31 @@ export function mountMarkdownEditor(options: { linkApply?.removeEventListener('click', handleLinkApply); linkCancel?.removeEventListener('click', closeLinkModal); linkModal?.removeEventListener('click', handleModalBackdropClick); - linkPopover.remove(); if (popoverHideTimer) { clearTimeout(popoverHideTimer); } - tiptap.destroy(); + linkPopover.remove(); + view.destroy(); options.target.replaceChildren(); }, focus: () => { - tiptap.commands.focus(); + view.focus(); }, - getValue: () => getTiptapMarkdown(), + getValue, setValue: (value: string) => { - tiptap.commands.setContent(rewriteWikiLinks(value), { emitUpdate: false }); - syncHeadingIds(editorDom); + suppressChange = true; + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } }); + suppressChange = false; }, - revealLine: (_lineNumber: number, headingId?: string) => { - if (headingId) { - const heading = editorDom.querySelector(`#${CSS.escape(headingId)}`); - if (heading) { - heading.scrollIntoView({ block: 'start', inline: 'nearest' }); - editorDom.scrollTop = Math.max(editorDom.scrollTop - 24, 0); - heading.setAttribute('tabindex', '-1'); - heading.focus({ preventScroll: true }); - return; - } - } - tiptap.commands.focus(); + revealLine: (lineNumber: number) => { + const targetLine = Math.max(1, Math.min(view.state.doc.lines, Math.floor(lineNumber))); + const line = view.state.doc.line(targetLine); + view.dispatch({ + selection: EditorSelection.cursor(line.from), + effects: EditorView.scrollIntoView(line.from, { y: 'start', yMargin: 48 }), + }); + view.focus(); }, setScrollTop: (scrollTop: number) => { - editorDom.scrollTop = Math.max(0, scrollTop); + view.scrollDOM.scrollTop = Math.max(0, scrollTop); }, }; } diff --git a/src/ui/file-preview/src/markdown/linking.ts b/src/ui/file-preview/src/markdown/linking.ts index dfe0baac..c7d6c1bc 100644 --- a/src/ui/file-preview/src/markdown/linking.ts +++ b/src/ui/file-preview/src/markdown/linking.ts @@ -15,9 +15,6 @@ interface ParsedWikiLink { alias?: string; } -const WIKI_LINK_PATTERN = /\[\[([^\]|#]*)(?:#([^\]|]+))?(?:\|([^\]]+))?\]\]/g; -const FENCE_PATTERN = /^(`{3,}|~{3,})/; - function encodeLinkPath(pathValue: string): string { return encodeURI(normalizePathSeparators(pathValue)); } @@ -43,22 +40,6 @@ function parseWikiLink(rawHref: string): ParsedWikiLink | null { }; } -function buildWikiDisplayText(link: ParsedWikiLink): string { - if (link.alias && link.alias.length > 0) { - return link.alias; - } - - if (link.path && link.anchor) { - return `${link.path}#${link.anchor}`; - } - - if (link.path) { - return link.path; - } - - return link.anchor ?? ''; -} - function appendMarkdownExtension(pathValue: string): string { if (/\.[A-Za-z0-9_-]+$/.test(pathValue)) { return pathValue; @@ -92,51 +73,6 @@ function buildWikiHref(link: ParsedWikiLink): string { return `${encodedPath}#${slugifyMarkdownHeading(link.anchor)}`; } -function rewriteWikiLinksInPlainText(segment: string): string { - return segment.replace(WIKI_LINK_PATTERN, (match) => { - const parsed = parseWikiLink(match); - if (!parsed) { - return match; - } - - const displayText = buildWikiDisplayText(parsed); - const href = buildWikiHref(parsed); - return `[${displayText}](${href} "mcp-wiki:${encodeURIComponent(match)}")`; - }); -} - -function replaceWikiLinksOutsideInlineCode(line: string): string { - let result = ''; - let cursor = 0; - - while (cursor < line.length) { - const codeStart = line.indexOf('`', cursor); - if (codeStart === -1) { - result += rewriteWikiLinksInPlainText(line.slice(cursor)); - break; - } - - result += rewriteWikiLinksInPlainText(line.slice(cursor, codeStart)); - - let delimiterEnd = codeStart; - while (delimiterEnd < line.length && line[delimiterEnd] === '`') { - delimiterEnd += 1; - } - - const delimiter = line.slice(codeStart, delimiterEnd); - const codeEnd = line.indexOf(delimiter, delimiterEnd); - if (codeEnd === -1) { - result += line.slice(codeStart); - break; - } - - result += line.slice(codeStart, codeEnd + delimiter.length); - cursor = codeEnd + delimiter.length; - } - - return result; -} - function decodeAnchorFragment(fragment: string | undefined): string | undefined { if (!fragment || fragment.length === 0) { return undefined; @@ -199,47 +135,6 @@ function resolveFileTargetPath(currentPath: string, rawPath: string): string { return normalizeFilePath(fromFileUrl(resolvedUrl)); } -/** - * Invert `rewriteWikiLinks`: convert `[alias](href "mcp-wiki:ENCODED")` links - * back to their original `[[...]]` form. Used when serializing a WYSIWYG - * edit session back to markdown β€” the `mcp-wiki:` title prefix is the - * round-trip marker written by `rewriteWikiLinks`. - */ -export function restoreWikiLinks(markdown: string): string { - return markdown.replace(/\[([^\]]*)\]\(([^)\s]*)(?:\s+"mcp-wiki:([^"]+)")\)/g, (_, _alias, _href, encoded) => { - try { - return decodeURIComponent(encoded); - } catch { - return `[[${encoded}]]`; - } - }); -} - -export function rewriteWikiLinks(source: string): string { - const lines = source.split('\n'); - let activeFence: string | null = null; - - return lines.map((line) => { - const trimmedStart = line.trimStart(); - const fenceMatch = trimmedStart.match(FENCE_PATTERN); - if (fenceMatch) { - const marker = fenceMatch[1]; - if (!activeFence) { - activeFence = marker; - } else if (marker[0] === activeFence[0] && marker.length >= activeFence.length) { - activeFence = null; - } - return line; - } - - if (activeFence) { - return line; - } - - return replaceWikiLinksOutsideInlineCode(line); - }).join('\n'); -} - export function resolveMarkdownLink(currentPath: string, rawHref: string): ResolvedMarkdownLink { const wikiLink = parseWikiLink(rawHref); if (wikiLink) { diff --git a/src/ui/file-preview/src/markdown/outline.ts b/src/ui/file-preview/src/markdown/outline.ts index 36ec29a0..ea853237 100644 --- a/src/ui/file-preview/src/markdown/outline.ts +++ b/src/ui/file-preview/src/markdown/outline.ts @@ -1,21 +1,87 @@ import type { DocumentOutlineItem } from '../document-outline.js'; -import { createMarkdownIt, prepareMarkdownSource, readHeadingProjection } from './parser.js'; +import { GFM, parser } from '@lezer/markdown'; import { createSlugTracker } from './slugify.js'; -const outlineParser = createMarkdownIt(); + +const outlineParser = parser.configure([GFM]); +const HEADING_NODE_PATTERN = /^(?:ATXHeading|SetextHeading)([1-6])$/; + +function buildLineStarts(source: string): number[] { + const starts = [0]; + for (let index = 0; index < source.length; index += 1) { + if (source[index] === '\n') { + starts.push(index + 1); + } + } + return starts; +} + +function lineNumberForOffset(lineStarts: number[], offset: number): number { + let low = 0; + let high = lineStarts.length - 1; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + if (lineStarts[mid] <= offset) { + low = mid + 1; + } else { + high = mid - 1; + } + } + return Math.max(1, high + 1); +} + +function stripInlineMarkdown(text: string): string { + return text + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/\[\[([^\]|#]*(?:#[^\]|]+)?)(?:\|([^\]]+))?\]\]/g, (_match, target, alias) => alias || target) + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/__([^_]+)__/g, '$1') + .replace(/~~([^~]+)~~/g, '$1') + .replace(/(^|[^*])\*([^*]+)\*/g, '$1$2') + .replace(/(^|[^_])_([^_]+)_/g, '$1$2') + .replace(/<[^>]+>/g, '') + .replace(/\\([\\`*{}\[\]()#+\-.!_>~|])/g, '$1') + .trim(); +} + +function readHeadingText(source: string, from: number, to: number, nodeName: string): string { + const rawHeading = source.slice(from, to); + const isSetext = nodeName.startsWith('SetextHeading'); + const text = isSetext + ? rawHeading.split(/\r?\n/)[0] ?? '' + : rawHeading + .replace(/^\s{0,3}#{1,6}\s*/, '') + .replace(/\s+#+\s*$/, ''); + return stripInlineMarkdown(text); +} export function extractMarkdownOutline(source: string): DocumentOutlineItem[] { - const tokens = outlineParser.parse(prepareMarkdownSource(source), {}); + const tree = outlineParser.parse(source); + const cursor = tree.cursor(); const nextSlug = createSlugTracker(); const outline: DocumentOutlineItem[] = []; + const lineStarts = buildLineStarts(source); - for (let index = 0; index < tokens.length; index += 1) { - const heading = readHeadingProjection(tokens, index, nextSlug); - if (!heading) { - continue; + cursor.iterate((node) => { + const match = node.name.match(HEADING_NODE_PATTERN); + if (!match) { + return; } - outline.push(heading); - } + const level = Number.parseInt(match[1], 10); + const text = readHeadingText(source, node.from, node.to, node.name); + if (!text) { + return; + } + + outline.push({ + id: nextSlug(text), + text, + level, + line: lineNumberForOffset(lineStarts, node.from), + }); + }); return outline; } diff --git a/src/ui/file-preview/src/markdown/parser.ts b/src/ui/file-preview/src/markdown/parser.ts deleted file mode 100644 index 83888b6a..00000000 --- a/src/ui/file-preview/src/markdown/parser.ts +++ /dev/null @@ -1,85 +0,0 @@ -// markdown-it is intentionally typed locally here to avoid maintaining ambient module declarations. -import MarkdownIt from 'markdown-it'; -import type { MarkdownSlugTracker } from './slugify.js'; -import { rewriteWikiLinks } from './linking.js'; -import { extractInlineText } from './utils.js'; - -export interface MarkdownToken { - type?: string; - tag?: string; - map?: number[]; - children?: unknown; - content?: unknown; - attrSet?: (name: string, value: string) => void; - attrGet?: (name: string) => string | null; - attrs?: Array<[string, string]>; -} - -interface MarkdownItInstance { - render: (source: string, env?: Record) => string; - parse: (source: string, env?: Record) => MarkdownToken[]; - renderer: { - rules: Record string>; - }; -} - -type MarkdownItConstructor = new (options?: { - html?: boolean; - linkify?: boolean; - typographer?: boolean; - highlight?: (code: string, language: string) => string; -}) => MarkdownItInstance; - -export interface MarkdownHeadingProjection { - id: string; - text: string; - level: number; - line: number; -} - -const MarkdownItCtor = MarkdownIt as unknown as MarkdownItConstructor; - -export function createMarkdownIt(options: { - highlight?: (code: string, language: string) => string; -} = {}): MarkdownItInstance { - return new MarkdownItCtor({ - html: false, - linkify: true, - typographer: false, - ...(options.highlight ? { highlight: options.highlight } : {}), - }); -} - -export function prepareMarkdownSource(source: string): string { - return rewriteWikiLinks(source); -} - -export function readHeadingProjection( - tokens: MarkdownToken[], - index: number, - nextSlug: MarkdownSlugTracker -): MarkdownHeadingProjection | null { - const token = tokens[index]; - if (token?.type !== 'heading_open' || typeof token.tag !== 'string') { - return null; - } - - const level = Number.parseInt(token.tag.replace(/^h/i, ''), 10); - if (!Number.isFinite(level)) { - return null; - } - - const inlineToken = tokens[index + 1] as Record | undefined; - const text = extractInlineText(inlineToken).trim(); - if (!text) { - return null; - } - - const lineMap = Array.isArray(token.map) ? token.map : undefined; - return { - id: nextSlug(text), - text, - level, - line: typeof lineMap?.[0] === 'number' ? lineMap[0] + 1 : index + 1, - }; -} diff --git a/src/ui/file-preview/src/markdown/preview.ts b/src/ui/file-preview/src/markdown/preview.ts deleted file mode 100644 index a8364d63..00000000 --- a/src/ui/file-preview/src/markdown/preview.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { renderMarkdown } from '../components/markdown-renderer.js'; - -export function getRenderedMarkdownCopyText(content: string): string { - const html = renderMarkdown(content); - const normalizedHtml = html - .replace(/<\s*br\s*\/?>/gi, '\n') - .replace(/<\/p>/gi, '\n\n') - .replace(/<\/h[1-6]>/gi, '\n\n') - .replace(/<\/li>/gi, '\n') - .replace(/
  • /gi, '- ') - .replace(/<[^>]+>/g, ''); - - return normalizedHtml - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/'/g, "'") - .replace(/"/g, '"') - .replace(/\n{3,}/g, '\n\n') - .trim(); -} - diff --git a/src/ui/file-preview/src/markdown/utils.ts b/src/ui/file-preview/src/markdown/utils.ts deleted file mode 100644 index a7a0e7ca..00000000 --- a/src/ui/file-preview/src/markdown/utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function extractInlineText(token: Record | undefined): string { - if (!token) { - return ''; - } - - const children = Array.isArray(token.children) ? token.children : []; - if (children.length === 0) { - return typeof token.content === 'string' ? token.content : ''; - } - - return children.map((child) => { - if (typeof child.content === 'string') { - return child.content; - } - return ''; - }).join(''); -} diff --git a/src/ui/file-preview/src/model.ts b/src/ui/file-preview/src/model.ts index df5adc6e..98730cd0 100644 --- a/src/ui/file-preview/src/model.ts +++ b/src/ui/file-preview/src/model.ts @@ -6,11 +6,9 @@ export type RenderPayload = FilePreviewStructuredContent & { content: string }; export interface MarkdownWorkspaceState { filePath: string; - sourceContent: string; fullDocumentContent: string; draftContent: string; outline: DocumentOutlineItem[]; - mode: 'edit'; dirty: boolean; activeHeadingId: string | null; pendingAnchor: string | null; diff --git a/src/ui/styles/apps/file-preview.css b/src/ui/styles/apps/file-preview.css index afdfe7d8..45ae19b9 100644 --- a/src/ui/styles/apps/file-preview.css +++ b/src/ui/styles/apps/file-preview.css @@ -397,28 +397,41 @@ html:has(.fullscreen) #app { max-width: none; } -.markdown-editor-context-menu { +.markdown-editor-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: min(50vh, 480px); + color: var(--markdown-muted); + font-size: 14px; +} + +.markdown-editor-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 6px; - background: #171717; - border: 1px solid color-mix(in srgb, var(--border) 34%, transparent); - border-radius: 10px; position: absolute; - z-index: 20; - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); + width: fit-content; + margin: 0; + z-index: 22; + background: color-mix(in srgb, var(--panel) 92%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 55%, transparent); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12); + backdrop-filter: blur(12px); + transform: translateZ(0); } -.markdown-editor-context-menu[hidden] { +.markdown-editor-toolbar[hidden] { display: none; } .markdown-format-button { border: none; background: transparent; - color: rgba(255, 255, 255, 0.9); + color: var(--text); border-radius: 6px; width: 32px; height: 32px; @@ -433,11 +446,149 @@ html:has(.fullscreen) #app { } .markdown-format-button:hover { - background: rgba(255, 255, 255, 0.14); + background: color-mix(in srgb, var(--panel-subtle) 72%, transparent); } -.markdown-format-button--swatch { - padding: 0; +.markdown-editor-root .cm-editor { + min-height: min(70vh, 800px); + width: 100%; + color: var(--markdown-text); + background: transparent; + outline: none; +} + +.markdown-editor-root .cm-focused { + outline: none; +} + +.markdown-editor-root .cm-scroller { + min-height: min(70vh, 800px); + overflow: auto; + font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); +} + +.markdown-editor-root .cm-content { + max-width: 780px; + min-height: min(70vh, 800px); + margin: 0 auto; + padding: 30px 28px 42px; + line-height: 1.78; + caret-color: var(--text); +} + +.markdown-editor-root .cm-line { + padding: 0 2px; + color: var(--markdown-muted); +} + +.markdown-editor-root .cm-md-hidden-marker { + display: inline-block; + width: 0; + min-width: 0; + max-width: 0; + overflow: hidden; + opacity: 0; + white-space: pre; +} + +.markdown-editor-root .cm-md-strong-text { + color: var(--markdown-text); + font-weight: 700; +} + +.markdown-editor-root .cm-md-emphasis-text { + font-style: italic; +} + +.markdown-editor-root .cm-md-strike-text { + text-decoration: line-through; +} + +.markdown-editor-root .cm-md-link-text { + color: inherit; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--text-secondary) 45%, transparent); + text-underline-offset: 0.18em; +} + +.markdown-editor-root .cm-md-inline-code-text { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: .9em; + background: var(--inline-code-bg); + color: var(--inline-code-text); + border: 1px solid var(--inline-code-border); + border-radius: 6px; + padding: 1px 5px; +} + +.markdown-editor-root .cm-md-heading { + color: var(--markdown-text); + letter-spacing: -0.015em; +} + +.markdown-editor-root .cm-md-heading-1 { + padding-top: 4px; + padding-bottom: 12px; + font-size: 28px; + line-height: 1.25; + font-weight: 700; +} + +.markdown-editor-root .cm-md-heading-2 { + padding-top: 22px; + padding-bottom: 8px; + font-size: 22px; + line-height: 1.3; + font-weight: 650; +} + +.markdown-editor-root .cm-md-heading-3 { + padding-top: 16px; + padding-bottom: 6px; + font-size: 18px; + line-height: 1.35; + font-weight: 620; +} + +.markdown-editor-root .cm-md-quote { + margin: 8px 0; + border-left: 3px solid color-mix(in srgb, var(--border) 70%, transparent); + color: var(--markdown-muted); + font-style: italic; +} + +.markdown-editor-root .cm-md-list { + line-height: 1.65; + position: relative; + padding-left: 1.15em; +} + +.markdown-editor-root .cm-md-bullet { + color: var(--markdown-text); + line-height: inherit; +} + +.markdown-editor-root .cm-md-rule { + color: color-mix(in srgb, var(--markdown-muted) 45%, transparent); + letter-spacing: 0.12em; +} + +.markdown-editor-root .cm-activeLine { + background: color-mix(in srgb, var(--panel-subtle) 42%, transparent); +} + +.markdown-editor-root .cm-selectionBackground, +.markdown-editor-root .cm-content ::selection { + background: color-mix(in srgb, var(--accent) 22%, transparent); +} + +.markdown-editor-root .cm-cursor { + border-left-color: var(--text); +} + +.markdown-editor-root .cm-matchingBracket, +.markdown-editor-root .cm-nonmatchingBracket { + background: color-mix(in srgb, var(--panel-subtle) 70%, transparent); } .markdown-format-size { @@ -447,14 +598,14 @@ html:has(.fullscreen) #app { height: 32px; border-radius: 6px; overflow: hidden; - background: rgba(255, 255, 255, 0.08); + background: color-mix(in srgb, var(--panel-subtle) 72%, transparent); } .markdown-format-size select { height: 32px; border: none; background: transparent; - color: rgba(255, 255, 255, 0.9); + color: var(--text); padding: 0 8px; font: inherit; font-size: 12px; @@ -464,7 +615,7 @@ html:has(.fullscreen) #app { .markdown-format-sep { width: 1px; height: 20px; - background: rgba(255, 255, 255, 0.14); + background: color-mix(in srgb, var(--border) 65%, transparent); } .markdown-link-modal { @@ -781,7 +932,7 @@ html:has(.fullscreen) #app { .markdown-link-popover { position: absolute; - z-index: 10; + z-index: 24; display: flex; align-items: center; gap: 2px; @@ -817,18 +968,6 @@ html:has(.fullscreen) #app { position: relative; } -.markdown-editor-surface { - flex: 1 1 auto; - min-height: min(70vh, 760px); - outline: none; -} - -.markdown-editor-surface--markdown { - max-width: none; - margin: 0; - padding: 22px 24px 28px; -} - .markdown-editor-textarea { width: 100%; min-height: min(70vh, 760px); @@ -1010,110 +1149,6 @@ html:has(.fullscreen) #app { .markdown p { margin: 1em 0; } .markdown ul, .markdown ol { margin: .8em 0; padding-left: 1.4rem; } -.markdown-doc { - max-width: 720px; - margin: 0 auto; - padding: 32px 28px 36px; - line-height: 1.8; - color: var(--markdown-text); - font-size: 16px; - background: transparent; - border: 0; - border-radius: 0; -} - -.markdown-doc h1 { - margin: 0 0 24px; - font-size: 28px; - font-weight: 600; - line-height: 1.25; - letter-spacing: -0.02em; - color: var(--markdown-text); -} - -.markdown-doc h2 { - margin: 36px 0 16px; - font-size: 22px; - font-weight: 600; - line-height: 1.3; - letter-spacing: -0.01em; - color: var(--markdown-text); -} - -.markdown-doc h3 { - margin: 28px 0 12px; - font-size: 18px; - font-weight: 600; - line-height: 1.35; - color: var(--markdown-text); -} - -.markdown-doc p { - font-size: 15px; - line-height: 1.75; - color: var(--markdown-muted); - margin: 0 0 1.2em; -} - -.markdown-doc li { - font-size: 15px; - line-height: 1.7; - color: var(--markdown-muted); - margin-bottom: 0.3em; -} - -.markdown-doc ul, .markdown-doc ol { - margin: 8px 0 20px; - padding-left: 1.3em; -} - -.markdown-doc blockquote { - margin: 20px 0; - padding: 2px 0 2px 20px; - border-left: 3px solid color-mix(in srgb, var(--border) 70%, transparent); - color: var(--markdown-muted); - font-style: italic; -} - -.markdown-doc hr { - border: none; - border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent); - margin: 32px 0; -} - -.markdown-doc img { - max-width: 100%; - border-radius: 8px; - margin: 16px 0; -} - -.markdown-doc code:not(.hljs) { - font-family: var(--font-mono, ui-monospace, monospace); - font-size: .9em; - background: var(--inline-code-bg); - color: var(--inline-code-text); - border: 1px solid var(--inline-code-border); - border-radius: 6px; - padding: 2px 6px; -} - -.markdown-doc .code-viewer { - margin: 14px 0; - border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); - border-radius: 10px; -} - -.markdown-doc a { - color: inherit; - text-decoration: underline; - text-decoration-color: color-mix(in srgb, var(--text-secondary) 45%, transparent); - text-underline-offset: 0.18em; -} - -.markdown-doc a:hover { - text-decoration-color: currentcolor; -} - /* ── HTML frame ── */ .html-rendered-frame { @@ -1171,14 +1206,6 @@ html:has(.fullscreen) #app { min-height: 58vh; padding: 14px; } - .markdown-editor-surface--markdown { - padding: 16px; - } - .markdown-doc { padding: 16px; } - .markdown-doc h1 { font-size: 27px; } - .markdown-doc h2 { font-size: 22px; } - .markdown-doc h3 { font-size: 18px; } - .markdown-doc p, .markdown-doc li { font-size: 15px; } } /* ── Directory tree ── */ diff --git a/test/test-markdown-preview.js b/test/test-markdown-preview.js index 5f48ebb0..fe62d66c 100644 --- a/test/test-markdown-preview.js +++ b/test/test-markdown-preview.js @@ -1,10 +1,8 @@ import assert from 'assert'; import { pathToFileURL } from 'url'; -import { renderMarkdown } from '../dist/ui/file-preview/src/components/markdown-renderer.js'; -import { resolveMarkdownLink, rewriteWikiLinks } from '../dist/ui/file-preview/src/markdown/linking.js'; +import { resolveMarkdownLink } from '../dist/ui/file-preview/src/markdown/linking.js'; import { extractMarkdownOutline } from '../dist/ui/file-preview/src/markdown/outline.js'; -import { getRenderedMarkdownCopyText } from '../dist/ui/file-preview/src/markdown/preview.js'; import { renderMarkdownEditorShell } from '../dist/ui/file-preview/src/markdown/editor.js'; import { createMarkdownController } from '../dist/ui/file-preview/src/markdown/controller.js'; import { createSlugTracker, slugifyMarkdownHeading } from '../dist/ui/file-preview/src/markdown/slugify.js'; @@ -125,40 +123,28 @@ async function testLinkResolution() { console.log('βœ“ anchors, file links, absolute paths, external URLs, and wiki links resolve correctly'); } -async function testWikiRewriteAndRendering() { - console.log('\n--- Test 4: wiki link rewrite and rendering ---'); +async function testOutlineFromMarkdownSource() { + console.log('\n--- Test 4: source-backed outline text ---'); - const rewritten = rewriteWikiLinks('See [[Meeting Notes#Action Items|Actions]] and `[[Code]]`.'); - assert.ok(rewritten.includes('[Actions](./Meeting%20Notes.md#action-items "mcp-wiki:'), 'Wiki links should rewrite to markdown links with round-trip metadata'); - assert.ok(rewritten.includes('`[[Code]]`'), 'Inline code should remain untouched'); - const multiTickRewrite = rewriteWikiLinks('Use ``[[Code]]`` and `code [[still-not-link]]` samples.'); - assert.ok(multiTickRewrite.includes('``[[Code]]``'), 'Multi-backtick inline code should remain untouched'); - assert.ok(multiTickRewrite.includes('`code [[still-not-link]]`'), 'Wiki links inside inline code should stay literal'); - - const fencedRewrite = rewriteWikiLinks([ - '````md', - '```', - '[[Inside Code]]', - '````', - '[[Outside Code]]', - ].join('\n')); - assert.ok(fencedRewrite.includes('[[Inside Code]]'), 'Long code fences should remain open until a matching-length close fence appears'); - assert.ok(fencedRewrite.includes('[Outside Code](./Outside%20Code.md "mcp-wiki:'), 'Wiki links outside closed fences should still rewrite'); - - const html = renderMarkdown([ + const outline = extractMarkdownOutline([ '# Title', '## Details', '## Details', '', - 'Go to [[Meeting Notes#Action Items|Actions]].', + '### Linked [Section](#details)', ].join('\n')); - assert.ok(html.includes('id="title"'), 'Rendered markdown should include slugged heading ids'); - assert.ok(html.includes('id="details-2"'), 'Duplicate headings should receive unique ids'); - assert.ok(html.includes('href="./Meeting%20Notes.md#action-items"'), 'Rendered markdown should keep rewritten wiki links'); - assert.ok(html.includes('data-wiki-link="[[Meeting Notes#Action Items|Actions]]"'), 'Rendered markdown should preserve original wiki-link syntax for editing'); + assert.deepStrictEqual( + outline.map((item) => ({ id: item.id, text: item.text, level: item.level })), + [ + { id: 'title', text: 'Title', level: 1 }, + { id: 'details', text: 'Details', level: 2 }, + { id: 'details-2', text: 'Details', level: 2 }, + { id: 'linked-section', text: 'Linked Section', level: 3 }, + ], + ); - console.log('βœ“ markdown rendering uses preview heading ids and rewritten wiki links'); + console.log('βœ“ outline text strips inline markdown and dedupes heading slugs'); } async function testFailedSaveResyncsEditBaseline() { @@ -274,7 +260,6 @@ async function testFailedSaveResyncsEditBaseline() { 'omega', '', ].join('\n'), 'The simulated disk should keep the partial save'); - assert.strictEqual(state.sourceContent, diskContent, 'Source content should match the latest disk contents'); assert.strictEqual(state.fullDocumentContent, diskContent, 'The full document baseline should match the latest disk contents'); assert.strictEqual(state.draftContent, [ 'alpha updated', @@ -370,6 +355,80 @@ async function testSuccessfulSaveResetsUndoBaseline() { console.log('βœ“ successful saves clear undo state against the latest saved content'); } +async function testInFlightSaveKeepsNewerDraftDirty() { + console.log('\n--- Test 13: in-flight saves keep newer drafts dirty ---'); + + const payload = { + fileName: 'notes.md', + filePath: '/Users/tester/docs/notes.md', + fileType: 'markdown', + content: 'alpha\n', + }; + + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + globalThis.window = { setTimeout: globalThis.setTimeout }; + globalThis.document = { + getElementById: () => null, + querySelector: () => null, + }; + + let resolveEditBlock; + let savedString = null; + + const controller = createMarkdownController({ + callTool: async (name, args) => { + if (name !== 'edit_block') { + throw new Error(`Unexpected tool call: ${name}`); + } + savedString = args.new_string; + await new Promise((resolve) => { + resolveEditBlock = resolve; + }); + return { + content: [{ type: 'text', text: 'Successfully applied 1 edit(s) to notes.md' }], + structuredContent: { + fileName: payload.fileName, + filePath: payload.filePath, + fileType: payload.fileType, + }, + }; + }, + getAvailableDisplayModes: () => ['inline', 'fullscreen'], + getCurrentDisplayMode: () => 'inline', + getCurrentPayload: () => payload, + setExpanded: () => {}, + storePayloadOverride: () => {}, + rerender: () => {}, + updateSaveStatus: () => {}, + }); + + try { + const state = controller.getState(payload); + state.draftContent = 'beta\n'; + state.dirty = true; + + const savePromise = controller.saveDocument(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + state.draftContent = 'gamma\n'; + state.dirty = true; + resolveEditBlock(); + await savePromise; + + assert.strictEqual(savedString, 'beta\n', 'The in-flight save should write the original save snapshot'); + assert.strictEqual(state.fullDocumentContent, 'beta\n', 'The saved snapshot should become the disk baseline'); + assert.strictEqual(state.draftContent, 'gamma\n', 'Newer local edits should remain in the draft'); + assert.strictEqual(state.dirty, true, 'Newer local edits should stay dirty after the older save completes'); + } finally { + controller.disposeHandles(); + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } + + console.log('βœ“ in-flight saves keep newer local edits dirty'); +} + async function testFullscreenWorkspaceHelpers() { console.log('\n--- Test 6: fullscreen document helpers ---'); @@ -399,10 +458,22 @@ async function testFullscreenWorkspaceHelpers() { async function testCopyFormatsAndEditorShell() { console.log('\n--- Test 8: copy formats and editor shell ---'); - const renderedCopy = getRenderedMarkdownCopyText('# Title\n\n- First\n- Second\n\n**Bold** text'); - assert.ok(renderedCopy.includes('Title'), 'Rendered copy should preserve heading text'); - assert.ok(renderedCopy.includes('- First'), 'Rendered copy should preserve list text'); - assert.ok(renderedCopy.includes('Bold text'), 'Rendered copy should flatten formatted inline text'); + const copySource = '# Title\n\n- First\n- Second\n\n**Bold** text'; + const controller = createMarkdownController({ + getAvailableDisplayModes: () => ['inline', 'fullscreen'], + getCurrentDisplayMode: () => 'inline', + getCurrentPayload: () => undefined, + setExpanded: () => {}, + storePayloadOverride: () => {}, + rerender: () => {}, + updateSaveStatus: () => {}, + }); + assert.strictEqual(controller.getCopyText({ + fileName: 'notes.md', + filePath: '/Users/tester/docs/notes.md', + fileType: 'markdown', + content: copySource, + }), copySource, 'Copy should preserve markdown source exactly'); const markdownShell = renderMarkdownEditorShell({ view: 'markdown', @@ -424,7 +495,7 @@ async function testCopyFormatsAndEditorShell() { assert.ok(!rawShell.includes('markdown-editor-context-menu'), 'Raw mode should not include markdown formatting context controls'); assert.ok(!rawShell.includes('data-format="bold"'), 'Raw mode should not include formatting buttons'); - console.log('βœ“ raw/rendered copy support and mode-specific editor shell are wired'); + console.log('βœ“ source copy support and mode-specific editor shell are wired'); } async function testPartialDocumentBecomesNewEditBaseline() { @@ -468,7 +539,6 @@ async function testPartialDocumentBecomesNewEditBaseline() { await controller.requestEditMode(partialPayload); const nextState = controller.getState(currentPayload); - assert.strictEqual(nextState.mode, 'edit'); assert.strictEqual(nextState.fullDocumentContent, fullContent, 'The full document should replace the truncated edit baseline'); assert.strictEqual(nextState.draftContent, fullContent, 'Draft content should start from the full document'); assert.strictEqual(controller.isUndoAvailable(nextState), false, 'Undo should stay disabled until the user edits the full document'); @@ -525,13 +595,14 @@ export default async function runTests() { await testSlugGeneration(); await testOutlineExtraction(); await testLinkResolution(); - await testWikiRewriteAndRendering(); + await testOutlineFromMarkdownSource(); await testFullscreenWorkspaceHelpers(); await testCopyFormatsAndEditorShell(); await testPartialDocumentBecomesNewEditBaseline(); await testRefreshDoesNotMisclassifyMarkdownContentAsDeletion(); await testFailedSaveResyncsEditBaseline(); await testSuccessfulSaveResetsUndoBaseline(); + await testInFlightSaveKeepsNewerDraftDirty(); console.log('\nβœ… Markdown preview tests passed!'); return true; } catch (error) { From 80bcb117de67c18241727a185614f326a9cb2282 Mon Sep 17 00:00:00 2001 From: edgarssskore Date: Mon, 27 Apr 2026 11:30:26 +0300 Subject: [PATCH 22/22] fix(file-preview): address markdown editor PR comments --- .../file-preview/src/markdown/controller.ts | 72 +++++++++++-- src/ui/file-preview/src/markdown/editor.ts | 101 ++++++++++++++++-- src/ui/file-preview/src/panel-actions.ts | 24 +---- 3 files changed, 161 insertions(+), 36 deletions(-) diff --git a/src/ui/file-preview/src/markdown/controller.ts b/src/ui/file-preview/src/markdown/controller.ts index babb0c47..016f37f7 100644 --- a/src/ui/file-preview/src/markdown/controller.ts +++ b/src/ui/file-preview/src/markdown/controller.ts @@ -150,10 +150,6 @@ function computeEditBlocks(oldText: string, newText: string): Array<{ old_string const context = 3; const merged = mergeCloseHunks(hunks, context * 2 + 1); - const totalChanged = merged.reduce((sum, hunk) => sum + (hunk.oldEnd - hunk.oldStart), 0); - if (totalChanged > oldLines.length * 0.7) { - return [{ old_string: oldText, new_string: newText }]; - } return merged.map((hunk) => { const contextBefore = Math.max(0, hunk.oldStart - context); @@ -170,6 +166,16 @@ function computeEditBlocks(oldText: string, newText: string): Array<{ old_string }).filter((block) => block.old_string !== block.new_string); } +function joinDocumentChunks(first: string, second: string): string { + if (!first) { + return second; + } + if (!second) { + return first; + } + return `${first}${first.endsWith('\n') ? '' : '\n'}${second}`; +} + function isToolErrorResult(value: unknown): value is ToolErrorResult { return typeof value === 'object' && value !== null; } @@ -437,7 +443,17 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende return extractMarkdownOutline(readPayloadContent(payload)).map((item) => ({ id: item.id, text: item.text })); } - function findHeading(anchor: string): HTMLElement | null { + function resolveOutlineHeading(anchor: string): MarkdownWorkspaceState['outline'][number] | null { + const trimmedAnchor = anchor.trim(); + if (!workspaceState || !trimmedAnchor) { + return null; + } + + const slug = slugifyMarkdownHeading(trimmedAnchor); + return workspaceState.outline.find((item) => item.id === trimmedAnchor || item.id === slug) ?? null; + } + + function findDomHeading(anchor: string): HTMLElement | null { const trimmedAnchor = anchor.trim(); if (!trimmedAnchor) { return null; @@ -447,7 +463,16 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende } function scrollHeadingIntoView(anchor: string): boolean { - const heading = findHeading(anchor); + const outlineHeading = resolveOutlineHeading(anchor); + if (outlineHeading && typeof outlineHeading.line === 'number') { + markdownEditorHandle?.revealLine(outlineHeading.line, outlineHeading.id); + if (workspaceState) { + workspaceState.activeHeadingId = outlineHeading.id; + } + return true; + } + + const heading = findDomHeading(anchor); if (!heading) { return false; } @@ -1036,6 +1061,40 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende return state.draftContent; } + function expandPartialPayload(payload: RenderPayload, direction: 'before' | 'after', loadedContent: string): RenderPayload { + const state = getState(payload); + const range = parseReadRange(payload.content); + if (!range?.isPartial) { + return payload; + } + + const cleanLoaded = stripReadStatusLine(loadedContent); + const nextBaseline = direction === 'before' + ? joinDocumentChunks(cleanLoaded, state.fullDocumentContent) + : joinDocumentChunks(state.fullDocumentContent, cleanLoaded); + const nextDraft = direction === 'before' + ? joinDocumentChunks(cleanLoaded, state.draftContent) + : joinDocumentChunks(state.draftContent, cleanLoaded); + const newFrom = direction === 'before' ? 1 : range.fromLine; + const newTo = direction === 'after' ? range.totalLines : range.toLine; + const lineCount = newTo - newFrom + 1; + const remaining = range.totalLines - newTo; + const isStillPartial = newFrom > 1 || newTo < range.totalLines; + const statusLine = isStillPartial + ? `[Reading ${lineCount} lines from ${newFrom === 1 ? 'start' : `line ${newFrom}`} (total: ${range.totalLines} lines, ${remaining} remaining)]\n` + : ''; + + state.fullDocumentContent = nextBaseline; + state.draftContent = nextDraft; + state.outline = extractMarkdownOutline(nextDraft); + state.dirty = nextDraft !== nextBaseline; + if (!state.outline.some((item) => item.id === state.activeHeadingId)) { + state.activeHeadingId = state.outline[0]?.id ?? null; + } + + return { ...payload, content: statusLine + nextBaseline }; + } + async function handleInlineExitFromFullscreen(originalPayload?: RenderPayload): Promise { const wasDirty = workspaceState?.saveIndicator === 'saved' || workspaceState?.dirty; if (workspaceState) { @@ -1059,6 +1118,7 @@ export function createMarkdownController(dependencies: MarkdownControllerDepende buildBody, clear, disposeHandles, + expandPartialPayload, getCopyText, getState, handleInlineExitFromFullscreen, diff --git a/src/ui/file-preview/src/markdown/editor.ts b/src/ui/file-preview/src/markdown/editor.ts index 63af60a2..9cb157fa 100644 --- a/src/ui/file-preview/src/markdown/editor.ts +++ b/src/ui/file-preview/src/markdown/editor.ts @@ -27,6 +27,94 @@ interface MarkdownLinkRange { kind: 'markdown' | 'wiki'; } +function isEscaped(text: string, index: number): boolean { + let slashCount = 0; + for (let current = index - 1; current >= 0 && text[current] === '\\'; current -= 1) { + slashCount += 1; + } + return slashCount % 2 === 1; +} + +function readBareMarkdownHref(value: string): string { + const trimmed = value.trim(); + if (trimmed.startsWith('<')) { + const closeIndex = trimmed.indexOf('>'); + return closeIndex >= 0 ? trimmed.slice(1, closeIndex).trim() : trimmed; + } + + let depth = 0; + for (let index = 0; index < trimmed.length; index += 1) { + const char = trimmed[index]; + if (char === '(' && !isEscaped(trimmed, index)) { + depth += 1; + } else if (char === ')' && depth > 0 && !isEscaped(trimmed, index)) { + depth -= 1; + } else if (/\s/.test(char) && depth === 0) { + return trimmed.slice(0, index); + } + } + + return trimmed; +} + +function findMarkdownLinksInLine(text: string): Array<{ start: number; end: number; label: string; href: string }> { + const links: Array<{ start: number; end: number; label: string; href: string }> = []; + let index = 0; + + while (index < text.length) { + const start = text.indexOf('[', index); + if (start < 0) { + break; + } + if (text[start - 1] === '!' || isEscaped(text, start)) { + index = start + 1; + continue; + } + + let labelEnd = start + 1; + while (labelEnd < text.length && (text[labelEnd] !== ']' || isEscaped(text, labelEnd))) { + if (text[labelEnd] === '\n') { + break; + } + labelEnd += 1; + } + if (text[labelEnd] !== ']' || text[labelEnd + 1] !== '(') { + index = start + 1; + continue; + } + + let depth = 1; + let hrefEnd = labelEnd + 2; + while (hrefEnd < text.length && depth > 0) { + const char = text[hrefEnd]; + if (char === '\n') { + break; + } + if (char === '(' && !isEscaped(text, hrefEnd)) { + depth += 1; + } else if (char === ')' && !isEscaped(text, hrefEnd)) { + depth -= 1; + } + hrefEnd += 1; + } + if (depth !== 0) { + index = start + 1; + continue; + } + + const hrefContent = text.slice(labelEnd + 2, hrefEnd - 1); + links.push({ + start, + end: hrefEnd, + label: text.slice(start + 1, labelEnd), + href: readBareMarkdownHref(hrefContent), + }); + index = hrefEnd; + } + + return links; +} + export interface MarkdownEditorHandle { destroy: () => void; focus: () => void; @@ -847,15 +935,9 @@ export function mountMarkdownEditor(options: { }; const findMarkdownLinkInLine = (line: { from: number; text: string }, relativeFrom: number, relativeTo: number = relativeFrom): MarkdownLinkRange | null => { - for (const match of line.text.matchAll(/\[([^\]\n]+)\]\(([^)\n]+)\)/g)) { - const start = match.index ?? 0; - const label = match[1] ?? ''; - if (line.text[start - 1] === '!' || label.startsWith('![')) { - continue; - } - const end = start + match[0].length; + for (const link of findMarkdownLinksInLine(line.text)) { + const { start, end, label, href } = link; if (start <= relativeFrom && relativeTo <= end) { - const href = (match[2] ?? '').trim(); return { from: line.from + start, labelFrom: line.from + start + 1, @@ -1338,9 +1420,6 @@ export function mountMarkdownEditor(options: { textarea.addEventListener('keydown', handleKeyDown); textarea.addEventListener('focusout', handleFocusOut); autosize(); - if (typeof options.initialScrollTop === 'number') { - textarea.scrollTop = options.initialScrollTop; - } return { destroy: () => { diff --git a/src/ui/file-preview/src/panel-actions.ts b/src/ui/file-preview/src/panel-actions.ts index 47051145..7b3703fa 100644 --- a/src/ui/file-preview/src/panel-actions.ts +++ b/src/ui/file-preview/src/panel-actions.ts @@ -160,7 +160,6 @@ export function attachPanelActions(options: { return; } - const currentContent = stripReadStatusLine(options.payload.content); const loadLines = async (button: HTMLButtonElement, direction: 'before' | 'after'): Promise => { const originalText = button.textContent; button.textContent = 'Loading…'; @@ -180,24 +179,11 @@ export function attachPanelActions(options: { const newText = extractToolText(result); if (newText && typeof newText === 'string') { - const cleanNew = stripReadStatusLine(newText); - const merged = direction === 'before' - ? `${cleanNew}${cleanNew.endsWith('\n') ? '' : '\n'}${currentContent}` - : `${currentContent}${currentContent.endsWith('\n') ? '' : '\n'}${cleanNew}`; - - const newFrom = direction === 'before' ? 1 : range.fromLine; - const newTo = direction === 'after' ? range.totalLines : range.toLine; - const lineCount = newTo - newFrom + 1; - const remaining = range.totalLines - newTo; - const isStillPartial = newFrom > 1 || newTo < range.totalLines; - const statusLine = isStillPartial - ? `[Reading ${lineCount} lines from ${newFrom === 1 ? 'start' : `line ${newFrom}`} (total: ${range.totalLines} lines, ${remaining} remaining)]\n` - : ''; - - options.render({ - ...options.payload, - content: statusLine + merged, - }, options.htmlMode, options.getIsExpanded()); + options.render( + options.markdownController.expandPartialPayload(options.payload, direction, newText), + options.htmlMode, + options.getIsExpanded() + ); return; } } catch {