From c3b1d67f86cbbc40fe3b1caf0af367c1fd2e16cc Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 26 Jan 2026 00:28:49 +0800 Subject: [PATCH] feat(file-explorer): add file explorer with @ mention support Complete file explorer implementation: - File tree panel with lazy directory loading and search filtering - Content search with ripgrep/grep/findstr support - File preview dialog for various file types (code, images, PDF, video, etc.) - Integration into details sidebar - Backend file operations API (read, list, search) - Real-time file change listener @ button in file tree: - Clicking @ icon on hover inserts file reference into chat input - Automatically focuses editor after insertion Co-Authored-By: Claude --- bun.lock | 11 +- src/main/index.ts | 120 ++- src/main/lib/trpc/routers/files.ts | 560 +++++++++++- .../ui/panel-container/center-peek-dialog.tsx | 57 ++ .../ui/panel-container/full-page-view.tsx | 31 + .../components/ui/panel-container/index.ts | 6 + .../ui/panel-container/panel-container.tsx | 105 +++ .../ui/panel-container/panel-header.tsx | 111 +++ .../panel-container/panel-mode-switcher.tsx | 76 ++ .../components/ui/panel-container/types.ts | 1 + src/renderer/features/agents/atoms/index.ts | 65 ++ .../features/agents/main/active-chat.tsx | 45 + .../features/agents/main/chat-input-area.tsx | 28 +- .../diff-center-peek-dialog.tsx | 78 +- .../diff-full-page-view.tsx | 52 +- src/renderer/features/cowork/atoms.ts | 228 +++++ .../cowork/file-preview/audio-preview.tsx | 73 ++ .../cowork/file-preview/code-editor.tsx | 454 ++++++++++ .../cowork/file-preview/excel-preview.tsx | 153 ++++ .../file-preview/file-preview-dialog.tsx | 423 +++++++++ .../cowork/file-preview/file-preview.tsx | 224 +++++ .../cowork/file-preview/html-preview.tsx | 123 +++ .../cowork/file-preview/image-preview.tsx | 92 ++ .../features/cowork/file-preview/index.ts | 13 + .../cowork/file-preview/markdown-preview.tsx | 203 +++++ .../cowork/file-preview/pdf-preview.tsx | 70 ++ .../cowork/file-preview/ppt-preview.tsx | 305 +++++++ .../cowork/file-preview/text-preview.tsx | 421 +++++++++ .../cowork/file-preview/video-preview.tsx | 61 ++ .../cowork/file-preview/word-preview.tsx | 136 +++ .../features/cowork/file-tree-panel.tsx | 817 ++++++++++++++++++ .../features/details-sidebar/atoms/index.ts | 5 +- .../details-sidebar/details-sidebar.tsx | 30 +- .../expanded-widget-sidebar.tsx | 4 + .../sections/explorer-panel.tsx | 144 +++ .../sections/explorer-section.tsx | 54 ++ .../sections/explorer-widget.tsx | 117 +++ src/renderer/index.html | 2 +- .../lib/hooks/use-file-change-listener.ts | 5 + 39 files changed, 5353 insertions(+), 150 deletions(-) create mode 100644 src/renderer/components/ui/panel-container/center-peek-dialog.tsx create mode 100644 src/renderer/components/ui/panel-container/full-page-view.tsx create mode 100644 src/renderer/components/ui/panel-container/index.ts create mode 100644 src/renderer/components/ui/panel-container/panel-container.tsx create mode 100644 src/renderer/components/ui/panel-container/panel-header.tsx create mode 100644 src/renderer/components/ui/panel-container/panel-mode-switcher.tsx create mode 100644 src/renderer/components/ui/panel-container/types.ts create mode 100644 src/renderer/features/cowork/atoms.ts create mode 100644 src/renderer/features/cowork/file-preview/audio-preview.tsx create mode 100644 src/renderer/features/cowork/file-preview/code-editor.tsx create mode 100644 src/renderer/features/cowork/file-preview/excel-preview.tsx create mode 100644 src/renderer/features/cowork/file-preview/file-preview-dialog.tsx create mode 100644 src/renderer/features/cowork/file-preview/file-preview.tsx create mode 100644 src/renderer/features/cowork/file-preview/html-preview.tsx create mode 100644 src/renderer/features/cowork/file-preview/image-preview.tsx create mode 100644 src/renderer/features/cowork/file-preview/index.ts create mode 100644 src/renderer/features/cowork/file-preview/markdown-preview.tsx create mode 100644 src/renderer/features/cowork/file-preview/pdf-preview.tsx create mode 100644 src/renderer/features/cowork/file-preview/ppt-preview.tsx create mode 100644 src/renderer/features/cowork/file-preview/text-preview.tsx create mode 100644 src/renderer/features/cowork/file-preview/video-preview.tsx create mode 100644 src/renderer/features/cowork/file-preview/word-preview.tsx create mode 100644 src/renderer/features/cowork/file-tree-panel.tsx create mode 100644 src/renderer/features/details-sidebar/sections/explorer-panel.tsx create mode 100644 src/renderer/features/details-sidebar/sections/explorer-section.tsx create mode 100644 src/renderer/features/details-sidebar/sections/explorer-widget.tsx diff --git a/bun.lock b/bun.lock index d0d0d406..dd8834c1 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "21st-desktop", @@ -40,7 +41,6 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", "ai": "^6.0.14", - "async-mutex": "^0.5.0", "better-sqlite3": "^11.8.1", "chokidar": "^5.0.0", "class-variance-authority": "^0.7.1", @@ -89,7 +89,6 @@ "@types/react-dom": "^19.0.3", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^4.3.4", - "@welldone-software/why-did-you-render": "^10.0.1", "autoprefixer": "^10.4.20", "drizzle-kit": "^0.31.8", "electron": "33.4.5", @@ -782,8 +781,6 @@ "@vue/shared": ["@vue/shared@3.5.27", "", {}, "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ=="], - "@welldone-software/why-did-you-render": ["@welldone-software/why-did-you-render@10.0.1", "", { "dependencies": { "lodash": "^4" }, "peerDependencies": { "react": "^19" } }, "sha512-tMgGkt30iVYeLMUKExNmtm019QgyjLtA7lwB0QAizYNEuihlCG2eoAWBBaz/bDeI7LeqAJ9msC6hY3vX+JB97g=="], - "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], "@xterm/addon-canvas": ["@xterm/addon-canvas@0.7.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw=="], @@ -856,8 +853,6 @@ "async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], - "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="], - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], @@ -1408,7 +1403,7 @@ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], - "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], @@ -2530,6 +2525,8 @@ "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], "dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], diff --git a/src/main/index.ts b/src/main/index.ts index 32bf51eb..7c8aa053 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,5 @@ import * as Sentry from "@sentry/electron/main" -import { app, BrowserWindow, Menu, session } from "electron" +import { app, BrowserWindow, Menu, net, session } from "electron" import { existsSync, readFileSync, readlinkSync, unlinkSync } from "fs" import { createServer } from "http" import { join } from "path" @@ -554,6 +554,124 @@ if (gotTheLock) { // Register protocol handler (must be after app is ready) initialRegistration = registerProtocol() + // Register local-file:// protocol for media file preview + // Supports Range requests for video/audio streaming + const mainSession = session.fromPartition("persist:main") + mainSession.protocol.handle("local-file", async (request) => { + try { + // Parse URL: local-file://localhost/C:/path/to/file.png + const url = new URL(request.url) + let filePath = decodeURIComponent(url.pathname) + + // Windows path handling: /C:/path -> C:/path or /D:/path -> D:/path + if (process.platform === "win32" && filePath.startsWith("/")) { + filePath = filePath.slice(1) + } + + // Convert forward slashes to backslashes on Windows + if (process.platform === "win32") { + filePath = filePath.replace(/\//g, "\\") + } + + // Verify file exists + if (!existsSync(filePath)) { + console.error("[local-file] File not found:", filePath) + return new Response("File not found", { status: 404 }) + } + + // Get file stats for size + const { statSync, createReadStream } = await import("fs") + const stats = statSync(filePath) + const fileSize = stats.size + + // Determine MIME type + const ext = filePath.split(".").pop()?.toLowerCase() || "" + const mimeTypes: Record = { + // Images + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + ico: "image/x-icon", + bmp: "image/bmp", + // Video + mp4: "video/mp4", + webm: "video/webm", + ogg: "video/ogg", + mov: "video/quicktime", + avi: "video/x-msvideo", + mkv: "video/x-matroska", + // Audio + mp3: "audio/mpeg", + wav: "audio/wav", + flac: "audio/flac", + aac: "audio/aac", + m4a: "audio/mp4", + // Documents + pdf: "application/pdf", + } + const contentType = mimeTypes[ext] || "application/octet-stream" + + // Check for Range header (needed for video/audio seeking) + const rangeHeader = request.headers.get("range") + + if (rangeHeader) { + // Parse Range header: bytes=start-end + const match = rangeHeader.match(/bytes=(\d*)-(\d*)/) + if (match) { + const start = match[1] ? parseInt(match[1], 10) : 0 + const end = match[2] ? parseInt(match[2], 10) : fileSize - 1 + const chunkSize = end - start + 1 + + // Create readable stream for the range + const stream = createReadStream(filePath, { start, end }) + const readable = new ReadableStream({ + start(controller) { + stream.on("data", (chunk: Buffer) => controller.enqueue(chunk)) + stream.on("end", () => controller.close()) + stream.on("error", (err) => controller.error(err)) + }, + }) + + return new Response(readable, { + status: 206, + headers: { + "Content-Type": contentType, + "Content-Length": String(chunkSize), + "Content-Range": `bytes ${start}-${end}/${fileSize}`, + "Accept-Ranges": "bytes", + }, + }) + } + } + + // No Range header - return full file + const stream = createReadStream(filePath) + const readable = new ReadableStream({ + start(controller) { + stream.on("data", (chunk: Buffer) => controller.enqueue(chunk)) + stream.on("end", () => controller.close()) + stream.on("error", (err) => controller.error(err)) + }, + }) + + return new Response(readable, { + status: 200, + headers: { + "Content-Type": contentType, + "Content-Length": String(fileSize), + "Accept-Ranges": "bytes", + }, + }) + } catch (error) { + console.error("[local-file] Protocol error:", error) + return new Response("Internal error", { status: 500 }) + } + }) + console.log("[Protocol] Registered local-file:// protocol handler") + // Handle deep link on macOS (app already running) app.on("open-url", (event, url) => { console.log("[Protocol] open-url event received:", url) diff --git a/src/main/lib/trpc/routers/files.ts b/src/main/lib/trpc/routers/files.ts index 061429ce..1317901d 100644 --- a/src/main/lib/trpc/routers/files.ts +++ b/src/main/lib/trpc/routers/files.ts @@ -1,7 +1,9 @@ import { z } from "zod" import { router, publicProcedure } from "../index" import { readdir, stat, readFile, writeFile, mkdir } from "node:fs/promises" -import { join, relative, basename } from "node:path" +import { join, relative, basename, posix } from "node:path" +import { spawn } from "node:child_process" +import { platform } from "node:os" import { app } from "electron" // Directories to ignore when scanning @@ -63,6 +65,16 @@ interface FileEntry { type: "file" | "folder" } +// Content search result type +interface ContentSearchResult { + file: string + line: number + column: number + text: string + beforeContext?: string[] + afterContext?: string[] +} + // Cache for file and folder listings const fileListCache = new Map() const CACHE_TTL = 5000 // 5 seconds @@ -94,7 +106,8 @@ async function scanDirectory( if (entry.name.startsWith(".") && !entry.name.startsWith(".github") && !entry.name.startsWith(".vscode")) continue // Add the folder itself to results - entries.push({ path: relativePath, type: "folder" }) + // Normalize path separators to forward slashes for cross-platform consistency + entries.push({ path: relativePath.replace(/\\/g, "/"), type: "folder" }) // Recurse into subdirectory const subEntries = await scanDirectory(rootPath, fullPath, depth + 1, maxDepth) @@ -110,7 +123,8 @@ async function scanDirectory( if (!ALLOWED_LOCK_FILES.has(entry.name)) continue } - entries.push({ path: relativePath, type: "file" }) + // Normalize path separators to forward slashes for cross-platform consistency + entries.push({ path: relativePath.replace(/\\/g, "/"), type: "file" }) } } } catch (error) { @@ -176,7 +190,7 @@ function filterEntries( const bStarts = bName.startsWith(queryLower) if (aStarts && !bStarts) return -1 if (!aStarts && bStarts) return 1 - + // Priority 3: If both start with query, shorter name = better match if (aStarts && bStarts) { if (aName.length !== bName.length) { @@ -237,7 +251,7 @@ export const filesRouter = router({ // Get entry list (cached or fresh scan) const entries = await getEntryList(projectPath) - + // Debug: log folder count const folderCount = entries.filter(e => e.type === "folder").length const fileCount = entries.filter(e => e.type === "file").length @@ -264,19 +278,545 @@ export const filesRouter = router({ }), /** - * Read file contents from filesystem + * List contents of a specific directory (non-recursive, for lazy loading) + */ + listDirectory: publicProcedure + .input( + z.object({ + projectPath: z.string(), + relativePath: z.string().default(""), + }) + ) + .query(async ({ input }) => { + const { projectPath, relativePath } = input + + if (!projectPath) { + return [] + } + + try { + const targetPath = relativePath ? join(projectPath, relativePath) : projectPath + + // Verify the path exists and is a directory + const pathStat = await stat(targetPath) + if (!pathStat.isDirectory()) { + console.warn(`[files] Not a directory: ${targetPath}`) + return [] + } + + const dirEntries = await readdir(targetPath, { withFileTypes: true }) + const results: Array<{ name: string; path: string; type: "file" | "folder" }> = [] + + for (const entry of dirEntries) { + // Use forward slash for cross-platform consistency + const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name + + if (entry.isDirectory()) { + // Skip ignored directories + if (IGNORED_DIRS.has(entry.name)) continue + // Skip hidden directories (except .github, .vscode, etc.) + if (entry.name.startsWith(".") && !entry.name.startsWith(".github") && !entry.name.startsWith(".vscode")) continue + + results.push({ + name: entry.name, + path: entryRelativePath, + type: "folder", + }) + } else if (entry.isFile()) { + // Skip ignored files + if (IGNORED_FILES.has(entry.name)) continue + + // Check extension + const ext = entry.name.includes(".") ? "." + entry.name.split(".").pop()?.toLowerCase() : "" + if (IGNORED_EXTENSIONS.has(ext)) { + // Allow specific lock files + if (!ALLOWED_LOCK_FILES.has(entry.name)) continue + } + + results.push({ + name: entry.name, + path: entryRelativePath, + type: "file", + }) + } + } + + // Sort: folders first, then alphabetically + results.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "folder" ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + + return results + } catch (error) { + console.error(`[files] Error listing directory:`, error) + return [] + } + }), + + /** + * Read file content (for preview) */ readFile: publicProcedure - .input(z.object({ filePath: z.string() })) + .input( + z.object({ + path: z.string(), + maxSize: z.number().default(1024 * 1024), // 1MB default max + }) + ) .query(async ({ input }) => { - const { filePath } = input + const { path: filePath, maxSize } = input try { + // Check file exists and get size + const fileStat = await stat(filePath) + + if (!fileStat.isFile()) { + throw new Error("Not a file") + } + + if (fileStat.size > maxSize) { + throw new Error(`File too large (${Math.round(fileStat.size / 1024)}KB > ${Math.round(maxSize / 1024)}KB limit)`) + } + + // Read file content const content = await readFile(filePath, "utf-8") return content } catch (error) { - console.error(`[files] Error reading file ${filePath}:`, error) - throw new Error(`Failed to read file: ${error instanceof Error ? error.message : "Unknown error"}`) + console.error(`[files] Error reading file:`, error) + throw error + } + }), + + /** + * Read binary file as base64 (for images, PDFs, etc.) + */ + readBinaryFile: publicProcedure + .input( + z.object({ + path: z.string(), + maxSize: z.number().default(10 * 1024 * 1024), // 10MB default max for binary files + }) + ) + .query(async ({ input }) => { + const { path: filePath, maxSize } = input + + try { + // Check file exists and get size + const fileStat = await stat(filePath) + + if (!fileStat.isFile()) { + throw new Error("Not a file") + } + + if (fileStat.size > maxSize) { + throw new Error(`File too large (${Math.round(fileStat.size / 1024)}KB > ${Math.round(maxSize / 1024)}KB limit)`) + } + + // Read file as buffer and convert to base64 + const buffer = await readFile(filePath) + const base64 = buffer.toString("base64") + + // Determine MIME type from extension + const ext = filePath.split(".").pop()?.toLowerCase() || "" + const mimeTypes: Record = { + // Images + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + ico: "image/x-icon", + bmp: "image/bmp", + avif: "image/avif", + // Documents + pdf: "application/pdf", + } + const mimeType = mimeTypes[ext] || "application/octet-stream" + + return { + base64, + mimeType, + size: fileStat.size, + } + } catch (error) { + console.error(`[files] Error reading binary file:`, error) + throw error + } + }), + + /** + * Search for files matching a filename pattern (returns all matching paths for auto-expand) + */ + searchFiles: publicProcedure + .input( + z.object({ + projectPath: z.string(), + query: z.string(), + limit: z.number().min(1).max(500).default(100), + }) + ) + .query(async ({ input }) => { + const { projectPath, query, limit } = input + + if (!projectPath) { + return { results: [], parentPaths: [] } + } + + try { + const entries = await getEntryList(projectPath) + + // If no query, return all folder paths for expand all functionality + if (!query) { + const allFolders = entries.filter((entry) => entry.type === "folder") + return { + results: [], + parentPaths: allFolders.map((f) => f.path), + } + } + + const queryLower = query.toLowerCase() + + // Find matching files + const matchingFiles = entries.filter((entry) => { + const name = basename(entry.path).toLowerCase() + return name.includes(queryLower) + }) + + // Sort by relevance + matchingFiles.sort((a, b) => { + const aName = basename(a.path).toLowerCase() + const bName = basename(b.path).toLowerCase() + + // Exact match first + if (aName === queryLower && bName !== queryLower) return -1 + if (bName === queryLower && aName !== queryLower) return 1 + + // Starts with query + if (aName.startsWith(queryLower) && !bName.startsWith(queryLower)) return -1 + if (bName.startsWith(queryLower) && !aName.startsWith(queryLower)) return 1 + + // Shorter name = better match + return aName.length - bName.length + }) + + const limited = matchingFiles.slice(0, limit) + + // Collect all parent directories that need to be expanded + // Use posix.dirname since paths are normalized to forward slashes + const parentPaths = new Set() + for (const entry of limited) { + let currentPath = posix.dirname(entry.path) + while (currentPath && currentPath !== ".") { + parentPaths.add(currentPath) + currentPath = posix.dirname(currentPath) + } + } + + return { + results: limited.map((entry) => ({ + path: entry.path, + type: entry.type, + name: basename(entry.path), + })), + parentPaths: Array.from(parentPaths), + } + } catch (error) { + console.error(`[files] Error searching files:`, error) + return { results: [], parentPaths: [] } + } + }), + + /** + * Search file contents using ripgrep (with grep/findstr fallback) + */ + searchContent: publicProcedure + .input( + z.object({ + projectPath: z.string(), + query: z.string(), + filePattern: z.string().optional(), + caseSensitive: z.boolean().default(false), + limit: z.number().min(1).max(500).default(100), + }) + ) + .mutation(async ({ input }) => { + const { projectPath, query, filePattern, caseSensitive, limit } = input + + if (!projectPath || !query) { + return { results: [], tool: "none" } + } + + const isWindows = platform() === "win32" + + return new Promise<{ results: ContentSearchResult[]; tool: string }>((resolve) => { + // Try ripgrep first, then fall back to grep/findstr + const rgPaths = isWindows + ? ["rg", "C:\\Program Files\\ripgrep\\rg.exe", "C:\\ProgramData\\scoop\\shims\\rg.exe"] + : ["rg", "/opt/homebrew/bin/rg", "/usr/local/bin/rg", "/usr/bin/rg"] + + let rgPathIndex = 0 + + const tryRipgrep = () => { + if (rgPathIndex >= rgPaths.length) { + // No ripgrep found, try fallback + tryFallback() + return + } + + const rgPath = rgPaths[rgPathIndex] + rgPathIndex++ + + console.log(`[files] Trying ripgrep at: ${rgPath} for content search: "${query}" in ${projectPath}`) + + const args = [ + "--json", + "--line-number", + "--column", + "-C", "2", // 2 lines of context + ] + + // Add case sensitivity flag + if (!caseSensitive) { + args.push("-i") + } + + // Add file pattern if provided + if (filePattern) { + args.push("-g", filePattern) + } + + // Add ignored directories + for (const dir of IGNORED_DIRS) { + args.push("-g", `!${dir}/**`) + } + + args.push("--", query, projectPath) + + const rg = spawn(rgPath, args) + let output = "" + + rg.stdout.on("data", (data) => { + output += data.toString() + }) + + rg.on("close", (code) => { + if (code === null || (code !== 0 && code !== 1)) { + // ripgrep error, try next path + tryRipgrep() + return + } + + // Parse JSON output + const lines = output.split("\n").filter(Boolean) + const matchMap = new Map() + + for (const line of lines) { + try { + const json = JSON.parse(line) + if (json.type === "match") { + const data = json.data + const file = relative(projectPath, data.path.text).replace(/\\/g, "/") + const lineNum = data.line_number + + // Skip ignored directories + if (IGNORED_DIRS.has(file.split("/")[0])) continue + + const key = `${file}:${lineNum}` + if (!matchMap.has(key)) { + matchMap.set(key, { + file, + line: lineNum, + column: data.submatches?.[0]?.start || 0, + text: data.lines.text.trim(), + beforeContext: [], + afterContext: [], + }) + } + } + } catch { + // Skip non-JSON lines + } + } + + const results = Array.from(matchMap.values()).slice(0, limit) + resolve({ + results, + tool: "ripgrep", + }) + }) + + rg.on("error", () => { + // This ripgrep path not found, try next path + tryRipgrep() + }) + } + + const tryFallback = () => { + if (isWindows) { + tryFindstr() + } else { + tryGrep() + } + } + + const tryFindstr = () => { + console.log(`[files] Trying findstr for content search: "${query}" in ${projectPath}`) + + // Windows findstr command + // /S = search subdirectories + // /N = print line numbers + // /I = case insensitive (if needed) + // /P = skip binary files + const args = ["/S", "/N", "/P"] + + if (!caseSensitive) { + args.push("/I") + } + + // Add search pattern + args.push(query) + + // Add file pattern + if (filePattern) { + args.push(filePattern) + } else { + args.push("*.*") + } + + const findstr = spawn("findstr", args, { cwd: projectPath }) + const results: ContentSearchResult[] = [] + let output = "" + + findstr.stdout.on("data", (data) => { + output += data.toString() + }) + + findstr.on("close", (code) => { + // findstr returns 1 when no matches found, 0 on success, 2 on error + if (code === 2) { + console.error(`[files] findstr failed`) + resolve({ results: [], tool: "findstr-failed" }) + return + } + + // Parse findstr output: filename:line:content + const lines = output.split("\r\n").filter(Boolean) + + for (const line of lines) { + if (results.length >= limit) break + + const match = line.match(/^(.+?):(\d+):(.*)$/) + if (match) { + let filePath = match[1] + // Normalize path separators to forward slashes + filePath = filePath.replace(/\\/g, "/") + // Skip ignored directories + if (IGNORED_DIRS.has(filePath.split("/")[0])) continue + if (filePath.includes("/node_modules/") || filePath.includes("/.git/")) continue + + results.push({ + file: filePath, + line: parseInt(match[2], 10), + column: 0, + text: match[3].trim(), + }) + } + } + + resolve({ results, tool: "findstr" }) + }) + + findstr.on("error", (err) => { + console.error(`[files] findstr spawn error:`, err) + resolve({ results: [], tool: "findstr-error" }) + }) + } + + const tryGrep = () => { + console.log(`[files] Trying grep for content search: "${query}" in ${projectPath}`) + + const grepPath = "/usr/bin/grep" + const args = ["-r", "-n", "-H"] + + if (filePattern) { + args.push("--include=" + filePattern) + } + + if (!caseSensitive) { + args.push("-i") + } + + for (const dir of IGNORED_DIRS) { + args.push(`--exclude-dir=${dir}`) + } + + args.push("--", query, projectPath) + + const grep = spawn(grepPath, args) + const results: ContentSearchResult[] = [] + let output = "" + + grep.stdout.on("data", (data) => { + output += data.toString() + }) + + grep.on("close", (code) => { + if (code === null || code > 1) { + resolve({ results: [], tool: "grep-failed" }) + return + } + + const lines = output.split("\n").filter(Boolean) + for (const line of lines) { + if (results.length >= limit) break + + const match = line.match(/^(.+?):(\d+):(.*)$/) + if (match) { + results.push({ + file: relative(projectPath, match[1]).replace(/\\/g, "/"), + line: parseInt(match[2], 10), + column: 0, + text: match[3].trim(), + }) + } + } + + resolve({ results, tool: "grep" }) + }) + + grep.on("error", () => { + resolve({ results: [], tool: "grep-error" }) + }) + } + + tryRipgrep() + }) + }), + + /** + * Write file content (for editing files) + */ + writeFile: publicProcedure + .input( + z.object({ + path: z.string(), + content: z.string(), + }) + ) + .mutation(async ({ input }) => { + const { path: filePath, content } = input + + try { + await writeFile(filePath, content, "utf-8") + console.log(`[files] Wrote file: ${filePath} (${content.length} bytes)`) + return { success: true } + } catch (error) { + console.error(`[files] Error writing file:`, error) + throw error } }), diff --git a/src/renderer/components/ui/panel-container/center-peek-dialog.tsx b/src/renderer/components/ui/panel-container/center-peek-dialog.tsx new file mode 100644 index 00000000..c9f8bdac --- /dev/null +++ b/src/renderer/components/ui/panel-container/center-peek-dialog.tsx @@ -0,0 +1,57 @@ +"use client" + +import { AnimatePresence, motion } from "motion/react" + +interface CenterPeekDialogProps { + isOpen: boolean + onClose: () => void + children: React.ReactNode +} + +export function CenterPeekDialog({ + isOpen, + onClose, + children, +}: CenterPeekDialogProps) { + return ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Dialog */} + + {children} + + + )} + + ) +} diff --git a/src/renderer/components/ui/panel-container/full-page-view.tsx b/src/renderer/components/ui/panel-container/full-page-view.tsx new file mode 100644 index 00000000..856caf1b --- /dev/null +++ b/src/renderer/components/ui/panel-container/full-page-view.tsx @@ -0,0 +1,31 @@ +"use client" + +import { AnimatePresence, motion } from "motion/react" + +interface FullPageViewProps { + isOpen: boolean + onClose: () => void + children: React.ReactNode +} + +export function FullPageView({ + isOpen, + onClose, + children, +}: FullPageViewProps) { + return ( + + {isOpen && ( + + {children} + + )} + + ) +} diff --git a/src/renderer/components/ui/panel-container/index.ts b/src/renderer/components/ui/panel-container/index.ts new file mode 100644 index 00000000..94765f45 --- /dev/null +++ b/src/renderer/components/ui/panel-container/index.ts @@ -0,0 +1,6 @@ +export { PanelContainer } from "./panel-container" +export { PanelHeader } from "./panel-header" +export { PanelModeSwitcher } from "./panel-mode-switcher" +export { CenterPeekDialog } from "./center-peek-dialog" +export { FullPageView } from "./full-page-view" +export type { PanelDisplayMode } from "./types" diff --git a/src/renderer/components/ui/panel-container/panel-container.tsx b/src/renderer/components/ui/panel-container/panel-container.tsx new file mode 100644 index 00000000..c28a3cc9 --- /dev/null +++ b/src/renderer/components/ui/panel-container/panel-container.tsx @@ -0,0 +1,105 @@ +"use client" + +import { useCallback, useEffect } from "react" +import type { WritableAtom } from "jotai" +import { ResizableSidebar } from "../resizable-sidebar" +import { CenterPeekDialog } from "./center-peek-dialog" +import { FullPageView } from "./full-page-view" +import type { PanelDisplayMode } from "./types" + +interface PanelContainerProps { + /** Whether the panel is open */ + isOpen: boolean + /** Close handler */ + onClose: () => void + /** Current display mode */ + displayMode: PanelDisplayMode + /** Panel content */ + children: React.ReactNode + /** Jotai atom for sidebar width (required for side-peek mode) */ + widthAtom?: WritableAtom + /** Minimum width for sidebar mode */ + minWidth?: number + /** Maximum width for sidebar mode */ + maxWidth?: number + /** Side for sidebar mode */ + side?: "left" | "right" + /** Additional class name for sidebar container */ + className?: string + /** Custom styles for sidebar container */ + style?: React.CSSProperties +} + +export function PanelContainer({ + isOpen, + onClose, + displayMode, + children, + widthAtom, + minWidth = 300, + maxWidth = 800, + side = "right", + className = "", + style, +}: PanelContainerProps) { + // ESC key handler for dialog and full-page modes + // (ResizableSidebar doesn't handle ESC by default) + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation() + onClose() + } + }, + [onClose] + ) + + useEffect(() => { + // Only add ESC handler for non-sidebar modes + // CenterPeekDialog and FullPageView have their own ESC handlers, + // but we add one at container level for consistency + if (isOpen && displayMode !== "side-peek") { + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + } + }, [isOpen, displayMode, handleKeyDown]) + + if (displayMode === "side-peek") { + if (!widthAtom) { + console.warn("PanelContainer: widthAtom is required for side-peek mode") + return null + } + return ( + + {children} + + ) + } + + if (displayMode === "center-peek") { + return ( + + {children} + + ) + } + + if (displayMode === "full-page") { + return ( + + {children} + + ) + } + + return null +} diff --git a/src/renderer/components/ui/panel-container/panel-header.tsx b/src/renderer/components/ui/panel-container/panel-header.tsx new file mode 100644 index 00000000..ac6f6660 --- /dev/null +++ b/src/renderer/components/ui/panel-container/panel-header.tsx @@ -0,0 +1,111 @@ +"use client" + +import { X } from "lucide-react" +import { Button } from "../button" +import { IconCloseSidebarRight } from "../icons" +import { PanelModeSwitcher } from "./panel-mode-switcher" +import type { PanelDisplayMode } from "./types" +import { cn } from "../../../lib/utils" + +interface PanelHeaderProps { + /** Title content (displayed after close button and mode switcher) */ + title?: React.ReactNode + /** Left slot - custom content after mode switcher, before title */ + leftSlot?: React.ReactNode + /** Right slot - custom actions on the right side */ + rightSlot?: React.ReactNode + /** Close handler */ + onClose?: () => void + /** Current display mode */ + displayMode?: PanelDisplayMode + /** Display mode change handler */ + onDisplayModeChange?: (mode: PanelDisplayMode) => void + /** Enable desktop window drag region */ + isDesktop?: boolean + /** Whether window is in fullscreen mode */ + isFullscreen?: boolean + /** Additional class names */ + className?: string +} + +export function PanelHeader({ + title, + leftSlot, + rightSlot, + onClose, + displayMode, + onDisplayModeChange, + isDesktop = false, + isFullscreen = false, + className, +}: PanelHeaderProps) { + return ( +
+ {/* Drag region for window dragging */} + {isDesktop && !isFullscreen && ( +
+ )} + + {/* Left side: Close button + Mode switcher + Left slot + Title */} +
+ {/* Close button - X icon for dialog/fullpage modes, chevron for sidebar */} + {onClose && ( + + )} + + {/* Display mode switcher */} + {displayMode && onDisplayModeChange && ( + + )} + + {/* Left slot (custom content like branch selector for Diff) */} + {leftSlot} + + {/* Title */} + {title} +
+ + {/* Right side (custom actions) */} +
+ {rightSlot} +
+
+ ) +} diff --git a/src/renderer/components/ui/panel-container/panel-mode-switcher.tsx b/src/renderer/components/ui/panel-container/panel-mode-switcher.tsx new file mode 100644 index 00000000..a46e01b0 --- /dev/null +++ b/src/renderer/components/ui/panel-container/panel-mode-switcher.tsx @@ -0,0 +1,76 @@ +"use client" + +import { Check } from "lucide-react" +import { Button } from "../button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../dropdown-menu" +import { + IconSidePeek, + IconCenterPeek, + IconFullPage, +} from "../icons" +import type { PanelDisplayMode } from "./types" + +interface PanelModeSwitcherProps { + mode: PanelDisplayMode + onModeChange: (mode: PanelDisplayMode) => void +} + +const MODES = [ + { + value: "side-peek" as const, + label: "Sidebar", + Icon: IconSidePeek, + }, + { + value: "center-peek" as const, + label: "Dialog", + Icon: IconCenterPeek, + }, + { + value: "full-page" as const, + label: "Fullscreen", + Icon: IconFullPage, + }, +] + +export function PanelModeSwitcher({ + mode, + onModeChange, +}: PanelModeSwitcherProps) { + const currentMode = MODES.find((m) => m.value === mode) ?? MODES[0] + const CurrentIcon = currentMode.Icon + + return ( + + + + + + {MODES.map(({ value, label, Icon }) => ( + onModeChange(value)} + className="flex items-center gap-2" + > + + {label} + {mode === value && ( + + )} + + ))} + + + ) +} diff --git a/src/renderer/components/ui/panel-container/types.ts b/src/renderer/components/ui/panel-container/types.ts new file mode 100644 index 00000000..58e56879 --- /dev/null +++ b/src/renderer/components/ui/panel-container/types.ts @@ -0,0 +1 @@ +export type PanelDisplayMode = "side-peek" | "center-peek" | "full-page" diff --git a/src/renderer/features/agents/atoms/index.ts b/src/renderer/features/agents/atoms/index.ts index cb858f6b..d3abfdf2 100644 --- a/src/renderer/features/agents/atoms/index.ts +++ b/src/renderer/features/agents/atoms/index.ts @@ -813,3 +813,68 @@ export const workspaceDiffCacheAtomFamily = atomFamily((chatId: string) => }, ), ) + +// ============================================================================ +// Explorer Panel State +// ============================================================================ + +// Explorer display mode - sidebar (side peek), center dialog, or fullscreen +export type ExplorerDisplayMode = "side-peek" | "center-peek" | "full-page" + +export const explorerDisplayModeAtom = atomWithStorage( + "agents:explorerDisplayMode", + "side-peek", // default to sidebar for existing behavior + undefined, + { getOnInit: true }, +) + +// Explorer sidebar width (persisted globally) +export const explorerSidebarWidthAtom = atomWithStorage( + "agents-explorer-sidebar-width", + 350, + undefined, + { getOnInit: true }, +) + +// Explorer panel open state storage - window-scoped, stores per chatId +const explorerPanelOpenStorageAtom = atomWithWindowStorage>( + "agents:explorerPanelOpen", + {}, + { getOnInit: true }, +) + +// Runtime open state - not persisted, used for dialog/fullscreen modes +const explorerPanelOpenRuntimeAtom = atom>({}) + +// atomFamily to get/set explorer panel open state per chatId +// Only restores persisted state when display mode is "side-peek" (sidebar mode) +// For dialog/fullscreen modes, we use runtime state only (not auto-restored on page load) +export const explorerPanelOpenAtomFamily = atomFamily((chatId: string) => + atom( + (get) => { + const displayMode = get(explorerDisplayModeAtom) + const runtimeOpen = get(explorerPanelOpenRuntimeAtom)[chatId] + + // If we have a runtime value, use it (user explicitly opened/closed) + if (runtimeOpen !== undefined) { + return runtimeOpen + } + + // For initial load: only restore persisted state for sidebar mode + // Dialog and fullscreen should not auto-open on page load + if (displayMode !== "side-peek") { + return false + } + return get(explorerPanelOpenStorageAtom)[chatId] ?? false + }, + (get, set, isOpen: boolean) => { + // Always update runtime state + const currentRuntime = get(explorerPanelOpenRuntimeAtom) + set(explorerPanelOpenRuntimeAtom, { ...currentRuntime, [chatId]: isOpen }) + + // Also persist for sidebar mode + const current = get(explorerPanelOpenStorageAtom) + set(explorerPanelOpenStorageAtom, { ...current, [chatId]: isOpen }) + }, + ), +) diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index 525d13ff..b957a546 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -202,10 +202,14 @@ import { generateCommitToPrMessage, generatePrMessage, generateReviewMessage } f import { ChatInputArea } from "./chat-input-area" import { IsolatedMessagesSection } from "./isolated-messages-section" import { DetailsSidebar } from "../../details-sidebar/details-sidebar" +import { ExpandedWidgetSidebar } from "../../details-sidebar/expanded-widget-sidebar" import { detailsSidebarOpenAtom, unifiedSidebarEnabledAtom, + expandedWidgetAtomFamily, } from "../../details-sidebar/atoms" +import { ExplorerPanel } from "../../details-sidebar/sections/explorer-panel" +import { explorerPanelOpenAtomFamily } from "../atoms" const clearSubChatSelectionAtom = atom(null, () => {}) const isSubChatMultiSelectModeAtom = atom(false) const selectedSubChatIdsAtom = atom(new Set()) @@ -4247,6 +4251,20 @@ export function ChatView({ ) const [isTerminalSidebarOpen, setIsTerminalSidebarOpen] = useAtom(terminalSidebarAtom) + // Per-chat expanded widget state - for Explorer and other expandable widgets + const expandedWidgetAtom = useMemo( + () => expandedWidgetAtomFamily(chatId), + [chatId], + ) + const [expandedWidget, setExpandedWidget] = useAtom(expandedWidgetAtom) + + // Explorer panel state - separate from ExpandedWidgetSidebar (supports three display modes) + const explorerPanelOpenAtom = useMemo( + () => explorerPanelOpenAtomFamily(chatId), + [chatId], + ) + const [isExplorerPanelOpen, setIsExplorerPanelOpen] = useAtom(explorerPanelOpenAtom) + // Mutual exclusion: Details sidebar vs Plan/Terminal/Diff(side-peek) sidebars // When one opens, close the conflicting ones and remember for restoration @@ -6761,6 +6779,8 @@ Make sure to preserve all functionality from both branches when resolving confli onExpandTerminal={() => setIsTerminalSidebarOpen(true)} onExpandPlan={() => setIsPlanSidebarOpen(true)} onExpandDiff={() => setIsDiffSidebarOpen(true)} + onExpandExplorer={() => setIsExplorerPanelOpen(true)} + isExplorerSidebarOpen={isExplorerPanelOpen} onFileSelect={(filePath) => { // Set the selected file path setSelectedFilePath(filePath) @@ -6773,6 +6793,31 @@ Make sure to preserve all functionality from both branches when resolving confli isRemoteChat={!!remoteInfo} /> )} + + {/* Expanded Widget Sidebar - for Info, Plan, Terminal, Diff widgets */} + {isUnifiedSidebarEnabled && !isMobileFullscreen && worktreePath && ( + + )} + + {/* Explorer Panel - supports sidebar/dialog/fullscreen modes */} + {worktreePath && ( + setIsExplorerPanelOpen(false)} + /> + )}
diff --git a/src/renderer/features/agents/main/chat-input-area.tsx b/src/renderer/features/agents/main/chat-input-area.tsx index 73f63cd1..7a7704cc 100644 --- a/src/renderer/features/agents/main/chat-input-area.tsx +++ b/src/renderer/features/agents/main/chat-input-area.tsx @@ -40,6 +40,7 @@ import { trpc } from "../../../lib/trpc" import { cn } from "../../../lib/utils" import { lastSelectedModelIdAtom, subChatModeAtomFamily, getNextMode, type AgentMode, type SubChatFileChange } from "../atoms" import { useAgentSubChatStore } from "../stores/sub-chat-store" +import { pendingFileReferenceAtom } from "../../cowork/atoms" import { AgentsSlashCommand, type SlashCommandOption } from "../commands" import { AgentSendButton } from "../components/agent-send-button" import type { UploadedFile, UploadedImage } from "../hooks/use-agents-file-upload" @@ -465,6 +466,9 @@ export const ChatInputArea = memo(function ChatInputArea({ const customHotkeys = useAtomValue(customHotkeysAtom) const voiceInputHotkey = getResolvedHotkey("voice-input", customHotkeys) + // Pending file reference from file tree panel (@ button click) + const [pendingFileReference, setPendingFileReference] = useAtom(pendingFileReferenceAtom) + // Refs for draft saving const currentSubChatIdRef = useRef(subChatId) const currentChatIdRef = useRef(parentChatId) @@ -650,6 +654,29 @@ export const ChatInputArea = memo(function ChatInputArea({ } }, [voiceInputHotkey, isVoiceRecording, isTranscribing, isStreaming, handleVoiceMouseDown, handleVoiceMouseUp]) + // Handle pending file reference from file tree panel (@ button click) + useEffect(() => { + if (pendingFileReference && editorRef.current) { + // Construct mention option from pending reference + const mentionOption: FileMentionOption = { + id: `file:local:${pendingFileReference.path}`, + label: pendingFileReference.name, + path: pendingFileReference.path, + repository: "local", + type: pendingFileReference.type, + } + + // Insert the mention into the editor + editorRef.current.insertMention(mentionOption) + + // Clear the pending reference + setPendingFileReference(null) + + // Focus the editor + editorRef.current.focus() + } + }, [pendingFileReference, setPendingFileReference, editorRef]) + // Save draft on blur (with attachments and text contexts) const handleEditorBlur = useCallback(async () => { setIsFocused(false) @@ -865,7 +892,6 @@ export const ChatInputArea = memo(function ChatInputArea({ // Process other files - for text files, read content and add as file mention for (const file of otherFiles) { // Get file path using Electron's webUtils API (more reliable than file.path) - // @ts-expect-error - Electron's webUtils API const filePath: string | undefined = window.webUtils?.getPathForFile?.(file) || (file as File & { path?: string }).path let mentionId: string diff --git a/src/renderer/features/changes/components/diff-center-peek-dialog/diff-center-peek-dialog.tsx b/src/renderer/features/changes/components/diff-center-peek-dialog/diff-center-peek-dialog.tsx index faa2536b..3ce501a2 100644 --- a/src/renderer/features/changes/components/diff-center-peek-dialog/diff-center-peek-dialog.tsx +++ b/src/renderer/features/changes/components/diff-center-peek-dialog/diff-center-peek-dialog.tsx @@ -1,76 +1,2 @@ -"use client" - -import { AnimatePresence, motion } from "motion/react" -import { useEffect, useCallback } from "react" - -interface DiffCenterPeekDialogProps { - isOpen: boolean - onClose: () => void - children: React.ReactNode -} - -export function DiffCenterPeekDialog({ - isOpen, - onClose, - children, -}: DiffCenterPeekDialogProps) { - // Close on Escape key - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.stopPropagation() // Prevent ESC from bubbling to stop stream handler - onClose() - } - }, - [onClose] - ) - - useEffect(() => { - if (isOpen) { - document.addEventListener("keydown", handleKeyDown) - return () => document.removeEventListener("keydown", handleKeyDown) - } - }, [isOpen, handleKeyDown]) - - return ( - - {isOpen && ( - <> - {/* Backdrop */} - - - {/* Dialog */} - - {children} - - - )} - - ) -} +// Re-export from unified panel-container for backward compatibility +export { CenterPeekDialog as DiffCenterPeekDialog } from "../../../../components/ui/panel-container" diff --git a/src/renderer/features/changes/components/diff-full-page-view/diff-full-page-view.tsx b/src/renderer/features/changes/components/diff-full-page-view/diff-full-page-view.tsx index b2d5c964..db451ba9 100644 --- a/src/renderer/features/changes/components/diff-full-page-view/diff-full-page-view.tsx +++ b/src/renderer/features/changes/components/diff-full-page-view/diff-full-page-view.tsx @@ -1,50 +1,2 @@ -"use client" - -import { AnimatePresence, motion } from "motion/react" -import { useEffect, useCallback } from "react" - -interface DiffFullPageViewProps { - isOpen: boolean - onClose: () => void - children: React.ReactNode -} - -export function DiffFullPageView({ - isOpen, - onClose, - children, -}: DiffFullPageViewProps) { - // Close on Escape key - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.stopPropagation() // Prevent ESC from bubbling to stop stream handler - onClose() - } - }, - [onClose] - ) - - useEffect(() => { - if (isOpen) { - document.addEventListener("keydown", handleKeyDown) - return () => document.removeEventListener("keydown", handleKeyDown) - } - }, [isOpen, handleKeyDown]) - - return ( - - {isOpen && ( - - {children} - - )} - - ) -} +// Re-export from unified panel-container for backward compatibility +export { FullPageView as DiffFullPageView } from "../../../../components/ui/panel-container" diff --git a/src/renderer/features/cowork/atoms.ts b/src/renderer/features/cowork/atoms.ts new file mode 100644 index 00000000..ea7c353f --- /dev/null +++ b/src/renderer/features/cowork/atoms.ts @@ -0,0 +1,228 @@ +import { atom } from "jotai" +import { atomWithStorage, atomFamily } from "jotai/utils" + +// ============================================================================ +// Cowork Mode Toggle +// ============================================================================ + +// Master switch for Cowork mode (persisted) +// When true, CoworkLayout is used instead of AgentsLayout +export const isCoworkModeAtom = atomWithStorage( + "cowork:enabled", + true, // Default to Cowork mode + undefined, + { getOnInit: true }, +) + +// ============================================================================ +// Right Panel State (Tasks + Files) +// ============================================================================ + +// Right panel width (persisted) +export const coworkRightPanelWidthAtom = atomWithStorage( + "cowork:rightPanelWidth", + 320, + undefined, + { getOnInit: true }, +) + +// Right panel open state (persisted) +export const coworkRightPanelOpenAtom = atomWithStorage( + "cowork:rightPanelOpen", + true, + undefined, + { getOnInit: true }, +) + +// Track if user has manually closed the right panel (persisted) +// Used to determine whether to auto-open panel when entering a chat +export const coworkRightPanelUserClosedAtom = atomWithStorage( + "cowork:rightPanelUserClosed", + false, + undefined, + { getOnInit: true }, +) + +// ============================================================================ +// Section Collapse State +// ============================================================================ + +// Task section expanded state (null = auto, true = expanded, false = collapsed) +// Auto mode: collapsed when no tasks, expanded when has tasks +export const taskSectionExpandedAtom = atom(null) + +// Artifacts section expanded state (null = auto, true = expanded, false = collapsed) +// Auto mode: collapsed when no artifacts, expanded when has artifacts +export const artifactsSectionExpandedAtom = atom(null) + +// ============================================================================ +// File Tree State +// ============================================================================ + +// Expanded folder paths in file tree (not persisted - resets on reload) +export const fileTreeExpandedPathsAtom = atom>(new Set()) + +// Selected file in file tree (for highlighting) +export const fileTreeSelectedPathAtom = atom(null) + +// Search query in file tree +export const fileTreeSearchQueryAtom = atom("") + +// Saved expanded paths before search (to restore after clearing search) +export const fileTreeSavedExpandedPathsAtom = atom | null>(null) + +// ============================================================================ +// Content Search State (Advanced Search) +// ============================================================================ + +// Whether advanced search (content search) mode is active +export const contentSearchActiveAtom = atom(false) + +// Content search query +export const contentSearchQueryAtom = atom("") + +// Content search file pattern filter (e.g., "*.ts", "*.{js,tsx}") +export const contentSearchPatternAtom = atom("") + +// Content search case sensitivity +export const contentSearchCaseSensitiveAtom = atom(false) + +// Content search loading state +export const contentSearchLoadingAtom = atom(false) + +// Content search result type +export interface ContentSearchResult { + file: string + line: number + column: number + text: string + beforeContext?: string[] + afterContext?: string[] +} + +// Content search results +export const contentSearchResultsAtom = atom([]) + +// Content search tool used (ripgrep or grep) +export const contentSearchToolAtom = atom("") + +// ============================================================================ +// Artifacts State (per chat - all sub-chats share the same artifacts) +// ============================================================================ + +// Context information for an artifact (files read, URLs visited) +export interface ArtifactContext { + type: "file" | "url" + // File context + filePath?: string + toolType?: "Read" | "Glob" | "Grep" + // URL context + url?: string + title?: string +} + +export interface Artifact { + path: string + description?: string + status: "created" | "modified" | "deleted" + timestamp: number + contexts?: ArtifactContext[] +} + +// All artifacts storage - keyed by chatId (persisted to localStorage) +const allArtifactsStorageAtom = atomWithStorage>( + "cowork:artifacts", + {}, + undefined, + { getOnInit: true } +) + +// atomFamily to get/set artifacts per subChatId (session) +// Supports both direct value and updater function: setArtifacts(newArr) or setArtifacts(prev => newArr) +type ArtifactsSetter = Artifact[] | ((prev: Artifact[]) => Artifact[]) + +export const artifactsAtomFamily = atomFamily((subChatId: string) => + atom( + (get) => get(allArtifactsStorageAtom)[subChatId] ?? [], + (get, set, update: ArtifactsSetter) => { + const current = get(allArtifactsStorageAtom) + const prevArtifacts = current[subChatId] ?? [] + // Support both direct value and updater function + const newArtifacts = typeof update === "function" ? update(prevArtifacts) : update + console.log("[Artifacts] Setting artifacts for subChatId:", subChatId, "count:", newArtifacts.length) + set(allArtifactsStorageAtom, { ...current, [subChatId]: newArtifacts }) + } + ) +) + +// ============================================================================ +// File Preview State +// ============================================================================ + +export type FilePreviewDisplayMode = "dialog" | "side-peek" | "full-page" + +// Display mode for file preview (persisted) +export const filePreviewDisplayModeAtom = atomWithStorage( + "cowork:filePreviewDisplayMode", + "dialog", + undefined, + { getOnInit: true } +) + +// Current preview file path (null = closed) +export const filePreviewPathAtom = atom(null) + +// Line number to scroll to in preview (null = no scroll) +export const filePreviewLineAtom = atom(null) + +// Search highlight keyword for preview (null = no highlight) +export const filePreviewHighlightAtom = atom(null) + +// ============================================================================ +// File Reference Insertion (File Tree -> Chat Input) +// ============================================================================ + +// Pending file reference to insert into chat input +// When set, chat input components should insert this file as a mention and clear the atom +export interface PendingFileReference { + path: string + name: string + type: "file" | "folder" +} + +export const pendingFileReferenceAtom = atom(null) + +// File preview dialog open state (derived from path) +export const filePreviewOpenAtom = atom( + (get) => get(filePreviewPathAtom) !== null, + (get, set, open: boolean) => { + if (!open) { + set(filePreviewPathAtom, null) + } + } +) + +// ============================================================================ +// Code Editor State +// ============================================================================ + +// Editor mode: "view" for read-only preview, "edit" for Monaco editor +export type EditorMode = "view" | "edit" +export const editorModeAtom = atom("view") + +// Whether current file has unsaved changes +export const editorDirtyAtom = atom(false) + +// Original content for dirty comparison (set when entering edit mode) +export const editorOriginalContentAtom = atom("") + +// Current editor content (synced with Monaco) +export const editorContentAtom = atom("") + +// Computed: reset editor state when preview closes +export const resetEditorStateAtom = atom(null, (_get, set) => { + set(editorModeAtom, "view") + set(editorDirtyAtom, false) + set(editorOriginalContentAtom, "") + set(editorContentAtom, "") +}) diff --git a/src/renderer/features/cowork/file-preview/audio-preview.tsx b/src/renderer/features/cowork/file-preview/audio-preview.tsx new file mode 100644 index 00000000..e8f496c5 --- /dev/null +++ b/src/renderer/features/cowork/file-preview/audio-preview.tsx @@ -0,0 +1,73 @@ +import { useState } from "react" +import { Music, VolumeX, Loader2 } from "lucide-react" +import { cn } from "../../../lib/utils" + +interface AudioPreviewProps { + filePath: string + className?: string +} + +export function AudioPreview({ filePath, className }: AudioPreviewProps) { + const [isLoading, setIsLoading] = useState(true) + const [hasError, setHasError] = useState(false) + + // Use local-file:// protocol for streaming audio access + // Format: local-file://localhost/ + // Ensure path starts with / for proper URL format (Windows paths like D:\... need leading /) + const normalizedPath = filePath.startsWith("/") ? filePath : `/${filePath}` + const fileUrl = `local-file://localhost${normalizedPath}` + // Use cross-platform path split + const fileName = filePath.split(/[\\/]/).pop() || filePath + + const handleLoadedData = () => { + setIsLoading(false) + setHasError(false) + } + + const handleError = () => { + console.error("[AudioPreview] Failed to load:", fileUrl) + setIsLoading(false) + setHasError(true) + } + + if (hasError) { + return ( +
+ +

Unable to play audio

+

{filePath}

+
+ ) + } + + return ( +
+ {/* Album art placeholder */} +
+ +
+ + {/* File name */} +

+ {fileName} +

+ + {/* Audio player */} +
+ {isLoading && ( +
+ +
+ )} +
+
+ ) +} diff --git a/src/renderer/features/cowork/file-preview/code-editor.tsx b/src/renderer/features/cowork/file-preview/code-editor.tsx new file mode 100644 index 00000000..deee8052 --- /dev/null +++ b/src/renderer/features/cowork/file-preview/code-editor.tsx @@ -0,0 +1,454 @@ +import { useCallback, useEffect, useRef } from "react" +import Editor, { OnMount, OnChange, loader } from "@monaco-editor/react" +import * as monaco from "monaco-editor" +import { useAtom, useSetAtom } from "jotai" +import { cn } from "../../../lib/utils" +import { trpc } from "../../../lib/trpc" +import { + editorDirtyAtom, + editorOriginalContentAtom, + editorContentAtom, +} from "../atoms" +import { Loader2 } from "lucide-react" + +// LSP is optional - stub it out if not available +// TODO: Import real useLSPClient when LSP module is ported +const useLSPClient = (_opts: { filePath: string; language: string; enabled: boolean }): { + isConnected: boolean + sendDidOpen: (content: string) => Promise + sendDidChange: (content: string) => Promise + sendDidClose: () => Promise + registerProviders: (monaco: any, editor: any) => () => void +} => ({ + isConnected: false, + sendDidOpen: async () => {}, + sendDidChange: async () => {}, + sendDidClose: async () => {}, + registerProviders: () => () => {}, +}) + +// Configure Monaco workers for Electron environment +import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker" +import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker" +import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker" +import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker" +import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker" + +// Set up Monaco environment for workers BEFORE loader.config +// @ts-ignore - Monaco global environment setup +self.MonacoEnvironment = { + getWorker(_: any, label: string) { + if (label === "json") { + return new jsonWorker() + } + if (label === "css" || label === "scss" || label === "less") { + return new cssWorker() + } + if (label === "html" || label === "handlebars" || label === "razor") { + return new htmlWorker() + } + if (label === "typescript" || label === "javascript") { + return new tsWorker() + } + return new editorWorker() + }, +} + +// Configure @monaco-editor/react to use locally installed monaco-editor +// This prevents CDN loading which is blocked by CSP +loader.config({ monaco }) + +interface CodeEditorProps { + filePath: string + content: string + language?: string + className?: string + onSave?: () => void + onDirtyChange?: (dirty: boolean) => void +} + +// Map file extensions to Monaco language identifiers +function getLanguageFromFileName(fileName: string): string { + const ext = fileName.split(".").pop()?.toLowerCase() || "" + + const langMap: Record = { + // JavaScript/TypeScript + js: "javascript", + jsx: "javascript", + ts: "typescript", + tsx: "typescript", + mjs: "javascript", + cjs: "javascript", + + // Web + html: "html", + htm: "html", + css: "css", + scss: "scss", + sass: "scss", + less: "less", + vue: "html", + svelte: "html", + + // Data formats + json: "json", + jsonc: "json", + json5: "json", + yaml: "yaml", + yml: "yaml", + toml: "toml", + xml: "xml", + csv: "plaintext", + + // Shell/Scripts + sh: "shell", + bash: "shell", + zsh: "shell", + fish: "shell", + ps1: "powershell", + bat: "bat", + cmd: "bat", + + // Python + py: "python", + pyw: "python", + pyi: "python", + + // Other languages + rb: "ruby", + php: "php", + java: "java", + kt: "kotlin", + kts: "kotlin", + swift: "swift", + go: "go", + rs: "rust", + c: "c", + h: "c", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + hpp: "cpp", + cs: "csharp", + fs: "fsharp", + scala: "scala", + clj: "clojure", + ex: "elixir", + exs: "elixir", + lua: "lua", + r: "r", + dart: "dart", + sql: "sql", + graphql: "graphql", + gql: "graphql", + + // Config files + dockerfile: "dockerfile", + makefile: "makefile", + ini: "ini", + conf: "ini", + env: "shell", + + // Markdown/Docs + md: "markdown", + mdx: "markdown", + + // Misc + diff: "diff", + patch: "diff", + log: "plaintext", + txt: "plaintext", + } + + // Handle special filenames - use cross-platform path split + const baseName = fileName.split(/[\\/]/).pop() || fileName + const specialFiles: Record = { + Dockerfile: "dockerfile", + Makefile: "makefile", + Gemfile: "ruby", + Rakefile: "ruby", + Podfile: "ruby", + } + + if (specialFiles[baseName]) { + return specialFiles[baseName] + } + + return langMap[ext] || "plaintext" +} + +/** + * CodeEditor - Monaco Editor wrapper for code editing + * + * Features: + * - Syntax highlighting for 50+ languages + * - Cmd+S / Ctrl+S to save + * - Dirty state tracking + * - Dark theme matching the app + * - LSP integration for TS/JS (completions, hover, diagnostics) + */ +export function CodeEditor({ + filePath, + content: initialContent, + language: explicitLanguage, + className, + onSave, + onDirtyChange, +}: CodeEditorProps) { + const editorRef = useRef(null) + const monacoRef = useRef(null) + const lspCleanupRef = useRef<(() => void) | null>(null) + + const [isDirty, setIsDirty] = useAtom(editorDirtyAtom) + const setOriginalContent = useSetAtom(editorOriginalContentAtom) + const [editorContent, setEditorContent] = useAtom(editorContentAtom) + + // File save mutation + const saveFileMutation = trpc.files.writeFile.useMutation() + + // Determine language from file extension or explicit prop + // Use cross-platform path split + const fileName = filePath.split(/[\\/]/).pop() || filePath + const language = explicitLanguage || getLanguageFromFileName(fileName) + + // LSP client for TypeScript/JavaScript + const { + isConnected: lspConnected, + sendDidOpen, + sendDidChange, + sendDidClose, + registerProviders, + } = useLSPClient({ + filePath, + language, + enabled: true, + }) + + // Initialize original content when component mounts or file changes + const initialContentRef = useRef(initialContent) + useEffect(() => { + // Only reset if content actually changed (new file) + if (initialContentRef.current !== initialContent) { + initialContentRef.current = initialContent + setOriginalContent(initialContent) + setEditorContent(initialContent) + setIsDirty(false) + } + }, [initialContent, setOriginalContent, setEditorContent, setIsDirty]) + + // Set initial content on mount + useEffect(() => { + setOriginalContent(initialContent) + setEditorContent(initialContent) + setIsDirty(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Handle editor mount + const handleEditorMount: OnMount = useCallback( + (editor, monaco) => { + editorRef.current = editor + monacoRef.current = monaco + + // Register Cmd+S / Ctrl+S save command + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { + handleSave() + }) + + // Register LSP providers if connected + if (lspConnected) { + // Clean up previous providers + if (lspCleanupRef.current) { + lspCleanupRef.current() + } + // Register new providers + lspCleanupRef.current = registerProviders(monaco, editor) + // Notify LSP server that file is open + sendDidOpen(initialContent) + } + + // Focus the editor + editor.focus() + }, + [filePath, lspConnected, registerProviders, sendDidOpen, initialContent] + ) + + // Update LSP providers when connection state changes + const lspInitializedRef = useRef(false) + // Reset LSP initialized state when file changes + const prevFilePathRef = useRef(filePath) + useEffect(() => { + if (prevFilePathRef.current !== filePath) { + prevFilePathRef.current = filePath + lspInitializedRef.current = false + } + }, [filePath]) + + useEffect(() => { + if (editorRef.current && monacoRef.current && lspConnected && !lspInitializedRef.current) { + lspInitializedRef.current = true + // Clean up previous providers + if (lspCleanupRef.current) { + lspCleanupRef.current() + } + // Register new providers + lspCleanupRef.current = registerProviders(monacoRef.current, editorRef.current) + // Notify LSP server that file is open + sendDidOpen(initialContentRef.current) + } + + return () => { + // Clean up providers on unmount + if (lspCleanupRef.current) { + lspCleanupRef.current() + lspCleanupRef.current = null + } + // Reset initialized flag so it can reinitialize on next mount + lspInitializedRef.current = false + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lspConnected]) + + // Notify LSP on content change (debounced) + const lspUpdateTimeoutRef = useRef(null) + const sendDidChangeRef = useRef(sendDidChange) + sendDidChangeRef.current = sendDidChange + + useEffect(() => { + if (!lspConnected || !editorContent) return + + // Clear previous timeout + if (lspUpdateTimeoutRef.current) { + clearTimeout(lspUpdateTimeoutRef.current) + } + + // Debounce LSP updates + lspUpdateTimeoutRef.current = setTimeout(() => { + sendDidChangeRef.current(editorContent) + }, 300) + + return () => { + if (lspUpdateTimeoutRef.current) { + clearTimeout(lspUpdateTimeoutRef.current) + } + } + }, [editorContent, lspConnected]) + + // Clean up LSP on unmount + const sendDidCloseRef = useRef(sendDidClose) + sendDidCloseRef.current = sendDidClose + + useEffect(() => { + return () => { + sendDidCloseRef.current() + } + }, []) + + // Handle content changes + const handleChange: OnChange = useCallback( + (value) => { + if (value !== undefined) { + setEditorContent(value) + const dirty = value !== initialContent + setIsDirty(dirty) + onDirtyChange?.(dirty) + } + }, + [initialContent, setEditorContent, setIsDirty, onDirtyChange] + ) + + // Handle save + const handleSave = useCallback(async () => { + if (!isDirty || !editorContent) return + + try { + await saveFileMutation.mutateAsync({ + path: filePath, + content: editorContent, + }) + + // Update original content to current content + setOriginalContent(editorContent) + setIsDirty(false) + onDirtyChange?.(false) + onSave?.() + + console.log("[CodeEditor] File saved:", filePath) + } catch (error) { + console.error("[CodeEditor] Failed to save file:", error) + // TODO: Show error toast + } + }, [ + filePath, + editorContent, + isDirty, + saveFileMutation, + setOriginalContent, + setIsDirty, + onDirtyChange, + onSave, + ]) + + // Expose save function for external use + useEffect(() => { + // Store save function on window for dialog to call + ;(window as any).__codeEditorSave = handleSave + return () => { + delete (window as any).__codeEditorSave + } + }, [handleSave]) + + return ( +
+ + +
+ } + options={{ + fontSize: 13, + fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, monospace", + fontLigatures: true, + minimap: { enabled: false }, + lineNumbers: "on", + scrollBeyondLastLine: false, + wordWrap: "on", + automaticLayout: true, + tabSize: 2, + insertSpaces: true, + padding: { top: 16, bottom: 16 }, + renderWhitespace: "selection", + bracketPairColorization: { enabled: true }, + guides: { + bracketPairs: true, + indentation: true, + }, + smoothScrolling: true, + cursorBlinking: "smooth", + cursorSmoothCaretAnimation: "on", + // Accessibility + accessibilitySupport: "auto", + // Performance + renderValidationDecorations: "on", + // Scroll + scrollbar: { + vertical: "auto", + horizontal: "auto", + verticalScrollbarSize: 10, + horizontalScrollbarSize: 10, + }, + }} + /> + + ) +} + +// Export language detection helper for use elsewhere +export { getLanguageFromFileName } diff --git a/src/renderer/features/cowork/file-preview/excel-preview.tsx b/src/renderer/features/cowork/file-preview/excel-preview.tsx new file mode 100644 index 00000000..f463b306 --- /dev/null +++ b/src/renderer/features/cowork/file-preview/excel-preview.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from "react" +import { Table2, Loader2 } from "lucide-react" +import { cn } from "../../../lib/utils" +import { trpc } from "../../../lib/trpc" + +interface ExcelPreviewProps { + filePath: string + className?: string +} + +interface SheetData { + name: string + data: string[][] +} + +export function ExcelPreview({ filePath, className }: ExcelPreviewProps) { + const [sheets, setSheets] = useState([]) + const [activeSheet, setActiveSheet] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const [hasError, setHasError] = useState(false) + const [errorMessage, setErrorMessage] = useState("") + + // Read file as binary via tRPC + const { data, error: fetchError } = trpc.files.readBinaryFile.useQuery( + { path: filePath, maxSize: 50 * 1024 * 1024 }, // 50MB max + { staleTime: 30000 } + ) + + useEffect(() => { + if (fetchError) { + setHasError(true) + setErrorMessage(fetchError.message) + setIsLoading(false) + return + } + + if (!data) return + + const parseExcel = async () => { + try { + // Dynamically import xlsx + const XLSX = await import("xlsx") + + // Convert base64 to ArrayBuffer + const binaryString = atob(data.base64) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + + // Parse the workbook + const workbook = XLSX.read(bytes, { type: "array" }) + + // Convert each sheet to array of arrays + const parsedSheets: SheetData[] = workbook.SheetNames.map((name) => { + const sheet = workbook.Sheets[name] + const data = XLSX.utils.sheet_to_json(sheet, { header: 1 }) + return { name, data: data as string[][] } + }) + + setSheets(parsedSheets) + setIsLoading(false) + setHasError(false) + } catch (err) { + console.error("[ExcelPreview] Failed to parse:", err) + setHasError(true) + setErrorMessage(err instanceof Error ? err.message : "Parse failed") + setIsLoading(false) + } + } + + parseExcel() + }, [data, fetchError]) + + if (hasError) { + return ( +
+ +

Unable to preview Excel file

+ {errorMessage && ( +

+ {errorMessage} +

+ )} +
+ ) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + const currentSheet = sheets[activeSheet] + + return ( +
+ {/* Sheet tabs */} + {sheets.length > 1 && ( +
+ {sheets.map((sheet, index) => ( + + ))} +
+ )} + + {/* Table */} +
+ {currentSheet && currentSheet.data.length > 0 ? ( + + + {currentSheet.data.map((row, rowIndex) => ( + + {/* Row number */} + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {rowIndex + 1} + + {cell ?? ""} +
+ ) : ( +
+

Empty sheet

+
+ )} +
+
+ ) +} diff --git a/src/renderer/features/cowork/file-preview/file-preview-dialog.tsx b/src/renderer/features/cowork/file-preview/file-preview-dialog.tsx new file mode 100644 index 00000000..072860d5 --- /dev/null +++ b/src/renderer/features/cowork/file-preview/file-preview-dialog.tsx @@ -0,0 +1,423 @@ +import { useAtom, useSetAtom } from "jotai" +import { useCallback, useState } from "react" +import { X, ExternalLink, Maximize2, Minimize2, Pencil, Save, Eye } from "lucide-react" +import { cn } from "../../../lib/utils" +import { isMacOS } from "../../../lib/utils/platform" +import { Button } from "../../../components/ui/button" +import { Dialog, DialogContent, DialogTitle, DialogDescription } from "../../../components/ui/dialog" +import { VisuallyHidden } from "@radix-ui/react-visually-hidden" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../../../components/ui/alert-dialog" +import { getFileIconByExtension } from "../../agents/mentions/agents-file-mention" +import { FilePreview } from "./file-preview" +import { trpc } from "../../../lib/trpc" +import { + filePreviewPathAtom, + filePreviewOpenAtom, + filePreviewDisplayModeAtom, + filePreviewLineAtom, + filePreviewHighlightAtom, + editorModeAtom, + editorDirtyAtom, + resetEditorStateAtom, +} from "../atoms" + +// File types that support editing +const EDITABLE_EXTENSIONS = new Set([ + // JavaScript/TypeScript + "js", "jsx", "ts", "tsx", "mjs", "cjs", + // Web + "css", "scss", "sass", "less", "vue", "svelte", + // Data formats + "json", "jsonc", "json5", "yaml", "yml", "toml", "xml", + // Shell/Scripts + "sh", "bash", "zsh", "fish", "ps1", "bat", "cmd", + // Python + "py", "pyw", "pyi", + // Other languages + "rb", "php", "java", "kt", "kts", "swift", "go", "rs", + "c", "h", "cpp", "cc", "cxx", "hpp", "cs", "fs", + "scala", "clj", "ex", "exs", "lua", "r", "dart", "sql", + // Config files + "ini", "conf", "env", "gitignore", "editorconfig", + // Docs + "md", "mdx", "txt", "rst", +]) + +function isFileEditable(fileName: string): boolean { + const ext = fileName.split(".").pop()?.toLowerCase() || "" + return EDITABLE_EXTENSIONS.has(ext) +} + +interface FilePreviewDialogProps { + className?: string +} + +export function FilePreviewDialog({ className }: FilePreviewDialogProps) { + const [open, setOpen] = useAtom(filePreviewOpenAtom) + const [filePath, setFilePath] = useAtom(filePreviewPathAtom) + const [displayMode, setDisplayMode] = useAtom(filePreviewDisplayModeAtom) + const [scrollToLine, setScrollToLine] = useAtom(filePreviewLineAtom) + const [highlightText, setHighlightText] = useAtom(filePreviewHighlightAtom) + const [editorMode, setEditorMode] = useAtom(editorModeAtom) + const [isDirty, setIsDirty] = useAtom(editorDirtyAtom) + const resetEditorState = useSetAtom(resetEditorStateAtom) + + const [showUnsavedDialog, setShowUnsavedDialog] = useState(false) + const [pendingCloseAction, setPendingCloseAction] = useState<(() => void) | null>(null) + + // Use cross-platform path split + const pathParts = filePath?.split(/[\\/]/) || [] + const fileName = pathParts.pop() || "" + const dirPath = pathParts.join("/") || "" + const FileIcon = fileName ? (getFileIconByExtension(fileName) ?? null) : null + const canEdit = fileName ? isFileEditable(fileName) : false + const isEditing = editorMode === "edit" + + // Handle close with unsaved changes check + const handleClose = useCallback(() => { + if (isDirty) { + setPendingCloseAction(() => () => { + resetEditorState() + setFilePath(null) + setScrollToLine(null) + setHighlightText(null) + }) + setShowUnsavedDialog(true) + } else { + resetEditorState() + setFilePath(null) + setScrollToLine(null) + setHighlightText(null) + } + }, [isDirty, resetEditorState, setFilePath, setScrollToLine, setHighlightText]) + + // Toggle between view and edit mode + const handleToggleEdit = useCallback(() => { + if (isEditing && isDirty) { + // Switching from edit to view with unsaved changes + setPendingCloseAction(() => () => { + setEditorMode("view") + setIsDirty(false) + }) + setShowUnsavedDialog(true) + } else { + setEditorMode(isEditing ? "view" : "edit") + } + }, [isEditing, isDirty, setEditorMode, setIsDirty]) + + // Handle save + const handleSave = useCallback(() => { + // Call the save function exposed by CodeEditor + const saveFunc = (window as any).__codeEditorSave + if (saveFunc) { + saveFunc() + } + }, []) + + // Handle unsaved dialog actions + const handleDiscardChanges = useCallback(() => { + setShowUnsavedDialog(false) + setIsDirty(false) + if (pendingCloseAction) { + pendingCloseAction() + setPendingCloseAction(null) + } + }, [pendingCloseAction, setIsDirty]) + + const handleSaveAndClose = useCallback(async () => { + setShowUnsavedDialog(false) + handleSave() + // Wait a bit for save to complete, then execute pending action + setTimeout(() => { + if (pendingCloseAction) { + pendingCloseAction() + setPendingCloseAction(null) + } + }, 100) + }, [pendingCloseAction, handleSave]) + + const handleCancelClose = useCallback(() => { + setShowUnsavedDialog(false) + setPendingCloseAction(null) + }, []) + + const openInFinderMutation = trpc.external.openInFinder.useMutation() + + const handleOpenExternal = () => { + if (filePath) { + openInFinderMutation.mutate(filePath) + } + } + + const handleToggleFullscreen = () => { + setDisplayMode(displayMode === "full-page" ? "dialog" : "full-page") + } + + // Callback when dirty state changes in editor + const handleDirtyChange = useCallback((dirty: boolean) => { + setIsDirty(dirty) + }, [setIsDirty]) + + // Callback when file is saved + const handleFileSaved = useCallback(() => { + // Optionally show a toast or notification + console.log("[FilePreviewDialog] File saved") + }, []) + + if (!filePath) return null + + const isMac = isMacOS() + + // Edit/Save button component + const EditSaveButton = () => { + if (!canEdit) return null + + if (isEditing) { + return ( + <> + + + + + ) + } + + return ( + + ) + } + + // Unsaved changes dialog + const UnsavedChangesDialog = () => ( + + + + Unsaved Changes + + You have unsaved changes. Do you want to save them before closing? + + + + Cancel + + Discard + + Save + + + + ) + + // Full page mode + if (displayMode === "full-page") { + return ( +
+ {/* Header */} +
+ {/* macOS: Left side has close + fullscreen buttons */} + {isMac && ( +
+ + + +
+ )} + + {/* File info - center on macOS, left on Windows */} +
+ {FileIcon && } + {fileName} + {dirPath && ( + + {dirPath} + + )} +
+ + {/* macOS: Right side has edit/save + "Show in Finder" */} + {isMac ? ( +
+ + +
+ ) : ( + /* Windows: Right side has all buttons - edit/save, external, fullscreen, close */ +
+ + + + + + +
+ )} +
+ + {/* Content */} +
+ +
+ + +
+ ) + } + + // Dialog mode (default) + return ( + !isOpen && handleClose()}> + + {/* Accessibility: Hidden title for screen readers */} + + File Preview: {fileName} + + + {/* Header */} +
+ {/* macOS: Left side has close + fullscreen buttons */} + {isMac && ( +
+ + + +
+ )} + + {/* File info - center on macOS, left on Windows */} +
+ {FileIcon && } + {fileName} + {dirPath && ( + + {dirPath} + + )} +
+ + {/* macOS: Right side has edit/save + "Show in Finder" */} + {isMac ? ( +
+ + +
+ ) : ( + /* Windows: Right side has all buttons - edit/save, external, fullscreen, close */ +
+ + + + + + +
+ )} +
+ + {/* Content */} +
+ +
+ + +
+
+ ) +} + +// Hook to open file preview +export function useFilePreview() { + const [, setFilePath] = useAtom(filePreviewPathAtom) + + return { + openPreview: (path: string) => setFilePath(path), + closePreview: () => setFilePath(null), + } +} diff --git a/src/renderer/features/cowork/file-preview/file-preview.tsx b/src/renderer/features/cowork/file-preview/file-preview.tsx new file mode 100644 index 00000000..faa30c68 --- /dev/null +++ b/src/renderer/features/cowork/file-preview/file-preview.tsx @@ -0,0 +1,224 @@ +import { useMemo } from "react" +import { FileQuestion, Loader2 } from "lucide-react" +import { cn } from "../../../lib/utils" +import { trpc } from "../../../lib/trpc" +import { TextPreview } from "./text-preview" +import { MarkdownPreview } from "./markdown-preview" +import { ImagePreview } from "./image-preview" +import { PdfPreview } from "./pdf-preview" +import { VideoPreview } from "./video-preview" +import { AudioPreview } from "./audio-preview" +import { WordPreview } from "./word-preview" +import { ExcelPreview } from "./excel-preview" +import { PptPreview } from "./ppt-preview" +import { HtmlPreview } from "./html-preview" + +interface FilePreviewProps { + filePath: string + className?: string + /** Enable editing mode for text files */ + editable?: boolean + /** Callback when file is saved */ + onSave?: () => void + /** Callback when dirty state changes */ + onDirtyChange?: (dirty: boolean) => void + /** Line number to scroll to (for text files) */ + scrollToLine?: number | null + /** Text to highlight in the content (for text files) */ + highlightText?: string | null +} + +type FileType = "text" | "markdown" | "html" | "image" | "pdf" | "video" | "audio" | "word" | "excel" | "ppt" | "unsupported" + +// Determine file type from extension +function getFileType(fileName: string): FileType { + const ext = fileName.split(".").pop()?.toLowerCase() || "" + + // Markdown files + if (["md", "mdx", "markdown"].includes(ext)) { + return "markdown" + } + + // HTML files + if (["html", "htm"].includes(ext)) { + return "html" + } + + // Image files + if (["png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "avif", "tiff", "heic", "heif"].includes(ext)) { + return "image" + } + + // PDF files + if (ext === "pdf") { + return "pdf" + } + + // Video files + if (["mp4", "webm", "mov", "avi", "mkv", "m4v", "ogv", "3gp"].includes(ext)) { + return "video" + } + + // Audio files + if (["mp3", "wav", "ogg", "flac", "m4a", "aac", "wma", "opus", "aiff"].includes(ext)) { + return "audio" + } + + // Word documents + if (["docx", "doc"].includes(ext)) { + return "word" + } + + // Excel spreadsheets + if (["xlsx", "xls", "xlsm", "xlsb"].includes(ext)) { + return "excel" + } + + // PowerPoint presentations + if (["pptx", "ppt"].includes(ext)) { + return "ppt" + } + + // Text/code files + const textExtensions = [ + // JavaScript/TypeScript + "js", "jsx", "ts", "tsx", "mjs", "cjs", + // Web + "css", "scss", "sass", "less", "vue", "svelte", + // Data formats + "json", "jsonc", "json5", "yaml", "yml", "toml", "xml", "csv", + // Shell/Scripts + "sh", "bash", "zsh", "fish", "ps1", "bat", "cmd", + // Python + "py", "pyw", "pyi", + // Other languages + "rb", "php", "java", "kt", "kts", "swift", "go", "rs", + "c", "h", "cpp", "cc", "cxx", "hpp", "cs", "fs", + "scala", "clj", "ex", "exs", "erl", "hs", "lua", + "r", "jl", "dart", "zig", "nim", "v", "d", "ml", + "sql", "graphql", "gql", + // Config files + "dockerfile", "makefile", "cmake", "gradle", "tf", "hcl", + "ini", "conf", "env", "gitignore", "editorconfig", + // Docs + "rst", "tex", "adoc", + // Misc + "diff", "patch", "log", "txt", + ] + + if (textExtensions.includes(ext)) { + return "text" + } + + // Check for common text file names without extensions + // Use cross-platform path split + const baseName = fileName.split(/[\\/]/).pop()?.toLowerCase() || "" + const textFileNames = [ + "dockerfile", "makefile", "cmakelists", "gemfile", "rakefile", + "podfile", "fastfile", "vagrantfile", "brewfile", "readme", + "license", "changelog", "contributing", "authors", "todo", + ] + + if (textFileNames.some((name) => baseName.startsWith(name))) { + return "text" + } + + return "unsupported" +} + +export function FilePreview({ filePath, className, editable = false, onSave, onDirtyChange, scrollToLine, highlightText }: FilePreviewProps) { + // Use cross-platform path split for fileName + const fileName = filePath.split(/[\\/]/).pop() || filePath + const fileType = useMemo(() => getFileType(fileName), [fileName]) + + // Read file content for text-based previews only + const { data: content, isLoading, error } = trpc.files.readFile.useQuery( + { path: filePath }, + { + enabled: fileType === "text" || fileType === "markdown" || fileType === "html", + staleTime: 30000, + } + ) + + // Loading state (only for text-based files that need tRPC) + if (isLoading && (fileType === "text" || fileType === "markdown" || fileType === "html")) { + return ( +
+ +
+ ) + } + + // Error state (only for text-based files) + if (error && (fileType === "text" || fileType === "markdown" || fileType === "html")) { + return ( +
+ +

