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
+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) => ({