From eaf5ccda96e3caa597f3627ffe4ef4d4cee20493 Mon Sep 17 00:00:00 2001 From: Daniel Gordon Date: Wed, 15 Jul 2026 14:30:38 +0000 Subject: [PATCH] feat(web): show dotfiles in the file explorer and allow changing its root (#33) The file explorer hid dotfiles and could only ever be rooted at the project workspace, so files like .env / .gitignore / .github/* and anything under the home directory were invisible and un-editable. - Show dotfiles by default, gated by a per-device "Show dotfiles" toggle on the Features settings page (default ON). The web client always sends the flag; the server treats an omitted flag as off, so mobile behavior is unchanged. - Server-side dotfile discovery: a bounded breadth-first walk supplements the native finder results, recursing into discovered dot-directories (e.g. .github/workflows/ci.yml) while skipping .git and node_modules and honoring the existing entry cap. - Add a root bar to the explorer (edit / Apply / Home (~) / Reset to project). The selected root flows through listing, reading and writing; ~ is expanded everywhere the root is consumed without weakening path containment. - Bind each open file to the root it was opened from so re-rooting, panel remounts and project-relative chat links resolve the correct file. Co-Authored-By: Claude Opus 4.8 --- apps/server/src/workspace/WorkspaceEntries.ts | 17 +--- .../src/workspace/WorkspaceFileSystem.ts | 10 ++- .../src/workspace/WorkspacePaths.test.ts | 23 ++++++ apps/server/src/workspace/WorkspacePaths.ts | 7 +- .../workspace/WorkspaceSearchIndex.test.ts | 43 ++++++++++ .../src/workspace/WorkspaceSearchIndex.ts | 79 ++++++++++++++++++- apps/web/src/components/ChatView.tsx | 12 +-- .../src/components/files/FileBrowserPanel.tsx | 71 +++++++++++++++-- .../src/components/files/FilePreviewPanel.tsx | 63 ++++++++++++--- .../files/projectFilesQueryState.ts | 11 ++- .../components/settings/FeaturesSettings.tsx | 27 ++++++- apps/web/src/rightPanelStore.test.ts | 32 ++++++++ apps/web/src/rightPanelStore.ts | 15 +++- packages/contracts/src/project.ts | 1 + packages/contracts/src/settings.ts | 2 + 15 files changed, 357 insertions(+), 56 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 26c7f7b3627..59476f9add0 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -1,6 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -152,16 +151,6 @@ function isMissingPathError(cause: unknown): boolean { return code === "ENOENT" || code === "ENOTDIR"; } -function expandHomePath(input: string, path: Path.Path): string { - if (input === "~") { - return NodeOS.homedir(); - } - if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(NodeOS.homedir(), input.slice(2)); - } - return input; -} - const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* ( input: FilesystemBrowseInput, path: Path.Path, @@ -176,7 +165,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu } if (!isExplicitRelativePath(input.partialPath)) { - return path.resolve(expandHomePath(input.partialPath, path)); + return path.resolve(WorkspacePaths.expandHomePath(input.partialPath, path)); } if (!input.cwd) { @@ -184,7 +173,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu partialPath: input.partialPath, }); } - return path.resolve(expandHomePath(input.cwd, path), input.partialPath); + return path.resolve(WorkspacePaths.expandHomePath(input.cwd, path), input.partialPath); }); export const make = Effect.gen(function* () { @@ -301,7 +290,7 @@ export const make = Effect.gen(function* () { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - return yield* searchIndex.list(); + return yield* searchIndex.list(input.showDotfiles ?? false); }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); }, ); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e2dc9cbbb39..39d6294ab90 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -135,19 +135,20 @@ export const make = Effect.gen(function* () { const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( "WorkspaceFileSystem.readFile", )(function* (input) { + const workspaceRoot = path.resolve(WorkspacePaths.expandHomePath(input.cwd.trim(), path)); const target = yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: input.cwd, + workspaceRoot, relativePath: input.relativePath, }); const realWorkspaceRoot = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(input.cwd), + try: () => NodeFSP.realpath(workspaceRoot), catch: (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: target.absolutePath, - operationPath: input.cwd, + operationPath: workspaceRoot, operation: "realpath-workspace-root", cause, }), @@ -262,8 +263,9 @@ export const make = Effect.gen(function* () { const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn( "WorkspaceFileSystem.writeFile", )(function* (input) { + const workspaceRoot = path.resolve(WorkspacePaths.expandHomePath(input.cwd.trim(), path)); const target = yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: input.cwd, + workspaceRoot, relativePath: input.relativePath, }); diff --git a/apps/server/src/workspace/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts index 4f3bc833b4c..0daaa0f1ec7 100644 --- a/apps/server/src/workspace/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/WorkspacePaths.test.ts @@ -1,4 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeOS from "node:os"; import { it, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -168,6 +170,27 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { }); describe("resolveRelativePathWithinRoot", () => { + it.effect("expands home-relative workspace roots", () => + Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const path = yield* Path.Path; + + const homeRoot = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: "~", + relativePath: "notes/todo.md", + }); + const homeSubdirectory = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: "~/projects", + relativePath: "README.md", + }); + + expect(homeRoot.absolutePath).toBe(path.join(NodeOS.homedir(), "notes/todo.md")); + expect(homeSubdirectory.absolutePath).toBe( + path.join(NodeOS.homedir(), "projects/README.md"), + ); + }), + ); + it.effect("resolves relative paths inside the workspace root", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 5acf6677cde..e3f7d819576 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -121,7 +121,7 @@ function toPosixRelativePath(input: string): string { return input.replaceAll("\\", "/"); } -function expandHomePath(input: string, path: Path.Path): string { +export function expandHomePath(input: string, path: Path.Path): string { if (input === "~") { return NodeOS.homedir(); } @@ -209,8 +209,9 @@ export const make = Effect.gen(function* () { }); } - const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath); - const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath)); + const workspaceRoot = path.resolve(expandHomePath(input.workspaceRoot.trim(), path)); + const absolutePath = path.resolve(workspaceRoot, normalizedInputPath); + const relativeToRoot = toPosixRelativePath(path.relative(workspaceRoot, absolutePath)); if ( relativeToRoot.length === 0 || relativeToRoot === "." || diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 9b7ed4e2453..1d366b59407 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,4 +1,8 @@ import { FileFinder } from "@ff-labs/fff-node"; +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { afterEach, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -157,3 +161,42 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => }), ), ); + +it.effect("walks dot-directories without descending into .git", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* Effect.acquireRelease( + Effect.tryPromise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-dotfiles-"))), + (directory) => + Effect.promise(() => NodeFSP.rm(directory, { recursive: true, force: true })), + ); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(NodePath.join(cwd, ".github", "workflows"), { recursive: true }); + await NodeFSP.mkdir(NodePath.join(cwd, ".git"), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(cwd, ".github", "workflows", "ci.yml"), "name: CI"); + await NodeFSP.writeFile(NodePath.join(cwd, ".git", "config"), "secret"); + await NodeFSP.writeFile(NodePath.join(cwd, ".env"), "TOKEN=test"); + }); + const finder = { + destroy: vi.fn(), + isScanning: vi.fn(() => false), + mixedSearch: vi.fn(() => ({ + ok: true, + value: { items: [], totalMatched: 0 }, + })), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make(cwd); + const result = yield* searchIndex.list(true); + + expect(result.entries).toEqual([ + { path: ".env", kind: "file" }, + { path: ".github", kind: "directory" }, + { path: ".github/workflows", kind: "directory" }, + { path: ".github/workflows/ci.yml", kind: "file" }, + ]); + expect(result.truncated).toBe(false); + }), + ), +); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index db4d46851e7..d0e95fe5f1b 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -1,4 +1,7 @@ import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node"; +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -17,6 +20,7 @@ const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis"; +const DOTFILE_SCAN_DENYLIST = new Set([".git", "node_modules"]); export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass()( "WorkspaceSearchIndexCreateFailed", @@ -92,7 +96,9 @@ export type WorkspaceSearchIndexError = export class WorkspaceSearchIndex extends Context.Service< WorkspaceSearchIndex, { - readonly list: () => Effect.Effect; + readonly list: ( + showDotfiles: boolean, + ) => Effect.Effect; readonly search: ( query: string, limit: number, @@ -169,6 +175,66 @@ function withDirectoryAncestors(entries: ReadonlyArray): ProjectEn return [...entryByPath.values()]; } +const supplementDotfiles = Effect.fn("WorkspaceSearchIndex.supplementDotfiles")(function* ( + cwd: string, + entries: ReadonlyArray, +) { + const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); + const queue = [ + { path: "", walkContents: false }, + ...entries + .filter((entry) => entry.kind === "directory") + .map((entry) => ({ path: entry.path, walkContents: false })), + ]; + const queuedPaths = new Set(queue.map((entry) => entry.path)); + let queueIndex = 0; + let truncated = false; + + while (queueIndex < queue.length && entryByPath.size < WORKSPACE_INDEX_MAX_ENTRIES) { + const batch = queue.slice(queueIndex, queueIndex + 16); + queueIndex += batch.length; + const batchEntries = yield* Effect.forEach( + batch, + (directory) => + Effect.tryPromise(() => + NodeFSP.readdir(NodePath.join(cwd, directory.path), { withFileTypes: true }), + ).pipe(Effect.orElseSucceed(() => [])), + { concurrency: "unbounded" }, + ); + + for (let index = 0; index < batch.length; index += 1) { + const directory = batch[index]; + if (!directory) continue; + for (const dirent of batchEntries[index] ?? []) { + if (DOTFILE_SCAN_DENYLIST.has(dirent.name)) continue; + if (!directory.walkContents && !dirent.name.startsWith(".")) continue; + const entryPath = toPosixPath(NodePath.join(directory.path, dirent.name)); + if (!entryByPath.has(entryPath)) { + if (entryByPath.size >= WORKSPACE_INDEX_MAX_ENTRIES) { + truncated = true; + break; + } + entryByPath.set(entryPath, { + path: entryPath, + kind: dirent.isDirectory() ? "directory" : "file", + }); + } + if (dirent.isDirectory() && !queuedPaths.has(entryPath)) { + if (entryByPath.size >= WORKSPACE_INDEX_MAX_ENTRIES) { + truncated = true; + break; + } + queuedPaths.add(entryPath); + queue.push({ path: entryPath, walkContents: true }); + } + } + if (truncated) break; + } + } + if (queueIndex < queue.length) truncated = true; + return { entries: [...entryByPath.values()], truncated }; +}); + const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { const result = yield* Effect.try({ try: () => @@ -286,16 +352,21 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin }); const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( - function* () { + function* (showDotfiles) { const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); - const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => + const entriesWithAncestors = withDirectoryAncestors(mapped.entries); + const supplemented = showDotfiles + ? yield* supplementDotfiles(cwd, entriesWithAncestors) + : { entries: entriesWithAncestors, truncated: false }; + const sortedEntries = supplemented.entries.toSorted((left, right) => left.path.localeCompare(right.path), ); const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES); return { entries, - truncated: mapped.truncated || entries.length < sortedEntries.length, + truncated: + mapped.truncated || supplemented.truncated || entries.length < sortedEntries.length, }; }, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 06ce9c278f9..41a4ed8e061 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -116,6 +116,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { + fileSurfaceId, selectActiveRightPanel, selectActiveRightPanelSurface, selectThreadRightPanelState, @@ -1431,11 +1432,11 @@ function ChatViewContent(props: ChatViewProps) { ? (pendingFileSurfaceIdsByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS) : EMPTY_PENDING_FILE_SURFACE_IDS; const handleFilePendingChange = useCallback( - (relativePath: string, pending: boolean) => { + (relativePath: string, cwd: string, pending: boolean) => { if (!activeProjectKey) return; setPendingFileSurfaceIdsByProject((currentByProject) => { const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS; - const surfaceId = `file:${relativePath}`; + const surfaceId = fileSurfaceId(relativePath, cwd); if (current.has(surfaceId) === pending) return currentByProject; const next = new Set(current); if (pending) next.add(surfaceId); @@ -2816,9 +2817,9 @@ function ChatViewContent(props: ChatViewProps) { useRightPanelStore.getState().open(activeThreadRef, "files"); }, [activeProject, activeThreadRef]); const openFileSurface = useCallback( - (relativePath: string) => { + (relativePath: string, cwd: string) => { if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().openFile(activeThreadRef, relativePath); + useRightPanelStore.getState().openFile(activeThreadRef, relativePath, undefined, cwd); }, [activeProject, activeThreadRef], ); @@ -5026,7 +5027,8 @@ function ChatViewContent(props: ChatViewProps) { void; + reRooted: boolean; + onRootChange: (cwd: string) => void; + onRootReset: () => void; + onOpenFile: (relativePath: string, cwd: string) => void; } const TREE_UNSAFE_CSS = ` @@ -36,10 +40,20 @@ export default function FileBrowserPanel({ environmentId, cwd, projectName, + reRooted, + onRootChange, + onRootReset, onOpenFile, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); - const entriesQuery = useProjectEntriesQuery(environmentId, cwd); + const showDotfiles = useClientSettings((settings) => settings.fileExplorerShowDotfiles); + const entriesQuery = useProjectEntriesQuery(environmentId, cwd, showDotfiles); + const [rootInput, setRootInput] = useState(cwd); + useEffect(() => setRootInput(cwd), [cwd]); + const applyRoot = () => { + const nextRoot = rootInput.trim(); + if (nextRoot) onRootChange(nextRoot); + }; const entries = entriesQuery.data?.entries ?? []; const entryKinds = useMemo( () => new Map(entries.map((entry) => [entry.path, entry.kind] as const)), @@ -58,7 +72,7 @@ export default function FileBrowserPanel({ onSelectionChange: (selectedPaths) => { const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { - onOpenFile(selectedPath); + onOpenFile(selectedPath, cwd); } }, paths: [], @@ -77,6 +91,11 @@ export default function FileBrowserPanel({ () => entries.reduce((count, entry) => count + (entry.kind === "file" ? 1 : 0), 0), [entries], ); + const rootLabel = useMemo(() => { + if (!reRooted) return projectName; + const normalized = cwd.replace(/[\\/]+$/, ""); + return normalized.split(/[\\/]/).at(-1) || cwd; + }, [cwd, projectName, reRooted]); return (
-
{projectName}
+
+ {rootLabel} +
{entriesQuery.isPending && entriesQuery.data === null ? "Indexing…" @@ -110,6 +131,44 @@ export default function FileBrowserPanel({
+
+ setRootInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") applyRoot(); + }} + className="min-w-0 flex-1 rounded-md border border-border/70 bg-background px-2 py-1 text-[11px] text-foreground outline-none focus:border-ring" + aria-label="File explorer root directory" + title={cwd} + /> + + + +
{entriesQuery.error && entriesQuery.data === null ? (
{entriesQuery.error}
) : ( diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 15e850f85d1..28ad83f33fb 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -72,6 +72,7 @@ import { interface FilePreviewPanelProps { environmentId: EnvironmentId; cwd: string; + projectRoot: string; projectName: string; relativePath: string | null; threadRef: ScopedThreadRef; @@ -80,8 +81,15 @@ interface FilePreviewPanelProps { availableEditors: ReadonlyArray; revealLine: number | null; revealRequestId: number; - onOpenFile: (relativePath: string) => void; - onPendingChange: (relativePath: string, pending: boolean) => void; + onOpenFile: (relativePath: string, cwd: string) => void; + onPendingChange: (relativePath: string, cwd: string, pending: boolean) => void; +} + +interface FilePreviewPanelContentProps extends FilePreviewPanelProps { + explorerRoot: string; + explorerReRooted: boolean; + onRootChange: (cwd: string) => void; + onRootReset: () => void; } const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; @@ -277,6 +285,7 @@ function useFileLineReveal( interface EditableFileSurfaceProps { environmentId: EnvironmentId; cwd: string; + projectRoot: string; relativePath: string; composerDraftTarget: ScopedThreadRef | DraftId; contents: string; @@ -284,7 +293,7 @@ interface EditableFileSurfaceProps { revealRequestId: number; wordWrap: boolean; onPostRender: FilePostRender; - onPendingChange: (relativePath: string, pending: boolean) => void; + onPendingChange: (relativePath: string, cwd: string, pending: boolean) => void; } interface FileSelectionOverride { @@ -306,7 +315,7 @@ function useFileSaveCoordinator({ () => new FileSaveCoordinator({ debounceMs: FILE_SAVE_DEBOUNCE_MS, - onPendingChange: (pending) => onPendingChange(relativePath, pending), + onPendingChange: (pending) => onPendingChange(relativePath, cwd, pending), persist: (nextContents) => writeFile({ environmentId, @@ -326,6 +335,7 @@ function useFileSaveCoordinator({ function EditableFileSurface({ environmentId, cwd, + projectRoot, relativePath, composerDraftTarget, contents, @@ -355,6 +365,8 @@ function EditableFileSurface({ relativePath, onPendingChange, }); + const reviewFilePath = + cwd === projectRoot ? relativePath : resolvePathLinkTarget(relativePath, cwd); /* The attached editor owns the rendered document: it patches lines in place * and expects the `file` prop to keep a stable identity across its own * edits. Rebuilding the file object per keystroke (the edited contents @@ -396,7 +408,7 @@ function EditableFileSurface({ composerDraftTarget, buildFileReviewComment({ id: entry.id, - filePath: relativePath, + filePath: reviewFilePath, startLine: entry.startLine, endLine: entry.endLine, text: entry.text, @@ -408,7 +420,15 @@ function EditableFileSurface({ } }, }), - [addReviewComment, composerDraftTarget, cwd, environmentId, relativePath, saveCoordinator], + [ + addReviewComment, + composerDraftTarget, + cwd, + environmentId, + relativePath, + reviewFilePath, + saveCoordinator, + ], ); useEffect( @@ -443,7 +463,7 @@ function EditableFileSurface({ composerDraftTarget, buildFileReviewComment({ id: entry.id, - filePath: relativePath, + filePath: reviewFilePath, startLine: entry.startLine, endLine: entry.endLine, text, @@ -469,7 +489,7 @@ function EditableFileSurface({ composerDraftTarget, contents, lineAnnotations, - relativePath, + reviewFilePath, setSelectedRange, ], ); @@ -613,6 +633,7 @@ function RenderedMarkdownSurface({ }: Omit< EditableFileSurfaceProps, | "viewerTheme" + | "projectRoot" | "composerDraftTarget" | "revealLine" | "revealRequestId" @@ -670,6 +691,7 @@ function initialInvertViewerTheme(): boolean { function FilePreviewPanelContent({ environmentId, cwd, + projectRoot, projectName, relativePath, threadRef, @@ -680,7 +702,11 @@ function FilePreviewPanelContent({ revealRequestId, onOpenFile, onPendingChange, -}: FilePreviewPanelProps) { + explorerRoot, + explorerReRooted, + onRootChange, + onRootReset, +}: FilePreviewPanelContentProps) { const { resolvedTheme } = useTheme(); const wordWrap = useClientSettings((settings) => settings.wordWrap); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -970,6 +996,7 @@ function FilePreviewPanelContent({ key={`${relativePath}:${viewerTheme}`} environmentId={environmentId} cwd={cwd} + projectRoot={projectRoot} relativePath={relativePath} composerDraftTarget={composerDraftTarget} contents={file.data.contents} @@ -992,10 +1019,13 @@ function FilePreviewPanelContent({ )} > @@ -1007,9 +1037,18 @@ function FilePreviewPanelContent({ export default function FilePreviewPanel(props: FilePreviewPanelProps) { const [workerPool] = useState(getFileViewerWorkerPool); + const [explorerRootOverride, setExplorerRootOverride] = useState(null); + useEffect(() => setExplorerRootOverride(null), [props.projectRoot]); + const explorerRoot = explorerRootOverride ?? props.projectRoot; return ( - + setExplorerRootOverride(null)} + /> ); } diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 191b97d6a96..dfbf8ba48f8 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -25,8 +25,12 @@ interface ProjectQueryState { readonly refresh: () => void; } -export function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { - return projectEnvironment.listEntries({ environmentId, input: { cwd } }); +export function getProjectEntriesQueryAtom( + environmentId: EnvironmentId, + cwd: string, + showDotfiles: boolean, +) { + return projectEnvironment.listEntries({ environmentId, input: { cwd, showDotfiles } }); } export function getProjectFileQueryAtom( @@ -120,8 +124,9 @@ function errorMessage(result: AsyncResult.AsyncResult): string | export function useProjectEntriesQuery( environmentId: EnvironmentId, cwd: string, + showDotfiles: boolean, ): ProjectQueryState { - const atom = getProjectEntriesQueryAtom(environmentId, cwd); + const atom = getProjectEntriesQueryAtom(environmentId, cwd, showDotfiles); const result = useAtomValue(atom); const refreshAtom = useAtomRefresh(atom); const refresh = useCallback(() => refreshAtom(), [refreshAtom]); diff --git a/apps/web/src/components/settings/FeaturesSettings.tsx b/apps/web/src/components/settings/FeaturesSettings.tsx index bfeee8fa58e..06006c9780c 100644 --- a/apps/web/src/components/settings/FeaturesSettings.tsx +++ b/apps/web/src/components/settings/FeaturesSettings.tsx @@ -1,4 +1,10 @@ -import { ActivityIcon, GitBranchIcon, PlayIcon, SquareArrowOutUpRightIcon } from "lucide-react"; +import { + ActivityIcon, + FileKeyIcon, + GitBranchIcon, + PlayIcon, + SquareArrowOutUpRightIcon, +} from "lucide-react"; import { type ReactNode } from "react"; import { type ClientSettings, @@ -64,6 +70,7 @@ export function FeaturesSettingsPanel() { headerGitActionsVisibility: s.headerGitActionsVisibility, headerOpenInEditorVisibility: s.headerOpenInEditorVisibility, headerProjectScriptsVisibility: s.headerProjectScriptsVisibility, + fileExplorerShowDotfiles: s.fileExplorerShowDotfiles, sidebarHostStatsVisible: s.sidebarHostStatsVisible, })); const updateSettings = useUpdateClientSettings(); @@ -106,6 +113,24 @@ export function FeaturesSettingsPanel() { ); })} + + + + Show dotfiles in the file explorer + + } + description="Show hidden files such as .env and .gitignore in the project tree." + control={ + updateSettings({ fileExplorerShowDotfiles: checked })} + aria-label="Show dotfiles in the file explorer" + /> + } + /> + { }); }); + it("binds explorer file surfaces to their workspace root", () => { + useRightPanelStore.getState().openFile(refA, ".env", undefined, "~"); + useRightPanelStore.getState().openFile(refA, ".env", undefined, "/workspace/project"); + + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: "file:%2Fworkspace%2Fproject:.env", + surfaces: [ + { + id: "file:~:.env", + kind: "file", + relativePath: ".env", + cwd: "~", + revealLine: null, + revealRequestId: 1, + }, + { + id: "file:%2Fworkspace%2Fproject:.env", + kind: "file", + relativePath: ".env", + cwd: "/workspace/project", + revealLine: null, + revealRequestId: 1, + }, + ], + }); + expect(fileSurfaceId(".env", "/workspace/project")).toBe( + selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id, + ); + }); + it("removes persisted file surfaces when their workspace no longer exists", () => { useRightPanelStore.getState().openFile(refA, "src/index.ts"); useRightPanelStore.getState().open(refA, "plan"); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 70d163306cc..0e57e305722 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -34,6 +34,7 @@ export type RightPanelSurface = id: `file:${string}`; kind: "file"; relativePath: string; + cwd?: string; revealLine: number | null; revealRequestId: number; } @@ -52,7 +53,7 @@ interface RightPanelStoreState { byThreadKey: Record; open: (ref: ScopedThreadRef, kind: Exclude) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; - openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; + openFile: (ref: ScopedThreadRef, relativePath: string, line?: number, cwd?: string) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; splitTerminal: ( ref: ScopedThreadRef, @@ -100,16 +101,21 @@ const browserSurface = (tabId: string | null): RightPanelSurface => ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId } : { id: "browser:new", kind: "preview", resourceId: null }; +export const fileSurfaceId = (relativePath: string, cwd?: string): `file:${string}` => + `file:${cwd ? `${encodeURIComponent(cwd)}:` : ""}${relativePath}`; + const fileSurface = ( relativePath: string, revealLine: number | null, revealRequestId: number, + cwd?: string, ): RightPanelSurface => ({ - id: `file:${relativePath}`, + id: fileSurfaceId(relativePath, cwd), kind: "file", relativePath, revealLine, revealRequestId, + ...(cwd ? { cwd } : {}), }); const terminalSurface = (terminalId: string): RightPanelSurface => ({ @@ -259,13 +265,13 @@ export const useRightPanelStore = create()( return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); }), })), - openFile: (ref, relativePath, line) => + openFile: (ref, relativePath, line, cwd) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { const withoutStandaloneExplorer = current.surfaces.filter( (surface) => surface.kind !== "files", ); - const surfaceId = `file:${relativePath}` as const; + const surfaceId = fileSurfaceId(relativePath, cwd); const existing = withoutStandaloneExplorer.find( (surface): surface is Extract => surface.id === surfaceId && surface.kind === "file", @@ -274,6 +280,7 @@ export const useRightPanelStore = create()( relativePath, normalizeRevealLine(line), (existing?.revealRequestId ?? 0) + 1, + cwd, ); return { isOpen: true, diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 5908b71563d..67c00c97a06 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -28,6 +28,7 @@ export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; export const ProjectListEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, + showDotfiles: Schema.optionalKey(Schema.Boolean), }); export type ProjectListEntriesInput = typeof ProjectListEntriesInput.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4097c461bf6..980a2907bc6 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -60,6 +60,7 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + fileExplorerShowDotfiles: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -581,6 +582,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), + fileExplorerShowDotfiles: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey( Schema.Array( Schema.Struct({