feat: project settings page with paseo.json editor

Adds a Projects settings flow for inspecting and editing per-project
paseo.json (worktree setup/teardown, scripts, terminals) directly from the
app. Reads/writes go through new daemon RPCs with revision-based optimistic
concurrency, and the screen always keeps its header and host picker visible
so a hung host doesn't trap the user.
This commit is contained in:
Mohamed Boudra
2026-04-26 11:37:47 +07:00
parent 18903bcecb
commit 219050a7ab
36 changed files with 6447 additions and 81 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,418 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
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(() => ({
theme: {
spacing: { 0: 0, 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
iconSize: { sm: 14, md: 20 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" as const, medium: "500" as const },
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
opacity: { 50: 0.5 },
colors: {
surface0: "#000",
surface1: "#111",
surface2: "#222",
surface3: "#333",
surfaceSidebarHover: "#1a1a1a",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#444",
accent: "#0a84ff",
palette: { red: { 300: "#ff6b6b" } },
},
},
projectsState: {
current: {
projects: [],
hostErrors: [],
hiddenUnsupportedRemoteCount: 0,
isLoading: false,
isFetching: false,
refetch: vi.fn(),
} as UseProjectsResult,
},
navigate: vi.fn(),
}));
vi.mock("react-native", () => {
const passthrough = ({
children,
testID,
accessibilityLabel,
accessibilityRole,
onPress,
onHoverIn,
onHoverOut,
...rest
}: {
children?:
| React.ReactNode
| ((state: { pressed: boolean; hovered: boolean }) => React.ReactNode);
testID?: string;
accessibilityLabel?: string;
accessibilityRole?: string;
onPress?: (event: { stopPropagation: () => void }) => void;
onHoverIn?: () => void;
onHoverOut?: () => void;
} & Record<string, unknown>) => {
const dataAttrs: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rest)) {
if (key.startsWith("data-")) {
dataAttrs[key] = value;
}
}
return React.createElement(
"div",
{
role: accessibilityRole,
"aria-label": accessibilityLabel,
"data-testid": testID,
onClick: onPress
? (event: React.MouseEvent) => {
onPress({ stopPropagation: () => event.stopPropagation() });
}
: undefined,
onMouseEnter: onHoverIn,
onMouseLeave: onHoverOut,
...dataAttrs,
},
typeof children === "function" ? children({ pressed: false, hovered: false }) : children,
);
};
return {
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
React.createElement("div", { "data-testid": testID }, children),
Text: ({ children }: { children?: React.ReactNode }) =>
React.createElement("span", null, children),
Pressable: passthrough,
Image: ({ source }: { source?: { uri?: string } }) =>
React.createElement("img", { src: source?.uri ?? "" }),
Platform: { OS: "web" },
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const icon = (name: string) => {
const Icon = () => React.createElement("span", { "data-icon": name });
Icon.displayName = name;
return Icon;
};
return {
ChevronRight: icon("ChevronRight"),
MoreVertical: icon("MoreVertical"),
ExternalLink: icon("ExternalLink"),
Pencil: icon("Pencil"),
FolderGit2: icon("FolderGit2"),
};
});
vi.mock("expo-router", () => ({
router: { navigate },
}));
vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: ({ size }: { size?: string | number }) =>
React.createElement("span", {
"data-testid": "projects-loading-spinner",
"data-size": size,
}),
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-testid": "dropdown-menu" }, children),
DropdownMenuTrigger: ({
children,
accessibilityLabel,
testID,
}: {
children?:
| React.ReactNode
| ((state: { pressed: boolean; hovered: boolean; open: boolean }) => React.ReactNode);
accessibilityLabel?: string;
testID?: string;
}) =>
React.createElement(
"button",
{
type: "button",
"aria-label": accessibilityLabel,
"data-testid": testID,
onClick: (event: React.MouseEvent) => event.stopPropagation(),
},
typeof children === "function"
? children({ pressed: false, hovered: false, open: false })
: children,
),
DropdownMenuContent: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", { "data-testid": "dropdown-menu-content" }, children),
DropdownMenuItem: ({
children,
onSelect,
testID,
}: {
children?: React.ReactNode;
onSelect?: () => void;
testID?: string;
}) =>
React.createElement(
"button",
{
type: "button",
"data-testid": testID,
onClick: (event: React.MouseEvent) => {
event.stopPropagation();
onSelect?.();
},
},
children,
),
}));
vi.mock("@/hooks/use-projects", () => ({
useProjects: () => projectsState.current,
}));
vi.mock("@/hooks/use-project-icon-query", () => ({
useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }),
}));
import ProjectsScreen from "./projects-screen";
function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceSummary {
return {
id: "ws-1",
name: "main",
workspaceKind: "directory",
status: "done",
currentBranch: "main",
...overrides,
};
}
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
return {
serverId: "host-a",
serverName: "alpha",
isOnline: true,
repoRoot: "/home/me/proj",
workspaceCount: 1,
workspaces: [workspaceSummary()],
...overrides,
};
}
function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
const hosts = overrides.hosts ?? [hostEntry()];
const totalWorkspaceCount =
overrides.totalWorkspaceCount ?? hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
const onlineHostCount = overrides.onlineHostCount ?? hosts.filter((h) => h.isOnline).length;
return {
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
hosts,
totalWorkspaceCount,
hostCount: hosts.length,
onlineHostCount,
githubUrl: "https://github.com/acme/app",
hiddenUnsupportedRemoteCount: 0,
...overrides,
};
}
function setProjectsState(overrides: Partial<UseProjectsResult>) {
projectsState.current = {
projects: [],
hostErrors: [],
hiddenUnsupportedRemoteCount: 0,
isLoading: false,
isFetching: false,
refetch: vi.fn(),
...overrides,
};
}
function findRow(container: HTMLElement, projectKey: string): HTMLElement {
const row = container.querySelector<HTMLElement>(`[data-testid="project-row-${projectKey}"]`);
if (!row) throw new Error(`Expected row for ${projectKey}`);
return row;
}
describe("ProjectsScreen", () => {
let container: HTMLElement | null = null;
let root: Root | null = null;
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
setProjectsState({});
navigate.mockReset();
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
});
function render(view: { kind: "projects" } | { kind: "project"; projectKey: string }) {
act(() => {
root?.render(<ProjectsScreen view={view} />);
});
}
it("renders a row whose visible content is the project name only", () => {
setProjectsState({
projects: [
project({
projectName: "acme/app",
hosts: [hostEntry({ serverName: "alpha", workspaceCount: 5 })],
}),
],
});
render({ kind: "projects" });
const rows = container?.querySelectorAll('[data-testid^="project-row-"]') ?? [];
expect(rows.length).toBe(1);
expect(container?.textContent).toContain("acme/app");
expect(container?.textContent).not.toContain("workspace");
expect(container?.textContent).not.toContain("offline");
expect(container?.textContent).not.toContain("github.com");
});
it("navigates to the project detail route when the row is pressed", () => {
setProjectsState({
projects: [project({ projectKey: "remote:github.com/acme/app" })],
});
render({ kind: "projects" });
const row = findRow(container!, "remote:github.com/acme/app");
act(() => {
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/settings/projects/remote%3Agithub.com%2Facme%2Fapp");
});
it("does not render a kebab menu on the row", () => {
setProjectsState({
projects: [project({ projectKey: "remote:github.com/acme/app" })],
});
render({ kind: "projects" });
expect(
container?.querySelector('[data-testid="project-row-menu-remote:github.com/acme/app"]'),
).toBeNull();
});
it("renders a centered loading spinner before the first response", () => {
setProjectsState({ isLoading: true, projects: [] });
render({ kind: "projects" });
expect(container?.querySelector('[data-testid="projects-loading-spinner"]')).not.toBeNull();
});
it("renders the empty state when there are no projects", () => {
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 0 });
render({ kind: "projects" });
expect(container?.textContent).toContain("No projects yet");
});
it("renders the unsupported empty state when only non-GitHub remotes were filtered", () => {
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 3 });
render({ kind: "projects" });
expect(container?.textContent).toContain("Non-GitHub remote projects aren't supported yet");
});
it("renders a partial-host-failure banner above the list, naming each failed host", () => {
const hostErrors: ProjectHostError[] = [
{ serverId: "a", serverName: "alpha", message: "timed out" },
{ serverId: "b", serverName: "beta", message: "unreachable" },
];
setProjectsState({
projects: [project()],
hostErrors,
});
render({ kind: "projects" });
const banner = container?.querySelector('[data-testid="projects-host-errors"]');
expect(banner).not.toBeNull();
expect(banner?.textContent).toContain("alpha");
expect(banner?.textContent).toContain("beta");
expect(container?.querySelector('[data-testid^="project-row-"]')).not.toBeNull();
});
it("highlights the selected row when the active view targets a project", () => {
setProjectsState({
projects: [
project({ projectKey: "remote:github.com/acme/app" }),
project({
projectKey: "remote:github.com/acme/other",
projectName: "acme/other",
githubUrl: "https://github.com/acme/other",
}),
],
});
render({ kind: "project", projectKey: "remote:github.com/acme/app" });
const selected = findRow(container!, "remote:github.com/acme/app");
const other = findRow(container!, "remote:github.com/acme/other");
expect(selected.getAttribute("data-selected")).toBe("true");
expect(other.getAttribute("data-selected")).toBe("false");
});
it("does not include the word 'checkout' anywhere in the rendered tree", () => {
setProjectsState({
projects: [
project({
hosts: [
hostEntry({ serverId: "a", serverName: "alpha", workspaceCount: 3 }),
hostEntry({ serverId: "b", serverName: "beta", workspaceCount: 2 }),
],
}),
],
hostErrors: [{ serverId: "x", serverName: "x", message: "down" }],
});
render({ kind: "projects" });
const html = container?.innerHTML.toLowerCase() ?? "";
expect(html).not.toContain("checkout");
});
});
@@ -0,0 +1,213 @@
import { useCallback, useMemo } from "react";
import { Image, 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 { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
import { settingsStyles } from "@/styles/settings";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
interface ProjectsScreenProps {
view: { kind: "projects" } | { kind: "project"; projectKey: string };
}
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
const { projects, hostErrors, hiddenUnsupportedRemoteCount, isLoading } = useProjects();
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
if (isLoading && projects.length === 0) {
return (
<View style={styles.centered} testID="projects-list">
<LoadingSpinner size="large" color={styles.spinnerColor.color} />
</View>
);
}
if (projects.length === 0) {
const message =
hiddenUnsupportedRemoteCount > 0
? "Non-GitHub remote projects aren't supported yet"
: "No projects yet";
return (
<View style={styles.centered} testID="projects-list">
<Text style={styles.emptyText}>{message}</Text>
</View>
);
}
return (
<View testID="projects-list">
{hostErrors.length > 0 ? <HostErrorsBanner errors={hostErrors} /> : null}
<View style={settingsStyles.card}>
{projects.map((project, index) => (
<ProjectRow
key={project.projectKey}
project={project}
isFirst={index === 0}
isSelected={selectedProjectKey === project.projectKey}
/>
))}
</View>
</View>
);
}
function HostErrorsBanner({ errors }: { errors: ProjectHostError[] }) {
return (
<View style={styles.errorsBanner} testID="projects-host-errors">
{errors.map((error) => (
<Text key={error.serverId} style={styles.errorsBannerText}>
{`Couldn't load projects from host ${error.serverName}: ${error.message}`}
</Text>
))}
</View>
);
}
interface ProjectRowProps {
project: ProjectSummary;
isFirst: boolean;
isSelected: boolean;
}
function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) {
const { theme } = useUnistyles();
const { hosts, projectKey, projectName } = project;
const leadingHost = hosts[0];
const handleNavigate = useCallback(() => {
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const rowStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
settingsStyles.row,
!isFirst && settingsStyles.rowBorder,
styles.row,
isSelected && styles.rowSelected,
hovered && !isSelected && styles.rowHovered,
pressed && styles.rowPressed,
],
[isFirst, isSelected],
);
return (
<Pressable
style={rowStyle}
onPress={handleNavigate}
accessibilityRole="button"
accessibilityLabel={`Edit ${projectName}`}
testID={`project-row-${projectKey}`}
data-selected={isSelected ? "true" : "false"}
>
<View style={styles.rowMain}>
<View style={styles.leading}>
<ProjectRowIcon host={leadingHost} projectName={projectName} />
</View>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{projectName}
</Text>
</View>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
);
}
function ProjectRowIcon({
host,
projectName,
}: {
host: ProjectHostEntry | undefined;
projectName: string;
}) {
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
const { icon } = useProjectIconQuery({
serverId: host?.serverId ?? "",
cwd: host?.repoRoot ?? "",
});
const iconDataUri =
icon && icon.data && icon.mimeType ? `data:${icon.mimeType};base64,${icon.data}` : null;
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
if (iconDataUri) {
return <Image source={imageSource} style={styles.iconImage} />;
}
return (
<View style={styles.iconFallback}>
<Text style={styles.iconFallbackText}>{initial}</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
centered: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[6],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
errorsBanner: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
marginBottom: theme.spacing[3],
gap: theme.spacing[1],
},
errorsBannerText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
},
row: {
gap: theme.spacing[3],
},
rowMain: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
rowHovered: {
backgroundColor: theme.colors.surface2,
},
rowPressed: {
backgroundColor: theme.colors.surface3,
},
rowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
leading: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
},
iconImage: {
width: 16,
height: 16,
borderRadius: theme.borderRadius.sm,
},
iconFallback: {
width: 16,
height: 16,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
},
iconFallbackText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
spinnerColor: {
color: theme.colors.foregroundMuted,
},
}));
+84 -15
View File
@@ -27,6 +27,7 @@ import {
Shield,
Puzzle,
Plus,
FolderGit2,
} from "lucide-react-native";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
@@ -70,11 +71,14 @@ import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { HostPage, HostRenameButton } from "@/screens/settings/host-page";
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import {
buildHostOpenProjectRoute,
buildHostWorkspaceRoute,
buildProjectsSettingsRoute,
buildSettingsHostRoute,
buildSettingsSectionRoute,
type SettingsSectionSlug,
@@ -88,7 +92,9 @@ import { getLastNavigationWorkspaceRouteSelection } from "@/stores/navigation-ac
export type SettingsView =
| { kind: "root" }
| { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string };
| { kind: "host"; serverId: string }
| { kind: "projects" }
| { kind: "project"; projectKey: string };
interface SidebarSectionItem {
id: SettingsSectionSlug;
@@ -544,6 +550,37 @@ function SidebarSectionButton({
);
}
interface SidebarProjectsButtonProps {
isSelected: boolean;
onSelect: () => void;
}
function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonProps) {
const { theme } = useUnistyles();
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
const labelStyle = useMemo(
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
[isSelected, theme.colors.foreground],
);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={onSelect}
testID="settings-projects"
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<FolderGit2
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={labelStyle} numberOfLines={1}>
Projects
</Text>
</Pressable>
);
}
interface SidebarHostItemProps {
serverId: string;
label: string;
@@ -590,6 +627,7 @@ interface SettingsSidebarProps {
view: SettingsView;
onSelectSection: (section: SettingsSectionSlug) => void;
onSelectHost: (serverId: string) => void;
onSelectProjects: () => void;
onAddHost: () => void;
onBackToWorkspace: () => void;
layout: "desktop" | "mobile";
@@ -599,6 +637,7 @@ function SettingsSidebar({
view,
onSelectSection,
onSelectHost,
onSelectProjects,
onAddHost,
onBackToWorkspace,
layout,
@@ -626,6 +665,7 @@ function SettingsSidebar({
const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
const selectedSectionId = view.kind === "section" ? view.section : null;
const selectedServerId = view.kind === "host" ? view.serverId : null;
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
return (
@@ -655,6 +695,7 @@ function SettingsSidebar({
onSelect={onSelectSection}
/>
))}
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
</View>
<SidebarSeparator />
<View style={sidebarStyles.list}>
@@ -818,6 +859,15 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[isCompactLayout, router],
);
const handleSelectProjects = useCallback(() => {
const target = buildProjectsSettingsRoute();
if (isCompactLayout) {
router.push(target);
} else {
router.replace(target);
}
}, [isCompactLayout, router]);
const handleScanQr = useCallback(() => {
closeAddConnectionFlow();
router.push({
@@ -844,17 +894,11 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
}, [router]);
const handleBackToWorkspace = useCallback(() => {
if (!isCompactLayout) {
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) {
router.replace(
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
);
return;
}
}
if (router.canGoBack()) {
router.back();
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) {
router.replace(
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
);
return;
}
if (anyOnlineServerId) {
@@ -862,7 +906,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
return;
}
router.replace("/");
}, [anyOnlineServerId, isCompactLayout, router]);
}, [anyOnlineServerId, router]);
const detailHeader = ((): {
title: string;
@@ -883,6 +927,9 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
if (!item) return null;
return { title: item.label, Icon: item.icon };
}
if (view.kind === "project" || view.kind === "projects") {
return { title: "Projects", Icon: FolderGit2 };
}
return null;
})();
@@ -890,6 +937,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
if (view.kind === "host") {
return <HostPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
}
if (view.kind === "projects") {
return <ProjectsScreen view={view} />;
}
if (view.kind === "project") {
return <ProjectSettingsScreen projectKey={view.projectKey} />;
}
if (view.kind === "section") {
switch (view.section) {
case "general":
@@ -964,6 +1017,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
view={view}
onSelectSection={handleSelectSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
layout="mobile"
@@ -974,14 +1028,18 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
);
}
// Mobile detail: full-screen content with a back header that returns to the list.
// Mobile detail: full-screen content with a back header. Project detail uses
// an app-level back (out of settings, to the workspace) since the in-body
// "Back to projects" ghost button handles list-level back; other detail views
// step back to the settings root.
const detailBackHandler = view.kind === "project" ? handleBackToWorkspace : handleBackToRoot;
if (isCompactLayout) {
return (
<View style={styles.container}>
<BackHeader
title={detailHeader?.title}
titleAccessory={detailHeader?.titleAccessory}
onBack={handleBackToRoot}
onBack={detailBackHandler}
/>
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
<View style={styles.content}>{content}</View>
@@ -1001,6 +1059,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
view={view}
onSelectSection={handleSelectSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
layout="desktop"
@@ -1094,6 +1153,16 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
placeholder: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[8],
},
placeholderText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
const desktopStyles = StyleSheet.create((theme) => ({