From b65481a209a3c93c203fe1f4d9a043ef7b5665cd Mon Sep 17 00:00:00 2001 From: Minigamer42 <51682780+Minigamer42@users.noreply.github.com> Date: Sat, 2 May 2026 23:24:21 +0200 Subject: [PATCH 1/3] Add remaining IntelliJ editors --- apps/server/src/open.test.ts | 228 ++++++- apps/web/src/components/Icons.tsx | 103 +-- apps/web/src/components/JetBrainsIcons.tsx | 610 ++++++++++++++++++ apps/web/src/components/chat/OpenInPicker.tsx | 70 +- packages/contracts/src/editor.ts | 11 + 5 files changed, 918 insertions(+), 104 deletions(-) create mode 100644 apps/web/src/components/JetBrainsIcons.tsx diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 77e85072a8..1efa36e957 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -98,6 +98,105 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { command: "idea", args: ["/tmp/workspace"], }); + + const aquaLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "aqua" }, + "darwin", + ); + assert.deepEqual(aquaLaunch, { + command: "aqua", + args: ["/tmp/workspace"], + }); + + const clionLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "clion" }, + "darwin", + ); + assert.deepEqual(clionLaunch, { + command: "clion", + args: ["/tmp/workspace"], + }); + + const datagripLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "datagrip" }, + "darwin", + ); + assert.deepEqual(datagripLaunch, { + command: "datagrip", + args: ["/tmp/workspace"], + }); + + const dataspellLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "dataspell" }, + "darwin", + ); + assert.deepEqual(dataspellLaunch, { + command: "dataspell", + args: ["/tmp/workspace"], + }); + + const golandLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "goland" }, + "darwin", + ); + assert.deepEqual(golandLaunch, { + command: "goland", + args: ["/tmp/workspace"], + }); + + const phpstormLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "phpstorm" }, + "darwin", + ); + assert.deepEqual(phpstormLaunch, { + command: "phpstorm", + args: ["/tmp/workspace"], + }); + + const pycharmLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "pycharm" }, + "darwin", + ); + assert.deepEqual(pycharmLaunch, { + command: "pycharm", + args: ["/tmp/workspace"], + }); + + const riderLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "rider" }, + "darwin", + ); + assert.deepEqual(riderLaunch, { + command: "rider", + args: ["/tmp/workspace"], + }); + + const rubymineLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "rubymine" }, + "darwin", + ); + assert.deepEqual(rubymineLaunch, { + command: "rubymine", + args: ["/tmp/workspace"], + }); + + const rustroverLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "rustrover" }, + "darwin", + ); + assert.deepEqual(rustroverLaunch, { + command: "rustrover", + args: ["/tmp/workspace"], + }); + + const webstormLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "webstorm" }, + "darwin", + ); + assert.deepEqual(webstormLaunch, { + command: "webstorm", + args: ["/tmp/workspace"], + }); }), ); @@ -207,6 +306,105 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { command: "idea", args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"], }); + + const aquaLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "aqua" }, + "darwin", + ); + assert.deepEqual(aquaLineAndColumn, { + command: "aqua", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"], + }); + + const clionLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "clion" }, + "darwin", + ); + assert.deepEqual(clionLineAndColumn, { + command: "clion", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"], + }); + + const datagripLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "datagrip" }, + "darwin", + ); + assert.deepEqual(datagripLineAndColumn, { + command: "datagrip", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"], + }); + + const dataspellLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "dataspell" }, + "darwin", + ); + assert.deepEqual(dataspellLineAndColumn, { + command: "dataspell", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"], + }); + + const golandLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "goland" }, + "darwin", + ); + assert.deepEqual(golandLineAndColumn, { + command: "goland", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"], + }); + + const phpstormLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "phpstorm" }, + "darwin", + ); + assert.deepEqual(phpstormLineAndColumn, { + command: "phpstorm", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"], + }); + + const pycharmLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "pycharm" }, + "darwin", + ); + assert.deepEqual(pycharmLineAndColumn, { + command: "pycharm", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"], + }); + + const riderLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "rider" }, + "darwin", + ); + assert.deepEqual(riderLineAndColumn, { + command: "rider", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"], + }); + + const rubymineLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "rubymine" }, + "darwin", + ); + assert.deepEqual(rubymineLineAndColumn, { + command: "rubymine", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"], + }); + + const rustroverLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "rustrover" }, + "darwin", + ); + assert.deepEqual(rustroverLineAndColumn, { + command: "rustrover", + args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"], + }); + + const webstormLineOnly = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/AGENTS.md:48", editor: "webstorm" }, + "darwin", + ); + assert.deepEqual(webstormLineOnly, { + command: "webstorm", + args: ["--line", "48", "/tmp/workspace/AGENTS.md"], + }); }), ); @@ -377,12 +575,40 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "aqua.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "clion.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "datagrip.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "dataspell.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "goland.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "phpstorm.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "pycharm.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "rider.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "rubymine.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "rustrover.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "webstorm.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); const editors = resolveAvailableEditors("win32", { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", }); - assert.deepEqual(editors, ["trae", "kiro", "vscode-insiders", "vscodium", "file-manager"]); + assert.deepEqual(editors, [ + "trae", + "kiro", + "vscode-insiders", + "vscodium", + "aqua", + "clion", + "datagrip", + "dataspell", + "goland", + "phpstorm", + "pycharm", + "rider", + "rubymine", + "rustrover", + "webstorm", + "file-manager", + ]); }), ); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 7df7975fb1..86720281df 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -1,4 +1,4 @@ -import { type SVGProps, useId } from "react"; +import React, { type SVGProps, useId } from "react"; import { cn } from "~/lib/utils"; export type Icon = React.FC>; @@ -461,107 +461,6 @@ export const AntigravityIcon: Icon = (props) => ( ); -export const IntelliJIdeaIcon: Icon = (props) => { - const id = useId(); - const gradientAId = `${id}-idea-a`; - const gradientBId = `${id}-idea-b`; - const gradientCId = `${id}-idea-c`; - const gradientDId = `${id}-idea-d`; - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - export const OpenCodeIcon: Icon = (props) => ( diff --git a/apps/web/src/components/JetBrainsIcons.tsx b/apps/web/src/components/JetBrainsIcons.tsx new file mode 100644 index 0000000000..85efa50bed --- /dev/null +++ b/apps/web/src/components/JetBrainsIcons.tsx @@ -0,0 +1,610 @@ +import { useId } from "react"; +import type { Icon } from "./Icons"; + +const useSvgGradientIds = (prefix: string, count: number) => { + const id = useId(); + return Array.from( + { length: count }, + (_, index) => `${id}-${prefix}-${String.fromCharCode(97 + index)}`, + ); +}; + +export const AquaIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("aqua", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const CLionIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("clion", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const DataGripIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("datagrip", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const DataSpellIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("dataspell", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const GoLandIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("goland", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const IntelliJIdeaIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("intellij-idea", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const PhpStormIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("phpstorm", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const PyCharmIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("pycharm", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const RiderIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("rider", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const RubyMineIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("rubymine", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const RustRoverIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("rustrover", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const WebStormIcon: Icon = (props) => { + const [gradientAId, gradientBId] = useSvgGradientIds("webstorm", 2); + + return ( + + + + + + + + + + + + + + + + + + ); +}; diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 11480074a4..6b5f2dacc2 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -12,12 +12,25 @@ import { Icon, KiroIcon, TraeIcon, - IntelliJIdeaIcon, VisualStudioCode, VisualStudioCodeInsiders, VSCodium, Zed, } from "../Icons"; +import { + AquaIcon, + CLionIcon, + DataGripIcon, + DataSpellIcon, + GoLandIcon, + IntelliJIdeaIcon, + PhpStormIcon, + PyCharmIcon, + RiderIcon, + RubyMineIcon, + RustRoverIcon, + WebStormIcon, +} from "../JetBrainsIcons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -68,6 +81,61 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray; From d1adf2b03d72e4edf210d0b5edb89cd6ab12775b Mon Sep 17 00:00:00 2001 From: Minigamer42 <51682780+Minigamer42@users.noreply.github.com> Date: Sat, 2 May 2026 23:30:36 +0200 Subject: [PATCH 2/3] Fix filename normalization --- apps/web/src/components/ChatMarkdown.tsx | 11 ++++++--- apps/web/src/markdown-links.test.ts | 30 ++++++++++++++++++++++++ apps/web/src/markdown-links.ts | 23 ++++++++++++++---- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4b53f8534f..e24c14c444 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -26,7 +26,11 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; -import { resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref } from "../markdown-links"; +import { + normalizeMarkdownLinkDestination, + resolveMarkdownFileLinkMeta, + rewriteMarkdownFileUriHref, +} from "../markdown-links"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; @@ -328,7 +332,8 @@ function extractMarkdownLinkHrefs(text: string): string[] { } function normalizeMarkdownLinkHrefKey(href: string): string { - return rewriteMarkdownFileUriHref(href.trim()) ?? href.trim(); + const normalizedHref = normalizeMarkdownLinkDestination(href); + return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; } const MarkdownFileLink = memo(function MarkdownFileLink({ @@ -523,7 +528,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { return ( { "/Users/julius/project/file%2520name.md", ); }); + + it("normalizes file uri hrefs for windows drive paths", () => { + expect( + rewriteMarkdownFileUriHref( + "file:///D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69", + ), + ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69"); + }); + + it("unwraps angle-bracketed file uri hrefs", () => { + expect( + rewriteMarkdownFileUriHref(" "), + ).toBe("D:/Programme/t3code/apps/web/src/markdown-links.ts"); + }); }); describe("resolveMarkdownFileLinkTarget", () => { @@ -84,6 +98,22 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); + it("normalizes slash-prefixed windows drive paths before resolving", () => { + expect( + resolveMarkdownFileLinkTarget( + "/D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69", + ), + ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx:69"); + }); + + it("resolves angle-bracketed windows drive paths", () => { + expect( + resolveMarkdownFileLinkTarget( + "", + ), + ).toBe("D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1"); + }); + it("does not treat app routes as file links", () => { expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); }); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 003fb0409d..9ec4ee4564 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -39,6 +39,14 @@ function safeDecode(value: string): string { } } +function unwrapMarkdownLinkDestination(value: string): string { + return value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value; +} + +export function normalizeMarkdownLinkDestination(value: string): string { + return unwrapMarkdownLinkDestination(value.trim()); +} + function stripSearchAndHash(value: string): { path: string; hash: string } { const hashIndex = value.indexOf("#"); const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; @@ -48,6 +56,10 @@ function stripSearchAndHash(value: string): { path: string; hash: string } { return { path, hash: rawHash }; } +function normalizeWindowsDrivePath(path: string): string { + return /^\/[A-Za-z]:[\\/]/.test(path) ? path.slice(1) : path; +} + function parseFileUrlHref( href: string, options?: { readonly decodePath?: boolean }, @@ -60,7 +72,7 @@ function parseFileUrlHref( if (rawPath.length === 0) return null; // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs. - const normalizedPath = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath; + const normalizedPath = normalizeWindowsDrivePath(rawPath); return { path: options?.decodePath === false ? normalizedPath : safeDecode(normalizedPath), @@ -73,7 +85,8 @@ function parseFileUrlHref( export function rewriteMarkdownFileUriHref(href: string | undefined): string | null { if (!href) return null; - const target = parseFileUrlHref(href.trim(), { decodePath: false }); + const normalizedHref = normalizeMarkdownLinkDestination(href); + const target = parseFileUrlHref(normalizedHref, { decodePath: false }); if (!target) return null; return `${target.path}${target.hash}`; } @@ -124,14 +137,16 @@ export function resolveMarkdownFileLinkTarget( cwd?: string, ): string | null { if (!href) return null; - const rawHref = href.trim(); + const rawHref = normalizeMarkdownLinkDestination(href); if (rawHref.length === 0 || rawHref.startsWith("#")) return null; const fileUrlTarget = rawHref.toLowerCase().startsWith("file:") ? parseFileUrlHref(rawHref) : null; const source = fileUrlTarget ?? stripSearchAndHash(rawHref); - const decodedPath = fileUrlTarget ? source.path.trim() : safeDecode(source.path.trim()); + const decodedPath = normalizeWindowsDrivePath( + fileUrlTarget ? source.path.trim() : safeDecode(source.path.trim()), + ); const decodedHash = safeDecode(source.hash.trim()); if (decodedPath.length === 0) return null; From 8463a8563e75b035dcdac9143f191bc37d0f9a2c Mon Sep 17 00:00:00 2001 From: Minigamer42 <51682780+Minigamer42@users.noreply.github.com> Date: Sun, 3 May 2026 01:31:00 +0200 Subject: [PATCH 3/3] Fix testcases that did now test the old state but is not actually a regression (to be verified by @julius) --- apps/web/src/components/ChatMarkdown.browser.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index a397d52a37..4e8698d4b1 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -65,7 +65,7 @@ describe("ChatMarkdown", () => { try { const link = page.getByRole("link", { name: "PermissionRule.ts · L1" }); await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute("href", `${filePath}#L1`); + await expect.element(link).toHaveAttribute("href", `${filePath}:1`); await link.click(); @@ -87,7 +87,7 @@ describe("ChatMarkdown", () => { try { const link = page.getByRole("link", { name: "PermissionRule.ts · L1:C7" }); await expect.element(link).toBeInTheDocument(); - await expect.element(link).toHaveAttribute("href", `${filePath}#L1C7`); + await expect.element(link).toHaveAttribute("href", `${filePath}:1:7`); await link.click();