diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 5ae248b6cde8..4c92325f5fbd 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -13,16 +13,9 @@ vi.mock("../../state/use-atom-command", () => ({ import { buildComposerSlashCommandItems, - composerSelectionAtEnd, resolveComposerCommandSelection, } from "./use-composer-command-menu"; -describe("composerSelectionAtEnd", () => { - it("resets a changed draft owner to the new draft end", () => { - expect(composerSelectionAtEnd("queued task 馃И")).toEqual({ start: 14, end: 14 }); - }); -}); - describe("mobile slash commands", () => { const antigravity = { driver: ProviderDriverKind.make("antigravity"), diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 5b5b444cca74..5900acada910 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -27,7 +27,7 @@ import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; -export function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { +function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { return { start: draftMessage.length, end: draftMessage.length }; } diff --git a/apps/web/src/components/chat/changedFilesPresentation.test.ts b/apps/web/src/components/chat/changedFilesPresentation.test.ts deleted file mode 100644 index d13445b02260..000000000000 --- a/apps/web/src/components/chat/changedFilesPresentation.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - changedFileName, - selectChangedFilePreview, - shouldAutoExpandChangedFiles, - summarizeChangedFileScopes, -} from "./changedFilesPresentation"; - -describe("changed-files presentation", () => { - it("auto-expands only small, low-churn latest changes", () => { - const smallFiles = [ - { path: "src/a.ts", kind: "modified", additions: 80, deletions: 20 }, - { path: "src/b.ts", kind: "modified", additions: 60, deletions: 20 }, - ]; - - expect(shouldAutoExpandChangedFiles(smallFiles, true)).toBe(true); - expect(shouldAutoExpandChangedFiles(smallFiles, false)).toBe(false); - expect( - shouldAutoExpandChangedFiles( - [{ path: "src/a.ts", kind: "modified", additions: 201, deletions: 0 }], - true, - ), - ).toBe(false); - expect( - shouldAutoExpandChangedFiles( - Array.from({ length: 6 }, (_, index) => ({ - path: `src/${index}.ts`, - kind: "modified", - additions: 1, - deletions: 0, - })), - true, - ), - ).toBe(false); - }); - - it("summarizes the most prominent top-level scopes", () => { - const files = [ - { path: "apps/web/src/App.tsx", kind: "modified", additions: 1, deletions: 0 }, - { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, - { path: "apps/server/src/index.ts", kind: "modified", additions: 1, deletions: 0 }, - { path: "packages/shared/src/git.ts", kind: "modified", additions: 1, deletions: 0 }, - { path: "apps\\mobile\\App.tsx", kind: "modified", additions: 1, deletions: 0 }, - ]; - - expect(summarizeChangedFileScopes(files)).toEqual([ - { label: "apps", fileCount: 3 }, - { label: "root", fileCount: 1 }, - { label: "packages", fileCount: 1 }, - ]); - }); - - it("previews files across different scopes before filling from one scope", () => { - const files = [ - { path: "apps/web/src/App.tsx", kind: "modified", additions: 1, deletions: 0 }, - { path: "apps/web/src/App.test.tsx", kind: "modified", additions: 1, deletions: 0 }, - { path: "packages/shared/src/git.ts", kind: "modified", additions: 1, deletions: 0 }, - { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, - ]; - - expect(selectChangedFilePreview(files).map((file) => file.path)).toEqual([ - "apps/web/src/App.tsx", - "packages/shared/src/git.ts", - "README.md", - ]); - expect(changedFileName("apps\\web\\src\\App.tsx")).toBe("App.tsx"); - }); -}); diff --git a/apps/web/src/components/chat/changedFilesPresentation.ts b/apps/web/src/components/chat/changedFilesPresentation.ts deleted file mode 100644 index bb3cac6c4b12..000000000000 --- a/apps/web/src/components/chat/changedFilesPresentation.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { type TurnDiffFileChange } from "../../types"; -import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; - -export const CHANGED_FILES_AUTO_EXPAND_FILE_LIMIT = 5; -export const CHANGED_FILES_AUTO_EXPAND_LINE_LIMIT = 200; -export const CHANGED_FILES_PREVIEW_FILE_LIMIT = 3; -export const CHANGED_FILES_PREVIEW_SCOPE_LIMIT = 4; - -export interface ChangedFilesScopeSummary { - readonly label: string; - readonly fileCount: number; -} - -function pathSegments(pathValue: string): string[] { - return pathValue - .replaceAll("\\", "/") - .split("/") - .filter((segment) => segment.length > 0); -} - -export function changedFileName(pathValue: string): string { - return pathSegments(pathValue).at(-1) ?? pathValue; -} - -function changedFileScope(pathValue: string): string { - const segments = pathSegments(pathValue); - return segments.length > 1 ? (segments[0] ?? "root") : "root"; -} - -export function shouldAutoExpandChangedFiles( - files: ReadonlyArray, - isLatestTurn: boolean, -): boolean { - if (!isLatestTurn || files.length > CHANGED_FILES_AUTO_EXPAND_FILE_LIMIT) { - return false; - } - const stat = summarizeTurnDiffStats(files); - return stat.additions + stat.deletions <= CHANGED_FILES_AUTO_EXPAND_LINE_LIMIT; -} - -export function summarizeChangedFileScopes( - files: ReadonlyArray, - limit = CHANGED_FILES_PREVIEW_SCOPE_LIMIT, -): ChangedFilesScopeSummary[] { - const scopes = new Map(); - files.forEach((file, index) => { - const label = changedFileScope(file.path); - const current = scopes.get(label); - scopes.set(label, { - fileCount: (current?.fileCount ?? 0) + 1, - firstIndex: current?.firstIndex ?? index, - }); - }); - - return Array.from(scopes, ([label, scope]) => ({ - label, - fileCount: scope.fileCount, - firstIndex: scope.firstIndex, - })) - .toSorted( - (left, right) => - right.fileCount - left.fileCount || - left.firstIndex - right.firstIndex || - left.label.localeCompare(right.label), - ) - .slice(0, limit) - .map(({ label, fileCount }) => ({ label, fileCount })); -} - -export function selectChangedFilePreview( - files: ReadonlyArray, - limit = CHANGED_FILES_PREVIEW_FILE_LIMIT, -): TurnDiffFileChange[] { - const selected: TurnDiffFileChange[] = []; - const selectedPaths = new Set(); - const selectedScopes = new Set(); - - for (const file of files) { - const scope = changedFileScope(file.path); - if (selectedScopes.has(scope)) { - continue; - } - selected.push(file); - selectedPaths.add(file.path); - selectedScopes.add(scope); - if (selected.length === limit) { - return selected; - } - } - - for (const file of files) { - if (selectedPaths.has(file.path)) { - continue; - } - selected.push(file); - if (selected.length === limit) { - break; - } - } - - return selected; -} diff --git a/apps/web/src/components/diffs/DiffFileTree.test.tsx b/apps/web/src/components/diffs/DiffFileTree.test.tsx new file mode 100644 index 000000000000..3a97c60254ab --- /dev/null +++ b/apps/web/src/components/diffs/DiffFileTree.test.tsx @@ -0,0 +1,193 @@ +import type { CodeViewScrollTarget } from "@pierre/diffs"; +import type { FileTree as FileTreeModel } from "@pierre/trees"; +import { FileTree } from "@pierre/trees/react"; +import { act, type MouseEvent, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { DiffFileTree, type DiffFileTreeEntry } from "./DiffFileTree"; +import { useCodeViewFileReveal } from "./useCodeViewFileReveal"; + +vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +// Tooltip positioning is unrelated to the tree's actual model and activation path. +vi.mock("../ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => children, + TooltipTrigger: ({ render }: { render: ReactNode }) => render, + TooltipPopup: () => null, +})); + +const entries: DiffFileTreeEntry[] = [ + { path: "01-tall.ts", status: "modified" }, + { path: "02-short.ts", status: "modified" }, + { path: "03-medium.ts", status: "modified" }, +]; + +class TreeRow { + constructor(readonly path: string) {} + + getAttribute(name: string) { + return name === "data-item-path" ? this.path : null; + } +} + +describe("diff tree file activation", () => { + let renderer: ReactTestRenderer | undefined; + const targets: CodeViewScrollTarget[] = []; + const viewer = { + getInstance: () => viewer, + scrollTo: (target: CodeViewScrollTarget) => targets.push(target), + }; + + function Panel({ + files = entries, + selectedPath = null, + }: { + files?: DiffFileTreeEntry[]; + selectedPath?: string | null; + }) { + const reveal = useCodeViewFileReveal(viewer, "working-tree"); + return ( + reveal(`${path}\0${path}`)} + /> + ); + } + + const model = (): FileTreeModel => renderer!.root.findByType(FileTree).props.model; + + async function mount(props: Parameters[0] = {}) { + await act(async () => { + renderer = create(); + }); + } + + // Exercise T3's capture handler before the real Pierre model's selection transition. + // Only DOM hit testing is represented here; native pointer/keyboard dispatch and diff + // geometry are verified separately in the integrated client. + async function activate(path: string, modifiers: Partial> = {}) { + const event = { + button: 0, + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + defaultPrevented: false, + nativeEvent: { composedPath: () => [{}, new TreeRow(path), {}] }, + ...modifiers, + } as MouseEvent; + await act(async () => { + renderer!.root + .find((node) => String(node.type) === "file-tree-container") + .props.onClickCapture?.(event); + const tree = model(); + const item = tree.getItem(path)!; + if (event.ctrlKey || event.metaKey) { + item.toggleSelect(); + } else { + for (const selected of tree.getSelectedPaths()) { + if (selected !== path) tree.getItem(selected)?.deselect(); + } + item.select(); + } + item.focus(); + if ("toggle" in item && !event.ctrlKey && !event.metaKey && !event.shiftKey) item.toggle(); + }); + } + + beforeEach(() => { + targets.length = 0; + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("HTMLElement", TreeRow); + }); + + afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("reissues the reveal when the sole selected file is activated again", async () => { + await mount(); + await activate("02-short.ts"); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + await activate("02-short.ts"); + expect(targets).toEqual([ + { type: "item", id: "02-short.ts\u000002-short.ts", align: "start" }, + { type: "item", id: "02-short.ts\u000002-short.ts", align: "start" }, + ]); + }); + + it("reveals newly selected files once in either direction", async () => { + await mount(); + await activate("02-short.ts"); + await activate("01-tall.ts"); + await activate("03-medium.ts"); + expect(targets.map((target) => ("id" in target ? target.id : null))).toEqual( + ["02-short.ts", "01-tall.ts", "03-medium.ts"].map((path) => `${path}\0${path}`), + ); + }); + + it("keeps focus-only navigation separate from button activation", async () => { + await mount(); + await activate("02-short.ts"); + await act(async () => model().getItem("01-tall.ts")!.focus()); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + expect(targets).toHaveLength(1); + await activate("01-tall.ts", { detail: 0 }); + await activate("01-tall.ts", { detail: 0 }); + expect(targets).toHaveLength(3); + }); + + it.each(["ctrlKey", "metaKey"] as const)( + "does not reveal a selected file that a %s click deselects", + async (modifier) => { + await mount(); + await activate("02-short.ts"); + await activate("02-short.ts", { [modifier]: true }); + expect(model().getSelectedPaths()).toEqual([]); + expect(targets).toHaveLength(1); + }, + ); + + it("lets a click narrow multiple selected files without a second reveal", async () => { + await mount(); + await activate("02-short.ts"); + await act(async () => model().getItem("01-tall.ts")!.select()); + expect(model().getSelectedPaths()).toHaveLength(2); + targets.length = 0; + await activate("02-short.ts"); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + expect(targets).toHaveLength(1); + }); + + it("leaves directory selection and expansion to the tree", async () => { + await mount({ files: [{ path: "src/app.ts", status: "modified" }] }); + const directory = model().getItem("src/")!; + if (!("isExpanded" in directory)) throw new Error("Expected the directory handle"); + expect(directory.isExpanded()).toBe(true); + await activate("src/"); + expect(directory.isExpanded()).toBe(false); + await activate("src/"); + expect(directory.isExpanded()).toBe(true); + expect(targets).toEqual([]); + }); + + it("does not echo controlled selection, but lets the reader activate it", async () => { + await mount({ selectedPath: "02-short.ts" }); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + expect(targets).toEqual([]); + await activate("02-short.ts"); + expect(targets).toHaveLength(1); + await act(async () => { + renderer!.update(); + }); + expect(model().getSelectedPaths()).toEqual(["03-medium.ts"]); + expect(targets).toHaveLength(1); + }); +}); diff --git a/apps/web/src/components/diffs/DiffFileTree.tsx b/apps/web/src/components/diffs/DiffFileTree.tsx index 3715b62ca15a..0d200853bcb4 100644 --- a/apps/web/src/components/diffs/DiffFileTree.tsx +++ b/apps/web/src/components/diffs/DiffFileTree.tsx @@ -177,6 +177,29 @@ export function DiffFileTree({ { + if ( + event.defaultPrevented || + event.button !== 0 || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.altKey + ) { + return; + } + // Pierre does not emit a selection change for its sole selected row. + // Read selection before the row handles the click so new selections reveal only once. + const selected = model.getSelectedPaths(); + const path = selected.length === 1 ? selected[0] : undefined; + if (!path || !filePathsRef.current.has(path)) return; + const clickedSelectedRow = event.nativeEvent + .composedPath() + .some( + (node) => node instanceof HTMLElement && node.getAttribute("data-item-path") === path, + ); + if (clickedSelectedRow) onSelectFileRef.current(path); + }} className="min-h-0 flex-1 overflow-hidden" style={pierreTreeStyle(resolvedTheme)} /> diff --git a/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts new file mode 100644 index 000000000000..520c0fa82d0e --- /dev/null +++ b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts @@ -0,0 +1,186 @@ +import { + FileRenderer, + disposeHighlighter, + getSharedHighlighter, + type BaseCodeOptions, + type DiffsHighlighter, + type FileContents, + type HighlightedToken, +} from "@pierre/diffs"; +import { TextDocument } from "@pierre/diffs/editor"; +import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; +import * as NodeWorkerThreads from "node:worker_threads"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +type DocumentChange = NonNullable["applyEdits"]>>; +interface Tokenizer { + tokenize(change: DocumentChange): Map; + cleanUp(): void; +} + +const tokenizerUrl = new URL("./editor/tokenizer.js", import.meta.resolve("@pierre/diffs")); +const { EditorTokenizer } = (await import(/* @vite-ignore */ tokenizerUrl.href)) as { + EditorTokenizer: new (options: { + codeOptions: BaseCodeOptions; + highlighter: DiffsHighlighter; + textDocument: TextDocument; + setStyle: (style: string) => void; + onDeferTokenize: () => void; + }) => Tokenizer; +}; + +const workerModule = import.meta.resolve("@pierre/diffs/worker/worker.js"); +const options = { + theme: "pierre-dark", + themeType: "dark", + preferredHighlighter: "shiki-wasm", + useTokenTransformer: true, +} as const; +const source = "export const View = () =>
Ready
;"; +let pool: WorkerPoolManager; +let renderer: FileRenderer; +let terminationPromises: Promise[]; + +class WorkerTransport { + private readonly worker = new NodeWorkerThreads.Worker( + `const { parentPort, workerData } = require("node:worker_threads"); + globalThis.self = { + addEventListener(type, listener) { + if (type === "message") parentPort.on("message", data => listener({ data })); + if (type === "error") process.on("uncaughtException", listener); + } + }; + globalThis.postMessage = data => parentPort.postMessage(data); + import(workerData.moduleUrl);`, + { eval: true, workerData: { moduleUrl: workerModule }, execArgv: [] }, + ); + + addEventListener( + type: "message" | "error", + listener: (event: { data: WorkerResponse } | Error) => void, + ) { + if (type === "error") this.worker.on("error", listener); + else this.worker.on("message", (data: WorkerResponse) => listener({ data })); + } + + postMessage(message: WorkerRequest) { + this.worker.postMessage(message, []); + } + + terminate() { + terminationPromises.push(this.worker.terminate()); + } +} + +function firstEnter(highlighter: DiffsHighlighter, file: FileContents, language: string) { + const document = new TextDocument(file.name, file.contents, language); + const tokenizer = new EditorTokenizer({ + codeOptions: options, + highlighter, + textDocument: document, + setStyle: () => {}, + onDeferTokenize: () => {}, + }); + try { + const end = document.positionAt(file.contents.length); + const change = document.applyEdits([{ range: { start: end, end }, newText: "\n" }]); + expect(change).toBeDefined(); + // This is the synchronous first edit, before the tokenizer's debounced prebuild. + const dirtyLines = tokenizer.tokenize(change!); + expect([...dirtyLines.keys()]).toEqual([0, 1]); + expect(document.getText()).toBe(`${file.contents}\n`); + } finally { + tokenizer.cleanUp(); + } +} + +beforeEach(async () => { + terminationPromises = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => + setImmediate(() => callback(0)), + ); + vi.stubGlobal("cancelAnimationFrame", clearImmediate); + vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); + await disposeHighlighter(); + pool = new WorkerPoolManager( + // Adapt transport only. The installed Pierre worker resolves and highlights the file. + { workerFactory: () => new WorkerTransport() as unknown as globalThis.Worker, poolSize: 1 }, + options, + ); + await pool.initialize(); + renderer = new FileRenderer(options, undefined, pool); +}); + +afterEach(async () => { + renderer?.cleanUp(); + pool?.terminate(); + await Promise.all(terminationPromises); + await disposeHighlighter(); + vi.unstubAllGlobals(); +}); + +describe("editable file language readiness", () => { + it.each(["hydrate", "renderFile"] as const)( + "%s prepares the inferred language before the first edit of a worker-highlighted file", + async (method) => { + const file = { name: "cold.tsx", contents: source, cacheKey: "cold-tsx" }; + await pool.primeFileHighlightCache(file); + expect(pool.getFileResultCache(file)).toBeDefined(); + const mainHighlighter = await getSharedHighlighter({ + themes: ["pierre-dark"], + langs: ["text"], + }); + expect(mainHighlighter.getLoadedLanguages()).not.toContain("tsx"); + renderer[method](file); + // Read-only worker rendering must not load editor grammars on the main thread. + expect(mainHighlighter.getLoadedLanguages()).not.toContain("tsx"); + const highlighter = await renderer.initializeHighlighter(); + firstEnter(highlighter, file, "tsx"); + }, + ); + + it.each(["hydrate", "renderFile"] as const)( + "%s respects an explicit language when the filename suggests plain text", + async (method) => { + const file: FileContents = { + name: "source.txt", + lang: "tsx", + contents: source, + cacheKey: "explicit-tsx", + }; + await pool.primeFileHighlightCache(file); + renderer[method](file); + firstEnter(await renderer.initializeHighlighter(), file, "tsx"); + }, + ); + + it("loads a newly opened language after reusing a worker-backed renderer", async () => { + const previousFile: FileContents = { + name: "previous.ts", + contents: "export const value = 1;", + cacheKey: "previous-ts", + }; + await getSharedHighlighter({ themes: ["pierre-dark"], langs: ["typescript"] }); + renderer.renderFile(previousFile); + firstEnter(await renderer.initializeHighlighter(), previousFile, "typescript"); + const nextFile = { name: "next.tsx", contents: source, cacheKey: "next-tsx" }; + renderer.renderFile(nextFile); + firstEnter(await renderer.initializeHighlighter(), nextFile, "tsx"); + }); + + it("prepares a hydrated non-worker file even when its theme was already loaded", async () => { + renderer.cleanUp(); + renderer = new FileRenderer(options); + const file = { name: "local.tsx", contents: source, cacheKey: "local-tsx" }; + renderer.hydrate(file); + firstEnter(await renderer.initializeHighlighter(), file, "tsx"); + }); + + it("keeps plain text editable without loading an unrelated grammar", async () => { + const file = { name: "notes.txt", contents: "Plain text", cacheKey: "plain-text" }; + renderer.renderFile(file); + const highlighter = await renderer.initializeHighlighter(); + firstEnter(highlighter, file, "text"); + expect(highlighter.getLoadedLanguages()).not.toContain("tsx"); + }); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index e45a687e981d..f77e2b845082 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -37,6 +37,7 @@ import { MenuPopup, MenuRadioGroup, MenuRadioItem, + MenuRadioItemIndicator, MenuSeparator, MenuSub, MenuSubPopup, @@ -205,6 +206,7 @@ function PullRequestFilterRadioGroup({ {option.label} {option.unavailable ? 路 Unavailable : null} + ); @@ -455,8 +457,8 @@ export function PullRequestFiltersMenu({ readonly environmentId: EnvironmentId; readonly title: string; readonly workspaceRoot: string; - readonly faviconPath?: string | null; - readonly projectIcon?: ProjectIconOverride | null; + readonly faviconPath?: string | null | undefined; + readonly projectIcon?: ProjectIconOverride | null | undefined; }>; projectId: ProjectId | undefined; /** diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts new file mode 100644 index 000000000000..5c660118ed28 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts @@ -0,0 +1,158 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { findScopedProject } from "./pullRequestList.logic"; +import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic"; + +const cups = EnvironmentId.make("env-cups"); +const nucbox = EnvironmentId.make("env-nucbox"); +const labels = new Map([ + [cups, "cups"], + [nucbox, "nucbox-1"], +]); + +function project( + id: string, + environmentId = nucbox, + canonicalKey: string | null = "github.com/pingdotgg/t3code", +) { + return { + id: ProjectId.make(id), + environmentId, + title: "t3code", + workspaceRoot: `/work/${id}`, + repositoryIdentity: canonicalKey === null ? null : { canonicalKey }, + faviconPath: `${id}/favicon.png`, + }; +} + +describe("pull request project filter choices", () => { + it("collapses three checkouts on one server without dropping another server's copy", () => { + const projects = [ + project("main"), + project("worktree-1"), + project("worktree-2"), + project("main", cups), + ]; + + const choices = pullRequestFilterProjects(projects, labels); + + expect(choices.map(({ id, environmentId, title }) => ({ id, environmentId, title }))).toEqual([ + { id: "main", environmentId: cups, title: "t3code 路 cups" }, + { id: "main", environmentId: nucbox, title: "t3code 路 nucbox-1" }, + ]); + expect(choices[1]?.workspaceRoot).toBe("/work/main"); + expect(choices[1]?.faviconPath).toBe("main/favicon.png"); + }); + + it("keeps a saved worktree selection as the repository's only choice", () => { + const projects = [project("main"), project("worktree"), project("worktree", cups)]; + const selected = findScopedProject(projects, nucbox, "worktree"); + + const choices = pullRequestFilterProjects(projects, labels, selected); + + expect(choices.filter((choice) => choice.environmentId === nucbox)).toEqual([ + { ...projects[1], title: "t3code 路 nucbox-1" }, + ]); + expect(findScopedProject(choices, nucbox, "worktree")).toBeDefined(); + expect(findScopedProject(choices, nucbox, "main")).toBeUndefined(); + expect(findScopedProject(choices, cups, "worktree")).toBeDefined(); + }); + + it("matches canonical repositories regardless of casing", () => { + const main = project("main"); + const worktree = project("worktree", nucbox, "GitHub.com/PingDotGG/T3Code"); + + expect(pullRequestFilterProjects([main, worktree], labels)).toEqual([main]); + }); + + it("does not add a server suffix after duplicate checkouts have collapsed", () => { + const main = project("main"); + + expect(pullRequestFilterProjects([main, project("worktree")], labels)).toEqual([main]); + expect(main.title).toBe("t3code"); + }); + + it("distinguishes same-named repositories on one server by checkout path", () => { + const projects = [ + project("upstream"), + project("fork", nucbox, "github.com/juliusmarminge/t3code"), + ]; + + const choices = pullRequestFilterProjects(projects, labels); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code 路 nucbox-1 路 /work/fork", + "t3code 路 nucbox-1 路 /work/upstream", + ]); + }); + + it("keeps repositories on different hosts separate", () => { + const choices = pullRequestFilterProjects( + [project("github"), project("enterprise", nucbox, "git.example.com/pingdotgg/t3code")], + labels, + ); + + expect(choices.map((choice) => choice.id)).toEqual(["enterprise", "github"]); + }); + + it("does not merge projects whose repository identity is unknown", () => { + const choices = pullRequestFilterProjects( + [project("first", nucbox, null), project("second", nucbox, null)], + labels, + ); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code 路 nucbox-1 路 /work/first", + "t3code 路 nucbox-1 路 /work/second", + ]); + }); + + it("distinguishes servers with the same display name and checkout path", () => { + const first = project("main"); + const second = project("main", cups); + const repeatedLabels = new Map([ + [cups, "nucbox-1"], + [nucbox, "nucbox-1"], + ]); + + const choices = pullRequestFilterProjects([first, second], repeatedLabels); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code 路 nucbox-1 路 /work/main 路 env-cups", + "t3code 路 nucbox-1 路 /work/main 路 env-nucbox", + ]); + }); + + it("uses the environment id when its label is unavailable", () => { + const choices = pullRequestFilterProjects( + [project("main"), project("remote", cups)], + new Map(), + ); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code 路 env-cups", + "t3code 路 env-nucbox", + ]); + }); + + it("can distinguish unresolved project records that also share a checkout path", () => { + const first = project("first", nucbox, null); + const second = { ...project("second", nucbox, null), workspaceRoot: first.workspaceRoot }; + + const choices = pullRequestFilterProjects([first, second], labels); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code 路 nucbox-1 路 /work/first 路 env-nucbox 路 first", + "t3code 路 nucbox-1 路 /work/first 路 env-nucbox 路 second", + ]); + }); + + it("leaves unrelated names unchanged and orders them alphabetically", () => { + const app = { ...project("app"), title: "Zebra" }; + const tools = { ...project("tools", nucbox, "github.com/acme/tools"), title: "Alpha" }; + + expect(pullRequestFilterProjects([app, tools], labels)).toEqual([tools, app]); + expect(pullRequestFilterProjects([], labels)).toEqual([]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts new file mode 100644 index 000000000000..5b214e3f37f6 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts @@ -0,0 +1,53 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +import type { AssignableProject } from "./pullRequestProjectAssignment.logic"; + +interface FilterProject extends AssignableProject { + readonly title: string; + readonly workspaceRoot: string; +} + +function distinguishTitles( + projects: ReadonlyArray, + suffix: (project: Project) => string, +) { + const counts = new Map(); + for (const project of projects) { + counts.set(project.title, (counts.get(project.title) ?? 0) + 1); + } + return projects.map((project) => + (counts.get(project.title) ?? 0) > 1 + ? { ...project, title: `${project.title} 路 ${suffix(project)}` } + : project, + ); +} + +/** One choice per repository per server, retaining the selected checkout for saved scopes. */ +export function pullRequestFilterProjects( + projects: ReadonlyArray, + environmentLabels: ReadonlyMap, + selectedProject?: Pick, +) { + const byRepository = new Map(); + for (const project of projects) { + const repository = project.repositoryIdentity?.canonicalKey?.toLowerCase(); + const key = JSON.stringify([ + project.environmentId, + repository ? ["repository", repository] : ["project", project.id], + ]); + const selected = + project.id === selectedProject?.id && project.environmentId === selectedProject.environmentId; + if (!byRepository.has(key) || selected) byRepository.set(key, project); + } + + const byServer = distinguishTitles( + [...byRepository.values()], + (project) => environmentLabels.get(project.environmentId) ?? project.environmentId, + ); + const byPath = distinguishTitles(byServer, (project) => project.workspaceRoot); + // Separate environments can share both their display name and their checkout path. + const byEnvironment = distinguishTitles(byPath, (project) => project.environmentId); + return distinguishTitles(byEnvironment, (project) => project.id).toSorted((left, right) => + left.title.localeCompare(right.title), + ); +} diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index b0beea24cc47..ad99328647af 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -1324,7 +1324,7 @@ function KeybindingsList(props: KeybindingsListProps) { /** Shown in the browser build only; the desktop app receives every shortcut. */ function BrowserKeybindingNotice() { return ( -
+
Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop app diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 7dc13fa4f25c..4bb8cf11bd4e 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -5,8 +5,6 @@ import { DRIVER_OPTION_BY_VALUE } from "./providerDriverMeta"; import { deriveProviderSettingsFields, nextProviderConfigWithFieldValue, - readProviderConfigBoolean, - readProviderConfigString, } from "./ProviderSettingsForm"; describe("ProviderSettingsForm helpers", () => { @@ -90,10 +88,6 @@ describe("ProviderSettingsForm helpers", () => { expect(next).toEqual({ forkOwned: 1 }); }); - it("reads non-string config values as blank strings", () => { - expect(readProviderConfigString({ binaryPath: 123 }, "binaryPath")).toBe(""); - }); - it("omits false boolean fields when clearWhenEmpty is omit", () => { const next = nextProviderConfigWithFieldValue( { forkOwned: 1, experimental: true }, @@ -156,12 +150,4 @@ describe("ProviderSettingsForm helpers", () => { expect(next).toEqual({ experimental: false }); }); - - it("reads non-boolean config values as false booleans", () => { - expect(readProviderConfigBoolean({ experimental: "true" }, "experimental")).toBe(false); - }); - - it("reads missing boolean config values from the supplied default", () => { - expect(readProviderConfigBoolean({}, "experimental", true)).toBe(true); - }); }); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index 902fd408b54f..6d644aaf01c5 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -119,17 +119,13 @@ export function deriveProviderSettingsFields( }); } -export function readProviderConfigString(config: unknown, key: string): string { +function readProviderConfigString(config: unknown, key: string): string { if (config === null || typeof config !== "object") return ""; const value = (config as Record)[key]; return typeof value === "string" ? value : ""; } -export function readProviderConfigBoolean( - config: unknown, - key: string, - defaultValue = false, -): boolean { +function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean { if (config === null || typeof config !== "object") return defaultValue; const value = (config as Record)[key]; return typeof value === "boolean" ? value : defaultValue; diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index d7892cb228ab..fbdcbc03480a 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -1,7 +1,7 @@ "use client"; import { Menu as MenuPrimitive } from "@base-ui/react/menu"; -import { ChevronRightIcon } from "lucide-react"; +import { CheckIcon, ChevronRightIcon } from "lucide-react"; import type * as React from "react"; import { cn } from "~/lib/utils"; @@ -177,6 +177,23 @@ function MenuRadioItem({ ); } +function MenuRadioItemIndicator({ + className, + children, + ...props +}: MenuPrimitive.RadioItemIndicator.Props) { + return ( + + {children ?? } + + ); +} + function MenuGroupLabel({ className, inset, @@ -300,6 +317,7 @@ export { MenuRadioGroup as DropdownMenuRadioGroup, MenuRadioItem, MenuRadioItem as DropdownMenuRadioItem, + MenuRadioItemIndicator, MenuGroupLabel, MenuGroupLabel as DropdownMenuLabel, MenuSeparator, diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index a9537423a7f0..37522ea3b0a2 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -86,6 +86,7 @@ import { writePullRequestListPreferences, } from "../components/pullRequest/pullRequestListPreferences"; import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic"; +import { pullRequestFilterProjects } from "../components/pullRequest/pullRequestProjectFilter.logic"; import { environmentMachineIcon } from "../components/EnvironmentMachineIcon"; import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; import { @@ -356,27 +357,6 @@ function PullRequestsRouteView() { ), [environments], ); - const scopedProjects = useMemo(() => { - // Two machines can hold the same repository, so a title the workspace carries twice is told - // apart by the environment it lives on rather than left as two identical rows. - const titleCounts = new Map(); - for (const project of projects) { - titleCounts.set(project.title, (titleCounts.get(project.title) ?? 0) + 1); - } - return projects - .map((project) => ({ - id: project.id, - environmentId: project.environmentId, - title: - (titleCounts.get(project.title) ?? 0) > 1 - ? `${project.title} 路 ${environmentLabels.get(project.environmentId) ?? project.environmentId}` - : project.title, - workspaceRoot: project.workspaceRoot, - faviconPath: project.faviconPath ?? null, - projectIcon: project.projectIcon ?? null, - })) - .toSorted((left, right) => left.title.localeCompare(right.title)); - }, [environmentLabels, projects]); // The scope the URL asks for, once the environments have had their say about whether it exists. const scopedProjectId = useMemo( () => resolveProjectScope(search.projectId, projects, projectsKnown), @@ -386,6 +366,10 @@ function PullRequestsRouteView() { () => findScopedProject(projects, scopedEnvironmentId, scopedProjectId), [projects, scopedEnvironmentId, scopedProjectId], ); + const scopedProjects = useMemo( + () => pullRequestFilterProjects(projects, environmentLabels, scopedProject), + [environmentLabels, projects, scopedProject], + ); // A link from a thread or the sidebar only knows the repository, so the owning project is // resolved here; an explicit `projectId` in the URL still wins. diff --git a/packages/client-runtime/src/environment/knownEnvironment.test.ts b/packages/client-runtime/src/environment/knownEnvironment.test.ts index 66bbb1df7e91..032be152fdbc 100644 --- a/packages/client-runtime/src/environment/knownEnvironment.test.ts +++ b/packages/client-runtime/src/environment/knownEnvironment.test.ts @@ -6,7 +6,6 @@ import { parseScopedProjectKey, parseScopedThreadKey, scopedProjectKey, - scopedRefKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, @@ -40,8 +39,6 @@ describe("scoped refs", () => { const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread-1")); it("builds stable scoped project and thread keys", () => { - expect(scopedRefKey(projectRef)).toBe("environment-test:project-1"); - expect(scopedRefKey(threadRef)).toBe("environment-test:thread-1"); expect(scopedProjectKey(projectRef)).toBe("environment-test:project-1"); expect(scopedThreadKey(threadRef)).toBe("environment-test:thread-1"); }); diff --git a/packages/client-runtime/src/environment/scoped.ts b/packages/client-runtime/src/environment/scoped.ts index 354c548c02de..7894c7ba5329 100644 --- a/packages/client-runtime/src/environment/scoped.ts +++ b/packages/client-runtime/src/environment/scoped.ts @@ -22,7 +22,7 @@ export function scopeThreadRef( return { environmentId, threadId }; } -export function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string { +function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string { const localId = "projectId" in ref ? ref.projectId : ref.threadId; return `${ref.environmentId}:${localId}`; } diff --git a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch index 3b558fe56552..0c9819145d3b 100644 --- a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch +++ b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch @@ -63,6 +63,18 @@ index e9f62f5..af82a46 100644 diff --git a/dist/renderers/FileRenderer.js b/dist/renderers/FileRenderer.js --- a/dist/renderers/FileRenderer.js +++ b/dist/renderers/FileRenderer.js +@@ -107,10 +107,10 @@ + result: massiveFile ? void 0 : cache?.result, + renderRange: void 0 + }; ++ this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + if (this.workerManager?.isWorkingPool() === true) { + if (this.renderCache.result == null && !massiveFile) this.workerManager.highlightFileAST(this, file); + } else if (this.highlighter == null) { +- this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + this.initializeHighlighter(); + } + } @@ -163,6 +163,8 @@ if (this.renderCache == null) return; const { file, result } = this.renderCache; @@ -72,6 +84,22 @@ diff --git a/dist/renderers/FileRenderer.js b/dist/renderers/FileRenderer.js const lineCache = this.lineCache != null && isLineCacheForFile(this.lineCache, file) ? this.lineCache : void 0; for (const [line, tokens] of dirtyLines) { if (lineCache != null && line < lineCache.lines.length) { +@@ -268,6 +270,7 @@ + const forcePlainText = !hasContent || isFilePlainText(file) || isFileMassive(lines.length, this.getTokenizeMaxLength()); + const newContent = !areFilesEqual(file, this.renderCache.file); + const newRenderRange = !areRenderRangesEqual(this.renderCache.renderRange, renderRange); ++ this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + if (this.workerManager?.isWorkingPool() === true) { + if (forcePlainText || this.renderCache.result == null || !this.renderCache.highlighted && (newContent || newRenderRange)) { + this.renderCache.file = file; +@@ -278,7 +281,6 @@ + } + if (!forcePlainText && hasContent && (!this.renderCache.highlighted || forceHighlight)) this.workerManager.highlightFileAST(this, file); + } else { +- this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + const hasThemes = this.highlighter != null && areThemesAttached(options.theme); + const hasLangs = this.highlighter != null && areLanguagesAttached(this.computedLang); + const canHighlight = !forcePlainText && hasLangs; diff --git a/package.json b/package.json index ff61c90..1e170e5 100644 --- a/package.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bfc515ab45d..45300c221190 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,7 +93,7 @@ patchedDependencies: '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 '@github/copilot-sdk@0.1.32': fda37620d052aa4a96436b0f7fd4964ce7d4826142f09352daf1a2e59226e6e3 '@legendapp/list@3.3.5': 03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43 - '@pierre/diffs@1.3.0-beta.10': c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e + '@pierre/diffs@1.3.0-beta.10': c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 @@ -249,7 +249,7 @@ importers: version: 1.9.1 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-ai/apple': specifier: 0.12.0 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) @@ -605,7 +605,7 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -13867,7 +13867,7 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@pierre/theme': 1.1.0 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0) @@ -13881,7 +13881,7 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@pierre/theme': 1.1.0 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)