diff --git a/packages/app/e2e/browser/folder-links.spec.ts b/packages/app/e2e/browser/folder-links.spec.ts index cbc16a88e9..7e1bfac1ae 100644 --- a/packages/app/e2e/browser/folder-links.spec.ts +++ b/packages/app/e2e/browser/folder-links.spec.ts @@ -16,7 +16,7 @@ async function expectFolderContents(page: Page) { ).toBeVisible(); } -for (const folder of ["docs/guides", "docs/guides/", "docs/reports.v1"]) { +for (const folder of ["docs/guides", "docs/guides/", "docs/reports.v1", ".github/workflows"]) { test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => { const session = await seedMockAgentWorkspace({ repoPrefix: "folder-link-repro-", @@ -28,6 +28,12 @@ for (const folder of ["docs/guides", "docs/guides/", "docs/reports.v1"]) { await mkdir(path.join(session.cwd, folder), { recursive: true }); await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n"); await session.client.waitForFinish(session.agentId, 30_000); + await page.addInitScript(() => { + localStorage.setItem( + "panel-state", + JSON.stringify({ state: { explorerShowHiddenFiles: false }, version: 0 }), + ); + }); await openAgentRoute(page, session); await openReportedFolderLink(page, "Open folder"); await expectFolderContents(page); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 83cfca9ced..c9b44ed679 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -87,14 +87,10 @@ import { } from "./bottom-anchor-controller"; import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache"; import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface"; -import { - AssistantFileLinkResolverProvider, - normalizeInlinePathTarget, - resolveInlinePathOpen, -} from "@/assistant-file-links"; +import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links"; import { createWorkspaceFileTabTarget, - normalizeWorkspaceFileLocation, + type WorkspaceFileLocation, type OpenFileDisposition, type WorkspaceFileOpenRequest, } from "@/workspace/file-open"; @@ -427,78 +423,53 @@ const AgentStreamViewComponent = forwardRef { - if (!target.path) { - return; - } - - const normalized = normalizeInlinePathTarget(target.path, context.cwd); - if (!normalized) { - return; - } - - const openFileLocation = () => { - if (!normalized.file) { - return; - } - const location = normalizeWorkspaceFileLocation({ - path: normalized.file, - lineStart: target.lineStart, - lineEnd: target.lineEnd, + const isActive = useRetainedPanelActive(); + const openInlineFile = useStableEvent( + (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => { + if (onOpenWorkspaceFile) { + onOpenWorkspaceFile({ location, disposition }); + } else if (context.workspaceId) { + navigateToWorkspace({ + serverId: resolvedServerId, + workspaceId: context.workspaceId, + target: createWorkspaceFileTabTarget(location), }); - if (!location) { - return; - } - - if (onOpenWorkspaceFile) { - onOpenWorkspaceFile({ - location, - disposition, - }); - return; - } - - if (context.workspaceId) { - navigateToWorkspace({ - serverId: resolvedServerId, - workspaceId: context.workspaceId, - target: createWorkspaceFileTabTarget(location), - }); - } - }; - - const openFilesExplorerAt = (directoryPath: string) => { - void revealDirectory(directoryPath); - - openExplorerSidebarView({ - isCompact: isMobile, - workspaceKey: buildWorkspaceTabPersistenceKey({ - serverId: resolvedServerId, - workspaceId: context.workspaceId ?? "", - }), - checkout: { - serverId: resolvedServerId, - cwd: context.cwd, - isGit: context.projectPlacement?.checkout?.isGit ?? true, - }, - view: "files", - }); - }; - - void resolveInlinePathOpen({ - file: normalized.file, - directory: normalized.directory, - lineStart: target.lineStart, + } + }, + ); + const openInlineDirectory = useStableEvent((directoryPath: string) => { + void revealDirectory(directoryPath); + openExplorerSidebarView({ + isCompact: isMobile, + workspaceKey: buildWorkspaceTabPersistenceKey({ + serverId: resolvedServerId, + workspaceId: context.workspaceId ?? "", + }), + checkout: { + serverId: resolvedServerId, + cwd: context.cwd, + isGit: context.projectPlacement?.checkout?.isGit ?? true, + }, + view: "files", + }); + }); + const inlinePathOpener = useMemo( + () => + createInlinePathOpener({ workspaceRoot, client, - }).then((destination) => { - if (destination.kind === "directory") { - return openFilesExplorerAt(destination.directoryPath); - } else { - return openFileLocation(); - } - }); + openFile: openInlineFile, + openDirectory: openInlineDirectory, + }), + [workspaceRoot, client, openInlineFile, openInlineDirectory], + ); + useEffect(() => { + if (!isActive) inlinePathOpener.cancel(); + return () => inlinePathOpener.cancel(); + }, [inlinePathOpener, isActive, agentId]); + const handleInlinePathPress = useStableEvent( + (target: InlinePathTarget, disposition: OpenFileDisposition) => { + if (isActive) void inlinePathOpener.open(target, disposition); }, ); @@ -535,7 +506,6 @@ const AgentStreamViewComponent = forwardRef { + const { createInlinePathOpener } = await import("./path-open"); + let rejectProbe!: (error: Error) => void; + const pendingProbe = new Promise((_, reject) => { + rejectProbe = reject; + }); + const opened: string[] = []; + const opener = createInlinePathOpener({ + workspaceRoot: "/repo", + client: { listDirectory: () => pendingProbe }, + openFile: (location) => { + opened.push(location.path); + }, + openDirectory: (directory) => { + opened.push(directory); + }, + }); + const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred"); + await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred"); + rejectProbe(new Error("Not a directory")); + await first; + expect(opened).toEqual(["second.md"]); +}); + +it("ignores a pending probe when its view is left", async () => { + const { createInlinePathOpener } = await import("./path-open"); + let resolveProbe!: (value: unknown) => void; + const pendingProbe = new Promise((resolve) => { + resolveProbe = resolve; + }); + const opened: string[] = []; + const opener = createInlinePathOpener({ + workspaceRoot: "/repo", + client: { listDirectory: () => pendingProbe }, + openFile: (location) => { + opened.push(location.path); + }, + openDirectory: (directory) => { + opened.push(directory); + }, + }); + const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred"); + opener.cancel(); + resolveProbe({}); + await pending; + expect(opened).toEqual([]); +}); diff --git a/packages/app/src/assistant-file-links/path-open.ts b/packages/app/src/assistant-file-links/path-open.ts index b08e2f9680..b3d2178165 100644 --- a/packages/app/src/assistant-file-links/path-open.ts +++ b/packages/app/src/assistant-file-links/path-open.ts @@ -1,3 +1,9 @@ +import { normalizeInlinePathTarget, type InlinePathTarget } from "./parse"; +import { + normalizeWorkspaceFileLocation, + type WorkspaceFileLocation, + type OpenFileDisposition, +} from "@/workspace/file-open"; import { isAbsolutePath } from "@/utils/path"; interface PathOpenInput { @@ -30,3 +36,39 @@ export async function resolveInlinePathOpen(input: PathOpenInput): Promise void; + openDirectory: (path: string) => void; +}) { + let generation = 0; + return { + async open(target: InlinePathTarget, disposition: OpenFileDisposition) { + const ownGeneration = ++generation; + const normalized = normalizeInlinePathTarget(target.path, input.workspaceRoot); + if (!normalized) return; + const destination = await resolveInlinePathOpen({ + ...normalized, + lineStart: target.lineStart, + workspaceRoot: input.workspaceRoot, + client: input.client, + }); + if (ownGeneration !== generation) return; + if (destination.kind === "directory") { + input.openDirectory(destination.directoryPath); + } else if (normalized.file) { + const location = normalizeWorkspaceFileLocation({ + path: normalized.file, + lineStart: target.lineStart, + lineEnd: target.lineEnd, + }); + if (location) input.openFile(location, disposition); + } + }, + cancel() { + generation += 1; + }, + }; +} diff --git a/packages/app/src/file-explorer/tree.ts b/packages/app/src/file-explorer/tree.ts index 13f94c7f3e..8b75ae7b25 100644 --- a/packages/app/src/file-explorer/tree.ts +++ b/packages/app/src/file-explorer/tree.ts @@ -1,6 +1,6 @@ import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store"; import type { SortOption } from "@/stores/panel-store/state"; -import { filterVisibleExplorerEntries } from "./visibility"; +import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility"; export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5; @@ -207,7 +207,9 @@ export async function revealExplorerDirectory(input: { directoryPath: string; requestDirectoryListing: (path: string) => Promise; expandPaths: (paths: string[]) => void; + showHiddenFiles: () => void; }): Promise { + if (isHiddenExplorerPath(input.directoryPath)) input.showHiddenFiles(); const paths = ["."]; if (input.directoryPath !== ".") { const segments = input.directoryPath.split("/").filter(Boolean); diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index e9d258c27c..ca3c99649b 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -180,6 +180,10 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor await revealExplorerDirectory({ directoryPath, requestDirectoryListing, + showHiddenFiles: () => { + const panel = usePanelStore.getState(); + if (!panel.explorerShowHiddenFiles) panel.toggleExplorerShowHiddenFiles(); + }, expandPaths: (paths) => usePanelStore .getState()