@wonderwhy-er/desktop-commander
Version:
MCP server for terminal operations and file editing
560 lines (559 loc) • 22.2 kB
JavaScript
/**
* 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, detectDefaultMarkdownEditor, 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 previewShownFired = 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();
const markdownEditorAppPending = new Set();
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?.();
},
});
/**
* Check if a payload needs its file content to be read.
* Tool results from edit_block/write_file include structuredContent but
* their text is a success message, not file content. Detect this by
* checking for the absence of the read status line that read_file always includes.
* URL payloads are fetched remotely by read_file(isUrl:true); we can't
* re-fetch them from here (no isUrl flag on the refresh path), so skip.
*/
function needsContentRead(payload) {
if (payload.fileType === 'directory' || payload.fileType === 'image' || payload.fileType === 'unsupported') {
return false;
}
if (/^https?:\/\//i.test(payload.filePath)) {
return false;
}
return !parseReadRange(payload.content);
}
async function readAndResolvePayload(payload, onReady) {
try {
const freshPayload = await markdownController.readPayload(payload.filePath);
if (freshPayload) {
onReady(freshPayload);
if (freshPayload.fileType === 'markdown') {
void markdownController.refreshFromDisk(freshPayload);
}
return;
}
}
catch {
// Fall through to original payload.
}
onReady(payload);
}
function renderStatusState(container, message) {
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');
}
export function renderApp(container, payload, htmlMode = 'rendered', expandedState = false) {
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;
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 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?.();
if (!previewShownFired) {
previewShownFired = true;
trackUiEvent?.('preview_shown', {
file_type: payload.fileType,
file_extension: fileExtension,
});
}
}
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 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;
};
trackUiEvent = createUiEventTracker((name, args) => app.callServerTool({ name, arguments: args }), {
component: 'file_preview',
baseParams: { tool_name: 'read_file' },
});
app.ontoolinput = (params) => {
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;
}
renderLoadingState(container);
onRender?.();
};
app.ontoolresult = (result) => {
pendingCachedPayload = undefined;
const payload = extractRenderPayload(result);
const message = extractToolText(result);
if (!initialStateResolved) {
if (payload) {
if (needsContentRead(payload)) {
void readAndResolvePayload(payload, (p) => resolveInitialState(getEffectiveIncomingPayload(p)));
return;
}
resolveInitialState(getEffectiveIncomingPayload(payload));
return;
}
if (message) {
resolveInitialState(undefined, message);
}
return;
}
if (payload) {
if (needsContentRead(payload)) {
renderLoadingState(container);
void readAndResolvePayload(payload, (p) => renderAndSync(getEffectiveIncomingPayload(p)));
}
else {
renderAndSync(getEffectiveIncomingPayload(payload));
}
}
else if (message) {
renderStatusState(container, message);
onRender?.();
}
};
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 = () => {
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 });
}