@gogitcms/editor 0.23.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 (143) hide show
  1. package/app/index.html +38 -0
  2. package/app/src/App.tsx +2665 -0
  3. package/app/src/apollo.ts +119 -0
  4. package/app/src/auth.ts +248 -0
  5. package/app/src/config.ts +88 -0
  6. package/app/src/main.tsx +18 -0
  7. package/app/src/media.ts +151 -0
  8. package/app/src/navigation.tsx +321 -0
  9. package/app/src/plugins.ts +11 -0
  10. package/app/src/previewHost.tsx +263 -0
  11. package/app/src/previewToken.ts +68 -0
  12. package/app/src/queries.ts +591 -0
  13. package/app/src/virtual-cms-plugins.d.ts +6 -0
  14. package/app/vendor/analytics/src/__tests__/config.test.ts +91 -0
  15. package/app/vendor/analytics/src/config.ts +94 -0
  16. package/app/vendor/analytics/src/events.ts +56 -0
  17. package/app/vendor/analytics/src/index.ts +23 -0
  18. package/app/vendor/analytics/src/provider.tsx +196 -0
  19. package/app/vendor/design-system/src/ThemeProvider.tsx +86 -0
  20. package/app/vendor/design-system/src/__tests__/ApplyChangesModal.test.tsx +148 -0
  21. package/app/vendor/design-system/src/__tests__/BranchImport.test.tsx +46 -0
  22. package/app/vendor/design-system/src/__tests__/Button.test.tsx +45 -0
  23. package/app/vendor/design-system/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
  24. package/app/vendor/design-system/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
  25. package/app/vendor/design-system/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
  26. package/app/vendor/design-system/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
  27. package/app/vendor/design-system/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
  28. package/app/vendor/design-system/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
  29. package/app/vendor/design-system/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
  30. package/app/vendor/design-system/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
  31. package/app/vendor/design-system/src/__tests__/ContentBrowser.media.test.tsx +140 -0
  32. package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
  33. package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
  34. package/app/vendor/design-system/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
  35. package/app/vendor/design-system/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
  36. package/app/vendor/design-system/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
  37. package/app/vendor/design-system/src/__tests__/ContentBrowser.search.test.tsx +135 -0
  38. package/app/vendor/design-system/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
  39. package/app/vendor/design-system/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
  40. package/app/vendor/design-system/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
  41. package/app/vendor/design-system/src/__tests__/MediaBrowser.test.tsx +353 -0
  42. package/app/vendor/design-system/src/__tests__/MediaField.test.tsx +185 -0
  43. package/app/vendor/design-system/src/__tests__/Notifications.test.tsx +69 -0
  44. package/app/vendor/design-system/src/__tests__/Onboarding.test.tsx +287 -0
  45. package/app/vendor/design-system/src/__tests__/cssTokens.test.ts +201 -0
  46. package/app/vendor/design-system/src/__tests__/fieldComponents.test.ts +43 -0
  47. package/app/vendor/design-system/src/__tests__/reorder.test.ts +58 -0
  48. package/app/vendor/design-system/src/components/ApplyChangesModal.tsx +348 -0
  49. package/app/vendor/design-system/src/components/BranchImport.tsx +157 -0
  50. package/app/vendor/design-system/src/components/BranchMenu.tsx +192 -0
  51. package/app/vendor/design-system/src/components/Button.tsx +131 -0
  52. package/app/vendor/design-system/src/components/ChangeDetail.tsx +472 -0
  53. package/app/vendor/design-system/src/components/ChangeRequestSummary.tsx +173 -0
  54. package/app/vendor/design-system/src/components/CollabField.tsx +388 -0
  55. package/app/vendor/design-system/src/components/ContentBrowser.tsx +5073 -0
  56. package/app/vendor/design-system/src/components/Icon.tsx +28 -0
  57. package/app/vendor/design-system/src/components/Icon.web.tsx +31 -0
  58. package/app/vendor/design-system/src/components/Input.tsx +106 -0
  59. package/app/vendor/design-system/src/components/MediaBrowser.tsx +766 -0
  60. package/app/vendor/design-system/src/components/MediaField.tsx +670 -0
  61. package/app/vendor/design-system/src/components/MediaPreview.tsx +91 -0
  62. package/app/vendor/design-system/src/components/MediaPreview.web.tsx +169 -0
  63. package/app/vendor/design-system/src/components/NavRow.tsx +105 -0
  64. package/app/vendor/design-system/src/components/Notifications.tsx +301 -0
  65. package/app/vendor/design-system/src/components/Onboarding.tsx +751 -0
  66. package/app/vendor/design-system/src/components/ProjectMenu.tsx +124 -0
  67. package/app/vendor/design-system/src/components/Segment.tsx +87 -0
  68. package/app/vendor/design-system/src/components/Skeleton.tsx +216 -0
  69. package/app/vendor/design-system/src/components/Spinner.tsx +44 -0
  70. package/app/vendor/design-system/src/components/Text.tsx +85 -0
  71. package/app/vendor/design-system/src/components/documentDrafts.ts +213 -0
  72. package/app/vendor/design-system/src/components/layout.tsx +284 -0
  73. package/app/vendor/design-system/src/components/primitives.tsx +143 -0
  74. package/app/vendor/design-system/src/components/reorder.ts +40 -0
  75. package/app/vendor/design-system/src/fieldComponents.ts +63 -0
  76. package/app/vendor/design-system/src/icons.ts +102 -0
  77. package/app/vendor/design-system/src/index.ts +198 -0
  78. package/app/vendor/design-system/src/media.ts +229 -0
  79. package/app/vendor/design-system/src/theme.ts +116 -0
  80. package/app/vendor/design-system/src/web/Button.tsx +110 -0
  81. package/app/vendor/design-system/src/web/Icon.tsx +52 -0
  82. package/app/vendor/design-system/src/web/Input.tsx +39 -0
  83. package/app/vendor/design-system/src/web/index.ts +43 -0
  84. package/app/vendor/design-system/src/web/primitives.tsx +119 -0
  85. package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +248 -0
  86. package/app/vendor/markdown-editor/src/Toolbar.tsx +157 -0
  87. package/app/vendor/markdown-editor/src/field.tsx +67 -0
  88. package/app/vendor/markdown-editor/src/flavors/commonmark/index.ts +27 -0
  89. package/app/vendor/markdown-editor/src/flavors/gfm/index.ts +30 -0
  90. package/app/vendor/markdown-editor/src/flavors/gfm/parser.ts +73 -0
  91. package/app/vendor/markdown-editor/src/flavors/gfm/schema.ts +70 -0
  92. package/app/vendor/markdown-editor/src/flavors/gfm/serializer.ts +82 -0
  93. package/app/vendor/markdown-editor/src/flavors/gfm/taskList.ts +39 -0
  94. package/app/vendor/markdown-editor/src/flavors/registry.ts +13 -0
  95. package/app/vendor/markdown-editor/src/flavors/shared/inputrules.ts +79 -0
  96. package/app/vendor/markdown-editor/src/flavors/shared/keymap.ts +89 -0
  97. package/app/vendor/markdown-editor/src/flavors/shared/placeholder.ts +24 -0
  98. package/app/vendor/markdown-editor/src/flavors/shared/plugins.ts +58 -0
  99. package/app/vendor/markdown-editor/src/flavors/types.ts +22 -0
  100. package/app/vendor/markdown-editor/src/formats/registry.ts +45 -0
  101. package/app/vendor/markdown-editor/src/icons.tsx +120 -0
  102. package/app/vendor/markdown-editor/src/index.ts +23 -0
  103. package/app/vendor/markdown-editor/src/theme.ts +249 -0
  104. package/app/vendor/markdown-editor/src/types.ts +58 -0
  105. package/app/vendor/plugin-sdk/src/__tests__/documentActions.test.ts +108 -0
  106. package/app/vendor/plugin-sdk/src/__tests__/loader.test.ts +75 -0
  107. package/app/vendor/plugin-sdk/src/__tests__/registry.test.ts +148 -0
  108. package/app/vendor/plugin-sdk/src/index.ts +33 -0
  109. package/app/vendor/plugin-sdk/src/loader.ts +45 -0
  110. package/app/vendor/plugin-sdk/src/navkeys.ts +17 -0
  111. package/app/vendor/plugin-sdk/src/react.tsx +155 -0
  112. package/app/vendor/plugin-sdk/src/registry.ts +337 -0
  113. package/app/vendor/plugin-sdk/src/types.ts +230 -0
  114. package/app/vendor/realtime/src/__tests__/pure.test.ts +58 -0
  115. package/app/vendor/realtime/src/__tests__/seeded-room.base64 +1 -0
  116. package/app/vendor/realtime/src/__tests__/seeding.test.ts +70 -0
  117. package/app/vendor/realtime/src/collab.ts +308 -0
  118. package/app/vendor/realtime/src/hooks.ts +57 -0
  119. package/app/vendor/realtime/src/index.ts +12 -0
  120. package/app/vendor/realtime/src/pure.ts +43 -0
  121. package/app/vite.config.mjs +59 -0
  122. package/bin/gogitcms-editor.mjs +168 -0
  123. package/npm-shrinkwrap.json +5696 -0
  124. package/package.json +74 -0
  125. package/src/commands/build.mjs +100 -0
  126. package/src/commands/dev.mjs +186 -0
  127. package/src/commands/init.mjs +144 -0
  128. package/src/commands/login.mjs +116 -0
  129. package/src/commands/status.mjs +68 -0
  130. package/src/lib/api.mjs +122 -0
  131. package/src/lib/appRoot.mjs +51 -0
  132. package/src/lib/baked.json +3 -0
  133. package/src/lib/config.mjs +108 -0
  134. package/src/lib/credentials.mjs +91 -0
  135. package/src/lib/defaults.mjs +26 -0
  136. package/src/lib/graphql.mjs +28 -0
  137. package/src/lib/localServer.mjs +206 -0
  138. package/src/lib/open.mjs +23 -0
  139. package/src/lib/plugins.mjs +164 -0
  140. package/src/lib/tokenBroker.mjs +85 -0
  141. package/src/tui/prompts.mjs +92 -0
  142. package/src/tui/screen.mjs +282 -0
  143. package/src/tui/theme.mjs +40 -0
