feat: implement device management UI
Adds device list panel with 1s real-time polling, device cards showing connection type/Android version/battery, wireless connect dialog, and DeviceInfoPanel with static fields fetched once and live fields (RAM, battery, thermal, storage, IP) pulsed every second. Introduces the selectedDeviceId vs profiledDeviceId split in Zustand store, Profile toggle per card, and auto-clear on device disconnect. All non-device feature panels gate on useActiveDevice and render NoDeviceSelected when no device is being profiled.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
Generated
+3719
-69
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -17,7 +19,9 @@
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"shadcn": "^4.7.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
24a0431a2cfc303dfc93d3a01c2b5e52
|
||||
@@ -1,17 +1,50 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
import { Sidebar } from '@/components/layout/Sidebar'
|
||||
import { NoDeviceSelected } from '@/components/layout/NoDeviceSelected'
|
||||
import { DevicePanel } from '@/features/devices/components/DevicePanel'
|
||||
import { useActiveDevice } from '@/hooks/use-active-device'
|
||||
|
||||
function App() {
|
||||
function FeaturePlaceholder({ label }: { label: string }) {
|
||||
const { isMonitoring } = useActiveDevice()
|
||||
if (!isMonitoring) return <NoDeviceSelected />
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
{label} — coming soon
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-full dark">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-hidden">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<div className="p-8 text-white">DroidScope</div>} />
|
||||
</Routes>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DevicePanel />} />
|
||||
<Route path="/logcat" element={<FeaturePlaceholder label="Logcat" />} />
|
||||
<Route path="/network" element={<FeaturePlaceholder label="Network Inspector" />} />
|
||||
<Route path="/storage" element={<FeaturePlaceholder label="Storage" />} />
|
||||
<Route path="/files" element={<FeaturePlaceholder label="File Browser" />} />
|
||||
<Route path="/runtime" element={<FeaturePlaceholder label="Runtime Config" />} />
|
||||
<Route path="/settings" element={
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Settings — coming soon
|
||||
</div>
|
||||
} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Radio } from 'lucide-react'
|
||||
|
||||
export function NoDeviceSelected() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
|
||||
<Radio size={40} strokeWidth={1.25} />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">No device being profiled</p>
|
||||
<p className="text-xs mt-1">
|
||||
Go to Devices, select a device, and press <span className="text-foreground font-medium">Profile</span> to start monitoring.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
MonitorSmartphone,
|
||||
ScrollText,
|
||||
Network,
|
||||
Database,
|
||||
FolderOpen,
|
||||
Sliders,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: MonitorSmartphone, label: 'Devices' },
|
||||
{ to: '/logcat', icon: ScrollText, label: 'Logcat' },
|
||||
{ to: '/network', icon: Network, label: 'Network' },
|
||||
{ to: '/storage', icon: Database, label: 'Storage' },
|
||||
{ to: '/files', icon: FolderOpen, label: 'Files' },
|
||||
{ to: '/runtime', icon: Sliders, label: 'Runtime' },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="w-14 flex flex-col items-center py-3 gap-1 border-r border-border bg-card shrink-0">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
title={label}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-accent',
|
||||
isActive && 'text-foreground bg-accent',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
<NavLink
|
||||
to="/settings"
|
||||
title="Settings"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-accent mt-auto',
|
||||
isActive && 'text-foreground bg-accent',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Settings size={18} />
|
||||
</NavLink>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react'
|
||||
import { Wifi } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface Props {
|
||||
onConnect: (address: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function ConnectDialog({ onConnect }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [address, setAddress] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleConnect() {
|
||||
if (!address.trim()) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await onConnect(address.trim())
|
||||
setOpen(false)
|
||||
setAddress('')
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => setOpen(true)}>
|
||||
<Wifi size={14} />
|
||||
Connect wireless
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect wireless device</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 pt-2">
|
||||
<Input
|
||||
placeholder="192.168.1.100:5555"
|
||||
value={address}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAddress(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => e.key === 'Enter' && handleConnect()}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleConnect} disabled={loading || !address.trim()}>
|
||||
{loading ? 'Connecting…' : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Battery, Cpu, MonitorSmartphone, Wifi, Usb, Radio } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Device } from '../hooks/use-devices'
|
||||
|
||||
interface Props {
|
||||
device: Device
|
||||
isSelected: boolean
|
||||
isProfiled: boolean
|
||||
onSelect: (id: string) => void
|
||||
onProfile: (id: string | null) => void
|
||||
onDisconnect: (id: string) => void
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
online: 'bg-green-500',
|
||||
offline: 'bg-zinc-500',
|
||||
unauthorized: 'bg-yellow-500',
|
||||
recovery: 'bg-orange-500',
|
||||
}
|
||||
|
||||
export function DeviceCard({ device, isSelected, isProfiled, onSelect, onProfile, onDisconnect }: Props) {
|
||||
const isOnline = device.status === 'online'
|
||||
|
||||
function handleProfileToggle(e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
onProfile(isProfiled ? null : device.id)
|
||||
}
|
||||
|
||||
function handleDisconnect(e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
onDisconnect(device.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => isOnline && onSelect(device.id)}
|
||||
className={[
|
||||
'rounded-lg border p-3 transition-colors',
|
||||
isOnline ? 'cursor-pointer hover:bg-accent' : 'opacity-60',
|
||||
isSelected ? 'border-primary bg-accent' : 'border-border',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<MonitorSmartphone className="shrink-0 text-muted-foreground" size={18} />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm truncate">
|
||||
{device.model || device.product || device.id}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{device.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${statusColor[device.status] ?? 'bg-zinc-500'}`} />
|
||||
<span className="text-xs text-muted-foreground capitalize">{device.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOnline && (
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary" className="gap-1 text-[10px]">
|
||||
{device.connectionType === 'wifi' ? <Wifi size={9} /> : <Usb size={9} />}
|
||||
{device.connectionType.toUpperCase()}
|
||||
</Badge>
|
||||
{device.androidVersion && (
|
||||
<Badge variant="secondary" className="text-[10px]">Android {device.androidVersion}</Badge>
|
||||
)}
|
||||
{device.abi && (
|
||||
<Badge variant="secondary" className="gap-1 text-[10px]">
|
||||
<Cpu size={9} />
|
||||
{device.abi}
|
||||
</Badge>
|
||||
)}
|
||||
{device.batteryLevel > 0 && (
|
||||
<Badge variant="secondary" className="gap-1 text-[10px]">
|
||||
<Battery size={9} />
|
||||
{device.batteryLevel}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.status === 'unauthorized' && (
|
||||
<p className="mt-1.5 text-xs text-yellow-400">
|
||||
Accept the USB debugging prompt on device.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isOnline && (
|
||||
<div className="mt-2.5 flex items-center justify-between gap-2">
|
||||
{/* Profile toggle */}
|
||||
<button
|
||||
onClick={handleProfileToggle}
|
||||
className={[
|
||||
'flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors',
|
||||
isProfiled
|
||||
? 'bg-green-500/15 text-green-400 hover:bg-green-500/25'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
].join(' ')}
|
||||
>
|
||||
<Radio size={11} className={isProfiled ? 'animate-pulse' : ''} />
|
||||
{isProfiled ? 'Profiling' : 'Profile'}
|
||||
</button>
|
||||
|
||||
{device.connectionType === 'wifi' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-destructive hover:text-destructive"
|
||||
onClick={handleDisconnect}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Battery,
|
||||
Wifi,
|
||||
Thermometer,
|
||||
Smartphone,
|
||||
Shield,
|
||||
Monitor,
|
||||
MemoryStick,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useDeviceInfo } from '../hooks/use-device-info'
|
||||
import { useDeviceLiveStats } from '../hooks/use-device-live-stats'
|
||||
|
||||
interface Props {
|
||||
deviceId: string
|
||||
}
|
||||
|
||||
const thermalColor: Record<string, string> = {
|
||||
none: 'text-green-400',
|
||||
light: 'text-yellow-400',
|
||||
moderate: 'text-orange-400',
|
||||
severe: 'text-red-400',
|
||||
critical: 'text-red-500',
|
||||
emergency: 'text-red-600',
|
||||
shutdown: 'text-red-700',
|
||||
}
|
||||
|
||||
export function DeviceInfoPanel({ deviceId }: Props) {
|
||||
const { info, loading, error } = useDeviceInfo(deviceId)
|
||||
const live = useDeviceLiveStats(deviceId)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full gap-2 text-muted-foreground text-sm">
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
Loading device info…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !info) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
{error ?? 'No device info available.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const usedRam = live && info.totalRamMb > 0
|
||||
? info.totalRamMb - live.availableRamMb
|
||||
: null
|
||||
const ramPct = usedRam !== null && info.totalRamMb > 0
|
||||
? Math.round((usedRam / info.totalRamMb) * 100)
|
||||
: null
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-5 space-y-5">
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{info.model || info.product}</h2>
|
||||
<p className="text-sm text-muted-foreground">{info.manufacturer}</p>
|
||||
{info.serialNumber && (
|
||||
<p className="text-xs text-muted-foreground font-mono mt-0.5">{info.serialNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* System — static */}
|
||||
<Section icon={Smartphone} title="System">
|
||||
<Row label="Android" value={
|
||||
info.androidVersion
|
||||
? `Android ${info.androidVersion} (SDK ${info.sdkVersion})`
|
||||
: '—'
|
||||
} />
|
||||
<Row label="Security patch" value={info.securityPatch || '—'} />
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Processor — static */}
|
||||
<Section icon={Cpu} title="Processor">
|
||||
<Row label="Primary ABI" value={info.abi || '—'} />
|
||||
<Row label="Supported ABIs" value={info.supportedAbis || '—'} />
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Display — static */}
|
||||
<Section icon={Monitor} title="Display">
|
||||
<Row label="Resolution" value={info.screenResolution || '—'} />
|
||||
<Row label="Density" value={info.screenDensity ? `${info.screenDensity} dpi` : '—'} />
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Memory — total static, available live */}
|
||||
<Section icon={MemoryStick} title="Memory">
|
||||
<Row label="Total RAM" value={info.totalRamMb > 0 ? `${info.totalRamMb} MB` : '—'} />
|
||||
<Row
|
||||
label="Available RAM"
|
||||
value={live ? `${live.availableRamMb} MB` : '—'}
|
||||
live
|
||||
/>
|
||||
{ramPct !== null && (
|
||||
<>
|
||||
<div className="mt-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-500"
|
||||
style={{ width: `${ramPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{ramPct}% used</p>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Storage — total static, available live */}
|
||||
<Section icon={HardDrive} title="Storage">
|
||||
<Row label="Total" value={info.totalStorageGb || '—'} />
|
||||
<Row label="Available" value={live?.availableStorageGb || '—'} live />
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Battery — fully live */}
|
||||
<Section icon={Battery} title="Battery">
|
||||
<Row
|
||||
label="Level"
|
||||
value={
|
||||
live && live.batteryLevel > 0 ? (
|
||||
<span className={live.batteryLevel < 20 ? 'text-red-400' : ''}>
|
||||
{live.batteryLevel}%
|
||||
</span>
|
||||
) : '—'
|
||||
}
|
||||
live
|
||||
/>
|
||||
<Row
|
||||
label="Status"
|
||||
value={live?.batteryStatus
|
||||
? <Badge variant="secondary">{live.batteryStatus}</Badge>
|
||||
: '—'
|
||||
}
|
||||
live
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Thermal — fully live */}
|
||||
<Section icon={Thermometer} title="Thermal">
|
||||
<Row
|
||||
label="Status"
|
||||
value={
|
||||
live?.thermalStatus ? (
|
||||
<span className={thermalColor[live.thermalStatus] ?? ''}>
|
||||
{live.thermalStatus}
|
||||
</span>
|
||||
) : '—'
|
||||
}
|
||||
live
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Network — IP is live */}
|
||||
<Section icon={Wifi} title="Network">
|
||||
<Row label="IP address" value={live?.ipAddress || '—'} mono live />
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Build — static */}
|
||||
<Section icon={Shield} title="Build">
|
||||
<Row label="Fingerprint" value={info.buildFingerprint || '—'} mono />
|
||||
</Section>
|
||||
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ElementType
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
<Icon size={12} />
|
||||
{title}
|
||||
</div>
|
||||
<div className="space-y-1.5">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
live,
|
||||
}: {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
mono?: boolean
|
||||
live?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 text-sm">
|
||||
<span className="text-muted-foreground shrink-0 flex items-center gap-1">
|
||||
{live && (
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
<span className={['text-right break-all', mono ? 'font-mono text-xs' : ''].join(' ')}>
|
||||
{value ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect } from 'react'
|
||||
import { RefreshCw, MonitorSmartphone, Radio } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useDevices } from '../hooks/use-devices'
|
||||
import { DeviceCard } from './DeviceCard'
|
||||
import { ConnectDialog } from './ConnectDialog'
|
||||
import { DeviceInfoPanel } from './DeviceInfoPanel'
|
||||
import { useAppStore } from '@/store/app-store'
|
||||
|
||||
export function DevicePanel() {
|
||||
const { devices, loading, connect, disconnect } = useDevices()
|
||||
const {
|
||||
selectedDeviceId, setSelectedDeviceId,
|
||||
profiledDeviceId, setProfiledDeviceId,
|
||||
} = useAppStore()
|
||||
|
||||
// Clear selected/profiled IDs when a device disconnects or goes offline
|
||||
useEffect(() => {
|
||||
const isOnline = (id: string | null) =>
|
||||
id !== null && devices.some((d) => d.id === id && d.status === 'online')
|
||||
|
||||
if (!isOnline(selectedDeviceId)) setSelectedDeviceId(null)
|
||||
if (!isOnline(profiledDeviceId)) setProfiledDeviceId(null)
|
||||
}, [devices])
|
||||
|
||||
const profiledDevice = devices.find((d) => d.id === profiledDeviceId)
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Device list */}
|
||||
<div className="w-96 flex flex-col border-r border-border shrink-0">
|
||||
|
||||
{/* Active profiling indicator */}
|
||||
{profiledDevice ? (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-green-500/10 border-b border-green-500/20 shrink-0">
|
||||
<Radio size={12} className="text-green-400 animate-pulse shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-green-400 truncate">
|
||||
Profiling: {profiledDevice.model || profiledDevice.id}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setProfiledDeviceId(null)}
|
||||
className="text-xs text-green-400/70 hover:text-green-400 transition-colors shrink-0"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-muted/30 border-b border-border shrink-0">
|
||||
<Radio size={12} className="text-muted-foreground shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">No device being profiled</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
||||
<div>
|
||||
<h2 className="font-semibold text-sm">Devices</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{devices.length} {devices.length === 1 ? 'device' : 'devices'} detected
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ConnectDialog onConnect={connect} />
|
||||
{loading && <RefreshCw size={14} className="animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3 space-y-2">
|
||||
{devices.length === 0 && !loading ? (
|
||||
<div className="text-center py-10 text-muted-foreground">
|
||||
<p className="text-sm">No devices connected.</p>
|
||||
<p className="text-xs mt-1">Connect via USB or use wireless ADB.</p>
|
||||
</div>
|
||||
) : (
|
||||
devices.map((d) => (
|
||||
<DeviceCard
|
||||
key={d.id}
|
||||
device={d}
|
||||
isSelected={d.id === selectedDeviceId}
|
||||
isProfiled={d.id === profiledDeviceId}
|
||||
onSelect={setSelectedDeviceId}
|
||||
onProfile={setProfiledDeviceId}
|
||||
onDisconnect={disconnect}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" />
|
||||
|
||||
{/* Device info */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selectedDeviceId ? (
|
||||
<DeviceInfoPanel deviceId={selectedDeviceId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
|
||||
<MonitorSmartphone size={40} strokeWidth={1.25} />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">No device selected</p>
|
||||
<p className="text-xs mt-1">Click a device to view its details.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { GetDeviceInfo } from '@/wailsjs/go/main/App'
|
||||
import type { device } from '@/wailsjs/go/models'
|
||||
|
||||
export type DeviceInfo = device.DeviceInfo
|
||||
|
||||
export function useDeviceInfo(deviceId: string | null) {
|
||||
const [info, setInfo] = useState<DeviceInfo | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceId) {
|
||||
setInfo(null)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
GetDeviceInfo(deviceId)
|
||||
.then(setInfo)
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [deviceId])
|
||||
|
||||
return { info, loading, error }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { GetDeviceLiveStats } from '@/wailsjs/go/main/App'
|
||||
import type { device } from '@/wailsjs/go/models'
|
||||
|
||||
export type DeviceLiveStats = device.DeviceLiveStats
|
||||
|
||||
export function useDeviceLiveStats(deviceId: string | null) {
|
||||
const [stats, setStats] = useState<DeviceLiveStats | null>(null)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceId) {
|
||||
setStats(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch immediately, then every second
|
||||
GetDeviceLiveStats(deviceId).then(setStats).catch(() => {})
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
GetDeviceLiveStats(deviceId).then(setStats).catch(() => {})
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
}
|
||||
}, [deviceId])
|
||||
|
||||
return stats
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ListDevices, ConnectDevice, DisconnectDevice } from '@/wailsjs/go/main/App'
|
||||
import { EventsOn, EventsOff } from '@/wailsjs/runtime/runtime'
|
||||
import type { device } from '@/wailsjs/go/models'
|
||||
|
||||
export type Device = device.Device
|
||||
|
||||
export function useDevices() {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
ListDevices()
|
||||
.then((d) => setDevices(d ?? []))
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false))
|
||||
|
||||
EventsOn('devices:changed', (d: Device[]) => setDevices(d ?? []))
|
||||
return () => EventsOff('devices:changed')
|
||||
}, [])
|
||||
|
||||
async function connect(address: string) {
|
||||
await ConnectDevice(address)
|
||||
}
|
||||
|
||||
async function disconnect(deviceId: string) {
|
||||
await DisconnectDevice(deviceId)
|
||||
}
|
||||
|
||||
return { devices, loading, error, connect, disconnect }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useAppStore } from '@/store/app-store'
|
||||
|
||||
/**
|
||||
* Returns the profiled device ID — the device all feature panels monitor.
|
||||
* `isMonitoring` is false when no device is being profiled.
|
||||
*
|
||||
* This is separate from `selectedDeviceId` (info panel view).
|
||||
* Data in `deviceCache` is never cleared on device switch.
|
||||
*/
|
||||
export function useActiveDevice() {
|
||||
const { profiledDeviceId, deviceCache, patchDeviceCache, clearDeviceCache } = useAppStore()
|
||||
|
||||
return {
|
||||
activeDeviceId: profiledDeviceId,
|
||||
isMonitoring: profiledDeviceId !== null,
|
||||
deviceCache,
|
||||
patchDeviceCache,
|
||||
clearDeviceCache,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -18,3 +23,127 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
|
||||
@@ -1,11 +1,43 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export interface DeviceCache {
|
||||
logcat?: unknown[]
|
||||
networkRequests?: unknown[]
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
activeDeviceId: string | null
|
||||
setActiveDeviceId: (id: string | null) => void
|
||||
/** Device whose info is shown in the Devices panel. */
|
||||
selectedDeviceId: string | null
|
||||
setSelectedDeviceId: (id: string | null) => void
|
||||
|
||||
/** Device being actively monitored by all feature panels (Logcat, Network, etc.). */
|
||||
profiledDeviceId: string | null
|
||||
setProfiledDeviceId: (id: string | null) => void
|
||||
|
||||
deviceCache: Record<string, DeviceCache>
|
||||
patchDeviceCache: (deviceId: string, patch: Partial<DeviceCache>) => void
|
||||
clearDeviceCache: (deviceId: string) => void
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
activeDeviceId: null,
|
||||
setActiveDeviceId: (id) => set({ activeDeviceId: id }),
|
||||
selectedDeviceId: null,
|
||||
setSelectedDeviceId: (id) => set({ selectedDeviceId: id }),
|
||||
|
||||
profiledDeviceId: null,
|
||||
setProfiledDeviceId: (id) => set({ profiledDeviceId: id }),
|
||||
|
||||
deviceCache: {},
|
||||
patchDeviceCache: (deviceId, patch) =>
|
||||
set((s) => ({
|
||||
deviceCache: {
|
||||
...s.deviceCache,
|
||||
[deviceId]: { ...s.deviceCache[deviceId], ...patch },
|
||||
},
|
||||
})),
|
||||
clearDeviceCache: (deviceId) =>
|
||||
set((s) => {
|
||||
const next = { ...s.deviceCache }
|
||||
delete next[deviceId]
|
||||
return { deviceCache: next }
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -16,11 +16,10 @@
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
@@ -10,7 +10,6 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
build: {
|
||||
outDir: '../desktop/frontend/dist',
|
||||
|
||||
Reference in New Issue
Block a user