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
@@ -39,6 +39,7 @@ import {
FolderGit2,
GitPullRequest,
Globe,
Settings,
SquareTerminal,
Monitor,
MoreVertical,
@@ -53,6 +54,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import {
buildHostNewWorkspaceRoute,
buildProjectSettingsRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
@@ -558,6 +560,10 @@ function ProjectKebabMenu({
() => <Trash2 size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const settingsLeadingIcon = useMemo(
() => <Settings size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const renderTriggerIcon = useCallback(
({ hovered }: { hovered?: boolean }) => (
<MoreVertical
@@ -567,6 +573,11 @@ function ProjectKebabMenu({
),
[theme.colors.foreground, theme.colors.foregroundMuted],
);
const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const canOpenProjectSettings = projectKey.trim().length > 0;
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -579,6 +590,15 @@ function ProjectKebabMenu({
{renderTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{canOpenProjectSettings ? (
<DropdownMenuItem
testID={`sidebar-project-menu-open-settings-${projectKey}`}
leading={settingsLeadingIcon}
onSelect={handleOpenProjectSettings}
>
Open project settings
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`}
leading={removeProjectLeadingIcon}
+32 -12
View File
@@ -6,7 +6,7 @@ import {
type PropsWithChildren,
type ReactElement,
} from "react";
import { Pressable, Text, View } from "react-native";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import type {
PressableProps,
PressableStateCallbackType,
@@ -104,6 +104,7 @@ export function Button({
style,
textStyle,
disabled,
loading = false,
accessibilityRole,
...props
}: PropsWithChildren<
@@ -113,10 +114,12 @@ export function Button({
leftIcon?: LeftIcon;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
loading?: boolean;
}
>) {
const [hovered, setHovered] = useState(false);
const { theme } = useUnistyles();
const isDisabled = disabled || loading;
let variantStyle: ViewStyle;
if (variant === "default") {
@@ -150,10 +153,10 @@ export function Button({
sizeStyle,
variantStyle,
pressed ? styles.pressed : null,
disabled ? styles.disabled : null,
isDisabled ? styles.disabled : null,
style,
],
[sizeStyle, variantStyle, disabled, style],
[sizeStyle, variantStyle, isDisabled, style],
);
const resolvedTextStyle = useMemo(
@@ -168,7 +171,30 @@ export function Button({
[variant, textStyle, isGhostHovered],
);
const accessibilityState = useMemo(
() => ({ disabled: isDisabled, busy: loading }),
[isDisabled, loading],
);
function resolveIconColor(): string {
if (variant === "default") {
return theme.colors.accentForeground;
}
if (variant === "ghost") {
return isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
}
return theme.colors.foreground;
}
function renderIcon() {
if (loading) {
return (
<View>
<ActivityIndicator size="small" color={resolveIconColor()} />
</View>
);
}
if (!leftIcon) return null;
// Pre-rendered element — pass through
@@ -176,14 +202,7 @@ export function Button({
return <View>{leftIcon}</View>;
}
let color: string;
if (variant === "default") {
color = theme.colors.accentForeground;
} else if (variant === "ghost") {
color = isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
} else {
color = theme.colors.foreground;
}
const color = resolveIconColor();
const iconSize = ICON_SIZE[size];
// Render function
@@ -208,7 +227,8 @@ export function Button({
<Pressable
{...props}
accessibilityRole={accessibilityRole ?? "button"}
disabled={disabled}
accessibilityState={accessibilityState}
disabled={isDisabled}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={pressableStyle}