Files
paseo/packages/app/src/desktop/components/integrations-section.tsx
T
Mohamed Boudra e0e50c9a8e fix(app): preserve persisted theme styles after startup
Module-level style composites could materialize the temporary adaptive theme before persisted settings loaded. Keep Unistyles reads in render and guard against eager module-scope access.
2026-07-18 17:45:02 +02:00

285 lines
9.3 KiB
TypeScript

import { useCallback, useMemo } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowUpRight, Terminal, Blocks, Check } from "lucide-react-native";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url";
import { confirmDialog } from "@/utils/confirm-dialog";
import {
shouldUseDesktopDaemon,
type SkillOp,
type SkillsStatus,
} from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
const OP_KIND_LABEL_KEY: Record<SkillOp["kind"], string> = {
add: "settings.integrations.operations.add",
update: "settings.integrations.operations.update",
delete: "settings.integrations.operations.delete",
};
function formatUpdateMessage(ops: readonly SkillOp[], t: TFunction): string {
const sorted = [...ops].sort((a, b) => {
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
});
return sorted.map((op) => `${t(OP_KIND_LABEL_KEY[op.kind])} ${op.name}`).join("\n");
}
export function IntegrationsSection() {
const { t } = useTranslation();
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const {
status: cliStatus,
isInstalling: isInstallingCli,
install: installCli,
refresh: refreshCliStatus,
} = useCliInstall();
const {
status: skillsStatus,
isWorking: isSkillsWorking,
install: installSkills,
update: updateSkills,
uninstall: uninstallSkills,
refresh: refreshSkillsStatus,
} = useSkillsStatus();
useFocusEffect(
useCallback(() => {
if (!showSection) return undefined;
refreshCliStatus();
void refreshSkillsStatus();
return undefined;
}, [refreshCliStatus, refreshSkillsStatus, showSection]),
);
const handleInstallCli = useCallback(() => {
if (isInstallingCli) return;
installCli();
}, [installCli, isInstallingCli]);
const handleInstallSkills = useCallback(() => {
if (isSkillsWorking) return;
void installSkills();
}, [installSkills, isSkillsWorking]);
const handleUpdateSkills = useCallback(async () => {
if (isSkillsWorking) return;
const ops = skillsStatus?.ops ?? [];
const confirmed = await confirmDialog({
title: t("settings.integrations.skills.updateTitle"),
message:
ops.length > 0
? formatUpdateMessage(ops, t)
: t("settings.integrations.skills.updateFallback"),
confirmLabel: t("settings.integrations.actions.update"),
});
if (!confirmed) return;
await updateSkills();
}, [isSkillsWorking, skillsStatus, t, updateSkills]);
const handleUninstallSkills = useCallback(async () => {
if (isSkillsWorking) return;
const confirmed = await confirmDialog({
title: t("settings.integrations.skills.uninstallTitle"),
message: t("settings.integrations.skills.uninstallMessage"),
confirmLabel: t("settings.integrations.actions.uninstall"),
destructive: true,
});
if (!confirmed) return;
await uninstallSkills();
}, [isSkillsWorking, t, uninstallSkills]);
const handleOpenCliDocs = useCallback(() => {
void openExternalUrl(CLI_DOCS_URL);
}, []);
const handleOpenSkillsDocs = useCallback(() => {
void openExternalUrl(SKILLS_DOCS_URL);
}, []);
const arrowIcon = useMemo(
() => <ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
const trailing = useMemo(
() => (
<View style={styles.headerLinks}>
<Button
variant="ghost"
size="sm"
leftIcon={arrowIcon}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenCliDocs}
accessibilityLabel={t("settings.integrations.docs.openCli")}
>
{t("settings.integrations.docs.cli")}
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={arrowIcon}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenSkillsDocs}
accessibilityLabel={t("settings.integrations.docs.openSkills")}
>
{t("settings.integrations.docs.skills")}
</Button>
</View>
),
[arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs, t],
);
if (!showSection) {
return null;
}
const skillsState = skillsStatus?.state ?? null;
return (
<SettingsSection title={t("settings.integrations.title")} trailing={trailing}>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>
{t("settings.integrations.commandLine.title")}
</Text>
</View>
<Text style={settingsStyles.rowHint}>
{t("settings.integrations.commandLine.description")}
</Text>
</View>
{cliStatus?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
</View>
) : (
<Button
variant="outline"
size="sm"
onPress={handleInstallCli}
disabled={isInstallingCli}
>
{isInstallingCli
? t("settings.integrations.actions.installing")
: t("settings.integrations.actions.install")}
</Button>
)}
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>{t("settings.integrations.skills.title")}</Text>
</View>
<Text style={settingsStyles.rowHint}>
{skillsState === "drift"
? t("settings.integrations.skills.updateAvailable")
: t("settings.integrations.skills.description")}
</Text>
</View>
<SkillsActions
state={skillsState}
isWorking={isSkillsWorking}
onInstall={handleInstallSkills}
onUpdate={handleUpdateSkills}
onUninstall={handleUninstallSkills}
/>
</View>
</View>
</SettingsSection>
);
}
interface SkillsActionsProps {
state: SkillsStatus["state"] | null;
isWorking: boolean;
onInstall: () => void;
onUpdate: () => void;
onUninstall: () => void;
}
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
if (state === "up-to-date") {
return (
<View style={styles.actionsRow}>
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
</View>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
{t("settings.integrations.actions.uninstall")}
</Button>
</View>
);
}
if (state === "drift") {
return (
<View style={styles.actionsRow}>
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
{isWorking
? t("settings.integrations.actions.working")
: t("settings.integrations.actions.update")}
</Button>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
{t("settings.integrations.actions.uninstall")}
</Button>
</View>
);
}
return (
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
{isWorking
? t("settings.integrations.actions.installing")
: t("settings.integrations.actions.install")}
</Button>
);
}
const styles = StyleSheet.create((theme) => ({
headerLinks: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[0],
},
rowTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
installedLabel: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
mutedText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
actionsRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
}));