mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
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:
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user