Restore project grouping across hosts (#2565)
* fix(projects): restore cross-host grouping Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration. * fix(projects): keep ambiguous clones separate A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate. * fix(projects): preserve host-local project identity * fix(projects): strengthen grouping identity * test(app): update project host shape contract * fix(projects): preserve selected host identity * fix(projects): close grouped routing gaps * fix(projects): stabilize legacy subproject grouping * fix(projects): preserve selected project identity * fix(projects): isolate legacy host identity * fix(projects): preserve symlinked bootstrap roots * fix(projects): reconcile grouped custom names * fix(projects): stabilize settings and path identity * fix(projects): normalize default remote ports * fix(projects): harden host and remote identity * fix(projects): preserve host-scoped actions * fix(projects): preserve remote path semantics * fix(projects): model SCP remote semantics * fix(projects): keep settings on routed host * fix(projects): preserve SSH URL path semantics * fix(projects): separate identity from presentation * test(projects): use grouped identity in UI specs * fix(projects): harden grouped project routing * test(projects): expect framed settings route * fix(projects): preserve exact placement identity * fix(projects): harden grouping identity and routing * fix(projects): restore project key as grouping identity Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping. * fix(projects): prevent project key collisions Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership. * test(projects): assert host-scoped project keys * fix(projects): preserve project key compatibility Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories. * fix(projects): harden project reference identity * fix(projects): retain archived reference errors * fix(projects): scope legacy paths to hosts * test(app): model persisted project keys * fix(projects): retain absolute scp users * fix(projects): resolve legacy mutation aliases * fix(projects): preserve conservative identity boundaries * fix(projects): preserve legacy placement identity * test(projects): assert legacy placement identity * test(projects): expect canonical Windows keys * fix(projects): separate bootstrap identity fields * fix(projects): scope legacy paths by host * fix(projects): stabilize opaque project identity * fix(projects): preserve remaining opaque identities * fix(projects): classify forward-slash UNC roots * fix(projects): preserve opaque selection keys * fix(projects): frame host-local project keys * fix(projects): canonicalize framed identities * test(projects): isolate repeated remote fixtures * fix(projects): keep project key as UI identity * fix(projects): reconcile hydrated project references * fix(projects): preserve archived project identity * refactor(projects): group projects from project records, not workspaces Cross-host grouping had been built by hanging a projectKey off every workspace descriptor. That scoped the key to the wrong entity: the app then had to reconstruct project identity per workspace in six modules, which forced projectKey fallbacks into RPC arguments and a server-side resolver that turned grouping keys back into project ids. A workspace has a projectId. The key belongs to the project, and grouping is a display concern, so the daemon now publishes projects over project.list plus the existing project.update channel and the app groups them in one place. Projects are addressed by host-local projectId everywhere, including the settings route. Projects persisted without a key are backfilled by ordinary boot reconciliation, with no migration. Old daemons advertise no projectList capability and simply do not group. * test(projects): seed projects alongside workspaces in fixtures Sidebar fixtures seeded workspaces without their projects, which the daemon cannot produce now that projects arrive on their own channel, so nothing rendered. Adds a seeding helper that supplies both. Also drops the provisioning test for accepting a project key where a project id belongs; that resolver was removed deliberately. * fix(projects): keep derived project identity stable Four gaps left by shrinking the key derivation: Local keys used a bare path resolve, so one directory reached by a different spelling produced a different key and reset grouped identity. Windows CI caught this with the 8.3 and long-name forms of one temp path. Canonicalize through the shared realpath-aware normalization. Subproject display names lost their selected-path input, so every subproject of a monorepo rendered under the same remote-derived name. Reconciliation only ever backfilled a missing key, so a project whose remote changed stayed grouped with hosts on the old remote. Project settings kept a disconnected host selected and rendered a dead end with no way back to an editable host. * fix(projects): read scheme remotes as URLs, not scp form An `ssh://user@host:22/org/repo` remote matched the scp-form pattern first, because `[^@]+` happily eats `ssh://git`, so the port landed in the path and the project keyed as `remote:host/22/org/repo`. Two spellings of one remote therefore never grouped. Try scp form only when there is no scheme, and drop protocol-default ports so an explicitly written `:22` matches the same remote without it.
This commit is contained in:
@@ -12,7 +12,7 @@ function projectFor(serverId: string, key = "project"): HostProjectListItem {
|
||||
projectName: key,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: `/work/${key}`,
|
||||
hosts: [{ serverId, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }],
|
||||
hosts: [{ serverId, projectId: key, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }],
|
||||
workspaceKeys: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import {
|
||||
getHostProjectSourceDirectory,
|
||||
getHostProjectId,
|
||||
hostProjectFromRoute,
|
||||
hostProjectFromWorkspace,
|
||||
useHostProjects,
|
||||
@@ -823,6 +824,8 @@ async function createMultiplicityWorkspace(input: {
|
||||
serverId: string;
|
||||
createFailedMessage: string;
|
||||
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
|
||||
const projectId = getHostProjectId(input.project, input.serverId);
|
||||
if (!projectId) throw new Error("Project is not available on the selected host");
|
||||
const isWorktree = input.isolation === "worktree";
|
||||
const checkoutRequest = isWorktree
|
||||
? resolveCheckoutRequest(input.selectedItem, input.currentBranch)
|
||||
@@ -836,14 +839,14 @@ async function createMultiplicityWorkspace(input: {
|
||||
? {
|
||||
kind: "worktree",
|
||||
cwd: input.sourceDirectory,
|
||||
projectId: input.project.projectKey,
|
||||
projectId,
|
||||
worktreeSlug: createNameId(),
|
||||
...checkoutRequest,
|
||||
}
|
||||
: {
|
||||
kind: "directory",
|
||||
path: input.sourceDirectory,
|
||||
projectId: input.project.projectKey,
|
||||
projectId,
|
||||
},
|
||||
...(firstAgentContext ? { firstAgentContext } : {}),
|
||||
});
|
||||
@@ -1411,6 +1414,8 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
>
|
||||
<Pressable
|
||||
ref={host.anchorRef}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Host"
|
||||
onPress={host.open}
|
||||
disabled={isPending || host.allHosts.length === 0}
|
||||
style={badgePressableStyle}
|
||||
@@ -1889,16 +1894,20 @@ export function NewWorkspaceScreen({
|
||||
}
|
||||
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
|
||||
const firstAgentContext = buildFirstAgentContext(input);
|
||||
const hostProjectId = getHostProjectId(selectedProject, selectedServerId);
|
||||
if (!hostProjectId) {
|
||||
throw new Error("Project is not available on the selected host");
|
||||
}
|
||||
|
||||
return {
|
||||
cwd: selectedSourceDirectory,
|
||||
projectId: selectedProject.projectKey,
|
||||
projectId: hostProjectId,
|
||||
worktreeSlug: createNameId(),
|
||||
...(firstAgentContext ? { firstAgentContext } : {}),
|
||||
...checkoutRequest,
|
||||
};
|
||||
},
|
||||
[currentBranch, selectedItem, selectedProject, selectedSourceDirectory],
|
||||
[currentBranch, selectedItem, selectedProject, selectedServerId, selectedSourceDirectory],
|
||||
);
|
||||
|
||||
const ensureWorkspace = useCallback(
|
||||
|
||||
@@ -17,7 +17,14 @@ function project(projectKey: string, serverId = "host"): HostProjectListItem {
|
||||
projectName: projectKey,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: `/work/${projectKey}`,
|
||||
hosts: [{ serverId, iconWorkingDir: `/work/${projectKey}`, canCreateWorktree: true }],
|
||||
hosts: [
|
||||
{
|
||||
serverId,
|
||||
projectId: projectKey,
|
||||
iconWorkingDir: `/work/${projectKey}`,
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
workspaceKeys: [],
|
||||
};
|
||||
}
|
||||
@@ -111,6 +118,46 @@ describe("reconcileProjectSelection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("adopts a routed project's hydrated cross-host key", () => {
|
||||
const routedProject = {
|
||||
...project("local-project-id"),
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host",
|
||||
projectId: "local-project-id",
|
||||
iconWorkingDir: "/work/project",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const hydratedProject = {
|
||||
...project("remote:github.com/acme/project"),
|
||||
hosts: [
|
||||
{
|
||||
serverId: "host",
|
||||
projectId: "local-project-id",
|
||||
iconWorkingDir: "/work/project",
|
||||
canCreateWorktree: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const current = createProjectSelection(
|
||||
context({ initialProject: routedProject, projects: [], routeProject: routedProject }),
|
||||
);
|
||||
const hydratedContext = context({
|
||||
initialProject: hydratedProject,
|
||||
projects: [hydratedProject],
|
||||
routeProject: routedProject,
|
||||
});
|
||||
|
||||
expect(reconcileProjectSelection(current, hydratedContext)).toEqual({
|
||||
contextKey: "host:",
|
||||
projectKey: hydratedProject.projectKey,
|
||||
project: hydratedProject,
|
||||
source: "initial",
|
||||
});
|
||||
});
|
||||
|
||||
it("stores hydrated project snapshots before archive gaps", () => {
|
||||
const routeProject = project("route-project");
|
||||
const hydratedProject: HostProjectListItem = {
|
||||
@@ -184,6 +231,17 @@ describe("reconcileProjectSelection", () => {
|
||||
expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual(current);
|
||||
});
|
||||
|
||||
it("preserves an opaque project key ending in whitespace", () => {
|
||||
const selected = project("host:project ");
|
||||
const current = createProjectSelection(
|
||||
context({ initialProject: selected, projects: [selected] }),
|
||||
);
|
||||
const currentContext = context({ initialProject: selected, projects: [selected] });
|
||||
|
||||
expect(resolveProjectSelection(current, currentContext)).toEqual(selected);
|
||||
expect(reconcileProjectSelection(current, currentContext)).toEqual(current);
|
||||
});
|
||||
|
||||
it("resets fallback selection when host project capability changes", () => {
|
||||
const fallback = project("git-fallback");
|
||||
const remembered = project("remembered-directory");
|
||||
|
||||
@@ -57,7 +57,17 @@ export function resolveInitialProjectSelectionSource(input: {
|
||||
if (!input.initialProject) {
|
||||
return null;
|
||||
}
|
||||
if (input.routeProject?.projectKey === input.initialProject.projectKey) {
|
||||
if (
|
||||
input.routeProject?.projectKey === input.initialProject.projectKey ||
|
||||
input.routeProject?.hosts.some((routeHost) =>
|
||||
input.initialProject?.hosts.some(
|
||||
(host) =>
|
||||
host.serverId === routeHost.serverId &&
|
||||
Boolean(host.projectId) &&
|
||||
host.projectId === routeHost.projectId,
|
||||
),
|
||||
)
|
||||
) {
|
||||
return "route";
|
||||
}
|
||||
if (input.lastActiveProject?.projectKey === input.initialProject.projectKey) {
|
||||
@@ -67,8 +77,8 @@ export function resolveInitialProjectSelectionSource(input: {
|
||||
}
|
||||
|
||||
function resolveProjectSelectionKey(selection: ProjectSelection): string | null {
|
||||
const projectKey = selection.projectKey?.trim() ?? "";
|
||||
return projectKey || null;
|
||||
const projectKey = selection.projectKey ?? "";
|
||||
return projectKey.trim() ? projectKey : null;
|
||||
}
|
||||
|
||||
function resolveSelectedProjectFromInitialInputs(
|
||||
@@ -95,14 +105,14 @@ function refreshSelectionProject(
|
||||
};
|
||||
}
|
||||
|
||||
function shouldResetInitialFallbackSelection(
|
||||
function shouldResetHydratedInitialSelection(
|
||||
selection: ProjectSelection,
|
||||
context: ProjectSelectionContext,
|
||||
): boolean {
|
||||
if (
|
||||
selection.source !== "initial" ||
|
||||
!context.initialProject ||
|
||||
context.initialProjectSource !== "lastActive"
|
||||
(context.initialProjectSource !== "route" && context.initialProjectSource !== "lastActive")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -149,7 +159,7 @@ export function reconcileProjectSelection(
|
||||
return initialSelection;
|
||||
}
|
||||
|
||||
if (shouldResetInitialFallbackSelection(current, context)) {
|
||||
if (shouldResetHydratedInitialSelection(current, context)) {
|
||||
return initialSelection;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { resolveProjectSettingsHostTarget } from "./project-settings-host-selection";
|
||||
|
||||
test("falls back when the selected host is no longer editable", () => {
|
||||
const hostA = { serverId: "host-a", projectId: "project-a" };
|
||||
const hostB = { serverId: "host-b", projectId: "project-b" };
|
||||
|
||||
expect(
|
||||
resolveProjectSettingsHostTarget({
|
||||
routeKey: "host-a:project-a",
|
||||
editableHosts: [hostA],
|
||||
defaultHost: hostA,
|
||||
selection: { routeKey: "host-a:project-a", ...hostB },
|
||||
}),
|
||||
).toEqual(hostA);
|
||||
});
|
||||
|
||||
test("keeps the selected host while it remains editable", () => {
|
||||
const hostA = { serverId: "host-a", projectId: "project-a" };
|
||||
const hostB = { serverId: "host-b", projectId: "project-b" };
|
||||
|
||||
expect(
|
||||
resolveProjectSettingsHostTarget({
|
||||
routeKey: "host-a:project-a",
|
||||
editableHosts: [hostA, hostB],
|
||||
defaultHost: hostA,
|
||||
selection: { routeKey: "host-a:project-a", ...hostB },
|
||||
}),
|
||||
).toEqual(hostB);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface ProjectSettingsHostTarget {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface StoredProjectSettingsHostSelection extends ProjectSettingsHostTarget {
|
||||
routeKey: string;
|
||||
}
|
||||
|
||||
interface ResolveProjectSettingsHostTargetInput {
|
||||
routeKey: string;
|
||||
editableHosts: readonly ProjectSettingsHostTarget[];
|
||||
defaultHost: ProjectSettingsHostTarget | undefined;
|
||||
selection: StoredProjectSettingsHostSelection;
|
||||
}
|
||||
|
||||
export function resolveProjectSettingsHostTarget(
|
||||
input: ResolveProjectSettingsHostTargetInput,
|
||||
): ProjectSettingsHostTarget | undefined {
|
||||
const selectedHost =
|
||||
input.selection.routeKey === input.routeKey
|
||||
? getEditableHost(input.editableHosts, input.selection)
|
||||
: undefined;
|
||||
return (
|
||||
selectedHost ??
|
||||
getEditableHost(input.editableHosts, input.defaultHost) ??
|
||||
input.editableHosts[0]
|
||||
);
|
||||
}
|
||||
|
||||
function getEditableHost(
|
||||
editableHosts: readonly ProjectSettingsHostTarget[],
|
||||
target: ProjectSettingsHostTarget | undefined,
|
||||
): ProjectSettingsHostTarget | undefined {
|
||||
if (!target) return undefined;
|
||||
for (const host of editableHosts) {
|
||||
if (host.serverId === target.serverId && host.projectId === target.projectId) return host;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -46,6 +46,10 @@ import {
|
||||
} from "@/utils/project-config-form";
|
||||
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
|
||||
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
|
||||
import { getProjectHostEntry, getProjectSummaryForHostProject } from "@/utils/projects";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { selectProjectIdForServer } from "@/stores/session-store-hooks/selectors";
|
||||
import { resolveProjectSettingsHostTarget } from "@/screens/project-settings-host-selection";
|
||||
|
||||
const SCRIPT_SERVICE_TYPE = "service";
|
||||
|
||||
@@ -84,35 +88,54 @@ const WORKTREE_DOCS_URL = "https://paseo.sh/docs/worktrees";
|
||||
type ReadProjectConfigData = Awaited<ReturnType<DaemonClient["readProjectConfig"]>>;
|
||||
|
||||
export interface ProjectSettingsScreenProps {
|
||||
projectKey: string;
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScreenProps) {
|
||||
export default function ProjectSettingsScreen({ serverId, projectId }: ProjectSettingsScreenProps) {
|
||||
const { projects } = useProjects();
|
||||
const project = useMemo(
|
||||
() => projects.find((entry) => entry.projectKey === projectKey),
|
||||
[projects, projectKey],
|
||||
() => getProjectSummaryForHostProject(projects, serverId, projectId),
|
||||
[projectId, projects, serverId],
|
||||
);
|
||||
const editableHosts = useMemo(() => filterEditableHosts(project), [project]);
|
||||
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>(
|
||||
() => editableHosts[0]?.serverId ?? "",
|
||||
const routeKey = `${serverId}:${projectId}`;
|
||||
const routeHost = getProjectHostEntry(project, serverId, projectId);
|
||||
const defaultHost = routeHost?.isOnline ? routeHost : editableHosts[0];
|
||||
const [hostSelection, setHostSelection] = useState({
|
||||
routeKey: "",
|
||||
serverId: "",
|
||||
projectId: "",
|
||||
});
|
||||
const selectedTarget = resolveProjectSettingsHostTarget({
|
||||
routeKey,
|
||||
editableHosts,
|
||||
defaultHost,
|
||||
selection: hostSelection,
|
||||
});
|
||||
const selectedServerId = selectedTarget?.serverId ?? "";
|
||||
const selectedProjectId = selectedTarget?.projectId ?? "";
|
||||
const setSelectedServerId = useCallback(
|
||||
(targetServerId: string) => {
|
||||
const targetProjectId = selectProjectIdForServer(useSessionStore.getState(), {
|
||||
sourceServerId: serverId,
|
||||
projectId,
|
||||
targetServerId,
|
||||
});
|
||||
if (targetProjectId) {
|
||||
setHostSelection({ routeKey, serverId: targetServerId, projectId: targetProjectId });
|
||||
}
|
||||
},
|
||||
[projectId, routeKey, serverId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const stillValid = editableHosts.some((host) => host.serverId === selectedServerId);
|
||||
if (!stillValid) {
|
||||
setSelectedServerId(editableHosts[0]?.serverId ?? "");
|
||||
}
|
||||
}, [editableHosts, selectedServerId]);
|
||||
|
||||
const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId);
|
||||
const isHostGone =
|
||||
Boolean(selectedServerId) &&
|
||||
(selectedSnapshot?.connectionStatus === "offline" ||
|
||||
selectedSnapshot?.connectionStatus === "error");
|
||||
|
||||
const selectedHost = editableHosts.find((host) => host.serverId === selectedServerId);
|
||||
const selectedHost = getProjectHostEntry(project, selectedServerId, selectedProjectId);
|
||||
const client = useHostRuntimeClient(selectedHost?.serverId ?? "");
|
||||
|
||||
if (!project || editableHosts.length === 0 || !selectedHost || !client) {
|
||||
@@ -231,7 +254,7 @@ function ProjectSettingsBody({
|
||||
const hasMultipleHosts = hosts.length > 1;
|
||||
|
||||
return (
|
||||
<View style={styles.body}>
|
||||
<View role="main" style={styles.body}>
|
||||
<BackToProjectsButton />
|
||||
|
||||
<View style={styles.headerBlock}>
|
||||
@@ -241,7 +264,13 @@ function ProjectSettingsBody({
|
||||
projectName={project.projectName}
|
||||
projectKey={project.projectKey}
|
||||
/>
|
||||
<ProjectNameEditor project={project} client={client} />
|
||||
<ProjectNameEditor
|
||||
key={`${selectedHost.serverId}:${selectedHost.projectId}`}
|
||||
projectName={selectedHost.projectName}
|
||||
projectCustomName={selectedHost.projectCustomName}
|
||||
projectId={selectedHost.projectId}
|
||||
client={client}
|
||||
/>
|
||||
</View>
|
||||
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
|
||||
</View>
|
||||
@@ -789,19 +818,26 @@ function ResolveSpinnerColor(): string {
|
||||
}
|
||||
|
||||
interface ProjectNameEditorProps {
|
||||
project: ProjectSummary;
|
||||
projectName: string;
|
||||
projectCustomName: string | null;
|
||||
projectId: string;
|
||||
client: DaemonClient;
|
||||
}
|
||||
|
||||
function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
function ProjectNameEditor({
|
||||
projectName,
|
||||
projectCustomName,
|
||||
projectId,
|
||||
client,
|
||||
}: ProjectNameEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState(project.projectCustomName ?? "");
|
||||
const [value, setValue] = useState(projectCustomName ?? "");
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: (customName: string | null) => client.renameProject(project.projectKey, customName),
|
||||
mutationFn: (customName: string | null) => client.renameProject(projectId, customName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
setIsEditing(false);
|
||||
@@ -815,24 +851,24 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
});
|
||||
|
||||
const handleStartEdit = useCallback(() => {
|
||||
setValue(project.projectCustomName ?? "");
|
||||
setValue(projectCustomName ?? "");
|
||||
setIsEditing(true);
|
||||
}, [project.projectCustomName]);
|
||||
}, [projectCustomName]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setValue(project.projectCustomName ?? "");
|
||||
}, [project.projectCustomName]);
|
||||
setValue(projectCustomName ?? "");
|
||||
}, [projectCustomName]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
const next = trimmed.length === 0 ? null : trimmed;
|
||||
if (next === (project.projectCustomName ?? null)) {
|
||||
if (next === projectCustomName) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
renameMutation.mutate(next);
|
||||
}, [value, project.projectCustomName, renameMutation]);
|
||||
}, [value, projectCustomName, renameMutation]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
renameMutation.mutate(null);
|
||||
@@ -840,12 +876,17 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<View style={styles.nameEditorRow}>
|
||||
<View
|
||||
role="group"
|
||||
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
|
||||
style={styles.nameEditorRow}
|
||||
>
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{project.projectName}
|
||||
{projectName}
|
||||
</Text>
|
||||
<Pressable
|
||||
testID="project-name-edit-button"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.project.rename.renameLabel")}
|
||||
onPress={handleStartEdit}
|
||||
hitSlop={8}
|
||||
@@ -853,9 +894,10 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
>
|
||||
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
|
||||
</Pressable>
|
||||
{project.projectCustomName ? (
|
||||
{projectCustomName ? (
|
||||
<Pressable
|
||||
testID="project-name-reset-button"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.project.rename.resetLabel")}
|
||||
onPress={handleReset}
|
||||
disabled={renameMutation.isPending}
|
||||
@@ -870,13 +912,17 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.nameEditorRow}>
|
||||
<View
|
||||
role="group"
|
||||
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
|
||||
style={styles.nameEditorRow}
|
||||
>
|
||||
<TextInput
|
||||
testID="project-name-input"
|
||||
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
placeholder={project.projectName}
|
||||
placeholder={projectName}
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
autoFocus
|
||||
style={styles.nameEditorInput}
|
||||
@@ -886,6 +932,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
/>
|
||||
<Pressable
|
||||
testID="project-name-save-button"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.project.rename.saveLabel")}
|
||||
onPress={handleSave}
|
||||
disabled={renameMutation.isPending}
|
||||
@@ -896,6 +943,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="project-name-cancel-button"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.project.rename.cancelLabel")}
|
||||
onPress={handleCancel}
|
||||
disabled={renameMutation.isPending}
|
||||
@@ -982,6 +1030,7 @@ function HostPicker({ hosts, selectedHost, onSelectHost }: HostPickerProps) {
|
||||
>
|
||||
<Pressable
|
||||
ref={triggerRef}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.project.switchHost")}
|
||||
testID="host-picker"
|
||||
style={styles.hostIndicator}
|
||||
|
||||
@@ -221,6 +221,9 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
|
||||
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
|
||||
return {
|
||||
serverId: "host-a",
|
||||
projectId: "project-a",
|
||||
projectName: "Project",
|
||||
projectCustomName: null,
|
||||
serverName: "alpha",
|
||||
isOnline: true,
|
||||
repoRoot: "/home/me/proj",
|
||||
@@ -290,7 +293,9 @@ describe("ProjectsScreen", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function render(view: { kind: "projects" } | { kind: "project"; projectKey: string }) {
|
||||
function render(
|
||||
view: { kind: "projects" } | { kind: "project"; serverId: string; projectId: string },
|
||||
) {
|
||||
act(() => {
|
||||
root?.render(<ProjectsScreen view={view} />);
|
||||
});
|
||||
@@ -329,7 +334,7 @@ describe("ProjectsScreen", () => {
|
||||
});
|
||||
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith("/settings/projects/remote%3Agithub.com%2Facme%2Fapp");
|
||||
expect(navigate).toHaveBeenCalledWith("/settings/projects/host-a/project-a");
|
||||
});
|
||||
|
||||
it("does not render a kebab menu on the row", () => {
|
||||
@@ -392,7 +397,7 @@ describe("ProjectsScreen", () => {
|
||||
],
|
||||
});
|
||||
|
||||
render({ kind: "project", projectKey: "remote:github.com/acme/app" });
|
||||
render({ kind: "project", serverId: "host-a", projectId: "project-a" });
|
||||
|
||||
const selected = findRow(container!, "remote:github.com/acme/app");
|
||||
const other = findRow(container!, "remote:github.com/acme/other");
|
||||
|
||||
@@ -10,16 +10,20 @@ import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
|
||||
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
import type { ProjectSummary } from "@/utils/projects";
|
||||
import { getProjectSummaryForHostProject, type ProjectSummary } from "@/utils/projects";
|
||||
|
||||
interface ProjectsScreenProps {
|
||||
view: { kind: "projects" } | { kind: "project"; projectKey: string };
|
||||
view: { kind: "projects" } | { kind: "project"; serverId: string; projectId: string };
|
||||
}
|
||||
|
||||
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
||||
const { t } = useTranslation();
|
||||
const { projects, hostErrors, isLoading } = useProjects();
|
||||
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
|
||||
const selectedProjectKey =
|
||||
view.kind === "project"
|
||||
? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.projectKey ??
|
||||
null)
|
||||
: null;
|
||||
const iconTargets = useMemo(
|
||||
() =>
|
||||
projects.flatMap((project) => {
|
||||
@@ -100,10 +104,10 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { projectKey, projectName } = project;
|
||||
|
||||
const target = project.hosts[0];
|
||||
const handleNavigate = useCallback(() => {
|
||||
router.navigate(buildProjectSettingsRoute(projectKey));
|
||||
}, [projectKey]);
|
||||
if (target) router.navigate(buildProjectSettingsRoute(target.serverId, target.projectId));
|
||||
}, [target]);
|
||||
|
||||
const rowStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
|
||||
@@ -129,7 +129,7 @@ export type SettingsView =
|
||||
| { kind: "section"; section: SettingsSectionSlug }
|
||||
| { kind: "host"; serverId: string; section: HostSectionSlug }
|
||||
| { kind: "projects" }
|
||||
| { kind: "project"; projectKey: string };
|
||||
| { kind: "project"; serverId: string; projectId: string };
|
||||
|
||||
interface SidebarSectionItem {
|
||||
id: SettingsSectionSlug;
|
||||
@@ -1386,7 +1386,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
return <ProjectsScreen view={view} />;
|
||||
}
|
||||
if (view.kind === "project") {
|
||||
return <ProjectSettingsScreen projectKey={view.projectKey} />;
|
||||
return <ProjectSettingsScreen serverId={view.serverId} projectId={view.projectId} />;
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
switch (view.section) {
|
||||
|
||||
Reference in New Issue
Block a user