WorkspaceEntry.svelte 14 KB

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