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..cbc16a88e9 --- /dev/null +++ b/packages/app/e2e/browser/folder-links.spec.ts @@ -0,0 +1,39 @@ +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"]) { + 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 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/inline-path-open.test.ts b/packages/app/src/agent-stream/inline-path-open.test.ts deleted file mode 100644 index d2a80a82b7..0000000000 --- a/packages/app/src/agent-stream/inline-path-open.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { planInlinePathOpen } from "./inline-path-open"; - -describe("planInlinePathOpen", () => { - it("opens paths with a line number directly as files", () => { - expect( - planInlinePathOpen({ - file: "src/moys-asr-workflow", - lineStart: 12, - workspaceRoot: "/repo", - }), - ).toEqual({ kind: "file" }); - }); - - it("opens paths with a file extension directly as files", () => { - expect( - planInlinePathOpen({ file: "src/components/message.tsx", workspaceRoot: "/repo" }), - ).toEqual({ kind: "file" }); - expect(planInlinePathOpen({ file: "README.md", workspaceRoot: "/repo" })).toEqual({ - kind: "file", - }); - }); - - it("treats a missing file segment as a directory", () => { - expect(planInlinePathOpen({ file: undefined, workspaceRoot: "/repo" })).toEqual({ - kind: "directory", - directoryPath: ".", - }); - }); - - it("probes extension-less workspace-relative paths", () => { - expect( - planInlinePathOpen({ file: "packages/app/moys-asr-workflow", workspaceRoot: "/repo" }), - ).toEqual({ kind: "probe", directoryPath: "packages/app/moys-asr-workflow" }); - expect(planInlinePathOpen({ file: "Makefile", workspaceRoot: "/repo" })).toEqual({ - kind: "probe", - directoryPath: "Makefile", - }); - }); - - it("opens extension-less absolute paths directly as files", () => { - expect(planInlinePathOpen({ file: "C:/Tools/some-folder", workspaceRoot: "C:/repo" })).toEqual({ - kind: "file", - }); - }); - - it("opens extension-less paths directly as files when there is no workspace root", () => { - expect(planInlinePathOpen({ file: "some-folder", workspaceRoot: " " })).toEqual({ - kind: "file", - }); - }); -}); diff --git a/packages/app/src/agent-stream/inline-path-open.ts b/packages/app/src/agent-stream/inline-path-open.ts deleted file mode 100644 index 80793cf061..0000000000 --- a/packages/app/src/agent-stream/inline-path-open.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { isAbsolutePath } from "@/utils/path"; - -export type InlinePathOpenPlan = - | { kind: "file" } - | { kind: "directory"; directoryPath: string } - | { kind: "probe"; directoryPath: string }; - -/** - * Decides how an inline path press opens. Extension-less workspace-relative paths without a - * line number cannot be told apart from folders lexically ("moys-asr-workflow" is a folder, - * "Makefile" is a file), so they come back as "probe" and the caller asks the daemon which - * one it is before opening a file tab that would fail with "Requested path is not a file". - */ -export function planInlinePathOpen(input: { - file?: string; - lineStart?: number; - workspaceRoot: string; -}): InlinePathOpenPlan { - const { file, lineStart, workspaceRoot } = input; - if (!file) { - // Lexical directory: a trailing slash, ".", or the workspace root itself. - return { kind: "directory", directoryPath: "." }; - } - if (lineStart !== undefined || hasFileExtension(file)) { - return { kind: "file" }; - } - if (isAbsolutePath(file) || !workspaceRoot.trim()) { - return { kind: "file" }; - } - return { kind: "probe", directoryPath: file }; -} - -function hasFileExtension(value: string): boolean { - const lastSegment = value.split("/").pop() ?? ""; - return /\.[A-Za-z0-9]+$/.test(lastSegment); -} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 83123c1b77..83cfca9ced 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -90,8 +90,8 @@ import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surfac import { AssistantFileLinkResolverProvider, normalizeInlinePathTarget, + resolveInlinePathOpen, } from "@/assistant-file-links"; -import { planInlinePathOpen } from "./inline-path-open"; import { createWorkspaceFileTabTarget, normalizeWorkspaceFileLocation, @@ -388,7 +388,7 @@ const AgentStreamViewComponent = forwardRef { - void requestDirectoryListing(directoryPath); + void revealDirectory(directoryPath); openExplorerSidebarView({ isCompact: isMobile, @@ -486,31 +486,19 @@ const AgentStreamViewComponent = forwardRef { + if (destination.kind === "directory") { + return openFilesExplorerAt(destination.directoryPath); + } else { + return openFileLocation(); + } }); - - if (plan.kind === "file") { - openFileLocation(); - return; - } - - if (plan.kind === "directory") { - openFilesExplorerAt(normalized.directory || "."); - return; - } - - if (!client) { - openFileLocation(); - return; - } - - void client.listDirectory(workspaceRoot, plan.directoryPath).then( - () => openFilesExplorerAt(plan.directoryPath), - () => openFileLocation(), - ); }, ); diff --git a/packages/app/src/assistant-file-links/index.ts b/packages/app/src/assistant-file-links/index.ts index 169afc38b0..e91aaf9c33 100644 --- a/packages/app/src/assistant-file-links/index.ts +++ b/packages/app/src/assistant-file-links/index.ts @@ -10,3 +10,5 @@ export { } from "./provider"; export type { AssistantFileLinkSource } from "./resolver"; export { useAssistantFileLinkActions, useFileLink } from "./use-file-link"; + +export { resolveInlinePathOpen } from "./path-open"; diff --git a/packages/app/src/assistant-file-links/path-open.test.ts b/packages/app/src/assistant-file-links/path-open.test.ts new file mode 100644 index 0000000000..852feb8127 --- /dev/null +++ b/packages/app/src/assistant-file-links/path-open.test.ts @@ -0,0 +1,56 @@ +import { mkdtemp, mkdir, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { resolveInlinePathOpen } from "./path-open"; + +const roots: string[] = []; +afterEach(async () => { + 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" }); + } +}); 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..b08e2f9680 --- /dev/null +++ b/packages/app/src/assistant-file-links/path-open.ts @@ -0,0 +1,32 @@ +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 }; +} diff --git a/packages/app/src/file-explorer/tree.ts b/packages/app/src/file-explorer/tree.ts index 472c3d7075..13f94c7f3e 100644 --- a/packages/app/src/file-explorer/tree.ts +++ b/packages/app/src/file-explorer/tree.ts @@ -201,3 +201,22 @@ 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; +}): Promise { + 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..e9d258c27c 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,23 @@ 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, + 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 +373,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor return { workspaceStateKey, + revealDirectory, requestDirectoryListing, requestFilePreview, requestFileDownloadToken,