fix(app): open folders from inline path links in the file explorer

Clicking a folder path in an assistant message opened a file tab that
failed with "Requested path is not a file". Extension-less
workspace-relative paths without a line number are now probed with a
directory listing before opening: directories open the Files explorer
navigated to that folder, everything else opens as a file like before.
Lexical directories (trailing slash, workspace root) now navigate the
explorer to the folder instead of only preloading its listing.
This commit is contained in:
Moy
2026-10-17 08:23:28 +02:00
committed by paseo-bot[bot]
parent 30799fda19
commit 09b3dcf9a1
3 changed files with 134 additions and 18 deletions
@@ -0,0 +1,52 @@
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",
});
});
});
@@ -0,0 +1,36 @@
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);
}
+46 -18
View File
@@ -91,6 +91,7 @@ import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
} from "@/assistant-file-links";
import { planInlinePathOpen } from "./inline-path-open";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
@@ -437,7 +438,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return;
}
if (normalized.file) {
const openFileLocation = () => {
if (!normalized.file) {
return;
}
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
lineStart: target.lineStart,
@@ -462,27 +466,51 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
target: createWorkspaceFileTabTarget(location),
});
}
};
const openFilesExplorerAt = (directoryPath: string) => {
void requestDirectoryListing(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 plan = planInlinePathOpen({
file: normalized.file,
lineStart: target.lineStart,
workspaceRoot,
});
if (plan.kind === "file") {
openFileLocation();
return;
}
void requestDirectoryListing(normalized.directory, {
recordHistory: false,
setCurrentPath: false,
});
if (plan.kind === "directory") {
openFilesExplorerAt(normalized.directory || ".");
return;
}
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 (!client) {
openFileLocation();
return;
}
void client.listDirectory(workspaceRoot, plan.directoryPath).then(
() => openFilesExplorerAt(plan.directoryPath),
() => openFileLocation(),
);
},
);