From 7a0477e1e3c6dd8bc07383d4be14a87188142758 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:43:22 +0200 Subject: [PATCH 1/2] Feat: Added Trae IDE support --- apps/server/src/open.test.ts | 21 ++++++++- apps/web/src/components/ChatView.browser.tsx | 46 +++++++++++++++++++ apps/web/src/components/Icons.tsx | 14 ++++++ apps/web/src/components/chat/OpenInPicker.tsx | 7 ++- packages/contracts/src/editor.ts | 1 + 5 files changed, 87 insertions(+), 2 deletions(-) diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 947a60ac2a..c612922fea 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -31,6 +31,15 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["/tmp/workspace"], }); + const traeLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "trae" }, + "darwin", + ); + assert.deepEqual(traeLaunch, { + command: "trae", + args: ["/tmp/workspace"], + }); + const vscodeLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "vscode" }, "darwin", @@ -89,6 +98,15 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], }); + const traeLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "trae" }, + "darwin", + ); + assert.deepEqual(traeLineAndColumn, { + command: "trae", + args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], + }); + const vscodeLineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" }, "darwin", @@ -256,6 +274,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { const path = yield* Path.Path; const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fs.writeFileString(path.join(dir, "trae.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, "explorer.CMD"), "MZ"); @@ -263,7 +282,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", }); - assert.deepEqual(editors, ["vscode-insiders", "vscodium", "file-manager"]); + assert.deepEqual(editors, ["trae", "vscode-insiders", "vscodium", "file-manager"]); }), ); }); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 33b9b62859..0e5f573d54 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1407,6 +1407,52 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("opens the project cwd with Trae when it is the only available editor", async () => { + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["trae"], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + const openButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Open", + ) as HTMLButtonElement | null, + "Unable to find Open button.", + ); + await vi.waitFor(() => { + expect(openButton.disabled).toBe(false); + }); + openButton.click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.shellOpenInEditor, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.shellOpenInEditor, + cwd: "/repo/project", + editor: "trae", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("filters the open picker menu and opens VSCodium from the menu", async () => { setDraftThreadWithoutWorktree(); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 7d210fa173..3f4844af80 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -20,6 +20,20 @@ export const CursorIcon: Icon = (props) => ( ); +export const TraeIcon: Icon = (props) => ( + + {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} + + + {/* Front frame: top bar + right bar only — left and bottom are replaced by the back strips above */} + + + {/* Two diamonds, offset slightly to the right within the open area */} + + + +); + export const VisualStudioCode: Icon = (props) => { const id = useId(); const maskId = `${id}-vscode-a`; diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 6a956f6f42..bb5362439e 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -6,7 +6,7 @@ import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; -import { AntigravityIcon, CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons"; +import { AntigravityIcon, CursorIcon, Icon, TraeIcon, VisualStudioCode, Zed } from "../Icons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; @@ -17,6 +17,11 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray Date: Wed, 1 Apr 2026 18:32:31 +0200 Subject: [PATCH 2/2] feat(web,server): respect Claude gitignore settings in @ picker and show partial results --- .../src/provider/claudeSettings.test.ts | 80 +++++++++++++++++++ apps/server/src/provider/claudeSettings.ts | 71 ++++++++++++++++ .../workspace/Layers/WorkspaceEntries.test.ts | 34 ++++++++ .../src/workspace/Layers/WorkspaceEntries.ts | 74 ++++++++++++++--- apps/web/src/components/ChatView.tsx | 9 +++ .../components/chat/ComposerCommandMenu.tsx | 28 ++++--- 6 files changed, 276 insertions(+), 20 deletions(-) create mode 100644 apps/server/src/provider/claudeSettings.test.ts create mode 100644 apps/server/src/provider/claudeSettings.ts diff --git a/apps/server/src/provider/claudeSettings.test.ts b/apps/server/src/provider/claudeSettings.test.ts new file mode 100644 index 0000000000..411ac5500d --- /dev/null +++ b/apps/server/src/provider/claudeSettings.test.ts @@ -0,0 +1,80 @@ +import fsPromises from "node:fs/promises"; +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect } from "effect"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { extractClaudeRespectGitignore, resolveClaudeRespectGitignore } from "./claudeSettings.ts"; + +const runWithNodeServices = (effect: Effect.Effect) => + Effect.runPromise(Effect.provide(effect, NodeServices.layer)); + +describe("extractClaudeRespectGitignore", () => { + it("reads top-level settings.json values", () => { + expect(extractClaudeRespectGitignore({ respectGitignore: false })).toBe(false); + expect(extractClaudeRespectGitignore({ respectGitignore: true })).toBe(true); + }); + + it("falls back to a nested legacy settings object", () => { + expect(extractClaudeRespectGitignore({ settings: { respectGitignore: false } })).toBe(false); + }); + + it("returns undefined for unrelated shapes", () => { + expect(extractClaudeRespectGitignore({})).toBeUndefined(); + expect(extractClaudeRespectGitignore(null)).toBeUndefined(); + expect(extractClaudeRespectGitignore("false")).toBeUndefined(); + }); +}); + +describe("resolveClaudeRespectGitignore", () => { + const tempDirectories: string[] = []; + + afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all( + tempDirectories + .splice(0) + .map((directory) => fsPromises.rm(directory, { force: true, recursive: true })), + ); + }); + + it("defaults to respecting gitignore when no Claude setting is present", async () => { + const cwd = await fsPromises.mkdtemp( + path.join(process.env.TMPDIR ?? "/tmp", "t3code-claude-settings-cwd-"), + ); + const homeDir = await fsPromises.mkdtemp( + path.join(process.env.TMPDIR ?? "/tmp", "t3code-claude-settings-home-"), + ); + tempDirectories.push(cwd, homeDir); + + await expect( + runWithNodeServices(resolveClaudeRespectGitignore(cwd, { homeDirectory: homeDir })), + ).resolves.toBe(true); + }); + + it("applies project-local settings over user settings", async () => { + const cwd = await fsPromises.mkdtemp( + path.join(process.env.TMPDIR ?? "/tmp", "t3code-claude-settings-cwd-"), + ); + const homeDir = await fsPromises.mkdtemp( + path.join(process.env.TMPDIR ?? "/tmp", "t3code-claude-settings-home-"), + ); + tempDirectories.push(cwd, homeDir); + + await fsPromises.mkdir(path.join(homeDir, ".claude"), { recursive: true }); + await fsPromises.mkdir(path.join(cwd, ".claude"), { recursive: true }); + await fsPromises.writeFile( + path.join(homeDir, ".claude", "settings.json"), + '{"respectGitignore":true}', + ); + await fsPromises.writeFile( + path.join(cwd, ".claude", "settings.local.json"), + '{"respectGitignore":false}', + ); + + await expect( + runWithNodeServices(resolveClaudeRespectGitignore(cwd, { homeDirectory: homeDir })), + ).resolves.toBe(false); + }); +}); diff --git a/apps/server/src/provider/claudeSettings.ts b/apps/server/src/provider/claudeSettings.ts new file mode 100644 index 0000000000..f245bb5fe4 --- /dev/null +++ b/apps/server/src/provider/claudeSettings.ts @@ -0,0 +1,71 @@ +import * as OS from "node:os"; +import { Effect, FileSystem, Path } from "effect"; + +function getBooleanProperty(record: Record, key: string): boolean | undefined { + const value = record[key]; + return typeof value === "boolean" ? value : undefined; +} + +export function extractClaudeRespectGitignore(value: unknown): boolean | undefined { + if (value === null || typeof value !== "object") { + return undefined; + } + + const record = value as Record; + const topLevel = getBooleanProperty(record, "respectGitignore"); + if (topLevel !== undefined) { + return topLevel; + } + + const nestedSettings = record.settings; + if (nestedSettings === null || typeof nestedSettings !== "object") { + return undefined; + } + + return getBooleanProperty(nestedSettings as Record, "respectGitignore"); +} + +function readClaudeRespectGitignoreFromFile(settingsPath: string) { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const contents = yield* fileSystem + .readFileString(settingsPath) + .pipe(Effect.catch(() => Effect.succeed(null))); + + if (contents === null) { + return undefined; + } + + return yield* Effect.sync(() => { + try { + return extractClaudeRespectGitignore(JSON.parse(contents)); + } catch { + return undefined; + } + }); + }); +} + +export function resolveClaudeRespectGitignore(cwd: string, options?: { homeDirectory?: string }) { + return Effect.gen(function* () { + const path = yield* Path.Path; + const homeDirectory = + options?.homeDirectory ?? process.env.HOME ?? process.env.USERPROFILE ?? OS.homedir(); + const candidatePaths = [ + path.join(homeDirectory, ".claude.json"), + path.join(homeDirectory, ".claude", "settings.json"), + path.join(cwd, ".claude", "settings.json"), + path.join(cwd, ".claude", "settings.local.json"), + ]; + + let respectGitignore = true; + for (const candidatePath of candidatePaths) { + const nextValue = yield* readClaudeRespectGitignoreFromFile(candidatePath); + if (nextValue !== undefined) { + respectGitignore = nextValue; + } + } + + return respectGitignore; + }); +} diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 960cb69bf1..860e6dcddc 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -163,6 +163,40 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { }), ); + it.effect("includes gitignored paths when Claude disables gitignore filtering", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ + prefix: "t3code-workspace-include-gitignored-", + git: true, + }); + const homeDir = yield* makeTempDir({ prefix: "t3code-claude-home-" }); + const previousHome = process.env.HOME; + process.env.HOME = homeDir; + try { + yield* writeTextFile(homeDir, ".claude/settings.json", '{"respectGitignore":false}'); + yield* writeTextFile(cwd, ".gitignore", ".agent/\nignored.txt\n"); + yield* writeTextFile(cwd, "src/keep.ts", "export {};"); + yield* writeTextFile(cwd, ".agent/local-instructions.md", "# secret"); + yield* writeTextFile(cwd, "ignored.txt", "ignore me"); + + const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); + const paths = result.entries.map((entry) => entry.path); + + expect(paths).toContain("src"); + expect(paths).toContain("src/keep.ts"); + expect(paths).toContain(".agent"); + expect(paths).toContain(".agent/local-instructions.md"); + expect(paths).toContain("ignored.txt"); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + } + }), + ); + it.effect("excludes tracked paths that match ignore rules", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 12af8601ca..73abae3212 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -1,11 +1,12 @@ import fsPromises from "node:fs/promises"; import type { Dirent } from "node:fs"; -import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect"; +import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { type ProjectEntry } from "@t3tools/contracts"; import { GitCore } from "../../git/Services/GitCore.ts"; +import { resolveClaudeRespectGitignore } from "../../provider/claudeSettings.ts"; import { WorkspaceEntries, WorkspaceEntriesError, @@ -17,6 +18,7 @@ const WORKSPACE_CACHE_TTL_MS = 15_000; const WORKSPACE_CACHE_MAX_KEYS = 4; const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; +const CLAUDE_SETTINGS_CACHE_TTL_MS = 5_000; const IGNORED_DIRECTORY_NAMES = new Set([ ".git", ".convex", @@ -45,6 +47,11 @@ interface RankedWorkspaceEntry { score: number; } +class WorkspaceIndexCacheKey extends Data.Class<{ + cwd: string; + respectGitignore: boolean; +}> {} + function toPosixPath(input: string): string { return input.replaceAll("\\", "/"); } @@ -219,8 +226,17 @@ const processErrorDetail = (cause: unknown): string => export const makeWorkspaceEntries = Effect.gen(function* () { const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; const gitOption = yield* Effect.serviceOption(GitCore); const workspacePaths = yield* WorkspacePaths; + const resolveClaudeRespectGitignoreWithServices = ( + cwd: string, + options?: { homeDirectory?: string }, + ) => + resolveClaudeRespectGitignore(cwd, options).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); const isInsideGitWorkTree = (cwd: string): Effect.Effect => Option.match(gitOption, { @@ -242,10 +258,13 @@ export const makeWorkspaceEntries = Effect.gen(function* () { }); const buildWorkspaceIndexFromGit = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromGit")( - function* (cwd: string) { + function* (cwd: string, respectGitignore: boolean) { if (Option.isNone(gitOption)) { return null; } + if (!respectGitignore) { + return null; + } if (!(yield* isInsideGitWorkTree(cwd))) { return null; } @@ -332,8 +351,11 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const buildWorkspaceIndexFromFilesystem = Effect.fn( "WorkspaceEntries.buildWorkspaceIndexFromFilesystem", - )(function* (cwd: string): Effect.fn.Return { - const shouldFilterWithGitIgnore = yield* isInsideGitWorkTree(cwd); + )(function* ( + cwd: string, + respectGitignore: boolean, + ): Effect.fn.Return { + const shouldFilterWithGitIgnore = respectGitignore && (yield* isInsideGitWorkTree(cwd)); let pendingDirectories: string[] = [""]; const entries: SearchableWorkspaceEntry[] = []; @@ -421,16 +443,22 @@ export const makeWorkspaceEntries = Effect.gen(function* () { }); const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* ( - cwd: string, + key: WorkspaceIndexCacheKey, ): Effect.fn.Return { - const gitIndexed = yield* buildWorkspaceIndexFromGit(cwd); + const gitIndexed = yield* buildWorkspaceIndexFromGit(key.cwd, key.respectGitignore); if (gitIndexed) { return gitIndexed; } - return yield* buildWorkspaceIndexFromFilesystem(cwd); + // `git ls-files --exclude-standard` cannot produce the mixed tracked/untracked/ignored + // set the picker needs when Claude disables `.gitignore` filtering. + return yield* buildWorkspaceIndexFromFilesystem(key.cwd, key.respectGitignore); }); - const workspaceIndexCache = yield* Cache.makeWith({ + const workspaceIndexCache = yield* Cache.makeWith< + WorkspaceIndexCacheKey, + WorkspaceIndex, + WorkspaceEntriesError + >({ capacity: WORKSPACE_CACHE_MAX_KEYS, lookup: buildWorkspaceIndex, timeToLive: (exit) => @@ -458,17 +486,41 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( Effect.catch(() => Effect.succeed(cwd)), ); - yield* Cache.invalidate(workspaceIndexCache, cwd); + yield* Cache.invalidate( + workspaceIndexCache, + new WorkspaceIndexCacheKey({ cwd, respectGitignore: true }), + ); + yield* Cache.invalidate( + workspaceIndexCache, + new WorkspaceIndexCacheKey({ cwd, respectGitignore: false }), + ); if (normalizedCwd !== cwd) { - yield* Cache.invalidate(workspaceIndexCache, normalizedCwd); + yield* Cache.invalidate( + workspaceIndexCache, + new WorkspaceIndexCacheKey({ cwd: normalizedCwd, respectGitignore: true }), + ); + yield* Cache.invalidate( + workspaceIndexCache, + new WorkspaceIndexCacheKey({ cwd: normalizedCwd, respectGitignore: false }), + ); } }, ); + const claudeRespectGitignoreCache = yield* Cache.makeWith({ + capacity: WORKSPACE_CACHE_MAX_KEYS, + lookup: resolveClaudeRespectGitignoreWithServices, + timeToLive: () => Duration.millis(CLAUDE_SETTINGS_CACHE_TTL_MS), + }); + const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); - return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe( + const respectGitignore = yield* Cache.get(claudeRespectGitignoreCache, normalizedCwd); + return yield* Cache.get( + workspaceIndexCache, + new WorkspaceIndexCacheKey({ cwd: normalizedCwd, respectGitignore }), + ).pipe( Effect.map((index) => { const normalizedQuery = normalizeQuery(input.query); const limit = Math.max(0, Math.floor(input.limit)); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 76133712d4..c00a47ce60 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1307,6 +1307,14 @@ export default function ChatView({ threadId }: ChatViewProps) { description: `${providerLabel} · ${slug}`, })); }, [composerTrigger, searchableModelOptions, workspaceEntries]); + const workspaceEntriesPartialResultsHint = + !workspaceEntriesQuery.isLoading && + composerTrigger?.kind === "path" && + workspaceEntriesQuery.data?.truncated + ? workspaceEntries.length === 0 + ? "Workspace results are partial. Refine your query to search more precisely." + : "Showing partial results. Refine your query to narrow the file search." + : null; const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => @@ -3919,6 +3927,7 @@ export default function ChatView({ threadId }: ChatViewProps) { resolvedTheme={resolvedTheme} isLoading={isComposerMenuLoading} triggerKind={composerTriggerKind} + partialResultsHint={workspaceEntriesPartialResultsHint} activeItemId={activeComposerMenuItem?.id ?? null} onHighlightedItemChange={onComposerMenuItemHighlighted} onSelect={onSelectComposerItem} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index fc7ea27c29..16dea794f9 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -37,6 +37,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { resolvedTheme: "light" | "dark"; isLoading: boolean; triggerKind: ComposerTriggerKind | null; + partialResultsHint?: string | null; activeItemId: string | null; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; @@ -77,15 +78,24 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { /> ))} - {props.items.length === 0 && ( -

- {props.isLoading - ? "Searching workspace files..." - : props.triggerKind === "path" - ? "No matching files or folders." - : "No matching command."} -

- )} + {props.items.length === 0 ? ( +
+

+ {props.isLoading + ? "Searching workspace files..." + : props.triggerKind === "path" + ? "No matching files or folders." + : "No matching command."} +

+ {props.partialResultsHint ? ( +

{props.partialResultsHint}

+ ) : null} +
+ ) : props.partialResultsHint ? ( +
+

{props.partialResultsHint}

+
+ ) : null} );