overmux 0.0.1 → 0.0.2

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 (193) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +7 -0
  3. package/dist/auth/.vite/manifest.json +11 -0
  4. package/dist/auth/assets/index-Bx8JhwVi.css +1 -0
  5. package/dist/auth/assets/index-CHIE7qYj.js +72 -0
  6. package/dist/auth/index.html +14 -0
  7. package/dist/bin.js +4504 -7870
  8. package/dist/bin.js.map +1 -0
  9. package/dist/docs/000-index.md +21 -0
  10. package/dist/docs/100-introduction/100-what-is-overmux.md +7 -0
  11. package/dist/docs/100-introduction/200-how-overmux-works.md +7 -0
  12. package/dist/docs/100-introduction/300-why-overmux.md +22 -0
  13. package/dist/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
  14. package/dist/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
  15. package/dist/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
  16. package/dist/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
  17. package/dist/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
  18. package/dist/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
  19. package/dist/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
  20. package/dist/docs/300-fundamentals/100-project-structure.md +3 -0
  21. package/dist/docs/300-fundamentals/200-configuration.md +3 -0
  22. package/dist/docs/300-fundamentals/300-theming.md +3 -0
  23. package/dist/docs/300-fundamentals/400-server.md +3 -0
  24. package/dist/docs/300-fundamentals/500-client.md +3 -0
  25. package/dist/docs/300-fundamentals/600-operations.md +3 -0
  26. package/dist/docs/300-fundamentals/700-resources.md +3 -0
  27. package/dist/docs/300-fundamentals/800-streams.md +3 -0
  28. package/dist/docs/300-fundamentals/900-authentication-and-security.md +3 -0
  29. package/dist/docs/400-reference/100-configuration.md +3 -0
  30. package/dist/docs/400-reference/200-server-api.md +3 -0
  31. package/dist/docs/400-reference/300-client-api.md +3 -0
  32. package/dist/docs/400-reference/400-cli/100-serve.md +33 -0
  33. package/dist/docs/400-reference/400-cli/200-auth.md +52 -0
  34. package/dist/docs/400-reference/400-cli/300-call.md +28 -0
  35. package/dist/docs/400-reference/400-cli/400-check.md +20 -0
  36. package/dist/docs/400-reference/400-cli/500-ai-context.md +102 -0
  37. package/dist/docs/400-reference/400-cli/600-docs.md +23 -0
  38. package/dist/docs/400-reference/400-cli/700-desktop.md +35 -0
  39. package/dist/docs/500-hosted-pages.md +31 -0
  40. package/dist/exports/client.d.ts +237 -0
  41. package/dist/exports/client.d.ts.map +1 -0
  42. package/dist/exports/client.js +1643 -0
  43. package/dist/exports/client.js.map +1 -0
  44. package/dist/exports/index-D3_Dpy_T.d.ts +155 -0
  45. package/dist/exports/index-D3_Dpy_T.d.ts.map +1 -0
  46. package/dist/exports/index.d.ts +2 -0
  47. package/dist/exports/index.js +49 -0
  48. package/dist/exports/index.js.map +1 -0
  49. package/dist/exports/notifications-kDp16bU_.js +56 -0
  50. package/dist/exports/notifications-kDp16bU_.js.map +1 -0
  51. package/dist/exports/server.d.ts +63 -0
  52. package/dist/exports/server.d.ts.map +1 -0
  53. package/dist/exports/server.js +1120 -0
  54. package/dist/exports/server.js.map +1 -0
  55. package/dist/exports/style.css +368 -0
  56. package/dist/internal/server/coordinator/server-child.d.ts +1 -0
  57. package/dist/internal/server/coordinator/server-child.js +3299 -0
  58. package/dist/internal/server/coordinator/server-child.js.map +1 -0
  59. package/docs/000-index.md +21 -0
  60. package/docs/100-introduction/100-what-is-overmux.md +7 -0
  61. package/docs/100-introduction/200-how-overmux-works.md +7 -0
  62. package/docs/100-introduction/300-why-overmux.md +22 -0
  63. package/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
  64. package/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
  65. package/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
  66. package/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
  67. package/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
  68. package/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
  69. package/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
  70. package/docs/300-fundamentals/100-project-structure.md +3 -0
  71. package/docs/300-fundamentals/200-configuration.md +3 -0
  72. package/docs/300-fundamentals/300-theming.md +3 -0
  73. package/docs/300-fundamentals/400-server.md +3 -0
  74. package/docs/300-fundamentals/500-client.md +3 -0
  75. package/docs/300-fundamentals/600-operations.md +3 -0
  76. package/docs/300-fundamentals/700-resources.md +3 -0
  77. package/docs/300-fundamentals/800-streams.md +3 -0
  78. package/docs/300-fundamentals/900-authentication-and-security.md +3 -0
  79. package/docs/400-reference/100-configuration.md +3 -0
  80. package/docs/400-reference/200-server-api.md +3 -0
  81. package/docs/400-reference/300-client-api.md +3 -0
  82. package/docs/400-reference/400-cli/100-serve.md +33 -0
  83. package/docs/400-reference/400-cli/200-auth.md +52 -0
  84. package/docs/400-reference/400-cli/300-call.md +28 -0
  85. package/docs/400-reference/400-cli/400-check.md +20 -0
  86. package/docs/400-reference/400-cli/500-ai-context.md +102 -0
  87. package/docs/400-reference/400-cli/600-docs.md +23 -0
  88. package/docs/400-reference/400-cli/700-desktop.md +35 -0
  89. package/docs/500-hosted-pages.md +31 -0
  90. package/package.json +59 -18
  91. package/src/internal/cli/app.ts +72 -0
  92. package/src/internal/cli/bin.ts +3 -0
  93. package/src/internal/cli/commands/ai.ts +44 -0
  94. package/src/internal/cli/commands/auth.ts +195 -0
  95. package/src/internal/cli/commands/call.ts +128 -0
  96. package/src/internal/cli/commands/check.ts +48 -0
  97. package/src/internal/cli/commands/desktop-installer.ts +446 -0
  98. package/src/internal/cli/commands/desktop-release.ts +190 -0
  99. package/src/internal/cli/commands/desktop.ts +47 -0
  100. package/src/internal/cli/commands/docs.ts +34 -0
  101. package/src/internal/cli/commands/serve.ts +322 -0
  102. package/src/internal/cli/environment.ts +54 -0
  103. package/src/internal/cli/login.ts +46 -0
  104. package/src/internal/cli/managed-vite.ts +267 -0
  105. package/src/internal/cli/parse-port.ts +8 -0
  106. package/src/internal/client/auth/auth-api.ts +53 -0
  107. package/src/internal/client/auth/auth-shell.css +74 -0
  108. package/src/internal/client/auth/auth-shell.tsx +167 -0
  109. package/src/internal/client/auth/entry.tsx +26 -0
  110. package/src/internal/client/auth/index.html +13 -0
  111. package/src/internal/client/background-notifications.ts +194 -0
  112. package/src/internal/client/browser-api.ts +97 -0
  113. package/src/internal/client/client-definition.ts +184 -0
  114. package/src/internal/client/commands.tsx +260 -0
  115. package/src/internal/client/host/browser-log-forwarding.ts +149 -0
  116. package/src/internal/client/host/hosted-pages/browser-notification-settings.tsx +118 -0
  117. package/src/internal/client/host/hosted-pages/hosted-page.css +87 -0
  118. package/src/internal/client/host/hosted-pages/hosted-page.tsx +9 -0
  119. package/src/internal/client/host/hosted-pages/logout-page.tsx +75 -0
  120. package/src/internal/client/host/hosted-pages/settings-page.tsx +45 -0
  121. package/src/internal/client/host/notification-forwarding.ts +22 -0
  122. package/src/internal/client/host/overmux-host.tsx +200 -0
  123. package/src/internal/client/host/recovery-screen.css +75 -0
  124. package/src/internal/client/host/recovery-screen.tsx +17 -0
  125. package/src/internal/client/host/update-popover.css +82 -0
  126. package/src/internal/client/host/update-popover.tsx +166 -0
  127. package/src/internal/client/index.ts +45 -0
  128. package/src/internal/client/overmux-react.ts +283 -0
  129. package/src/internal/client/shortcuts.tsx +373 -0
  130. package/src/internal/client/theme-scope.css +114 -0
  131. package/src/internal/client/theme-scope.tsx +107 -0
  132. package/src/internal/client/transport.ts +69 -0
  133. package/src/internal/client/websocket.ts +441 -0
  134. package/src/internal/server/auth/auth-http.ts +414 -0
  135. package/src/internal/server/auth/auth-service.ts +627 -0
  136. package/src/internal/server/auth/auth-shell-http.ts +90 -0
  137. package/src/internal/server/auth/instance-control.ts +381 -0
  138. package/src/internal/server/check-overmux.ts +364 -0
  139. package/src/internal/server/coordinator/ipc-protocol.ts +44 -0
  140. package/src/internal/server/coordinator/server-child.ts +108 -0
  141. package/src/internal/server/coordinator/server-coordinator.ts +262 -0
  142. package/src/internal/server/coordinator/update-watcher.ts +54 -0
  143. package/src/internal/server/http/authenticated-http-app-test-helper.ts +45 -0
  144. package/src/internal/server/http/background-notification-handlers.ts +83 -0
  145. package/src/internal/server/http/control-handlers.ts +44 -0
  146. package/src/internal/server/http/create-http-app.ts +53 -0
  147. package/src/internal/server/http/development-web-backend.ts +83 -0
  148. package/src/internal/server/http/operation-handler.ts +93 -0
  149. package/src/internal/server/http/request-logging.ts +34 -0
  150. package/src/internal/server/http/routes.ts +241 -0
  151. package/src/internal/server/http/runtime-manifest-handler.ts +12 -0
  152. package/src/internal/server/index.ts +16 -0
  153. package/src/internal/server/notifications/background-notification-payload.ts +74 -0
  154. package/src/internal/server/notifications/background-notification-service.ts +139 -0
  155. package/src/internal/server/notifications/background-notification-store.ts +162 -0
  156. package/src/internal/server/notifications/notification-service.ts +70 -0
  157. package/src/internal/server/notifications/private-json-file.ts +84 -0
  158. package/src/internal/server/paths.ts +13 -0
  159. package/src/internal/server/rate-limiter.ts +58 -0
  160. package/src/internal/server/runtime/create-runtime.ts +87 -0
  161. package/src/internal/server/runtime/resource-dependency-graph.ts +128 -0
  162. package/src/internal/server/runtime/runtime-lifecycle.ts +95 -0
  163. package/src/internal/server/runtime/runtime-operations.ts +78 -0
  164. package/src/internal/server/runtime/runtime-resources.ts +201 -0
  165. package/src/internal/server/runtime/runtime-streams.ts +130 -0
  166. package/src/internal/server/server-logger.ts +110 -0
  167. package/src/internal/server/server-startup-options.ts +51 -0
  168. package/src/internal/server/start-application-server.ts +412 -0
  169. package/src/internal/server/start-overmux-server.ts +26 -0
  170. package/src/internal/server/test-port.ts +23 -0
  171. package/src/internal/server/vite-websocket-proxy.ts +201 -0
  172. package/src/internal/server/websocket-connection.ts +586 -0
  173. package/src/internal/server/websocket-limits.ts +2 -0
  174. package/src/internal/server/websocket-server.ts +311 -0
  175. package/src/internal/shared/background-notifications.ts +37 -0
  176. package/src/internal/shared/index.ts +2 -0
  177. package/src/internal/shared/protocol.ts +317 -0
  178. package/src/internal/shared/routes.ts +3 -0
  179. package/src/public/ai-context.ts +44 -0
  180. package/src/public/client.ts +53 -0
  181. package/src/public/config.ts +287 -0
  182. package/src/public/contracts.ts +55 -0
  183. package/src/public/index.ts +57 -0
  184. package/src/public/notifications.ts +10 -0
  185. package/src/public/server.ts +16 -0
  186. package/dist/public/core.ts +0 -119
  187. package/dist/public/protocol.ts +0 -51
  188. package/dist/public/terminal.ts +0 -23
  189. package/dist/web/assets/index-DC___IUw.css +0 -2
  190. package/dist/web/assets/index-DF9Cptse.js +0 -74
  191. package/dist/web/assets/terminal-component-BrP-ENHg.css +0 -1
  192. package/dist/web/assets/terminal-component-DyLEbMrx.js +0 -36
  193. package/dist/web/index.html +0 -14
