diff --git a/packages/app/e2e/browser/folder-links.spec.ts b/packages/app/e2e/browser/folder-links.spec.ts new file mode 100644 index 0000000000..7e1bfac1ae --- /dev/null +++ b/packages/app/e2e/browser/folder-links.spec.ts @@ -0,0 +1,45 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, test, type Page } from "../support/fixtures"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; + +async function openReportedFolderLink(page: Page, name: string) { + const link = page.getByTestId("assistant-message").getByRole("link", { name, exact: true }); + await link.click(); +} + +async function expectFolderContents(page: Page) { + await expect(page.getByText("Requested path is not a file", { exact: true })).toHaveCount(0); + await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible(); + await expect( + page.getByTestId("file-explorer-tree-scroll").getByText("guide.md", { exact: true }), + ).toBeVisible(); +} + +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-", + title: "Folder link reproduction", + initialPrompt: "Show the folder link", + featureValues: { mockAssistantResponse: `[Open folder](./${folder})` }, + }); + try { + 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); + } finally { + await page.screenshot({ path: test.info().outputPath("folder-link-result.png") }); + await session.cleanup(); + } + }); +} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 09b02cda18..c9b44ed679 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -87,13 +87,10 @@ import { } from "./bottom-anchor-controller"; import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache"; import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface"; -import { - AssistantFileLinkResolverProvider, - normalizeInlinePathTarget, -} from "@/assistant-file-links"; +import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links"; import { createWorkspaceFileTabTarget, - normalizeWorkspaceFileLocation, + type WorkspaceFileLocation, type OpenFileDisposition, type WorkspaceFileOpenRequest, } from "@/workspace/file-open"; @@ -387,7 +384,7 @@ const AgentStreamViewComponent = forwardRef { + if (onOpenWorkspaceFile) { + onOpenWorkspaceFile({ location, disposition }); + } else if (context.workspaceId) { + navigateToWorkspace({ + serverId: resolvedServerId, + workspaceId: context.workspaceId, + target: createWorkspaceFileTabTarget(location), + }); + } + }, + ); + 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, + 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 (!target.path) { - return; - } - - const normalized = normalizeInlinePathTarget(target.path, context.cwd); - if (!normalized) { - return; - } - - if (normalized.file) { - const location = normalizeWorkspaceFileLocation({ - path: normalized.file, - lineStart: target.lineStart, - lineEnd: target.lineEnd, - }); - if (!location) { - return; - } - - if (onOpenWorkspaceFile) { - onOpenWorkspaceFile({ - location, - disposition, - }); - return; - } - - if (context.workspaceId) { - navigateToWorkspace({ - serverId: resolvedServerId, - workspaceId: context.workspaceId, - target: createWorkspaceFileTabTarget(location), - }); - } - return; - } - - void requestDirectoryListing(normalized.directory, { - recordHistory: false, - setCurrentPath: false, - }); - - openExplorerSidebarView({ - isCompact: isMobile, - workspaceKey: buildWorkspaceTabPersistenceKey({ - serverId: resolvedServerId, - workspaceId: context.workspaceId ?? "", - }), - checkout: { - serverId: resolvedServerId, - cwd: context.cwd, - isGit: context.projectPlacement?.checkout?.isGit ?? true, - }, - view: "files", - }); + if (isActive) void inlinePathOpener.open(target, disposition); }, ); @@ -519,7 +506,6 @@ const AgentStreamViewComponent = forwardRef { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +it("distinguishes dotted and extensionless folders from files using the filesystem", async () => { + const workspaceRoot = await mkdtemp(path.join(tmpdir(), "folder-link-")); + roots.push(workspaceRoot); + await mkdir(path.join(workspaceRoot, "docs/guides"), { recursive: true }); + await mkdir(path.join(workspaceRoot, "docs/reports.v1")); + await writeFile(path.join(workspaceRoot, "Makefile"), "all:\n"); + await writeFile(path.join(workspaceRoot, "README.md"), "# Readme\n"); + const client = { + listDirectory: (root: string, file: string) => readdir(path.resolve(root, file)), + }; + for (const file of ["docs/guides", "docs/reports.v1"]) { + await expect( + resolveInlinePathOpen({ file, directory: "docs", workspaceRoot, client }), + ).resolves.toEqual({ kind: "directory", directoryPath: file }); + } + for (const file of ["Makefile", "README.md", "missing.md"]) { + await expect( + resolveInlinePathOpen({ file, directory: ".", workspaceRoot, client }), + ).resolves.toEqual({ kind: "file" }); + } +}); + +it("preserves explicit directory destinations and file line references", async () => { + const workspaceRoot = "/repo"; + await expect( + resolveInlinePathOpen({ directory: "docs/guides", workspaceRoot, client: null }), + ).resolves.toEqual({ kind: "directory", directoryPath: "docs/guides" }); + await expect( + resolveInlinePathOpen({ + file: "docs/guides", + directory: "docs", + lineStart: 12, + workspaceRoot, + client: null, + }), + ).resolves.toEqual({ kind: "file" }); +}); + +it("keeps unavailable probes and external paths on the existing file-open path", async () => { + for (const file of ["Makefile", "/external/folder", "C:/external/folder"]) { + await expect( + resolveInlinePathOpen({ file, directory: ".", workspaceRoot: "/repo", client: null }), + ).resolves.toEqual({ kind: "file" }); + } +}); + +it("ignores an older probe after a newer file link opens", async () => { + 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 new file mode 100644 index 0000000000..b3d2178165 --- /dev/null +++ b/packages/app/src/assistant-file-links/path-open.ts @@ -0,0 +1,74 @@ +import { normalizeInlinePathTarget, type InlinePathTarget } from "./parse"; +import { + normalizeWorkspaceFileLocation, + type WorkspaceFileLocation, + type OpenFileDisposition, +} from "@/workspace/file-open"; +import { isAbsolutePath } from "@/utils/path"; + +interface PathOpenInput { + file?: string; + directory: string; + lineStart?: number; + workspaceRoot: string; + client: { listDirectory(root: string, path: string): Promise } | null; +} + +type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string }; + +/** Resolve workspace folders from filesystem facts, including names that contain dots. */ +export async function resolveInlinePathOpen(input: PathOpenInput): Promise { + if (!input.file) { + return { kind: "directory", directoryPath: input.directory }; + } + if ( + input.lineStart !== undefined || + isAbsolutePath(input.file) || + !input.workspaceRoot.trim() || + !input.client + ) { + return { kind: "file" }; + } + try { + await input.client.listDirectory(input.workspaceRoot, input.file); + } catch { + return { kind: "file" }; + } + return { kind: "directory", directoryPath: input.file }; +} + +export function createInlinePathOpener(input: { + workspaceRoot: string; + client: PathOpenInput["client"]; + openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => 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 472c3d7075..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; @@ -201,3 +201,24 @@ function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption): }); return sorted; } + +/** Load and expand the path to a directory selected outside the tree. */ +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); + for (let length = 1; length <= segments.length; length += 1) { + paths.push(segments.slice(0, length).join("/")); + } + } + for (const path of paths) { + if (!(await input.requestDirectoryListing(path))) return; + } + input.expandPaths(paths); +} diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index c29feb5241..ca3c99649b 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -6,6 +6,8 @@ import { type ExplorerDirectory, } from "@/stores/session-store"; import { explorerFileFromReadResult } from "@/file-explorer/read-result"; +import { usePanelStore } from "@/stores/panel-store"; +import { revealExplorerDirectory } from "@/file-explorer/tree"; import { parentExplorerPath } from "@/utils/explorer-paths"; function createExplorerState(): AgentFileExplorerState { @@ -172,6 +174,27 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor [client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey], ); + const revealDirectory = useCallback( + async (directoryPath: string) => { + if (!workspaceStateKey) return; + await revealExplorerDirectory({ + directoryPath, + requestDirectoryListing, + showHiddenFiles: () => { + const panel = usePanelStore.getState(); + if (!panel.explorerShowHiddenFiles) panel.toggleExplorerShowHiddenFiles(); + }, + expandPaths: (paths) => + usePanelStore + .getState() + .setExpandedPathsForWorkspace(workspaceStateKey, (current) => + Array.from(new Set([...current, ...paths])), + ), + }); + }, + [requestDirectoryListing, workspaceStateKey], + ); + const requestFilePreview = useCallback( async (path: string) => { if (!workspaceStateKey) { @@ -354,6 +377,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor return { workspaceStateKey, + revealDirectory, requestDirectoryListing, requestFilePreview, requestFileDownloadToken,