diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts index a1fb400e6..844f0bbd2 100644 --- a/apps/server/src/workspaceEntries.ts +++ b/apps/server/src/workspaceEntries.ts @@ -686,6 +686,23 @@ async function mapWithConcurrency( return results; } +const ENV_FILE_PATTERN = /^\.env(\..+)?$/; + +/** + * Discover .env* files at the workspace root so they appear in the file tree + * even when gitignored. Only root-level env files are included. + */ +async function discoverEnvFiles(cwd: string): Promise { + try { + const dirEntries = await fs.readdir(cwd, { withFileTypes: true }); + return dirEntries + .filter((entry) => entry.isFile() && ENV_FILE_PATTERN.test(entry.name)) + .map((entry) => entry.name); + } catch { + return []; + } +} + async function isInsideGitWorkTree(cwd: string): Promise { const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], { cwd, @@ -799,6 +816,14 @@ async function buildWorkspaceIndexFromGit(cwd: string): Promise entry.length > 0 && !isPathInIgnoredDirectory(entry)); const filePaths = await filterGitIgnoredPaths(cwd, listedPaths); + // Include .env* files that may be gitignored so they appear in the file tree + const envFiles = await discoverEnvFiles(cwd); + for (const envPath of envFiles) { + if (!filePaths.includes(envPath)) { + filePaths.push(envPath); + } + } + const directorySet = new Set(); for (const filePath of filePaths) { for (const directoryPath of directoryAncestorsOf(filePath)) { diff --git a/apps/web/src/components/CodeViewerPanel.tsx b/apps/web/src/components/CodeViewerPanel.tsx index 2829470fc..f2bc6160f 100644 --- a/apps/web/src/components/CodeViewerPanel.tsx +++ b/apps/web/src/components/CodeViewerPanel.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; -import { FileCodeIcon, XIcon } from "lucide-react"; -import { memo, useCallback } from "react"; +import { EyeIcon, EyeOffIcon, FileCodeIcon, XIcon } from "lucide-react"; +import { memo, useCallback, useState } from "react"; import { useCodeViewerStore, type CodeViewerTab } from "~/codeViewerStore"; import { useTheme } from "~/hooks/useTheme"; @@ -13,6 +13,12 @@ import { MarkdownPreview } from "./MarkdownPreview"; import { isElectron } from "~/env"; import { Button } from "./ui/button"; +/** Check if a file path is a dotenv / secrets file whose values should be masked. */ +function isEnvFile(filePath: string): boolean { + const basename = filePath.split("/").pop() ?? filePath; + return /^\.env(\..*)?$/.test(basename); +} + export function CodeViewerTabStrip(props: { tabs: CodeViewerTab[]; activeTabPath: string | null; @@ -66,6 +72,9 @@ export const CodeViewerFileContent = memo(function CodeViewerFileContent(props: resolvedTheme: "light" | "dark"; onAddContext: (ctx: CodeContextSelection) => void; }) { + const envFile = isEnvFile(props.relativePath); + const [envValuesRevealed, setEnvValuesRevealed] = useState(false); + const query = useQuery( projectReadFileQueryOptions({ cwd: props.cwd, @@ -109,22 +118,59 @@ export const CodeViewerFileContent = memo(function CodeViewerFileContent(props: } return ( -
+
{query.data.truncated && (
File is larger than 1MB. Showing truncated content.
)} - {isMarkdownPreviewFilePath(props.relativePath) ? ( - - ) : ( - + + {/* Env file: show/hide toggle banner */} + {envFile && ( +
+ + Sensitive file — values are hidden by default + + +
)} + +
+ {isMarkdownPreviewFilePath(props.relativePath) ? ( + + ) : ( + + )} +
); });