@rozenite/controls-plugin 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +16 -0
- package/.turbo/turbo-lint.log +4 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/LICENSE +20 -0
- package/README.md +118 -0
- package/dist/assets/panel-B5zz0XK3.css +1 -0
- package/dist/assets/panel-OL0XQF_c.js +17 -0
- package/dist/panel.html +31 -0
- package/dist/react-native.cjs +1 -0
- package/dist/react-native.d.ts +2 -0
- package/dist/react-native.js +8 -0
- package/dist/rozenite.config.d.ts +7 -0
- package/dist/rozenite.json +1 -0
- package/dist/src/__tests__/serialization.test.d.ts +1 -0
- package/dist/src/react-native/useRozeniteControlsPlugin.d.ts +3 -0
- package/dist/src/shared/messaging.d.ts +35 -0
- package/dist/src/shared/serialization.d.ts +22 -0
- package/dist/src/shared/types.d.ts +67 -0
- package/dist/src/ui/panel.d.ts +1 -0
- package/dist/useRozeniteControlsPlugin.cjs +1 -0
- package/dist/useRozeniteControlsPlugin.js +226 -0
- package/package.json +38 -0
- package/postcss.config.js +6 -0
- package/react-native.d.ts +19 -0
- package/react-native.ts +25 -0
- package/rozenite.config.ts +8 -0
- package/src/__tests__/serialization.test.ts +210 -0
- package/src/react-native/useRozeniteControlsPlugin.ts +215 -0
- package/src/shared/messaging.ts +45 -0
- package/src/shared/serialization.ts +153 -0
- package/src/shared/types.ts +103 -0
- package/src/ui/globals.css +86 -0
- package/src/ui/panel.tsx +501 -0
- package/tailwind.config.ts +53 -0
- package/tsconfig.json +36 -0
- package/vite.config.ts +23 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export type ControlsTextItem = {
|
|
2
|
+
id: string;
|
|
3
|
+
type: 'text';
|
|
4
|
+
title: string;
|
|
5
|
+
value: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type ControlsValidationResult =
|
|
10
|
+
| { valid: true }
|
|
11
|
+
| { valid: false; message: string };
|
|
12
|
+
|
|
13
|
+
export type ControlsMutableItemBase<TValue> = {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
value: TValue;
|
|
17
|
+
description?: string;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
validate?: (nextValue: TValue) => ControlsValidationResult;
|
|
20
|
+
onUpdate: (nextValue: TValue) => void | Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ControlsToggleItem = ControlsMutableItemBase<boolean> & {
|
|
24
|
+
type: 'toggle';
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ControlsButtonItem = {
|
|
28
|
+
id: string;
|
|
29
|
+
type: 'button';
|
|
30
|
+
title: string;
|
|
31
|
+
actionLabel?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
onPress: () => void | Promise<void>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type ControlsSelectOption = {
|
|
38
|
+
label: string;
|
|
39
|
+
value: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type ControlsSelectItem = ControlsMutableItemBase<string> & {
|
|
43
|
+
type: 'select';
|
|
44
|
+
options: ControlsSelectOption[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type ControlsInputItem = ControlsMutableItemBase<string> & {
|
|
48
|
+
type: 'input';
|
|
49
|
+
placeholder?: string;
|
|
50
|
+
applyLabel?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type ControlsItem =
|
|
54
|
+
| ControlsTextItem
|
|
55
|
+
| ControlsToggleItem
|
|
56
|
+
| ControlsButtonItem
|
|
57
|
+
| ControlsSelectItem
|
|
58
|
+
| ControlsInputItem;
|
|
59
|
+
|
|
60
|
+
export type ControlsSection = {
|
|
61
|
+
id: string;
|
|
62
|
+
title: string;
|
|
63
|
+
description?: string;
|
|
64
|
+
items: ControlsItem[];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type ControlsTextItemSnapshot = Omit<ControlsTextItem, never>;
|
|
68
|
+
|
|
69
|
+
export type ControlsToggleItemSnapshot = Omit<
|
|
70
|
+
ControlsToggleItem,
|
|
71
|
+
'validate' | 'onUpdate'
|
|
72
|
+
>;
|
|
73
|
+
|
|
74
|
+
export type ControlsButtonItemSnapshot = Omit<ControlsButtonItem, 'onPress'>;
|
|
75
|
+
|
|
76
|
+
export type ControlsSelectItemSnapshot = Omit<
|
|
77
|
+
ControlsSelectItem,
|
|
78
|
+
'validate' | 'onUpdate'
|
|
79
|
+
>;
|
|
80
|
+
|
|
81
|
+
export type ControlsInputItemSnapshot = Omit<
|
|
82
|
+
ControlsInputItem,
|
|
83
|
+
'validate' | 'onUpdate'
|
|
84
|
+
>;
|
|
85
|
+
|
|
86
|
+
export type ControlsItemSnapshot =
|
|
87
|
+
| ControlsTextItemSnapshot
|
|
88
|
+
| ControlsToggleItemSnapshot
|
|
89
|
+
| ControlsButtonItemSnapshot
|
|
90
|
+
| ControlsSelectItemSnapshot
|
|
91
|
+
| ControlsInputItemSnapshot;
|
|
92
|
+
|
|
93
|
+
export type ControlsSectionSnapshot = Omit<ControlsSection, 'items'> & {
|
|
94
|
+
items: ControlsItemSnapshot[];
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export type RozeniteControlsPluginOptions = {
|
|
98
|
+
sections: ControlsSection[];
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const createSection = <TSection extends ControlsSection>(
|
|
102
|
+
section: TSection
|
|
103
|
+
): TSection => section;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
@layer base {
|
|
6
|
+
:root {
|
|
7
|
+
--background: 0 0% 100%;
|
|
8
|
+
--foreground: 0 0% 3.9%;
|
|
9
|
+
--card: 0 0% 100%;
|
|
10
|
+
--card-foreground: 0 0% 3.9%;
|
|
11
|
+
--popover: 0 0% 100%;
|
|
12
|
+
--popover-foreground: 0 0% 3.9%;
|
|
13
|
+
--primary: 0 0% 9%;
|
|
14
|
+
--primary-foreground: 0 0% 98%;
|
|
15
|
+
--secondary: 0 0% 96.1%;
|
|
16
|
+
--secondary-foreground: 0 0% 9%;
|
|
17
|
+
--muted: 0 0% 96.1%;
|
|
18
|
+
--muted-foreground: 0 0% 45.1%;
|
|
19
|
+
--accent: 0 0% 96.1%;
|
|
20
|
+
--accent-foreground: 0 0% 9%;
|
|
21
|
+
--destructive: 0 84.2% 60.2%;
|
|
22
|
+
--destructive-foreground: 0 0% 98%;
|
|
23
|
+
--border: 0 0% 14.9%;
|
|
24
|
+
--input: 0 0% 14.9%;
|
|
25
|
+
--ring: 0 0% 83.1%;
|
|
26
|
+
--radius: 0.5rem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.dark {
|
|
30
|
+
--background: 0 0% 3.9%;
|
|
31
|
+
--foreground: 0 0% 98%;
|
|
32
|
+
--card: 0 0% 3.9%;
|
|
33
|
+
--card-foreground: 0 0% 98%;
|
|
34
|
+
--popover: 0 0% 3.9%;
|
|
35
|
+
--popover-foreground: 0 0% 98%;
|
|
36
|
+
--primary: 0 0% 98%;
|
|
37
|
+
--primary-foreground: 0 0% 9%;
|
|
38
|
+
--secondary: 0 0% 14.9%;
|
|
39
|
+
--secondary-foreground: 0 0% 98%;
|
|
40
|
+
--muted: 0 0% 14.9%;
|
|
41
|
+
--muted-foreground: 0 0% 63.9%;
|
|
42
|
+
--accent: 0 0% 14.9%;
|
|
43
|
+
--accent-foreground: 0 0% 98%;
|
|
44
|
+
--destructive: 0 62.8% 30.6%;
|
|
45
|
+
--destructive-foreground: 0 0% 98%;
|
|
46
|
+
--border: 0 0% 14.9%;
|
|
47
|
+
--input: 0 0% 14.9%;
|
|
48
|
+
--ring: 0 0% 83.1%;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
* {
|
|
52
|
+
@apply border-border;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
body {
|
|
56
|
+
@apply bg-background text-foreground;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
::-webkit-scrollbar {
|
|
61
|
+
width: 8px;
|
|
62
|
+
height: 8px;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
::-webkit-scrollbar-track {
|
|
66
|
+
background: #1f2937;
|
|
67
|
+
border-radius: 4px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
::-webkit-scrollbar-thumb {
|
|
71
|
+
background: #374151;
|
|
72
|
+
border-radius: 4px;
|
|
73
|
+
border: 1px solid #1f2937;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
::-webkit-scrollbar-thumb:hover {
|
|
77
|
+
background: #4b5563;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
::-webkit-scrollbar-thumb:active {
|
|
81
|
+
background: #6b7280;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
::-webkit-scrollbar-corner {
|
|
85
|
+
background: #1f2937;
|
|
86
|
+
}
|
package/src/ui/panel.tsx
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type {
|
|
5
|
+
ControlsEventMap,
|
|
6
|
+
ControlsSnapshotEvent,
|
|
7
|
+
ControlsUpdateResultEvent,
|
|
8
|
+
} from '../shared/messaging';
|
|
9
|
+
import type { ControlsItemSnapshot, ControlsSectionSnapshot } from '../shared/types';
|
|
10
|
+
import './globals.css';
|
|
11
|
+
|
|
12
|
+
type ItemUiState = {
|
|
13
|
+
pending: boolean;
|
|
14
|
+
message?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const getItemKey = (sectionId: string, itemId: string) => `${sectionId}:${itemId}`;
|
|
18
|
+
|
|
19
|
+
const createRequestId = () =>
|
|
20
|
+
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
21
|
+
|
|
22
|
+
const RowShell = ({
|
|
23
|
+
title,
|
|
24
|
+
description,
|
|
25
|
+
errorMessage,
|
|
26
|
+
children,
|
|
27
|
+
}: {
|
|
28
|
+
title: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
errorMessage?: string;
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
}) => (
|
|
33
|
+
<div className="flex items-start justify-between gap-4 py-3">
|
|
34
|
+
<div className="min-w-0">
|
|
35
|
+
<div className="text-sm font-medium text-gray-100">{title}</div>
|
|
36
|
+
{description ? (
|
|
37
|
+
<div className="mt-1 text-xs text-gray-400">{description}</div>
|
|
38
|
+
) : null}
|
|
39
|
+
{errorMessage ? (
|
|
40
|
+
<div className="mt-1 text-xs text-red-400">{errorMessage}</div>
|
|
41
|
+
) : null}
|
|
42
|
+
</div>
|
|
43
|
+
{children}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const ToggleRow = ({
|
|
48
|
+
sectionId,
|
|
49
|
+
item,
|
|
50
|
+
uiState,
|
|
51
|
+
onToggle,
|
|
52
|
+
}: {
|
|
53
|
+
sectionId: string;
|
|
54
|
+
item: Extract<ControlsItemSnapshot, { type: 'toggle' }>;
|
|
55
|
+
uiState?: ItemUiState;
|
|
56
|
+
onToggle: (sectionId: string, itemId: string, value: boolean) => void;
|
|
57
|
+
}) => {
|
|
58
|
+
return (
|
|
59
|
+
<RowShell
|
|
60
|
+
title={item.title}
|
|
61
|
+
description={item.description}
|
|
62
|
+
errorMessage={uiState?.message}
|
|
63
|
+
>
|
|
64
|
+
<label className="relative inline-flex cursor-pointer items-center">
|
|
65
|
+
<input
|
|
66
|
+
type="checkbox"
|
|
67
|
+
className="peer sr-only"
|
|
68
|
+
checked={item.value}
|
|
69
|
+
disabled={item.disabled || uiState?.pending}
|
|
70
|
+
onChange={(event) => onToggle(sectionId, item.id, event.target.checked)}
|
|
71
|
+
/>
|
|
72
|
+
<div className="h-6 w-11 rounded-full bg-gray-700 transition peer-checked:bg-violet-500 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-5" />
|
|
73
|
+
</label>
|
|
74
|
+
</RowShell>
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const ButtonRow = ({
|
|
79
|
+
sectionId,
|
|
80
|
+
item,
|
|
81
|
+
uiState,
|
|
82
|
+
onPress,
|
|
83
|
+
}: {
|
|
84
|
+
sectionId: string;
|
|
85
|
+
item: Extract<ControlsItemSnapshot, { type: 'button' }>;
|
|
86
|
+
uiState?: ItemUiState;
|
|
87
|
+
onPress: (sectionId: string, itemId: string) => void;
|
|
88
|
+
}) => {
|
|
89
|
+
return (
|
|
90
|
+
<RowShell
|
|
91
|
+
title={item.title}
|
|
92
|
+
description={item.description}
|
|
93
|
+
errorMessage={uiState?.message}
|
|
94
|
+
>
|
|
95
|
+
<button
|
|
96
|
+
className="rounded-md border border-violet-500/60 bg-violet-500/10 px-3 py-1.5 text-xs font-semibold text-violet-100 transition hover:bg-violet-500/20 disabled:cursor-not-allowed disabled:border-gray-700 disabled:bg-gray-800 disabled:text-gray-500"
|
|
97
|
+
disabled={item.disabled || uiState?.pending}
|
|
98
|
+
onClick={() => onPress(sectionId, item.id)}
|
|
99
|
+
>
|
|
100
|
+
{uiState?.pending ? 'Running...' : item.actionLabel ?? 'Run'}
|
|
101
|
+
</button>
|
|
102
|
+
</RowShell>
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const SelectRow = ({
|
|
107
|
+
sectionId,
|
|
108
|
+
item,
|
|
109
|
+
uiState,
|
|
110
|
+
onSelect,
|
|
111
|
+
}: {
|
|
112
|
+
sectionId: string;
|
|
113
|
+
item: Extract<ControlsItemSnapshot, { type: 'select' }>;
|
|
114
|
+
uiState?: ItemUiState;
|
|
115
|
+
onSelect: (sectionId: string, itemId: string, value: string) => void;
|
|
116
|
+
}) => {
|
|
117
|
+
return (
|
|
118
|
+
<RowShell
|
|
119
|
+
title={item.title}
|
|
120
|
+
description={item.description}
|
|
121
|
+
errorMessage={uiState?.message}
|
|
122
|
+
>
|
|
123
|
+
<select
|
|
124
|
+
className="min-w-[160px] rounded-md border border-gray-700 bg-gray-950 px-3 py-2 text-xs text-gray-200 outline-none transition focus:border-violet-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
125
|
+
value={item.value}
|
|
126
|
+
disabled={item.disabled || uiState?.pending}
|
|
127
|
+
onChange={(event) => onSelect(sectionId, item.id, event.target.value)}
|
|
128
|
+
>
|
|
129
|
+
{item.options.map((option) => (
|
|
130
|
+
<option key={option.value} value={option.value}>
|
|
131
|
+
{option.label}
|
|
132
|
+
</option>
|
|
133
|
+
))}
|
|
134
|
+
</select>
|
|
135
|
+
</RowShell>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const TextRow = ({
|
|
140
|
+
item,
|
|
141
|
+
}: {
|
|
142
|
+
item: Extract<ControlsItemSnapshot, { type: 'text' }>;
|
|
143
|
+
}) => {
|
|
144
|
+
return (
|
|
145
|
+
<RowShell title={item.title} description={item.description}>
|
|
146
|
+
<div className="max-w-[50%] rounded-md bg-gray-950/80 px-3 py-1.5 text-right text-xs text-gray-300">
|
|
147
|
+
{item.value}
|
|
148
|
+
</div>
|
|
149
|
+
</RowShell>
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const InputRow = ({
|
|
154
|
+
sectionId,
|
|
155
|
+
item,
|
|
156
|
+
uiState,
|
|
157
|
+
draftValue,
|
|
158
|
+
onDraftChange,
|
|
159
|
+
onApply,
|
|
160
|
+
}: {
|
|
161
|
+
sectionId: string;
|
|
162
|
+
item: Extract<ControlsItemSnapshot, { type: 'input' }>;
|
|
163
|
+
uiState?: ItemUiState;
|
|
164
|
+
draftValue: string;
|
|
165
|
+
onDraftChange: (sectionId: string, itemId: string, value: string) => void;
|
|
166
|
+
onApply: (sectionId: string, itemId: string) => void;
|
|
167
|
+
}) => {
|
|
168
|
+
const isChanged = draftValue !== item.value;
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<RowShell
|
|
172
|
+
title={item.title}
|
|
173
|
+
description={item.description}
|
|
174
|
+
errorMessage={uiState?.message}
|
|
175
|
+
>
|
|
176
|
+
<div className="flex min-w-[240px] items-center gap-2">
|
|
177
|
+
<input
|
|
178
|
+
className="flex-1 rounded-md border border-gray-700 bg-gray-950 px-3 py-2 text-xs text-gray-200 outline-none transition focus:border-violet-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
179
|
+
type="text"
|
|
180
|
+
value={draftValue}
|
|
181
|
+
placeholder={item.placeholder}
|
|
182
|
+
disabled={item.disabled || uiState?.pending}
|
|
183
|
+
onChange={(event) =>
|
|
184
|
+
onDraftChange(sectionId, item.id, event.target.value)
|
|
185
|
+
}
|
|
186
|
+
/>
|
|
187
|
+
<button
|
|
188
|
+
className="rounded-md border border-violet-500/60 bg-violet-500/10 px-3 py-2 text-xs font-semibold text-violet-100 transition hover:bg-violet-500/20 disabled:cursor-not-allowed disabled:border-gray-700 disabled:bg-gray-800 disabled:text-gray-500"
|
|
189
|
+
disabled={!isChanged || item.disabled || uiState?.pending}
|
|
190
|
+
onClick={() => onApply(sectionId, item.id)}
|
|
191
|
+
>
|
|
192
|
+
{uiState?.pending ? 'Applying...' : item.applyLabel ?? 'Apply'}
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
</RowShell>
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const renderItem = ({
|
|
200
|
+
sectionId,
|
|
201
|
+
item,
|
|
202
|
+
uiState,
|
|
203
|
+
inputDraft,
|
|
204
|
+
onToggle,
|
|
205
|
+
onPress,
|
|
206
|
+
onSelect,
|
|
207
|
+
onInputDraftChange,
|
|
208
|
+
onInputApply,
|
|
209
|
+
}: {
|
|
210
|
+
sectionId: string;
|
|
211
|
+
item: ControlsItemSnapshot;
|
|
212
|
+
uiState?: ItemUiState;
|
|
213
|
+
inputDraft?: string;
|
|
214
|
+
onToggle: (sectionId: string, itemId: string, value: boolean) => void;
|
|
215
|
+
onPress: (sectionId: string, itemId: string) => void;
|
|
216
|
+
onSelect: (sectionId: string, itemId: string, value: string) => void;
|
|
217
|
+
onInputDraftChange: (sectionId: string, itemId: string, value: string) => void;
|
|
218
|
+
onInputApply: (sectionId: string, itemId: string) => void;
|
|
219
|
+
}) => {
|
|
220
|
+
if (item.type === 'text') {
|
|
221
|
+
return <TextRow item={item} />;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (item.type === 'toggle') {
|
|
225
|
+
return (
|
|
226
|
+
<ToggleRow
|
|
227
|
+
sectionId={sectionId}
|
|
228
|
+
item={item}
|
|
229
|
+
uiState={uiState}
|
|
230
|
+
onToggle={onToggle}
|
|
231
|
+
/>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (item.type === 'select') {
|
|
236
|
+
return (
|
|
237
|
+
<SelectRow
|
|
238
|
+
sectionId={sectionId}
|
|
239
|
+
item={item}
|
|
240
|
+
uiState={uiState}
|
|
241
|
+
onSelect={onSelect}
|
|
242
|
+
/>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (item.type === 'input') {
|
|
247
|
+
return (
|
|
248
|
+
<InputRow
|
|
249
|
+
sectionId={sectionId}
|
|
250
|
+
item={item}
|
|
251
|
+
uiState={uiState}
|
|
252
|
+
draftValue={inputDraft ?? item.value}
|
|
253
|
+
onDraftChange={onInputDraftChange}
|
|
254
|
+
onApply={onInputApply}
|
|
255
|
+
/>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<ButtonRow
|
|
261
|
+
sectionId={sectionId}
|
|
262
|
+
item={item}
|
|
263
|
+
uiState={uiState}
|
|
264
|
+
onPress={onPress}
|
|
265
|
+
/>
|
|
266
|
+
);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export default function ControlsPanel() {
|
|
270
|
+
const [sections, setSections] = useState<ControlsSectionSnapshot[]>([]);
|
|
271
|
+
const [loading, setLoading] = useState(true);
|
|
272
|
+
const [itemUiState, setItemUiState] = useState<Map<string, ItemUiState>>(new Map());
|
|
273
|
+
const [inputDrafts, setInputDrafts] = useState<Map<string, string>>(new Map());
|
|
274
|
+
const committedInputValuesRef = useRef<Map<string, string>>(new Map());
|
|
275
|
+
|
|
276
|
+
const client = useRozeniteDevToolsClient<ControlsEventMap>({
|
|
277
|
+
pluginId: '@rozenite/controls-plugin',
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
if (!client) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const snapshotSubscription = client.onMessage(
|
|
286
|
+
'snapshot',
|
|
287
|
+
(event: ControlsSnapshotEvent) => {
|
|
288
|
+
setSections(event.sections);
|
|
289
|
+
setLoading(false);
|
|
290
|
+
|
|
291
|
+
const nextCommittedValues = new Map<string, string>();
|
|
292
|
+
event.sections.forEach((section) => {
|
|
293
|
+
section.items.forEach((item) => {
|
|
294
|
+
if (item.type === 'input') {
|
|
295
|
+
nextCommittedValues.set(getItemKey(section.id, item.id), item.value);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
setInputDrafts((previous) => {
|
|
301
|
+
const next = new Map(previous);
|
|
302
|
+
|
|
303
|
+
next.forEach((_value, key) => {
|
|
304
|
+
if (!nextCommittedValues.has(key)) {
|
|
305
|
+
next.delete(key);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
nextCommittedValues.forEach((committedValue, key) => {
|
|
310
|
+
const previousCommitted = committedInputValuesRef.current.get(key);
|
|
311
|
+
const previousDraft = previous.get(key);
|
|
312
|
+
const isDirty =
|
|
313
|
+
previousDraft !== undefined && previousDraft !== previousCommitted;
|
|
314
|
+
|
|
315
|
+
if (!isDirty || previousDraft === committedValue) {
|
|
316
|
+
next.set(key, committedValue);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return next;
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
committedInputValuesRef.current = nextCommittedValues;
|
|
324
|
+
}
|
|
325
|
+
);
|
|
326
|
+
const updateResultSubscription = client.onMessage(
|
|
327
|
+
'update-result',
|
|
328
|
+
(event: ControlsUpdateResultEvent) => {
|
|
329
|
+
const key = getItemKey(event.sectionId, event.itemId);
|
|
330
|
+
|
|
331
|
+
setItemUiState((previous) => {
|
|
332
|
+
const next = new Map(previous);
|
|
333
|
+
next.set(key, {
|
|
334
|
+
pending: false,
|
|
335
|
+
message: event.status === 'error' ? event.message : undefined,
|
|
336
|
+
});
|
|
337
|
+
return next;
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
client.send('get-snapshot', {
|
|
343
|
+
type: 'get-snapshot',
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
return () => {
|
|
347
|
+
snapshotSubscription.remove();
|
|
348
|
+
updateResultSubscription.remove();
|
|
349
|
+
};
|
|
350
|
+
}, [client]);
|
|
351
|
+
|
|
352
|
+
const sendUpdateRequest = (
|
|
353
|
+
sectionId: string,
|
|
354
|
+
itemId: string,
|
|
355
|
+
value: boolean | string
|
|
356
|
+
) => {
|
|
357
|
+
if (!client) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const requestId = createRequestId();
|
|
362
|
+
const key = getItemKey(sectionId, itemId);
|
|
363
|
+
|
|
364
|
+
setItemUiState((previous) => {
|
|
365
|
+
const next = new Map(previous);
|
|
366
|
+
next.set(key, {
|
|
367
|
+
pending: true,
|
|
368
|
+
message: undefined,
|
|
369
|
+
});
|
|
370
|
+
return next;
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
client.send('update-request', {
|
|
374
|
+
type: 'update-request',
|
|
375
|
+
requestId,
|
|
376
|
+
sectionId,
|
|
377
|
+
itemId,
|
|
378
|
+
value,
|
|
379
|
+
});
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const handleToggle = (sectionId: string, itemId: string, value: boolean) => {
|
|
383
|
+
sendUpdateRequest(sectionId, itemId, value);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const handlePress = (sectionId: string, itemId: string) => {
|
|
387
|
+
if (!client) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
client.send('invoke-action', {
|
|
392
|
+
type: 'invoke-action',
|
|
393
|
+
sectionId,
|
|
394
|
+
itemId,
|
|
395
|
+
action: 'press',
|
|
396
|
+
});
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const handleSelect = (sectionId: string, itemId: string, value: string) => {
|
|
400
|
+
sendUpdateRequest(sectionId, itemId, value);
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const handleInputDraftChange = (
|
|
404
|
+
sectionId: string,
|
|
405
|
+
itemId: string,
|
|
406
|
+
value: string
|
|
407
|
+
) => {
|
|
408
|
+
const key = getItemKey(sectionId, itemId);
|
|
409
|
+
|
|
410
|
+
setInputDrafts((previous) => {
|
|
411
|
+
const next = new Map(previous);
|
|
412
|
+
next.set(key, value);
|
|
413
|
+
return next;
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
setItemUiState((previous) => {
|
|
417
|
+
const next = new Map(previous);
|
|
418
|
+
const current = next.get(key);
|
|
419
|
+
if (current?.message) {
|
|
420
|
+
next.set(key, {
|
|
421
|
+
...current,
|
|
422
|
+
message: undefined,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return next;
|
|
426
|
+
});
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const handleInputApply = (sectionId: string, itemId: string) => {
|
|
430
|
+
const key = getItemKey(sectionId, itemId);
|
|
431
|
+
const draftValue = inputDrafts.get(key);
|
|
432
|
+
|
|
433
|
+
if (draftValue === undefined) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
sendUpdateRequest(sectionId, itemId, draftValue);
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
return (
|
|
441
|
+
<div className="h-screen bg-gray-900 text-gray-100 flex flex-col">
|
|
442
|
+
<div className="flex items-center gap-2 border-b border-gray-700 bg-gray-800 p-2">
|
|
443
|
+
<span className="text-sm font-medium text-gray-200">Controls</span>
|
|
444
|
+
<div className="flex-1" />
|
|
445
|
+
<span className="text-xs text-gray-400">{sections.length} sections</span>
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
<div className="flex-1 overflow-auto p-4">
|
|
449
|
+
{loading ? (
|
|
450
|
+
<div className="rounded-lg border border-gray-800 bg-gray-800 p-6 text-sm text-gray-400">
|
|
451
|
+
Loading controls snapshot...
|
|
452
|
+
</div>
|
|
453
|
+
) : null}
|
|
454
|
+
|
|
455
|
+
{!loading && sections.length === 0 ? (
|
|
456
|
+
<div className="rounded-lg border border-dashed border-gray-700 bg-gray-800/80 p-6 text-sm text-gray-400">
|
|
457
|
+
No controls registered on the device.
|
|
458
|
+
</div>
|
|
459
|
+
) : null}
|
|
460
|
+
|
|
461
|
+
<div className="space-y-4">
|
|
462
|
+
{sections.map((section) => (
|
|
463
|
+
<section
|
|
464
|
+
key={section.id}
|
|
465
|
+
className="rounded-xl border border-gray-800 bg-gray-800/90 shadow-lg shadow-black/10"
|
|
466
|
+
>
|
|
467
|
+
<div className="border-b border-gray-800 px-4 py-3">
|
|
468
|
+
<div className="text-sm font-semibold text-gray-100">
|
|
469
|
+
{section.title}
|
|
470
|
+
</div>
|
|
471
|
+
{section.description ? (
|
|
472
|
+
<div className="mt-1 text-xs text-gray-400">
|
|
473
|
+
{section.description}
|
|
474
|
+
</div>
|
|
475
|
+
) : null}
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
<div className="divide-y divide-gray-800 px-4">
|
|
479
|
+
{section.items.map((item) => (
|
|
480
|
+
<div key={item.id}>
|
|
481
|
+
{renderItem({
|
|
482
|
+
sectionId: section.id,
|
|
483
|
+
item,
|
|
484
|
+
uiState: itemUiState.get(getItemKey(section.id, item.id)),
|
|
485
|
+
inputDraft: inputDrafts.get(getItemKey(section.id, item.id)),
|
|
486
|
+
onToggle: handleToggle,
|
|
487
|
+
onPress: handlePress,
|
|
488
|
+
onSelect: handleSelect,
|
|
489
|
+
onInputDraftChange: handleInputDraftChange,
|
|
490
|
+
onInputApply: handleInputApply,
|
|
491
|
+
})}
|
|
492
|
+
</div>
|
|
493
|
+
))}
|
|
494
|
+
</div>
|
|
495
|
+
</section>
|
|
496
|
+
))}
|
|
497
|
+
</div>
|
|
498
|
+
</div>
|
|
499
|
+
</div>
|
|
500
|
+
);
|
|
501
|
+
}
|