From 8de61d4a97635382ee08d098b2a588e88532cc0a Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 11 Oct 2026 11:42:43 -0700 Subject: [PATCH] fix(client): merge No project filters across environments (#17050) Co-authored-by: Julius Marminge --- .../src/features/home/HomeRouteScreen.tsx | 12 +- .../src/features/home/homeThreadList.test.ts | 74 +++++++++ .../src/features/home/homeThreadList.ts | 1 + .../threads/ThreadNavigationSidebar.tsx | 12 +- apps/mobile/src/state/projects.ts | 2 + apps/web/src/components/Sidebar.tsx | 67 +++++++-- apps/web/src/environmentGrouping.test.ts | 141 ++++++++++++++++++ apps/web/src/sidebarProjectGrouping.ts | 50 ++++++- apps/web/src/state/projects.ts | 2 + packages/client-runtime/src/state/models.ts | 2 + .../src/state/projectEntities.test.ts | 135 +++++++++++++++++ .../src/state/projectEntities.ts | 37 ++++- .../src/state/projectGrouping.test.ts | 127 ++++++++++++++++ .../src/state/projectGrouping.ts | 70 +++++++-- 14 files changed, 704 insertions(+), 28 deletions(-) create mode 100644 packages/client-runtime/src/state/projectEntities.test.ts diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 965f57262a8015609a94b3768f0a4633cdfa8657..808a1dd76f61b5b8acbfdf3ec0f76b190666af8a 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects } from "../../state/entities"; @@ -119,7 +120,16 @@ export function HomeRouteScreen() { setSelectedProjectKey, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; - const selectedProjectKey = listOptions.selectedProjectKey; + const projectScopeKey = listOptions.selectedProjectKey; + const selectedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { + sidebarProjectGroupingMode: listOptions.projectGroupingMode, + sidebarProjectGroupingOverrides: {}, + }); + useEffect(() => { + if (selectedProjectKey !== projectScopeKey) { + setSelectedProjectKey(selectedProjectKey); + } + }, [projectScopeKey, selectedProjectKey, setSelectedProjectKey]); const projectFilterOptions = useMemo( () => buildHomeProjectScopes({ diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index fc134c9d74e4f0b9696b5d7968dc0b465017430e..8e0f3e81a85c0d27d05130f39521b898f054f0b4 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -11,6 +11,7 @@ import { sortHomeProjectScopes, } from "./homeThreadList"; import { makeThreadShellFixture } from "../../test-fixtures"; +import { buildThreadListV2Items } from "../threads/threadListV2"; function makeProject( input: Partial & Pick, @@ -48,6 +49,79 @@ function makeThread( } describe("home project scopes", () => { + it("filters threads across both scratch hosts while retaining the selected environment boundary", () => { + const localEnvironmentId = EnvironmentId.make("local"); + const remoteEnvironmentId = EnvironmentId.make("remote"); + const projects = [ + makeProject({ + environmentId: localEnvironmentId, + id: ProjectId.make("scratch-local"), + title: "No project", + workspaceRoot: "/local/scratch", + isScratch: true, + }), + makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("scratch-remote"), + title: "No project", + workspaceRoot: "/remote/scratch", + isScratch: true, + }), + makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("ordinary"), + title: "No project", + }), + ]; + const threads = projects.map((project) => + makeThread({ + environmentId: project.environmentId, + projectId: project.id, + id: ThreadId.make(`thread-${project.id}`), + title: project.id, + }), + ); + const scopes = buildHomeProjectScopes({ + projects, + environmentId: null, + projectGroupingMode: "separate", + }); + expect(scopes).toHaveLength(2); + const scratchScope = scopes.find((scope) => scope.representative.isScratch)!; + const filtered = buildThreadListV2Items({ + threads, + environmentId: null, + projectRefs: scratchScope.projectRefs, + searchQuery: "", + now: "2026-06-01T00:00:00.000Z", + }); + expect(filtered.items.map((item) => item.thread.id).sort()).toEqual( + threads + .slice(0, 2) + .map((thread) => thread.id) + .sort(), + ); + const remoteScopes = buildHomeProjectScopes({ + projects, + environmentId: remoteEnvironmentId, + projectGroupingMode: "separate", + }); + const remoteScratch = remoteScopes.find((scope) => scope.representative.isScratch)!; + expect(remoteScratch.key).toBe(scratchScope.key); + expect(remoteScratch.projectRefs).toEqual([ + { environmentId: remoteEnvironmentId, projectId: projects[1]!.id }, + ]); + expect( + buildThreadListV2Items({ + threads, + environmentId: remoteEnvironmentId, + projectRefs: remoteScratch.projectRefs, + searchQuery: "", + now: "2026-06-01T00:00:00.000Z", + }).items.map((item) => item.thread.id), + ).toEqual([threads[1]!.id]); + }); + it("builds one v2 scope for the same repository across environments", () => { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 165278857c19cc0c6ccc14a4a942bbc44e43bfb0..9119e39cdf360b159aa53ac71d6251091f9b7f2c 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -50,6 +50,7 @@ export function buildHomeProjectScopes(input: { ); return buildProjectGroups({ projects, + groupScratchProjects: true, settings: { sidebarProjectGroupingMode: input.projectGroupingMode, sidebarProjectGroupingOverrides: {}, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index c686810f2ff57e6a4aaf97241770316f9d952e7e..d920336111da4be3c34211da87ce098bf5aea9c7 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,4 +1,5 @@ import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; +import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { computeThreadMoveAvailability } from "./threadOrder"; import type { @@ -206,7 +207,16 @@ function ThreadNavigationSidebarPane( () => new Set(threadSearch.matches.map(threadSearchMatchKey)), [threadSearch.matches], ); - const selectedProjectKey = options.selectedProjectKey; + const projectScopeKey = options.selectedProjectKey; + const selectedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { + sidebarProjectGroupingMode: options.projectGroupingMode, + sidebarProjectGroupingOverrides: {}, + }); + useEffect(() => { + if (selectedProjectKey !== projectScopeKey) { + setSelectedProjectKey(selectedProjectKey); + } + }, [projectScopeKey, selectedProjectKey, setSelectedProjectKey]); const projectScopes = useMemo( () => buildHomeProjectScopes({ diff --git a/apps/mobile/src/state/projects.ts b/apps/mobile/src/state/projects.ts index aaad677ef9cdf39b6bf75e768ddf7a61b1a7f88e..9b3b1d3af5b9f7de87135a670cf668a615a49d58 100644 --- a/apps/mobile/src/state/projects.ts +++ b/apps/mobile/src/state/projects.ts @@ -4,10 +4,12 @@ import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/pro import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; +import { serverEnvironment } from "./server"; export const environmentProjects = createEnvironmentProjectAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: environmentSnapshotAtom, + serverConfigValueAtom: serverEnvironment.configValueAtom, }); export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, { projectAtom: environmentProjects.projectAtom, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 16f80abe550d2d6fab38f2b06f71ad7801604c66..56e51346b79e5eb16f443c85435d2c4f290cd752 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { type EnvironmentId } from "@t3tools/contracts"; +import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard"; import { CollapsibleSectionHeader } from "./ui/collapsible-section-header"; import { setThreadChangeRequestSnapshot } from "./ThreadStatusIndicators"; @@ -129,6 +129,8 @@ import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalPro import { buildSidebarProjectSnapshots, projectGroupsSpanEnvironments, + resolveSidebarProjectScopeKey, + getSidebarProjectSettingsKey, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -158,6 +160,7 @@ import { useThreadShells, } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { environmentProjects } from "../state/projects"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; @@ -2562,6 +2565,7 @@ export default function Sidebar() { () => buildSidebarProjectSnapshots({ projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, + groupScratchProjects: true, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, @@ -2625,8 +2629,17 @@ export default function Sidebar() { // The selection lives in the persisted UI store next to the other sidebar // project preferences, so routes that unmount the sidebar (Settings) and // app restarts keep it. - const projectScopeKey = useUiStateStore((store) => store.sidebarProjectScopeKey); + const persistedProjectScopeKey = useUiStateStore((store) => store.sidebarProjectScopeKey); const setProjectScopeKey = useUiStateStore((store) => store.setSidebarProjectScopeKey); + const projectScopeKey = useMemo( + () => + resolveSidebarProjectScopeKey({ + groups: projectGroups, + key: persistedProjectScopeKey, + settings: projectGroupingSettings, + }), + [persistedProjectScopeKey, projectGroups, projectGroupingSettings], + ); // {value, label} items let Base UI drive the combobox selection contract // while the popup search filters the same collection. const projectScopeItems = useMemo( @@ -2709,15 +2722,28 @@ export default function Sidebar() { ), [scopedProjectGroup], ); - // A persisted scope whose project is gone falls back to all projects, but - // only after every catalog environment has a live project snapshot. Cached - // or disconnected environments cannot establish that the project is gone. + // Configs identify scratch projects, so both they and live project snapshots + // must arrive before a persisted scope can be considered missing. const allProjectSnapshotsReady = useAllEnvironmentProjectSnapshotsReady(); + const allProjectConfigsReady = useAtomValue(environmentProjects.projectConfigsReadyAtom); useEffect(() => { - if (projectScopeKey !== null && allProjectSnapshotsReady && scopedProjectGroup === null) { - setProjectScopeKey(null); + const nextKey = resolveSidebarProjectScopeKey({ + groups: projectGroups, + key: persistedProjectScopeKey, + settings: projectGroupingSettings, + canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady, + }); + if (nextKey !== persistedProjectScopeKey) { + setProjectScopeKey(nextKey); } - }, [allProjectSnapshotsReady, projectScopeKey, scopedProjectGroup, setProjectScopeKey]); + }, [ + allProjectConfigsReady, + allProjectSnapshotsReady, + persistedProjectScopeKey, + projectGroups, + projectGroupingSettings, + setProjectScopeKey, + ]); // Count-only subscription: the parent needs "are there draft rows" for the // empty state, while SidebarDraftBlock owns the per-keystroke content // subscription. Selecting a number keeps typing in a draft composer from @@ -2751,16 +2777,23 @@ export default function Sidebar() { }, [clearSelection, projectScopeKey]); const openProjectSettings = useCallback( - (projectGroup: SidebarProjectSnapshot) => { + (projectGroup: SidebarProjectSnapshot, projectRef?: ScopedProjectRef) => { if (isMobile) { setOpenMobile(false); } void router.navigate({ to: "/projects/$projectKey", - params: { projectKey: projectGroup.projectKey }, + params: { + projectKey: getSidebarProjectSettingsKey( + projectGroup, + projectGroupingSettings, + projects, + projectRef, + ), + }, }); }, - [isMobile, router, setOpenMobile], + [isMobile, projectGroupingSettings, projects, router, setOpenMobile], ); // Anchor for the scope popup: the header search field, not its icon trigger. const headerSearchRef = useRef(null); @@ -4579,7 +4612,11 @@ export default function Sidebar() { if (clicked._tag === "Failure") return; switch (clicked.value) { case "project-settings": - if (projectGroup) openProjectSettings(projectGroup); + if (projectGroup) + openProjectSettings(projectGroup, { + environmentId: session.environmentId, + projectId: session.projectId, + }); return; case "copy-path": if (workspacePath) copyPathToClipboard(workspacePath, { path: workspacePath }); @@ -4703,7 +4740,11 @@ export default function Sidebar() { } return; case "project-settings": - if (threadProjectGroup) openProjectSettings(threadProjectGroup); + if (threadProjectGroup) + openProjectSettings(threadProjectGroup, { + environmentId: threadRef.environmentId, + projectId: thread.projectId, + }); return; case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index efb6d8412ce059941a3d2d51fbe257c78787ea88..c62d483d892f639cc82c0b6156c4153aaa1792b6 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -13,6 +13,8 @@ import { buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, projectGroupsSpanEnvironments, + resolveSidebarProjectScopeKey, + getSidebarProjectSettingsKey, } from "./sidebarProjectGrouping"; import { orderItemsByPreferredIds } from "./components/Sidebar.logic"; import { legacyProjectCwdPreferenceKey } from "./uiStateStore"; @@ -52,6 +54,145 @@ function makeProject(overrides: Partial = {}): Project { } describe("environment grouping", () => { + it("opens physical scratch settings from the merged filter, including a remote thread target", () => { + const local = makeProject({ + title: "No project", + workspaceRoot: "/local/scratch", + isScratch: true, + }); + const remote = makeProject({ + title: "No project", + id: ProjectId.make("remote-scratch"), + environmentId: remoteEnvironmentId, + workspaceRoot: "/remote/scratch", + isScratch: true, + }); + const input = { + projects: [local, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }; + const filters = buildSidebarProjectSnapshots({ ...input, groupScratchProjects: true }); + const settingsGroups = buildSidebarProjectSnapshots(input); + expect(filters).toHaveLength(1); + expect(settingsGroups).toHaveLength(2); + expect(settingsGroups.map((group) => group.memberProjectRefs)).toEqual([ + [{ environmentId: local.environmentId, projectId: local.id }], + [{ environmentId: remote.environmentId, projectId: remote.id }], + ]); + expect(getSidebarProjectSettingsKey(filters[0]!, defaultGroupingSettings, input.projects)).toBe( + settingsGroups[0]?.projectKey, + ); + expect( + getSidebarProjectSettingsKey(filters[0]!, defaultGroupingSettings, input.projects, { + environmentId: remote.environmentId, + projectId: remote.id, + }), + ).toBe(settingsGroups[1]?.projectKey); + }); + + it.each(["repository", "repository_path"] as const)( + "migrates a scratch scope selected before config in %s mode", + (mode) => { + const project = makeProject({ + workspaceRoot: "/repo/scratch", + repositoryIdentity: { ...repositoryIdentity, rootPath: "/repo" }, + }); + const settings = { ...defaultGroupingSettings, sidebarProjectGroupingMode: mode }; + const key = deriveLogicalProjectKeyFromSettings(project, settings); + const groups = buildSidebarProjectSnapshots({ + projects: [{ ...project, isScratch: true }], + groupScratchProjects: true, + settings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + expect( + resolveSidebarProjectScopeKey({ groups, key, settings, canClearMissingScope: true }), + ).toBe(groups[0]?.projectKey); + }, + ); + + it("prefers an existing ordinary repository scope over a scratch alias", () => { + const scratch = makeProject({ isScratch: true, repositoryIdentity }); + const ordinary = makeProject({ + id: ProjectId.make("ordinary"), + workspaceRoot: "/ordinary", + repositoryIdentity, + }); + const groups = buildSidebarProjectSnapshots({ + projects: [scratch, ordinary], + groupScratchProjects: true, + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + const key = deriveLogicalProjectKeyFromSettings(ordinary, defaultGroupingSettings); + expect(resolveSidebarProjectScopeKey({ groups, key, canClearMissingScope: true })).toBe(key); + }); + + it("keeps both scratch hosts in one filter and migrates a physical scope after config loads", () => { + const projects = [ + makeProject({ title: "No project", workspaceRoot: "/local/scratch" }), + makeProject({ + id: ProjectId.make("scratch-remote"), + environmentId: remoteEnvironmentId, + title: "No project", + workspaceRoot: "/remote/scratch", + }), + ]; + const build = (scratch: boolean) => + buildSidebarProjectSnapshots({ + projects: projects.map((project) => ({ + ...project, + ...(scratch ? { isScratch: true as const } : {}), + })), + groupScratchProjects: true, + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (id) => id, + }); + const oldKey = derivePhysicalProjectKey(projects[1]!); + const unloaded = build(false); + const groups = build(true); + const scratchKey = groups[0]!.projectKey; + expect(groups).toHaveLength(1); + expect(groups[0]?.displayName).toBe("No project"); + expect(groups[0]?.memberProjects).toHaveLength(2); + expect(groups[0]?.memberProjectRefs).toEqual( + projects.map((project) => ({ environmentId: project.environmentId, projectId: project.id })), + ); + expect(resolveSidebarProjectScopeKey({ groups: unloaded, key: oldKey })).toBe(oldKey); + expect(resolveSidebarProjectScopeKey({ groups, key: oldKey, canClearMissingScope: true })).toBe( + scratchKey, + ); + expect(resolveSidebarProjectScopeKey({ groups: unloaded, key: scratchKey })).toBe(scratchKey); + expect( + resolveSidebarProjectScopeKey({ groups, key: scratchKey, canClearMissingScope: true }), + ).toBe(scratchKey); + expect( + resolveSidebarProjectScopeKey({ groups, key: "removed", canClearMissingScope: true }), + ).toBeNull(); + }); + + it("does not migrate an ordinary physical filter into a repository group", () => { + const project = makeProject({ repositoryIdentity, title: "No project" }); + const groups = buildSidebarProjectSnapshots({ + projects: [project], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + expect( + resolveSidebarProjectScopeKey({ + groups, + key: derivePhysicalProjectKey(project), + canClearMissingScope: true, + }), + ).toBeNull(); + }); + it("groups matching repository identities across environments", () => { const primary = makeProject({ repositoryIdentity }); const remote = makeProject({ diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 914b0cac844dfcbba025cf2b71e38c4c54b4a6a5..75a5e9867b6afc385fb730ea7861638e9234d06d 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -1,5 +1,10 @@ import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; -import { buildProjectGroups, type ProjectGroupingSettings } from "./logicalProject"; +import { + buildProjectGroups, + derivePhysicalProjectKey, + type ProjectGroupingSettings, +} from "./logicalProject"; +import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping"; import type { Project } from "./types"; export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; @@ -45,6 +50,47 @@ export interface SidebarProjectPickerEntry { isPreferred: boolean; } +/** A merged filter still opens settings for the chosen physical project. */ +export function getSidebarProjectSettingsKey( + group: SidebarProjectSnapshot, + settings: ProjectGroupingSettings, + projects: ReadonlyArray, + projectRef?: ScopedProjectRef, +): string { + if (!group.isScratch) return group.projectKey; + const project = + group.memberProjects.find( + (member) => + member.environmentId === projectRef?.environmentId && member.id === projectRef.projectId, + ) ?? + group.memberProjects.find((member) => member.environmentId === projectRef?.environmentId) ?? + group; + return ( + buildPhysicalToLogicalProjectKeyMap({ projects, settings, primaryEnvironmentId: null }).get( + derivePhysicalProjectKey(project), + ) ?? group.projectKey + ); +} + +/** Retains a saved physical scratch scope when its environment config coalesces the group. */ +export function resolveSidebarProjectScopeKey(input: { + readonly groups: ReadonlyArray; + readonly key: string | null; + readonly settings?: ProjectGroupingSettings; + readonly canClearMissingScope?: boolean; +}): string | null { + if (input.key === null) return null; + const group = + input.groups.find((candidate) => candidate.projectKey === input.key) ?? + input.groups.find( + (candidate) => + candidate.isScratch && + resolveScratchProjectScopeKey(input.key, candidate.memberProjects, input.settings) === + candidate.projectKey, + ); + return group?.projectKey ?? (input.canClearMissingScope ? null : input.key); +} + export function buildPhysicalToLogicalProjectKeyMap(input: { projects: ReadonlyArray; settings: ProjectGroupingSettings; @@ -67,6 +113,7 @@ export function buildPhysicalToLogicalProjectKeyMap(input: { export function buildSidebarProjectSnapshots(input: { projects: ReadonlyArray; settings: ProjectGroupingSettings; + groupScratchProjects?: boolean; primaryEnvironmentId: EnvironmentId | null; resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; // Returns true when an env id maps to a desktop-local saved-env @@ -78,6 +125,7 @@ export function buildSidebarProjectSnapshots(input: { }): SidebarProjectSnapshot[] { return buildProjectGroups({ projects: input.projects, + groupScratchProjects: input.groupScratchProjects ?? false, settings: input.settings, preferredEnvironmentId: input.primaryEnvironmentId, }).map((group): SidebarProjectSnapshot => { diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts index d5a07058a80b00da0a261e09f46753c55267948c..339011559d01f5e952a000c72d8318d20111c208 100644 --- a/apps/web/src/state/projects.ts +++ b/apps/web/src/state/projects.ts @@ -6,10 +6,12 @@ import { WS_METHODS } from "@t3tools/contracts"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; +import { serverEnvironment } from "./server"; export const environmentProjects = createEnvironmentProjectAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: environmentSnapshotAtom, + serverConfigValueAtom: serverEnvironment.configValueAtom, }); export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, { projectAtom: environmentProjects.projectAtom, diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index e9049239e7e7ee0277b06e40ec32340a2c449944..68c9cfe4fdc5359c15fd09774d590b41b203bb20 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -22,6 +22,8 @@ import { formatSubagentDisplayTitle } from "./subagentDisplay.ts"; export interface EnvironmentProject extends OrchestrationProjectShell { readonly environmentId: EnvironmentId; + /** Derived from the environment's configured scratch root, never persisted. */ + readonly isScratch?: true; } /** diff --git a/packages/client-runtime/src/state/projectEntities.test.ts b/packages/client-runtime/src/state/projectEntities.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e90a1005bbb700c6e6ce9db7a8a28076e7d7460 --- /dev/null +++ b/packages/client-runtime/src/state/projectEntities.test.ts @@ -0,0 +1,135 @@ +import { EnvironmentId, ProjectId, type ServerConfig } from "@t3tools/contracts"; +import { Atom, AtomRegistry } from "effect/reactivity"; +import * as Option from "effect/Option"; +import { describe, expect, it } from "vite-plus/test"; + +import { PrimaryConnectionTarget } from "../connection/model.ts"; +import { createEnvironmentProjectAtoms } from "./projectEntities.ts"; +import { v2Project, v2ShellSnapshot } from "./orchestrationV2TestFixtures.ts"; + +const environmentId = EnvironmentId.make("local"); +const remoteEnvironmentId = EnvironmentId.make("remote"); + +function makeHarness(withConfig = true) { + const snapshotAtom = Atom.family((_environmentId: EnvironmentId) => + Atom.make({ + ...v2ShellSnapshot, + projects: [ + { ...v2Project, title: "No project" }, + { + ...v2Project, + title: "No project", + id: ProjectId.make("ordinary"), + workspaceRoot: "/repo", + }, + ], + }), + ); + const configAtom = Atom.family((_environmentId: EnvironmentId) => + Atom.make<(Pick & { revision?: number }) | null>(null), + ); + const catalogValueAtom = Atom.make({ + isReady: true, + entries: new Map( + [environmentId, remoteEnvironmentId].map((id) => [ + id, + { + target: new PrimaryConnectionTarget({ + environmentId: id, + label: id, + httpBaseUrl: `https://${id}.example.test`, + wsBaseUrl: `wss://${id}.example.test`, + }), + profile: Option.none(), + enabled: true, + }, + ]), + ), + }); + const projects = createEnvironmentProjectAtoms({ + catalogValueAtom, + snapshotAtom, + ...(withConfig ? { serverConfigValueAtom: configAtom } : {}), + }); + return { registry: AtomRegistry.make(), projects, configAtom, snapshotAtom, catalogValueAtom }; +} + +describe("project scratch identity", () => { + it("waits for enabled project configs and ignores a disabled environment with no config", () => { + const { registry, projects, configAtom, catalogValueAtom } = makeHarness(); + try { + expect(registry.get(projects.projectConfigsReadyAtom)).toBe(false); + registry.set(configAtom(environmentId), { scratchWorkspaceRoot: v2Project.workspaceRoot }); + expect(registry.get(projects.projectConfigsReadyAtom)).toBe(false); + const catalog = registry.get(catalogValueAtom); + registry.set(catalogValueAtom, { + ...catalog, + entries: new Map( + [...catalog.entries].map(([id, entry]) => [ + id, + { ...entry, enabled: id === environmentId }, + ]), + ), + }); + expect(registry.get(projects.projectConfigsReadyAtom)).toBe(true); + registry.set(catalogValueAtom, catalog); + expect(registry.get(projects.projectConfigsReadyAtom)).toBe(false); + registry.set(configAtom(remoteEnvironmentId), {}); + expect(registry.get(projects.projectConfigsReadyAtom)).toBe(true); + registry.set(catalogValueAtom, { ...catalog, isReady: false }); + expect(registry.get(projects.projectConfigsReadyAtom)).toBe(false); + } finally { + registry.dispose(); + } + }); + + it("updates a loaded project when config arrives, preserving unrelated projects and snapshots", () => { + const { registry, projects, configAtom, snapshotAtom } = makeHarness(); + try { + const initial = registry.get(projects.projectsAtom); + const source = registry.get(snapshotAtom(environmentId)); + registry.set(configAtom(environmentId), { + scratchWorkspaceRoot: `${v2Project.workspaceRoot}/`, + }); + const next = registry.get(projects.projectsAtom); + + expect(next[0]?.isScratch).toBe(true); + expect(next[0]).not.toBe(initial[0]); + expect(next.slice(1)).toEqual(initial.slice(1)); + for (let index = 1; index < initial.length; index++) { + expect(next[index]).toBe(initial[index]); + } + expect(registry.get(snapshotAtom(environmentId))).toBe(source); + expect(v2Project).not.toHaveProperty("isScratch"); + + registry.set(configAtom(environmentId), { + scratchWorkspaceRoot: v2Project.workspaceRoot, + revision: 1, + }); + expect(registry.get(projects.projectsAtom)).toBe(next); + + registry.set(configAtom(environmentId), { scratchWorkspaceRoot: "/repo" }); + const changedRoot = registry.get(projects.projectsAtom); + expect(changedRoot[0]).not.toHaveProperty("isScratch"); + expect(changedRoot[1]?.isScratch).toBe(true); + expect(changedRoot[2]).toBe(initial[2]); + + registry.set(configAtom(environmentId), null); + expect(registry.get(projects.projectsAtom).every((project) => !project.isScratch)).toBe(true); + } finally { + registry.dispose(); + } + }); + + it("keeps callers without server configs working without marking a same-title project", () => { + const { registry, projects, configAtom } = makeHarness(false); + try { + const initial = registry.get(projects.projectsAtom); + registry.set(configAtom(environmentId), { scratchWorkspaceRoot: v2Project.workspaceRoot }); + expect(registry.get(projects.projectsAtom)).toBe(initial); + expect(initial.every((project) => !project.isScratch)).toBe(true); + } finally { + registry.dispose(); + } + }); +}); diff --git a/packages/client-runtime/src/state/projectEntities.ts b/packages/client-runtime/src/state/projectEntities.ts index cc6faefa3819ba47da227c4176ac81b20a53a745..313f845797e97afbbbdd7b179f040d434205b6b5 100644 --- a/packages/client-runtime/src/state/projectEntities.ts +++ b/packages/client-runtime/src/state/projectEntities.ts @@ -4,11 +4,13 @@ import type { OrchestrationV2ShellSnapshot, ProjectId, ScopedProjectRef, + ServerConfig, } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import type { EnvironmentProject } from "./models.ts"; import { scopeProject } from "./models.ts"; +import { isScratchProject } from "./projects.ts"; import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts"; @@ -20,6 +22,9 @@ export function createEnvironmentProjectAtoms(input: { readonly snapshotAtom: ( environmentId: EnvironmentId, ) => Atom.Atom; + readonly serverConfigValueAtom?: ( + environmentId: EnvironmentId, + ) => Atom.Atom | null>; }) { const environmentProjectsAtom = Atom.family((environmentId: EnvironmentId) => Atom.make( @@ -56,14 +61,30 @@ export function createEnvironmentProjectAtoms(input: { const projectAtomFamily = Atom.family((key: string) => { const ref = parseProjectKey(key); let previousSource: OrchestrationProjectShell | null = null; + let previousIsScratch = false; let previousValue: EnvironmentProject | null = null; return Atom.make((get) => { const source = get(environmentProjectIndexAtom(ref.environmentId)).get(ref.projectId) ?? null; - if (source === previousSource) { + const scratch = + source !== null && + isScratchProject( + source, + input.serverConfigValueAtom + ? get(input.serverConfigValueAtom(ref.environmentId))?.scratchWorkspaceRoot + : null, + ); + if (source === previousSource && scratch === previousIsScratch) { return previousValue; } previousSource = source; - previousValue = source === null ? null : scopeProject(ref.environmentId, source); + previousIsScratch = scratch; + previousValue = + source === null + ? null + : { + ...scopeProject(ref.environmentId, source), + ...(scratch ? { isScratch: true as const } : {}), + }; return previousValue; }).pipe(Atom.withLabel(`environment-project:${key}`)); }); @@ -94,12 +115,24 @@ export function createEnvironmentProjectAtoms(input: { return previousProjects; }).pipe(Atom.withLabel("environment-project-list")); + const projectConfigsReadyAtom = Atom.make((get) => { + const catalog = get(input.catalogValueAtom); + if (!catalog.isReady) return false; + for (const environmentId of enabledEnvironmentIds(catalog)) { + if (input.serverConfigValueAtom && get(input.serverConfigValueAtom(environmentId)) === null) { + return false; + } + } + return true; + }).pipe(Atom.withLabel("environment-project-configs-ready")); + return { environmentProjectsAtom, environmentProjectIndexAtom, environmentProjectRefsAtom, projectRefsAtom, projectsAtom, + projectConfigsReadyAtom, projectAtom: (ref: ScopedProjectRef) => projectAtomFamily(projectKey(ref)), }; } diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 958b04073de9f613f2c5c4c048dcb2d8b5b52b32..32861c9ccace822dfffce78068f34d1290688d9b 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -5,7 +5,9 @@ import type { EnvironmentProject } from "./models.ts"; import { chooseLoadBalancedEnvironment } from "../load-balancing.ts"; import { buildProjectGroups, + deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, + resolveScratchProjectScopeKey, type ProjectGroupingSettings, } from "./projectGrouping.ts"; @@ -117,6 +119,131 @@ function settings( } describe("buildProjectGroups", () => { + it.each(["repository", "repository_path", "separate"] as const)( + "keeps scratch project ownership separate by default in %s mode", + (mode) => { + const projects = [ + makeProject("scratch-local", "/local/scratch", { + isScratch: true, + repositoryIdentity: null, + }), + makeProject("scratch-remote", "/remote/scratch", { + environmentId: EnvironmentId.make("remote"), + isScratch: true, + repositoryIdentity: null, + }), + ]; + const groups = buildProjectGroups({ projects, settings: settings(mode) }); + expect(groups.map((group) => group.key)).toEqual(projects.map(derivePhysicalProjectKey)); + expect(groups.map((group) => group.memberProjectRefs)).toEqual( + projects.map((project) => [ + { environmentId: project.environmentId, projectId: project.id }, + ]), + ); + expect( + buildProjectGroups({ projects, settings: settings(mode), groupScratchProjects: true }), + ).toHaveLength(1); + }, + ); + + it.each(["repository", "repository_path", "separate"] as const)( + "migrates a pre-config %s scratch selection and retains it after the original host is removed", + (mode) => { + const local = makeProject("scratch-local", "/local/repo/scratch", { + repositoryIdentity: { ...repositoryIdentity, rootPath: "/local/repo" }, + }); + const remote = makeProject("scratch-remote", "/remote/scratch", { + environmentId: EnvironmentId.make("remote"), + repositoryIdentity: null, + isScratch: true, + }); + const groupingSettings = settings(mode); + const selectedBeforeConfig = deriveLogicalProjectKeyFromSettings(local, groupingSettings); + const loaded = [{ ...local, isScratch: true as const }, remote]; + const migrated = resolveScratchProjectScopeKey( + selectedBeforeConfig, + loaded, + groupingSettings, + ); + const scratchKey = buildProjectGroups({ + projects: loaded, + settings: groupingSettings, + groupScratchProjects: true, + })[0]!.key; + expect(migrated).toBe(scratchKey); + expect(resolveScratchProjectScopeKey(migrated, [remote], groupingSettings)).toBe(scratchKey); + expect( + buildProjectGroups({ + projects: [remote], + settings: groupingSettings, + groupScratchProjects: true, + })[0]?.memberProjectRefs, + ).toEqual([{ environmentId: remote.environmentId, projectId: remote.id }]); + }, + ); + + it("retains a repository filter that still belongs to an ordinary project", () => { + const scratch = makeProject("scratch", "/scratch", { isScratch: true }); + const ordinary = makeProject("ordinary", "/ordinary"); + const key = deriveLogicalProjectKeyFromSettings(ordinary, settings("repository")); + expect(resolveScratchProjectScopeKey(key, [scratch, ordinary])).toBe(key); + }); + + it.each(["repository", "repository_path", "separate"] as const)( + "coalesces scratch homes in %s mode without merging ordinary same-title projects", + (mode) => { + const remoteEnvironmentId = EnvironmentId.make("remote"); + const projects = [ + makeProject("scratch-local", "/local/scratch", { + title: "No project", + isScratch: true, + repositoryIdentity: null, + }), + makeProject("scratch-remote", "/remote/scratch", { + title: "No project", + environmentId: remoteEnvironmentId, + isScratch: true, + }), + makeProject("scratch-duplicate", "/remote/scratch/", { + title: "No project", + environmentId: remoteEnvironmentId, + isScratch: true, + repositoryIdentity: null, + updatedAt: "2026-07-02T00:00:00.000Z", + }), + makeProject("ordinary", "/ordinary", { title: "No project" }), + makeProject("reserved-collision", "/other", { + title: "No project", + repositoryIdentity: { ...repositoryIdentity, canonicalKey: "\0no-project" }, + }), + ]; + const groups = buildProjectGroups({ + projects, + groupScratchProjects: true, + settings: settings(mode, { [derivePhysicalProjectKey(projects[0]!)]: "separate" }), + preferredEnvironmentId: remoteEnvironmentId, + }); + expect(groups).toHaveLength(3); + const scratch = groups.find((group) => group.representative.isScratch)!; + expect(scratch.label).toBe("No project"); + expect(scratch.representative.id).toBe(projects[2]?.id); + expect(scratch.members).toHaveLength(2); + expect(scratch.memberProjectRefs).toEqual( + projects.slice(0, 3).map((project) => ({ + environmentId: project.environmentId, + projectId: project.id, + })), + ); + expect(resolveScratchProjectScopeKey(derivePhysicalProjectKey(projects[1]!), projects)).toBe( + scratch.key, + ); + expect(resolveScratchProjectScopeKey(derivePhysicalProjectKey(projects[3]!), projects)).toBe( + derivePhysicalProjectKey(projects[3]!), + ); + expect(groups.filter((group) => !group.representative.isScratch)).toHaveLength(2); + }, + ); + it("preserves every physical clone as a selectable member in repository modes", () => { const projects = [ makeProject("t3code", "/work/t3code"), diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index b1abd6685262fe43a9fd4336682bb1c3719e9481..7d615de7b126b0eac3206206dbb6dbaf3039c5f0 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -11,6 +11,8 @@ import type { ClientSettings } from "@t3tools/contracts/settings"; import type { EnvironmentProject } from "./models.ts"; import { normalizeProjectPathForComparison } from "./projects.ts"; +const SCRATCH_PROJECT_SCOPE_KEY = "\0no-project"; + export interface ProjectGroupingSettings { readonly sidebarProjectGroupingMode: SidebarProjectGroupingMode; readonly sidebarProjectGroupingOverrides: Record; @@ -105,7 +107,8 @@ function deriveRepositoryScopedKey( const canonicalKey = project.repositoryIdentity ? repositoryGroupingKeyOf(project.repositoryIdentity) : null; - if (!canonicalKey) { + // A NUL reserves the scratch key namespace outside valid repository identities. + if (!canonicalKey || canonicalKey.includes("\0")) { return null; } @@ -126,12 +129,14 @@ function deriveRepositoryScopedKey( export function deriveLogicalProjectKey( project: Pick< EnvironmentProject, - "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" + "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch" >, options?: { readonly groupingMode?: SidebarProjectGroupingMode; + readonly groupScratchProjects?: boolean; }, ): string { + if (options?.groupScratchProjects && project.isScratch) return SCRATCH_PROJECT_SCOPE_KEY; const groupingMode = options?.groupingMode ?? "repository"; if (groupingMode === "separate") { return derivePhysicalProjectKey(project); @@ -147,15 +152,53 @@ export function deriveLogicalProjectKey( export function deriveLogicalProjectKeyFromSettings( project: Pick< EnvironmentProject, - "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" + "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch" >, settings: ProjectGroupingSettings, + options?: { readonly groupScratchProjects?: boolean }, ): string { return deriveLogicalProjectKey(project, { groupingMode: resolveProjectGroupingMode(project, settings), + groupScratchProjects: options?.groupScratchProjects ?? false, }); } +/** Resolves a scratch filter selected before its environment config identified the project. */ +export function resolveScratchProjectScopeKey( + key: string | null, + projects: ReadonlyArray< + Pick< + EnvironmentProject, + "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch" + > + >, + settings: ProjectGroupingSettings = { + sidebarProjectGroupingMode: "repository", + sidebarProjectGroupingOverrides: {}, + }, +): string | null { + if (key === null || key === SCRATCH_PROJECT_SCOPE_KEY) return key; + if ( + projects.some( + (project) => + deriveLogicalProjectKeyFromSettings(project, settings, { groupScratchProjects: true }) === + key, + ) + ) { + return key; + } + return projects.some((project) => { + if (!project.isScratch) return false; + const mode = resolveProjectGroupingMode(project, settings); + return ( + derivePhysicalProjectKey(project) === key || + (mode !== "separate" && deriveRepositoryScopedKey(project, mode) === key) + ); + }) + ? SCRATCH_PROJECT_SCOPE_KEY + : key; +} + export function deriveProjectGroupLabel(input: { readonly representative: Pick; readonly members: ReadonlyArray>; @@ -254,6 +297,8 @@ export function buildProjectGroups(input: { readonly projects: ReadonlyArray; readonly settings: ProjectGroupingSettings; readonly preferredEnvironmentId?: EnvironmentId | null; + /** Filter equivalence does not change the default ownership of mutable project settings. */ + readonly groupScratchProjects?: boolean; }): ReadonlyArray> { const projectsByPhysicalKey = new Map(); for (const project of input.projects) { @@ -275,6 +320,7 @@ export function buildProjectGroups(input: { const identitySource = selectProjectIdentitySource(physicalProjects, winner); const logicalKey = deriveLogicalProjectKey(identitySource, { groupingMode: resolveProjectGroupingMode(winner, input.settings), + groupScratchProjects: input.groupScratchProjects ?? false, }); logicalKeyByPhysicalKey.set(physicalProjectKey, logicalKey); const member = { physicalProjectKey, project: winner }; @@ -292,7 +338,9 @@ export function buildProjectGroups(input: { const physicalProjectKey = derivePhysicalProjectKey(project); const logicalKey = logicalKeyByPhysicalKey.get(physicalProjectKey) ?? - deriveLogicalProjectKeyFromSettings(project, input.settings); + deriveLogicalProjectKeyFromSettings(project, input.settings, { + groupScratchProjects: input.groupScratchProjects ?? false, + }); const projectRefKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); if (seenProjectRefs.has(projectRefKey)) continue; seenProjectRefs.add(projectRefKey); @@ -314,12 +362,14 @@ export function buildProjectGroups(input: { return { key, label: - members.length > 1 - ? deriveProjectGroupLabel({ - representative, - members: members.map((member) => member.project), - }) - : representative.title, + input.groupScratchProjects && representative.isScratch + ? "No project" + : members.length > 1 + ? deriveProjectGroupLabel({ + representative, + members: members.map((member) => member.project), + }) + : representative.title, representative, members, memberProjectRefs: projectRefsByLogicalKey.get(key) ?? [], -- 2.54.0