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..50765c9 --- /dev/null +++ b/app/components/State/StateSnapshots.tsx @@ -0,0 +1,171 @@ +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 { Tooltip } from "../Tooltip" +import { useState } from "react" +import { useGlobal } from "../../state/useGlobal" +import { sendToCore } from "../../state/connectToServer" +import IRClipboard from "../../native/IRClipboard/NativeIRClipboard" +import type { Snapshot } from "app/types" + +export function StateSnapshots() { + const theme = useTheme() + const [snapshots, setSnapshots] = useGlobal("snapshots", []) + const [activeClientId, _] = useGlobal("activeClientId", "") + const [expandedSnapshotIds, setExpandedSnapshotIds] = useState>(new Set()) + + const deleteSnapshot = (snapshotId: string) => { + setSnapshots((prev) => prev.filter((s) => s.id !== snapshotId)) + } + + const restoreSnapshot = (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, + }) + } + + 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, null, 2)) + console.log("Snapshot copied to clipboard") + } 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..1cf767c --- /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 "app/types" +import { useGlobal } from "app/state/useGlobal" +import { sendToCore } from "app/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..ee634fa 100644 --- a/app/screens/StateScreen.tsx +++ b/app/screens/StateScreen.tsx @@ -2,41 +2,41 @@ 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" + +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] = useGlobal("snapshots", []) - 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 +52,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 +169,15 @@ 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 { + console.log("Copying all snapshots to clipboard", snapshots) + IRClipboard.setString(JSON.stringify(snapshots, null, 2)) + console.log("All snapshots copied to clipboard") + } catch (error) { + console.error("Failed to copy snapshots to clipboard:", error) + } +} const $container = themed(({ spacing }) => ({ padding: spacing.xl, @@ -203,17 +198,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 +221,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 +294,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..3aea45a 100644 --- a/app/state/connectToServer.ts +++ b/app/state/connectToServer.ts @@ -40,6 +40,7 @@ export function connectToServer(props: { port: number } = { port: 9292 }): Unsub const [_customCommands, setCustomCommands] = withGlobal("customCommands", [], { persist: true, }) + const [_snapshots, setSnapshots] = withGlobal("snapshots", []) ws.socket = new WebSocket(`ws://localhost:${props.port}`) if (!ws.socket) throw new Error("Failed to connect to Reactotron server") @@ -188,6 +189,49 @@ 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) { + setSnapshots((prev) => { + // Use the server-provided date to check for duplicates + const serverDate = data.cmd.date + const clientId = data.cmd.clientId + + // 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() === new Date(serverDate).getTime(), + ) + + if (existingSnapshot) { + console.log("Duplicate snapshot detected, skipping:", { serverDate, clientId }) + return prev + } + + // Format the date as "Wednesday @ 5:00:15 PM" + const snapshotDate = new Date(serverDate) + 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 = { + id: `${Date.now()}-${clientId}`, + name: snapshotName, + date: snapshotDate, + state: data.cmd.payload?.state || data.cmd.payload, + clientId: clientId, + } + console.log("Adding snapshot:", newSnapshot) + return [...prev, newSnapshot] + }) + return + } } console.log(data) 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