Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 43 additions & 18 deletions src/handlers/filesystem-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,36 @@ import {
WritePdfArgsSchema
} from '../tools/schemas.js';
import path from 'path';
import os from 'os';
import { buildUiToolMeta, FILE_PREVIEW_RESOURCE_URI } from '../ui/contracts.js';
import { resolvePreviewFileType } from '../ui/file-preview/shared/preview-file-types.js';

/**
* Expand home directory (~) in a file path
*/
function expandHome(filePath: string): string {
if (filePath === '~' || filePath.startsWith('~/') || filePath.startsWith(`~${path.sep}`)) {
return path.join(os.homedir(), filePath.slice(1));
}
return filePath;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Resolve a file path to an absolute path for use in structured content.
* This ensures "Open in folder" always has a valid absolute path.
*/
function resolveAbsolutePath(filePath: string): string {
const expanded = expandHome(filePath);
return path.isAbsolute(expanded)
? path.resolve(expanded)
: path.resolve(process.cwd(), expanded);
}

/**
* Helper function to check if path contains an error
*/
function isErrorPath(path: string): boolean {
return path.startsWith('__ERROR__:');
function isErrorPath(filePath: string): boolean {
return filePath.startsWith('__ERROR__:');
}

/**
Expand Down Expand Up @@ -78,6 +100,12 @@ export async function handleReadFile(args: unknown): Promise<ServerResult> {
sheet: sheetParam,
range: parsed.range
};

// Resolve to absolute path for local files (not URLs) so "Open in folder" works
const resolvedFilePath = parsed.isUrl
? parsed.path
: resolveAbsolutePath(parsed.path);

const fileResult = await readFile(parsed.path, options);

// Handle PDF files
Expand Down Expand Up @@ -107,8 +135,8 @@ export async function handleReadFile(args: unknown): Promise<ServerResult> {
...pdfContent
],
structuredContent: {
fileName: path.basename(parsed.path),
filePath: parsed.path,
fileName: path.basename(resolvedFilePath),
filePath: resolvedFilePath,
fileType: 'unsupported',
content: ''
},
Expand All @@ -118,8 +146,8 @@ export async function handleReadFile(args: unknown): Promise<ServerResult> {

// Handle image files
if (fileResult.metadata?.isImage) {
// For image files, return as an image content type
// Content should already be base64-encoded string from handler
// For image files, keep content payload text-only for broad host compatibility.
// The preview widget reads image bytes from structuredContent.
const imageData = typeof fileResult.content === 'string'
? fileResult.content
: fileResult.content.toString('base64');
Expand All @@ -129,18 +157,15 @@ export async function handleReadFile(args: unknown): Promise<ServerResult> {
{
type: "text",
text: imageSummary
},
{
type: "image",
data: imageData,
mimeType: fileResult.mimeType
}
],
structuredContent: {
fileName: path.basename(parsed.path),
filePath: parsed.path,
fileType: 'unsupported',
content: imageSummary
fileName: path.basename(resolvedFilePath),
filePath: resolvedFilePath,
fileType: 'image',
content: imageSummary,
imageData,
mimeType: fileResult.mimeType
},
_meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true)
};
Expand All @@ -149,12 +174,12 @@ export async function handleReadFile(args: unknown): Promise<ServerResult> {
const textContent = typeof fileResult.content === 'string'
? fileResult.content
: fileResult.content.toString('utf8');
const previewFileType = resolvePreviewFileType(parsed.path);
const previewFileType = resolvePreviewFileType(resolvedFilePath);
return {
content: [{ type: "text", text: textContent }],
structuredContent: {
fileName: path.basename(parsed.path),
filePath: parsed.path,
fileName: path.basename(resolvedFilePath),
filePath: resolvedFilePath,
fileType: previewFileType,
content: textContent
},
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export interface FilePreviewStructuredContent {
filePath: string;
fileType: PreviewFileType;
content: string;
imageData?: string;
mimeType?: string;
}

export interface ServerResult {
Expand Down
2 changes: 1 addition & 1 deletion src/ui/file-preview/shared/preview-file-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
import path from 'path';

export type PreviewFileType = 'markdown' | 'text' | 'html' | 'unsupported';
export type PreviewFileType = 'markdown' | 'text' | 'html' | 'image' | 'unsupported';

export const MARKDOWN_PREVIEW_EXTENSIONS = new Set(['.md', '.markdown', '.mdx']);
export const HTML_PREVIEW_EXTENSIONS = new Set(['.html', '.htm']);
Expand Down
93 changes: 61 additions & 32 deletions src/ui/file-preview/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { formatJsonIfPossible, inferLanguageFromPath, renderCodeViewer } from '.
import { renderHtmlPreview } from './components/html-renderer.js';
import { renderMarkdown } from './components/markdown-renderer.js';
import { escapeHtml } from './components/highlighting.js';
import type { HtmlPreviewMode, PreviewStructuredContent } from './types.js';
import { isAllowedImageMimeType, normalizeImageMimeType } from './image-preview.js';
import type { FilePreviewStructuredContent } from '../../../types.js';
import type { HtmlPreviewMode } from './types.js';
import { createWindowRpcClient, isTrustedParentMessageSource } from '../../shared/rpc-client.js';
import { createToolShellController, type ToolShellController } from '../../shared/tool-shell.js';
import { createUiHostLifecycle } from '../../shared/host-lifecycle.js';
Expand Down Expand Up @@ -34,7 +36,7 @@ function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

function isPreviewStructuredContent(value: unknown): value is PreviewStructuredContent {
function isPreviewStructuredContent(value: unknown): value is FilePreviewStructuredContent {
if (!isObject(value)) {
return false;
}
Expand All @@ -47,7 +49,7 @@ function isPreviewStructuredContent(value: unknown): value is PreviewStructuredC
);
}

function readStructuredContentFromWindow(): PreviewStructuredContent | undefined {
function readStructuredContentFromWindow(): FilePreviewStructuredContent | undefined {
const candidates: unknown[] = [
(window as any).__DC_FILE_PREVIEW__,
(window as any).__MCP_TOOL_RESULT__,
Expand All @@ -70,7 +72,7 @@ function readStructuredContentFromWindow(): PreviewStructuredContent | undefined
return undefined;
}

function extractStructuredContent(value: unknown): PreviewStructuredContent | undefined {
function extractStructuredContent(value: unknown): FilePreviewStructuredContent | undefined {
if (!isObject(value)) {
return undefined;
}
Expand Down Expand Up @@ -196,6 +198,28 @@ function stripReadStatusLine(content: string): string {
return content.replace(/^\[Reading [^\]]+\]\r?\n?/, '');
}

function renderImageBody(payload: FilePreviewStructuredContent): { html: string; notice?: string } {
const mimeType = normalizeImageMimeType(payload.mimeType);
if (!isAllowedImageMimeType(mimeType)) {
return {
notice: 'Preview is unavailable for this image format.',
html: '<div class="panel-content source-content"></div>'
};
}

if (!payload.imageData || payload.imageData.trim().length === 0) {
return {
notice: 'Preview is unavailable because image data is missing.',
html: '<div class="panel-content source-content"></div>'
};
}

const src = `data:${mimeType};base64,${payload.imageData}`;
return {
html: `<div class="panel-content image-content"><div class="image-preview"><img src="${escapeHtml(src)}" alt="${escapeHtml(payload.fileName)}" loading="eager" decoding="async"></div></div>`
};
}

function countContentLines(content: string): number {
const cleaned = stripReadStatusLine(content);
if (cleaned === '') return 0;
Expand Down Expand Up @@ -226,9 +250,13 @@ function parseReadRange(content: string): ReadRange | undefined {
};
}

function renderBody(payload: PreviewStructuredContent, htmlMode: HtmlPreviewMode, startLine = 1): { html: string; notice?: string } {
function renderBody(payload: FilePreviewStructuredContent, htmlMode: HtmlPreviewMode, startLine = 1): { html: string; notice?: string } {
const cleanedContent = stripReadStatusLine(payload.content);

if (payload.fileType === 'image') {
return renderImageBody(payload);
}

if (payload.fileType === 'unsupported') {
return {
notice: 'Preview is not available for this file type.',
Expand Down Expand Up @@ -261,7 +289,7 @@ function renderBody(payload: PreviewStructuredContent, htmlMode: HtmlPreviewMode
}
}

function attachCopyHandler(payload: PreviewStructuredContent): void {
function attachCopyHandler(payload: FilePreviewStructuredContent): void {
const copyButton = document.getElementById('copy-source');
if (!copyButton) {
return;
Expand Down Expand Up @@ -293,29 +321,32 @@ function attachCopyHandler(payload: PreviewStructuredContent): void {
}
};

const copyTextData = async (text: string): Promise<boolean> => {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
return fallbackCopy(text);
} catch {
return fallbackCopy(text);
}
};

copyButton.addEventListener('click', async () => {
const cleanedContent = stripReadStatusLine(payload.content);
trackUiEvent?.('copy_clicked', {
file_type: payload.fileType,
file_extension: getFileExtensionForAnalytics(payload.filePath)
});

try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(cleanedContent);
setButtonState('Copied!', 1500);
return;
}
} catch {
// fallback below
}
const cleanedContent = stripReadStatusLine(payload.content);

const copied = fallbackCopy(cleanedContent);
const copied = await copyTextData(cleanedContent);
setButtonState(copied ? 'Copied!' : 'Copy failed', 1500);
});
}

function attachHtmlToggleHandler(container: HTMLElement, payload: PreviewStructuredContent, htmlMode: HtmlPreviewMode): void {
function attachHtmlToggleHandler(container: HTMLElement, payload: FilePreviewStructuredContent, htmlMode: HtmlPreviewMode): void {
const toggleButton = document.getElementById('toggle-html-mode');
if (!toggleButton || payload.fileType !== 'html') {
return;
Expand All @@ -330,7 +361,7 @@ function attachHtmlToggleHandler(container: HTMLElement, payload: PreviewStructu
});
}

function attachOpenInFolderHandler(payload: PreviewStructuredContent): void {
function attachOpenInFolderHandler(payload: FilePreviewStructuredContent): void {
const openButton = document.getElementById('open-in-folder') as HTMLButtonElement | null;
if (!openButton) {
return;
Expand Down Expand Up @@ -361,7 +392,7 @@ function attachOpenInFolderHandler(payload: PreviewStructuredContent): void {

function attachLoadAllHandler(
container: HTMLElement,
payload: PreviewStructuredContent,
payload: FilePreviewStructuredContent,
htmlMode: HtmlPreviewMode
): void {
const beforeBtn = document.getElementById('load-before') as HTMLButtonElement | null;
Expand Down Expand Up @@ -413,7 +444,7 @@ function attachLoadAllHandler(
? `[Reading ${lineCount} lines from ${newFrom === 1 ? 'start' : `line ${newFrom}`} (total: ${range.totalLines} lines, ${remaining} remaining)]\n`
: '';

const mergedPayload: PreviewStructuredContent = {
const mergedPayload: FilePreviewStructuredContent = {
...payload,
content: statusLine + merged
};
Expand Down Expand Up @@ -449,7 +480,7 @@ function attachLoadAllHandler(
*/
let selectionAbortController: AbortController | null = null;

function attachTextSelectionHandler(payload: PreviewStructuredContent): void {
function attachTextSelectionHandler(payload: FilePreviewStructuredContent): void {
const contentWrapper = document.querySelector('.panel-content-wrapper') as HTMLElement | null;
if (!contentWrapper) return;

Expand Down Expand Up @@ -585,7 +616,7 @@ function renderLoadingState(container: HTMLElement): void {

export function renderApp(
container: HTMLElement,
payload?: PreviewStructuredContent,
payload?: FilePreviewStructuredContent,
htmlMode: HtmlPreviewMode = 'rendered',
expandedState = false
): void {
Expand All @@ -599,7 +630,7 @@ export function renderApp(
return;
}

const canCopy = payload.fileType !== 'unsupported';
const canCopy = payload.fileType !== 'unsupported' && payload.fileType !== 'image';
const canOpenInFolder = !isLikelyUrl(payload.filePath);
const fileExtension = getFileExtensionForAnalytics(payload.filePath);
const supportsPreview = payload.fileType !== 'unsupported';
Expand All @@ -611,6 +642,7 @@ export function renderApp(
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'
: fileExtension !== 'none' ? fileExtension.toUpperCase()
: 'TEXT';

Expand All @@ -627,9 +659,7 @@ export function renderApp(

const copyIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
const folderIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`;

const loadAllButton = '';


// 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;
Expand All @@ -653,7 +683,7 @@ export function renderApp(
<span class="panel-topbar-actions">
${htmlToggle}
${canOpenInFolder ? `<button class="panel-action" id="open-in-folder">${folderIcon} Open in folder</button>` : ''}
${canCopy && supportsPreview ? `<button class="panel-action" id="copy-source">${copyIcon} Copy</button>` : ''}
${canCopy && supportsPreview ? `<button class="panel-action" id="copy-source" title="Copy source" aria-label="Copy source">${copyIcon} Copy</button>` : ''}
</span>
</div>
${notice}
Expand Down Expand Up @@ -771,19 +801,18 @@ export function bootstrapApp(): void {
};

// ChatGPT widget state persistence (other hosts use standard ui/notifications/tool-result)
const widgetState = createWidgetStateStorage<PreviewStructuredContent>(isPreviewStructuredContent);
const widgetState = createWidgetStateStorage<FilePreviewStructuredContent>(isPreviewStructuredContent);

onRender?.();
themeAdapter.applyFromData((window as any).__MCP_HOST_CONTEXT__);

const renderAndSync = (payload?: PreviewStructuredContent): void => {
const renderAndSync = (payload?: FilePreviewStructuredContent): void => {
if (payload) {
widgetState.write(payload); // Persist for refresh recovery (cross-host)
}
renderApp(container, payload, 'rendered', false);
};
let initialStateResolved = false;
const resolveInitialState = (payload?: PreviewStructuredContent, message?: string): void => {
const resolveInitialState = (payload?: FilePreviewStructuredContent, message?: string): void => {
if (initialStateResolved) {
return;
}
Expand Down
7 changes: 4 additions & 3 deletions src/ui/file-preview/src/components/toolbar.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
/**
* Toolbar component for preview controls (view mode, metadata, actions). It isolates UI control rendering and event plumbing from core preview orchestration.
*/
import type { HtmlPreviewMode, PreviewStructuredContent } from '../types.js';
import type { FilePreviewStructuredContent } from '../../../../types.js';
import type { HtmlPreviewMode } from '../types.js';
import { renderToolHeader } from '../../../shared/tool-header.js';

function inferFilePill(payload: PreviewStructuredContent): { label: string; className: string } {
function inferFilePill(payload: FilePreviewStructuredContent): { label: string; className: string } {
if (payload.fileType === 'markdown') {
return { label: 'MD', className: 'file-pill--md' };
}
Expand All @@ -23,7 +24,7 @@ function inferFilePill(payload: PreviewStructuredContent): { label: string; clas
}

export function renderToolbar(
payload: PreviewStructuredContent,
payload: FilePreviewStructuredContent,
canCopy: boolean,
htmlMode: HtmlPreviewMode,
isExpanded: boolean,
Expand Down
Loading