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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user