refactor: transition to opaque workspace identifiers

This commit is contained in:
Mohamed Boudra
2026-04-15 17:59:09 +07:00
parent beeb315c8d
commit e6044264e2
34 changed files with 462 additions and 382 deletions
@@ -1280,7 +1280,7 @@ function WorkspaceRowWithMenu({
void (async () => {
try {
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
const payload = await client.archiveWorkspace(workspace.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
@@ -1438,7 +1438,7 @@ function NonGitProjectRowWithMenuContent({
void (async () => {
try {
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
const payload = await client.archiveWorkspace(workspace.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
@@ -1792,7 +1792,7 @@ function ProjectBlock({
void Promise.allSettled(
project.workspaces.map(async (ws) => {
const payload = await client.archiveWorkspace(Number(ws.workspaceId));
const payload = await client.archiveWorkspace(ws.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
@@ -5,6 +5,7 @@ import { patchWorkspaceScripts } from "./session-workspace-scripts";
function workspace(input: {
id: string;
workspaceDirectory?: string;
scripts?: WorkspaceDescriptor["scripts"];
}): WorkspaceDescriptor {
return {
@@ -12,7 +13,7 @@ function workspace(input: {
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: input.id,
workspaceDirectory: input.workspaceDirectory ?? "/repo/main",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
@@ -35,58 +36,54 @@ const runningScript: WorkspaceScriptPayload = {
describe("patchWorkspaceScripts", () => {
it("patches only the matching workspace scripts", () => {
const other = workspace({ id: "/repo/other", scripts: [] });
const other = workspace({ id: "ws-other", workspaceDirectory: "/repo/other", scripts: [] });
const current = new Map<string, WorkspaceDescriptor>([
["/repo/main", workspace({ id: "/repo/main", scripts: [] })],
["ws-main", workspace({ id: "ws-main", workspaceDirectory: "/repo/main", scripts: [] })],
[other.id, other],
]);
const next = patchWorkspaceScripts(current, {
workspaceId: "/repo/main",
workspaceId: "ws-main",
scripts: [runningScript],
});
expect(next).not.toBe(current);
expect(next.get("/repo/main")?.scripts).toEqual([runningScript]);
expect(next.get("/repo/other")).toBe(other);
expect(next.get("ws-main")?.scripts).toEqual([runningScript]);
expect(next.get("ws-other")).toBe(other);
});
it("patches the matching workspace when the update uses workspace directory identity", () => {
it("patches the matching workspace when the map key differs from the workspace id", () => {
const current = new Map<string, WorkspaceDescriptor>([
[
"42",
"workspace-record-42",
workspace({
id: "42",
id: "ws-main",
workspaceDirectory: "C:\\repo\\main\\",
scripts: [],
}),
],
]);
current.set("42", {
...current.get("42")!,
workspaceDirectory: "C:\\repo\\main\\",
});
const next = patchWorkspaceScripts(current, {
workspaceId: "C:/repo/main",
workspaceId: "ws-main",
scripts: [runningScript],
});
expect(next).not.toBe(current);
expect(next.get("42")?.scripts).toEqual([runningScript]);
expect(next.get("workspace-record-42")?.scripts).toEqual([runningScript]);
});
it("ignores updates for unknown workspaces", () => {
const current = new Map<string, WorkspaceDescriptor>([
["/repo/main", workspace({ id: "/repo/main", scripts: [] })],
["ws-main", workspace({ id: "ws-main", workspaceDirectory: "/repo/main", scripts: [] })],
]);
const next = patchWorkspaceScripts(current, {
workspaceId: "/repo/missing",
workspaceId: "ws-missing",
scripts: [runningScript],
});
expect(next).toBe(current);
expect(next.get("/repo/main")?.scripts).toEqual([]);
expect(next.get("ws-main")?.scripts).toEqual([]);
});
});
@@ -8,7 +8,7 @@ export function patchWorkspaceScripts(
): Map<string, WorkspaceDescriptor> {
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceIdentity: update.workspaceId,
workspaceId: update.workspaceId,
});
if (!workspaceKey) {
return workspaces;
@@ -80,7 +80,6 @@ function compareWorkspaceBaseline(
}
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
numeric: true,
sensitivity: "base",
});
}
@@ -54,15 +54,15 @@ function makeAgent(input: {
describe("workspace agent visibility", () => {
it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => {
const workspaceId = "/repo/worktree";
const workspaceDirectory = "/repo/worktree";
const visible = makeAgent({
id: "visible-agent",
cwd: workspaceId,
cwd: workspaceDirectory,
createdAt: new Date("2026-03-04T00:00:00.000Z"),
});
const archived = makeAgent({
id: "archived-agent",
cwd: workspaceId,
cwd: workspaceDirectory,
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
createdAt: new Date("2026-03-04T00:01:00.000Z"),
});
@@ -79,7 +79,7 @@ describe("workspace agent visibility", () => {
const result = deriveWorkspaceAgentVisibility({
sessionAgents,
workspaceDirectory: workspaceId,
workspaceDirectory,
});
expect(result.activeAgentIds).toEqual(new Set(["visible-agent"]));
@@ -1,8 +1,8 @@
import type { Agent } from "@/stores/session-store";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
function normalizeWorkspaceId(value: string | null | undefined): string {
return normalizeWorkspaceIdentity(value) ?? "";
return normalizeWorkspacePath(value) ?? "";
}
export interface WorkspaceAgentVisibility {
+2 -2
View File
@@ -27,7 +27,7 @@ import type {
AgentSnapshotPayload,
WorkspaceDescriptorPayload,
} from "@server/shared/messages";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import {
createAgentLastActivityCoalescer,
type AgentLastActivityCommitter,
@@ -130,7 +130,7 @@ export function normalizeWorkspaceDescriptor(
payload: WorkspaceDescriptorPayload,
): WorkspaceDescriptor {
return {
id: normalizeWorkspaceIdentity(String(payload.id)) ?? String(payload.id),
id: normalizeWorkspaceOpaqueId(String(payload.id)) ?? String(payload.id),
projectId: String(payload.projectId),
projectDisplayName: payload.projectDisplayName,
projectRootPath: payload.projectRootPath,
@@ -32,7 +32,7 @@ import {
} from "@/stores/workspace-layout-store";
const SERVER_ID = "server-1";
const WORKSPACE_ID = "/repo/worktree";
const WORKSPACE_ID = "ws-main";
function createTab(tabId: string): WorkspaceTab {
return {
@@ -813,7 +813,7 @@ describe("workspace-layout-store actions", () => {
const workspaceKey = createWorkspaceKey();
const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: "/repo/other-worktree",
workspaceId: "ws-other-worktree",
});
expect(otherWorkspaceKey).toBeTruthy();
@@ -849,7 +849,7 @@ describe("workspace-layout-store actions", () => {
const workspaceKey = createWorkspaceKey();
const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: "/repo/other-worktree",
workspaceId: "ws-other-worktree",
});
expect(otherWorkspaceKey).toBeTruthy();
@@ -23,6 +23,17 @@ import {
const SERVER_ID = "server-1";
const WORKSPACE_ID = "/repo/worktree";
describe("buildWorkspaceTabPersistenceKey", () => {
it("preserves opaque workspace ids instead of normalizing them like paths", () => {
expect(
buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: " setup\\workspace\\ ",
}),
).toBe("server-1:setup\\workspace\\");
});
});
describe("workspace-tabs-store retargetTab", () => {
beforeEach(() => {
useWorkspaceTabsStore.setState({
@@ -28,10 +28,6 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
function normalizeWorkspaceId(value: string): string {
return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
}
export function buildWorkspaceTabPersistenceKey(input: {
serverId: string;
workspaceId: string;
@@ -41,7 +37,7 @@ export function buildWorkspaceTabPersistenceKey(input: {
if (!serverId || !workspaceId) {
return null;
}
return `${serverId}:${normalizeWorkspaceId(workspaceId)}`;
return `${serverId}:${workspaceId}`;
}
function normalizeTabOrder(list: unknown): string[] {
+17 -8
View File
@@ -24,13 +24,13 @@ describe("parseHostAgentRouteFromPathname", () => {
});
describe("workspace route parsing", () => {
it("encodes numeric workspace IDs without base64", () => {
it("keeps URL-safe workspace IDs unencoded", () => {
expect(encodeWorkspaceIdForPathSegment("164")).toBe("164");
expect(decodeWorkspaceIdFromPathSegment("164")).toBe("164");
});
it("encodes path-based workspace IDs as base64url (legacy)", () => {
expect(encodeWorkspaceIdForPathSegment("/tmp/repo")).toBe("L3RtcC9yZXBv");
it("encodes non-URL-safe workspace IDs as base64url", () => {
expect(encodeWorkspaceIdForPathSegment("/tmp/repo")).toBe("b64_L3RtcC9yZXBv");
expect(decodeWorkspaceIdFromPathSegment("L3RtcC9yZXBv")).toBe("/tmp/repo");
});
@@ -46,7 +46,7 @@ describe("workspace route parsing", () => {
expect(decodeFilePathFromPathSegment(encoded)).toBe("src/index.ts");
});
it("parses workspace route with numeric ID", () => {
it("parses workspace route with a plain workspace id", () => {
expect(parseHostWorkspaceRouteFromPathname("/h/local/workspace/164")).toEqual({
serverId: "local",
workspaceId: "164",
@@ -66,12 +66,14 @@ describe("workspace route parsing", () => {
).toBeNull();
});
it("builds numeric workspace routes without base64", () => {
it("builds plain workspace routes for URL-safe ids", () => {
expect(buildHostWorkspaceRoute("local", "164")).toBe("/h/local/workspace/164");
});
it("builds base64url workspace routes for legacy paths", () => {
expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe("/h/local/workspace/L3RtcC9yZXBv");
expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe(
"/h/local/workspace/b64_L3RtcC9yZXBv",
);
});
it("builds host root routes", () => {
@@ -115,12 +117,19 @@ describe("workspace route parsing", () => {
);
});
it("round-trips numeric IDs through encode/decode", () => {
const ids = ["1", "40", "164", "9999"];
it("round-trips URL-safe IDs through encode/decode", () => {
const ids = ["1", "40", "164", "9999", "workspace-1", "opaque_id.v2~test"];
for (const id of ids) {
const encoded = encodeWorkspaceIdForPathSegment(id);
const decoded = decodeWorkspaceIdFromPathSegment(encoded);
expect(decoded).toBe(id);
}
});
it("round-trips opaque IDs with reserved characters through base64 encoding", () => {
const id = " team/setup:id#1 ";
const encoded = encodeWorkspaceIdForPathSegment(id);
expect(encoded).toBe("b64_dGVhbS9zZXR1cDppZCMx");
expect(decodeWorkspaceIdFromPathSegment(encoded)).toBe("team/setup:id#1");
});
});
+19 -23
View File
@@ -1,6 +1,7 @@
import { Buffer } from "buffer";
type NullableString = string | null | undefined;
const BASE64_WORKSPACE_ID_PREFIX = "b64_";
function stripSearchAndHash(pathname: string): string {
const hashIndex = pathname.indexOf("#");
@@ -87,12 +88,16 @@ function tryDecodeBase64UrlNoPadUtf8(input: string): string | null {
return decoded;
}
function isPathLikeWorkspaceIdentity(value: string): boolean {
return value.includes("/") || value.includes("\\") || /^[A-Za-z]:[\\/]/.test(value);
function normalizeWorkspaceId(value: string): string {
return value.trim();
}
function normalizeWorkspaceId(value: string): string {
return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
function isUrlSafeWorkspaceId(value: string): boolean {
return /^[A-Za-z0-9._~-]+$/.test(value);
}
function isLegacyPathLikeWorkspaceValue(value: string): boolean {
return value.includes("/") || value.includes("\\") || /^[A-Za-z]:[\\/]/.test(value);
}
export type WorkspaceOpenIntent =
@@ -163,13 +168,11 @@ export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {
if (!normalized) {
return "";
}
// Numeric string IDs are URL-safe and don't need encoding.
// Legacy path-based IDs still get base64-encoded for safety.
const id = normalizeWorkspaceId(normalized);
if (isPathLikeWorkspaceIdentity(id)) {
return toBase64UrlNoPad(id);
if (isUrlSafeWorkspaceId(id)) {
return id;
}
return encodeURIComponent(id);
return `${BASE64_WORKSPACE_ID_PREFIX}${toBase64UrlNoPad(id)}`;
}
export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): string | null {
@@ -178,32 +181,25 @@ export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): st
return null;
}
// Decode %2F etc first (legacy scheme), but keep the raw segment to decide if base64 applies.
const decoded = trimNonEmpty(decodeSegment(normalizedSegment));
if (!decoded) {
return null;
}
// Legacy: if it already looks like a path after decoding, keep it.
if (decoded.includes("/") || decoded.includes("\\")) {
return normalizeWorkspaceId(decoded);
}
// If the segment looks like a plain numeric ID, return it directly.
// Do NOT attempt base64 decode on short alphanumeric strings.
if (/^\d+$/.test(decoded)) {
return decoded;
if (decoded.startsWith(BASE64_WORKSPACE_ID_PREFIX)) {
const encodedPayload = decoded.slice(BASE64_WORKSPACE_ID_PREFIX.length);
const prefixedDecoded =
tryDecodeBase64UrlNoPadUtf8(encodedPayload) ?? decodeBase64UrlNoPadUtf8(encodedPayload);
return prefixedDecoded ? normalizeWorkspaceId(prefixedDecoded) : null;
}
const base64Decoded = tryDecodeBase64UrlNoPadUtf8(decoded);
if (base64Decoded) {
if (base64Decoded && isLegacyPathLikeWorkspaceValue(base64Decoded)) {
return normalizeWorkspaceId(base64Decoded);
}
// Some older links use non-canonical base64url (non-zero pad bits). Accept
// decoded values only when they clearly represent filesystem paths.
const relaxedBase64Decoded = decodeBase64UrlNoPadUtf8(decoded);
if (relaxedBase64Decoded && isPathLikeWorkspaceIdentity(relaxedBase64Decoded)) {
if (relaxedBase64Decoded && isLegacyPathLikeWorkspaceValue(relaxedBase64Decoded)) {
return normalizeWorkspaceId(relaxedBase64Decoded);
}
@@ -50,9 +50,9 @@ describe("buildNotificationRoute", () => {
buildNotificationRoute({
serverId: "srv-1",
agentId: "agent-1",
workspaceId: "/tmp/repo",
workspaceId: "ws-main",
}),
).toBe("/h/srv-1/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1");
).toBe("/h/srv-1/workspace/ws-main?open=agent%3Aagent-1");
});
it("routes directly to server-scoped agent path when both ids are present", () => {
@@ -10,9 +10,10 @@ import type {
function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorkspaceEntry {
return {
workspaceKey: "srv:/repo",
workspaceKey: "srv:ws-root",
serverId: "srv",
workspaceId: "/repo",
workspaceId: "ws-root",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "paseo",
@@ -41,7 +42,7 @@ function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEn
describe("buildSidebarProjectRowModel", () => {
it("flattens non-git projects with one workspace into a direct workspace row model", () => {
const flattenedWorkspace = workspace({
workspaceId: "/repo/non-git",
workspaceId: "ws-non-git",
workspaceKind: "checkout",
statusBucket: "running",
});
@@ -66,7 +67,7 @@ describe("buildSidebarProjectRowModel", () => {
it("marks flattened non-git project rows as selected when their workspace is active", () => {
const flattenedWorkspace = workspace({
serverId: "srv-2",
workspaceId: "/repo/non-git",
workspaceId: "ws-non-git",
});
const result = buildSidebarProjectRowModel({
@@ -78,7 +79,7 @@ describe("buildSidebarProjectRowModel", () => {
serverId: "srv-2",
activeWorkspaceSelection: {
serverId: "srv-2",
workspaceId: "/repo/non-git",
workspaceId: "ws-non-git",
},
});
@@ -90,7 +91,7 @@ describe("buildSidebarProjectRowModel", () => {
it("keeps single-workspace git projects as sections with the new worktree action", () => {
const onlyWorkspace = workspace({
workspaceId: "/repo/main",
workspaceId: "ws-main",
workspaceKind: "checkout",
});
@@ -114,8 +115,8 @@ describe("buildSidebarProjectRowModel", () => {
project: project({
projectKind: "git",
workspaces: [
workspace({ workspaceId: "/repo/main", workspaceKind: "checkout" }),
workspace({ workspaceId: "/repo/feature", workspaceKind: "worktree" }),
workspace({ workspaceId: "ws-main", workspaceKind: "checkout" }),
workspace({ workspaceId: "ws-feature", workspaceKind: "worktree" }),
],
}),
collapsed: true,
@@ -144,8 +145,8 @@ describe("isSidebarProjectFlattened", () => {
isSidebarProjectFlattened(
project({
workspaces: [
workspace({ workspaceId: "/repo/main" }),
workspace({ workspaceId: "/repo/feat" }),
workspace({ workspaceId: "ws-main" }),
workspace({ workspaceId: "ws-feat" }),
],
}),
),
@@ -6,14 +6,20 @@ import type {
import { buildSidebarShortcutModel } from "./sidebar-shortcuts";
function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
function workspace(input: {
serverId: string;
workspaceId: string;
workspaceDirectory: string;
name: string;
}): SidebarWorkspaceEntry {
return {
workspaceKey: `${serverId}:${cwd}`,
serverId,
workspaceId: cwd,
workspaceKey: `${input.serverId}:${input.workspaceId}`,
serverId: input.serverId,
workspaceId: input.workspaceId,
workspaceDirectory: input.workspaceDirectory,
projectKind: "git",
workspaceKind: "checkout",
name: cwd,
name: input.name,
statusBucket: "done",
diffStat: null,
scripts: [],
@@ -26,7 +32,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
projectKey,
projectName: projectKey,
projectKind: "git",
iconWorkingDir: workspaces[0]?.workspaceId ?? "",
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
statusBucket: "done",
activeCount: 0,
totalWorkspaces: workspaces.length,
@@ -37,8 +43,34 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
describe("buildSidebarShortcutModel", () => {
it("builds shortcut targets in visual order and excludes collapsed projects", () => {
const projects = [
project("p1", [workspace("s1", "/repo/main"), workspace("s1", "/repo/feat-a")]),
project("p2", [workspace("s1", "/repo2/main"), workspace("s1", "/repo2/feat-a")]),
project("p1", [
workspace({
serverId: "s1",
workspaceId: "ws-main",
workspaceDirectory: "/repo/main",
name: "main",
}),
workspace({
serverId: "s1",
workspaceId: "ws-feat-a",
workspaceDirectory: "/repo/feat-a",
name: "feat-a",
}),
]),
project("p2", [
workspace({
serverId: "s1",
workspaceId: "ws-repo2-main",
workspaceDirectory: "/repo2/main",
name: "main",
}),
workspace({
serverId: "s1",
workspaceId: "ws-repo2-feat-a",
workspaceDirectory: "/repo2/feat-a",
name: "feat-a",
}),
]),
];
const model = buildSidebarShortcutModel({
@@ -47,21 +79,26 @@ describe("buildSidebarShortcutModel", () => {
});
expect(model.visibleTargets).toEqual([
{ serverId: "s1", workspaceId: "/repo/main" },
{ serverId: "s1", workspaceId: "/repo/feat-a" },
{ serverId: "s1", workspaceId: "ws-main" },
{ serverId: "s1", workspaceId: "ws-feat-a" },
]);
expect(model.shortcutTargets).toEqual([
{ serverId: "s1", workspaceId: "/repo/main" },
{ serverId: "s1", workspaceId: "/repo/feat-a" },
{ serverId: "s1", workspaceId: "ws-main" },
{ serverId: "s1", workspaceId: "ws-feat-a" },
]);
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo/main")).toBe(1);
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo/feat-a")).toBe(2);
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo2/main")).toBeUndefined();
expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-main")).toBe(1);
expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-feat-a")).toBe(2);
expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-repo2-main")).toBeUndefined();
});
it("limits shortcuts to 9", () => {
const workspaces = Array.from({ length: 20 }, (_, index) =>
workspace("s", `/repo/w${index + 1}`),
workspace({
serverId: "s",
workspaceId: `ws-${index + 1}`,
workspaceDirectory: `/repo/w${index + 1}`,
name: `w${index + 1}`,
}),
);
const projects = [project("p", workspaces)];
@@ -71,14 +108,23 @@ describe("buildSidebarShortcutModel", () => {
});
expect(model.visibleTargets).toHaveLength(20);
expect(model.visibleTargets[19]).toEqual({ serverId: "s", workspaceId: "/repo/w20" });
expect(model.visibleTargets[19]).toEqual({ serverId: "s", workspaceId: "ws-20" });
expect(model.shortcutTargets).toHaveLength(9);
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "/repo/w1" });
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "ws-1" });
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" });
});
it("still excludes collapsed single-workspace git projects because they are not flattened", () => {
const projects = [project("p1", [workspace("s1", "/repo/main")])];
const projects = [
project("p1", [
workspace({
serverId: "s1",
workspaceId: "ws-main",
workspaceDirectory: "/repo/main",
name: "main",
}),
]),
];
const model = buildSidebarShortcutModel({
projects,
@@ -27,8 +27,10 @@ function createWorkspace(
}
describe("resolveWorkspaceRouteId", () => {
it("normalizes route workspace ids", () => {
expect(resolveWorkspaceRouteId({ routeWorkspaceId: " /tmp/repo/ " })).toBe("/tmp/repo");
it("trims route workspace ids without path normalization", () => {
expect(resolveWorkspaceRouteId({ routeWorkspaceId: " C:\\tmp\\repo\\ " })).toBe(
"C:\\tmp\\repo\\",
);
});
it("returns null for empty values", () => {
@@ -87,12 +89,12 @@ describe("resolveWorkspaceMapKeyByIdentity", () => {
expect(
resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceIdentity: "workspace-1",
workspaceId: "workspace-1",
}),
).toBe("workspace-1");
});
it("resolves a workspace directory identity to the canonical map key", () => {
it("does not resolve workspace directories when an id is required", () => {
const workspaces = new Map<string, WorkspaceDescriptor>([
[
"workspace-1",
@@ -106,9 +108,9 @@ describe("resolveWorkspaceMapKeyByIdentity", () => {
expect(
resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceIdentity: "C:/repo/feature",
workspaceId: "C:/repo/feature",
}),
).toBe("workspace-1");
).toBeNull();
});
});
+14 -33
View File
@@ -1,5 +1,5 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
export type WorkspaceAuthorityResult = {
workspaceId: string;
@@ -23,20 +23,20 @@ export type WorkspaceExecutionAuthorityResult =
export function resolveWorkspaceRouteId(input: {
routeWorkspaceId: string | null | undefined;
}): string | null {
return normalizeWorkspaceIdentity(input.routeWorkspaceId);
return normalizeWorkspaceOpaqueId(input.routeWorkspaceId);
}
export function resolveWorkspaceIdByExecutionDirectory(input: {
workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
workspaceDirectory: string | null | undefined;
}): string | null {
const normalizedWorkspaceDirectory = normalizeWorkspaceIdentity(input.workspaceDirectory);
const normalizedWorkspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
if (!normalizedWorkspaceDirectory) {
return null;
}
for (const workspace of input.workspaces ?? []) {
if (normalizeWorkspaceIdentity(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) {
if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) {
return workspace.id;
}
}
@@ -46,10 +46,10 @@ export function resolveWorkspaceIdByExecutionDirectory(input: {
export function resolveWorkspaceMapKeyByIdentity(input: {
workspaces: Map<string, WorkspaceDescriptor> | null | undefined;
workspaceIdentity: string | null | undefined;
workspaceId: string | null | undefined;
}): string | null {
const normalizedWorkspaceIdentity = normalizeWorkspaceIdentity(input.workspaceIdentity);
if (!normalizedWorkspaceIdentity) {
const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!normalizedWorkspaceId) {
return null;
}
@@ -58,15 +58,12 @@ export function resolveWorkspaceMapKeyByIdentity(input: {
return null;
}
if (workspaces.has(normalizedWorkspaceIdentity)) {
return normalizedWorkspaceIdentity;
if (workspaces.has(normalizedWorkspaceId)) {
return normalizedWorkspaceId;
}
for (const [workspaceKey, workspace] of workspaces) {
if (
normalizeWorkspaceIdentity(workspace.id) === normalizedWorkspaceIdentity ||
normalizeWorkspaceIdentity(workspace.workspaceDirectory) === normalizedWorkspaceIdentity
) {
if (normalizeWorkspaceOpaqueId(workspace.id) === normalizedWorkspaceId) {
return workspaceKey;
}
}
@@ -90,7 +87,7 @@ export function getWorkspaceExecutionAuthority(
: (() => {
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces: input.workspaces,
workspaceIdentity: input.workspaceId,
workspaceId: input.workspaceId,
});
if (!workspaceKey) {
return null;
@@ -99,7 +96,7 @@ export function getWorkspaceExecutionAuthority(
})();
if ("workspaces" in input) {
const normalizedWorkspaceId = normalizeWorkspaceIdentity(input.workspaceId);
const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!normalizedWorkspaceId) {
return {
ok: false,
@@ -120,7 +117,7 @@ export function getWorkspaceExecutionAuthority(
};
}
const workspaceDirectory = normalizeWorkspaceIdentity(workspace.workspaceDirectory);
const workspaceDirectory = normalizeWorkspacePath(workspace.workspaceDirectory);
if (!workspaceDirectory) {
return {
ok: false,
@@ -156,24 +153,10 @@ export function requireWorkspaceExecutionAuthority(
return result.authority;
}
export function requireWorkspaceRecordId(workspaceId: string): number {
const normalizedWorkspaceId = normalizeWorkspaceIdentity(workspaceId);
if (!normalizedWorkspaceId) {
throw new Error("Workspace ID is required");
}
const parsedWorkspaceId = Number(normalizedWorkspaceId);
if (!Number.isInteger(parsedWorkspaceId)) {
throw new Error(`Workspace ID is not a persisted record ID: ${workspaceId}`);
}
return parsedWorkspaceId;
}
export function resolveWorkspaceExecutionDirectory(input: {
workspaceDirectory: string | null | undefined;
}): string | null {
return normalizeWorkspaceIdentity(input.workspaceDirectory);
return normalizeWorkspacePath(input.workspaceDirectory);
}
export function requireWorkspaceExecutionDirectory(input: {
@@ -206,5 +189,3 @@ export function resolveWorkspaceExecutionAuthority(
const result = getWorkspaceExecutionAuthority(input);
return result.ok ? result.authority : null;
}
export const parseWorkspaceRecordId = requireWorkspaceRecordId;
+5 -1
View File
@@ -6,7 +6,11 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
export function normalizeWorkspaceIdentity(value: string | null | undefined): string | null {
export function normalizeWorkspaceOpaqueId(value: string | null | undefined): string | null {
return trimNonEmpty(value);
}
export function normalizeWorkspacePath(value: string | null | undefined): string | null {
const trimmed = trimNonEmpty(value);
if (!trimmed) {
return null;
@@ -24,7 +24,7 @@ export function normalizeWorkspaceTabTarget(
}
if (value.kind === "setup") {
const workspaceId = trimNonEmpty(value.workspaceId);
return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null;
return workspaceId ? { kind: "setup", workspaceId } : null;
}
return null;
}