@noya-app/noya-multiplayer-react 0.1.76 → 0.1.78

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.
@@ -0,0 +1,500 @@
1
+ "use client";
2
+
3
+ import { GitFileEditHandle, GitManager } from "@noya-app/state-manager";
4
+ import React, { useEffect, useRef, useState } from "react";
5
+ import { useObservable } from "../../useObservable";
6
+ import { ColoredDot } from "../ColoredDot";
7
+ import { getStateInspectorTheme } from "../inspectorTheme";
8
+ import { StateInspectorButton } from "../StateInspectorButton";
9
+ import {
10
+ StateInspectorDisclosureRowInner,
11
+ StateInspectorDisclosureSection,
12
+ } from "../StateInspectorDisclosureSection";
13
+ import { StateInspectorTitleLabel } from "../StateInspectorTitleLabel";
14
+
15
+ export function GitSection({
16
+ showGit,
17
+ setShowGit,
18
+ colorScheme,
19
+ gitManager,
20
+ }: {
21
+ showGit: boolean;
22
+ setShowGit: (value: boolean) => void;
23
+ colorScheme: "light" | "dark";
24
+ gitManager: GitManager;
25
+ }) {
26
+ const theme = getStateInspectorTheme(colorScheme);
27
+ const repos = useObservable(gitManager.repos$);
28
+ const isInitialized = useObservable(gitManager.isInitialized$);
29
+ const repoFiles = useObservable(gitManager.repoFiles$);
30
+
31
+ // Local state for focused repo (debugging UI only)
32
+ const [focusedRepoId, setFocusedRepoId] = useState<string | null>(null);
33
+
34
+ // State for editing a file
35
+ const [editingFile, setEditingFile] = useState<string | null>(null);
36
+ const editHandleRef = useRef<GitFileEditHandle | null>(null);
37
+ const [editContent, setEditContent] = useState<string>("");
38
+ const [isEditInitialized, setIsEditInitialized] = useState(false);
39
+
40
+ const focusedRepo = focusedRepoId
41
+ ? repos.find((r) => r.id === focusedRepoId)
42
+ : null;
43
+ const focusedRepoFilesState = focusedRepoId ? repoFiles[focusedRepoId] : null;
44
+
45
+ const handleFocusRepo = async (repoId: string | null) => {
46
+ // Close any open edit session when switching repos
47
+ if (editHandleRef.current) {
48
+ editHandleRef.current.close();
49
+ editHandleRef.current = null;
50
+ setEditingFile(null);
51
+ setEditContent("");
52
+ setIsEditInitialized(false);
53
+ }
54
+ setFocusedRepoId(repoId);
55
+ if (repoId && !repoFiles[repoId]) {
56
+ await gitManager.fetchRepoFiles(repoId);
57
+ }
58
+ };
59
+
60
+ const handleEditFile = (filePath: string) => {
61
+ if (!focusedRepoId || !focusedRepoFilesState) return;
62
+
63
+ // Close existing session
64
+ if (editHandleRef.current) {
65
+ editHandleRef.current.close();
66
+ }
67
+
68
+ const handle = gitManager.openEditingHandle(
69
+ focusedRepoId,
70
+ filePath,
71
+ focusedRepoFilesState.ref
72
+ );
73
+ editHandleRef.current = handle;
74
+ setEditingFile(filePath);
75
+ setIsEditInitialized(false);
76
+ setEditContent("");
77
+ };
78
+
79
+ const handleCloseEdit = () => {
80
+ if (editHandleRef.current) {
81
+ editHandleRef.current.close();
82
+ editHandleRef.current = null;
83
+ }
84
+ setEditingFile(null);
85
+ setEditContent("");
86
+ setIsEditInitialized(false);
87
+ };
88
+
89
+ // Subscribe to content updates from the editing handle
90
+ useEffect(() => {
91
+ const handle = editHandleRef.current;
92
+ if (!handle) return;
93
+
94
+ const unsubContent = handle.content$.subscribe((content) => {
95
+ setEditContent(content);
96
+ });
97
+
98
+ const unsubInitialized = handle.isInitialized$.subscribe((initialized) => {
99
+ setIsEditInitialized(initialized);
100
+ });
101
+
102
+ return () => {
103
+ unsubContent();
104
+ unsubInitialized();
105
+ };
106
+ }, [editingFile]);
107
+
108
+ const handleContentChange = (newContent: string) => {
109
+ const handle = editHandleRef.current;
110
+ if (!handle) return;
111
+
112
+ handle.setState(newContent);
113
+ };
114
+
115
+ const handleBranchChange = async (ref: string) => {
116
+ if (focusedRepoId) {
117
+ await gitManager.fetchRepoFiles(focusedRepoId, ref);
118
+ }
119
+ };
120
+
121
+ const handleAddFile = async () => {
122
+ if (!focusedRepoId || !focusedRepoFilesState) return;
123
+
124
+ const path = prompt("Enter file path (e.g., src/index.ts):");
125
+ if (!path) return;
126
+
127
+ try {
128
+ await gitManager.patchFiles(
129
+ focusedRepoId,
130
+ { create: [{ path, content: "" }] },
131
+ focusedRepoFilesState.ref
132
+ );
133
+ } catch (error) {
134
+ alert(`Failed to create file: ${error}`);
135
+ }
136
+ };
137
+
138
+ const handleRenameFile = async (oldPath: string) => {
139
+ if (!focusedRepoId || !focusedRepoFilesState) return;
140
+
141
+ const newPath = prompt(`Rename "${oldPath}" to:`, oldPath);
142
+ if (!newPath || newPath === oldPath) return;
143
+
144
+ try {
145
+ await gitManager.patchFiles(
146
+ focusedRepoId,
147
+ { rename: [{ oldPath, newPath }] },
148
+ focusedRepoFilesState.ref
149
+ );
150
+ } catch (error) {
151
+ alert(`Failed to rename file: ${error}`);
152
+ }
153
+ };
154
+
155
+ const handleDeleteFile = async (path: string) => {
156
+ if (!focusedRepoId || !focusedRepoFilesState) return;
157
+
158
+ // eslint-disable-next-line no-restricted-globals
159
+ const confirmed = confirm(`Delete "${path}"?`);
160
+ if (!confirmed) return;
161
+
162
+ try {
163
+ await gitManager.patchFiles(
164
+ focusedRepoId,
165
+ { delete: [{ path }] },
166
+ focusedRepoFilesState.ref
167
+ );
168
+
169
+ // If we were editing this file, close the editor
170
+ if (editingFile === path) {
171
+ handleCloseEdit();
172
+ }
173
+ } catch (error) {
174
+ alert(`Failed to delete file: ${error}`);
175
+ }
176
+ };
177
+
178
+ return (
179
+ <StateInspectorDisclosureSection
180
+ open={showGit}
181
+ setOpen={setShowGit}
182
+ title={
183
+ <StateInspectorTitleLabel>
184
+ Git Repos ({repos.length})
185
+ <ColoredDot type={isInitialized ? "success" : "error"} />
186
+ </StateInspectorTitleLabel>
187
+ }
188
+ colorScheme={colorScheme}
189
+ right={
190
+ <span style={{ display: "flex", gap: "10px" }}>
191
+ <StateInspectorButton
192
+ theme={theme}
193
+ onClick={async () => {
194
+ try {
195
+ await gitManager.initRepo();
196
+ } catch (error) {
197
+ console.error("[GitSection] Failed to create repo:", error);
198
+ }
199
+ }}
200
+ >
201
+ Init Repo
202
+ </StateInspectorButton>
203
+ </span>
204
+ }
205
+ >
206
+ <StateInspectorDisclosureRowInner>
207
+ {/* Repo selector */}
208
+ <div style={{ padding: "4px 12px", display: "flex", gap: "4px" }}>
209
+ <select
210
+ name="repoSelector"
211
+ value={focusedRepoId ?? ""}
212
+ style={{ flex: "1 1 auto", height: "19px", minWidth: 0 }}
213
+ onChange={(e) => {
214
+ const value = e.target.value;
215
+ handleFocusRepo(value || null);
216
+ }}
217
+ >
218
+ <option value="">Select a repo...</option>
219
+ {repos.map((repo) => (
220
+ <option key={repo.id} value={repo.id}>
221
+ {repo.id}
222
+ </option>
223
+ ))}
224
+ </select>
225
+ {focusedRepoId && (
226
+ <StateInspectorButton
227
+ theme={theme}
228
+ onClick={() => gitManager.fetchRepoFiles(focusedRepoId)}
229
+ >
230
+ Refresh
231
+ </StateInspectorButton>
232
+ )}
233
+ <label
234
+ style={{
235
+ display: "flex",
236
+ alignItems: "center",
237
+ gap: "4px",
238
+ fontSize: "11px",
239
+ whiteSpace: "nowrap",
240
+ }}
241
+ >
242
+ [Include Content = {gitManager.includeContent ? "true" : "false"}]
243
+ </label>
244
+ </div>
245
+
246
+ {/* Branch selector and files list for focused repo */}
247
+ {focusedRepo && focusedRepoFilesState && (
248
+ <>
249
+ {/* Branch selector */}
250
+ <div
251
+ style={{
252
+ padding: "4px 12px",
253
+ display: "flex",
254
+ gap: "8px",
255
+ alignItems: "center",
256
+ fontSize: "12px",
257
+ }}
258
+ >
259
+ <span style={{ color: theme.BASE_COLOR }}>Branch:</span>
260
+ {focusedRepoFilesState.branches.length > 0 ? (
261
+ <select
262
+ value={focusedRepoFilesState.ref}
263
+ style={{ flex: "1 1 auto", height: "19px" }}
264
+ onChange={(e) => handleBranchChange(e.target.value)}
265
+ >
266
+ {focusedRepoFilesState.branches.map((branch) => (
267
+ <option key={branch.name} value={branch.name}>
268
+ {branch.name}
269
+ </option>
270
+ ))}
271
+ </select>
272
+ ) : (
273
+ <span style={{ opacity: 0.6, fontStyle: "italic" }}>
274
+ {focusedRepoFilesState.ref || "No branches"}
275
+ </span>
276
+ )}
277
+ {focusedRepoFilesState.oid && (
278
+ <span
279
+ style={{
280
+ fontFamily: "monospace",
281
+ fontSize: "10px",
282
+ opacity: 0.6,
283
+ }}
284
+ title={focusedRepoFilesState.oid}
285
+ >
286
+ {focusedRepoFilesState.oid.slice(0, 7)}
287
+ {focusedRepoFilesState.fetchState === "refreshing" && "…"}
288
+ </span>
289
+ )}
290
+ <StateInspectorButton
291
+ theme={theme}
292
+ onClick={handleAddFile}
293
+ style={{ fontSize: "10px", padding: "2px 6px" }}
294
+ >
295
+ + Add File
296
+ </StateInspectorButton>
297
+ </div>
298
+
299
+ {focusedRepoFilesState.fetchState === "loading" && (
300
+ <div
301
+ style={{
302
+ padding: "12px",
303
+ fontSize: "12px",
304
+ }}
305
+ >
306
+ Loading files...
307
+ </div>
308
+ )}
309
+ {focusedRepoFilesState.error && (
310
+ <div
311
+ style={{
312
+ padding: "12px",
313
+ fontSize: "12px",
314
+ color: "#f44",
315
+ }}
316
+ >
317
+ Error: {focusedRepoFilesState.error}
318
+ </div>
319
+ )}
320
+ {focusedRepoFilesState.fetchState !== "loading" &&
321
+ !focusedRepoFilesState.error &&
322
+ focusedRepoFilesState.files.length === 0 && (
323
+ <div
324
+ style={{
325
+ padding: "12px",
326
+ fontSize: "12px",
327
+ }}
328
+ >
329
+ No files (empty repo)
330
+ </div>
331
+ )}
332
+ {/* Hide file list when editing a file */}
333
+ {!editingFile &&
334
+ focusedRepoFilesState.files.map((file, index) => {
335
+ // Get content preview if available
336
+ const fileInfo = focusedRepoFilesState.fileContents?.find(
337
+ (f) => f.path === file
338
+ );
339
+ let preview = "";
340
+ if (fileInfo) {
341
+ try {
342
+ const decoded = atob(fileInfo.content);
343
+ // Take first 40 chars, replace newlines with spaces
344
+ preview = decoded
345
+ .slice(0, 40)
346
+ .replace(/\n/g, " ")
347
+ .replace(/\s+/g, " ");
348
+ if (decoded.length > 40) preview += "…";
349
+ } catch {
350
+ // ignore decode errors
351
+ }
352
+ }
353
+
354
+ return (
355
+ <div
356
+ key={index}
357
+ style={{
358
+ padding: "2px 12px",
359
+ display: "flex",
360
+ justifyContent: "space-between",
361
+ alignItems: "center",
362
+ gap: "8px",
363
+ borderBottom: `1px solid ${
364
+ colorScheme === "light"
365
+ ? "rgb(223 223 223)"
366
+ : "rgb(29 29 29)"
367
+ }`,
368
+ }}
369
+ >
370
+ <span
371
+ style={{
372
+ fontFamily: "monospace",
373
+ fontSize: "11px",
374
+ overflow: "hidden",
375
+ textOverflow: "ellipsis",
376
+ whiteSpace: "nowrap",
377
+ minWidth: 0,
378
+ display: "flex",
379
+ gap: "8px",
380
+ }}
381
+ title={file}
382
+ >
383
+ <span>{file}</span>
384
+ {preview && (
385
+ <span style={{ opacity: 0.5 }}>{preview}</span>
386
+ )}
387
+ </span>
388
+ <span style={{ display: "flex", gap: "4px", flexShrink: 0 }}>
389
+ <StateInspectorButton
390
+ theme={theme}
391
+ onClick={() => handleEditFile(file)}
392
+ style={{ fontSize: "10px", padding: "2px 6px" }}
393
+ >
394
+ Edit
395
+ </StateInspectorButton>
396
+ <StateInspectorButton
397
+ theme={theme}
398
+ onClick={() => handleRenameFile(file)}
399
+ style={{ fontSize: "10px", padding: "2px 6px" }}
400
+ >
401
+ Rename
402
+ </StateInspectorButton>
403
+ <StateInspectorButton
404
+ theme={theme}
405
+ onClick={() => handleDeleteFile(file)}
406
+ style={{ fontSize: "10px", padding: "2px 6px" }}
407
+ >
408
+ Delete
409
+ </StateInspectorButton>
410
+ </span>
411
+ </div>
412
+ );
413
+ })}
414
+
415
+ {/* File editor textarea */}
416
+ {editingFile && (
417
+ <div
418
+ style={{
419
+ padding: "8px 12px",
420
+ borderTop: `1px solid ${theme.DIVIDER_COLOR}`,
421
+ flex: "1 1 auto",
422
+ display: "flex",
423
+ flexDirection: "column",
424
+ minHeight: 0,
425
+ }}
426
+ >
427
+ <div
428
+ style={{
429
+ display: "flex",
430
+ justifyContent: "space-between",
431
+ alignItems: "center",
432
+ marginBottom: "8px",
433
+ flexShrink: 0,
434
+ }}
435
+ >
436
+ <span
437
+ style={{
438
+ fontFamily: "monospace",
439
+ fontSize: "11px",
440
+ fontWeight: "bold",
441
+ }}
442
+ >
443
+ {editingFile}
444
+ {!isEditInitialized && (
445
+ <span style={{ opacity: 0.5, marginLeft: "8px" }}>
446
+ Loading...
447
+ </span>
448
+ )}
449
+ </span>
450
+ <StateInspectorButton
451
+ theme={theme}
452
+ onClick={handleCloseEdit}
453
+ style={{ fontSize: "10px", padding: "2px 6px" }}
454
+ >
455
+ Close
456
+ </StateInspectorButton>
457
+ </div>
458
+ <textarea
459
+ value={editContent}
460
+ onChange={(e) => handleContentChange(e.target.value)}
461
+ disabled={!isEditInitialized}
462
+ style={{
463
+ width: "100%",
464
+ flex: "1 1 auto",
465
+ minHeight: "100px",
466
+ fontFamily: "monospace",
467
+ fontSize: "11px",
468
+ padding: "8px",
469
+ border: `1px solid ${theme.DIVIDER_COLOR}`,
470
+ borderRadius: "4px",
471
+ backgroundColor: theme.ROW_BACKGROUND,
472
+ color: theme.BASE_COLOR,
473
+ resize: "none",
474
+ boxSizing: "border-box",
475
+ }}
476
+ />
477
+ </div>
478
+ )}
479
+
480
+ </>
481
+ )}
482
+
483
+ {/* Empty state when no repos */}
484
+ {repos.length === 0 && (
485
+ <div
486
+ style={{
487
+ padding: "12px",
488
+ fontSize: "12px",
489
+ display: "flex",
490
+ flexDirection: "column",
491
+ gap: "4px",
492
+ }}
493
+ >
494
+ <span>No git repos</span>
495
+ </div>
496
+ )}
497
+ </StateInspectorDisclosureRowInner>
498
+ </StateInspectorDisclosureSection>
499
+ );
500
+ }
package/src/noyaApp.ts CHANGED
@@ -149,6 +149,7 @@ export function getSyncAdapter<
149
149
  debug?: boolean;
