| author | |
| committer | |
| log | d114e2772d059c04460a7e0c25e19ac0c2468f14 |
| tree | 76f90bc43015680e08cc4e2626e47416e9ad421f |
| parent | 0418301de79bb7b6f82305236325cb9811615e9e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>9 files changed, 385 insertions(+), 48 deletions(-)
apps/server/src/orchestration/commandInvariants.ts+25| ... | ... | @@ -6,6 +6,7 @@ import type { |
| 6 | 6 | ProjectId, |
| 7 | 7 | ThreadId, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; | |
| 9 | 10 | import * as Effect from "effect/Effect"; |
| 10 | 11 | |
| 11 | 12 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; |
| ... | ... | @@ -71,6 +72,30 @@ export function requireProjectAbsent(input: { |
| 71 | 72 | ); |
| 72 | 73 | } |
| 73 | 74 | |
| 75 | export function requireActiveProjectWorkspaceRootAbsent(input: { | |
| 76 | readonly readModel: OrchestrationReadModel; | |
| 77 | readonly command: OrchestrationCommand; | |
| 78 | readonly workspaceRoot: string; | |
| 79 | readonly exceptProjectId?: ProjectId; | |
| 80 | }): Effect.Effect<void, OrchestrationCommandInvariantError> { | |
| 81 | const normalizedWorkspaceRoot = normalizeProjectPathForComparison(input.workspaceRoot); | |
| 82 | const existingProject = input.readModel.projects.find( | |
| 83 | (project) => | |
| 84 | project.deletedAt === null && | |
| 85 | normalizeProjectPathForComparison(project.workspaceRoot) === normalizedWorkspaceRoot && | |
| 86 | project.id !== input.exceptProjectId, | |
| 87 | ); | |
| 88 | if (existingProject === undefined) { | |
| 89 | return Effect.void; | |
| 90 | } | |
| 91 | return Effect.fail( | |
| 92 | invariantError( | |
| 93 | input.command.type, | |
| 94 | `Active project '${existingProject.id}' already exists for workspace root '${normalizedWorkspaceRoot}'.`, | |
| 95 | ), | |
| 96 | ); | |
| 97 | } | |
| 98 | ||
| 74 | 99 | export function requireThread(input: { |
| 75 | 100 | readonly readModel: OrchestrationReadModel; |
| 76 | 101 | readonly command: OrchestrationCommand; |
apps/server/src/orchestration/decider.projectScripts.test.ts+111| ... | ... | @@ -94,6 +94,117 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { |
| 94 | 94 | }), |
| 95 | 95 | ); |
| 96 | 96 | |
| 97 | it.effect("rejects project.create for an active workspace root that already exists", () => | |
| 98 | Effect.gen(function* () { | |
| 99 | const now = "2026-01-01T00:00:00.000Z"; | |
| 100 | const initial = createEmptyReadModel(now); | |
| 101 | const readModel = yield* projectEvent(initial, { | |
| 102 | sequence: 1, | |
| 103 | eventId: asEventId("evt-project-create"), | |
| 104 | aggregateKind: "project", | |
| 105 | aggregateId: asProjectId("project-existing"), | |
| 106 | type: "project.created", | |
| 107 | occurredAt: now, | |
| 108 | commandId: CommandId.make("cmd-project-create"), | |
| 109 | causationEventId: null, | |
| 110 | correlationId: CommandId.make("cmd-project-create"), | |
| 111 | metadata: {}, | |
| 112 | payload: { | |
| 113 | projectId: asProjectId("project-existing"), | |
| 114 | title: "Project", | |
| 115 | workspaceRoot: "/tmp/project", | |
| 116 | defaultModelSelection: null, | |
| 117 | scripts: [], | |
| 118 | createdAt: now, | |
| 119 | updatedAt: now, | |
| 120 | }, | |
| 121 | }); | |
| 122 | ||
| 123 | const failure = yield* Effect.flip( | |
| 124 | decideOrchestrationCommand({ | |
| 125 | command: { | |
| 126 | type: "project.create", | |
| 127 | commandId: CommandId.make("cmd-project-create-duplicate-root"), | |
| 128 | projectId: asProjectId("project-duplicate-root"), | |
| 129 | title: "Duplicate Project", | |
| 130 | workspaceRoot: "/tmp/project/", | |
| 131 | createdAt: now, | |
| 132 | }, | |
| 133 | readModel, | |
| 134 | }), | |
| 135 | ); | |
| 136 | ||
| 137 | expect(failure.message).toContain( | |
| 138 | "Active project 'project-existing' already exists for workspace root '/tmp/project'.", | |
| 139 | ); | |
| 140 | }), | |
| 141 | ); | |
| 142 | ||
| 143 | it.effect("rejects project.meta.update when moving onto another active workspace root", () => | |
| 144 | Effect.gen(function* () { | |
| 145 | const now = "2026-01-01T00:00:00.000Z"; | |
| 146 | const initial = createEmptyReadModel(now); | |
| 147 | const withFirstProject = yield* projectEvent(initial, { | |
| 148 | sequence: 1, | |
| 149 | eventId: asEventId("evt-project-create-first"), | |
| 150 | aggregateKind: "project", | |
| 151 | aggregateId: asProjectId("project-first"), | |
| 152 | type: "project.created", | |
| 153 | occurredAt: now, | |
| 154 | commandId: CommandId.make("cmd-project-create-first"), | |
| 155 | causationEventId: null, | |
| 156 | correlationId: CommandId.make("cmd-project-create-first"), | |
| 157 | metadata: {}, | |
| 158 | payload: { | |
| 159 | projectId: asProjectId("project-first"), | |
| 160 | title: "First", | |
| 161 | workspaceRoot: "/tmp/project-first", | |
| 162 | defaultModelSelection: null, | |
| 163 | scripts: [], | |
| 164 | createdAt: now, | |
| 165 | updatedAt: now, | |
| 166 | }, | |
| 167 | }); | |
| 168 | const readModel = yield* projectEvent(withFirstProject, { | |
| 169 | sequence: 2, | |
| 170 | eventId: asEventId("evt-project-create-second"), | |
| 171 | aggregateKind: "project", | |
| 172 | aggregateId: asProjectId("project-second"), | |
| 173 | type: "project.created", | |
| 174 | occurredAt: now, | |
| 175 | commandId: CommandId.make("cmd-project-create-second"), | |
| 176 | causationEventId: null, | |
| 177 | correlationId: CommandId.make("cmd-project-create-second"), | |
| 178 | metadata: {}, | |
| 179 | payload: { | |
| 180 | projectId: asProjectId("project-second"), | |
| 181 | title: "Second", | |
| 182 | workspaceRoot: "/tmp/project-second", | |
| 183 | defaultModelSelection: null, | |
| 184 | scripts: [], | |
| 185 | createdAt: now, | |
| 186 | updatedAt: now, | |
| 187 | }, | |
| 188 | }); | |
| 189 | ||
| 190 | const failure = yield* Effect.flip( | |
| 191 | decideOrchestrationCommand({ | |
| 192 | command: { | |
| 193 | type: "project.meta.update", | |
| 194 | commandId: CommandId.make("cmd-project-update-duplicate-root"), | |
| 195 | projectId: asProjectId("project-second"), | |
| 196 | workspaceRoot: "/tmp/project-first", | |
| 197 | }, | |
| 198 | readModel, | |
| 199 | }), | |
| 200 | ); | |
| 201 | ||
| 202 | expect(failure.message).toContain( | |
| 203 | "Active project 'project-first' already exists for workspace root '/tmp/project-first'.", | |
| 204 | ); | |
| 205 | }), | |
| 206 | ); | |
| 207 | ||
| 97 | 208 | it.effect("emits user message and turn-start-requested events for thread.turn.start", () => |
| 98 | 209 | Effect.gen(function* () { |
| 99 | 210 | const now = "2026-01-01T00:00:00.000Z"; |
apps/server/src/orchestration/decider.ts+15| ... | ... | @@ -12,6 +12,7 @@ import type * as PlatformError from "effect/PlatformError"; |
| 12 | 12 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; |
| 13 | 13 | import { |
| 14 | 14 | listThreadsByProjectId, |
| 15 | requireActiveProjectWorkspaceRootAbsent, | |
| 15 | 16 | requireProject, |
| 16 | 17 | requireProjectAbsent, |
| 17 | 18 | requireThread, |
| ... | ... | @@ -111,6 +112,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 111 | 112 | command, |
| 112 | 113 | projectId: command.projectId, |
| 113 | 114 | }); |
| 115 | yield* requireActiveProjectWorkspaceRootAbsent({ | |
| 116 | readModel, | |
| 117 | command, | |
| 118 | workspaceRoot: command.workspaceRoot, | |
| 119 | exceptProjectId: command.projectId, | |
| 120 | }); | |
| 114 | 121 | |
| 115 | 122 | return { |
| 116 | 123 | ...(yield* withEventBase({ |
| ... | ... | @@ -138,6 +145,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 138 | 145 | command, |
| 139 | 146 | projectId: command.projectId, |
| 140 | 147 | }); |
| 148 | if (command.workspaceRoot !== undefined) { | |
| 149 | yield* requireActiveProjectWorkspaceRootAbsent({ | |
| 150 | readModel, | |
| 151 | command, | |
| 152 | workspaceRoot: command.workspaceRoot, | |
| 153 | exceptProjectId: command.projectId, | |
| 154 | }); | |
| 155 | } | |
| 141 | 156 | const occurredAt = yield* nowIso; |
| 142 | 157 | return { |
| 143 | 158 | ...(yield* withEventBase({ |
apps/server/src/provider/Layers/CursorAdapter.test.ts+6-6| ... | ... | @@ -974,8 +974,13 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 974 | 974 | if (String(event.threadId) !== String(threadId)) { |
| 975 | 975 | return; |
| 976 | 976 | } |
| 977 | if (event.type === "request.opened" && !interrupted) { | |
| 977 | if (event.type === "request.opened" && event.requestId && !interrupted) { | |
| 978 | 978 | interrupted = true; |
| 979 | yield* adapter.respondToRequest( | |
| 980 | threadId, | |
| 981 | ApprovalRequestId.make(String(event.requestId)), | |
| 982 | "cancel", | |
| 983 | ); | |
| 979 | 984 | yield* adapter.interruptTurn(threadId); |
| 980 | 985 | return; |
| 981 | 986 | } |
| ... | ... | @@ -1030,15 +1035,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 1030 | 1035 | entry.result.outcome !== null && |
| 1031 | 1036 | "outcome" in entry.result.outcome && |
| 1032 | 1037 | entry.result.outcome.outcome === "cancelled"; |
| 1033 | const cancelRequests = yield* waitForJsonLogMatch( | |
| 1034 | requestLogPath, | |
| 1035 | (entry) => entry.method === "session/cancel", | |
| 1036 | ); | |
| 1037 | 1038 | const approvalResponses = yield* waitForJsonLogMatch( |
| 1038 | 1039 | requestLogPath, |
| 1039 | 1040 | isCancelledApprovalResponse, |
| 1040 | 1041 | ); |
| 1041 | assert.isTrue(cancelRequests.some((entry) => entry.method === "session/cancel")); | |
| 1042 | 1042 | assert.isTrue(approvalResponses.some(isCancelledApprovalResponse)); |
| 1043 | 1043 | |
| 1044 | 1044 | yield* adapter.stopSession(threadId); |
apps/web/src/components/Sidebar.tsx+2-1| ... | ... | @@ -3183,8 +3183,9 @@ export default function Sidebar() { |
| 3183 | 3183 | return buildPhysicalToLogicalProjectKeyMap({ |
| 3184 | 3184 | projects: orderedProjects, |
| 3185 | 3185 | settings: projectGroupingSettings, |
| 3186 | primaryEnvironmentId, | |
| 3186 | 3187 | }); |
| 3187 | }, [orderedProjects, projectGroupingSettings]); | |
| 3188 | }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]); | |
| 3188 | 3189 | const projectPhysicalKeyByScopedRef = useMemo( |
| 3189 | 3190 | () => |
| 3190 | 3191 | new Map( |
apps/web/src/environmentGrouping.test.ts+121| ... | ... | @@ -7,6 +7,10 @@ import { |
| 7 | 7 | derivePhysicalProjectKey, |
| 8 | 8 | resolveProjectGroupingMode, |
| 9 | 9 | } from "./logicalProject"; |
| 10 | import { | |
| 11 | buildPhysicalToLogicalProjectKeyMap, | |
| 12 | buildSidebarProjectSnapshots, | |
| 13 | } from "./sidebarProjectGrouping"; | |
| 10 | 14 | import type { Project } from "./types"; |
| 11 | 15 | |
| 12 | 16 | const primaryEnvironmentId = EnvironmentId.make("env-primary"); |
| ... | ... | @@ -119,4 +123,121 @@ describe("environment grouping", () => { |
| 119 | 123 | }), |
| 120 | 124 | ).toBe("separate"); |
| 121 | 125 | }); |
| 126 | ||
| 127 | it("dedupes stale project rows with the same environment and workspace path", () => { | |
| 128 | const duplicate = makeProject({ | |
| 129 | id: ProjectId.make("project-duplicate"), | |
| 130 | workspaceRoot: "/tmp/shared-repo/", | |
| 131 | repositoryIdentity, | |
| 132 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 133 | }); | |
| 134 | const primary = makeProject({ | |
| 135 | id: ProjectId.make("project-primary"), | |
| 136 | repositoryIdentity, | |
| 137 | updatedAt: "2026-01-02T00:00:00.000Z", | |
| 138 | }); | |
| 139 | const remote = makeProject({ | |
| 140 | id: ProjectId.make("project-remote"), | |
| 141 | environmentId: remoteEnvironmentId, | |
| 142 | workspaceRoot: "/tmp/shared-repo", | |
| 143 | repositoryIdentity, | |
| 144 | }); | |
| 145 | ||
| 146 | const snapshots = buildSidebarProjectSnapshots({ | |
| 147 | projects: [primary, duplicate, remote], | |
| 148 | settings: defaultGroupingSettings, | |
| 149 | primaryEnvironmentId, | |
| 150 | resolveEnvironmentLabel: (environmentId) => | |
| 151 | environmentId === remoteEnvironmentId ? "remote" : "primary", | |
| 152 | }); | |
| 153 | ||
| 154 | expect(snapshots).toHaveLength(1); | |
| 155 | expect(snapshots[0]?.groupedProjectCount).toBe(2); | |
| 156 | expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([ | |
| 157 | primary.id, | |
| 158 | remote.id, | |
| 159 | ]); | |
| 160 | }); | |
| 161 | ||
| 162 | it("prefers the fresher project row when duplicate stale rows are ordered first", () => { | |
| 163 | const staleDuplicate = makeProject({ | |
| 164 | id: ProjectId.make("project-stale"), | |
| 165 | workspaceRoot: "/tmp/shared-repo/", | |
| 166 | repositoryIdentity, | |
| 167 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 168 | }); | |
| 169 | const canonical = makeProject({ | |
| 170 | id: ProjectId.make("project-canonical"), | |
| 171 | workspaceRoot: "/tmp/shared-repo", | |
| 172 | repositoryIdentity, | |
| 173 | updatedAt: "2026-01-02T00:00:00.000Z", | |
| 174 | }); | |
| 175 | ||
| 176 | const snapshots = buildSidebarProjectSnapshots({ | |
| 177 | projects: [staleDuplicate, canonical], | |
| 178 | settings: defaultGroupingSettings, | |
| 179 | primaryEnvironmentId, | |
| 180 | resolveEnvironmentLabel: () => "primary", | |
| 181 | }); | |
| 182 | ||
| 183 | expect(snapshots).toHaveLength(1); | |
| 184 | expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([canonical.id]); | |
| 185 | expect(snapshots[0]?.id).toBe(canonical.id); | |
| 186 | }); | |
| 187 | ||
| 188 | it("dedupes stale project rows before logical grouping", () => { | |
| 189 | const staleWithoutRepositoryIdentity = makeProject({ | |
| 190 | id: ProjectId.make("project-stale"), | |
| 191 | repositoryIdentity: null, | |
| 192 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 193 | }); | |
| 194 | const canonical = makeProject({ | |
| 195 | id: ProjectId.make("project-canonical"), | |
| 196 | repositoryIdentity, | |
| 197 | updatedAt: "2026-01-02T00:00:00.000Z", | |
| 198 | }); | |
| 199 | const remote = makeProject({ | |
| 200 | id: ProjectId.make("project-remote"), | |
| 201 | environmentId: remoteEnvironmentId, | |
| 202 | repositoryIdentity, | |
| 203 | }); | |
| 204 | ||
| 205 | const snapshots = buildSidebarProjectSnapshots({ | |
| 206 | projects: [staleWithoutRepositoryIdentity, canonical, remote], | |
| 207 | settings: defaultGroupingSettings, | |
| 208 | primaryEnvironmentId, | |
| 209 | resolveEnvironmentLabel: (environmentId) => | |
| 210 | environmentId === remoteEnvironmentId ? "remote" : "primary", | |
| 211 | }); | |
| 212 | ||
| 213 | expect(snapshots).toHaveLength(1); | |
| 214 | expect(snapshots[0]?.projectKey).toBe(repositoryIdentity.canonicalKey); | |
| 215 | expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([ | |
| 216 | canonical.id, | |
| 217 | remote.id, | |
| 218 | ]); | |
| 219 | }); | |
| 220 | ||
| 221 | it("routes duplicate physical project keys to the winning logical group", () => { | |
| 222 | const staleWithoutRepositoryIdentity = makeProject({ | |
| 223 | id: ProjectId.make("project-stale"), | |
| 224 | repositoryIdentity: null, | |
| 225 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 226 | }); | |
| 227 | const canonical = makeProject({ | |
| 228 | id: ProjectId.make("project-canonical"), | |
| 229 | repositoryIdentity, | |
| 230 | updatedAt: "2026-01-02T00:00:00.000Z", | |
| 231 | }); | |
| 232 | ||
| 233 | const physicalToLogicalKey = buildPhysicalToLogicalProjectKeyMap({ | |
| 234 | projects: [staleWithoutRepositoryIdentity, canonical], | |
| 235 | settings: defaultGroupingSettings, | |
| 236 | primaryEnvironmentId, | |
| 237 | }); | |
| 238 | ||
| 239 | expect(physicalToLogicalKey.get(derivePhysicalProjectKey(staleWithoutRepositoryIdentity))).toBe( | |
| 240 | repositoryIdentity.canonicalKey, | |
| 241 | ); | |
| 242 | }); | |
| 122 | 243 | }); |
apps/web/src/sidebarProjectGrouping.ts+71-10| ... | ... | @@ -31,16 +31,77 @@ export interface SidebarProjectSnapshot extends Project { |
| 31 | 31 | remoteEnvironmentLabels: readonly string[]; |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | interface SidebarProjectGroupCandidate { | |
| 35 | readonly logicalKey: string; | |
| 36 | readonly project: Project; | |
| 37 | } | |
| 38 | ||
| 39 | function getProjectFreshnessTime(project: Project): number { | |
| 40 | const updatedAtTime = Date.parse(project.updatedAt); | |
| 41 | if (Number.isFinite(updatedAtTime)) { | |
| 42 | return updatedAtTime; | |
| 43 | } | |
| 44 | const createdAtTime = Date.parse(project.createdAt); | |
| 45 | return Number.isFinite(createdAtTime) ? createdAtTime : 0; | |
| 46 | } | |
| 47 | ||
| 48 | function shouldReplaceDuplicateMember(input: { | |
| 49 | existingMember: Project; | |
| 50 | candidateMember: Project; | |
| 51 | primaryEnvironmentId: EnvironmentId | null; | |
| 52 | }): boolean { | |
| 53 | if ( | |
| 54 | input.primaryEnvironmentId !== null && | |
| 55 | input.existingMember.environmentId !== input.primaryEnvironmentId && | |
| 56 | input.candidateMember.environmentId === input.primaryEnvironmentId | |
| 57 | ) { | |
| 58 | return true; | |
| 59 | } | |
| 60 | ||
| 61 | const existingFreshness = getProjectFreshnessTime(input.existingMember); | |
| 62 | const candidateFreshness = getProjectFreshnessTime(input.candidateMember); | |
| 63 | if (candidateFreshness !== existingFreshness) { | |
| 64 | return candidateFreshness > existingFreshness; | |
| 65 | } | |
| 66 | ||
| 67 | return input.candidateMember.id > input.existingMember.id; | |
| 68 | } | |
| 69 | ||
| 70 | function collectProjectWinnersByPhysicalKey(input: { | |
| 71 | projects: ReadonlyArray<Project>; | |
| 72 | settings: ProjectGroupingSettings; | |
| 73 | primaryEnvironmentId: EnvironmentId | null; | |
| 74 | }): Map<string, SidebarProjectGroupCandidate> { | |
| 75 | const winnersByPhysicalKey = new Map<string, SidebarProjectGroupCandidate>(); | |
| 76 | for (const project of input.projects) { | |
| 77 | const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); | |
| 78 | const physicalProjectKey = derivePhysicalProjectKey(project); | |
| 79 | const existing = winnersByPhysicalKey.get(physicalProjectKey); | |
| 80 | if (!existing) { | |
| 81 | winnersByPhysicalKey.set(physicalProjectKey, { logicalKey, project }); | |
| 82 | continue; | |
| 83 | } | |
| 84 | if ( | |
| 85 | shouldReplaceDuplicateMember({ | |
| 86 | existingMember: existing.project, | |
| 87 | candidateMember: project, | |
| 88 | primaryEnvironmentId: input.primaryEnvironmentId, | |
| 89 | }) | |
| 90 | ) { | |
| 91 | winnersByPhysicalKey.set(physicalProjectKey, { logicalKey, project }); | |
| 92 | } | |
| 93 | } | |
| 94 | return winnersByPhysicalKey; | |
| 95 | } | |
| 96 | ||
| 34 | 97 | export function buildPhysicalToLogicalProjectKeyMap(input: { |
| 35 | 98 | projects: ReadonlyArray<Project>; |
| 36 | 99 | settings: ProjectGroupingSettings; |
| 100 | primaryEnvironmentId: EnvironmentId | null; | |
| 37 | 101 | }): Map<string, string> { |
| 38 | 102 | const mapping = new Map<string, string>(); |
| 39 | for (const project of input.projects) { | |
| 40 | mapping.set( | |
| 41 | derivePhysicalProjectKey(project), | |
| 42 | deriveLogicalProjectKeyFromSettings(project, input.settings), | |
| 43 | ); | |
| 103 | for (const [physicalProjectKey, winner] of collectProjectWinnersByPhysicalKey(input)) { | |
| 104 | mapping.set(physicalProjectKey, winner.logicalKey); | |
| 44 | 105 | } |
| 45 | 106 | return mapping; |
| 46 | 107 | } |
| ... | ... | @@ -56,17 +117,17 @@ export function buildSidebarProjectSnapshots(input: { |
| 56 | 117 | // legacy behavior. |
| 57 | 118 | isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean; |
| 58 | 119 | }): SidebarProjectSnapshot[] { |
| 120 | const winnersByPhysicalKey = collectProjectWinnersByPhysicalKey(input); | |
| 59 | 121 | const groupedMembers = new Map<string, SidebarProjectGroupMember[]>(); |
| 60 | for (const project of input.projects) { | |
| 61 | const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); | |
| 122 | for (const { logicalKey, project } of winnersByPhysicalKey.values()) { | |
| 62 | 123 | const member: SidebarProjectGroupMember = { |
| 63 | 124 | ...project, |
| 64 | 125 | physicalProjectKey: derivePhysicalProjectKey(project), |
| 65 | 126 | environmentLabel: input.resolveEnvironmentLabel(project.environmentId), |
| 66 | 127 | }; |
| 67 | const existing = groupedMembers.get(logicalKey); | |
| 68 | if (existing) { | |
| 69 | existing.push(member); | |
| 128 | const existingMembers = groupedMembers.get(logicalKey); | |
| 129 | if (existingMembers) { | |
| 130 | existingMembers.push(member); | |
| 70 | 131 | } else { |
| 71 | 132 | groupedMembers.set(logicalKey, [member]); |
| 72 | 133 | } |
packages/client-runtime/src/state/projects.ts+5-31| ... | ... | @@ -3,16 +3,16 @@ import { |
| 3 | 3 | isUncPath, |
| 4 | 4 | isWindowsAbsolutePath, |
| 5 | 5 | isWindowsDrivePath, |
| 6 | normalizeProjectPathForComparison, | |
| 7 | normalizeProjectPathForDispatch, | |
| 6 | 8 | } from "@t3tools/shared/path"; |
| 7 | 9 | |
| 10 | export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch }; | |
| 11 | ||
| 8 | 12 | const isWindowsPlatform = (platform: string): boolean => { |
| 9 | 13 | return /^win(dows)?/i.test(platform); |
| 10 | 14 | }; |
| 11 | 15 | |
| 12 | function isRootPath(value: string): boolean { | |
| 13 | return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); | |
| 14 | } | |
| 15 | ||
| 16 | 16 | function getAbsolutePathKind(value: string): "unix" | "windows" | null { |
| 17 | 17 | if (isWindowsDrivePath(value) || isUncPath(value)) { |
| 18 | 18 | return "windows"; |
| ... | ... | @@ -23,20 +23,6 @@ function getAbsolutePathKind(value: string): "unix" | "windows" | null { |
| 23 | 23 | return null; |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | function trimTrailingPathSeparators(value: string): string { | |
| 27 | if (value.length === 0 || isRootPath(value)) { | |
| 28 | return value; | |
| 29 | } | |
| 30 | const trimmed = | |
| 31 | getAbsolutePathKind(value) === "unix" | |
| 32 | ? value.replace(/\/+$/g, "") | |
| 33 | : value.replace(/[\\/]+$/g, ""); | |
| 34 | if (trimmed.length === 0) { | |
| 35 | return value; | |
| 36 | } | |
| 37 | return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed; | |
| 38 | } | |
| 39 | ||
| 40 | 26 | function preferredPathSeparator(value: string): "/" | "\\" { |
| 41 | 27 | const absolutePathKind = getAbsolutePathKind(value); |
| 42 | 28 | if (absolutePathKind === "windows") return "\\"; |
| ... | ... | @@ -108,10 +94,6 @@ export function isUnsupportedWindowsProjectPath(value: string, platform: string) |
| 108 | 94 | return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform); |
| 109 | 95 | } |
| 110 | 96 | |
| 111 | export function normalizeProjectPathForDispatch(value: string): string { | |
| 112 | return trimTrailingPathSeparators(value.trim()); | |
| 113 | } | |
| 114 | ||
| 115 | 97 | export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string { |
| 116 | 98 | const trimmedValue = value.trim(); |
| 117 | 99 | if (!isExplicitRelativePath(trimmedValue) || !cwd) { |
| ... | ... | @@ -139,14 +121,6 @@ export function resolveProjectPathForDispatch(value: string, cwd?: string | null |
| 139 | 121 | ); |
| 140 | 122 | } |
| 141 | 123 | |
| 142 | export function normalizeProjectPathForComparison(value: string): string { | |
| 143 | const normalized = normalizeProjectPathForDispatch(value); | |
| 144 | if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) { | |
| 145 | return normalized.replaceAll("/", "\\").toLowerCase(); | |
| 146 | } | |
| 147 | return normalized; | |
| 148 | } | |
| 149 | ||
| 150 | 124 | export function findProjectByPath<T extends { workspaceRoot?: string; cwd?: string }>( |
| 151 | 125 | projects: ReadonlyArray<T>, |
| 152 | 126 | candidatePath: string, |
| ... | ... | @@ -198,7 +172,7 @@ export function ensureBrowseDirectoryPath(currentPath: string): string { |
| 198 | 172 | } |
| 199 | 173 | |
| 200 | 174 | export function getBrowseParentPath(currentPath: string): string | null { |
| 201 | const trimmed = trimTrailingPathSeparators(currentPath); | |
| 175 | const trimmed = normalizeProjectPathForDispatch(currentPath); | |
| 202 | 176 | const absolutePath = splitAbsolutePath(trimmed); |
| 203 | 177 | if (absolutePath) { |
| 204 | 178 | if (absolutePath.segments.length === 0) return null; |
packages/shared/src/path.ts+29| ... | ... | @@ -20,3 +20,32 @@ export function isExplicitRelativePath(value: string): boolean { |
| 20 | 20 | value.startsWith("..\\") |
| 21 | 21 | ); |
| 22 | 22 | } |
| 23 | ||
| 24 | function isRootPath(value: string): boolean { | |
| 25 | return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); | |
| 26 | } | |
| 27 | ||
| 28 | function trimTrailingPathSeparators(value: string): string { | |
| 29 | if (value.length === 0 || isRootPath(value)) { | |
| 30 | return value; | |
| 31 | } | |
| 32 | const trimmed = value.startsWith("/") | |
| 33 | ? value.replace(/\/+$/g, "") | |
| 34 | : value.replace(/[\\/]+$/g, ""); | |
| 35 | if (trimmed.length === 0) { | |
| 36 | return value; | |
| 37 | } | |
| 38 | return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed; | |
| 39 | } | |
| 40 | ||
| 41 | export function normalizeProjectPathForDispatch(value: string): string { | |
| 42 | return trimTrailingPathSeparators(value.trim()); | |
| 43 | } | |
| 44 | ||
| 45 | export function normalizeProjectPathForComparison(value: string): string { | |
| 46 | const normalized = normalizeProjectPathForDispatch(value); | |
| 47 | if (isWindowsDrivePath(normalized) || isUncPath(normalized)) { | |
| 48 | return normalized.replaceAll("/", "\\").toLowerCase(); | |
| 49 | } | |
| 50 | return normalized; | |
| 51 | } |