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:
Mohamed Boudra
2026-07-29 23:36:31 +02:00
committed by GitHub
parent 9e5accee3c
commit 20e46e3e3f
113 changed files with 3073 additions and 2038 deletions
@@ -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 -6
View File
@@ -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 }) => [
+2 -2
View File
@@ -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) {