@opengeni/react 0.3.1 → 0.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.
Files changed (40) hide show
  1. package/dist/index.d.ts +1035 -14
  2. package/dist/index.js +6867 -1884
  3. package/dist/index.js.map +1 -1
  4. package/package.json +65 -2
  5. package/src/client.ts +21 -0
  6. package/src/components/code-editor.tsx +398 -0
  7. package/src/components/desktop-viewer.tsx +647 -0
  8. package/src/components/diff-view.tsx +230 -0
  9. package/src/components/file-browser.tsx +838 -0
  10. package/src/components/message-timeline.tsx +70 -196
  11. package/src/components/pierre-diff.tsx +140 -0
  12. package/src/components/pierre-file.tsx +142 -0
  13. package/src/components/sandbox-files.tsx +509 -0
  14. package/src/components/sandbox-terminal.tsx +425 -0
  15. package/src/components/workspace-dock.tsx +247 -0
  16. package/src/hooks/use-desktop-stream.ts +214 -0
  17. package/src/hooks/use-sandbox-files.ts +670 -0
  18. package/src/hooks/use-sandbox-git.ts +105 -0
  19. package/src/hooks/use-sandbox-terminal.ts +226 -0
  20. package/src/hooks/use-session-capabilities.ts +415 -0
  21. package/src/hooks/use-terminal-stream.ts +207 -0
  22. package/src/index.ts +111 -2
  23. package/src/lib/cn.ts +20 -1
  24. package/src/lib/git-patch.ts +37 -0
  25. package/src/lib/use-theme-type.ts +40 -0
  26. package/src/lib/xterm-theme.ts +34 -0
  27. package/src/timeline/activity-rail.tsx +207 -0
  28. package/src/timeline/disclosure-context.tsx +34 -0
  29. package/src/timeline/index.ts +85 -0
  30. package/src/timeline/parsers.ts +248 -0
  31. package/src/{timeline.ts → timeline/projection.ts} +59 -134
  32. package/src/timeline/registry.ts +96 -0
  33. package/src/timeline/screenshot-lightbox.tsx +152 -0
  34. package/src/timeline/shared.tsx +481 -0
  35. package/src/timeline/tool-diff.tsx +91 -0
  36. package/src/timeline/tool-renderers.tsx +882 -0
  37. package/src/timeline/turn-summary.tsx +125 -0
  38. package/src/timeline/types.ts +131 -0
  39. package/src/types/external.d.ts +7 -0
  40. package/styles/index.css +72 -0
