diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index d47aaaec826..4094f6e924c 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -651,13 +651,63 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect(result).toEqual({ parentPath: cwd, entries: [ - { name: "alpha", fullPath: path.join(cwd, "alpha") }, - { name: "alpine", fullPath: path.join(cwd, "alpine") }, + { name: "alpha", fullPath: path.join(cwd, "alpha"), kind: "directory" }, + { name: "alpine", fullPath: path.join(cwd, "alpine"), kind: "directory" }, ], }); }), ); + it.effect("returns requested kinds in directory-first order and applies the limit", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-kinds-" }); + yield* writeTextFile(cwd, "alpha/index.ts", "export {};\n"); + yield* writeTextFile(cwd, "zeta/index.ts", "export {};\n"); + yield* writeTextFile(cwd, "alphabet.txt", "a"); + yield* writeTextFile(cwd, "beta.txt", "b"); + + const result = yield* workspaceEntries.browse({ + partialPath: yield* appendSeparator(cwd), + kinds: ["file", "directory"], + limit: 3, + }); + const filteredResult = yield* workspaceEntries.browse({ + partialPath: path.join(cwd, "b"), + kinds: ["file", "directory"], + limit: 1, + }); + + expect(result).toEqual({ + parentPath: cwd, + entries: [ + { name: "alpha", fullPath: path.join(cwd, "alpha"), kind: "directory" }, + { name: "zeta", fullPath: path.join(cwd, "zeta"), kind: "directory" }, + { name: "alphabet.txt", fullPath: path.join(cwd, "alphabet.txt"), kind: "file" }, + ], + }); + expect(filteredResult.entries).toEqual([ + { name: "beta.txt", fullPath: path.join(cwd, "beta.txt"), kind: "file" }, + ]); + }), + ); + + it.effect("returns no entries when no kinds are requested", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-empty-kinds-" }); + yield* writeTextFile(cwd, "src/index.ts", "export {};\n"); + + const result = yield* workspaceEntries.browse({ + partialPath: yield* appendSeparator(cwd), + kinds: [], + }); + + expect(result).toEqual({ parentPath: cwd, entries: [] }); + }), + ); + it.effect("shows dot directories in directory mode and hidden-prefix mode", () => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; @@ -677,7 +727,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]); expect(hiddenPrefixResult).toEqual({ parentPath: cwd, - entries: [{ name: ".config", fullPath: path.join(cwd, ".config") }], + entries: [{ name: ".config", fullPath: path.join(cwd, ".config"), kind: "directory" }], }); }), ); @@ -696,7 +746,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect(result).toEqual({ parentPath: cwd, - entries: [{ name: "packages", fullPath: path.join(cwd, "packages") }], + entries: [{ name: "packages", fullPath: path.join(cwd, "packages"), kind: "directory" }], }); }), ); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index bb2113dac37..ac306066e8a 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -10,6 +10,7 @@ import * as RcMap from "effect/RcMap"; import * as Schema from "effect/Schema"; import type { + FilesystemBrowseEntry, FilesystemBrowseInput, FilesystemBrowseResult, ProjectListEntriesInput, @@ -216,23 +217,39 @@ export const make = Effect.gen(function* () { const showHidden = endsWithSeparator || prefix.startsWith("."); const lowerPrefix = prefix.toLowerCase(); - const entries: Array<{ readonly name: string; readonly fullPath: string }> = []; + const requestedKinds = new Set(input.kinds ?? ["directory"]); + const entries: FilesystemBrowseEntry[] = []; for (const dirent of dirents) { + const kind = dirent.isDirectory() ? "directory" : dirent.isFile() ? "file" : null; if ( - dirent.isDirectory() && + kind !== null && + requestedKinds.has(kind) && dirent.name.toLowerCase().startsWith(lowerPrefix) && (showHidden || !dirent.name.startsWith(".")) ) { entries.push({ name: dirent.name, fullPath: path.join(parentPath, dirent.name), + kind, }); } } + const byName = (left: FilesystemBrowseEntry, right: FilesystemBrowseEntry) => + left.name.localeCompare(right.name); + const directories = entries.filter((entry) => entry.kind === "directory").toSorted(byName); + const files = entries.filter((entry) => entry.kind !== "directory").toSorted(byName); + + // Truncating the directories-first order would hide every file behind a + // large enough set of directories, so each kind keeps a share of the limit. + const limit = input.limit ?? directories.length + files.length; + const fileCount = Math.min(files.length, Math.max(0, limit - directories.length)); + const keptFiles = files.slice(0, Math.max(fileCount, Math.min(files.length, limit >> 1))); + const keptDirectories = directories.slice(0, limit - keptFiles.length); + return { parentPath, - entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), + entries: [...keptDirectories, ...keptFiles], }; }, ); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 1335e6bb05b..c66ba013dec 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -68,6 +68,8 @@ import { import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { normalizeMarkdownLinkDestination, + preserveWindowsMarkdownFileHref, + resolveCanonicalMarkdownFileLinkMeta, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, @@ -734,6 +736,7 @@ function UncachedShikiCodeBlock({ interface MarkdownFileLinkProps { href: string; targetPath: string; + openTargetPath: string | null; iconPath: string; displayPath: string; workspaceRelativePath: string | null; @@ -747,9 +750,24 @@ interface MarkdownFileLinkProps { className?: string | undefined; } -const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; -const MARKDOWN_FILE_LINK_CLASS_NAME = - "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70"; +const MARKDOWN_LINK_PATTERN = /\[((?:\\.|[^\]\\])*)]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; +const WHOLE_MARKDOWN_LINK_PATTERN = new RegExp(`^${MARKDOWN_LINK_PATTERN.source}$`); +const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link transition-colors"; + +/** + * Nodes recovered by `remarkNormalizeListItemIndentation` carry offsets into a + * synthetic reparsed source, so slicing the original text by them yields the + * wrong span. Fall back to a reconstruction unless the slice really is a link. + */ +function authoredMarkdownLinkSource( + text: string, + start: number | undefined, + end: number | undefined, +): string | null { + if (typeof start !== "number" || typeof end !== "number" || end > text.length) return null; + const slice = text.slice(start, end); + return WHOLE_MARKDOWN_LINK_PATTERN.test(slice) ? slice : null; +} function pathParentSegments(path: string): string[] { const normalized = path.replaceAll("\\", "/"); @@ -826,14 +844,44 @@ function extractInlineCodeSpans(text: string): string[] { return spans; } -function extractMarkdownLinkHrefs(text: string): string[] { - const hrefs: string[] = []; - for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { - const href = match[1]?.trim(); +const WRAPPED_LABEL_DELIMITERS = ["***", "___", "**", "__", "*", "_", "`"]; + +/** Unwraps a fully wrapped label (`*name*`) to the text the parser will render. */ +function markdownLabelInnerText(label: string): string { + let inner = label; + let unwrapping = true; + while (unwrapping) { + unwrapping = false; + for (const delimiter of WRAPPED_LABEL_DELIMITERS) { + if ( + inner.length > delimiter.length * 2 && + inner.startsWith(delimiter) && + inner.endsWith(delimiter) + ) { + inner = inner.slice(delimiter.length, -delimiter.length); + unwrapping = true; + break; + } + } + } + return inner; +} + +function extractMarkdownLinks(text: string): Array<{ label: string; href: string }> { + const links: Array<{ label: string; href: string }> = []; + for (const match of text.matchAll(MARKDOWN_LINK_PATTERN)) { + const href = match[2]?.trim(); if (!href) continue; - hrefs.push(href); + links.push({ + label: (match[1] ?? "").replace(/\\(.)/g, "$1"), + href, + }); } - return hrefs; + return links; +} + +function canonicalMarkdownLinkKey(label: string, href: string): string { + return `${label}\0${href}`; } function normalizeMarkdownLinkHrefKey(href: string): string { @@ -884,6 +932,21 @@ function breakableExternalLinkText(text: string): ReactNode[] { )); } +/** + * Link labels reach the renderer as parsed nodes, so `[*name*](path)` arrives as + * an `em` wrapping the text. Reading through inline formatting keeps the lookup + * key aligned with the label text indexed from the source. + */ +function inlineHastText(node: unknown): string | null { + if (!node || typeof node !== "object") return null; + if ("type" in node && node.type === "text") { + return "value" in node && typeof node.value === "string" ? node.value : null; + } + if (!("children" in node) || !Array.isArray(node.children)) return null; + const parts = node.children.map((child) => inlineHastText(child)); + return parts.every((part) => part !== null) ? parts.join("") : null; +} + function plainHastText(node: unknown): string | null { if (!node || typeof node !== "object" || !("children" in node) || !Array.isArray(node.children)) { return null; @@ -1017,6 +1080,7 @@ function MarkdownExternalLinkContent({ const MarkdownFileLink = memo(function MarkdownFileLink({ href, targetPath, + openTargetPath, iconPath, displayPath, workspaceRelativePath, @@ -1030,14 +1094,15 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { + if (openTargetPath === null) return; void (async () => { try { - const result = await onOpen(targetPath); + const result = await onOpen(openTargetPath); if (result._tag === "Success" || isAtomCommandInterrupted(result)) { return; } reportMarkdownActionFailure( - { operation: "open-file-in-editor", target: targetPath }, + { operation: "open-file-in-editor", target: openTargetPath }, result.cause, ); const error = squashAtomCommandFailure(result); @@ -1050,7 +1115,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); } catch (cause) { reportMarkdownActionFailure( - { operation: "open-file-in-editor", target: targetPath }, + { operation: "open-file-in-editor", target: openTargetPath }, cause, ); toastManager.add( @@ -1062,7 +1127,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); } })(); - }, [onOpen, targetPath]); + }, [onOpen, openTargetPath]); const handleOpenInFilePreview = useCallback(() => { if (!threadRef || !workspaceRelativePath) { @@ -1150,7 +1215,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); const handleContextMenu = useCallback( - async (event: ReactMouseEvent) => { + async (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); @@ -1160,7 +1225,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ try { const clicked = await api.contextMenu.show( [ - { id: "open", label: "Open in editor" }, + ...(openTargetPath ? ([{ id: "open", label: "Open in editor" }] as const) : []), ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), @@ -1192,32 +1257,53 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); } }, - [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], + [ + displayPath, + handleCopy, + handleOpenInBrowser, + handleOpenInEditor, + onOpenInBrowser, + openTargetPath, + targetPath, + ], + ); + + const chip = openTargetPath ? ( + { + event.preventDefault(); + event.stopPropagation(); + if (onOpenInBrowser) { + handleOpenInBrowser(); + return; + } + handleOpenInFilePreview(); + }} + onContextMenu={handleContextMenu} + > + + + ) : ( + + + ); return ( - { - event.preventDefault(); - event.stopPropagation(); - if (onOpenInBrowser) { - handleOpenInBrowser(); - return; - } - handleOpenInFilePreview(); - }} - onContextMenu={handleContextMenu} - > - - - } - /> + > >(); - for (const href of extractMarkdownLinkHrefs(text)) { + for (const { href } of extractMarkdownLinks(text)) { const normalizedHref = normalizeMarkdownLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); @@ -1291,6 +1378,23 @@ function ChatMarkdown({ } return metaByHref; }, [cwd, text]); + const canonicalFileLinkMetaByKey = useMemo(() => { + const metaByKey = new Map(); + for (const { label, href } of extractMarkdownLinks(text)) { + const normalizedHref = normalizeMarkdownLinkHrefKey(href); + // A formatted label such as `*name*` renders as its inner text, so index + // both forms and let whichever the renderer reports find the entry. + for (const candidate of new Set([label, markdownLabelInnerText(label)])) { + const key = canonicalMarkdownLinkKey(candidate, normalizedHref); + if (metaByKey.has(key)) continue; + const meta = resolveCanonicalMarkdownFileLinkMeta(candidate, normalizedHref, cwd); + if (meta) { + metaByKey.set(key, meta); + } + } + } + return metaByKey; + }, [cwd, text]); const inlineCodeFileLinkMetaByText = useMemo(() => { const metaByText = new Map(); for (const span of extractInlineCodeSpans(text)) { @@ -1305,12 +1409,17 @@ function ChatMarkdown({ const fileLinkParentSuffixByPath = useMemo(() => { const filePaths = [ ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), + ...[...canonicalFileLinkMetaByKey.values()].map((meta) => meta.filePath), ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), ]; return buildFileLinkParentSuffixByPath(filePaths); - }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); + }, [canonicalFileLinkMetaByKey, inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { - return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); + return ( + rewriteMarkdownFileUriHref(href) ?? + preserveWindowsMarkdownFileHref(href) ?? + defaultUrlTransform(href) + ); }, []); // Re-emit highlighted content as markdown so copying out of the rendered // view keeps links, emphasis, lists, and code fences intact. @@ -1387,6 +1496,7 @@ function ChatMarkdown({ (() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { + if (isFilesystemPathTrigger) { + return filesystemEntries.entries.map((entry) => ({ + id: `filesystem-path:${entry.kind ?? "directory"}:${entry.fullPath}`, + type: "path" as const, + path: composerFilesystemSuggestionPath(pathTriggerQuery, entry.name), + pathKind: entry.kind ?? "directory", + label: entry.name, + description: composerFilesystemSuggestionParentPath(pathTriggerQuery), + })); + } return workspaceEntries.entries.map((entry) => ({ id: `path:${entry.kind}:${entry.path}`, type: "path", @@ -1140,7 +1170,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); } return []; - }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); + }, [ + composerTrigger, + filesystemEntries.entries, + isFilesystemPathTrigger, + pathTriggerQuery, + selectedProvider, + selectedProviderStatus, + workspaceEntries.entries, + ]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -1205,7 +1243,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const isComposerMenuLoading = - composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending; + composerTriggerKind === "path" && + pathTriggerQuery.length > 0 && + (isFilesystemPathTrigger ? filesystemEntries.isPending : workspaceEntries.isPending); const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; diff --git a/apps/web/src/composer-file-link-markdown.test.tsx b/apps/web/src/composer-file-link-markdown.test.tsx new file mode 100644 index 00000000000..f3eb16e9b35 --- /dev/null +++ b/apps/web/src/composer-file-link-markdown.test.tsx @@ -0,0 +1,35 @@ +import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { describe, expect, it } from "vite-plus/test"; +import ChatMarkdown from "./components/ChatMarkdown"; + +describe("composer file link markdown", () => { + it("keeps markdown syntax in a filename as plain link text", () => { + const markdown = serializeComposerFileLink("/custom/*draft* &"); + const markup = renderToStaticMarkup({markdown}); + + expect(markup).toContain('href="/custom/*draft*%20%26amp;"'); + expect(markup).toContain(">*draft* &amp;"); + expect(markup).not.toContain(""); + }); + + it("renders a chip when the label carries inline formatting", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("chat-markdown-file-link"); + }); + + it("keeps pipes in filenames inside GFM table cells", () => { + const markdown = `| File |\n| --- |\n| ${serializeComposerFileLink("/tmp/a|b")} |`; + const markup = renderToStaticMarkup( + {markdown}, + ); + + expect(markup).toContain('href="/tmp/a%7Cb"'); + expect(markup).toContain(">a|b"); + }); +}); diff --git a/apps/web/src/lib/composerFilesystemBrowse.test.ts b/apps/web/src/lib/composerFilesystemBrowse.test.ts new file mode 100644 index 00000000000..f83511467af --- /dev/null +++ b/apps/web/src/lib/composerFilesystemBrowse.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + canBrowseComposerFilesystemPath, + composerFilesystemSuggestionPath, + isComposerFilesystemPathQuery, +} from "./composerFilesystemBrowse"; + +describe("composer filesystem browsing", () => { + it.each([ + ["~/Sites/t3", "t3code", "~/Sites/t3code"], + ["../sha", "shared", "../shared"], + ["/tmp/rep", "report.md", "/tmp/report.md"], + ["C:\\Users\\ch", "chris", "C:\\Users\\chris"], + ])("preserves the typed parent for %s", (query, name, expected) => { + expect(composerFilesystemSuggestionPath(query, name)).toBe(expected); + }); + + it("routes explicit paths according to platform and cwd", () => { + expect(isComposerFilesystemPathQuery("./src", "linux")).toBe(true); + expect(canBrowseComposerFilesystemPath("~/src", null, "linux")).toBe(true); + expect(canBrowseComposerFilesystemPath("/tmp", null, "linux")).toBe(true); + expect(canBrowseComposerFilesystemPath("./src", null, "linux")).toBe(false); + expect(canBrowseComposerFilesystemPath("../src", "/repo", "linux")).toBe(true); + expect(canBrowseComposerFilesystemPath("C:\\Users\\ch", null, "linux")).toBe(false); + expect(canBrowseComposerFilesystemPath("C:\\Users\\ch", null, "win32")).toBe(true); + expect(canBrowseComposerFilesystemPath("component", "/repo", "linux")).toBe(false); + }); +}); diff --git a/apps/web/src/lib/composerFilesystemBrowse.ts b/apps/web/src/lib/composerFilesystemBrowse.ts new file mode 100644 index 00000000000..1254a8d1c3d --- /dev/null +++ b/apps/web/src/lib/composerFilesystemBrowse.ts @@ -0,0 +1,28 @@ +import { + getBrowseDirectoryPath, + isExplicitRelativeProjectPath, + isFilesystemBrowseQuery, +} from "./projectPaths"; + +export function canBrowseComposerFilesystemPath( + query: string, + cwd: string | null, + platform: string, +): boolean { + return ( + isFilesystemBrowseQuery(query, platform) && + (!isExplicitRelativeProjectPath(query) || cwd !== null) + ); +} + +export function isComposerFilesystemPathQuery(query: string, platform: string): boolean { + return isFilesystemBrowseQuery(query, platform); +} + +export function composerFilesystemSuggestionParentPath(query: string): string { + return getBrowseDirectoryPath(query); +} + +export function composerFilesystemSuggestionPath(query: string, entryName: string): string { + return `${getBrowseDirectoryPath(query)}${entryName}`; +} diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 9fc29613867..aab65429c16 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -1,12 +1,100 @@ import { describe, expect, it } from "vite-plus/test"; import { + preserveWindowsMarkdownFileHref, + resolveCanonicalMarkdownFileLinkMeta, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref, } from "./markdown-links"; +describe("resolveCanonicalMarkdownFileLinkMeta", () => { + it("recognizes absolute extensionless paths outside conventional roots", () => { + expect(resolveCanonicalMarkdownFileLinkMeta("data", "/custom/mount/data")).toMatchObject({ + filePath: "/custom/mount/data", + openTargetPath: "/custom/mount/data", + basename: "data", + }); + }); + + it("resolves relative canonical paths against the cwd", () => { + expect(resolveCanonicalMarkdownFileLinkMeta("shared", "../shared", "/repo/app")).toMatchObject({ + targetPath: "/repo/shared", + openTargetPath: "/repo/shared", + workspaceRelativePath: null, + }); + }); + + it("keeps unresolved home paths displayable but inert", () => { + expect(resolveCanonicalMarkdownFileLinkMeta("project", "~/project")).toMatchObject({ + targetPath: "~/project", + openTargetPath: null, + basename: "project", + }); + }); + + it("rejects label mismatches and external links", () => { + expect(resolveCanonicalMarkdownFileLinkMeta("other", "/custom/mount/data")).toBeNull(); + expect(resolveCanonicalMarkdownFileLinkMeta("docs", "https://example.com/docs")).toBeNull(); + }); + + it("does not interpret numeric filename suffixes as source positions", () => { + const meta = resolveCanonicalMarkdownFileLinkMeta("report:12", "/tmp/report:12"); + expect(meta).toMatchObject({ + filePath: "/tmp/report:12", + openTargetPath: "/tmp/report:12", + basename: "report:12", + }); + expect(meta?.line).toBeUndefined(); + }); + + it("keeps a scoped package reference displayable but inert instead of resolving under cwd", () => { + expect( + resolveCanonicalMarkdownFileLinkMeta("package.json", "@scope/package.json", "/repo"), + ).toMatchObject({ + targetPath: "@scope/package.json", + openTargetPath: null, + basename: "package.json", + }); + }); + + it("still resolves an ordinary relative path against the cwd", () => { + expect(resolveCanonicalMarkdownFileLinkMeta("index.ts", "src/index.ts", "/repo")).toMatchObject( + { + targetPath: "/repo/src/index.ts", + openTargetPath: "/repo/src/index.ts", + }, + ); + }); + + it("normalizes absolute paths before workspace containment", () => { + expect( + resolveCanonicalMarkdownFileLinkMeta("page.html", "/repo/../outside/page.html", "/repo"), + ).toMatchObject({ workspaceRelativePath: null }); + }); + + it("uses platform-appropriate casing for workspace containment", () => { + expect(resolveCanonicalMarkdownFileLinkMeta("Page.ts", "/Repo/Page.ts", "/repo")).toMatchObject( + { workspaceRelativePath: null }, + ); + expect( + resolveCanonicalMarkdownFileLinkMeta("Page.ts", "C:/Repo/Page.ts", "c:/repo"), + ).toMatchObject({ workspaceRelativePath: "Page.ts" }); + }); +}); + +describe("preserveWindowsMarkdownFileHref", () => { + it("preserves only Windows drive path destinations", () => { + expect(preserveWindowsMarkdownFileHref("C:%5CUsers%5Cme%5Cfile.ts")).toBe( + "C:%5CUsers%5Cme%5Cfile.ts", + ); + expect(preserveWindowsMarkdownFileHref("C:/Users/me/file.ts")).toBe("C:/Users/me/file.ts"); + expect(preserveWindowsMarkdownFileHref("custom:thing")).toBeNull(); + expect(preserveWindowsMarkdownFileHref("https://example.com")).toBeNull(); + }); +}); + describe("rewriteMarkdownFileUriHref", () => { it("rewrites file uri hrefs into direct path hrefs", () => { expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe( diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index a6dba941b8a..8d8feafec4e 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,4 +1,10 @@ +import { + decodeCanonicalComposerFileLinkPath, + isScopedPackageReferencePath, +} from "@t3tools/shared/composerInlineTokens"; + import { formatWorkspaceRelativePath } from "./filePathDisplay"; +import { isExplicitRelativeProjectPath, resolveProjectPathForDispatch } from "./lib/projectPaths"; import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links"; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; @@ -41,6 +47,7 @@ const POSIX_FILE_ROOT_PREFIXES = [ export interface MarkdownFileLinkMeta { filePath: string; targetPath: string; + openTargetPath: string | null; displayPath: string; workspaceRelativePath: string | null; basename: string; @@ -108,6 +115,11 @@ export function rewriteMarkdownFileUriHref(href: string | undefined): string | n return `${target.path}${target.hash}`; } +export function preserveWindowsMarkdownFileHref(href: string): string | null { + const normalizedHref = normalizeMarkdownLinkDestination(href); + return /^[A-Za-z]:(?:[\\/]|%5c)/i.test(normalizedHref) ? normalizedHref : null; +} + function looksLikePosixFilesystemPath(path: string): boolean { if (!path.startsWith("/")) return false; if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; @@ -365,15 +377,44 @@ function basenameOfPath(path: string): string { function workspaceRelativePath(path: string, workspaceRoot: string | undefined): string | null { if (!workspaceRoot) return null; - const normalizedPath = normalizeWindowsDrivePath(path.replaceAll("\\", "/")); - const normalizedRoot = normalizeWindowsDrivePath(workspaceRoot.replaceAll("\\", "/")).replace( - /\/+$/, - "", - ); - const pathForCompare = normalizedPath.toLowerCase(); - const rootForCompare = normalizedRoot.toLowerCase(); - if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null; - return normalizedPath.slice(normalizedRoot.length + 1); + const normalizedPath = normalizeAbsolutePathForContainment(path); + const normalizedRoot = normalizeAbsolutePathForContainment(workspaceRoot); + if (!normalizedPath || !normalizedRoot) return null; + const caseInsensitive = normalizedPath.windowsStyle && normalizedRoot.windowsStyle; + const pathForCompare = caseInsensitive + ? normalizedPath.value.toLowerCase() + : normalizedPath.value; + const rootForCompare = caseInsensitive + ? normalizedRoot.value.toLowerCase() + : normalizedRoot.value; + const rootPrefix = rootForCompare.endsWith("/") ? rootForCompare : `${rootForCompare}/`; + if (!pathForCompare.startsWith(rootPrefix)) return null; + return normalizedPath.value.slice(rootPrefix.length); +} + +function normalizeAbsolutePathForContainment( + path: string, +): { readonly value: string; readonly windowsStyle: boolean } | null { + const normalized = normalizeWindowsDrivePath(path.replaceAll("\\", "/")); + const windowsDrive = /^[A-Za-z]:\//.exec(normalized)?.[0]; + const windowsStyle = windowsDrive !== undefined || normalized.startsWith("//"); + const root = + windowsDrive ?? (normalized.startsWith("//") ? "//" : normalized.startsWith("/") ? "/" : null); + if (root === null) return null; + + const segments: string[] = []; + for (const segment of normalized.slice(root.length).split("/")) { + if (!segment || segment === ".") continue; + if (segment === "..") { + segments.pop(); + } else { + segments.push(segment); + } + } + return { + value: segments.length === 0 ? root : `${root}${segments.join("/")}`, + windowsStyle, + }; } export function resolveMarkdownFileLinkMeta( @@ -385,8 +426,51 @@ export function resolveMarkdownFileLinkMeta( return buildFileLinkMetaFromTarget(targetPath, cwd); } -function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta { - const { path, line, column } = splitPathAndPosition(targetPath); +export function resolveCanonicalMarkdownFileLinkMeta( + label: string, + href: string, + cwd?: string, +): MarkdownFileLinkMeta | null { + const authoredPath = decodeCanonicalComposerFileLinkPath( + label, + normalizeMarkdownLinkDestination(href), + ); + if (authoredPath === null) return null; + + if (!isRelativePath(authoredPath)) { + return buildFileLinkMetaFromTarget(authoredPath, cwd, { parsePosition: false }); + } + if (!cwd || isScopedPackageReferencePath(authoredPath)) { + return buildFileLinkMetaFromTarget(authoredPath, cwd, { + openTargetPath: null, + parsePosition: false, + }); + } + + const resolvedPath = isExplicitRelativeProjectPath(authoredPath) + ? resolveProjectPathForDispatch(authoredPath, cwd) + : resolvePathLinkTarget(authoredPath, cwd); + if (resolvedPath.startsWith("~/")) { + return buildFileLinkMetaFromTarget(authoredPath, cwd, { + openTargetPath: null, + parsePosition: false, + }); + } + return buildFileLinkMetaFromTarget(resolvedPath, cwd, { parsePosition: false }); +} + +function buildFileLinkMetaFromTarget( + targetPath: string, + cwd?: string, + options: { + readonly openTargetPath?: string | null; + readonly parsePosition?: boolean; + } = {}, +): MarkdownFileLinkMeta { + const { path, line, column } = + options.parsePosition === false + ? { path: targetPath, line: undefined, column: undefined } + : splitPathAndPosition(targetPath); const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN; const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN; const lineNumber = Number.isFinite(parsedLine) ? parsedLine : undefined; @@ -395,6 +479,7 @@ function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): Markdown return { filePath: path, targetPath, + openTargetPath: options.openTargetPath === undefined ? targetPath : options.openTargetPath, displayPath: formatWorkspaceRelativePath(targetPath, cwd), workspaceRelativePath: workspaceRelativePath(path, cwd), basename: basenameOfPath(path), diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 2a095b8f584..ddc7f0a8611 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -11,6 +11,7 @@ import { import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, + FilesystemBrowseEntry, OrchestrationThread, ProjectContentMatch, ProjectEntryKind, @@ -24,6 +25,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { filesystemEnvironment } from "./filesystem"; import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; import { projectContentSearch, projectEnvironment } from "./projects"; @@ -33,6 +35,7 @@ import { vcsEnvironment } from "./vcs"; const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120; const COMPOSER_PATH_SEARCH_LIMIT = 80; +const COMPOSER_FILESYSTEM_BROWSE_LIMIT = 80; const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120; const PROJECT_CONTENT_SEARCH_LIMIT = 500; const THREAD_SEARCH_DEBOUNCE_MS = 200; @@ -297,6 +300,59 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT); } +interface ComposerFilesystemBrowseTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query: string | null; +} + +const EMPTY_FILESYSTEM_ENTRIES: ReadonlyArray = []; + +function areComposerFilesystemBrowseTargetsEqual( + left: ComposerFilesystemBrowseTarget, + right: ComposerFilesystemBrowseTarget, +): boolean { + return ( + left.environmentId === right.environmentId && + left.cwd === right.cwd && + left.query === right.query + ); +} + +export function useComposerFilesystemBrowse(target: ComposerFilesystemBrowseTarget) { + const normalizedTarget = useMemo( + () => ({ + environmentId: target.environmentId, + cwd: target.cwd, + query: target.query?.trim() || null, + }), + [target.cwd, target.environmentId, target.query], + ); + const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS); + const isSettled = areComposerFilesystemBrowseTargetsEqual(normalizedTarget, debouncedTarget); + const result = useEnvironmentQuery( + debouncedTarget.environmentId !== null && debouncedTarget.query !== null + ? filesystemEnvironment.browse({ + environmentId: debouncedTarget.environmentId, + input: { + partialPath: debouncedTarget.query, + ...(debouncedTarget.cwd === null ? {} : { cwd: debouncedTarget.cwd }), + kinds: ["file", "directory"], + limit: COMPOSER_FILESYSTEM_BROWSE_LIMIT, + }, + }) + : null, + ); + + return { + entries: isSettled + ? (result.data?.entries ?? EMPTY_FILESYSTEM_ENTRIES) + : EMPTY_FILESYSTEM_ENTRIES, + error: result.error, + isPending: !isSettled || result.isPending, + }; +} + interface ProjectContentSearchTarget { readonly environmentId: EnvironmentId | null; readonly cwd: string | null; diff --git a/packages/contracts/src/filesystem.test.ts b/packages/contracts/src/filesystem.test.ts index 45355b73edc..e930378e1a6 100644 --- a/packages/contracts/src/filesystem.test.ts +++ b/packages/contracts/src/filesystem.test.ts @@ -1,7 +1,43 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { FilesystemBrowseError } from "./filesystem.ts"; +import { + FILESYSTEM_BROWSE_MAX_LIMIT, + FilesystemBrowseEntry, + FilesystemBrowseError, + FilesystemBrowseInput, +} from "./filesystem.ts"; + +describe("filesystem browse schemas", () => { + it("decodes legacy and extended browse inputs", () => { + const decode = Schema.decodeUnknownSync(FilesystemBrowseInput); + expect(decode({ partialPath: "~/" })).toEqual({ partialPath: "~/" }); + expect(decode({ partialPath: "~/src", kinds: ["file", "directory"], limit: 50 })).toEqual({ + partialPath: "~/src", + kinds: ["file", "directory"], + limit: 50, + }); + expect(() => decode({ partialPath: "~/", limit: FILESYSTEM_BROWSE_MAX_LIMIT + 1 })).toThrow(); + }); + + it("accepts legacy entries without kind and new typed entries", () => { + const decode = Schema.decodeUnknownSync(FilesystemBrowseEntry); + expect(decode({ name: "src", fullPath: "/repo/src" })).toEqual({ + name: "src", + fullPath: "/repo/src", + }); + expect(decode({ name: "main.ts", fullPath: "/repo/main.ts", kind: "file" })).toEqual({ + name: "main.ts", + fullPath: "/repo/main.ts", + kind: "file", + }); + expect(decode({ name: "src", fullPath: "/repo/src", kind: "directory" })).toEqual({ + name: "src", + fullPath: "/repo/src", + kind: "directory", + }); + }); +}); describe("FilesystemBrowseError", () => { it("derives a stable message from browse context while retaining the cause", () => { diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index ca4519b4c8b..a4e331ae98a 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -1,17 +1,26 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; const FILESYSTEM_PATH_MAX_LENGTH = 512; +export const FILESYSTEM_BROWSE_MAX_LIMIT = 200; + +export const FilesystemBrowseKind = Schema.Literals(["file", "directory"]); +export type FilesystemBrowseKind = typeof FilesystemBrowseKind.Type; export const FilesystemBrowseInput = Schema.Struct({ partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), + kinds: Schema.optionalKey(Schema.Array(FilesystemBrowseKind)), + limit: Schema.optionalKey( + PositiveInt.check(Schema.isLessThanOrEqualTo(FILESYSTEM_BROWSE_MAX_LIMIT)), + ), }); export type FilesystemBrowseInput = typeof FilesystemBrowseInput.Type; export const FilesystemBrowseEntry = Schema.Struct({ name: TrimmedNonEmptyString, fullPath: TrimmedNonEmptyString, + kind: Schema.optionalKey(FilesystemBrowseKind), }); export type FilesystemBrowseEntry = typeof FilesystemBrowseEntry.Type; diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts index 5a7c14f1725..a234fbc5b16 100644 --- a/packages/shared/src/composerInlineTokens.test.ts +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -1,6 +1,37 @@ import { describe, expect, it } from "vite-plus/test"; -import { collectComposerInlineTokens } from "./composerInlineTokens.ts"; +import { + collectComposerInlineTokens, + decodeCanonicalComposerFileLinkPath, + isCanonicalComposerFileLink, + isScopedPackageReferencePath, +} from "./composerInlineTokens.ts"; + +describe("isCanonicalComposerFileLink", () => { + it.each([ + ["data", "/custom/mount/data"], + ["project", "~/Sites/project"], + ["shared", "../shared"], + ["file.ts", "C:%5CUsers%5Cme%5Cfile.ts"], + ["my file.txt", "/tmp/my%20file.txt"], + ])("accepts %s for %s", (label, path) => { + expect(isCanonicalComposerFileLink(label, path)).toBe(true); + }); + + it.each([ + ["other", "/custom/mount/data"], + ["docs", "https://example.com/docs"], + ["", ""], + ])("rejects %s for %s", (label, path) => { + expect(isCanonicalComposerFileLink(label, path)).toBe(false); + }); + + it("returns the decoded path", () => { + expect(decodeCanonicalComposerFileLinkPath("my file.txt", "/tmp/my%20file.txt")).toBe( + "/tmp/my file.txt", + ); + }); +}); describe("collectComposerInlineTokens", () => { it("collects file links, mentions, and skills with source ranges", () => { @@ -118,6 +149,20 @@ describe("collectComposerInlineTokens", () => { ]); }); + it.each(["@scope/package.json", "@expo/ui", "@jane/foo.js/deep"])( + "isScopedPackageReferencePath accepts %s", + (reference) => { + expect(isScopedPackageReferencePath(reference)).toBe(true); + }, + ); + + it.each(["src/index.ts", "./src/index.ts", "scope/package.json", "package.json"])( + "isScopedPackageReferencePath rejects %s", + (reference) => { + expect(isScopedPackageReferencePath(reference)).toBe(false); + }, + ); + it("allows ambiguous scoped paths through explicit quoted mentions", () => { expect(collectComposerInlineTokens('Inspect @"expo/ui" next')).toEqual([ { diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts index dda548059df..81c15bc3680 100644 --- a/packages/shared/src/composerInlineTokens.ts +++ b/packages/shared/src/composerInlineTokens.ts @@ -27,6 +27,36 @@ const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; const SCOPED_PACKAGE_REFERENCE_REGEX = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[^\s@"]+)*$/; +/** + * Matches an authored `@scope/name[/...]` reference (npm-style scoped + * package), as distinct from a relative filesystem path. Callers resolving + * paths against a cwd use this to avoid treating a package reference as a + * directory under the project root. + */ +export function isScopedPackageReferencePath(path: string): boolean { + return path.startsWith("@") && SCOPED_PACKAGE_REFERENCE_REGEX.test(path.slice(1)); +} + +export function decodeCanonicalComposerFileLinkPath( + label: string, + encodedPath: string, +): string | null { + let path = encodedPath; + try { + path = decodeURIComponent(encodedPath); + } catch { + // Preserve malformed source so manually authored paths remain usable. + } + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; + const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path); + return path && !hasExternalScheme && label === basename ? path : null; +} + +export function isCanonicalComposerFileLink(label: string, encodedPath: string): boolean { + return decodeCanonicalComposerFileLinkPath(label, encodedPath) !== null; +} + function collectMentionTokens(text: string): ComposerInlineToken[] { const matches: ComposerInlineToken[] = []; @@ -35,16 +65,8 @@ function collectMentionTokens(text: string): ComposerInlineToken[] { const prefix = match[1] ?? ""; const label = (match[2] ?? "").replace(/\\(.)/g, "$1"); const encodedPath = match[3] ?? ""; - let path = encodedPath; - try { - path = decodeURIComponent(encodedPath); - } catch { - // Preserve malformed source rather than dropping a user-authored token. - } - const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); - const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; - const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path); - if (!path || hasExternalScheme || label !== basename) { + const path = decodeCanonicalComposerFileLinkPath(label, encodedPath); + if (path === null) { continue; } const start = (match.index ?? 0) + prefix.length; diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index 50c8cd7c208..7ea9c233136 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -40,4 +40,11 @@ describe("serializeComposerFileLink", () => { "[package.json](@scope/package.json)", ); }); + + it("escapes markdown syntax in filenames", () => { + expect(serializeComposerFileLink("/custom/*draft* &")).toBe( + "[\\*draft\\* \\&](/custom/*draft*%20%26amp;)", + ); + expect(serializeComposerFileLink("/tmp/a|b")).toBe("[a\\|b](/tmp/a%7Cb)"); + }); }); diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index dcbdc784934..c354c60e9ea 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -23,7 +23,7 @@ function composerFileLinkBasename(path: string): string { } function escapeMarkdownLinkLabel(label: string): string { - return label.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]"); + return label.replace(/[\\[\]*_~`<>&|]/g, "\\$&"); } function encodeMarkdownLinkDestination(path: string): string { @@ -32,6 +32,7 @@ function encodeMarkdownLinkDestination(path: string): string { .replaceAll(")", "%29") .replaceAll("#", "%23") .replaceAll("?", "%3F") + .replaceAll("&", "%26") .replaceAll("\\", "%5C"); }