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" } diff --git a/src/handlers/filesystem-handlers.ts b/src/handlers/filesystem-handlers.ts index c1c5a2a0..e9c5b33a 100644 --- a/src/handlers/filesystem-handlers.ts +++ b/src/handlers/filesystem-handlers.ts @@ -171,7 +171,7 @@ export async function handleReadFile(args: unknown): Promise { const textContent = typeof fileResult.content === 'string' ? fileResult.content : fileResult.content.toString('utf8'); - const fileType = resolvePreviewFileType(resolvedFilePath); + const fileType = fileResult.metadata?.isDirectory ? 'directory' as const : resolvePreviewFileType(resolvedFilePath); return { content: [{ type: "text", text: textContent }], structuredContent: { @@ -326,9 +326,15 @@ export async function handleListDirectory(args: unknown): Promise const duration = Date.now() - startTime; const resultText = entries.join('\n'); + const resolvedPath = resolveAbsolutePath(parsed.path); return { content: [{ type: "text", text: resultText }], + structuredContent: { + fileName: path.basename(resolvedPath), + filePath: resolvedPath, + fileType: 'directory' as const, + }, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/server.ts b/src/server.ts index 975574b2..f06a5557 100644 --- a/src/server.ts +++ b/src/server.ts @@ -522,6 +522,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { ${PATH_GUIDANCE} ${CMD_PREFIX_DESCRIPTION}`, inputSchema: zodToJsonSchema(ListDirectoryArgsSchema), + _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true), annotations: { title: "List Directory Contents", readOnlyHint: true, diff --git a/src/tools/filesystem.ts b/src/tools/filesystem.ts index 78c2b2de..49930a1f 100644 --- a/src/tools/filesystem.ts +++ b/src/tools/filesystem.ts @@ -369,6 +369,40 @@ export async function readFileFromDisk( // Get file extension for telemetry const fileExtension = getFileExtension(validPath); + // Check if path is a directory — return listing instead of EISDIR error + try { + const stats = await fs.stat(validPath); + if (stats.isDirectory()) { + const dirListOp = async () => { + const entries = await listDirectory(validPath); + const listing = entries.join('\n'); + return { + content: `This is a directory, not a file. Use the list_directory tool instead of read_file for directories.\n\n${listing}`, + mimeType: 'text/plain', + metadata: { isImage: false, isDirectory: true } + } as FileResult; + }; + const dirResult = await withTimeout( + dirListOp(), + FILE_OPERATION_TIMEOUTS.FILE_READ, + 'Directory listing fallback', + null + ); + if (dirResult === null) { + throw new Error(`Directory listing timed out for: ${filePath}`); + } + return dirResult; + } + } catch (error) { + // If stat itself failed, fall through to the read path which will produce a proper error. + // But if this was a directory-listing error, re-throw — don't let it fall into the file-read path. + const err = error as NodeJS.ErrnoException; + if (err.message?.includes('Directory listing') || err.message?.includes('list_directory')) { + throw error; + } + // stat() failed (e.g. ENOENT) — fall through to the read path below + } + // Check file size before attempting to read try { const stats = await fs.stat(validPath); diff --git a/src/ui/file-preview/shared/preview-file-types.ts b/src/ui/file-preview/shared/preview-file-types.ts index 9282573a..bff829ef 100644 --- a/src/ui/file-preview/shared/preview-file-types.ts +++ b/src/ui/file-preview/shared/preview-file-types.ts @@ -3,7 +3,7 @@ */ import path from 'path'; -export type PreviewFileType = 'markdown' | 'text' | 'html' | 'image' | 'unsupported'; +export type PreviewFileType = 'markdown' | 'text' | 'html' | 'image' | 'directory' | 'unsupported'; export const MARKDOWN_PREVIEW_EXTENSIONS = new Set(['.md', '.markdown', '.mdx']); export const HTML_PREVIEW_EXTENSIONS = new Set(['.html', '.htm']); diff --git a/src/ui/file-preview/src/app.ts b/src/ui/file-preview/src/app.ts index c66c1fa3..a3ed2054 100644 --- a/src/ui/file-preview/src/app.ts +++ b/src/ui/file-preview/src/app.ts @@ -23,6 +23,7 @@ let trackUiEvent: ((event: string, params?: Record) => void) | let rpcCallTool: ((name: string, args: Record) => Promise) | undefined; let rpcUpdateContext: ((text: string) => void) | undefined; let shellController: ToolShellController | undefined; +let directoryBackPayload: RenderPayload | undefined; function getFileExtensionForAnalytics(filePath: string): string { const normalizedPath = filePath.trim().replace(/\\/g, '/'); @@ -153,6 +154,226 @@ 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 stripReadStatusLine(content: string): string { // Remove the synthetic read status header shown by read_file pagination. return content.replace(/^\[Reading [^\]]+\]\r?\n?/, ''); @@ -217,6 +438,10 @@ function renderBody(payload: RenderPayload, htmlMode: HtmlPreviewMode, startLine 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.', @@ -605,11 +830,13 @@ export function renderApp( 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'; const footerLabel = range?.isPartial ? `${escapeHtml(fileTypeLabel)} • LINES ${range.fromLine}–${range.toLine} OF ${range.totalLines}` @@ -632,11 +859,16 @@ export function renderApp( ? `` : ''; + const backButton = (directoryBackPayload && payload.fileType !== 'directory') + ? `` + : ''; + container.innerHTML = `
${renderCompactRow({ id: 'compact-toggle', label: compactLabel, filename: payload.fileName, variant: 'ready', expandable: true, expanded: isExpanded, interactive: true })}
+ ${backButton} ${breadcrumb} ${htmlToggle} @@ -662,6 +894,22 @@ export function renderApp( attachOpenInFolderHandler(payload); attachLoadAllHandler(container, payload, htmlMode); attachTextSelectionHandler(payload); + if (payload.fileType === 'directory') { + attachDirectoryHandlers(container, payload); + } + // Back to directory navigation + const backBtn = document.getElementById('dir-back'); + if (backBtn && directoryBackPayload) { + const savedPayload = directoryBackPayload; + backBtn.addEventListener('click', () => { + directoryBackPayload = undefined; + 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; diff --git a/src/ui/styles/apps/file-preview.css b/src/ui/styles/apps/file-preview.css index 2db567b5..42802871 100644 --- a/src/ui/styles/apps/file-preview.css +++ b/src/ui/styles/apps/file-preview.css @@ -448,3 +448,155 @@ .markdown-doc h3 { font-size: 18px; } .markdown-doc p, .markdown-doc li { font-size: 15px; } } + +/* ── Directory tree ── */ + +.directory-content { + padding: 12px 16px; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 13px; + line-height: 1.6; +} + +.dir-tree { + display: flex; + flex-direction: column; +} + +.dir-entry, .dir-entry-group { + display: flex; + flex-direction: column; +} + +.dir-row { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: 4px; + cursor: pointer; + user-select: none; +} + +.dir-row:hover { + background: color-mix(in srgb, var(--text) 8%, transparent); +} + +.dir-row-file:active { + background: color-mix(in srgb, var(--text) 14%, transparent); +} + +.dir-row.dir-loading { + opacity: 0.5; + pointer-events: none; +} + +.dir-children { + padding-left: 20px; + border-left: 1px solid color-mix(in srgb, var(--border) 40%, transparent); + margin-left: 11px; +} + +.dir-children.dir-collapsed { + display: none; +} + +.dir-chevron { + display: inline-block; + width: 14px; + text-align: center; + font-size: 10px; + color: var(--secondary); + flex-shrink: 0; + transition: transform 0.15s ease; +} + +.dir-icon, .file-icon { + font-size: 14px; + flex-shrink: 0; +} + +.dir-name { + font-weight: 600; + color: var(--text); +} + +.dir-name-denied { + color: var(--secondary); + text-decoration: line-through; +} + +.file-name { + color: var(--secondary); +} + +.dir-row-file:hover .file-name { + color: var(--text); +} + +.dir-empty { + color: var(--secondary); + font-style: italic; + padding: 4px 6px; +} + +.dir-row-warning { + cursor: default; + opacity: 0.7; + font-style: italic; + font-size: 12px; +} + +.dir-row-warning:hover { + background: none; +} + +.dir-load-more { + cursor: pointer; +} + +.dir-load-more:hover { + background: color-mix(in srgb, var(--text) 8%, transparent) !important; + opacity: 1; +} + +.dir-load-more:hover .dir-warning-text { + text-decoration: underline; +} + +.dir-warning-text { + color: var(--secondary); +} + +.dir-back-btn { + flex-shrink: 0; + margin-right: 4px; + font-weight: 600; +} + +.dir-open-btn { + margin-left: 6px; + padding: 0 4px; + border: none; + background: none; + cursor: pointer; + font-size: 13px; + opacity: 0; + flex-shrink: 0; + border-radius: 3px; + pointer-events: none; + transition: opacity 0.1s ease; +} + +.dir-open-btn:hover { + background: color-mix(in srgb, var(--text) 10%, transparent); +} + +.dir-row-folder:hover .dir-open-btn { + opacity: 0.6; + pointer-events: auto; +} + +.dir-row-folder:hover .dir-open-btn:hover { + opacity: 1; +} diff --git a/src/utils/files/base.ts b/src/utils/files/base.ts index 852e0b21..278a784f 100644 --- a/src/utils/files/base.ts +++ b/src/utils/files/base.ts @@ -111,6 +111,9 @@ export interface FileMetadata { /** For images */ isImage?: boolean; + /** For directories (read_file fallback to listDirectory) */ + isDirectory?: boolean; + /** For binary files */ isBinary?: boolean;