Unable to read file

+

{error.message}

+
+ ) + } + + // Render appropriate preview based on file type + switch (fileType) { + case "markdown": + return ( + + ) + + case "html": + return + + case "text": + return ( + + ) + + case "image": + return + + case "pdf": + return + + case "video": + return + + case "audio": + return + + case "word": + return + + case "excel": + return + + case "ppt": + return + + case "unsupported": + default: + return ( +
+ +

Preview not supported for this file type

+

{fileName}

+
+ ) + } +} diff --git a/src/renderer/features/cowork/file-preview/html-preview.tsx b/src/renderer/features/cowork/file-preview/html-preview.tsx new file mode 100644 index 00000000..c5a7efbb --- /dev/null +++ b/src/renderer/features/cowork/file-preview/html-preview.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState, useRef } from "react" +import { codeToHtml } from "shiki" +import { cn } from "../../../lib/utils" +import { Loader2, Code, Eye } from "lucide-react" + +interface HtmlPreviewProps { + content: string + fileName: string + className?: string +} + +type TabType = "preview" | "source" + +export function HtmlPreview({ content, fileName, className }: HtmlPreviewProps) { + const [activeTab, setActiveTab] = useState("preview") + const [highlightedHtml, setHighlightedHtml] = useState("") + const [isHighlighting, setIsHighlighting] = useState(false) + const iframeRef = useRef(null) + + // Syntax highlight for source view + useEffect(() => { + if (activeTab !== "source") return + + let cancelled = false + setIsHighlighting(true) + + async function highlight() { + try { + const result = await codeToHtml(content, { + lang: "html", + theme: "github-dark-default", + }) + + if (!cancelled) { + setHighlightedHtml(result) + } + } catch (err) { + console.error("Syntax highlighting error:", err) + if (!cancelled) { + // Fallback to plain text + setHighlightedHtml( + `
${content
+              .replace(/&/g, "&")
+              .replace(//g, ">")}
` + ) + } + } finally { + if (!cancelled) { + setIsHighlighting(false) + } + } + } + + highlight() + + return () => { + cancelled = true + } + }, [content, activeTab]) + + // Update iframe content when switching to preview + useEffect(() => { + if (activeTab !== "preview" || !iframeRef.current) return + + const iframe = iframeRef.current + const doc = iframe.contentDocument || iframe.contentWindow?.document + if (doc) { + doc.open() + doc.write(content) + doc.close() + } + }, [content, activeTab]) + + const tabs: { id: TabType; label: string; icon: React.ReactNode }[] = [ + { id: "preview", label: "Preview", icon: }, + { id: "source", label: "Source", icon: }, + ] + + return ( +
+ {/* Tab bar */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Content */} +
+ {activeTab === "preview" ? ( +