@@ -0,0 +1,214 @@
1
+ import {
2
+ desktopSocketUrl,
3
+ nextDesktopState,
4
+ type DesktopConnectionState,
5
+ type DesktopRfbFactory,
6
+ type DesktopRfbLike,
7
+ type DesktopStreamCapability,
8
+ } from "@opengeni/sdk";
9
+ import { type RefObject, useEffect, useRef, useState } from "react";
10
+
11
+ export type UseDesktopStreamOptions = {
12
+ /** The desktop cell of the negotiated capabilities (`capabilities.DesktopStream`). */
13
+ capability: DesktopStreamCapability | null;
14
+ /** The mount target. RFB attaches here on connect. */
15
+ containerRef: RefObject<HTMLDivElement | null>;
16
+ /** Read-only by default (v1 ruling H). interactive only when cap.mode allows. */
17
+ interactive?: boolean | undefined;
18
+ scaleViewport?: boolean | undefined;
19
+ /** Custom RFB factory (tests / a WebRTC swap). Defaults to a lazy @novnc/novnc. */
20
+ rfbFactory?: DesktopRfbFactory | undefined;
21
+ };
22
+
23
+ export type UseDesktopStreamResult = {
24
+ state: DesktopConnectionState;
25
+ error: Error | null;
26
+ /** Manual reconnect (e.g. after a securityfailure once a fresh URL arrives). */
27
+ reconnect: () => void;
28
+ };
29
+
30
+ /** Lazy-load @novnc/novnc's RFB as the default factory. Imported inside the
31
+ * connect effect so SSR / non-desktop bundles never pull the DOM-only lib.
32
+ * @novnc/novnc ships no types and a single `export default class RFB`. The
33
+ * specifier is STATIC (`import("@novnc/novnc")`) so Vite can pre-bundle and
34
+ * resolve it — a runtime-string indirection with `@vite-ignore` (the previous
35
+ * approach) hands the browser a bare specifier and throws
36
+ * "Failed to resolve module specifier '@novnc/novnc'". The dynamic form keeps
37
+ * it out of the SSR / non-desktop critical path while staying resolvable. */
38
+ async function defaultRfbFactory(): Promise<DesktopRfbFactory> {
39
+ const mod = (await import("@novnc/novnc")) as unknown as {
40
+ default: new (
41
+ t: HTMLElement,
42
+ u: string,
43
+ o: { credentials?: { password?: string | undefined } | undefined },
44
+ ) => DesktopRfbLike;
45
+ };
46
+ const RFB = mod.default;
47
+ return (target, url, opts) => new RFB(target, url, opts);
48
+ }
49
+
50
+ /**
51
+ * Drive the noVNC RFB lifecycle from a `DesktopStreamCapability`, using the
52
+ * SDK's `desktop.ts` reducer + `desktopSocketUrl`. SSR-safe: the RFB import and
53
+ * the DOM attach happen inside `useEffect`, so a server render is a no-op and
54
+ * the component shows its placeholder until hydration.
55
+ *
56
+ * Read-only is enforced at three layers: `capability.mode` (server) →
57
+ * `interactive` prop → `RFB.viewOnly`. v1 always resolves to read-only. On a
58
+ * capability `url` change (a rotation), the old RFB disconnects and a fresh one
59
+ * connects to the new URL — a brief "desktop blink", acceptable on rollover.
60
+ */
61
+ export function useDesktopStream(options: UseDesktopStreamOptions): UseDesktopStreamResult {
62
+ const { capability, containerRef, interactive, scaleViewport, rfbFactory } = options;
63
+ const [state, setState] = useState<DesktopConnectionState>("idle");
64
+ const [error, setError] = useState<Error | null>(null);
65
+ const [nonce, setNonce] = useState(0);
66
+ const stateRef = useRef<DesktopConnectionState>("idle");
67
+ const setBoth = (next: DesktopConnectionState) => {
68
+ stateRef.current = next;
69
+ setState(next);
70
+ };
71
+
72
+ const reconnect = () => setNonce((n) => n + 1);
73
+
74
+ const url = capability?.url ?? null;
75
+ // The credential is keyed into the connect effect so a token rotation (new
76
+ // password) reconnects, but NOT folded into the whole-`capability` identity —
77
+ // a benign re-negotiation that re-mints the same cell must not churn the socket.
78
+ const token = capability?.token ?? null;
79
+ const transport = capability?.transport ?? null;
80
+ const mode = capability?.mode ?? "read-only";
81
+
82
+ // The live RFB handle. Holding it in a ref lets us flip view-only (take
83
+ // control / return control) and re-scale on the OPEN connection instead of
84
+ // tearing the socket down — the old behaviour reconnected on every
85
+ // `interactive` change, which read as a constant "refresh" and (combined with
86
+ // the canvas churn) bounced control back to watch.
87
+ const rfbRef = useRef<DesktopRfbLike | null>(null);
88
+ // Latest input/scale/factory the connect effect reads via refs so they are NOT
89
+ // connect-effect dependencies: changing them must update the live RFB in place,
90
+ // never reconnect it.
91
+ const interactiveRef = useRef(interactive);
92
+ const scaleViewportRef = useRef(scaleViewport);
93
+ const modeRef = useRef(mode);
94
+ const rfbFactoryRef = useRef(rfbFactory);
95
+ interactiveRef.current = interactive;
96
+ scaleViewportRef.current = scaleViewport;
97
+ modeRef.current = mode;
98
+ rfbFactoryRef.current = rfbFactory;
99
+
100
+ // read-only is forced when the server says so OR the caller didn't opt in.
101
+ const viewOnlyFor = (m: string, want: boolean | undefined) => m === "read-only" || !want;
102
+
103
+ useEffect(() => {
104
+ // SSR / no DOM / no usable transport: stay idle and show the placeholder.
105
+ if (typeof window === "undefined") return;
106
+ if (transport !== "vnc-ws" || !url) {
107
+ setBoth("idle");
108
+ return;
109
+ }
110
+ const container = containerRef.current;
111
+ if (!container) {
112
+ // The mount target isn't in the DOM yet (e.g. the tab is still hidden).
113
+ // Stay idle (not a stale negotiating/connecting) so a re-attach nudge —
114
+ // fired by the viewer on becoming visible / on mount — can re-run this
115
+ // effect once the container exists. Without this the surface can stick
116
+ // forever on the idle scrim and never open a socket.
117
+ setBoth("idle");
118
+ return;
119
+ }
120
+
121
+ let rfb: DesktopRfbLike | null = null;
122
+ let disposed = false;
123
+ setError(null);
124
+ setBoth("negotiating");
125
+
126
+ const onConnect = () => {
127
+ if (!disposed) setBoth(nextDesktopState(stateRef.current, { type: "connected" }));
128
+ };
129
+ const onDisconnect = () => {
130
+ if (!disposed) setBoth(nextDesktopState(stateRef.current, { type: "disconnected" }));
131
+ };
132
+ const onSecurityFailure = () => {
133
+ if (disposed) return;
134
+ setError(new Error("desktop authentication failed (token expired or revoked)"));
135
+ setBoth(nextDesktopState(stateRef.current, { type: "fail" }));
136
+ };
137
+
138
+ void (async () => {
139
+ try {
140
+ const factory = rfbFactoryRef.current ?? (await defaultRfbFactory());
141
+ if (disposed) return;
142
+ const socketUrl = desktopSocketUrl({ url });
143
+ setBoth(nextDesktopState(stateRef.current, { type: "negotiated" }));
144
+ rfb = factory(container, socketUrl, {
145
+ credentials: token ? { password: token } : undefined,
146
+ });
147
+ rfb.viewOnly = viewOnlyFor(modeRef.current, interactiveRef.current);
148
+ // Fit-to-panel: SCALE the 1280x800 framebuffer down to the container
149
+ // (aspect-preserved) and never 1:1-clip. `clipViewport=false` is pinned
150
+ // explicitly — noVNC forces it off while scaling, but a fresh RFB on a
151
+ // url rotation could otherwise start from a stale clip and read as
152
+ // "zoomed in". Order matters: clip off, then scale on.
153
+ rfb.clipViewport = false;
154
+ rfb.scaleViewport = scaleViewportRef.current ?? true;
155
+ rfb.addEventListener("connect", onConnect);
156
+ rfb.addEventListener("disconnect", onDisconnect);
157
+ rfb.addEventListener("securityfailure", onSecurityFailure);
158
+ rfbRef.current = rfb;
159
+ } catch (cause) {
160
+ if (!disposed) {
161
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
162
+ setBoth("error");
163
+ }
164
+ }
165
+ })();
166
+
167
+ return () => {
168
+ disposed = true;
169
+ if (rfbRef.current === rfb) rfbRef.current = null;
170
+ if (rfb) {
171
+ rfb.removeEventListener?.("connect", onConnect);
172
+ rfb.removeEventListener?.("disconnect", onDisconnect);
173
+ rfb.removeEventListener?.("securityfailure", onSecurityFailure);
174
+ try {
175
+ rfb.disconnect();
176
+ } catch {
177
+ // ignore teardown errors
178
+ }
179
+ }
180
+ };
181
+ // ONLY a real transport change reconnects: a fresh url (rotation), a new
182
+ // credential, the transport flipping, or an explicit manual reconnect
183
+ // (`nonce`). `interactive`/`scaleViewport`/`mode`/`rfbFactory` are read via
184
+ // refs and applied live below — they must never re-open the socket.
185
+ // eslint-disable-next-line react-hooks/exhaustive-deps
186
+ }, [url, token, transport, nonce]);
187
+
188
+ // Apply take-control / return-control to the OPEN connection in place. noVNC
189
+ // honours `viewOnly` live, so flipping it neither blinks the surface nor drops
190
+ // the framebuffer — the cure for the take-control "refresh + auto-release" loop.
191
+ useEffect(() => {
192
+ const rfb = rfbRef.current;
193
+ if (rfb) rfb.viewOnly = viewOnlyFor(mode, interactive);
194
+ // viewOnlyFor is pure; re-run only when the inputs change.
195
+ // eslint-disable-next-line react-hooks/exhaustive-deps
196
+ }, [interactive, mode, state]);
197
+
198
+ // Re-scale the live RFB in place (no reconnect) when the scale preference flips
199
+ // OR the connection state advances (e.g. the first framebuffer paints after a
200
+ // take-control flip / a re-attach). Re-asserting clip-off + scale-on here is the
201
+ // belt-and-suspenders against a "zoomed" surface: it re-fits the canvas to the
202
+ // panel on every meaningful transition, so a transient mis-measure at connect
203
+ // time can never stick.
204
+ useEffect(() => {
205
+ const rfb = rfbRef.current;
206
+ if (rfb) {
207
+ rfb.clipViewport = false;
208
+ rfb.scaleViewport = scaleViewport ?? true;
209
+ }
210
+ // eslint-disable-next-line react-hooks/exhaustive-deps
211
+ }, [scaleViewport, state]);
212
+
213
+ return { state, error, reconnect };
214
+ }