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 {...@@ -7,6 +7,7 @@ import {
7} from "./fileCommentAnnotations";7} from "./fileCommentAnnotations";
8import {8import {
9 isMarkdownPreviewFile,9 isMarkdownPreviewFile,
10 resolveFilePreviewPath,
10 setMarkdownTaskChecked,11 setMarkdownTaskChecked,
11 shouldShowFileExplorer,12 shouldShowFileExplorer,
12} from "./filePreviewMode";13} from "./filePreviewMode";
...@@ -120,3 +121,24 @@ describe("setMarkdownTaskChecked", () => {...@@ -120,3 +121,24 @@ describe("setMarkdownTaskChecked", () => {
120 expect(setMarkdownTaskChecked(markdown, 200, true)).toBe(markdown);121 expect(setMarkdownTaskChecked(markdown, 200, true)).toBe(markdown);
121 });122 });
122});123});
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";...@@ -81,6 +81,7 @@ import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";
81import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";81import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";
82import {82import {
83 isMarkdownPreviewFile,83 isMarkdownPreviewFile,
84 resolveFilePreviewPath,
84 setMarkdownTaskChecked,85 setMarkdownTaskChecked,
85 shouldShowFileExplorer,86 shouldShowFileExplorer,
86} from "./filePreviewMode";87} from "./filePreviewMode";
...@@ -907,7 +908,7 @@ export default function FilePreviewPanel({...@@ -907,7 +908,7 @@ export default function FilePreviewPanel({
907 environmentId,908 environmentId,
908 cwd,909 cwd,
909 projectName,910 projectName,
910 relativePath,911 relativePath: requestedPath,
911 attachment,912 attachment,
912 threadRef,913 threadRef,
913 composerDraftTarget,914 composerDraftTarget,
...@@ -920,6 +921,8 @@ export default function FilePreviewPanel({...@@ -920,6 +921,8 @@ export default function FilePreviewPanel({
920 selectedFilePending,921 selectedFilePending,
921 workspaceMutationId,922 workspaceMutationId,
922}: FilePreviewPanelProps) {923}: FilePreviewPanelProps) {
924 const relativePath =
925 attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath;
923 const { resolvedTheme } = useTheme();926 const { resolvedTheme } = useTheme();
924 const wordWrap = useClientSettings((settings) => settings.wordWrap);927 const wordWrap = useClientSettings((settings) => settings.wordWrap);
925 const primaryEnvironmentId = usePrimaryEnvironmentId();928 const primaryEnvironmentId = usePrimaryEnvironmentId();
...@@ -945,7 +948,12 @@ export default function FilePreviewPanel({...@@ -945,7 +948,12 @@ export default function FilePreviewPanel({
945 // shown. The read still runs: a folder named `assets.png` is only knowable as a948 // shown. The read still runs: a folder named `assets.png` is only knowable as a
946 // folder from the read failure, and the server stats before reading, so a folder949 // folder from the read failure, and the server stats before reading, so a folder
947 // costs an open and a stat and returns no body.950 // 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 );
949 // A chat link cannot tell a folder from a file, so a folder arrives here as957 // A chat link cannot tell a folder from a file, so a folder arrives here as
950 // a file surface and the read fails. Keep the breadcrumbs, drop the preview958 // a file surface and the read fails. Keep the breadcrumbs, drop the preview
951 // pane, and let the tree fill the surface with the folder revealed. Mutation959 // 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 @@...@@ -1,5 +1,12 @@
1import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links";
1import { isAbsolutePath } from "~/terminal-links";2import { 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
3export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path);10export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path);
411
5export function shouldShowFileExplorer(input: {12export function shouldShowFileExplorer(input: {
apps/web/src/markdown-links.test.ts+8
...@@ -522,3 +522,11 @@ describe("directory paths with a trailing separator", () => {...@@ -522,3 +522,11 @@ describe("directory paths with a trailing separator", () => {
522 expect(meta?.basename).not.toBe("");522 expect(meta?.basename).not.toBe("");
523 });523 });
524});524});
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", () => {...@@ -447,6 +447,24 @@ describe("rightPanelStore", () => {
447 });447 });
448 });448 });
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
450 it("replaces the standalone explorer with peer file surfaces", () => {468 it("replaces the standalone explorer with peer file surfaces", () => {
451 useRightPanelStore.getState().open(refA, "files");469 useRightPanelStore.getState().open(refA, "files");
452 useRightPanelStore.getState().openFile(refA, "src/index.ts");470 useRightPanelStore.getState().openFile(refA, "src/index.ts");
apps/web/src/rightPanelStore.ts+3
...@@ -578,6 +578,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -578,6 +578,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
578 openFile: (ref, requestedPath, line) =>578 openFile: (ref, requestedPath, line) =>
579 set((state) =>579 set((state) =>
580 userAction(state, scopedThreadKey(ref), (current) => {580 userAction(state, scopedThreadKey(ref), (current) => {
581 if (requestedPath === ".") {
582 return upsertSurface(current, singletonSurface("files"));
583 }
581 // Workspace entry paths use '/', including on Windows.584 // Workspace entry paths use '/', including on Windows.
582 const relativePath = /^[A-Za-z]:\/+$/.test(requestedPath)585 const relativePath = /^[A-Za-z]:\/+$/.test(requestedPath)
583 ? requestedPath586 ? requestedPath
apps/web/src/workspaceBasenameLookup.test.ts+2
...@@ -15,6 +15,8 @@ describe("needsWorkspaceBasenameLookup", () => {...@@ -15,6 +15,8 @@ describe("needsWorkspaceBasenameLookup", () => {
15 it("leaves anything with a directory alone", () => {15 it("leaves anything with a directory alone", () => {
16 expect(needsWorkspaceBasenameLookup("apps/web/src/components/ChatView.tsx")).toBe(false);16 expect(needsWorkspaceBasenameLookup("apps/web/src/components/ChatView.tsx")).toBe(false);
17 expect(needsWorkspaceBasenameLookup("apps\\web\\ChatView.tsx")).toBe(false);17 expect(needsWorkspaceBasenameLookup("apps\\web\\ChatView.tsx")).toBe(false);
18 expect(needsWorkspaceBasenameLookup(".")).toBe(false);
19 expect(needsWorkspaceBasenameLookup("..")).toBe(false);
18 expect(needsWorkspaceBasenameLookup(" ")).toBe(false);20 expect(needsWorkspaceBasenameLookup(" ")).toBe(false);
19 });21 });
20});22});
apps/web/src/workspaceBasenameLookup.ts+7-1
...@@ -25,7 +25,13 @@ function basenameOfPath(path: string): string {...@@ -25,7 +25,13 @@ function basenameOfPath(path: string): string {
2525
26export function needsWorkspaceBasenameLookup(relativePath: string): boolean {26export function needsWorkspaceBasenameLookup(relativePath: string): boolean {
27 const trimmed = relativePath.trim();27 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 );
29}35}
3036
31export function pickWorkspaceBasenameMatch(37export function pickWorkspaceBasenameMatch(
packages/client-runtime/src/markdownLinks.test.ts+5
...@@ -155,6 +155,11 @@ describe("fileBasename", () => {...@@ -155,6 +155,11 @@ describe("fileBasename", () => {
155155
156describe("workspaceRelativeFilePath", () => {156describe("workspaceRelativeFilePath", () => {
157 it.each([157 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:/", "."],
158 ["/repo/project/src/main.ts", "/repo/project", "src/main.ts"],163 ["/repo/project/src/main.ts", "/repo/project", "src/main.ts"],
159 ["/repo/project/src/main.ts", "/repo/project/", "src/main.ts"],164 ["/repo/project/src/main.ts", "/repo/project/", "src/main.ts"],
160 ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"],165 ["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(...@@ -340,6 +340,7 @@ export function workspaceRelativeFilePath(
340 const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot));340 const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot));
341 const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath;341 const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath;
342 const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot;342 const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot;
343 if (pathForCompare.replace(/\/+$/, "") === rootForCompare) return ".";
343 if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null;344 if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null;
344 return normalizedPath.slice(normalizedRoot.length + 1);345 return normalizedPath.slice(normalizedRoot.length + 1);
345}346}