@@ -0,0 +1,94 @@
1
+ // Build-time PostHog configuration, shared by every CMS front end.
2
+ //
3
+ // Each app resolves the raw values from wherever it gets configured — the
4
+ // frontend from cms.config.js or the server's window globals, the dashboard and
5
+ // desktop app from Vite env vars — and hands them here to be normalized. The
6
+ // key names are deliberately the same everywhere so operators only learn one
7
+ // vocabulary; only the transport differs.
8
+ //
9
+ // Analytics is off unless POSTHOG_API_KEY is present, matching the Go server's
10
+ // CMS_POSTHOG_API_KEY switch: a self-hosted CMS phones home to nobody.
11
+
12
+ /** Raw string values as they arrive from a config file or env vars. */
13
+ export type RawAnalyticsConfig = {
14
+ POSTHOG_API_KEY?: string;
15
+ POSTHOG_API_HOST?: string;
16
+ POSTHOG_UI_HOST?: string;
17
+ POSTHOG_ENVIRONMENT?: string;
18
+ /** "true" opts this build into session replay. Off unless set — see below. */
19
+ POSTHOG_SESSION_RECORDING?: string;
20
+ };
21
+
22
+ export type AnalyticsConfig = {
23
+ apiKey: string;
24
+ apiHost: string;
25
+ uiHost?: string;
26
+ environment?: string;
27
+ sessionRecording: boolean;
28
+ };
29
+
30
+ export const DEFAULT_API_HOST = "https://us.i.posthog.com";
31
+
32
+ const clean = (v: string | undefined | null): string | undefined => {
33
+ const s = typeof v === "string" ? v.trim() : "";
34
+ return s === "" ? undefined : s;
35
+ };
36
+
37
+ /**
38
+ * resolveAnalyticsConfig normalizes raw values into an AnalyticsConfig. An
39
+ * absent or blank POSTHOG_API_KEY yields an empty apiKey, which is what
40
+ * isAnalyticsEnabled keys off.
41
+ */
42
+ export function resolveAnalyticsConfig(raw: RawAnalyticsConfig | undefined | null): AnalyticsConfig {
43
+ const r = raw ?? {};
44
+ return {
45
+ apiKey: clean(r.POSTHOG_API_KEY) ?? "",
46
+ apiHost: clean(r.POSTHOG_API_HOST) ?? DEFAULT_API_HOST,
47
+ uiHost: clean(r.POSTHOG_UI_HOST),
48
+ environment: clean(r.POSTHOG_ENVIRONMENT),
49
+ // Opt-in, never opt-out. The CMS renders customer content — draft posts,
50
+ // unpublished copy, private repositories — and a session replay records all
51
+ // of it into a third party. That is a decision for whoever runs the
52
+ // deployment to make deliberately, not a default they inherit.
53
+ sessionRecording: clean(r.POSTHOG_SESSION_RECORDING) === "true",
54
+ };
55
+ }
56
+
57
+ export const isAnalyticsEnabled = (cfg: AnalyticsConfig): boolean => cfg.apiKey !== "";
58
+
59
+ /**
60
+ * posthogOptions maps an AnalyticsConfig onto posthog-js init options.
61
+ *
62
+ * The privacy-relevant choices, all of which matter for a CMS:
63
+ *
64
+ * person_profiles identified_only — anonymous visitors don't create person
65
+ * records; only signed-in users do, once identify() runs.
66
+ * autocapture on, but it records which element was clicked, never the
67
+ * text typed into it.
68
+ * mask_all_text on for session replay, so if a deployment does opt in, the
69
+ * recording still won't contain the content being edited.
70
+ *
71
+ * capture_exceptions is set explicitly rather than left to default: undefined
72
+ * means "whatever the project's remote config says", so error tracking silently
73
+ * depended on a PostHog UI toggle and was off for every deployment here. `true`
74
+ * hooks window.onerror and unhandledrejection, which is what turns a crashed
75
+ * render or a rejected API promise into an $exception. Console errors stay off
76
+ * (their default) — the CMS logs plenty that aren't faults.
77
+ */
78
+ export function posthogOptions(cfg: AnalyticsConfig): Record<string, unknown> {
79
+ return {
80
+ capture_exceptions: true,
81
+ api_host: cfg.apiHost,
82
+ ...(cfg.uiHost ? { ui_host: cfg.uiHost } : {}),
83
+ // Pins the behaviour of every option we don't set explicitly, so a
84
+ // posthog-js upgrade can't silently change what gets captured.
85
+ defaults: "2025-05-24",
86
+ person_profiles: "identified_only",
87
+ // Both SPAs drive real URLs, so history-based pageviews line up with the
88
+ // routes users actually see.
89
+ capture_pageview: "history_change",
90
+ disable_session_recording: !cfg.sessionRecording,
91
+ mask_all_text: true,
92
+ session_recording: { maskAllInputs: true },
93
+ };
94
+ }
@@ -0,0 +1,56 @@
1
+ // The CMS's product-analytics taxonomy.
2
+ //
3
+ // Every event name lives here rather than as a string literal at the call site,
4
+ // because a typo in an event name is invisible: nothing errors, the event just
5
+ // lands under a slightly different name and quietly splits a funnel in two.
6
+ // capture() only accepts these names, so a new event has to be added here first.
7
+ //
8
+ // Naming: snake_case, object then past-tense verb (workspace_created, not
9
+ // create_workspace), so related events sort together in PostHog's event list.
10
+ //
11
+ // PROPERTIES — what must never go in one:
12
+ // * Content. Document bodies, field values, and search query text are the
13
+ // customer's material, not ours to ship to a third party.
14
+ // * Personal data. Emails and names; identify() already ties events to a user
15
+ // ID, which is the join key for anything else.
16
+ // * Free-text a user typed. It reliably contains the two things above.
17
+ // Prefer opaque ids (joinable against the product database), schema metadata
18
+ // (collection and field names), counts, and enumerations.
19
+
20
+ export const AnalyticsEvent = {
21
+ // --- Dashboard: signup and onboarding ---
22
+ MagicLinkRequested: "magic_link_requested",
23
+ GitHubOAuthStarted: "github_oauth_started",
24
+ RegistrationCompleted: "registration_completed",
25
+ WorkspaceCreated: "workspace_created",
26
+
27
+ // --- Dashboard: connecting a repository ---
28
+ GitHubOrganizationConnected: "github_organization_connected",
29
+ GitHubRepositorySelected: "github_repository_selected",
30
+ RepositoryImportSucceeded: "repository_import_succeeded",
31
+ RepositoryImportFailed: "repository_import_failed",
32
+
33
+ // --- Dashboard: self-hosted editors ---
34
+ SelfHostedEditorModalOpened: "self_hosted_editor_modal_opened",
35
+ SelfHostedEditorAdded: "self_hosted_editor_added",
36
+
37
+ // --- Dashboard: opening the editor ---
38
+ // Properties: workspace_id, target ("cloud" | "self_hosted"), source (the
39
+ // surface the launch started from). Which editor was picked is an id, never
40
+ // the customer's own hostname.
41
+ EditorLaunched: "editor_launched",
42
+
43
+ // --- Editor: finding content ---
44
+ BasicSearchExecuted: "basic_search_executed",
45
+ AdvancedSearchExecuted: "advanced_search_executed",
46
+
47
+ // --- Editor: navigating content ---
48
+ TabOpened: "tab_opened",
49
+ TabClosed: "tab_closed",
50
+ CollectionViewed: "collection_viewed",
51
+ CollectionItemViewed: "collection_item_viewed",
52
+ ConfigurationItemViewed: "configuration_item_viewed",
53
+ } as const;
54
+
55
+ /** The only event names capture() accepts. */
56
+ export type AnalyticsEventName = (typeof AnalyticsEvent)[keyof typeof AnalyticsEvent];
@@ -0,0 +1,23 @@
1
+ // @gogitcms/analytics — PostHog for the CMS front ends.
2
+ //
3
+ // Configuration-driven and off by default, mirroring the Go server's
4
+ // packages/telemetry: with no POSTHOG_API_KEY baked into the build, the SDK is
5
+ // never loaded and nothing is sent. See config.ts for how each app supplies it.
6
+ export {
7
+ DEFAULT_API_HOST,
8
+ isAnalyticsEnabled,
9
+ posthogOptions,
10
+ resolveAnalyticsConfig,
11
+ type AnalyticsConfig,
12
+ type RawAnalyticsConfig,
13
+ } from "./config";
14
+ export { AnalyticsEvent, type AnalyticsEventName } from "./events";
15
+ export {
16
+ AnalyticsProvider,
17
+ captureException,
18
+ useAnalytics,
19
+ useIdentify,
20
+ type Analytics,
21
+ type AnalyticsProperties,
22
+ type AnalyticsProviderProps,
23
+ } from "./provider";
@@ -0,0 +1,196 @@
1
+ import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
2
+ import { type AnalyticsConfig, isAnalyticsEnabled, posthogOptions } from "./config";
3
+ import { type AnalyticsEventName } from "./events";
4
+
5
+ // The bit of posthog-js this package uses. Declared structurally rather than
6
+ // imported as a type from posthog-js, so nothing in the module graph reaches the
7
+ // SDK — a type-only import would be erased, but it is easy to turn one into a
8
+ // value import by accident and silently undo the code splitting below.
9
+ type PostHogClient = {
10
+ init(apiKey: string, options: Record<string, unknown>): void;
11
+ /** Only names from the AnalyticsEvent taxonomy — see events.ts. */
12
+ capture(event: AnalyticsEventName, properties?: AnalyticsProperties): void;
13
+ captureException(error: unknown, properties?: Record<string, unknown>): void;
14
+ identify(id: string, properties?: Record<string, unknown>): void;
15
+ register(properties: Record<string, unknown>): void;
16
+ reset(): void;
17
+ };
18
+
19
+ const ClientContext = createContext<PostHogClient | null>(null);
20
+
21
+ // The same client, reachable outside React. Errors are raised by plain modules
22
+ // too — the REST client is the main one — and those callers have no hook to
23
+ // call. Assigned by AnalyticsProvider, null whenever PostHog is unconfigured or
24
+ // its chunk is still loading.
25
+ let liveClient: PostHogClient | null = null;
26
+
27
+ // Exceptions raised before the SDK chunk arrives, held so they aren't lost. The
28
+ // dashboard fires its first API requests on mount, well before a ~230 kB import
29
+ // resolves, so the failures most worth seeing — the ones on initial load — are
30
+ // exactly the ones that would otherwise fall in that gap. Bounded because an
31
+ // app erroring in a loop must not grow this without limit.
32
+ const PENDING_LIMIT = 20;
33
+ let pending: { error: unknown; properties?: Record<string, unknown> }[] = [];
34
+
35
+ /**
36
+ * captureException reports a handled or unhandled error to PostHog's error
37
+ * tracking. Safe to call from anywhere, at any time: it no-ops when analytics is
38
+ * disabled and buffers when the SDK has not finished loading.
39
+ *
40
+ * Properties follow the same rule as event properties (see AnalyticsProperties):
41
+ * ids, statuses and paths, never content or personal data.
42
+ */
43
+ export function captureException(error: unknown, properties?: Record<string, unknown>): void {
44
+ if (liveClient) {
45
+ liveClient.captureException(error, properties);
46
+ return;
47
+ }
48
+ if (pending.length < PENDING_LIMIT) pending.push({ error, properties });
49
+ }
50
+
51
+ export type AnalyticsProviderProps = {
52
+ config: AnalyticsConfig;
53
+ children?: React.ReactNode;
54
+ };
55
+
56
+ /**
57
+ * AnalyticsProvider loads and initializes PostHog when config carries an API
58
+ * key, and does nothing at all when it doesn't.
59
+ *
60
+ * posthog-js is pulled in with a dynamic import() so the bundler splits it into
61
+ * its own chunk: a self-hosted build with no key never downloads the ~230 kB
62
+ * SDK, which is the whole point of analytics being opt-in. The rendered tree is
63
+ * the same either way — the client arrives through context once it has loaded —
64
+ * so children mount exactly once and never remount when it does.
65
+ *
66
+ * config must be a module-level constant (it is, in every app here): a new
67
+ * object each render would re-run the effect and re-initialize the SDK.
68
+ */
69
+ export function AnalyticsProvider({ config, children }: AnalyticsProviderProps) {
70
+ const [client, setClient] = useState<PostHogClient | null>(null);
71
+
72
+ useEffect(() => {
73
+ if (!isAnalyticsEnabled(config)) return;
74
+ let cancelled = false;
75
+ void import("posthog-js").then(({ default: posthog }) => {
76
+ // The app may have unmounted (or config changed) while the chunk was in
77
+ // flight; initializing then would leave an orphaned client capturing.
78
+ if (cancelled) return;
79
+ posthog.init(config.apiKey, posthogOptions(config));
80
+ // Tags every event with the deployment it came from, so production and
81
+ // staging can be told apart. Registered once rather than passed at each
82
+ // capture call site.
83
+ if (config.environment) posthog.register({ environment: config.environment });
84
+ const c = posthog as unknown as PostHogClient;
85
+ liveClient = c;
86
+ // Drain anything that failed while the chunk was in flight.
87
+ const buffered = pending;
88
+ pending = [];
89
+ for (const { error, properties } of buffered) c.captureException(error, properties);
90
+ setClient(c);
91
+ });
92
+ return () => {
93
+ cancelled = true;
94
+ };
95
+ }, [config]);
96
+
97
+ return <ClientContext.Provider value={client}>{children}</ClientContext.Provider>;
98
+ }
99
+
100
+ /**
101
+ * Event properties. Scalars only: nested objects arrive in PostHog as
102
+ * "[object Object]", and the flat shape discourages attaching whole records
103
+ * (which is how content and personal data leak into analytics).
104
+ */
105
+ export type AnalyticsProperties = Record<string, string | number | boolean | null | undefined>;
106
+
107
+ /** The subset of PostHog the CMS apps use, safe to call when disabled. */
108
+ export type Analytics = {
109
+ /** False when PostHog is unconfigured or its chunk has not loaded yet. */
110
+ enabled: boolean;
111
+ /** Only names from the AnalyticsEvent taxonomy — see events.ts. */
112
+ capture(event: AnalyticsEventName, properties?: AnalyticsProperties): void;
113
+ /**
114
+ * captureException reports an error the UI caught and handled — a failed save,
115
+ * a rejected request — which autocapture never sees precisely because it was
116
+ * caught. Unlike capture(), it buffers rather than dropping while the SDK
117
+ * loads, so it stays useful before `enabled` flips true.
118
+ */
119
+ captureException(error: unknown, properties?: Record<string, unknown>): void;
120
+ /**
121
+ * identify ties subsequent events to a signed-in user. The first argument is
122
+ * the user's ID — the same identifier the API uses, so PostHog persons join
123
+ * against the product database.
124
+ *
125
+ * properties become *person* properties, and this is the one place an email
126
+ * belongs: it makes a person record recognizable in PostHog without putting
127
+ * the address on every event (see the property rules in events.ts, which still
128
+ * hold). Pass it as `email`, the key PostHog's person UI reads.
129
+ */
130
+ identify(userId: string, properties?: Record<string, unknown>): void;
131
+ /** reset unlinks the session at sign-out, so the next user is not merged in. */
132
+ reset(): void;
133
+ };
134
+
135
+ const NOOP: Analytics = {
136
+ enabled: false,
137
+ capture: () => {},
138
+ // Not a no-op: buffered by the module-level captureException above, so an
139
+ // error during the SDK's load window still lands once it arrives.
140
+ captureException,
141
+ identify: () => {},
142
+ reset: () => {},
143
+ };
144
+
145
+ /**
146
+ * useAnalytics returns an Analytics facade. Every method is a no-op until (and
147
+ * unless) PostHog is configured and loaded, so callers never branch on it.
148
+ */
149
+ export function useAnalytics(): Analytics {
150
+ const client = useContext(ClientContext);
151
+ return useMemo(() => {
152
+ if (!client) return NOOP;
153
+ return {
154
+ enabled: true,
155
+ capture: (event, properties) => client.capture(event, properties),
156
+ captureException: (error, properties) => client.captureException(error, properties),
157
+ identify: (userId, properties) => client.identify(userId, properties),
158
+ reset: () => client.reset(),
159
+ };
160
+ }, [client]);
161
+ }
162
+
163
+ /**
164
+ * useIdentify keeps PostHog's identity in step with the signed-in user: it
165
+ * identifies on sign-in and resets on sign-out, including the sign-out that
166
+ * happens when a token expires. Pass undefined or null while signed out.
167
+ *
168
+ * Because it depends on the analytics facade, it also re-runs once the SDK
169
+ * finishes loading — so a user who was already signed in when the page opened
170
+ * still gets identified, rather than being missed by the race.
171
+ *
172
+ * Placing this in one hook means each app has a single line to add rather than
173
+ * its own identify/reset bookkeeping to get subtly wrong.
174
+ */
175
+ export function useIdentify(userId: string | undefined | null, properties?: Record<string, unknown>) {
176
+ const analytics = useAnalytics();
177
+ // Serialized so a fresh object literal at the call site doesn't re-identify
178
+ // on every render.
179
+ const propsKey = properties ? JSON.stringify(properties) : "";
180
+ // Whether we have identified anyone yet. Reset is only correct as a
181
+ // *transition* out of a signed-in session — calling it on first load would
182
+ // throw away the anonymous ID that ties a visitor's pre-login events to the
183
+ // account they then sign into.
184
+ const identified = useRef(false);
185
+
186
+ useEffect(() => {
187
+ if (!analytics.enabled) return;
188
+ if (userId) {
189
+ identified.current = true;
190
+ analytics.identify(userId, propsKey ? JSON.parse(propsKey) : undefined);
191
+ } else if (identified.current) {
192
+ identified.current = false;
193
+ analytics.reset();
194
+ }
195
+ }, [analytics, userId, propsKey]);
196
+ }
@@ -0,0 +1,86 @@
1
+ import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
2
+ import { useColorScheme, useWindowDimensions } from "react-native";
3
+ import { makeTheme, lightTheme, Theme, ThemeMode, DESKTOP_BREAKPOINT } from "./theme";
4
+
5
+ type ThemeContextValue = {
6
+ theme: Theme;
7
+ mode: ThemeMode;
8
+ setMode: (mode: ThemeMode) => void;
9
+ toggleMode: () => void;
10
+ };
11
+
12
+ const ThemeContext = createContext<ThemeContextValue | null>(null);
13
+
14
+ export type ThemeProviderProps = {
15
+ children: React.ReactNode;
16
+ /** Force a mode; omit to follow the system color scheme. */
17
+ initialMode?: ThemeMode;
18
+ };
19
+
20
+ export function ThemeProvider({ children, initialMode }: ThemeProviderProps) {
21
+ const system = useColorScheme();
22
+ const [override, setOverride] = useState<ThemeMode | undefined>(initialMode);
23
+ const mode: ThemeMode = override ?? (system === "dark" ? "dark" : "light");
24
+
25
+ const theme = useMemo(() => makeTheme(mode), [mode]);
26
+ const setMode = useCallback((m: ThemeMode) => setOverride(m), []);
27
+ const toggleMode = useCallback(
28
+ () => setOverride((m) => ((m ?? mode) === "dark" ? "light" : "dark")),
29
+ [mode]
30
+ );
31
+
32
+ const value = useMemo(
33
+ () => ({ theme, mode, setMode, toggleMode }),
34
+ [theme, mode, setMode, toggleMode]
35
+ );
36
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
37
+ }
38
+
39
+ /**
40
+ * Overrides the theme palette for a subtree while keeping the app's real
41
+ * mode/setMode/toggleMode intact — so a nested control (e.g. the top bar's
42
+ * theme toggle) still drives the whole app, not just the scoped subtree.
43
+ * Used to render the dark-chrome top bar's inline contents against a dark
44
+ * palette regardless of the active app theme.
45
+ */
46
+ export function ThemeScope({ theme, children }: { theme: Theme; children: React.ReactNode }) {
47
+ const ctx = useContext(ThemeContext);
48
+ const value = useMemo<ThemeContextValue>(
49
+ () => ({
50
+ theme,
51
+ mode: ctx?.mode ?? "light",
52
+ setMode: ctx?.setMode ?? (() => {}),
53
+ toggleMode: ctx?.toggleMode ?? (() => {}),
54
+ }),
55
+ [theme, ctx]
56
+ );
57
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
58
+ }
59
+
60
+ // Both hooks fall back to the light theme when no provider is mounted, so
61
+ // components never crash in isolation (tests, Storybook-style previews).
62
+ export function useTheme(): Theme {
63
+ const ctx = useContext(ThemeContext);
64
+ return ctx ? ctx.theme : lightTheme;
65
+ }
66
+
67
+ export function useThemeMode() {
68
+ const ctx = useContext(ThemeContext);
69
+ if (ctx) return { mode: ctx.mode, setMode: ctx.setMode, toggleMode: ctx.toggleMode };
70
+ return { mode: "light" as ThemeMode, setMode: () => {}, toggleMode: () => {} };
71
+ }
72
+
73
+ /**
74
+ * Responsive layout switch. Works on native and web (react-native-web) via
75
+ * useWindowDimensions, so the same components pick desktop vs mobile layout
76
+ * from the viewport width — no CSS media queries needed.
77
+ */
78
+ export function useResponsive() {
79
+ const { width, height } = useWindowDimensions();
80
+ return {
81
+ width,
82
+ height,
83
+ isDesktop: width >= DESKTOP_BREAKPOINT,
84
+ isMobile: width < DESKTOP_BREAKPOINT,
85
+ };
86
+ }
@@ -0,0 +1,148 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { ApplyChangesModal, type MergeProgress } from "../components/ApplyChangesModal";
5
+
6
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
7
+
8
+ const base: MergeProgress = {
9
+ status: "running",
10
+ step: "comparing",
11
+ sourceBranch: "draft/spring",
12
+ targetBranch: "main",
13
+ };
14
+
15
+ const branches = { sourceBranch: "draft/spring", targetBranch: "main" };
16
+
17
+ test("without a run it shows a confirmation step explaining the effects", () => {
18
+ const onConfirm = jest.fn();
19
+ render(wrap(<ApplyChangesModal {...branches} summary="3 collections changed" onConfirm={onConfirm} onClose={() => {}} />));
20
+
21
+ const confirm = screen.getByTestId("apply-confirm");
22
+ // The irreversible, outward-facing effects are spelled out.
23
+ expect(confirm).toHaveTextContent("merges draft/spring into main");
24
+ expect(confirm).toHaveTextContent("pushes a merge commit to origin/main");
25
+ expect(confirm).toHaveTextContent("draft/spring is then deleted");
26
+ expect(confirm).toHaveTextContent("can’t be undone");
27
+ expect(confirm).toHaveTextContent("3 collections changed");
28
+
29
+ // The checklist is not shown until the merge actually starts.
30
+ expect(screen.queryByTestId("merge-step-active")).not.toBeInTheDocument();
31
+
32
+ fireEvent.click(screen.getByTestId("apply-confirm-submit"));
33
+ expect(onConfirm).toHaveBeenCalledTimes(1);
34
+ });
35
+
36
+ test("the checklist advances: earlier steps done, current active, later pending", () => {
37
+ render(wrap(<ApplyChangesModal {...branches} progress={{ ...base, step: "merging" }} onClose={() => {}} />));
38
+
39
+ // comparing is behind us → done; merging is current → active; pushing → pending.
40
+ expect(screen.getAllByTestId("merge-step-done")).toHaveLength(1);
41
+ expect(screen.getAllByTestId("merge-step-active")).toHaveLength(1);
42
+ expect(screen.getAllByTestId("merge-step-pending")).toHaveLength(1);
43
+ });
44
+
45
+ test("the header shows source → target", () => {
46
+ render(wrap(<ApplyChangesModal {...branches} progress={base} onClose={() => {}} />));
47
+ expect(screen.getByText("draft/spring")).toBeInTheDocument();
48
+ expect(screen.getByText("main")).toBeInTheDocument();
49
+ });
50
+
51
+ test("success shows the merge panel with commit and stats, and a Done button", () => {
52
+ const onDone = jest.fn();
53
+ render(
54
+ wrap(
55
+ <ApplyChangesModal
56
+ {...branches}
57
+ progress={{
58
+ ...base,
59
+ status: "succeeded",
60
+ step: "pushing",
61
+ commit: "b7e1a2c9deadbeef",
62
+ filesChanged: 3,
63
+ added: 64,
64
+ removed: 6,
65
+ }}
66
+ onClose={() => {}}
67
+ onDone={onDone}
68
+ />,
69
+ ),
70
+ );
71
+
72
+ const panel = screen.getByTestId("merge-success");
73
+ expect(panel).toHaveTextContent("Merged draft/spring into main");
74
+ expect(panel).toHaveTextContent("b7e1a2c"); // short sha
75
+ expect(panel).toHaveTextContent("3 files");
76
+ expect(panel).toHaveTextContent("+64 −6");
77
+
78
+ // Every step reads as done on success.
79
+ expect(screen.getAllByTestId("merge-step-done")).toHaveLength(3);
80
+ expect(screen.getByTestId("apply-done")).toBeInTheDocument();
81
+ });
82
+
83
+ test("needs_review lists the conflicting files and escalates with an explanation", () => {
84
+ const onCreateChangeRequest = jest.fn();
85
+ render(
86
+ wrap(
87
+ <ApplyChangesModal
88
+ {...branches}
89
+ progress={{ ...base, status: "needs_review", step: "merging", escalationFiles: ["src/app.go", "package.json"] }}
90
+ onCreateChangeRequest={onCreateChangeRequest}
91
+ onClose={() => {}}
92
+ />,
93
+ ),
94
+ );
95
+
96
+ const panel = screen.getByTestId("merge-needs-review");
97
+ expect(panel).toHaveTextContent("Code conflicts need a developer");
98
+ expect(screen.getByTestId("escalation-file-src/app.go")).toBeInTheDocument();
99
+ expect(screen.getByTestId("escalation-file-package.json")).toBeInTheDocument();
100
+
101
+ // The submit is disabled until an explanation is entered.
102
+ const submit = screen.getByTestId("create-change-request-submit");
103
+ fireEvent.click(submit);
104
+ expect(onCreateChangeRequest).not.toHaveBeenCalled();
105
+
106
+ fireEvent.change(screen.getByTestId("change-request-explanation"), { target: { value: "Updated pricing copy" } });
107
+ fireEvent.click(submit);
108
+ expect(onCreateChangeRequest).toHaveBeenCalledWith("Updated pricing copy");
109
+ });
110
+
111
+ test("an open change request replaces the confirm step with a shareable link and view button", () => {
112
+ const onViewChangeRequest = jest.fn();
113
+ render(
114
+ wrap(
115
+ <ApplyChangesModal
116
+ {...branches}
117
+ changeRequestOpen
118
+ changeRequestUrl="https://github.com/acme/site/pull/42"
119
+ onViewChangeRequest={onViewChangeRequest}
120
+ onClose={() => {}}
121
+ />,
122
+ ),
123
+ );
124
+ const panel = screen.getByTestId("apply-cr-open");
125
+ expect(panel).toHaveTextContent("A change request is already open");
126
+ expect(panel).toHaveTextContent("Send them this link");
127
+ // The normal apply confirmation is not offered.
128
+ expect(screen.queryByTestId("apply-confirm-submit")).not.toBeInTheDocument();
129
+ // The PR link is shown read-only with a copy button.
130
+ expect(screen.getByTestId("change-request-link")).toHaveValue("https://github.com/acme/site/pull/42");
131
+ expect(screen.getByTestId("copy-change-request-link")).toBeInTheDocument();
132
+ fireEvent.click(screen.getByTestId("apply-view-change-request"));
133
+ expect(onViewChangeRequest).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ test("failure surfaces the error and a dismiss button", () => {
137
+ render(
138
+ wrap(
139
+ <ApplyChangesModal
140
+ {...branches}
141
+ progress={{ ...base, status: "failed", step: "pushing", error: "main moved while merging" }}
142
+ onClose={() => {}}
143
+ />,
144
+ ),
145
+ );
146
+ expect(screen.getByTestId("merge-error")).toHaveTextContent("main moved while merging");
147
+ expect(screen.getByTestId("apply-dismiss")).toBeInTheDocument();
148
+ });
@@ -0,0 +1,46 @@
1
+ import React from "react";
2
+ import { render, fireEvent, screen } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { BranchImportingScreen, BranchImportFailedScreen } from "../components/BranchImport";
5
+
6
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
7
+
8
+ test("the importing screen names the branch it is waiting on", () => {
9
+ render(wrap(<BranchImportingScreen branch="release/2.0" repository="acme/site" />));
10
+ expect(screen.getByTestId("branch-importing")).toBeInTheDocument();
11
+ expect(screen.getByText("Importing release/2.0")).toBeInTheDocument();
12
+ expect(screen.getByText(/acme\/site/)).toBeInTheDocument();
13
+ });
14
+
15
+ test("a missing config reads as a setup step, not an error", () => {
16
+ render(
17
+ wrap(
18
+ <BranchImportFailedScreen
19
+ branch="main"
20
+ repository="acme/site"
21
+ error="no go-git-cms config found"
22
+ errorCode="config_not_found"
23
+ />,
24
+ ),
25
+ );
26
+ expect(screen.getByText("main has no CMS config yet")).toBeInTheDocument();
27
+ expect(screen.getByText(/go-git-cms\.yml/)).toBeInTheDocument();
28
+ // The raw message is redundant once the screen has said what to commit.
29
+ expect(screen.queryByTestId("branch-import-error")).not.toBeInTheDocument();
30
+ });
31
+
32
+ test("any other failure shows the run's own message", () => {
33
+ render(wrap(<BranchImportFailedScreen branch="main" error="clone: permission denied" />));
34
+ expect(screen.getByText("Couldn’t import main")).toBeInTheDocument();
35
+ expect(screen.getByTestId("branch-import-error")).toHaveTextContent("clone: permission denied");
36
+ });
37
+
38
+ test("check again is offered only when the host can re-read the state", () => {
39
+ const onRecheck = jest.fn();
40
+ const { rerender } = render(wrap(<BranchImportFailedScreen branch="main" errorCode="config_not_found" />));
41
+ expect(screen.queryByTestId("branch-import-recheck")).not.toBeInTheDocument();
42
+
43
+ rerender(wrap(<BranchImportFailedScreen branch="main" errorCode="config_not_found" onRecheck={onRecheck} />));
44
+ fireEvent.click(screen.getByTestId("branch-import-recheck"));
45
+ expect(onRecheck).toHaveBeenCalled();
46
+ });