fix(app): reveal linked folders and their contents

Resolve workspace folder links from directory metadata and expand their ancestor paths in Files. Preserve file and line-reference opening, including extensionless files.

Co-authored-by: Moy <moyfoxther@gmail.com>
This commit is contained in:
paseo-bot[bot]
2026-10-17 08:27:12 +02:00
co-authored by Moy
parent 09b3dcf9a1
commit 59c2880418
9 changed files with 180 additions and 112 deletions
@@ -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();
}
});
}
@@ -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",
});
});
});
@@ -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);
}
+12 -24
View File
@@ -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<AgentStreamViewHandle, AgentStreamVi
);
const workspaceRoot = context.cwd?.trim() || "";
const { requestDirectoryListing } = useFileExplorerActions({
const { revealDirectory } = useFileExplorerActions({
serverId: resolvedServerId,
workspaceId: context.workspaceId,
workspaceRoot,
@@ -469,7 +469,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
};
const openFilesExplorerAt = (directoryPath: string) => {
void requestDirectoryListing(directoryPath);
void revealDirectory(directoryPath);
openExplorerSidebarView({
isCompact: isMobile,
@@ -486,31 +486,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
});
};
const plan = planInlinePathOpen({
void resolveInlinePathOpen({
file: normalized.file,
directory: normalized.directory,
lineStart: target.lineStart,
workspaceRoot,
client,
}).then((destination) => {
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(),
);
},
);
@@ -10,3 +10,5 @@ export {
} from "./provider";
export type { AssistantFileLinkSource } from "./resolver";
export { useAssistantFileLinkActions, useFileLink } from "./use-file-link";
export { resolveInlinePathOpen } from "./path-open";
@@ -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" });
}
});
@@ -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<unknown> } | 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<PathOpenTarget> {
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 };
}
+19
View File
@@ -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<ExplorerDirectory | null>;
expandPaths: (paths: string[]) => void;
}): Promise<void> {
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);
}
@@ -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,