Keep sheet text inputs native-owned to avoid React Native controlled input flicker during fast typing. Add reset keys for fields that need programmatic clears, and move model, mode, and thinking pickers onto shared combobox rows with fuzzy ranked search and native virtualization.
517 lines
17 KiB
TypeScript
517 lines
17 KiB
TypeScript
import React, { type ReactElement, useCallback, useMemo, useState } from "react";
|
|
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
|
import * as Clipboard from "expo-clipboard";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { settingsStyles } from "@/styles/settings";
|
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
|
import { ArrowUpRight, Copy, FileText, Activity } from "lucide-react-native";
|
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { openExternalUrl } from "@/utils/open-external-url";
|
|
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
|
import { getCliDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
|
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
|
|
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
|
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
|
|
import { resolveAppVersion } from "@/utils/app-version";
|
|
|
|
type DesktopDaemonSettings = DesktopSettings["daemon"];
|
|
|
|
function useKeepRunningAfterQuitToggle(args: {
|
|
settings: DesktopDaemonSettings;
|
|
updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>;
|
|
}) {
|
|
const { settings, updateSettings } = args;
|
|
const [isUpdatingKeepRunningAfterQuit, setIsUpdatingKeepRunningAfterQuit] = useState(false);
|
|
|
|
const handleToggleKeepRunningAfterQuit = useCallback(() => {
|
|
setIsUpdatingKeepRunningAfterQuit(true);
|
|
void updateSettings({ keepRunningAfterQuit: !settings.keepRunningAfterQuit })
|
|
.catch(() => {
|
|
// useDesktopSettings owns the user-visible IPC error.
|
|
})
|
|
.finally(() => {
|
|
setIsUpdatingKeepRunningAfterQuit(false);
|
|
});
|
|
}, [settings.keepRunningAfterQuit, updateSettings]);
|
|
|
|
return { isUpdatingKeepRunningAfterQuit, handleToggleKeepRunningAfterQuit };
|
|
}
|
|
|
|
function useDaemonCliStatusModal() {
|
|
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
|
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
|
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
|
|
|
|
const handleOpenCliStatus = useCallback(async () => {
|
|
setIsLoadingCliStatus(true);
|
|
try {
|
|
setCliStatusOutput(await getCliDaemonStatus());
|
|
setIsCliStatusModalOpen(true);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
setCliStatusOutput(`Failed to fetch daemon status: ${message}`);
|
|
setIsCliStatusModalOpen(true);
|
|
} finally {
|
|
setIsLoadingCliStatus(false);
|
|
}
|
|
}, []);
|
|
|
|
const handleCopyCliStatus = useCallback(() => {
|
|
if (!cliStatusOutput) {
|
|
return;
|
|
}
|
|
void Clipboard.setStringAsync(cliStatusOutput)
|
|
.then(() => {
|
|
Alert.alert("Copied", "Status copied to clipboard.");
|
|
return;
|
|
})
|
|
.catch((error) => {
|
|
console.error("[Settings] Failed to copy daemon status", error);
|
|
});
|
|
}, [cliStatusOutput]);
|
|
|
|
const handleCloseCliStatusModal = useCallback(() => setIsCliStatusModalOpen(false), []);
|
|
|
|
return {
|
|
cliStatusOutput,
|
|
isCliStatusModalOpen,
|
|
isLoadingCliStatus,
|
|
handleCopyCliStatus,
|
|
handleOpenCliStatus,
|
|
handleCloseCliStatusModal,
|
|
};
|
|
}
|
|
|
|
function useDaemonLogsModal(daemonLogs: { logPath?: string } | null) {
|
|
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
|
|
|
const handleCopyLogPath = useCallback(() => {
|
|
const logPath = daemonLogs?.logPath;
|
|
if (!logPath) {
|
|
return;
|
|
}
|
|
|
|
void Clipboard.setStringAsync(logPath)
|
|
.then(() => {
|
|
Alert.alert("Copied", "Log path copied.");
|
|
return;
|
|
})
|
|
.catch((error) => {
|
|
console.error("[Settings] Failed to copy log path", error);
|
|
Alert.alert("Error", "Unable to copy log path.");
|
|
});
|
|
}, [daemonLogs?.logPath]);
|
|
|
|
const handleOpenLogs = useCallback(() => {
|
|
if (!daemonLogs) {
|
|
return;
|
|
}
|
|
setIsLogsModalOpen(true);
|
|
}, [daemonLogs]);
|
|
|
|
const handleCloseLogsModal = useCallback(() => setIsLogsModalOpen(false), []);
|
|
|
|
return { isLogsModalOpen, handleCopyLogPath, handleOpenLogs, handleCloseLogsModal };
|
|
}
|
|
|
|
interface DaemonLogsModalProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
daemonLogs: { logPath?: string; contents?: string } | null;
|
|
}
|
|
|
|
function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps) {
|
|
return (
|
|
<AdaptiveModalSheet
|
|
visible={visible}
|
|
onClose={onClose}
|
|
header={DAEMON_LOGS_HEADER}
|
|
testID="managed-daemon-logs-dialog"
|
|
snapPoints={LOGS_MODAL_SNAP_POINTS}
|
|
>
|
|
<View style={styles.modalBody}>
|
|
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable"}</Text>
|
|
<Text style={styles.logOutput} selectable>
|
|
{daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"}
|
|
</Text>
|
|
</View>
|
|
</AdaptiveModalSheet>
|
|
);
|
|
}
|
|
|
|
interface DaemonCliStatusModalProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
cliStatusOutput: string | null;
|
|
onCopy: () => void;
|
|
}
|
|
|
|
function DaemonCliStatusModal({
|
|
visible,
|
|
onClose,
|
|
cliStatusOutput,
|
|
onCopy,
|
|
}: DaemonCliStatusModalProps) {
|
|
return (
|
|
<AdaptiveModalSheet
|
|
visible={visible}
|
|
onClose={onClose}
|
|
header={DAEMON_STATUS_HEADER}
|
|
testID="daemon-cli-status-dialog"
|
|
snapPoints={CLI_STATUS_MODAL_SNAP_POINTS}
|
|
>
|
|
<View style={styles.modalBody}>
|
|
<Text style={styles.logOutput} selectable>
|
|
{cliStatusOutput ?? ""}
|
|
</Text>
|
|
<View style={styles.modalActions}>
|
|
<Button variant="outline" size="sm" onPress={onClose}>
|
|
Close
|
|
</Button>
|
|
<Button size="sm" onPress={onCopy}>
|
|
Copy
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
</AdaptiveModalSheet>
|
|
);
|
|
}
|
|
|
|
interface DaemonInfoCardProps {
|
|
daemonStatusStateText: string;
|
|
daemonStatusDetailText: string;
|
|
isDaemonManagementPaused: boolean;
|
|
copyIcon: ReactElement;
|
|
fileTextIcon: ReactElement;
|
|
activityIcon: ReactElement;
|
|
handleToggleDaemonManagement: () => void;
|
|
isUpdatingDaemonManagement: boolean;
|
|
keepRunningAfterQuit: boolean;
|
|
handleToggleKeepRunningAfterQuit: () => void;
|
|
isUpdatingKeepRunningAfterQuit: boolean;
|
|
daemonLogs: { logPath?: string } | null;
|
|
handleCopyLogPath: () => void;
|
|
handleOpenLogs: () => void;
|
|
handleRunCliStatus: () => void;
|
|
isLoadingCliStatus: boolean;
|
|
}
|
|
|
|
function DaemonInfoCard(props: DaemonInfoCardProps) {
|
|
const {
|
|
daemonStatusStateText,
|
|
daemonStatusDetailText,
|
|
isDaemonManagementPaused,
|
|
copyIcon,
|
|
fileTextIcon,
|
|
activityIcon,
|
|
handleToggleDaemonManagement,
|
|
isUpdatingDaemonManagement,
|
|
keepRunningAfterQuit,
|
|
handleToggleKeepRunningAfterQuit,
|
|
isUpdatingKeepRunningAfterQuit,
|
|
daemonLogs,
|
|
handleCopyLogPath,
|
|
handleOpenLogs,
|
|
handleRunCliStatus,
|
|
isLoadingCliStatus,
|
|
} = props;
|
|
|
|
return (
|
|
<View style={settingsStyles.card}>
|
|
<View style={settingsStyles.row}>
|
|
<View style={settingsStyles.rowContent}>
|
|
<Text style={settingsStyles.rowTitle}>Status</Text>
|
|
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here</Text>
|
|
</View>
|
|
<View style={styles.statusValueGroup}>
|
|
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
|
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
|
|
</View>
|
|
</View>
|
|
<View style={ROW_WITH_BORDER_STYLE}>
|
|
<View style={settingsStyles.rowContent}>
|
|
<Text style={settingsStyles.rowTitle}>Manage built-in daemon</Text>
|
|
<Text style={settingsStyles.rowHint}>Let Paseo start and stop the built-in daemon</Text>
|
|
</View>
|
|
<Switch
|
|
value={!isDaemonManagementPaused}
|
|
onValueChange={handleToggleDaemonManagement}
|
|
disabled={isUpdatingDaemonManagement}
|
|
accessibilityLabel="Manage built-in daemon"
|
|
/>
|
|
</View>
|
|
<View style={ROW_WITH_BORDER_STYLE}>
|
|
<View style={settingsStyles.rowContent}>
|
|
<Text style={settingsStyles.rowTitle}>Keep daemon running after quit</Text>
|
|
<Text style={settingsStyles.rowHint}>Daemon keeps running when you quit Paseo</Text>
|
|
</View>
|
|
<Switch
|
|
value={keepRunningAfterQuit}
|
|
onValueChange={handleToggleKeepRunningAfterQuit}
|
|
disabled={isUpdatingKeepRunningAfterQuit}
|
|
accessibilityLabel="Keep daemon running after quit"
|
|
/>
|
|
</View>
|
|
<View style={ROW_WITH_BORDER_STYLE}>
|
|
<View style={settingsStyles.rowContent}>
|
|
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
|
<Text style={settingsStyles.rowHint}>
|
|
{daemonLogs?.logPath ?? "Log path unavailable"}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.actionGroup}>
|
|
{daemonLogs?.logPath ? (
|
|
<Button variant="outline" size="sm" leftIcon={copyIcon} onPress={handleCopyLogPath}>
|
|
Copy path
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
leftIcon={fileTextIcon}
|
|
onPress={handleOpenLogs}
|
|
disabled={!daemonLogs}
|
|
>
|
|
Open logs
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
<View style={ROW_WITH_BORDER_STYLE}>
|
|
<View style={settingsStyles.rowContent}>
|
|
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
|
<Text style={settingsStyles.rowHint}>
|
|
Runs `paseo daemon status` and shows the output
|
|
</Text>
|
|
</View>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
leftIcon={activityIcon}
|
|
onPress={handleRunCliStatus}
|
|
disabled={isLoadingCliStatus}
|
|
>
|
|
{isLoadingCliStatus ? "Loading..." : "View status"}
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function LocalDaemonSection() {
|
|
const { theme } = useUnistyles();
|
|
const showSection = shouldUseDesktopDaemon();
|
|
const appVersion = resolveAppVersion();
|
|
const { settings, updateSettings, isLoading: isLoadingSettings } = useDesktopSettings();
|
|
const daemonSettings = settings.daemon;
|
|
const updateDaemonSettings = useCallback(
|
|
(updates: Partial<DesktopDaemonSettings>) => updateSettings({ daemon: updates }),
|
|
[updateSettings],
|
|
);
|
|
const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus();
|
|
|
|
const daemonStatus = data?.status ?? null;
|
|
const daemonLogs = data?.logs ?? null;
|
|
const daemonVersion = daemonStatus?.version ?? null;
|
|
|
|
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
|
|
const daemonStatusStateText =
|
|
statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running");
|
|
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`;
|
|
const isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon;
|
|
|
|
const { isUpdating: isUpdatingDaemonManagement, toggle: handleToggleDaemonManagement } =
|
|
useBuiltInDaemonManagement({
|
|
daemonStatus,
|
|
settings: daemonSettings,
|
|
updateSettings: updateDaemonSettings,
|
|
setStatus,
|
|
refreshStatus: refetch,
|
|
});
|
|
const { isUpdatingKeepRunningAfterQuit, handleToggleKeepRunningAfterQuit } =
|
|
useKeepRunningAfterQuitToggle({
|
|
settings: daemonSettings,
|
|
updateSettings: updateDaemonSettings,
|
|
});
|
|
|
|
const { isLogsModalOpen, handleCopyLogPath, handleOpenLogs, handleCloseLogsModal } =
|
|
useDaemonLogsModal(daemonLogs);
|
|
|
|
const {
|
|
cliStatusOutput,
|
|
isCliStatusModalOpen,
|
|
isLoadingCliStatus,
|
|
handleCopyCliStatus,
|
|
handleOpenCliStatus,
|
|
handleCloseCliStatusModal,
|
|
} = useDaemonCliStatusModal();
|
|
const handleRunCliStatus = useCallback(() => {
|
|
void handleOpenCliStatus();
|
|
}, [handleOpenCliStatus]);
|
|
|
|
const handleOpenAdvancedSettings = useCallback(
|
|
() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL),
|
|
[],
|
|
);
|
|
|
|
const advancedSettingsIcon = useMemo(
|
|
() => <ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
|
[theme.iconSize.sm, theme.colors.foregroundMuted],
|
|
);
|
|
const copyIcon = useMemo(
|
|
() => <Copy size={theme.iconSize.sm} color={theme.colors.foreground} />,
|
|
[theme.iconSize.sm, theme.colors.foreground],
|
|
);
|
|
const fileTextIcon = useMemo(
|
|
() => <FileText size={theme.iconSize.sm} color={theme.colors.foreground} />,
|
|
[theme.iconSize.sm, theme.colors.foreground],
|
|
);
|
|
const activityIcon = useMemo(
|
|
() => <Activity size={theme.iconSize.sm} color={theme.colors.foreground} />,
|
|
[theme.iconSize.sm, theme.colors.foreground],
|
|
);
|
|
|
|
const advancedSettingsButton = useMemo(
|
|
() => (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
leftIcon={advancedSettingsIcon}
|
|
textStyle={settingsStyles.sectionHeaderLinkText}
|
|
style={settingsStyles.sectionHeaderLink}
|
|
onPress={handleOpenAdvancedSettings}
|
|
accessibilityLabel="Open advanced daemon settings"
|
|
>
|
|
Advanced settings
|
|
</Button>
|
|
),
|
|
[advancedSettingsIcon, handleOpenAdvancedSettings],
|
|
);
|
|
|
|
if (!showSection) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<SettingsSection
|
|
title="Daemon"
|
|
trailing={advancedSettingsButton}
|
|
testID="host-page-daemon-lifecycle-card"
|
|
>
|
|
{isLoading || isLoadingSettings ? (
|
|
<View style={LOADING_CARD_STYLE}>
|
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
</View>
|
|
) : (
|
|
<>
|
|
<DaemonInfoCard
|
|
daemonStatusStateText={daemonStatusStateText}
|
|
daemonStatusDetailText={daemonStatusDetailText}
|
|
isDaemonManagementPaused={isDaemonManagementPaused}
|
|
copyIcon={copyIcon}
|
|
fileTextIcon={fileTextIcon}
|
|
activityIcon={activityIcon}
|
|
handleToggleDaemonManagement={handleToggleDaemonManagement}
|
|
isUpdatingDaemonManagement={isUpdatingDaemonManagement}
|
|
keepRunningAfterQuit={daemonSettings.keepRunningAfterQuit}
|
|
handleToggleKeepRunningAfterQuit={handleToggleKeepRunningAfterQuit}
|
|
isUpdatingKeepRunningAfterQuit={isUpdatingKeepRunningAfterQuit}
|
|
daemonLogs={daemonLogs}
|
|
handleCopyLogPath={handleCopyLogPath}
|
|
handleOpenLogs={handleOpenLogs}
|
|
handleRunCliStatus={handleRunCliStatus}
|
|
isLoadingCliStatus={isLoadingCliStatus}
|
|
/>
|
|
|
|
{daemonVersionMismatch ? (
|
|
<View style={styles.warningCard}>
|
|
<Text style={styles.warningText}>
|
|
{
|
|
"App and daemon versions don't match. Update both to the same version for the best experience."
|
|
}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
</>
|
|
)}
|
|
|
|
<DaemonLogsModal
|
|
visible={isLogsModalOpen}
|
|
onClose={handleCloseLogsModal}
|
|
daemonLogs={daemonLogs}
|
|
/>
|
|
|
|
<DaemonCliStatusModal
|
|
visible={isCliStatusModalOpen}
|
|
onClose={handleCloseCliStatusModal}
|
|
cliStatusOutput={cliStatusOutput}
|
|
onCopy={handleCopyCliStatus}
|
|
/>
|
|
</SettingsSection>
|
|
);
|
|
}
|
|
|
|
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
actionGroup: {
|
|
flexDirection: "row",
|
|
gap: theme.spacing[2],
|
|
flexWrap: "wrap",
|
|
justifyContent: "flex-end",
|
|
},
|
|
loadingCard: {
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
paddingVertical: theme.spacing[6],
|
|
},
|
|
statusValueGroup: {
|
|
alignItems: "flex-end",
|
|
gap: 2,
|
|
},
|
|
valueText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
valueSubtext: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
warningCard: {
|
|
marginTop: theme.spacing[3],
|
|
borderRadius: theme.borderRadius.lg,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.palette.amber[500],
|
|
backgroundColor: "rgba(245, 158, 11, 0.12)",
|
|
paddingVertical: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
},
|
|
warningText: {
|
|
color: theme.colors.palette.amber[500],
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
modalBody: {
|
|
gap: theme.spacing[3],
|
|
paddingBottom: theme.spacing[2],
|
|
},
|
|
logOutput: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
|
lineHeight: 18,
|
|
},
|
|
modalActions: {
|
|
flexDirection: "row",
|
|
justifyContent: "flex-end",
|
|
gap: theme.spacing[2],
|
|
},
|
|
}));
|
|
|
|
const LOADING_CARD_STYLE = [settingsStyles.card, styles.loadingCard];
|
|
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
|
const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"];
|
|
const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"];
|
|
const DAEMON_LOGS_HEADER: SheetHeader = { title: "Daemon logs" };
|
|
const DAEMON_STATUS_HEADER: SheetHeader = { title: "Daemon status" };
|