UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

681 lines (680 loc) 28 kB
/** * Composition root for the File Preview app. It wires host services, file-type handlers, and specialized controllers together without owning feature logic inline. */ import { App } from '@modelcontextprotocol/ext-apps'; import { createCompactRowShellController } from '../../shared/tool-shell.js'; import { createWidgetStateStorage } from '../../shared/widget-state.js'; import { renderCompactRow } from '../../shared/compact-row.js'; import { connectWithSharedHostContext } from '../../shared/host-context.js'; import { createUiEventTracker } from '../../shared/ui-event-tracker.js'; 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, renderMarkdownEditorAppIcon } from './host/external-actions.js'; import { attachSelectionContext } from './host/selection-context.js'; import { createMarkdownController } from './markdown/controller.js'; import { createConflictDialogController, renderConflictDialogMarkup, } from './markdown/conflict-dialog.js'; import { attachPanelActions } from './panel-actions.js'; import { extractRenderPayload, extractToolText, getFileExtensionForAnalytics, isLikelyUrl, isPreviewStructuredContent } from './payload-utils.js'; let isExpanded = false; let hideSummaryRow = false; let onRender; let trackUiEvent; let conflictDialogController; let rpcCallTool; let rpcUpdateContext; let openExternalLink; let requestDisplayMode; let shellController; let currentPayload; let currentHtmlMode = 'rendered'; let currentHostContext; let rerenderCurrent; let syncPayload; let persistPayload; let localPayloadOverride; let hostPayload; let inlinePayloadBeforeFullscreen; let directoryBackPayload; let selectionAbortController = null; const markdownEditorAppCache = new Map(); function getTelemetryToolName(payload) { return typeof payload?.sourceTool === 'string' ? payload.sourceTool : 'read_file'; } async function callToolIfReady(name, args) { return rpcCallTool ? rpcCallTool(name, args) : undefined; } function getAvailableDisplayModes() { const rawModes = currentHostContext?.availableDisplayModes; if (!Array.isArray(rawModes)) { return []; } return rawModes.filter((mode) => typeof mode === 'string'); } function getCurrentDisplayMode() { return typeof currentHostContext?.displayMode === 'string' ? currentHostContext.displayMode : null; } function storePayloadOverride(payload) { localPayloadOverride = payload; currentPayload = payload; persistPayload?.(payload); } function getEffectiveIncomingPayload(payload) { 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 updateSaveStatusDOM(label, statusClass) { const existing = document.querySelector('.panel-save-status'); if (label) { if (existing) { existing.textContent = label; existing.className = `panel-save-status panel-save-status--${statusClass}`; } else { const actions = document.querySelector('.panel-topbar-actions'); 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(); } } 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, trackUiEvent: (event, params) => trackUiEvent?.(event, params), showConflictDialog: (options) => { if (conflictDialogController) { conflictDialogController.open(options); return; } // Dialog not yet initialized (would only happen if the save failure // somehow fires before bootstrapApp). Fall back to the cancel callback // so the editor still shows its inline note instead of silently no-op'ing. console.warn('[file-preview] conflictDialogController not ready; firing onCancel fallback'); options.onCancel?.(); }, }); // Only read_file's own parameters may ride along on a re-read. Forwarding a // different tool's args (e.g. edit_block's old_string) makes the server prepend // an "unsupported params" warning that would pollute the pulled content. const READ_ARG_KEYS = ['path', 'offset', 'length', 'sheet', 'range', 'isUrl']; function pickReadArgs(args) { if (!args || typeof args !== 'object') { return undefined; } const src = args; // edit_block calls the file "file_path"; read_file/write_file call it "path". const path = typeof src.path === 'string' ? src.path : typeof src.file_path === 'string' ? src.file_path : undefined; if (!path) { return undefined; } const out = { path }; for (const key of READ_ARG_KEYS) { if (key !== 'path' && src[key] !== undefined) { out[key] = src[key]; } } return out; } // The host doesn't send the tool name with tool-input, so infer mutations from // their arg shape: write_file carries `content`; edit_block `old_string`/ // `new_string`. Drives two things: timing (a mutation's tool-input arrives // BEFORE the file changes, so those pull at tool-result time instead of input // time) and telemetry (the pulled payload's sourceTool is re-stamped with the // originating tool, since the pull itself is always a read_file). function inferMutationTool(args) { if (!args || typeof args !== 'object') { return undefined; } const src = args; if (src.old_string !== undefined || src.new_string !== undefined) { return 'edit_block'; } if (src.content !== undefined) { return 'write_file'; } return undefined; } // Bound the RPC read so a stalled host response resolves to onFail instead of // hanging. Kept under the loading watchdog (PREVIEW_WATCHDOG_MS) so the pull // gets its chance to fail before the failure row appears. const PULL_TIMEOUT_MS = 8000; // One in-flight pull per path — the input-time pull and a tool-result-triggered // pull for the same call would otherwise read the file twice. let pullInFlightPath; /** * The widget's render path: pull content + metadata over the RPC channel * (`app.callServerTool` with origin:'ui'). The notification channel is not used * for rendering — the host may strip structuredContent from it or swallow it * entirely (images). `args` reproduces the original read exactly * (offset/length/sheet/range/isUrl) so partial reads stay faithful. */ async function pullPayloadByArgs(args, onReady, onFail) { const filePath = typeof args.path === 'string' ? args.path : undefined; if (!filePath) { onFail(); return; } if (pullInFlightPath === filePath) { return; } pullInFlightPath = filePath; try { // The host can hold the RPC response indefinitely (e.g. it preempts // delivery to inline-render an image), so callServerTool would await // forever with no throw. Race a timeout so the pull settles either way. const raw = await Promise.race([ callToolIfReady('read_file', { ...args, origin: 'ui' }), new Promise((_resolve, reject) => { setTimeout(() => reject(new Error(`RPC read_file did not respond within ${PULL_TIMEOUT_MS}ms`)), PULL_TIMEOUT_MS); }), ]); const payload = extractRenderPayload(raw); if (payload) { onReady(payload); return; } } catch { // Timed out or rejected — fall through to the caller's fallback. } finally { pullInFlightPath = undefined; } onFail(); } // If a loading state hasn't resolved into a real render within this window, the // host most likely never delivered the tool result / RPC response (e.g. it // preempted delivery to inline-render an image). Rather than sit on "Preparing // preview…" forever, we surface a failure row. const PREVIEW_WATCHDOG_MS = 10000; let previewWatchdogId; function clearPreviewWatchdog() { if (previewWatchdogId !== undefined) { clearTimeout(previewWatchdogId); previewWatchdogId = undefined; } } function renderStatusState(container, message) { clearPreviewWatchdog(); container.innerHTML = ` <main class="shell"> ${renderCompactRow({ label: message, variant: 'status', interactive: false })} </main> `; document.body.classList.add('dc-ready'); } function renderLoadingState(container) { container.innerHTML = ` <main class="shell"> ${renderCompactRow({ label: 'Preparing preview…', variant: 'loading', interactive: false })} </main> `; document.body.classList.add('dc-ready'); // Fall back to a failure row if nothing renders in time. Any successful // render (renderApp) or explicit status clears this first. clearPreviewWatchdog(); previewWatchdogId = setTimeout(() => { previewWatchdogId = undefined; renderStatusState(container, 'Unable to generate preview in this environment.'); }, PREVIEW_WATCHDOG_MS); } export function renderApp(container, payload, htmlMode = 'rendered', expandedState = false) { clearPreviewWatchdog(); isExpanded = expandedState; currentHtmlMode = htmlMode; shellController?.dispose(); shellController = undefined; 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; } currentPayload = payload; const capabilities = getFileTypeCapabilities(payload); if (!capabilities.supportsPreview && hideSummaryRow) { isExpanded = false; } 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 isFullscreen = getCurrentDisplayMode() === 'fullscreen'; const canGoFullscreen = !isFullscreen && getDocumentFullscreenAvailability({ availableDisplayModes: getAvailableDisplayModes(), }).canFullscreen; if (payload.fileType === 'markdown' && payload.defaultEditorName) { markdownEditorAppCache.set(payload.filePath, { appName: payload.defaultEditorName, appPath: payload.defaultEditorPath, }); } const defaultMarkdownEditor = payload.fileType === 'markdown' ? markdownEditorAppCache.get(payload.filePath) : undefined; 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), }); container.innerHTML = layout.html; document.body.classList.add('dc-ready'); 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, }); if (payload.fileType === 'markdown') { markdownController.attachHandlers(payload); } selectionAbortController = attachSelectionContext({ payload, isMarkdownEditing: payload.fileType === 'markdown' && !!markdownWorkspace, updateContext: rpcUpdateContext, trackUiEvent, getFileExtensionForAnalytics, previousAbortController: selectionAbortController, }); if (payload.fileType === 'directory') { attachDirectoryHandlers({ container, callTool: callToolIfReady, buildOpenInFolderCommand: (filePath) => buildOpenInFolderCommand(filePath, isLikelyUrl), onOpenPayload: (nextPayload) => { directoryBackPayload = payload; renderApp(container, nextPayload, 'rendered', true); }, }); } const backBtn = document.getElementById('dir-back'); if (backBtn && directoryBackPayload) { const savedPayload = directoryBackPayload; backBtn.addEventListener('click', () => { directoryBackPayload = undefined; renderApp(container, savedPayload, 'rendered', true); }); } if (payload.fileType === 'directory') { directoryBackPayload = undefined; } const compactRow = document.getElementById('compact-toggle'); shellController = createCompactRowShellController({ shell: document.getElementById('tool-shell'), compactRow, initialExpanded: layout.effectiveExpanded, onToggle: (expanded) => { isExpanded = expanded; trackUiEvent?.(expanded ? 'expand' : 'collapse', { file_type: payload.fileType, file_extension: fileExtension, }); }, onScrollAfterExpand: () => { trackUiEvent?.('scroll_after_expand', { file_type: payload.fileType, file_extension: fileExtension, }); }, onRender, }); onRender?.(); } export function bootstrapApp() { const container = document.getElementById('app'); if (!container) { return; } renderLoadingState(container); // Mount the conflict dialog once at body level. It's position: fixed and // must live outside the app container so that re-renders of the document // body never wipe it while it's open. if (!document.getElementById('md-conflict-modal')) { const dialogHost = document.createElement('div'); dialogHost.innerHTML = renderConflictDialogMarkup(); const dialogRoot = dialogHost.firstElementChild; if (dialogRoot) { document.body.appendChild(dialogRoot); } } conflictDialogController = createConflictDialogController({ container: document }); const app = new App({ name: 'Desktop Commander File Preview', version: '1.0.0' }, { updateModelContext: { text: {} } }, { autoResize: true }); const chrome = { expanded: isExpanded, hideSummaryRow, }; const syncChromeState = () => { isExpanded = chrome.expanded; hideSummaryRow = chrome.hideSummaryRow; }; const widgetState = createWidgetStateStorage((value) => isPreviewStructuredContent(value) && typeof value.content === 'string'); const renderAndSync = (payload) => { if (payload) { widgetState.write(payload); } renderApp(container, payload, 'rendered', isExpanded); }; const syncFromPersistedWidgetState = () => { const persistedPayload = widgetState.read(); if (!persistedPayload) { return; } if (currentPayload && currentPayload.filePath === persistedPayload.filePath && stripReadStatusLine(currentPayload.content) === stripReadStatusLine(persistedPayload.content)) { return; } renderAndSync(persistedPayload); }; syncPayload = renderAndSync; persistPayload = (payload) => { widgetState.write(payload); }; rerenderCurrent = () => { renderApp(container, currentPayload, currentHtmlMode, isExpanded); }; let pendingCachedPayload; let lastToolInputArgs; // The mutation tool that produced the current tool call, if any — stamped // onto the pulled payload so telemetry attributes to write_file/edit_block // rather than the read_file pull that rendered it. let lastMutationTool; // True once the current tool call has been rendered (via the input-time pull), // so a late tool-result notification doesn't trigger a redundant second pull. let renderedForCurrentInput = false; let initialStateResolved = false; const resolveInitialState = (payload, message) => { if (initialStateResolved) { return; } initialStateResolved = true; if (payload) { hostPayload = payload; renderAndSync(payload); if (payload.fileType === 'markdown' && getCurrentDisplayMode() === 'fullscreen') { void markdownController.requestEditMode(payload); } if (payload.fileType === 'markdown') { void markdownController.refreshFromDisk(payload); } return; } renderStatusState(container, message ?? 'No preview available for this response.'); onRender?.(); }; onRender = () => { }; rpcCallTool = (name, args) => (app.callServerTool({ name, arguments: args })); rpcUpdateContext = (text) => { const params = text ? { content: [{ type: 'text', text }] } : { content: [] }; app.updateModelContext(params).catch(() => { // Host may not support updateModelContext. }); }; openExternalLink = async (url) => { const result = await app.openLink({ url }); return result.isError !== true; }; requestDisplayMode = async (mode) => { const result = await app.requestDisplayMode({ mode }); return typeof result.mode === 'string' ? result.mode : null; }; const filePreviewUiEvent = createUiEventTracker((name, args) => app.callServerTool({ name, arguments: args }), { component: 'file_preview', }); trackUiEvent = (event, params = {}) => filePreviewUiEvent(event, { tool_name: getTelemetryToolName(currentPayload ?? hostPayload), ...params, }); app.ontoolinput = (params) => { const requestedPath = typeof params.arguments?.path === 'string' ? params.arguments.path : undefined; const readArgs = pickReadArgs(params.arguments); lastMutationTool = inferMutationTool(params.arguments); if (readArgs) { // Retained so mutations (which pull at tool-result time) know what // to re-read — faithfully reproducing offset/length/sheet/range. lastToolInputArgs = readArgs; } if (!initialStateResolved && pendingCachedPayload && requestedPath && pendingCachedPayload.filePath === requestedPath) { const cached = pendingCachedPayload; pendingCachedPayload = undefined; resolveInitialState(cached); return; } renderLoadingState(container); onRender?.(); renderedForCurrentInput = false; // All previews render from the widget's own origin:'ui' RPC read — the // notification channel is unreliable (the host strips structuredContent, // and for images swallows the tool-result entirely to inline-render). // tool-input always fires and carries the path + read args, so pull here. // Exception: mutations (write_file/edit_block) — their tool-input arrives // before the file changes, so they pull at tool-result time instead. if (readArgs && !lastMutationTool) { void pullPayloadByArgs(readArgs, (p) => { renderedForCurrentInput = true; if (initialStateResolved) { renderAndSync(getEffectiveIncomingPayload(p)); } else { resolveInitialState(getEffectiveIncomingPayload(p)); } }, // Pull failed/timed out — the loading watchdog shows the failure row. () => { }); } }; app.ontoolresult = (result) => { pendingCachedPayload = undefined; // Host-facing responses carry no structuredContent, so this notification // only signals completion. Reads already rendered from their input-time // pull; mutations (write_file/edit_block) pull now that the file changed. if (renderedForCurrentInput) { return; } const message = extractToolText(result); const isError = result?.isError === true; const pullArgs = lastToolInputArgs ?? (currentPayload?.filePath ? { path: currentPayload.filePath } : undefined); const deliver = (pulled) => { renderedForCurrentInput = true; // The pull is always a read_file; re-stamp the originating mutation // tool so telemetry attributes to write_file/edit_block. const p = lastMutationTool ? { ...pulled, sourceTool: lastMutationTool } : pulled; if (initialStateResolved) { renderAndSync(getEffectiveIncomingPayload(p)); } else { resolveInitialState(getEffectiveIncomingPayload(p)); } }; const renderMessageFallback = () => { if (!message) { return; } if (!initialStateResolved) { resolveInitialState(undefined, message); } else { renderStatusState(container, message); onRender?.(); } }; // A failed tool call has nothing worth re-reading — show its message. if (!isError && pullArgs) { renderLoadingState(container); onRender?.(); void pullPayloadByArgs(pullArgs, deliver, renderMessageFallback); return; } renderMessageFallback(); }; app.ontoolcancelled = (params) => { resolveInitialState(undefined, params.reason ?? 'Tool was cancelled.'); }; const handleVisibilitySync = () => { if (document.visibilityState === 'visible') { syncFromPersistedWidgetState(); } }; const handleFocusSync = () => { // Only sync cross-tab state if the page was hidden (tab switch). // Simple focus changes within the same page should not trigger a re-render // as it destroys the active editor. if (document.visibilityState !== 'visible') { syncFromPersistedWidgetState(); } }; const teardown = () => { clearPreviewWatchdog(); 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, onContextApplied: () => { const previousDisplayMode = getCurrentDisplayMode(); syncChromeState(); currentHostContext = app.getHostContext(); const nextDisplayMode = getCurrentDisplayMode(); const displayModeChanged = previousDisplayMode !== nextDisplayMode; // Clicking a display-mode button blurs the editor first, and the // editor's onBlur handler already persists dirty drafts, so there // is nothing additional to save here. if (previousDisplayMode === 'fullscreen' && nextDisplayMode === 'inline' && currentPayload?.fileType === 'markdown') { isExpanded = true; chrome.expanded = true; const restorePayload = inlinePayloadBeforeFullscreen ?? hostPayload; const restoreWasPartial = restorePayload ? parseReadRange(restorePayload.content)?.isPartial === true : false; if (restoreWasPartial && restorePayload) { localPayloadOverride = restorePayload; currentPayload = restorePayload; widgetState.write(restorePayload); void markdownController.handleInlineExitFromFullscreen(restorePayload).then((freshPayload) => { if (freshPayload) { currentPayload = freshPayload; localPayloadOverride = freshPayload; widgetState.write(freshPayload); rerenderCurrent?.(); } }); } else { void markdownController.handleInlineExitFromFullscreen(); } inlinePayloadBeforeFullscreen = undefined; } if (previousDisplayMode !== 'fullscreen' && nextDisplayMode === 'fullscreen' && currentPayload?.fileType === 'markdown') { inlinePayloadBeforeFullscreen = currentPayload; if (parseReadRange(currentPayload.content)?.isPartial) { void markdownController.requestEditMode(currentPayload); } } if (initialStateResolved && displayModeChanged) { rerenderCurrent?.(); } }, onConnected: () => { currentHostContext = app.getHostContext(); pendingCachedPayload = widgetState.read() ?? undefined; }, }).catch(() => { renderStatusState(container, 'Failed to connect to host.'); onRender?.(); }); window.addEventListener('beforeunload', () => { teardown(); }, { once: true }); }