authorgravatar for saphid@gmail.comAlex Southwell <saphid@gmail.com> 2026-09-30 10:05:44+10:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-29 21:05:44-03:00
log55ec55b1fb2e65f2736ff8420ae0373210c1911e
treeb6f67fca531af54133558764f08ac5b8946cebc4
parent916ec94f93da686303f878d506a760efd13506c1
signature Signed by PGP key B5690EEEBB952194

fix(web): open workspace root links in the file explorer (#12449)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

10 files changed, 83 insertions(+), 3 deletions(-)

apps/web/src/components/files/FilePreviewPanel.test.ts+22
......@@ -7,6 +7,7 @@ import {
77} from "./fileCommentAnnotations";
88import {
99 isMarkdownPreviewFile,
10 resolveFilePreviewPath,
1011 setMarkdownTaskChecked,
1112 shouldShowFileExplorer,
1213} from "./filePreviewMode";
......@@ -120,3 +121,24 @@ describe("setMarkdownTaskChecked", () => {
120121 expect(setMarkdownTaskChecked(markdown, 200, true)).toBe(markdown);
121122 });
122123});
124
125describe("resolveFilePreviewPath", () => {
126 it.each([
127 ["/repo/project", null],
128 ["/repo/project/", null],
129 [".", null],
130 [null, null],
131 ["/repo/project/src", "/repo/project/src"],
132 ["/repo/project/src/main.ts", "/repo/project/src/main.ts"],
133 ["src/main.ts", "src/main.ts"],
134 ["/repo/project-other", "/repo/project-other"],
135 ])("opens %s in the appropriate workspace surface", (path, expected) => {
136 const relativePath = resolveFilePreviewPath(path, "/repo/project");
137 expect(relativePath).toBe(expected);
138 if (expected === null) {
139 expect(
140 shouldShowFileExplorer({ relativePath, explorerOpen: false, attachmentOpen: false }),
141 ).toBe(true);
142 }
143 });
144});
apps/web/src/components/files/FilePreviewPanel.tsx+10-2
......@@ -81,6 +81,7 @@ import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";
8181import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";
8282import {
8383 isMarkdownPreviewFile,
84 resolveFilePreviewPath,
8485 setMarkdownTaskChecked,
8586 shouldShowFileExplorer,
8687} from "./filePreviewMode";
......@@ -907,7 +908,7 @@ export default function FilePreviewPanel({
907908 environmentId,
908909 cwd,
909910 projectName,
910 relativePath,
911 relativePath: requestedPath,
911912 attachment,
912913 threadRef,
913914 composerDraftTarget,
......@@ -920,6 +921,8 @@ export default function FilePreviewPanel({
920921 selectedFilePending,
921922 workspaceMutationId,
922923}: FilePreviewPanelProps) {
924 const relativePath =
925 attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath;
923926 const { resolvedTheme } = useTheme();
924927 const wordWrap = useClientSettings((settings) => settings.wordWrap);
925928 const primaryEnvironmentId = usePrimaryEnvironmentId();
......@@ -945,7 +948,12 @@ export default function FilePreviewPanel({
945948 // shown. The read still runs: a folder named `assets.png` is only knowable as a
946949 // folder from the read failure, and the server stats before reading, so a folder
947950 // costs an open and a stat and returns no body.
948 const file = useProjectFileQuery(environmentId, cwd, relativePath, attachment === undefined);
951 const file = useProjectFileQuery(
952 environmentId,
953 cwd,
954 relativePath,
955 attachment === undefined && relativePath !== null,
956 );
949957 // A chat link cannot tell a folder from a file, so a folder arrives here as
950958 // a file surface and the read fails. Keep the breadcrumbs, drop the preview
951959 // pane, and let the tree fill the surface with the folder revealed. Mutation
apps/web/src/components/files/filePreviewMode.ts+7
......@@ -1,5 +1,12 @@
1import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links";
12import { isAbsolutePath } from "~/terminal-links";
23
4/** Resolve workspace links before choosing between the explorer and a file preview. */
5export function resolveFilePreviewPath(path: string | null, cwd: string): string | null {
6 if (path === null) return null;
7 return path === "." || workspaceRelativeFilePath(path, cwd) === "." ? null : path;
8}
9
310export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path);
411
512export function shouldShowFileExplorer(input: {
apps/web/src/markdown-links.test.ts+8
......@@ -522,3 +522,11 @@ describe("directory paths with a trailing separator", () => {
522522 expect(meta?.basename).not.toBe("");
523523 });
524524});
525
526it("routes the project-root code link to the workspace explorer", () => {
527 const cwd = "/Users/saphid/.t3/worktrees/ov2-standalone-20260918";
528 expect(resolveInlineCodeFileLinkMeta(cwd, cwd)).toMatchObject({
529 workspaceRelativePath: ".",
530 filePath: cwd,
531 });
532});
apps/web/src/rightPanelStore.test.ts+18
......@@ -447,6 +447,24 @@ describe("rightPanelStore", () => {
447447 });
448448 });
449449
450 it("opens workspace-root links as the singleton files explorer", () => {
451 const store = useRightPanelStore.getState();
452 store.openFile(refA, "README.md");
453 store.openFile(refA, ".");
454 store.openFile(refA, ".");
455 const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
456 expect(state.activeSurfaceId).toBe("files");
457 expect(state.surfaces.map((surface) => surface.id)).toEqual(["file:README.md", "files"]);
458 store.closeSurface(refA, "files");
459 expect(
460 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).activeSurfaceId,
461 ).toBe("file:README.md");
462 store.openFile(refA, ".");
463 expect(
464 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).activeSurfaceId,
465 ).toBe("files");
466 });
467
450468 it("replaces the standalone explorer with peer file surfaces", () => {
451469 useRightPanelStore.getState().open(refA, "files");
452470 useRightPanelStore.getState().openFile(refA, "src/index.ts");
apps/web/src/rightPanelStore.ts+3
......@@ -578,6 +578,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
578578 openFile: (ref, requestedPath, line) =>
579579 set((state) =>
580580 userAction(state, scopedThreadKey(ref), (current) => {
581 if (requestedPath === ".") {
582 return upsertSurface(current, singletonSurface("files"));
583 }
581584 // Workspace entry paths use '/', including on Windows.
582585 const relativePath = /^[A-Za-z]:\/+$/.test(requestedPath)
583586 ? requestedPath
apps/web/src/workspaceBasenameLookup.test.ts+2
......@@ -15,6 +15,8 @@ describe("needsWorkspaceBasenameLookup", () => {
1515 it("leaves anything with a directory alone", () => {
1616 expect(needsWorkspaceBasenameLookup("apps/web/src/components/ChatView.tsx")).toBe(false);
1717 expect(needsWorkspaceBasenameLookup("apps\\web\\ChatView.tsx")).toBe(false);
18 expect(needsWorkspaceBasenameLookup(".")).toBe(false);
19 expect(needsWorkspaceBasenameLookup("..")).toBe(false);
1820 expect(needsWorkspaceBasenameLookup(" ")).toBe(false);
1921 });
2022});
apps/web/src/workspaceBasenameLookup.ts+7-1
......@@ -25,7 +25,13 @@ function basenameOfPath(path: string): string {
2525
2626export function needsWorkspaceBasenameLookup(relativePath: string): boolean {
2727 const trimmed = relativePath.trim();
28 return trimmed.length > 0 && !trimmed.includes("/") && !trimmed.includes("\\");
28 return (
29 trimmed !== "." &&
30 trimmed !== ".." &&
31 trimmed.length > 0 &&
32 !trimmed.includes("/") &&
33 !trimmed.includes("\\")
34 );
2935}
3036
3137export function pickWorkspaceBasenameMatch(
packages/client-runtime/src/markdownLinks.test.ts+5
......@@ -155,6 +155,11 @@ describe("fileBasename", () => {
155155
156156describe("workspaceRelativeFilePath", () => {
157157 it.each([
158 ["/repo/project", "/repo/project", "."],
159 ["/repo/project/", "/repo/project/", "."],
160 ["/", "/", "."],
161 ["C:/USERS/mike/project", "c:/users/MIKE/project", "."],
162 ["C:/", "c:/", "."],
158163 ["/repo/project/src/main.ts", "/repo/project", "src/main.ts"],
159164 ["/repo/project/src/main.ts", "/repo/project/", "src/main.ts"],
160165 ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"],
packages/client-runtime/src/markdownLinks.ts+1
......@@ -340,6 +340,7 @@ export function workspaceRelativeFilePath(
340340 const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot));
341341 const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath;
342342 const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot;
343 if (pathForCompare.replace(/\/+$/, "") === rootForCompare) return ".";
343344 if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null;
344345 return normalizedPath.slice(normalizedRoot.length + 1);
345346}