150
150
  policyAuthContext?: PolicyAuthContext;
151
151
  serverScripts?: LocalStorageSyncOptions["serverScripts"];
152
+ git?: LocalStorageSyncOptions["git"];
152
153
  }): SyncAdapter<S, M, E, MenuT, I> {
153
154
  const {
154
155
  multiplayerUrl,
@@ -156,6 +157,7 @@ export function getSyncAdapter<
156
157
  debug,
157
158
  policyAuthContext,
158
159
  serverScripts,
160
+ git,
159
161
  } = params ?? {};
160
162
 
161
163
  return isEmbeddedApp()
@@ -166,6 +168,7 @@ export function getSyncAdapter<
166
168
  ? localStorageSync<S, M, E, MenuT, I>({
167
169
  key: offlineStorageKey,
168
170
  ...(serverScripts ? { serverScripts } : {}),
171
+ ...(git ? { git } : {}),
169
172
  })
170
173
  : stubSync<S, M, E, MenuT, I>();
171
174
  }
@@ -178,6 +181,8 @@ export type UseNoyaStateOptions<
178
181
  I extends Record<string, any> = Record<string, unknown>,
179
182
  > = UseMultiplayerStateOptions<S, M, E, MenuT, I> & {
180
183
  offlineStorageKey?: string;
184
+ serverScripts?: LocalStorageSyncOptions["serverScripts"];
185
+ git?: LocalStorageSyncOptions["git"];
181
186
  };