@@ -0,0 +1,283 @@
1
+ import type { ContractInputArguments } from "../../public/index";
2
+ import {
3
+ useMutation,
4
+ useQuery,
5
+ useQueryClient,
6
+ type UseMutationResult,
7
+ } from "@tanstack/react-query";
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
+ import type { z } from "zod";
10
+
11
+ import { skipToken, type SkipToken, useRuntime } from "./commands";
12
+
13
+ type Resources<TConfig> = TConfig extends { resources?: infer TResources }
14
+ ? NonNullable<TResources>
15
+ : never;
16
+ type Operations<TConfig> = TConfig extends { operations?: infer TOperations }
17
+ ? NonNullable<TOperations>
18
+ : never;
19
+ type Streams<TConfig> = TConfig extends { streams?: infer TStreams }
20
+ ? NonNullable<TStreams>
21
+ : never;
22
+ type ContractOf<T> = T extends { contract: infer TContract }
23
+ ? TContract
24
+ : never;
25
+ type InputOf<T> = T extends { input: infer TInput extends z.ZodType }
26
+ ? TInput
27
+ : never;
28
+ type OutputOf<T> = T extends { output: infer TOutput extends z.ZodType }
29
+ ? z.output<TOutput>
30
+ : never;
31
+ type ClientMessageOf<T> =
32
+ ContractOf<T> extends {
33
+ clientMessage: infer TMessage extends z.ZodType;
34
+ }
35
+ ? z.input<TMessage>
36
+ : never;
37
+ type ServerMessageOf<T> =
38
+ ContractOf<T> extends {
39
+ serverMessage: infer TMessage extends z.ZodType;
40
+ }
41
+ ? z.output<TMessage>
42
+ : never;
43
+ type InputProperty<TInput extends z.ZodType> =
44
+ undefined extends z.input<TInput>
45
+ ? { input?: Exclude<z.input<TInput>, undefined> }
46
+ : { input: z.input<TInput> };
47
+ type OperationOptions<TId extends string, TInput extends z.ZodType> = {
48
+ id: TId;
49
+ } & InputProperty<TInput>;
50
+ type UseResource<TServerConfig> = {
51
+ <TId extends Extract<keyof Resources<TServerConfig>, string>>(
52
+ options: OperationOptions<
53
+ TId,
54
+ InputOf<ContractOf<Resources<TServerConfig>[TId]>>
55
+ >,
56
+ ): ResourceResult<OutputOf<ContractOf<Resources<TServerConfig>[TId]>>>;
57
+ <TId extends Extract<keyof Resources<TServerConfig>, string>>(options: {
58
+ id: TId;
59
+ input: SkipToken;
60
+ }): undefined;
61
+ <TId extends Extract<keyof Resources<TServerConfig>, string>>(options: {
62
+ id: TId;
63
+ input:
64
+ | z.input<InputOf<ContractOf<Resources<TServerConfig>[TId]>>>
65
+ | SkipToken;
66
+ }):
67
+ | ResourceResult<OutputOf<ContractOf<Resources<TServerConfig>[TId]>>>
68
+ | undefined;
69
+ };
70
+
71
+ export type ResourceResult<T> =
72
+ | {
73
+ data?: never;
74
+ error?: never;
75
+ refetch: () => void;
76
+ status: "pending";
77
+ }
78
+ | {
79
+ data?: T;
80
+ error: Error;
81
+ refetch: () => void;
82
+ status: "error";
83
+ }
84
+ | {
85
+ data: T;
86
+ error?: never;
87
+ refetch: () => void;
88
+ status: "success";
89
+ };
90
+
91
+ export type StreamResult<TClientMessage, TServerMessage> = {
92
+ close: () => void;
93
+ error?: Error;
94
+ send: (message: TClientMessage) => boolean;
95
+ status: "closed" | "open" | "opening";
96
+ subscribe: (listener: (message: TServerMessage) => void) => () => void;
97
+ };
98
+
99
+ type OperationResult<TInputSchema extends z.ZodType, TOutput> = Omit<
100
+ UseMutationResult<TOutput, Error, z.input<TInputSchema>>,
101
+ "mutate" | "mutateAsync"
102
+ > & {
103
+ mutate: (...args: ContractInputArguments<TInputSchema>) => void;
104
+ mutateAsync: (
105
+ ...args: ContractInputArguments<TInputSchema>
106
+ ) => Promise<TOutput>;
107
+ };
108
+
109
+ const inputKey = (input: unknown) => JSON.stringify(input) ?? "";
110
+
111
+ const useCanonicalInput = (input: unknown) => {
112
+ const key = inputKey(input);
113
+ return useMemo(() => (key ? JSON.parse(key) : undefined), [key]);
114
+ };
115
+
116
+ const useResourceById = <TOutput>(
117
+ id: string,
118
+ input: unknown,
119
+ skipped: boolean,
120
+ ): ResourceResult<TOutput> | undefined => {
121
+ const { manifest, overmuxServerApi } = useRuntime();
122
+ const queryClient = useQueryClient();
123
+ const canonicalInput = useCanonicalInput(input);
124
+ const queryKey = useMemo(
125
+ () => [id, canonicalInput] as const,
126
+ [canonicalInput, id],
127
+ );
128
+ const query = useQuery<TOutput, Error>({
129
+ enabled: !skipped,
130
+ queryFn: () =>
131
+ overmuxServerApi.readResource({
132
+ id,
133
+ input: canonicalInput,
134
+ }) as Promise<TOutput>,
135
+ queryKey,
136
+ });
137
+ useEffect(
138
+ () =>
139
+ !skipped && manifest.resources.includes(id)
140
+ ? overmuxServerApi.subscribeResource({
141
+ id,
142
+ input: canonicalInput,
143
+ onError: () =>
144
+ void queryClient.invalidateQueries({ exact: true, queryKey }),
145
+ onInvalidate: () =>
146
+ void queryClient.invalidateQueries({ exact: true, queryKey }),
147
+ })
148
+ : undefined,
149
+ [
150
+ manifest.resources,
151
+ canonicalInput,
152
+ id,
153
+ overmuxServerApi,
154
+ queryClient,
155
+ queryKey,
156
+ skipped,
157
+ ],
158
+ );
159
+ const refetch = useCallback(() => {
160
+ void queryClient.invalidateQueries({ exact: true, queryKey });
161
+ }, [queryClient, queryKey]);
162
+ if (skipped) {
163
+ return undefined;
164
+ }
165
+ if (query.status === "pending") {
166
+ return { refetch, status: "pending" };
167
+ }
168
+ if (query.status === "error") {
169
+ return query.data === undefined
170
+ ? { error: query.error, refetch, status: "error" }
171
+ : { data: query.data, error: query.error, refetch, status: "error" };
172
+ }
173
+ return { data: query.data, refetch, status: "success" };
174
+ };
175
+
176
+ const useOperationById = <TInputSchema extends z.ZodType, TOutput>(
177
+ id: string,
178
+ signal?: AbortSignal,
179
+ ): OperationResult<TInputSchema, TOutput> => {
180
+ const { overmuxServerApi } = useRuntime();
181
+ return useMutation<TOutput, Error, z.input<TInputSchema>>({
182
+ mutationFn: (input) =>
183
+ overmuxServerApi.executeOperation(id, input, signal) as Promise<TOutput>,
184
+ }) as OperationResult<TInputSchema, TOutput>;
185
+ };
186
+
187
+ const useStreamById = <TClientMessage, TServerMessage>(
188
+ id: string,
189
+ input: unknown,
190
+ ): StreamResult<TClientMessage, TServerMessage> => {
191
+ const { manifest, overmuxServerApi } = useRuntime();
192
+ const streamAvailable = manifest.streams.includes(id);
193
+ const [state, setState] = useState<{
194
+ error?: Error;
195
+ status: "closed" | "open" | "opening";
196
+ }>({ status: "opening" });
197
+ const listeners = useMemo(
198
+ () => new Set<(message: TServerMessage) => void>(),
199
+ [],
200
+ );
201
+ const canonicalInput = useCanonicalInput(input);
202
+ const streamRef = useRef<
203
+ ReturnType<typeof overmuxServerApi.openStream> | undefined
204
+ >(undefined);
205
+ useEffect(() => {
206
+ streamRef.current?.close();
207
+ streamRef.current = undefined;
208
+ if (!streamAvailable) {
209
+ setState({ status: "closed" });
210
+ return;
211
+ }
212
+ setState({ status: "opening" });
213
+ const stream = overmuxServerApi.openStream({
214
+ id,
215
+ input: canonicalInput,
216
+ onClose: () => setState({ status: "closed" }),
217
+ onError: (error) => setState({ error, status: "closed" }),
218
+ onOpen: () => setState({ status: "open" }),
219
+ onMessage: (message) =>
220
+ listeners.forEach((listener) => listener(message as TServerMessage)),
221
+ });
222
+ streamRef.current = stream;
223
+ return () => {
224
+ stream.close();
225
+ if (streamRef.current === stream) {
226
+ streamRef.current = undefined;
227
+ }
228
+ };
229
+ }, [canonicalInput, id, listeners, overmuxServerApi, streamAvailable]);
230
+ const close = useCallback(() => {
231
+ streamRef.current?.close();
232
+ streamRef.current = undefined;
233
+ setState({ status: "closed" });
234
+ }, []);
235
+ const send = useCallback(
236
+ (message: TClientMessage) => streamRef.current?.send(message) ?? false,
237
+ [],
238
+ );
239
+ const subscribe = useCallback(
240
+ (listener: (message: TServerMessage) => void) => {
241
+ listeners.add(listener);
242
+ return () => listeners.delete(listener);
243
+ },
244
+ [listeners],
245
+ );
246
+ return {
247
+ close,
248
+ error: streamAvailable
249
+ ? state.error
250
+ : new Error(`Stream is not available: ${id}`),
251
+ send,
252
+ status: streamAvailable ? state.status : "closed",
253
+ subscribe,
254
+ };
255
+ };
256
+
257
+ export const createOvermuxHooks = <TServerConfig>() => ({
258
+ useOperation: <TId extends Extract<keyof Operations<TServerConfig>, string>>({
259
+ id,
260
+ signal,
261
+ }: {
262
+ id: TId;
263
+ signal?: AbortSignal;
264
+ }) => {
265
+ type TInputSchema = InputOf<Operations<TServerConfig>[TId]>;
266
+ type TOutput = OutputOf<Operations<TServerConfig>[TId]>;
267
+ return useOperationById<TInputSchema, TOutput>(id, signal);
268
+ },
269
+ useResource: (({ id, input }: { id: string; input?: unknown }) =>
270
+ useResourceById(
271
+ id,
272
+ input,
273
+ input === skipToken,
274
+ )) as UseResource<TServerConfig>,
275
+ useStream: <TId extends Extract<keyof Streams<TServerConfig>, string>>({
276
+ id,
277
+ input,
278
+ }: OperationOptions<TId, InputOf<ContractOf<Streams<TServerConfig>[TId]>>>) =>
279
+ useStreamById<
280
+ ClientMessageOf<Streams<TServerConfig>[TId]>,
281
+ ServerMessageOf<Streams<TServerConfig>[TId]>
282
+ >(id, input),
283
+ });
@@ -0,0 +1,373 @@
1
+ import {
2
+ type ReactNode,
3
+ createContext,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useState,
8
+ type RefObject,
9
+ } from "react";
10
+ import {
11
+ matchesKeyBinding,
12
+ platformBinding,
13
+ type KeyBinding,
14
+ } from "@overmux/keybindings";
15
+
16
+ import {
17
+ activeCommandBindings,
18
+ configuredCommandBindings,
19
+ shortcutMediaQueries,
20
+ type ClientAppDefinition,
21
+ type CommandHandle,
22
+ type ShortcutBinding,
23
+ } from "./client-definition";
24
+ import {
25
+ CommandsContext,
26
+ createCommandEntries,
27
+ executeCommand,
28
+ selectCommandRegistration,
29
+ type RegisteredCommand,
30
+ } from "./commands";
31
+
32
+ const bindingSequence = (binding: ShortcutBinding): KeyBinding[] =>
33
+ typeof binding === "string" ? [binding] : [...binding];
34
+
35
+ const normalizedBinding = (binding: ShortcutBinding): string[] =>
36
+ bindingSequence(binding).map((key) => platformBinding(key));
37
+
38
+ const sequenceMatches = (
39
+ binding: ShortcutBinding,
40
+ pending: string[],
41
+ event: KeyboardEvent,
42
+ ) => {
43
+ const sequence = normalizedBinding(binding);
44
+ return (
45
+ sequence.length > pending.length &&
46
+ pending.every((key, index) => sequence[index] === key) &&
47
+ matchesKeyBinding(sequence[pending.length] as KeyBinding, event)
48
+ );
49
+ };
50
+
51
+ const isTextInput = (element: Element | null) =>
52
+ element instanceof HTMLInputElement ||
53
+ element instanceof HTMLSelectElement ||
54
+ element instanceof HTMLTextAreaElement ||
55
+ (element instanceof HTMLElement &&
56
+ (element.isContentEditable || element.getAttribute("role") === "textbox"));
57
+
58
+ const acceptsTextInput = (bindings: readonly ShortcutBinding[]) => {
59
+ if (!(document.activeElement instanceof Element)) {
60
+ return false;
61
+ }
62
+ const terminal = document.activeElement.closest(".xterm");
63
+ if (!terminal) {
64
+ return false;
65
+ }
66
+ if (terminal.closest("[data-copy-mode]")) {
67
+ return true;
68
+ }
69
+ return bindings.some((binding) => {
70
+ const sequence = bindingSequence(binding);
71
+ return (
72
+ sequence.length > 1 ||
73
+ sequence[0]?.startsWith("F") ||
74
+ sequence[0]?.includes("+")
75
+ );
76
+ });
77
+ };
78
+
79
+ type Shortcut = {
80
+ bindings: readonly [ShortcutBinding, ...ShortcutBinding[]];
81
+ command: CommandHandle;
82
+ };
83
+
84
+ const flattenShortcuts = (
85
+ definition: ClientAppDefinition,
86
+ matchesMedia: (media: string) => boolean,
87
+ ): Shortcut[] =>
88
+ Object.values(definition.commands).flatMap((command) => {
89
+ const bindings = activeCommandBindings(
90
+ configuredCommandBindings(definition, command),
91
+ matchesMedia,
92
+ );
93
+ return bindings.length
94
+ ? [
95
+ {
96
+ bindings: bindings as [ShortcutBinding, ...ShortcutBinding[]],
97
+ command,
98
+ },
99
+ ]
100
+ : [];
101
+ });
102
+
103
+ const canRunShortcut = (
104
+ shortcut: Shortcut,
105
+ event: KeyboardEvent,
106
+ registrations: ReadonlySet<RegisteredCommand>,
107
+ ) => {
108
+ const selected = selectCommandRegistration(
109
+ shortcut.command,
110
+ registrations,
111
+ document.activeElement,
112
+ );
113
+ return Boolean(
114
+ selected?.enabled() &&
115
+ !event.repeat &&
116
+ !(
117
+ isTextInput(document.activeElement) &&
118
+ !acceptsTextInput(shortcut.bindings)
119
+ ),
120
+ );
121
+ };
122
+
123
+ const runShortcut = (
124
+ shortcut: Shortcut,
125
+ event: KeyboardEvent,
126
+ registrations: ReadonlySet<RegisteredCommand>,
127
+ ) => {
128
+ if (!canRunShortcut(shortcut, event, registrations)) {
129
+ return false;
130
+ }
131
+ void executeCommand(shortcut.command, registrations);
132
+ event.preventDefault();
133
+ return true;
134
+ };
135
+
136
+ export type ShortcutInputTarget = {
137
+ container: RefObject<HTMLElement | null>;
138
+ input: RefObject<HTMLElement | null>;
139
+ };
140
+ type RegisterShortcutInputTarget = (target: ShortcutInputTarget) => () => void;
141
+
142
+ const ShortcutInputTargetsContext = createContext<
143
+ RegisterShortcutInputTarget | undefined
144
+ >(undefined);
145
+
146
+ export const useShortcutInputTarget = (target: ShortcutInputTarget) => {
147
+ const register = useContext(ShortcutInputTargetsContext);
148
+ const { container, input } = target;
149
+ useEffect(
150
+ () => register?.({ container, input }),
151
+ [container, input, register],
152
+ );
153
+ };
154
+
155
+ const elementDepth = (element: HTMLElement) => {
156
+ let depth = 0;
157
+ for (
158
+ let current: HTMLElement | null = element;
159
+ current;
160
+ current = current.parentElement
161
+ ) {
162
+ depth += 1;
163
+ }
164
+ return depth;
165
+ };
166
+
167
+ const selectShortcutInputTarget = (
168
+ targets: ReadonlySet<ShortcutInputTarget>,
169
+ activeElement: Element | null,
170
+ ) =>
171
+ [...targets]
172
+ .filter((target) => {
173
+ const container = target.container.current;
174
+ return Boolean(
175
+ container && activeElement && container.contains(activeElement),
176
+ );
177
+ })
178
+ .reduce<ShortcutInputTarget | undefined>((selected, candidate) => {
179
+ const selectedElement = selected?.container.current;
180
+ const candidateElement = candidate.container.current;
181
+ if (!selectedElement || !candidateElement) {
182
+ return candidate;
183
+ }
184
+ return elementDepth(candidateElement) >= elementDepth(selectedElement)
185
+ ? candidate
186
+ : selected;
187
+ }, undefined);
188
+
189
+ const replayedKeyboardEvents = new WeakSet<KeyboardEvent>();
190
+ const keyboardEventProperties = ["charCode", "keyCode", "which"] as const;
191
+
192
+ const replayKeyboardEvent = (target: HTMLElement, source: KeyboardEvent) => {
193
+ const replay = new KeyboardEvent(source.type, {
194
+ altKey: source.altKey,
195
+ bubbles: true,
196
+ cancelable: true,
197
+ code: source.code,
198
+ composed: true,
199
+ ctrlKey: source.ctrlKey,
200
+ key: source.key,
201
+ location: source.location,
202
+ metaKey: source.metaKey,
203
+ repeat: source.repeat,
204
+ shiftKey: source.shiftKey,
205
+ });
206
+ keyboardEventProperties.forEach((property) =>
207
+ Object.defineProperty(replay, property, { value: source[property] }),
208
+ );
209
+ replayedKeyboardEvents.add(replay);
210
+ target.dispatchEvent(replay);
211
+ };
212
+
213
+ const replayChord = (
214
+ target: ShortcutInputTarget | undefined,
215
+ events: readonly KeyboardEvent[],
216
+ ) => {
217
+ const input = target?.input.current;
218
+ if (!input) {
219
+ return;
220
+ }
221
+ events.forEach((event) => replayKeyboardEvent(input, event));
222
+ };
223
+
224
+ export const ShortcutHost = ({
225
+ children,
226
+ definition,
227
+ registrations,
228
+ registrationRevision,
229
+ }: {
230
+ children: ReactNode;
231
+ definition: ClientAppDefinition;
232
+ registrations: Set<RegisteredCommand>;
233
+ registrationRevision: number;
234
+ }) => {
235
+ const [, setFocusRevision] = useState(0);
236
+ const [inputTargets] = useState(() => new Set<ShortcutInputTarget>());
237
+ const [mediaRevision, setMediaRevision] = useState(0);
238
+ const registerInputTarget = useCallback(
239
+ (target: ShortcutInputTarget) => {
240
+ inputTargets.add(target);
241
+ return () => {
242
+ inputTargets.delete(target);
243
+ };
244
+ },
245
+ [inputTargets],
246
+ );
247
+ useEffect(() => {
248
+ const refreshMedia = () => setMediaRevision((revision) => revision + 1);
249
+ const queries = shortcutMediaQueries(definition).map((media) =>
250
+ window.matchMedia(media),
251
+ );
252
+ queries.forEach((query) => query.addEventListener("change", refreshMedia));
253
+ return () =>
254
+ queries.forEach((query) =>
255
+ query.removeEventListener("change", refreshMedia),
256
+ );
257
+ }, [definition]);
258
+ useEffect(() => {
259
+ const refreshFocus = () => setFocusRevision((revision) => revision + 1);
260
+ window.addEventListener("focusin", refreshFocus);
261
+ return () => window.removeEventListener("focusin", refreshFocus);
262
+ }, []);
263
+ useEffect(() => {
264
+ const shortcuts = flattenShortcuts(
265
+ definition,
266
+ (media) => window.matchMedia(media).matches,
267
+ );
268
+ let pending: string[] = [];
269
+ let pendingEvents: KeyboardEvent[] = [];
270
+ let pendingTarget: ShortcutInputTarget | undefined;
271
+ let timeout: number | undefined;
272
+ const clearPending = () => {
273
+ pending = [];
274
+ pendingEvents = [];
275
+ pendingTarget = undefined;
276
+ if (timeout !== undefined) {
277
+ window.clearTimeout(timeout);
278
+ timeout = undefined;
279
+ }
280
+ };
281
+ const replayPending = (events = pendingEvents) => {
282
+ replayChord(pendingTarget, events);
283
+ clearPending();
284
+ };
285
+ const scheduleTimeout = () => {
286
+ if (timeout !== undefined) {
287
+ window.clearTimeout(timeout);
288
+ }
289
+ timeout = window.setTimeout(() => replayPending(), 1_000);
290
+ };
291
+ const onKeyDown = (event: KeyboardEvent) => {
292
+ if (replayedKeyboardEvents.has(event)) {
293
+ return;
294
+ }
295
+ if (pending.length && event.key === "Escape") {
296
+ event.preventDefault();
297
+ event.stopPropagation();
298
+ clearPending();
299
+ return;
300
+ }
301
+ const matches = shortcuts.flatMap((shortcut) =>
302
+ shortcut.bindings
303
+ .filter(
304
+ (binding) =>
305
+ sequenceMatches(binding, pending, event) &&
306
+ canRunShortcut(shortcut, event, registrations),
307
+ )
308
+ .map((binding) => ({ binding, shortcut })),
309
+ );
310
+ if (!matches.length) {
311
+ if (pending.length) {
312
+ event.preventDefault();
313
+ event.stopPropagation();
314
+ replayPending([...pendingEvents, event]);
315
+ }
316
+ return;
317
+ }
318
+ const complete = matches.find(
319
+ ({ binding }) =>
320
+ normalizedBinding(binding).length === pending.length + 1,
321
+ );
322
+ if (complete) {
323
+ clearPending();
324
+ if (runShortcut(complete.shortcut, event, registrations)) {
325
+ event.stopPropagation();
326
+ }
327
+ return;
328
+ }
329
+ if (!pending.length) {
330
+ const shouldReplay = definition.chordPrefixes?.some((prefix) =>
331
+ matchesKeyBinding(prefix.binding, event),
332
+ );
333
+ pendingTarget = shouldReplay
334
+ ? selectShortcutInputTarget(inputTargets, document.activeElement)
335
+ : undefined;
336
+ }
337
+ pending = [
338
+ ...pending,
339
+ normalizedBinding(matches[0]!.binding)[pending.length]!,
340
+ ];
341
+ pendingEvents = [...pendingEvents, event];
342
+ event.preventDefault();
343
+ event.stopPropagation();
344
+ scheduleTimeout();
345
+ };
346
+ window.addEventListener("keydown", onKeyDown, { capture: true });
347
+ return () => {
348
+ window.removeEventListener("keydown", onKeyDown, { capture: true });
349
+ if (timeout !== undefined) {
350
+ window.clearTimeout(timeout);
351
+ }
352
+ };
353
+ }, [
354
+ definition,
355
+ inputTargets,
356
+ mediaRevision,
357
+ registrationRevision,
358
+ registrations,
359
+ ]);
360
+ const commands = createCommandEntries(
361
+ definition,
362
+ registrations,
363
+ () => document.activeElement,
364
+ (media) => window.matchMedia(media).matches,
365
+ );
366
+ return (
367
+ <ShortcutInputTargetsContext.Provider value={registerInputTarget}>
368
+ <CommandsContext.Provider value={commands}>
369
+ {children}
370
+ </CommandsContext.Provider>
371
+ </ShortcutInputTargetsContext.Provider>
372
+ );
373
+ };