WorkspaceEntry.svelte 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. <script lang="ts">
  2. import * as Select from "$lib/components/ui/select";
  3. import {
  4. state as _state,
  5. deleteBody,
  6. deleteHeader,
  7. insertHeader,
  8. selectEntry,
  9. sendRequest,
  10. setEntryAuth,
  11. updateBodyContent,
  12. updateEntryName,
  13. updateHeader,
  14. updateUrl,
  15. } from "$lib/state.svelte";
  16. import { Button } from "$lib/components/ui/button";
  17. import { Input } from "$lib/components/ui/input";
  18. import * as Tabs from "$lib/components/ui/tabs";
  19. import type { UrlError, WorkspaceEntry } from "$lib/types";
  20. import Editable from "./Editable.svelte";
  21. import Highlight, { LineNumbers } from "svelte-highlight";
  22. import json from "svelte-highlight/languages/json";
  23. import { atelierForest } from "svelte-highlight/styles";
  24. import { Loader, PlusIcon, Trash } from "@lucide/svelte";
  25. import CodeMirror from "./CodeMirror.svelte";
  26. import * as Resizable from "$lib/components/ui/resizable/index";
  27. import AuthParams from "./AuthParams.svelte";
  28. import Checkbox from "./ui/checkbox/checkbox.svelte";
  29. let requestPane: Resizable.Pane;
  30. let responsePane: Resizable.Pane;
  31. let isSending = $state(false);
  32. let response: any = $state();
  33. const parentAuth = $derived.by(() => {
  34. let parentId = _state.entry!!.parent_id;
  35. while (parentId != null) {
  36. const parent = _state.indexes[parentId];
  37. if (!parent) {
  38. console.warn("Parent index is null", parentId);
  39. return;
  40. }
  41. if (parent.auth_inherit) {
  42. parentId = parent.id;
  43. continue;
  44. }
  45. if (parent.auth == null) {
  46. return null;
  47. }
  48. return _state.auth.find((a) => a.id === parent.auth) ?? null;
  49. }
  50. });
  51. const referenceChain = $derived.by(() => {
  52. const parents = [];
  53. let parent = _state.entry!!.parent_id;
  54. while (parent != null) {
  55. parents.push(_state.indexes[parent]);
  56. parent = _state.indexes[parent].parent_id;
  57. }
  58. return parents.reverse();
  59. });
  60. async function handleSendRequest() {
  61. if (isSending) return;
  62. console.time("request");
  63. isSending = true;
  64. try {
  65. response = await sendRequest();
  66. } catch (e) {
  67. console.error("error sending request", e);
  68. } finally {
  69. if (responsePane.getSize() === 0) {
  70. requestPane.resize(50);
  71. responsePane.resize(50);
  72. }
  73. console.timeEnd("request");
  74. isSending = false;
  75. }
  76. }
  77. async function handleUrlUpdate(direct: boolean = false) {
  78. const u = direct ? _state.entry!!.url : reconstructUrl();
  79. try {
  80. await updateUrl(u, !direct);
  81. } catch (err) {
  82. console.error(err);
  83. const e = err as UrlError;
  84. switch (e.type) {
  85. case "Parse": {
  86. console.error("url parse error", e.error);
  87. break;
  88. }
  89. case "DuplicatePath": {
  90. console.error("url duplicate path error", e.error);
  91. }
  92. case "Db": {
  93. console.error("url persist error", e.error);
  94. break;
  95. }
  96. }
  97. return;
  98. }
  99. }
  100. /** Construct a URL from the binded input values for query and path parameters. */
  101. function reconstructUrl(): string {
  102. let url = _state.entry.workingUrl.pre;
  103. for (const param of _state.entry.workingUrl.path) {
  104. const [name, position] = param.value;
  105. if (param.type === "Static") {
  106. url += "/" + name;
  107. continue;
  108. }
  109. const replacement = _state.entry!!.path.find(
  110. (p) => p.position === position,
  111. );
  112. if (replacement !== undefined) {
  113. url += "/:" + replacement.name;
  114. } else {
  115. url += "/:" + name;
  116. }
  117. }
  118. if (_state.entry.workingUrl.query_params.length > 0) {
  119. url +=
  120. "?" +
  121. _state.entry
  122. .workingUrl!!.query_params.map((p) => `${p[0]}=${p[1]}`)
  123. .join("&");
  124. } else if (_state.entry.workingUrl!!.has_query) {
  125. url += "?";
  126. }
  127. return url;
  128. }
  129. </script>
  130. <svelte:head>
  131. {@html atelierForest}
  132. </svelte:head>
  133. {#snippet entryPath()}
  134. <!-- ENTRY PATH -->
  135. <div class="h-8 flex items-center">
  136. {#each referenceChain as ref}
  137. <Button
  138. class="p-0 h-fit cursor-pointer"
  139. onclick={() => selectEntry(ref.id)}
  140. variant="ghost"
  141. >
  142. {ref.name || ref.type + "(" + ref.id + ")"}
  143. </Button>
  144. <p class="pl-1 pr-1">/</p>
  145. {/each}
  146. <Editable
  147. bind:value={_state.entry!!.name}
  148. onSave={(value) => {
  149. updateEntryName(value);
  150. }}
  151. >
  152. {#snippet display({ value, startEdit })}
  153. <h1 ondblclick={startEdit}>
  154. {value || _state.entry!!.type + "(" + _state.entry!!.id + ")"}
  155. </h1>
  156. {/snippet}
  157. </Editable>
  158. </div>
  159. {/snippet}
  160. {#snippet authParams(
  161. entry: WorkspaceEntry & { auth: number | null; auth_inherit: boolean },
  162. )}
  163. <div class="w-full p-4 pl-2">
  164. <Select.Root
  165. type="single"
  166. value={entry.auth_inherit ? "inherit" : (entry.auth?.toString() ?? "-")}
  167. >
  168. <Select.Trigger>
  169. {#if entry.auth != null && !entry.auth_inherit}
  170. {_state.auth.find((a) => a.id === entry.auth)?.name}
  171. {:else if entry.auth_inherit}
  172. Inherit ({parentAuth?.name})
  173. {:else}
  174. -
  175. {/if}
  176. </Select.Trigger>
  177. <Select.Content>
  178. <Select.Item onclick={() => setEntryAuth(null, false)} value="-">
  179. -
  180. </Select.Item>
  181. {#if entry.parent_id != null}
  182. <Select.Item onclick={() => setEntryAuth(null, true)} value="inherit">
  183. Inherit ({parentAuth?.name})
  184. </Select.Item>
  185. {/if}
  186. <Select.Separator />
  187. {#each _state.auth as auth}
  188. <Select.Item
  189. onclick={() => setEntryAuth(auth.id, false)}
  190. value={auth.id.toString()}
  191. >
  192. {auth.name}
  193. </Select.Item>
  194. {/each}
  195. </Select.Content>
  196. </Select.Root>
  197. </div>
  198. {#if entry.auth_inherit && parentAuth}
  199. <div class="opacity-75 p-4">
  200. <AuthParams auth={parentAuth} readonly={true} />
  201. </div>
  202. {:else if entry.auth}
  203. <div class="opacity-75 p-4">
  204. <AuthParams
  205. auth={_state.auth.find((a) => a.id === entry.auth)!!}
  206. readonly={true}
  207. />
  208. </div>
  209. {/if}
  210. {/snippet}
  211. {#if _state.entry?.type === "Collection"}
  212. <!-- COLLECTION VIEW -->
  213. {@render entryPath()}
  214. <div class="flex flex-wrap p-2 border">
  215. <h2 class="pb-2 w-full border-b">Auth</h2>
  216. {@render authParams(_state.entry!!)}
  217. </div>
  218. {:else if _state.entry?.type === "Request"}
  219. <!-- REQUEST WORK AREA -->
  220. {@render entryPath()}
  221. <section class="h-[90%] space-y-4">
  222. <!-- URL BAR -->
  223. <div class="flex flex-wrap gap-3 mx-auto">
  224. <Input
  225. class="w-10/12 flex font-mono"
  226. bind:value={_state.entry.url}
  227. placeholder="https://api.example.com/resource"
  228. oninput={() => {
  229. handleUrlUpdate(true);
  230. }}
  231. />
  232. <Button
  233. class="w-1/12 flex items-center justify-center gap-2"
  234. disabled={isSending}
  235. onclick={handleSendRequest}
  236. >
  237. {#if isSending}
  238. <Loader class="h-4 w-4 animate-spin" />
  239. Sending
  240. {:else}
  241. Send
  242. {/if}
  243. </Button>
  244. <p class="w-full pl-1 text-xs text-muted-foreground">
  245. {_state.entry.expandedUrl ?? ""}
  246. </p>
  247. </div>
  248. <!-- ================= REQUEST PANEL ================= -->
  249. <Resizable.PaneGroup direction="vertical" class="flex-1 w-full rounded-lg">
  250. <Resizable.Pane defaultSize={100} bind:this={requestPane}>
  251. <Tabs.Root value="params" class="h-full flex flex-col">
  252. <Tabs.List class="shrink-0">
  253. <Tabs.Trigger value="params">Parameters</Tabs.Trigger>
  254. <Tabs.Trigger value="headers">Headers</Tabs.Trigger>
  255. <Tabs.Trigger value="body">Body</Tabs.Trigger>
  256. <Tabs.Trigger value="auth">Auth</Tabs.Trigger>
  257. </Tabs.List>
  258. <div class="flex-1 overflow-auto p-2">
  259. <!-- ================= PARAMETERS ================= -->
  260. <Tabs.Content value="params" class="space-y-4">
  261. {#if _state.entry.path.length > 0}
  262. <div>
  263. <h3 class="mb-2 text-sm font-medium">Path</h3>
  264. <div class="grid grid-cols-2 gap-2 text-sm">
  265. {#each _state.entry.path as param}
  266. <Input
  267. bind:value={param.name}
  268. placeholder="key"
  269. oninput={() => handleUrlUpdate()}
  270. />
  271. <Input
  272. bind:value={param.value}
  273. placeholder="value"
  274. oninput={() => handleUrlUpdate()}
  275. />
  276. {/each}
  277. </div>
  278. </div>
  279. {/if}
  280. {#if _state.entry.workingUrl?.query_params.length > 0}
  281. <div>
  282. <h3 class="mb-2 text-sm font-medium">Query</h3>
  283. <div class="grid grid-cols-2 gap-2 text-sm">
  284. {#each _state.entry.workingUrl.query_params as param}
  285. <Input
  286. bind:value={param[0]}
  287. placeholder="key"
  288. oninput={() => handleUrlUpdate()}
  289. />
  290. <Input
  291. bind:value={param[1]}
  292. placeholder="value"
  293. oninput={() => handleUrlUpdate()}
  294. />
  295. {/each}
  296. </div>
  297. </div>
  298. {/if}
  299. </Tabs.Content>
  300. <!-- ================= HEADERS ================= -->
  301. <Tabs.Content value="headers">
  302. <div
  303. class="w-10/12 mx-auto grid grid-cols-[auto_1fr] gap-2 items-center"
  304. >
  305. {#each _state.entry.headers as header}
  306. <div class="contents">
  307. <Input
  308. bind:value={header.name}
  309. placeholder="Name"
  310. oninput={() =>
  311. updateHeader(header.id, header.name, header.value)}
  312. />
  313. <Input
  314. bind:value={header.value}
  315. placeholder="Value"
  316. oninput={() =>
  317. updateHeader(header.id, header.name, header.value)}
  318. />
  319. <Trash
  320. class="h-4 w-4 cursor-pointer text-muted-foreground hover:text-destructive"
  321. onclick={() => deleteHeader(header.id)}
  322. />
  323. </div>
  324. {/each}
  325. <PlusIcon
  326. class="border p-1 rounded-2xl mx-auto col-span-3 cursor-pointer"
  327. onclick={() => insertHeader()}
  328. />
  329. </div>
  330. </Tabs.Content>
  331. <!-- ================= BODY ================= -->
  332. <Tabs.Content value="body" class="space-y-4">
  333. <Tabs.Root value={_state.entry.body === null ? "none" : "json"}>
  334. <Tabs.List>
  335. <Tabs.Trigger value="none" onclick={deleteBody}
  336. >None</Tabs.Trigger
  337. >
  338. <Tabs.Trigger value="json">JSON</Tabs.Trigger>
  339. <Tabs.Trigger value="form">Form</Tabs.Trigger>
  340. <Tabs.Trigger value="text">Text</Tabs.Trigger>
  341. </Tabs.List>
  342. <Tabs.Content value="json">
  343. <CodeMirror
  344. input={_state.entry.body?.body}
  345. onStateChange={(update) => {
  346. if (
  347. update.docChanged &&
  348. _state.entry.body?.body !== update.state.doc.toString()
  349. ) {
  350. updateBodyContent(update.state.doc.toString(), "Json");
  351. }
  352. }}
  353. />
  354. </Tabs.Content>
  355. <Tabs.Content value="form">
  356. <p class="text-sm text-muted-foreground">
  357. Form body editor coming soon.
  358. </p>
  359. </Tabs.Content>
  360. <Tabs.Content value="text">
  361. <textarea
  362. class="w-full min-h-[200px] rounded-md border bg-background p-2 font-mono text-sm"
  363. placeholder="Raw text body"
  364. ></textarea>
  365. </Tabs.Content>
  366. </Tabs.Root>
  367. </Tabs.Content>
  368. <!-- ================= AUTH ================= -->
  369. <Tabs.Content value="auth" class="space-y-4">
  370. {@render authParams(_state.entry!!)}
  371. </Tabs.Content>
  372. </div>
  373. </Tabs.Root>
  374. </Resizable.Pane>
  375. <Resizable.Handle
  376. withHandle
  377. class="p-1.5"
  378. ondblclick={() => {
  379. requestPane.resize(50);
  380. responsePane.resize(50);
  381. }}
  382. />
  383. <!-- RESPONSE -->
  384. <Resizable.Pane class="p-2" defaultSize={0} bind:this={responsePane}>
  385. {#if isSending}
  386. <div class="flex justify-center py-8">
  387. <Loader class="h-6 w-6 animate-spin text-muted-foreground" />
  388. </div>
  389. {:else if response}
  390. <!-- Prevents line number selection -->
  391. <p>{response.status}</p>
  392. <div
  393. class="
  394. w-full
  395. [&_td:first-child]:select-none
  396. [&_td:first-child]:pointer-events-none
  397. "
  398. >
  399. <Highlight
  400. language={json}
  401. code={response.body.Json}
  402. let:highlighted
  403. >
  404. <LineNumbers {highlighted} wrapLines hideBorder />
  405. </Highlight>
  406. </div>
  407. {/if}
  408. </Resizable.Pane>
  409. </Resizable.PaneGroup>
  410. </section>
  411. {/if}