authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-16 10:39:13+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-16 10:39:13+02:00
logd114e2772d059c04460a7e0c25e19ac0c2468f14
tree76f90bc43015680e08cc4e2626e47416e9ad421f
parent0418301de79bb7b6f82305236325cb9811615e9e
signature Signed by PGP key B5690EEEBB952194

Prevent duplicate project workspace roots (#3829)

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";
9import { normalizeProjectPathForComparison } from "@t3tools/shared/path";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
1011
11import { OrchestrationCommandInvariantError } from "./Errors.ts";12import { OrchestrationCommandInvariantError } from "./Errors.ts";
...@@ -71,6 +72,30 @@ export function requireProjectAbsent(input: {...@@ -71,6 +72,30 @@ export function requireProjectAbsent(input: {
71 );72 );
72}73}
7374
75export 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
74export function requireThread(input: {99export 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 );
9696
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";
12import { OrchestrationCommandInvariantError } from "./Errors.ts";12import { OrchestrationCommandInvariantError } from "./Errors.ts";
13import {13import {
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 });
114121
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));
10431043
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";
10import {
11 buildPhysicalToLogicalProjectKeyMap,
12 buildSidebarProjectSnapshots,
13} from "./sidebarProjectGrouping";
10import type { Project } from "./types";14import type { Project } from "./types";
1115
12const primaryEnvironmentId = EnvironmentId.make("env-primary");16const 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}
3333
34interface SidebarProjectGroupCandidate {
35 readonly logicalKey: string;
36 readonly project: Project;
37}
38
39function 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
48function 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
70function 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
34export function buildPhysicalToLogicalProjectKeyMap(input: {97export 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";
79
10export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch };
11
8const isWindowsPlatform = (platform: string): boolean => {12const isWindowsPlatform = (platform: string): boolean => {
9 return /^win(dows)?/i.test(platform);13 return /^win(dows)?/i.test(platform);
10};14};
1115
12function isRootPath(value: string): boolean {
13 return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value);
14}
15
16function getAbsolutePathKind(value: string): "unix" | "windows" | null {16function 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}
2525
26function 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
40function preferredPathSeparator(value: string): "/" | "\\" {26function 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}
11096
111export function normalizeProjectPathForDispatch(value: string): string {
112 return trimTrailingPathSeparators(value.trim());
113}
114
115export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string {97export 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}
141123
142export 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
150export function findProjectByPath<T extends { workspaceRoot?: string; cwd?: string }>(124export 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}
199173
200export function getBrowseParentPath(currentPath: string): string | null {174export 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
24function isRootPath(value: string): boolean {
25 return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value);
26}
27
28function 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
41export function normalizeProjectPathForDispatch(value: string): string {
42 return trimTrailingPathSeparators(value.trim());
43}
44
45export 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}