| 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 +6,7 @@ import type { |
| 6 | ProjectId, | 6 | ProjectId, |
| 7 | ThreadId, | 7 | ThreadId, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; | ||
| 9 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 10 | 11 | ||
| 11 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; | 12 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; |
| ... | @@ -71,6 +72,30 @@ export function requireProjectAbsent(input: { | ... | @@ -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 | export function requireThread(input: { | 99 | export function requireThread(input: { |
| 75 | readonly readModel: OrchestrationReadModel; | 100 | readonly readModel: OrchestrationReadModel; |
| 76 | readonly command: OrchestrationCommand; | 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,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 | it.effect("emits user message and turn-start-requested events for thread.turn.start", () => | 208 | it.effect("emits user message and turn-start-requested events for thread.turn.start", () => |
| 98 | Effect.gen(function* () { | 209 | Effect.gen(function* () { |
| 99 | const now = "2026-01-01T00:00:00.000Z"; | 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,6 +12,7 @@ import type * as PlatformError from "effect/PlatformError"; |
| 12 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; | 12 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; |
| 13 | import { | 13 | import { |
| 14 | listThreadsByProjectId, | 14 | listThreadsByProjectId, |
| 15 | requireActiveProjectWorkspaceRootAbsent, | ||
| 15 | requireProject, | 16 | requireProject, |
| 16 | requireProjectAbsent, | 17 | requireProjectAbsent, |
| 17 | requireThread, | 18 | requireThread, |
| ... | @@ -111,6 +112,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -111,6 +112,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 111 | command, | 112 | command, |
| 112 | projectId: command.projectId, | 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 | return { | 122 | return { |
| 116 | ...(yield* withEventBase({ | 123 | ...(yield* withEventBase({ |
| ... | @@ -138,6 +145,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -138,6 +145,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 138 | command, | 145 | command, |
| 139 | projectId: command.projectId, | 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 | const occurredAt = yield* nowIso; | 156 | const occurredAt = yield* nowIso; |
| 142 | return { | 157 | return { |
| 143 | ...(yield* withEventBase({ | 158 | ...(yield* withEventBase({ |
apps/server/src/provider/Layers/CursorAdapter.test.ts+6-6| ... | @@ -974,8 +974,13 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -974,8 +974,13 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 974 | if (String(event.threadId) !== String(threadId)) { | 974 | if (String(event.threadId) !== String(threadId)) { |
| 975 | return; | 975 | return; |
| 976 | } | 976 | } |
| 977 | if (event.type === "request.opened" && !interrupted) { | 977 | if (event.type === "request.opened" && event.requestId && !interrupted) { |
| 978 | interrupted = true; | 978 | interrupted = true; |
| 979 | yield* adapter.respondToRequest( | ||
| 980 | threadId, | ||
| 981 | ApprovalRequestId.make(String(event.requestId)), | ||
| 982 | "cancel", | ||
| 983 | ); | ||
| 979 | yield* adapter.interruptTurn(threadId); | 984 | yield* adapter.interruptTurn(threadId); |
| 980 | return; | 985 | return; |
| 981 | } | 986 | } |
| ... | @@ -1030,15 +1035,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -1030,15 +1035,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 1030 | entry.result.outcome !== null && | 1035 | entry.result.outcome !== null && |
| 1031 | "outcome" in entry.result.outcome && | 1036 | "outcome" in entry.result.outcome && |
| 1032 | entry.result.outcome.outcome === "cancelled"; | 1037 | entry.result.outcome.outcome === "cancelled"; |
| 1033 | const cancelRequests = yield* waitForJsonLogMatch( | ||
| 1034 | requestLogPath, | ||
| 1035 | (entry) => entry.method === "session/cancel", | ||
| 1036 | ); | ||
| 1037 | const approvalResponses = yield* waitForJsonLogMatch( | 1038 | const approvalResponses = yield* waitForJsonLogMatch( |
| 1038 | requestLogPath, | 1039 | requestLogPath, |
| 1039 | isCancelledApprovalResponse, | 1040 | isCancelledApprovalResponse, |
| 1040 | ); | 1041 | ); |
| 1041 | assert.isTrue(cancelRequests.some((entry) => entry.method === "session/cancel")); | ||
| 1042 | assert.isTrue(approvalResponses.some(isCancelledApprovalResponse)); | 1042 | assert.isTrue(approvalResponses.some(isCancelledApprovalResponse)); |
| 1043 | 1043 | ||
| 1044 | yield* adapter.stopSession(threadId); | 1044 | yield* adapter.stopSession(threadId); |
apps/web/src/components/Sidebar.tsx+2-1| ... | @@ -3183,8 +3183,9 @@ export default function Sidebar() { | ... | @@ -3183,8 +3183,9 @@ export default function Sidebar() { |
| 3183 | return buildPhysicalToLogicalProjectKeyMap({ | 3183 | return buildPhysicalToLogicalProjectKeyMap({ |
| 3184 | projects: orderedProjects, | 3184 | projects: orderedProjects, |
| 3185 | settings: projectGroupingSettings, | 3185 | settings: projectGroupingSettings, |
| 3186 | primaryEnvironmentId, | ||
| 3186 | }); | 3187 | }); |
| 3187 | }, [orderedProjects, projectGroupingSettings]); | 3188 | }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]); |
| 3188 | const projectPhysicalKeyByScopedRef = useMemo( | 3189 | const projectPhysicalKeyByScopedRef = useMemo( |
| 3189 | () => | 3190 | () => |
| 3190 | new Map( | 3191 | new Map( |
apps/web/src/environmentGrouping.test.ts+121| ... | @@ -7,6 +7,10 @@ import { | ... | @@ -7,6 +7,10 @@ import { |
| 7 | derivePhysicalProjectKey, | 7 | derivePhysicalProjectKey, |
| 8 | resolveProjectGroupingMode, | 8 | resolveProjectGroupingMode, |
| 9 | } from "./logicalProject"; | 9 | } from "./logicalProject"; |
| 10 | import { | ||
| 11 | buildPhysicalToLogicalProjectKeyMap, | ||
| 12 | buildSidebarProjectSnapshots, | ||
| 13 | } from "./sidebarProjectGrouping"; | ||
| 10 | import type { Project } from "./types"; | 14 | import type { Project } from "./types"; |
| 11 | 15 | ||
| 12 | const primaryEnvironmentId = EnvironmentId.make("env-primary"); | 16 | const primaryEnvironmentId = EnvironmentId.make("env-primary"); |
| ... | @@ -119,4 +123,121 @@ describe("environment grouping", () => { | ... | @@ -119,4 +123,121 @@ describe("environment grouping", () => { |
| 119 | }), | 123 | }), |
| 120 | ).toBe("separate"); | 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,16 +31,77 @@ export interface SidebarProjectSnapshot extends Project { |
| 31 | remoteEnvironmentLabels: readonly string[]; | 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 | export function buildPhysicalToLogicalProjectKeyMap(input: { | 97 | export function buildPhysicalToLogicalProjectKeyMap(input: { |
| 35 | projects: ReadonlyArray<Project>; | 98 | projects: ReadonlyArray<Project>; |
| 36 | settings: ProjectGroupingSettings; | 99 | settings: ProjectGroupingSettings; |
| 100 | primaryEnvironmentId: EnvironmentId | null; | ||
| 37 | }): Map<string, string> { | 101 | }): Map<string, string> { |
| 38 | const mapping = new Map<string, string>(); | 102 | const mapping = new Map<string, string>(); |
| 39 | for (const project of input.projects) { | 103 | for (const [physicalProjectKey, winner] of collectProjectWinnersByPhysicalKey(input)) { |
| 40 | mapping.set( | 104 | mapping.set(physicalProjectKey, winner.logicalKey); |
| 41 | derivePhysicalProjectKey(project), | ||
| 42 | deriveLogicalProjectKeyFromSettings(project, input.settings), | ||
| 43 | ); | ||
| 44 | } | 105 | } |
| 45 | return mapping; | 106 | return mapping; |
| 46 | } | 107 | } |
| ... | @@ -56,17 +117,17 @@ export function buildSidebarProjectSnapshots(input: { | ... | @@ -56,17 +117,17 @@ export function buildSidebarProjectSnapshots(input: { |
| 56 | // legacy behavior. | 117 | // legacy behavior. |
| 57 | isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean; | 118 | isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean; |
| 58 | }): SidebarProjectSnapshot[] { | 119 | }): SidebarProjectSnapshot[] { |
| 120 | const winnersByPhysicalKey = collectProjectWinnersByPhysicalKey(input); | ||
| 59 | const groupedMembers = new Map<string, SidebarProjectGroupMember[]>(); | 121 | const groupedMembers = new Map<string, SidebarProjectGroupMember[]>(); |
| 60 | for (const project of input.projects) { | 122 | for (const { logicalKey, project } of winnersByPhysicalKey.values()) { |
| 61 | const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); | ||
| 62 | const member: SidebarProjectGroupMember = { | 123 | const member: SidebarProjectGroupMember = { |
| 63 | ...project, | 124 | ...project, |
| 64 | physicalProjectKey: derivePhysicalProjectKey(project), | 125 | physicalProjectKey: derivePhysicalProjectKey(project), |
| 65 | environmentLabel: input.resolveEnvironmentLabel(project.environmentId), | 126 | environmentLabel: input.resolveEnvironmentLabel(project.environmentId), |
| 66 | }; | 127 | }; |
| 67 | const existing = groupedMembers.get(logicalKey); | 128 | const existingMembers = groupedMembers.get(logicalKey); |
| 68 | if (existing) { | 129 | if (existingMembers) { |
| 69 | existing.push(member); | 130 | existingMembers.push(member); |
| 70 | } else { | 131 | } else { |
| 71 | groupedMembers.set(logicalKey, [member]); | 132 | groupedMembers.set(logicalKey, [member]); |
| 72 | } | 133 | } |
packages/client-runtime/src/state/projects.ts+5-31| ... | @@ -3,16 +3,16 @@ import { | ... | @@ -3,16 +3,16 @@ import { |
| 3 | isUncPath, | 3 | isUncPath, |
| 4 | isWindowsAbsolutePath, | 4 | isWindowsAbsolutePath, |
| 5 | isWindowsDrivePath, | 5 | isWindowsDrivePath, |
| 6 | normalizeProjectPathForComparison, | ||
| 7 | normalizeProjectPathForDispatch, | ||
| 6 | } from "@t3tools/shared/path"; | 8 | } from "@t3tools/shared/path"; |
| 7 | 9 | ||
| 10 | export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch }; | ||
| 11 | |||
| 8 | const isWindowsPlatform = (platform: string): boolean => { | 12 | const isWindowsPlatform = (platform: string): boolean => { |
| 9 | return /^win(dows)?/i.test(platform); | 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 | function getAbsolutePathKind(value: string): "unix" | "windows" | null { | 16 | function getAbsolutePathKind(value: string): "unix" | "windows" | null { |
| 17 | if (isWindowsDrivePath(value) || isUncPath(value)) { | 17 | if (isWindowsDrivePath(value) || isUncPath(value)) { |
| 18 | return "windows"; | 18 | return "windows"; |
| ... | @@ -23,20 +23,6 @@ function getAbsolutePathKind(value: string): "unix" | "windows" | null { | ... | @@ -23,20 +23,6 @@ function getAbsolutePathKind(value: string): "unix" | "windows" | null { |
| 23 | return null; | 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 | function preferredPathSeparator(value: string): "/" | "\\" { | 26 | function preferredPathSeparator(value: string): "/" | "\\" { |
| 41 | const absolutePathKind = getAbsolutePathKind(value); | 27 | const absolutePathKind = getAbsolutePathKind(value); |
| 42 | if (absolutePathKind === "windows") return "\\"; | 28 | if (absolutePathKind === "windows") return "\\"; |
| ... | @@ -108,10 +94,6 @@ export function isUnsupportedWindowsProjectPath(value: string, platform: string) | ... | @@ -108,10 +94,6 @@ export function isUnsupportedWindowsProjectPath(value: string, platform: string) |
| 108 | return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform); | 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 | export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string { | 97 | export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string { |
| 116 | const trimmedValue = value.trim(); | 98 | const trimmedValue = value.trim(); |
| 117 | if (!isExplicitRelativePath(trimmedValue) || !cwd) { | 99 | if (!isExplicitRelativePath(trimmedValue) || !cwd) { |
| ... | @@ -139,14 +121,6 @@ export function resolveProjectPathForDispatch(value: string, cwd?: string | null | ... | @@ -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 | export function findProjectByPath<T extends { workspaceRoot?: string; cwd?: string }>( | 124 | export function findProjectByPath<T extends { workspaceRoot?: string; cwd?: string }>( |
| 151 | projects: ReadonlyArray<T>, | 125 | projects: ReadonlyArray<T>, |
| 152 | candidatePath: string, | 126 | candidatePath: string, |
| ... | @@ -198,7 +172,7 @@ export function ensureBrowseDirectoryPath(currentPath: string): string { | ... | @@ -198,7 +172,7 @@ export function ensureBrowseDirectoryPath(currentPath: string): string { |
| 198 | } | 172 | } |
| 199 | 173 | ||
| 200 | export function getBrowseParentPath(currentPath: string): string | null { | 174 | export function getBrowseParentPath(currentPath: string): string | null { |
| 201 | const trimmed = trimTrailingPathSeparators(currentPath); | 175 | const trimmed = normalizeProjectPathForDispatch(currentPath); |
| 202 | const absolutePath = splitAbsolutePath(trimmed); | 176 | const absolutePath = splitAbsolutePath(trimmed); |
| 203 | if (absolutePath) { | 177 | if (absolutePath) { |
| 204 | if (absolutePath.segments.length === 0) return null; | 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,3 +20,32 @@ export function isExplicitRelativePath(value: string): boolean { |
| 20 | value.startsWith("..\\") | 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 | } |