diff --git a/app/components/Icon.tsx b/app/components/Icon.tsx index 13b5077..43b3bfe 100644 --- a/app/components/Icon.tsx +++ b/app/components/Icon.tsx @@ -66,6 +66,7 @@ export function Icon(props: IconProps) { } export const iconRegistry = { + arrowUpFromLine: require("../../assets/icons/arrowUpFromLine.png"), chevronsLeftRightEllipsis: require("../../assets/icons/chevronsLeftRightEllipsis.png"), circleGauge: require("../../assets/icons/circleGauge.png"), clipboard: require("../../assets/icons/clipboard.png"), @@ -73,6 +74,7 @@ export const iconRegistry = { messageSquare: require("../../assets/icons/messageSquare.png"), panelLeftClose: require("../../assets/icons/panelLeftClose.png"), panelLeftOpen: require("../../assets/icons/panelLeftOpen.png"), + pen: require("../../assets/icons/pen.png"), plug: require("../../assets/icons/plug.png"), questionMark: require("../../assets/icons/questionMark.png"), scrollText: require("../../assets/icons/scrollText.png"), diff --git a/app/components/State/StateSnapshots.tsx b/app/components/State/StateSnapshots.tsx new file mode 100644 index 0000000..674650b --- /dev/null +++ b/app/components/State/StateSnapshots.tsx @@ -0,0 +1,162 @@ +import { Text, ViewStyle, TextStyle, Pressable, View } from "react-native" +import { themed, useTheme, useThemeName } from "../../theme/theme" +import { TreeViewWithProvider } from "../TreeView" +import { Divider } from "../Divider" +import { Icon } from "../Icon" +import { Tooltip } from "../Tooltip" +import { useState } from "react" +import IRClipboard from "../../native/IRClipboard/NativeIRClipboard" +import type { Snapshot } from "../../types" +import { useSnapshots } from "../../state/useSnapshots" + +export function StateSnapshots() { + const theme = useTheme() + const [themeName] = useThemeName() + const { snapshots, deleteSnapshot, restoreSnapshot } = useSnapshots() + const [expandedSnapshotIds, setExpandedSnapshotIds] = useState>(new Set()) + + const iconColor = theme.colors.mainText + + const toggleSnapshotExpanded = (snapshotId: string) => { + setExpandedSnapshotIds((prev) => { + const newSet = new Set(prev) + if (newSet.has(snapshotId)) { + newSet.delete(snapshotId) + } else { + newSet.add(snapshotId) + } + return newSet + }) + } + + if (snapshots.length === 0) { + return ( + + To take a snapshot of your current redux or mobx-state-tree store, press the Create Snapshot + button in the top right corner of this window. + + ) + } + + return ( + <> + {snapshots.map((snapshot) => ( + + + toggleSnapshotExpanded(snapshot.id)} + > + + {snapshot.name} + + { + e.stopPropagation() + copySnapshotToClipboard(snapshot) + }} + > + + + + + { + e.stopPropagation() + restoreSnapshot(snapshot) + }} + > + + + + + { + e.stopPropagation() + deleteSnapshot(snapshot.id) + }} + > + + + + + + {expandedSnapshotIds.has(snapshot.id) && ( + + + + )} + + + + ))} + + ) +} + +function copySnapshotToClipboard(snapshot: Snapshot) { + try { + IRClipboard.setString(JSON.stringify(snapshot.state)) + } catch (error) { + console.error("Failed to copy snapshot to clipboard:", error) + } +} + +const $snapshotCard = themed(({ colors }) => ({ + backgroundColor: colors.cardBackground, + overflow: "hidden", +})) + +const $snapshotHeader = themed(({ spacing, colors }) => ({ + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: spacing.sm, + backgroundColor: colors.cardBackground, + cursor: "pointer", +})) + +const $snapshotInfo = themed(({ spacing }) => ({ + flexDirection: "row", + alignItems: "center", + gap: spacing.md, +})) + +const $snapshotName = themed(({ colors, typography }) => ({ + flex: 1, + fontSize: typography.body, + fontWeight: "600", + color: colors.mainText, + fontFamily: typography.code.normal, +})) + +const $iconButton = themed(({ spacing, colors }) => ({ + padding: spacing.xs, + borderRadius: 4, + cursor: "pointer", + backgroundColor: colors.neutralVery, +})) + +const $snapshotContent = themed(({ spacing, colors }) => ({ + padding: spacing.md, + backgroundColor: colors.cardBackground, +})) + +const $emptyStateText = themed(({ colors, typography }) => ({ + fontSize: typography.body, + fontWeight: "400", + color: colors.mainText, +})) diff --git a/app/components/State/StateSubscriptions.tsx b/app/components/State/StateSubscriptions.tsx new file mode 100644 index 0000000..1627669 --- /dev/null +++ b/app/components/State/StateSubscriptions.tsx @@ -0,0 +1,82 @@ +import { Text, ViewStyle, TextStyle, Pressable, View } from "react-native" +import { themed, useTheme } from "../../theme/theme" +import { TreeViewWithProvider } from "../TreeView" +import { Divider } from "../Divider" +import { Icon } from "../Icon" +import type { StateSubscription } from "../../types" +import { useGlobal } from "../../state/useGlobal" +import { sendToCore } from "../../state/connectToServer" + +export function StateSubscriptions() { + const theme = useTheme() + const [stateSubscriptionsByClientId, setStateSubscriptionsByClientId] = useGlobal<{ + [clientId: string]: StateSubscription[] + }>("stateSubscriptionsByClientId", {}) + const [activeClientId, _] = useGlobal("activeClientId", "") + const clientStateSubscriptions = stateSubscriptionsByClientId[activeClientId] || [] + + const removeSubscription = (path: string) => { + const newStateSubscriptions = clientStateSubscriptions.filter((s) => s.path !== path) + sendToCore("state.values.subscribe", { + paths: newStateSubscriptions.map((s) => s.path), + clientId: activeClientId, + }) + setStateSubscriptionsByClientId((prev) => ({ + ...prev, + [activeClientId]: newStateSubscriptions, + })) + } + + if (clientStateSubscriptions.length === 0) { + return State is empty + } + + return ( + <> + {clientStateSubscriptions.map((subscription, index) => ( + + {subscription.path ? subscription.path : "Full State"} + + + + + removeSubscription(subscription.path)}> + + + + {index < clientStateSubscriptions.length - 1 && } + + ))} + + ) +} + +const $pathText = themed(({ colors, typography, spacing }) => ({ + fontSize: typography.body, + fontWeight: "400", + color: colors.mainText, + marginBottom: spacing.sm, +})) + +const $stateItemContainer = themed(({ spacing }) => ({ + marginTop: spacing.xl, +})) + +const $treeViewInnerContainer = themed(() => ({ + flex: 1, +})) + +const $treeViewContainer = themed(() => ({ + flexDirection: "row", + justifyContent: "space-between", +})) + +const $stateDivider = themed(({ spacing }) => ({ + marginTop: spacing.lg, +})) + +const $emptyStateText = themed(({ colors, typography }) => ({ + fontSize: typography.body, + fontWeight: "400", + color: colors.mainText, +})) diff --git a/app/screens/StateScreen.tsx b/app/screens/StateScreen.tsx index 9786299..cfcfae5 100644 --- a/app/screens/StateScreen.tsx +++ b/app/screens/StateScreen.tsx @@ -2,41 +2,42 @@ import { Text, ViewStyle, ScrollView, TextStyle, Pressable, View, TextInput } fr import { themed } from "../theme/theme" import { sendToCore } from "../state/connectToServer" import { useGlobal } from "../state/useGlobal" -import { TreeViewWithProvider } from "../components/TreeView" import { useState } from "react" import { Divider } from "../components/Divider" import { useKeyboardEvents } from "../utils/system" -import type { StateSubscription } from "app/types" -import { Icon } from "../components/Icon" +import type { StateSubscription, Snapshot } from "app/types" +import { Tab } from "../components/Tab" +import { StateSubscriptions } from "../components/State/StateSubscriptions" +import { StateSnapshots } from "../components/State/StateSnapshots" +import IRClipboard from "../native/IRClipboard/NativeIRClipboard" +import { useSnapshots } from "../state/useSnapshots" + +type StateTab = "Subscriptions" | "Snapshots" export function StateScreen() { const [showAddSubscription, setShowAddSubscription] = useState(false) + const [activeStateTab] = useGlobal("activeStateTab", "Subscriptions") const [stateSubscriptionsByClientId, setStateSubscriptionsByClientId] = useGlobal<{ [clientId: string]: StateSubscription[] }>("stateSubscriptionsByClientId", {}) - const [activeTab, setActiveTab] = useGlobal("activeClientId", "") + const [activeClientId, setActiveClient] = useGlobal("activeClientId", "") + const { snapshots } = useSnapshots() - const clientStateSubscriptions = stateSubscriptionsByClientId[activeTab] || [] + const clientStateSubscriptions = stateSubscriptionsByClientId[activeClientId] || [] const saveSubscription = (path: string) => { if (clientStateSubscriptions.some((s) => s.path === path)) return sendToCore("state.values.subscribe", { paths: [...clientStateSubscriptions.map((s) => s.path), path], - clientId: activeTab, + clientId: activeClientId, }) } - const removeSubscription = (path: string) => { - const newStateSubscriptions = clientStateSubscriptions.filter((s) => s.path !== path) - sendToCore("state.values.subscribe", { - paths: newStateSubscriptions.map((s) => s.path), - clientId: activeTab, - }) - setStateSubscriptionsByClientId((prev) => ({ - ...prev, - [activeTab]: newStateSubscriptions, - })) + const createSnapshot = () => { + if (!activeClientId) return + + sendToCore("state.backup.request", { clientId: activeClientId }) } if (showAddSubscription) { @@ -52,51 +53,43 @@ export function StateScreen() { State - - setShowAddSubscription(true)}> - Add Subscription - - { - setStateSubscriptionsByClientId((prev) => ({ - ...prev, - [activeTab]: [], - })) - sendToCore("state.values.subscribe", { paths: [], clientId: activeTab }) - setActiveTab("") - }} - > - Clear State - - - - - {clientStateSubscriptions.length > 0 ? ( - <> - {clientStateSubscriptions.map((subscription, index) => ( - - - {subscription.path ? subscription.path : "Full State"} - - - - - - removeSubscription(subscription.path)}> - - - - {index < clientStateSubscriptions.length - 1 && ( - - )} - - ))} - + {activeStateTab === "Subscriptions" ? ( + + setShowAddSubscription(true)}> + Add Subscription + + { + setStateSubscriptionsByClientId((prev) => ({ + ...prev, + [activeClientId]: [], + })) + sendToCore("state.values.subscribe", { paths: [], clientId: activeClientId }) + setActiveClient("") + }} + > + Clear State + + ) : ( - State is empty + + copyAllSnapshotsToClipboard(snapshots)}> + Copy All + + + Create Snapshot + + )} + + + + + + {activeStateTab === "Subscriptions" ? : } + ) } @@ -177,12 +170,13 @@ function AddSubscription({ ) } -const $pathText = themed(({ colors, typography, spacing }) => ({ - fontSize: typography.body, - fontWeight: "400", - color: colors.mainText, - marginBottom: spacing.sm, -})) +function copyAllSnapshotsToClipboard(snapshots: Snapshot[]): void { + try { + IRClipboard.setString(JSON.stringify(snapshots)) + } catch (error) { + console.error("Failed to copy snapshots to clipboard:", error) + } +} const $container = themed(({ spacing }) => ({ padding: spacing.xl, @@ -203,17 +197,10 @@ const $title = themed(({ colors, spacing, typography }) => ({ marginTop: spacing.xl, })) -const $stateItemContainer = themed(({ spacing }) => ({ - marginTop: spacing.xl, -})) - -const $treeViewInnerContainer = themed(() => ({ - flex: 1, -})) - -const $treeViewContainer = themed(() => ({ +const $tabsContainer = themed(({ spacing }) => ({ flexDirection: "row", - justifyContent: "space-between", + marginTop: spacing.lg, + marginBottom: spacing.md, })) const $stateContainer = themed(({ spacing }) => ({ @@ -233,6 +220,10 @@ const $button = themed(({ colors, spacing }) => ({ cursor: "pointer", })) +const $buttonText = themed(({ colors }) => ({ + color: colors.mainText, +})) + const $addSubscriptionOuterContainer = themed(({ spacing }) => ({ flex: 1, padding: spacing.xl, @@ -302,7 +293,3 @@ const $subscriptionButton = themed(({ colors, spacing }) => ({ borderRadius: 8, cursor: "pointer", })) - -const $stateDivider = themed(({ spacing }) => ({ - marginTop: spacing.lg, -})) diff --git a/app/state/connectToServer.ts b/app/state/connectToServer.ts index e67bf04..cb6a2c1 100644 --- a/app/state/connectToServer.ts +++ b/app/state/connectToServer.ts @@ -3,6 +3,7 @@ import { deleteGlobal, withGlobal } from "./useGlobal" import { CommandType } from "reactotron-core-contract" import type { StateSubscription, TimelineItem, CustomCommand } from "../types" import { isSafeKey, sanitizeValue } from "../utils/sanitize" +import { withSnapshots } from "./useSnapshots" type UnsubscribeFn = () => void type SendToClientFn = (message: string | object, payload?: object, clientId?: string) => void @@ -40,6 +41,7 @@ export function connectToServer(props: { port: number } = { port: 9292 }): Unsub const [_customCommands, setCustomCommands] = withGlobal("customCommands", [], { persist: true, }) + const { addSnapshot } = withSnapshots() ws.socket = new WebSocket(`ws://localhost:${props.port}`) if (!ws.socket) throw new Error("Failed to connect to Reactotron server") @@ -188,6 +190,16 @@ export function connectToServer(props: { port: number } = { port: 9292 }): Unsub setCustomCommands((prev) => prev.filter((cmd) => cmd.id !== commandId)) return } + + // Handle state backup response + if (data.cmd.type === CommandType.StateBackupResponse) { + addSnapshot({ + date: data.cmd.date, + clientId: data.cmd.clientId, + state: data.cmd.payload?.state || data.cmd.payload, + }) + return + } } console.log(data) diff --git a/app/state/useSnapshots.ts b/app/state/useSnapshots.ts new file mode 100644 index 0000000..480c42b --- /dev/null +++ b/app/state/useSnapshots.ts @@ -0,0 +1,92 @@ +import { Snapshot } from "app/types" +import { useGlobal, withGlobal } from "./useGlobal" +import { sendToCore } from "./connectToServer" + +type SnapshotSetter = (value: Snapshot[] | ((prev: Snapshot[]) => Snapshot[])) => void + +function buildSnapshotHelpers(setSnapshots: SnapshotSetter) { + const addSnapshot = ({ + date, + clientId, + state, + }: { + date: Date | string + clientId: string + state: any + }) => { + setSnapshots((prev) => { + // Use the server-provided date to check for duplicates + const snapshotDate = new Date(date) + + // Check if we already have a snapshot with the same server date and clientId + const existingSnapshot = prev.find( + (s) => s.clientId === clientId && new Date(s.date).getTime() === snapshotDate.getTime(), + ) + + if (existingSnapshot) { + return prev + } + + // Format the date as "Wednesday @ 5:00:15 PM" + const dayName = snapshotDate.toLocaleDateString("en-US", { weekday: "long" }) + const timeString = snapshotDate.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: true, + }) + const snapshotName = `${dayName} @ ${timeString}` + + const newSnapshot: Snapshot = { + id: `${Date.now()}-${clientId}`, + name: snapshotName, + date: snapshotDate, + state: state, + clientId: clientId, + } + + return [...prev, newSnapshot] + }) + } + + const deleteSnapshot = (snapshotId: string) => { + setSnapshots((prev) => prev.filter((s) => s.id !== snapshotId)) + } + + return { addSnapshot, deleteSnapshot } +} + +function buildRestoreSnapshot(activeClientId: string) { + return (snapshot: Snapshot) => { + if (!snapshot || !snapshot.state) return + + // Use the snapshot's clientId if available, otherwise fall back to the active client + const targetClientId = snapshot.clientId || activeClientId + + if (!targetClientId) return + + // Send the restore command to the client + sendToCore("state.restore.request", { + clientId: targetClientId, + state: snapshot.state, + }) + } +} + +export function useSnapshots() { + const [snapshots, setSnapshots] = useGlobal("snapshots", []) + const [activeClientId] = useGlobal("activeClientId", "") + const { addSnapshot, deleteSnapshot } = buildSnapshotHelpers(setSnapshots) + const restoreSnapshot = buildRestoreSnapshot(activeClientId) + + return { snapshots, setSnapshots, addSnapshot, deleteSnapshot, restoreSnapshot } +} + +export function withSnapshots() { + const [snapshots, setSnapshots] = withGlobal("snapshots", []) + const [activeClientId] = withGlobal("activeClientId", "") + const { addSnapshot, deleteSnapshot } = buildSnapshotHelpers(setSnapshots) + const restoreSnapshot = buildRestoreSnapshot(activeClientId) + + return { snapshots, setSnapshots, addSnapshot, deleteSnapshot, restoreSnapshot } +} diff --git a/app/types.ts b/app/types.ts index c1c867d..7d2cdc8 100644 --- a/app/types.ts +++ b/app/types.ts @@ -174,3 +174,12 @@ export type CustomCommand = { }> clientId?: string } + +// Snapshot represents a captured state snapshot that can be saved, restored, or exported +export type Snapshot = { + id: string + name: string + date: Date + state: Record // The actual state data (must be JSON-serializable) + clientId?: string +} diff --git a/assets/icons/arrowUpFromLine.png b/assets/icons/arrowUpFromLine.png new file mode 100644 index 0000000..c0f265f Binary files /dev/null and b/assets/icons/arrowUpFromLine.png differ diff --git a/assets/icons/arrowUpFromLine@2x.png b/assets/icons/arrowUpFromLine@2x.png new file mode 100644 index 0000000..a5d864b Binary files /dev/null and b/assets/icons/arrowUpFromLine@2x.png differ diff --git a/assets/icons/arrowUpFromLine@3x.png b/assets/icons/arrowUpFromLine@3x.png new file mode 100644 index 0000000..16adefb Binary files /dev/null and b/assets/icons/arrowUpFromLine@3x.png differ diff --git a/assets/icons/pen.png b/assets/icons/pen.png new file mode 100644 index 0000000..510b366 Binary files /dev/null and b/assets/icons/pen.png differ diff --git a/assets/icons/pen@2x.png b/assets/icons/pen@2x.png new file mode 100644 index 0000000..4551d26 Binary files /dev/null and b/assets/icons/pen@2x.png differ diff --git a/assets/icons/pen@3x.png b/assets/icons/pen@3x.png new file mode 100644 index 0000000..91bb9b0 Binary files /dev/null and b/assets/icons/pen@3x.png differ