fix(app): make settings navigation deterministic
Settings detail screens must return to their logical parent regardless of the route used to enter them. Keep compact navigation in the header while preserving the desktop project-list affordance.
This commit is contained in:
@@ -68,9 +68,9 @@ import {
|
||||
buildSchedulesRoute,
|
||||
buildSessionsRoute,
|
||||
buildSettingsAddHostRoute,
|
||||
buildSettingsHostSectionRoute,
|
||||
buildSettingsRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
@@ -198,13 +198,13 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
const handleOpenHostSettingsMobile = useCallback(
|
||||
(serverId: string) => {
|
||||
showMobileAgent();
|
||||
router.push(buildSettingsHostSectionRoute(serverId, "connections"));
|
||||
openHostOverview(serverId);
|
||||
},
|
||||
[showMobileAgent],
|
||||
);
|
||||
|
||||
const handleOpenHostSettingsDesktop = useCallback((serverId: string) => {
|
||||
router.push(buildSettingsHostSectionRoute(serverId, "connections"));
|
||||
openHostOverview(serverId);
|
||||
}, []);
|
||||
|
||||
const handleHomeMobile = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { router, type Href } from "expo-router";
|
||||
import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildProjectSettingsRoute,
|
||||
buildProjectsSettingsRoute,
|
||||
buildSettingsHostSectionRoute,
|
||||
buildSettingsRoute,
|
||||
type HostSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export type SettingsView =
|
||||
| { kind: "root" }
|
||||
| { kind: "section"; section: SettingsSectionSlug }
|
||||
| { kind: "host"; serverId: string; section: HostSectionSlug }
|
||||
| { kind: "project"; serverId: string; projectId: string };
|
||||
|
||||
export function openHostOverview(serverId: string): void {
|
||||
router.push(buildSettingsHostSectionRoute(serverId, "host"));
|
||||
}
|
||||
|
||||
export function openProjectSettings(serverId: string, projectId: string): void {
|
||||
router.push(buildProjectSettingsRoute(serverId, projectId));
|
||||
}
|
||||
|
||||
export function returnFromSettings(view: SettingsView): void {
|
||||
if (view.kind === "root") {
|
||||
if (!navigateToLastWorkspace()) {
|
||||
router.replace(buildOpenProjectRoute());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const parent =
|
||||
view.kind === "project" ? buildProjectsSettingsRoute(view.serverId) : buildSettingsRoute();
|
||||
router.dismissTo(parent as Href);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, TextInput, View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -47,7 +46,6 @@ import {
|
||||
type ProjectConfigDraft,
|
||||
type ProjectScriptDraft,
|
||||
} from "@/utils/project-config-form";
|
||||
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
getProjectHostEntry,
|
||||
getProjectSummaryForHostProject,
|
||||
@@ -94,9 +92,16 @@ type ReadProjectConfigData = Awaited<ReturnType<DaemonClient["readProjectConfig"
|
||||
export interface ProjectSettingsScreenProps {
|
||||
serverId: string;
|
||||
projectId: string;
|
||||
onBackToProjects: () => void;
|
||||
showBackToProjects: boolean;
|
||||
}
|
||||
|
||||
export default function ProjectSettingsScreen({ serverId, projectId }: ProjectSettingsScreenProps) {
|
||||
export default function ProjectSettingsScreen({
|
||||
serverId,
|
||||
projectId,
|
||||
onBackToProjects,
|
||||
showBackToProjects,
|
||||
}: ProjectSettingsScreenProps) {
|
||||
const { projects } = useProjects();
|
||||
const project = useMemo(
|
||||
() => getProjectSummaryForHostProject(projects, serverId, projectId),
|
||||
@@ -116,7 +121,12 @@ export default function ProjectSettingsScreen({ serverId, projectId }: ProjectSe
|
||||
selectedHost.repoRoot.trim().length > 0;
|
||||
|
||||
if (!project || !selectedHost || !client || !canEdit) {
|
||||
return <NoEditableTarget serverId={serverId} />;
|
||||
return (
|
||||
<NoEditableTarget
|
||||
onBackToProjects={onBackToProjects}
|
||||
showBackToProjects={showBackToProjects}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -125,41 +135,45 @@ export default function ProjectSettingsScreen({ serverId, projectId }: ProjectSe
|
||||
selectedHost={selectedHost}
|
||||
client={client}
|
||||
isHostGone={isHostGone}
|
||||
onBackToProjects={onBackToProjects}
|
||||
showBackToProjects={showBackToProjects}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function navigateBackToProjects(serverId: string) {
|
||||
router.navigate(buildProjectsSettingsRoute(serverId));
|
||||
}
|
||||
|
||||
function NoEditableTarget({ serverId }: { serverId: string }) {
|
||||
function NoEditableTarget({
|
||||
onBackToProjects,
|
||||
showBackToProjects,
|
||||
}: {
|
||||
onBackToProjects: () => void;
|
||||
showBackToProjects: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const handleBack = useCallback(() => navigateBackToProjects(serverId), [serverId]);
|
||||
return (
|
||||
<View style={styles.noTargetContainer}>
|
||||
<BackToProjectsButton serverId={serverId} />
|
||||
{showBackToProjects ? <BackToProjectsButton onPress={onBackToProjects} /> : null}
|
||||
<Text style={styles.noTargetText}>{t("settings.project.noEditableTarget")}</Text>
|
||||
<Button
|
||||
testID="project-settings-back-button"
|
||||
onPress={handleBack}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
>
|
||||
{t("settings.project.backToProjects")}
|
||||
</Button>
|
||||
{showBackToProjects ? (
|
||||
<Button
|
||||
testID="project-settings-back-button"
|
||||
onPress={onBackToProjects}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
>
|
||||
{t("settings.project.backToProjects")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function BackToProjectsButton({ serverId }: { serverId: string }) {
|
||||
function BackToProjectsButton({ onPress }: { onPress: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const handleBack = useCallback(() => navigateBackToProjects(serverId), [serverId]);
|
||||
return (
|
||||
<Button
|
||||
testID="project-settings-back-link"
|
||||
accessibilityLabel={t("settings.project.backToProjects")}
|
||||
onPress={handleBack}
|
||||
onPress={onPress}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={ArrowLeft}
|
||||
@@ -175,6 +189,8 @@ interface ProjectSettingsBodyProps {
|
||||
selectedHost: ProjectHostEntry;
|
||||
client: DaemonClient;
|
||||
isHostGone: boolean;
|
||||
onBackToProjects: () => void;
|
||||
showBackToProjects: boolean;
|
||||
}
|
||||
|
||||
function ProjectSettingsBody({
|
||||
@@ -182,6 +198,8 @@ function ProjectSettingsBody({
|
||||
selectedHost,
|
||||
client,
|
||||
isHostGone,
|
||||
onBackToProjects,
|
||||
showBackToProjects,
|
||||
}: ProjectSettingsBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
|
||||
@@ -257,7 +275,7 @@ function ProjectSettingsBody({
|
||||
|
||||
return (
|
||||
<View role="main" style={styles.body}>
|
||||
<BackToProjectsButton serverId={selectedHost.serverId} />
|
||||
{showBackToProjects ? <BackToProjectsButton onPress={onBackToProjects} /> : null}
|
||||
|
||||
<View style={styles.headerBlock}>
|
||||
<View style={styles.titleRow}>
|
||||
@@ -306,6 +324,8 @@ function ProjectSettingsBody({
|
||||
client,
|
||||
onReload: handleReload,
|
||||
isHostGone,
|
||||
onBackToProjects,
|
||||
showBackToProjects,
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
@@ -322,6 +342,8 @@ interface RenderContentInput {
|
||||
client: DaemonClient;
|
||||
onReload: () => void;
|
||||
isHostGone: boolean;
|
||||
onBackToProjects: () => void;
|
||||
showBackToProjects: boolean;
|
||||
}
|
||||
|
||||
function renderContent({
|
||||
@@ -335,6 +357,8 @@ function renderContent({
|
||||
client,
|
||||
onReload,
|
||||
isHostGone,
|
||||
onBackToProjects,
|
||||
showBackToProjects,
|
||||
}: RenderContentInput) {
|
||||
if (readQuery.isLoading) {
|
||||
return (
|
||||
@@ -353,7 +377,12 @@ function renderContent({
|
||||
}
|
||||
|
||||
if (isHostGone) {
|
||||
return <NoEditableTarget serverId={selectedHost.serverId} />;
|
||||
return (
|
||||
<NoEditableTarget
|
||||
onBackToProjects={onBackToProjects}
|
||||
showBackToProjects={showBackToProjects}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!loadedConfig) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProjectHostEntry, ProjectSummary, WorkspaceSummary } from "@/utils/projects";
|
||||
import type { ProjectHostError, UseProjectsResult } from "@/hooks/use-projects";
|
||||
|
||||
const { theme, projectsState, navigate } = vi.hoisted(() => ({
|
||||
const { theme, projectsState, push } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
spacing: { 0: 0, 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
||||
iconSize: { sm: 14, md: 20 },
|
||||
@@ -37,7 +37,7 @@ const { theme, projectsState, navigate } = vi.hoisted(() => ({
|
||||
refetch: vi.fn(),
|
||||
} as UseProjectsResult,
|
||||
},
|
||||
navigate: vi.fn(),
|
||||
push: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-native", () => {
|
||||
@@ -125,7 +125,7 @@ vi.mock("lucide-react-native", () => {
|
||||
});
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: { navigate },
|
||||
router: { push },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
@@ -281,7 +281,7 @@ describe("ProjectsScreen", () => {
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
setProjectsState({});
|
||||
navigate.mockReset();
|
||||
push.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -334,8 +334,8 @@ describe("ProjectsScreen", () => {
|
||||
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith("/settings/hosts/host-a/projects/project-a");
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
expect(push).toHaveBeenCalledWith("/settings/hosts/host-a/projects/project-a");
|
||||
});
|
||||
|
||||
it("does not render a kebab menu on the row", () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -9,7 +8,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
|
||||
import { useProjectIcons } from "@/projects/icons";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
import { openProjectSettings } from "@/navigation/settings-navigation";
|
||||
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
|
||||
|
||||
interface ProjectsScreenProps {
|
||||
@@ -112,7 +111,7 @@ function ProjectRow({ project, host, isFirst, iconDataUri }: ProjectRowProps) {
|
||||
const { viewKey } = project;
|
||||
const { projectName } = host;
|
||||
const handleNavigate = useCallback(() => {
|
||||
router.navigate(buildProjectSettingsRoute(host.serverId, host.projectId));
|
||||
openProjectSettings(host.serverId, host.projectId);
|
||||
}, [host.projectId, host.serverId]);
|
||||
|
||||
const rowStyle = useCallback(
|
||||
|
||||
@@ -121,27 +121,18 @@ import {
|
||||
useEnableBuiltInDaemonOption,
|
||||
} from "@/desktop/hooks/use-enable-built-in-daemon-option";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildSettingsHostSectionRoute,
|
||||
buildSettingsSectionRoute,
|
||||
type HostSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
navigateToLastWorkspace,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SettingsView =
|
||||
| { kind: "root" }
|
||||
| { kind: "section"; section: SettingsSectionSlug }
|
||||
| { kind: "host"; serverId: string; section: HostSectionSlug }
|
||||
| { kind: "project"; serverId: string; projectId: string };
|
||||
|
||||
interface SidebarSectionItem {
|
||||
id: SettingsSectionSlug;
|
||||
labelKey: string;
|
||||
@@ -1367,29 +1358,13 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
}
|
||||
}, [isCompactLayout, router]);
|
||||
|
||||
const handleBackToRoot = useCallback(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/settings");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const detailProjectServerId = view.kind === "project" ? view.serverId : null;
|
||||
const handleBackFromDetail = useCallback(() => {
|
||||
if (detailProjectServerId) {
|
||||
router.navigate(buildSettingsHostSectionRoute(detailProjectServerId, "projects"));
|
||||
return;
|
||||
}
|
||||
handleBackToRoot();
|
||||
}, [detailProjectServerId, handleBackToRoot, router]);
|
||||
returnFromSettings(view);
|
||||
}, [view]);
|
||||
|
||||
const handleBackToWorkspace = useCallback(() => {
|
||||
if (navigateToLastWorkspace()) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildOpenProjectRoute());
|
||||
}, [router]);
|
||||
returnFromSettings({ kind: "root" });
|
||||
}, []);
|
||||
|
||||
const detailHeader = ((): {
|
||||
title: string;
|
||||
@@ -1417,7 +1392,14 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
return renderHostSettingsContent(view, handleHostRemoved);
|
||||
}
|
||||
if (view.kind === "project") {
|
||||
return <ProjectSettingsScreen serverId={view.serverId} projectId={view.projectId} />;
|
||||
return (
|
||||
<ProjectSettingsScreen
|
||||
serverId={view.serverId}
|
||||
projectId={view.projectId}
|
||||
onBackToProjects={handleBackFromDetail}
|
||||
showBackToProjects={!isCompactLayout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
switch (view.section) {
|
||||
|
||||
Reference in New Issue
Block a user