mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Project rename: distinguishable names for duplicate projects (#1003)
* Add project rename so duplicates get distinguishable names
Two checkouts of the same remote collapse into a single project record
(same projectId), which left users unable to tell duplicates apart in
the UI. customName lives alongside the derived displayName as an
override that reconciliation never touches, so renames persist across
git-remote changes. The rename UI lives in project settings.
Closes #987 (rename half).
* Fix COMPAT(projectCustomName) version to v0.1.76
* Update useProjects shape test for new projectCustomName key
* Use sendCorrelatedSessionRequest for renameProject
Match the pattern used by checkoutPrMerge and other newer RPCs
instead of hand-rolling the select callback.
* Use project.rename.{request,response} dotted RPC names
Match the convention from docs/rpc-namespacing.md (added in 75a6f8277).
Schemas, type exports, daemon client, session handler, and tests all
move to the namespaced names. No back-compat shim needed since the RPC
hasn't shipped.
This commit is contained in:
@@ -255,6 +255,7 @@ describe("useProjects", () => {
|
||||
"hostCount",
|
||||
"hosts",
|
||||
"onlineHostCount",
|
||||
"projectCustomName",
|
||||
"projectKey",
|
||||
"projectName",
|
||||
"totalWorkspaceCount",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Image, Pressable, Text, TextInput, View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, ChevronDown, MoreVertical, Plus } from "lucide-react-native";
|
||||
import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native";
|
||||
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
||||
import type {
|
||||
PaseoConfigRaw,
|
||||
@@ -234,7 +234,7 @@ function ProjectSettingsBody({
|
||||
<View style={styles.headerBlock}>
|
||||
<View style={styles.titleRow}>
|
||||
<ProjectTitleIcon host={selectedHost} projectName={project.projectName} />
|
||||
<Text style={styles.projectTitle}>{project.projectName}</Text>
|
||||
<ProjectNameEditor project={project} client={client} />
|
||||
</View>
|
||||
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
|
||||
</View>
|
||||
@@ -771,6 +771,124 @@ function ResolveSpinnerColor(): string {
|
||||
return styles.spinnerColor.color;
|
||||
}
|
||||
|
||||
interface ProjectNameEditorProps {
|
||||
project: ProjectSummary;
|
||||
client: DaemonClient;
|
||||
}
|
||||
|
||||
function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState(project.projectCustomName ?? "");
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: (customName: string | null) => client.renameProject(project.projectKey, customName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
setIsEditing(false);
|
||||
toast.show("Project renamed", { variant: "success" });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error instanceof Error ? error.message : "Couldn't rename project";
|
||||
toast.show(message, { variant: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleStartEdit = useCallback(() => {
|
||||
setValue(project.projectCustomName ?? "");
|
||||
setIsEditing(true);
|
||||
}, [project.projectCustomName]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setValue(project.projectCustomName ?? "");
|
||||
}, [project.projectCustomName]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
const next = trimmed.length === 0 ? null : trimmed;
|
||||
if (next === (project.projectCustomName ?? null)) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
renameMutation.mutate(next);
|
||||
}, [value, project.projectCustomName, renameMutation]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
renameMutation.mutate(null);
|
||||
}, [renameMutation]);
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<View style={styles.nameEditorRow}>
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{project.projectName}
|
||||
</Text>
|
||||
<Pressable
|
||||
testID="project-name-edit-button"
|
||||
accessibilityLabel="Rename project"
|
||||
onPress={handleStartEdit}
|
||||
hitSlop={8}
|
||||
style={styles.nameEditorIconButton}
|
||||
>
|
||||
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
|
||||
</Pressable>
|
||||
{project.projectCustomName ? (
|
||||
<Pressable
|
||||
testID="project-name-reset-button"
|
||||
accessibilityLabel="Reset project name to default"
|
||||
onPress={handleReset}
|
||||
disabled={renameMutation.isPending}
|
||||
hitSlop={8}
|
||||
style={styles.nameEditorResetButton}
|
||||
>
|
||||
<Text style={styles.nameEditorResetText}>Reset</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.nameEditorRow}>
|
||||
<TextInput
|
||||
testID="project-name-input"
|
||||
accessibilityLabel="Project name"
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
placeholder={project.projectName}
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
autoFocus
|
||||
style={styles.nameEditorInput}
|
||||
editable={!renameMutation.isPending}
|
||||
onSubmitEditing={handleSave}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
<Pressable
|
||||
testID="project-name-save-button"
|
||||
accessibilityLabel="Save project name"
|
||||
onPress={handleSave}
|
||||
disabled={renameMutation.isPending}
|
||||
hitSlop={8}
|
||||
style={styles.nameEditorIconButton}
|
||||
>
|
||||
<Check size={ICON_SIZE} color={styles.iconColor.color} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="project-name-cancel-button"
|
||||
accessibilityLabel="Cancel renaming"
|
||||
onPress={handleCancel}
|
||||
disabled={renameMutation.isPending}
|
||||
hitSlop={8}
|
||||
style={styles.nameEditorIconButton}
|
||||
>
|
||||
<X size={ICON_SIZE} color={styles.iconColor.color} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectTitleIcon({ host, projectName }: { host: ProjectHostEntry; projectName: string }) {
|
||||
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
|
||||
const { icon } = useProjectIconQuery({ serverId: host.serverId, cwd: host.repoRoot });
|
||||
@@ -1140,6 +1258,37 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
flexShrink: 1,
|
||||
},
|
||||
nameEditorRow: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
nameEditorIconButton: {
|
||||
padding: theme.spacing[1],
|
||||
},
|
||||
nameEditorInput: {
|
||||
flex: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
minWidth: 0,
|
||||
},
|
||||
nameEditorResetButton: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
nameEditorResetText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
titleIcon: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
|
||||
@@ -113,6 +113,7 @@ export interface WorkspaceDescriptor {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
projectCustomName?: string | null;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
||||
@@ -134,6 +135,7 @@ export function normalizeWorkspaceDescriptor(
|
||||
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
|
||||
projectId: payload.projectId,
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectCustomName: payload.projectCustomName ?? null,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
workspaceDirectory: payload.workspaceDirectory,
|
||||
projectKind: payload.projectKind,
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ProjectHostEntry {
|
||||
export interface ProjectSummary {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
projectCustomName?: string | null;
|
||||
hosts: ProjectHostEntry[];
|
||||
totalWorkspaceCount: number;
|
||||
hostCount: number;
|
||||
@@ -56,6 +57,7 @@ interface HostGroup {
|
||||
interface ProjectGroup {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
projectCustomName: string | null;
|
||||
hostsByServerId: Map<string, HostGroup>;
|
||||
}
|
||||
|
||||
@@ -119,6 +121,7 @@ function toProjectSummary(draft: ProjectGroup): ProjectSummary {
|
||||
return {
|
||||
projectKey: draft.projectKey,
|
||||
projectName: draft.projectName,
|
||||
projectCustomName: draft.projectCustomName,
|
||||
hosts,
|
||||
totalWorkspaceCount,
|
||||
hostCount: hosts.length,
|
||||
@@ -139,9 +142,13 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
|
||||
group = {
|
||||
projectKey,
|
||||
projectName: workspace.projectDisplayName,
|
||||
projectCustomName: workspace.projectCustomName ?? null,
|
||||
hostsByServerId: new Map(),
|
||||
};
|
||||
groups.set(projectKey, group);
|
||||
} else if (workspace.projectCustomName && !group.projectCustomName) {
|
||||
group.projectCustomName = workspace.projectCustomName;
|
||||
group.projectName = workspace.projectDisplayName;
|
||||
}
|
||||
|
||||
let hostGroup = group.hostsByServerId.get(host.serverId);
|
||||
|
||||
@@ -1938,6 +1938,27 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
async renameProject(
|
||||
projectId: string,
|
||||
customName: string | null,
|
||||
requestId?: string,
|
||||
): Promise<{ customName: string | null }> {
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "project.rename.request",
|
||||
projectId,
|
||||
customName,
|
||||
},
|
||||
responseType: "project.rename.response",
|
||||
timeout: 10000,
|
||||
});
|
||||
if (!payload.accepted) {
|
||||
throw new Error(payload.error ?? "renameProject rejected");
|
||||
}
|
||||
return { customName: payload.customName };
|
||||
}
|
||||
|
||||
async resumeAgent(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
|
||||
@@ -153,6 +153,7 @@ import {
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
resolveProjectDisplayName,
|
||||
type PersistedProjectRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
type ProjectRegistry,
|
||||
@@ -1576,7 +1577,7 @@ export class Session {
|
||||
const checkout = buildWorkspaceCheckout(workspace, project);
|
||||
return {
|
||||
projectKey: project.projectId,
|
||||
projectName: project.displayName,
|
||||
projectName: resolveProjectDisplayName(project),
|
||||
checkout,
|
||||
};
|
||||
}
|
||||
@@ -1806,6 +1807,8 @@ export class Session {
|
||||
return this.handleCloseItemsRequest(msg);
|
||||
case "update_agent_request":
|
||||
return this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId);
|
||||
case "project.rename.request":
|
||||
return this.handleProjectRenameRequest(msg.projectId, msg.customName, msg.requestId);
|
||||
case "send_agent_message_request":
|
||||
return this.handleSendAgentMessageRequest(msg);
|
||||
case "wait_for_finish_request":
|
||||
@@ -2495,6 +2498,90 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleProjectRenameRequest(
|
||||
projectId: string,
|
||||
customName: string | null,
|
||||
requestId: string,
|
||||
): Promise<void> {
|
||||
this.sessionLogger.info(
|
||||
{ projectId, requestId, hasCustomName: typeof customName === "string" },
|
||||
"session: project.rename.request",
|
||||
);
|
||||
|
||||
try {
|
||||
const existing = await this.projectRegistry.get(projectId);
|
||||
if (!existing) {
|
||||
this.emit({
|
||||
type: "project.rename.response",
|
||||
payload: {
|
||||
requestId,
|
||||
projectId,
|
||||
accepted: false,
|
||||
customName: null,
|
||||
error: "Project not found",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = customName?.trim() ?? "";
|
||||
const nextCustomName = trimmed.length === 0 ? null : trimmed;
|
||||
|
||||
await this.projectRegistry.upsert({
|
||||
...existing,
|
||||
customName: nextCustomName,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
this.emit({
|
||||
type: "project.rename.response",
|
||||
payload: {
|
||||
requestId,
|
||||
projectId,
|
||||
accepted: true,
|
||||
customName: nextCustomName,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-emit descriptors for every workspace under this project so the new
|
||||
// resolved name lands in the UI immediately.
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
const affectedWorkspaceIds = workspaces
|
||||
.filter((workspace) => workspace.projectId === projectId)
|
||||
.map((workspace) => workspace.workspaceId);
|
||||
if (affectedWorkspaceIds.length > 0) {
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds, {
|
||||
skipReconcile: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, projectId, requestId },
|
||||
"session: project.rename.request error",
|
||||
);
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: `Failed to rename project: ${getErrorMessage(error)}`,
|
||||
},
|
||||
});
|
||||
this.emit({
|
||||
type: "project.rename.response",
|
||||
payload: {
|
||||
requestId,
|
||||
projectId,
|
||||
accepted: false,
|
||||
customName: null,
|
||||
error: getErrorMessageOr(error, "Failed to rename project"),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private toVoiceFeatureUnavailableContext(
|
||||
state: SpeechReadinessState,
|
||||
): VoiceFeatureUnavailableContext {
|
||||
@@ -6010,7 +6097,10 @@ export class Session {
|
||||
return {
|
||||
id: workspace.workspaceId,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: resolvedProjectRecord?.displayName ?? workspace.projectId,
|
||||
projectDisplayName: resolvedProjectRecord
|
||||
? resolveProjectDisplayName(resolvedProjectRecord)
|
||||
: workspace.projectId,
|
||||
projectCustomName: resolvedProjectRecord?.customName ?? null,
|
||||
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
|
||||
workspaceDirectory: workspace.cwd,
|
||||
projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git",
|
||||
@@ -6098,7 +6188,10 @@ export class Session {
|
||||
return {
|
||||
id: result.workspace.workspaceId,
|
||||
projectId: result.workspace.projectId,
|
||||
projectDisplayName: projectRecord?.displayName ?? result.workspace.projectId,
|
||||
projectDisplayName: projectRecord
|
||||
? resolveProjectDisplayName(projectRecord)
|
||||
: result.workspace.projectId,
|
||||
projectCustomName: projectRecord?.customName ?? null,
|
||||
projectRootPath: projectRecord?.rootPath ?? result.repoRoot,
|
||||
workspaceDirectory: result.workspace.cwd,
|
||||
projectKind: "git",
|
||||
|
||||
@@ -4395,6 +4395,145 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho
|
||||
);
|
||||
});
|
||||
|
||||
test("project.rename.request stores customName and emits an updated workspace descriptor", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
||||
);
|
||||
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-1",
|
||||
projectId: project.projectId,
|
||||
cwd: REPO_CWD,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
|
||||
const projects = new Map([[project.projectId, project]]);
|
||||
session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
|
||||
session.projectRegistry.list = async () => Array.from(projects.values());
|
||||
session.projectRegistry.upsert = async (record: unknown) => {
|
||||
const parsed = record as typeof project;
|
||||
projects.set(parsed.projectId, parsed);
|
||||
};
|
||||
session.workspaceRegistry.list = async () => [workspace];
|
||||
session.workspaceRegistry.get = async (id: string) =>
|
||||
id === workspace.workspaceId ? workspace : null;
|
||||
|
||||
session.workspaceUpdatesSubscription = {
|
||||
subscriptionId: "sub-workspaces",
|
||||
filter: {},
|
||||
isBootstrapping: false,
|
||||
lastEmittedByWorkspaceId: new Map(),
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
};
|
||||
|
||||
await session.handleMessage({
|
||||
type: "project.rename.request",
|
||||
projectId: project.projectId,
|
||||
customName: " My Fork ",
|
||||
requestId: "req-rename-1",
|
||||
});
|
||||
|
||||
const response = findByType(emitted, "project.rename.response");
|
||||
expect(response?.payload).toEqual({
|
||||
requestId: "req-rename-1",
|
||||
projectId: project.projectId,
|
||||
accepted: true,
|
||||
customName: "My Fork",
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(projects.get(project.projectId)?.customName).toBe("My Fork");
|
||||
|
||||
const update = findByType(emitted, "workspace_update");
|
||||
expect(update?.payload).toMatchObject({
|
||||
kind: "upsert",
|
||||
workspace: {
|
||||
id: "ws-1",
|
||||
projectDisplayName: "My Fork",
|
||||
projectCustomName: "My Fork",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("project.rename.request with whitespace-only customName clears the override", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
||||
);
|
||||
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
rootPath: REPO_CWD,
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
customName: "My Fork",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
|
||||
const projects = new Map([[project.projectId, project]]);
|
||||
session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
|
||||
session.projectRegistry.list = async () => Array.from(projects.values());
|
||||
session.projectRegistry.upsert = async (record: unknown) => {
|
||||
const parsed = record as typeof project;
|
||||
projects.set(parsed.projectId, parsed);
|
||||
};
|
||||
session.workspaceRegistry.list = async () => [];
|
||||
|
||||
await session.handleMessage({
|
||||
type: "project.rename.request",
|
||||
projectId: project.projectId,
|
||||
customName: " ",
|
||||
requestId: "req-rename-clear",
|
||||
});
|
||||
|
||||
const response = findByType(emitted, "project.rename.response");
|
||||
expect(response?.payload).toEqual({
|
||||
requestId: "req-rename-clear",
|
||||
projectId: project.projectId,
|
||||
accepted: true,
|
||||
customName: null,
|
||||
error: null,
|
||||
});
|
||||
expect(projects.get(project.projectId)?.customName).toBeNull();
|
||||
});
|
||||
|
||||
test("project.rename.request returns accepted=false when project is not found", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = asTestSession(
|
||||
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
|
||||
);
|
||||
session.projectRegistry.get = async () => null;
|
||||
|
||||
await session.handleMessage({
|
||||
type: "project.rename.request",
|
||||
projectId: "does-not-exist",
|
||||
customName: "X",
|
||||
requestId: "req-rename-missing",
|
||||
});
|
||||
|
||||
const response = findByType(emitted, "project.rename.response");
|
||||
expect(response?.payload).toMatchObject({
|
||||
requestId: "req-rename-missing",
|
||||
projectId: "does-not-exist",
|
||||
accepted: false,
|
||||
customName: null,
|
||||
});
|
||||
expect(response?.payload.error).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a prefix", () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const home = homedir();
|
||||
|
||||
@@ -393,6 +393,62 @@ describe("WorkspaceReconciliationService", () => {
|
||||
expect(projects.get("p1")!.displayName).toBe("new-owner/new-repo");
|
||||
});
|
||||
|
||||
test("preserves customName even when the derived displayName changes", async () => {
|
||||
const dir = createTempGitRepo("reconcile-customname-");
|
||||
tempDirs.push(dir);
|
||||
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: dir,
|
||||
kind: "git",
|
||||
displayName: "old-owner/old-repo",
|
||||
customName: "My Fork",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
"w1",
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: dir,
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
execFileSync("git", ["remote", "add", "origin", "git@github.com:new-owner/new-repo.git"], {
|
||||
cwd: dir,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
workspaceGitService: createWorkspaceGitServiceStub({
|
||||
[dir]: {
|
||||
projectKind: "git",
|
||||
projectDisplayName: "new-owner/new-repo",
|
||||
workspaceDisplayName: "main",
|
||||
gitRemote: "git@github.com:new-owner/new-repo.git",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await service.runOnce();
|
||||
|
||||
expect(projects.get("p1")!.displayName).toBe("new-owner/new-repo");
|
||||
expect(projects.get("p1")!.customName).toBe("My Fork");
|
||||
});
|
||||
|
||||
test("updates workspace display name when branch changes", async () => {
|
||||
const dir = createTempGitRepo("reconcile-branch-");
|
||||
tempDirs.push(dir);
|
||||
|
||||
@@ -68,6 +68,83 @@ describe("workspace registries", () => {
|
||||
expect(await projectRegistry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("PIN: two checkouts of the same git remote collapse into a single project record", async () => {
|
||||
// Reproduces the situation in #987: two directories that share a git remote
|
||||
// both derive the same projectKey/displayName. Because the registry is keyed
|
||||
// by projectId, the second upsert overwrites the first — so the registry can
|
||||
// only ever hold one record per remote, and there is no way to distinguish
|
||||
// the two checkouts in the UI.
|
||||
await projectRegistry.initialize();
|
||||
|
||||
const remoteKey = "remote:github.com/acme/repo";
|
||||
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: remoteKey,
|
||||
rootPath: "/home/me/work/repo",
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: remoteKey,
|
||||
rootPath: "/home/me/scratch/repo",
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const all = await projectRegistry.list();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]?.displayName).toBe("acme/repo");
|
||||
// Second upsert wins — the first rootPath is lost.
|
||||
expect(all[0]?.rootPath).toBe("/home/me/scratch/repo");
|
||||
});
|
||||
|
||||
test("project record schema accepts records without customName (legacy on-disk records)", async () => {
|
||||
await projectRegistry.initialize();
|
||||
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
rootPath: "/tmp/repo",
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const record = await projectRegistry.get("remote:github.com/acme/repo");
|
||||
expect(record?.customName).toBeNull();
|
||||
});
|
||||
|
||||
test("project record persists a customName override", async () => {
|
||||
await projectRegistry.initialize();
|
||||
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
rootPath: "/home/me/work/repo",
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
customName: "Acme (work)",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const record = await projectRegistry.get("remote:github.com/acme/repo");
|
||||
expect(record?.customName).toBe("Acme (work)");
|
||||
expect(record?.displayName).toBe("acme/repo");
|
||||
});
|
||||
|
||||
test("creates, updates, archives, deletes, and lists workspace records", async () => {
|
||||
await workspaceRegistry.initialize();
|
||||
await workspaceRegistry.upsert(
|
||||
|
||||
@@ -12,6 +12,13 @@ const PersistedProjectRecordSchema = z.object({
|
||||
rootPath: z.string(),
|
||||
kind: z.enum(["git", "non_git"]),
|
||||
displayName: z.string(),
|
||||
// User-set override layered over the derived displayName. Reconciliation
|
||||
// never touches this. Null means "use the derived name". Added for #987.
|
||||
customName: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
archivedAt: z.string().nullable(),
|
||||
@@ -56,7 +63,7 @@ type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord;
|
||||
class FileBackedRegistry<TRecord extends RegistryRecord> {
|
||||
private readonly filePath: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly schema: z.ZodSchema<TRecord>;
|
||||
private readonly schema: z.ZodType<TRecord, z.ZodTypeDef, unknown>;
|
||||
private readonly getId: (record: TRecord) => string;
|
||||
private loaded = false;
|
||||
private readonly cache = new Map<string, TRecord>();
|
||||
@@ -65,7 +72,7 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
|
||||
constructor(options: {
|
||||
filePath: string;
|
||||
logger: Logger;
|
||||
schema: z.ZodSchema<TRecord>;
|
||||
schema: z.ZodType<TRecord, z.ZodTypeDef, unknown>;
|
||||
getId: (record: TRecord) => string;
|
||||
component: string;
|
||||
}) {
|
||||
@@ -202,16 +209,22 @@ export function createPersistedProjectRecord(input: {
|
||||
rootPath: string;
|
||||
kind: PersistedProjectKind;
|
||||
displayName: string;
|
||||
customName?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt?: string | null;
|
||||
}): PersistedProjectRecord {
|
||||
return PersistedProjectRecordSchema.parse({
|
||||
...input,
|
||||
customName: input.customName ?? null,
|
||||
archivedAt: input.archivedAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveProjectDisplayName(record: PersistedProjectRecord): string {
|
||||
return record.customName ?? record.displayName;
|
||||
}
|
||||
|
||||
export function createPersistedWorkspaceRecord(input: {
|
||||
workspaceId: string;
|
||||
projectId: string;
|
||||
|
||||
@@ -741,6 +741,14 @@ export const UpdateAgentRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectRenameRequestSchema = z.object({
|
||||
type: z.literal("project.rename.request"),
|
||||
projectId: z.string(),
|
||||
// Null or empty string clears the override and reverts to the derived name.
|
||||
customName: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const SetVoiceModeMessageSchema = z.object({
|
||||
type: z.literal("set_voice_mode"),
|
||||
enabled: z.boolean(),
|
||||
@@ -1203,6 +1211,19 @@ export const UpdateAgentResponseMessageSchema = z.object({
|
||||
payload: AgentActionResponsePayloadSchema,
|
||||
});
|
||||
|
||||
export const ProjectRenameResponsePayloadSchema = z.object({
|
||||
requestId: z.string(),
|
||||
projectId: z.string(),
|
||||
accepted: z.boolean(),
|
||||
customName: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const ProjectRenameResponseSchema = z.object({
|
||||
type: z.literal("project.rename.response"),
|
||||
payload: ProjectRenameResponsePayloadSchema,
|
||||
});
|
||||
|
||||
export const SetVoiceModeResponseMessageSchema = z.object({
|
||||
type: z.literal("set_voice_mode_response"),
|
||||
payload: z.object({
|
||||
@@ -1730,6 +1751,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ArchiveAgentRequestMessageSchema,
|
||||
CloseItemsRequestMessageSchema,
|
||||
UpdateAgentRequestMessageSchema,
|
||||
ProjectRenameRequestSchema,
|
||||
SetVoiceModeMessageSchema,
|
||||
SendAgentMessageRequestSchema,
|
||||
WaitForFinishRequestSchema,
|
||||
@@ -2234,6 +2256,11 @@ export const WorkspaceDescriptorPayloadSchema = z
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
projectDisplayName: z.string(),
|
||||
// COMPAT(projectCustomName): added in v0.1.76, drop the optional gate when floor >= v0.1.76.
|
||||
// When the user has renamed a project, projectDisplayName carries the resolved
|
||||
// value (customName) and projectCustomName mirrors the raw override so the
|
||||
// settings UI can prefill its input and offer a "reset" action.
|
||||
projectCustomName: z.string().nullable().optional(),
|
||||
projectRootPath: z.string(),
|
||||
workspaceDirectory: z.string().optional(),
|
||||
projectKind: z.enum(["git", "non_git", "directory"]),
|
||||
@@ -3454,6 +3481,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SetAgentThinkingResponseMessageSchema,
|
||||
SetAgentFeatureResponseMessageSchema,
|
||||
UpdateAgentResponseMessageSchema,
|
||||
ProjectRenameResponseSchema,
|
||||
WaitForFinishResponseMessageSchema,
|
||||
AgentPermissionRequestMessageSchema,
|
||||
AgentPermissionResolvedMessageSchema,
|
||||
@@ -3590,6 +3618,8 @@ export type SetAgentModelResponseMessage = z.infer<typeof SetAgentModelResponseM
|
||||
export type SetAgentThinkingResponseMessage = z.infer<typeof SetAgentThinkingResponseMessageSchema>;
|
||||
export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureResponseMessageSchema>;
|
||||
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
|
||||
export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>;
|
||||
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
|
||||
export type WaitForFinishResponseMessage = z.infer<typeof WaitForFinishResponseMessageSchema>;
|
||||
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
|
||||
export type AgentPermissionResolvedMessage = z.infer<typeof AgentPermissionResolvedMessageSchema>;
|
||||
@@ -3701,6 +3731,7 @@ export type LoopStopRequest = z.infer<typeof LoopStopRequestSchema>;
|
||||
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
|
||||
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
|
||||
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>;
|
||||
export type ProjectRenameRequest = z.infer<typeof ProjectRenameRequestSchema>;
|
||||
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
|
||||
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
|
||||
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
|
||||
|
||||
Reference in New Issue
Block a user