Skip to content

Commit c55894d

Browse files
ReekinDimillian
andauthored
fix: support windows file links in messages (#570)
Co-authored-by: Thomas Ricouard <ricouard77@gmail.com>
1 parent 01a4210 commit c55894d

17 files changed

Lines changed: 1709 additions & 754 deletions

src/features/messages/components/Markdown.test.tsx

Lines changed: 228 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// @vitest-environment jsdom
22
import { cleanup, createEvent, fireEvent, render, screen } from "@testing-library/react";
33
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { expectOpenedFileTarget } from "../test/fileLinkAssertions";
45
import { Markdown } from "./Markdown";
56

67
describe("Markdown file-like href behavior", () => {
@@ -46,7 +47,7 @@ describe("Markdown file-like href behavior", () => {
4647
});
4748
fireEvent(link as Element, clickEvent);
4849
expect(clickEvent.defaultPrevented).toBe(true);
49-
expect(onOpenFileLink).toHaveBeenCalledWith("./docs/setup.md");
50+
expectOpenedFileTarget(onOpenFileLink, "./docs/setup.md");
5051
});
5152

5253
it("prevents bare relative link navigation without treating it as a file", () => {
@@ -89,7 +90,7 @@ describe("Markdown file-like href behavior", () => {
8990
});
9091
fireEvent(link as Element, clickEvent);
9192
expect(clickEvent.defaultPrevented).toBe(true);
92-
expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/src/example.ts");
93+
expectOpenedFileTarget(onOpenFileLink, "/workspace/src/example.ts");
9394
});
9495

9596
it("still intercepts dotless workspace file hrefs when a file opener is provided", () => {
@@ -112,7 +113,7 @@ describe("Markdown file-like href behavior", () => {
112113
});
113114
fireEvent(link as Element, clickEvent);
114115
expect(clickEvent.defaultPrevented).toBe(true);
115-
expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/CodexMonitor/LICENSE");
116+
expectOpenedFileTarget(onOpenFileLink, "/workspace/CodexMonitor/LICENSE");
116117
});
117118

118119
it("intercepts mounted workspace links outside the old root allowlist", () => {
@@ -135,7 +136,7 @@ describe("Markdown file-like href behavior", () => {
135136
});
136137
fireEvent(link as Element, clickEvent);
137138
expect(clickEvent.defaultPrevented).toBe(true);
138-
expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/.github/workflows");
139+
expectOpenedFileTarget(onOpenFileLink, "/workspace/.github/workflows");
139140
});
140141

141142
it("intercepts mounted workspace directory links that resolve relative to the workspace", () => {
@@ -158,20 +159,43 @@ describe("Markdown file-like href behavior", () => {
158159
});
159160
fireEvent(link as Element, clickEvent);
160161
expect(clickEvent.defaultPrevented).toBe(true);
161-
expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/dist/assets");
162+
expectOpenedFileTarget(onOpenFileLink, "/workspace/dist/assets");
162163
});
163164

164-
it("keeps generic workspace routes as normal markdown links", () => {
165+
it("keeps exact workspace routes as normal markdown links", () => {
165166
const onOpenFileLink = vi.fn();
166167
render(
167168
<Markdown
168-
value="See [overview](/workspace/reviews/overview)"
169+
value="See [reviews](/workspace/reviews)"
169170
className="markdown"
170171
workspacePath="/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor"
171172
onOpenFileLink={onOpenFileLink}
172173
/>,
173174
);
174175

176+
const link = screen.getByText("reviews").closest("a");
177+
expect(link?.getAttribute("href")).toBe("/workspace/reviews");
178+
179+
const clickEvent = createEvent.click(link as Element, {
180+
bubbles: true,
181+
cancelable: true,
182+
});
183+
fireEvent(link as Element, clickEvent);
184+
expect(clickEvent.defaultPrevented).toBe(true);
185+
expect(onOpenFileLink).not.toHaveBeenCalled();
186+
});
187+
188+
it("keeps nested workspace reviews routes local even when the workspace basename matches", () => {
189+
const onOpenFileLink = vi.fn();
190+
render(
191+
<Markdown
192+
value="See [overview](/workspace/reviews/overview)"
193+
className="markdown"
194+
workspacePath="/Users/sotiriskaniras/Documents/Development/Forks/reviews"
195+
onOpenFileLink={onOpenFileLink}
196+
/>,
197+
);
198+
175199
const link = screen.getByText("overview").closest("a");
176200
expect(link?.getAttribute("href")).toBe("/workspace/reviews/overview");
177201

@@ -207,6 +231,29 @@ describe("Markdown file-like href behavior", () => {
207231
expect(onOpenFileLink).not.toHaveBeenCalled();
208232
});
209233

234+
it("keeps nested reviews routes local even when the workspace basename matches the route segment", () => {
235+
const onOpenFileLink = vi.fn();
236+
render(
237+
<Markdown
238+
value="See [overview](/workspaces/team/reviews/overview)"
239+
className="markdown"
240+
workspacePath="/Users/sotiriskaniras/Documents/Development/Forks/reviews"
241+
onOpenFileLink={onOpenFileLink}
242+
/>,
243+
);
244+
245+
const link = screen.getByText("overview").closest("a");
246+
expect(link?.getAttribute("href")).toBe("/workspaces/team/reviews/overview");
247+
248+
const clickEvent = createEvent.click(link as Element, {
249+
bubbles: true,
250+
cancelable: true,
251+
});
252+
fireEvent(link as Element, clickEvent);
253+
expect(clickEvent.defaultPrevented).toBe(true);
254+
expect(onOpenFileLink).not.toHaveBeenCalled();
255+
});
256+
210257
it("still intercepts nested workspace file hrefs when a file opener is provided", () => {
211258
const onOpenFileLink = vi.fn();
212259
render(
@@ -227,7 +274,30 @@ describe("Markdown file-like href behavior", () => {
227274
});
228275
fireEvent(link as Element, clickEvent);
229276
expect(clickEvent.defaultPrevented).toBe(true);
230-
expect(onOpenFileLink).toHaveBeenCalledWith("/workspaces/team/CodexMonitor/src");
277+
expectOpenedFileTarget(onOpenFileLink, "/workspaces/team/CodexMonitor/src");
278+
});
279+
280+
it("treats extensionless paths under /workspace/settings as files", () => {
281+
const onOpenFileLink = vi.fn();
282+
render(
283+
<Markdown
284+
value="See [license](/workspace/settings/LICENSE)"
285+
className="markdown"
286+
workspacePath="/Users/sotiriskaniras/Documents/Development/Forks/settings"
287+
onOpenFileLink={onOpenFileLink}
288+
/>,
289+
);
290+
291+
const link = screen.getByText("license").closest("a");
292+
expect(link?.getAttribute("href")).toBe("/workspace/settings/LICENSE");
293+
294+
const clickEvent = createEvent.click(link as Element, {
295+
bubbles: true,
296+
cancelable: true,
297+
});
298+
fireEvent(link as Element, clickEvent);
299+
expect(clickEvent.defaultPrevented).toBe(true);
300+
expectOpenedFileTarget(onOpenFileLink, "/workspace/settings/LICENSE");
231301
});
232302

233303
it("intercepts file hrefs that use #L line anchors", () => {
@@ -249,7 +319,52 @@ describe("Markdown file-like href behavior", () => {
249319
});
250320
fireEvent(link as Element, clickEvent);
251321
expect(clickEvent.defaultPrevented).toBe(true);
252-
expect(onOpenFileLink).toHaveBeenCalledWith("./docs/setup.md:12");
322+
expectOpenedFileTarget(onOpenFileLink, "./docs/setup.md", 12);
323+
});
324+
325+
it("intercepts Windows absolute file hrefs with #L anchors and preserves the tooltip", () => {
326+
const onOpenFileLink = vi.fn();
327+
const onOpenFileLinkMenu = vi.fn();
328+
const linkedPath =
329+
"I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx#L422";
330+
render(
331+
<Markdown
332+
value={`See [SettingsDisplaySection.tsx](${linkedPath})`}
333+
className="markdown"
334+
onOpenFileLink={onOpenFileLink}
335+
onOpenFileLinkMenu={onOpenFileLinkMenu}
336+
/>,
337+
);
338+
339+
const link = screen.getByText("SettingsDisplaySection.tsx").closest("a");
340+
expect(link?.getAttribute("href")).toBe(
341+
"I:%5Cgpt-projects%5CCodexMonitor%5Csrc%5Cfeatures%5Csettings%5Ccomponents%5Csections%5CSettingsDisplaySection.tsx#L422",
342+
);
343+
expect(link?.getAttribute("title")).toBe(
344+
"I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx:422",
345+
);
346+
347+
const clickEvent = createEvent.click(link as Element, {
348+
bubbles: true,
349+
cancelable: true,
350+
});
351+
fireEvent(link as Element, clickEvent);
352+
expect(clickEvent.defaultPrevented).toBe(true);
353+
expectOpenedFileTarget(
354+
onOpenFileLink,
355+
"I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx",
356+
422,
357+
);
358+
359+
fireEvent.contextMenu(link as Element);
360+
expect(onOpenFileLinkMenu).toHaveBeenCalledWith(
361+
expect.anything(),
362+
{
363+
path: "I:\\gpt-projects\\CodexMonitor\\src\\features\\settings\\components\\sections\\SettingsDisplaySection.tsx",
364+
line: 422,
365+
column: null,
366+
},
367+
);
253368
});
254369

255370
it("prevents unsupported route fragments without treating them as file links", () => {
@@ -274,42 +389,133 @@ describe("Markdown file-like href behavior", () => {
274389
expect(onOpenFileLink).not.toHaveBeenCalled();
275390
});
276391

277-
it("does not turn natural-language slash phrases into file links", () => {
392+
it("keeps workspace settings #L anchors as local routes", () => {
393+
const onOpenFileLink = vi.fn();
394+
render(
395+
<Markdown
396+
value="See [settings](/workspace/settings#L12)"
397+
className="markdown"
398+
workspacePath="/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor"
399+
onOpenFileLink={onOpenFileLink}
400+
/>,
401+
);
402+
403+
const link = screen.getByText("settings").closest("a");
404+
expect(link?.getAttribute("href")).toBe("/workspace/settings#L12");
405+
406+
const clickEvent = createEvent.click(link as Element, {
407+
bubbles: true,
408+
cancelable: true,
409+
});
410+
fireEvent(link as Element, clickEvent);
411+
expect(clickEvent.defaultPrevented).toBe(true);
412+
expect(onOpenFileLink).not.toHaveBeenCalled();
413+
});
414+
415+
it("keeps workspace reviews #L anchors as local routes", () => {
416+
const onOpenFileLink = vi.fn();
417+
render(
418+
<Markdown
419+
value="See [reviews](/workspace/reviews#L9)"
420+
className="markdown"
421+
workspacePath="/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor"
422+
onOpenFileLink={onOpenFileLink}
423+
/>,
424+
);
425+
426+
const link = screen.getByText("reviews").closest("a");
427+
expect(link?.getAttribute("href")).toBe("/workspace/reviews#L9");
428+
429+
const clickEvent = createEvent.click(link as Element, {
430+
bubbles: true,
431+
cancelable: true,
432+
});
433+
fireEvent(link as Element, clickEvent);
434+
expect(clickEvent.defaultPrevented).toBe(true);
435+
expect(onOpenFileLink).not.toHaveBeenCalled();
436+
});
437+
438+
it("does not linkify workspace settings #L anchors in plain text", () => {
278439
const { container } = render(
279440
<Markdown
280-
value="Keep the current app/daemon behavior and the existing Git/Plan experience."
441+
value="See /workspace/settings#L12 for app settings."
281442
className="markdown"
443+
workspacePath="/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor"
282444
/>,
283445
);
284446

285447
expect(container.querySelector(".message-file-link")).toBeNull();
286-
expect(container.textContent).toContain("app/daemon");
287-
expect(container.textContent).toContain("Git/Plan");
448+
expect(container.textContent).toContain("/workspace/settings#L12");
288449
});
289450

290-
it("does not turn longer slash phrases into file links", () => {
451+
it("does not linkify Windows file paths embedded in custom URIs", () => {
291452
const { container } = render(
292453
<Markdown
293-
value="This keeps Spec/Verification/Evidence in the note without turning it into a file link."
454+
value="Open vscode://file/C:/repo/src/App.tsx:12 in VS Code."
294455
className="markdown"
295456
/>,
296457
);
297458

298459
expect(container.querySelector(".message-file-link")).toBeNull();
299-
expect(container.textContent).toContain("Spec/Verification/Evidence");
460+
expect(container.textContent).toContain("vscode://file/C:/repo/src/App.tsx:12");
300461
});
301462

302-
it("still turns clear file paths in plain text into file links", () => {
463+
it("does not turn workspace review #L anchors in inline code into file links", () => {
303464
const { container } = render(
304465
<Markdown
305-
value="See docs/setup.md and /Users/example/project/src/index.ts for details."
466+
value="Use `/workspace/reviews#L9` to reference the reviews route."
306467
className="markdown"
468+
workspacePath="/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor"
469+
/>,
470+
);
471+
472+
expect(container.querySelector(".message-file-link")).toBeNull();
473+
expect(container.querySelector("code")?.textContent).toBe("/workspace/reviews#L9");
474+
});
475+
476+
it("still opens mounted file links when the workspace basename is settings", () => {
477+
const onOpenFileLink = vi.fn();
478+
render(
479+
<Markdown
480+
value="See [app](/workspace/settings/src/App.tsx)"
481+
className="markdown"
482+
onOpenFileLink={onOpenFileLink}
307483
/>,
308484
);
309485

310-
const fileLinks = [...container.querySelectorAll(".message-file-link")];
311-
expect(fileLinks).toHaveLength(2);
312-
expect(fileLinks[0]?.textContent).toContain("setup.md");
313-
expect(fileLinks[1]?.textContent).toContain("index.ts");
486+
const link = screen.getByText("app").closest("a");
487+
expect(link?.getAttribute("href")).toBe("/workspace/settings/src/App.tsx");
488+
489+
const clickEvent = createEvent.click(link as Element, {
490+
bubbles: true,
491+
cancelable: true,
492+
});
493+
fireEvent(link as Element, clickEvent);
494+
expect(clickEvent.defaultPrevented).toBe(true);
495+
expectOpenedFileTarget(onOpenFileLink, "/workspace/settings/src/App.tsx");
314496
});
497+
498+
it("keeps nested settings routes local when the workspace basename is settings", () => {
499+
const onOpenFileLink = vi.fn();
500+
render(
501+
<Markdown
502+
value="See [profile](/workspace/settings/profile)"
503+
className="markdown"
504+
workspacePath="/Users/sotiriskaniras/Documents/Development/Forks/settings"
505+
onOpenFileLink={onOpenFileLink}
506+
/>,
507+
);
508+
509+
const link = screen.getByText("profile").closest("a");
510+
expect(link?.getAttribute("href")).toBe("/workspace/settings/profile");
511+
512+
const clickEvent = createEvent.click(link as Element, {
513+
bubbles: true,
514+
cancelable: true,
515+
});
516+
fireEvent(link as Element, clickEvent);
517+
expect(clickEvent.defaultPrevented).toBe(true);
518+
expect(onOpenFileLink).not.toHaveBeenCalled();
519+
});
520+
315521
});

0 commit comments

Comments
 (0)