182
187
 
183
188
  export type UseNoyaStateResult<
@@ -282,12 +287,16 @@ export function useNoyaStateInternal<
282
287
  offlineStorageKey: options?.offlineStorageKey,
283
288
  debug: options?.debug,
284
289
  policyAuthContext: basePolicyAuthContext,
290
+ serverScripts: options?.serverScripts,
291
+ git: options?.git,
285
292
  });
286
293
  }, [
287
294
  multiplayerUrl,
288
295
  options?.offlineStorageKey,
289
296
  options?.debug,
290
297
  basePolicyAuthContext,
298
+ options?.serverScripts,
299
+ options?.git,
291
300
  ]);
292
301
 
293
302
  const [noyaManager] = useState(
package/src/singleton.tsx CHANGED
@@ -44,6 +44,7 @@ export type AnyNoyaSingletonOptions<
44
44
  offlineStorageKey?: string;
45
45
  multiplayerUrl?: string;
46
46
  serverScripts?: LocalStorageSyncOptions["serverScripts"];
47
+ git?: LocalStorageSyncOptions["git"];
47
48
  };
48
49
 
49
50
  export function initializeNoyaSingleton<
@@ -106,6 +107,7 @@ export function initializeNoyaSingleton<
106
107
  offlineStorageKey,
107
108
  debug: managerOptions?.debug,
108
109
  serverScripts: managerOptions?.serverScripts,
110
+ git: managerOptions?.git,
109
111
  policyAuthContext,
110
112
  });
111
113