Эх сурвалжийг харах

add help menu and shortcuts

biblius 1 долоо хоног өмнө
parent
commit
d027b6846a

+ 0 - 2
src/lib/components/Auth.svelte

@@ -4,12 +4,10 @@
     createAuth,
     deleteAuth,
     renameAuth,
-    updateAuthParams,
   } from "$lib/state.svelte";
 
   import { Button } from "$lib/components/ui/button";
   import { Trash, Plus } from "@lucide/svelte";
-  import { Input } from "./ui/input";
   import Editable from "./Editable.svelte";
   import AuthParams from "./AuthParams.svelte";
 </script>

+ 81 - 20
src/lib/components/Environment.svelte

@@ -1,19 +1,15 @@
 <script lang="ts">
   import {
     state as _state,
-    createEnvironment,
     deleteEnvVariable,
     insertEnvVariable,
-    selectEnvironment,
     updateEnvironment,
     updateEnvVariable,
   } from "$lib/state.svelte";
-  import * as Select from "$lib/components/ui/select";
   import { Input } from "$lib/components/ui/input";
-  import Button from "./ui/button/button.svelte";
   import Editable from "./Editable.svelte";
   import type { EnvVariable } from "$lib/types";
-  import { Plus, Trash } from "@lucide/svelte";
+  import { ChevronDown, ChevronRight, Plus, Trash } from "@lucide/svelte";
 
   let placeholderKey = $state("");
   let placeholderVal = $state("");
@@ -21,6 +17,54 @@
   let errorMessage = $state("");
   let showTooltip = $state(false);
 
+  let openVariableGroups: Record<string, boolean> = $state({});
+
+  let variableGroups: { group: string; vars: EnvVariable[] }[] = $derived.by(
+    () => {
+      if (!_state.environment) {
+        return [];
+      }
+
+      const groups: Record<string, EnvVariable[]> = {};
+
+      for (const v of _state.environment.variables) {
+        const [group] = v.name.split("_");
+        if (groups[group] != null) {
+          groups[group].push(v);
+        } else {
+          groups[group] = [v];
+        }
+      }
+
+      const entries = Object.entries(groups);
+      entries.sort((a, b) => a[1].length - b[1].length);
+
+      const vars = [];
+
+      for (const [group, vs] of entries) {
+        vars.push({ group, vars: vs });
+      }
+
+      return vars;
+    },
+  );
+
+  function isGroupOpen(group) {
+    return (
+      openVariableGroups[group.group] === undefined ||
+      openVariableGroups[group.group]
+    );
+  }
+
+  function toggleGroupOpen(group) {
+    if (openVariableGroups[group.group] === undefined) {
+      openVariableGroups[group.group] = false;
+      return;
+    }
+
+    openVariableGroups[group.group] = !openVariableGroups[group.group];
+  }
+
   async function handlePlaceholder() {
     if (placeholderKey && placeholderVal) {
       try {
@@ -68,21 +112,38 @@
 
     <!-- Variables -->
     <div class="grid grid-cols-[1fr_1fr_auto] items-center gap-3">
-      {#each _state.environment.variables as v}
-        <Input
-          class="font-mono"
-          bind:value={v.name}
-          oninput={() => handleUpdate(v)}
-        />
-        <Input
-          class="font-mono"
-          bind:value={v.value}
-          oninput={() => handleUpdate(v)}
-        />
-        <Trash
-          class="h-4 w-4 cursor-pointer text-muted-foreground hover:text-destructive"
-          onclick={() => deleteEnvVariable(v.id)}
-        />
+      {#each variableGroups as group}
+        {#if group.vars.length > 1}
+          <h3
+            class="flex items-center text-xs text-muted-foreground border-b col-span-3 hover:border-b-secondary hover:text-primary cursor-default"
+            onclick={() => toggleGroupOpen(group)}
+          >
+            {#if isGroupOpen(group)}
+              <ChevronDown class="size-3" />
+            {:else}
+              <ChevronRight class="size-3" />
+            {/if}
+            <p class="ml-1">{group.group}</p>
+          </h3>
+        {/if}
+        {#if isGroupOpen(group)}
+          {#each group.vars as v}
+            <Input
+              class="font-mono"
+              bind:value={v.name}
+              oninput={() => handleUpdate(v)}
+            />
+            <Input
+              class="font-mono"
+              bind:value={v.value}
+              oninput={() => handleUpdate(v)}
+            />
+            <Trash
+              class="h-4 w-4 cursor-pointer text-muted-foreground hover:text-destructive"
+              onclick={() => deleteEnvVariable(v.id)}
+            />
+          {/each}
+        {/if}
       {/each}
     </div>
 

+ 38 - 0
src/lib/components/Shortcuts.svelte

@@ -0,0 +1,38 @@
+<script>
+  export let shortcuts = [
+    { modifiers: ["Ctrl"], key: "Enter", action: "Send request" },
+    { modifiers: ["Ctrl"], key: "I", action: "Select next request" },
+    { modifiers: ["Ctrl"], key: "O", action: "Select previous request" },
+    { modifiers: ["Ctrl"], key: "H", action: "Toggle help" },
+  ];
+</script>
+
+<div
+  class="
+    rounded-md border bg-popover shadow-md
+    p-4 h-full
+    text-sm text-popover-foreground
+  "
+>
+  <h4 class="mb-2 font-medium">Keyboard Shortcuts</h4>
+  <div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 items-center">
+    {#each shortcuts as { modifiers, key, action }}
+      <div class="flex items-center gap-1">
+        {#each modifiers as mod}
+          <div
+            class="font-mono text-shadow-muted-foreground bg-muted px-1 py-1 rounded text-xs"
+          >
+            {mod}
+          </div>
+          <div class="font-mono text-muted-foreground">+</div>
+        {/each}
+        <div
+          class="font-mono text-shadow-muted-foreground bg-muted px-1 py-1 rounded text-xs"
+        >
+          {key}
+        </div>
+      </div>
+      <div>{action}</div>
+    {/each}
+  </div>
+</div>

+ 20 - 12
src/lib/components/WorkspaceEntry.svelte

@@ -1,4 +1,5 @@
 <script lang="ts">
+  let { requestPane = $bindable(), responsePane = $bindable() } = $props();
   import { Clipboard } from "@lucide/svelte";
   import * as Select from "$lib/components/ui/select";
   import {
@@ -8,6 +9,7 @@
     deleteHeader,
     deleteQueryParam,
     insertHeader,
+    isRequestSending,
     sendRequest,
     setEntryAuth,
     updateBodyContent,
@@ -22,7 +24,11 @@
   import { Input } from "$lib/components/ui/input";
   import * as Tabs from "$lib/components/ui/tabs";
   import type { UrlError, WorkspaceEntry } from "$lib/types";
-  import { REQUEST_METHODS } from "$lib/types";
+  import {
+    REQUEST_METHODS,
+    REQUEST_PANE_ID,
+    RESPONSE_PANE_ID,
+  } from "$lib/types";
   import Editable from "./Editable.svelte";
   import { Loader, PlusIcon, Trash } from "@lucide/svelte";
   import BodyEditor from "./BodyEditor.svelte";
@@ -31,10 +37,7 @@
   import Response from "./Response.svelte";
   import Checkbox from "./ui/checkbox/checkbox.svelte";
 
-  let requestPane: Resizable.Pane | undefined = $state();
-  let responsePane: Resizable.Pane | undefined = $state();
-
-  let isSending = $derived(_state.pendingRequests.includes(_state.entry!!.id));
+  let isSending = $derived.by(isRequestSending);
 
   const parentAuth = $derived.by(() => {
     let parentId = _state.entry!!.parent_id;
@@ -60,8 +63,10 @@
     }
   });
 
+  let updateUrlTimeout: number | undefined = $state();
+
   async function handleRequest() {
-    if (isSending) {
+    if (isRequestSending()) {
       try {
         await cancelRequest();
       } catch (e) {
@@ -75,15 +80,13 @@
     } catch (e) {
       console.error("error sending request", e);
     } finally {
-      if (responsePane!!.getSize() === 0) {
+      if (responsePane && responsePane.getSize() === 0) {
         requestPane!!.resize(50);
         responsePane!!.resize(50);
       }
     }
   }
 
-  let updateUrlTimeout: number | undefined = $state();
-
   async function handleUrlUpdate(update: UrlUpdate) {
     if (updateUrlTimeout != undefined) {
       clearTimeout(updateUrlTimeout);
@@ -275,7 +278,11 @@
     <!-- ================= REQUEST PANEL ================= -->
 
     <Resizable.PaneGroup direction="vertical" class="flex-1 w-full ">
-      <Resizable.Pane defaultSize={100} bind:this={requestPane}>
+      <Resizable.Pane
+        id={REQUEST_PANE_ID}
+        defaultSize={100}
+        bind:this={requestPane}
+      >
         <Tabs.Root value="params" class="h-full flex flex-col">
           <Tabs.List class="shrink-0">
             <Tabs.Trigger value="params">Parameters</Tabs.Trigger>
@@ -480,14 +487,15 @@
       <Resizable.Handle
         class="hover:bg-primary"
         ondblclick={() => {
-          requestPane.resize(50);
-          responsePane.resize(50);
+          requestPane!!.resize(50);
+          responsePane!!.resize(50);
         }}
       />
 
       <!-- RESPONSE -->
 
       <Resizable.Pane
+        id={RESPONSE_PANE_ID}
         class="flex flex-col"
         defaultSize={0}
         bind:this={responsePane}

+ 5 - 0
src/lib/state.svelte.ts

@@ -15,6 +15,7 @@ import type {
   ResponseResult,
   QueryParam,
 } from "./types";
+import * as Resizable from "$lib/components/ui/resizable/index";
 import { getSetting, setSetting } from "./settings.svelte";
 
 export type WorkspaceState = {
@@ -94,6 +95,10 @@ export const state: WorkspaceState = $state({
   entryIndex: 0,
 });
 
+export function isRequestSending() {
+  return state.pendingRequests.includes(state.entry!!.id);
+}
+
 const index = (entry: WorkspaceEntry) => {
   console.log("indexing", entry);
   state.indexes[entry.id] = entry;

+ 3 - 0
src/lib/types.ts

@@ -1,3 +1,6 @@
+export const REQUEST_PANE_ID = "request-pane";
+export const RESPONSE_PANE_ID = "response-pane";
+
 export type Workspace = {
   id: number;
   name: string;

+ 129 - 11
src/routes/+page.svelte

@@ -3,17 +3,23 @@
   import * as Sidebar from "$lib/components/ui/sidebar/index.js";
   import { toggleMode } from "mode-watcher";
   import { Button } from "$lib/components/ui/button";
-  import { SunIcon, MoonIcon, Lock } from "@lucide/svelte";
+  import {
+    SunIcon,
+    MoonIcon,
+    Lock,
+    CircleQuestionMarkIcon,
+  } from "@lucide/svelte";
   import WorkspaceEntry from "$lib/components/WorkspaceEntry.svelte";
   import {
     state as _state,
     selectNextEntry,
     selectPreviousEntry,
-  } from "$lib/state.svelte";
-  import {
     listWorkspaces,
     loadWorkspace,
     selectEntry,
+    isRequestSending,
+    cancelRequest,
+    sendRequest,
   } from "$lib/state.svelte";
   import { SlidersHorizontal } from "@lucide/svelte";
   import Environment from "$lib/components/Environment.svelte";
@@ -23,15 +29,22 @@
   import { getSetting } from "$lib/settings.svelte";
   import Header from "$lib/components/Header.svelte";
   import AppSidebar from "$lib/components/Sidebar.svelte";
+  import Shortcuts from "$lib/components/Shortcuts.svelte";
 
   let sidePane: Resizable.Pane;
   let mainPane: Resizable.Pane;
 
-  let displayModal: "env" | "auth" | null = $state(null);
+  let requestPane: Resizable.Pane | undefined = $state();
+  let responsePane: Resizable.Pane | undefined = $state();
+
+  let hoveringOverButton = $state(false);
+  let displayModal: "env" | "auth" | "shortcuts" | null = $state(null);
 
   let workspaces: Workspace[] = $state([]);
 
   window.addEventListener("keydown", (e) => {
+    // console.log(e.ctrlKey, e.key);
+
     if (e.ctrlKey && e.key === "o") {
       selectPreviousEntry();
       return;
@@ -41,8 +54,46 @@
       selectNextEntry();
       return;
     }
+
+    if (e.ctrlKey && e.key === "Enter") {
+      if (_state.entry) {
+        handleRequest();
+      }
+      return;
+    }
+
+    if (e.ctrlKey && e.key === "h") {
+      if (displayModal === "shortcuts") {
+        displayModal = null;
+      } else {
+        displayModal = "shortcuts";
+      }
+      return;
+    }
   });
 
+  async function handleRequest() {
+    if (isRequestSending()) {
+      try {
+        await cancelRequest();
+      } catch (e) {
+        console.error("error cancelling request", e);
+      }
+      return;
+    }
+
+    try {
+      await sendRequest();
+    } catch (e) {
+      console.error("error sending request", e);
+    } finally {
+      if (responsePane && responsePane.getSize() === 0) {
+        requestPane!!.resize(50);
+        responsePane!!.resize(50);
+      }
+    }
+  }
+
   onMount(async () => {
     workspaces = await listWorkspaces();
     const lastEntry = await getSetting("lastEntry");
@@ -58,6 +109,30 @@
   });
 </script>
 
+{#snippet floatingBadge(value: string)}
+  {#if displayModal === "shortcuts" && hoveringOverButton}
+    <div
+      class="
+        absolute right-full top-1/2 mr-2 -translate-y-1/2
+        z-50 whitespace-nowrap
+        rounded-md px-3 text-sm text-popover-foreground
+        bg-secondary
+        shadow-md
+        after:content-['']
+        after:absolute
+        after:right-[-11px]
+        after:top-1/2
+        after:-translate-y-1/2
+        after:border-6
+        after:border-transparent
+        after:border-l-secondary
+      "
+    >
+      {value}
+    </div>
+  {/if}
+{/snippet}
+
 <Resizable.PaneGroup direction="horizontal">
   <Resizable.Pane bind:this={sidePane} defaultSize={15}>
     <AppSidebar onSelect={() => (displayModal = null)} />
@@ -73,21 +148,34 @@
 
   <Resizable.Pane bind:this={mainPane} defaultSize={85}>
     <main class="w-full h-full px-2 py-4 space-y-4">
-      <Header {workspaces} />
+      {#if displayModal !== "shortcuts"}
+        <Header {workspaces} />
+      {/if}
       {#if displayModal === "env"}
         <Environment />
       {:else if displayModal === "auth"}
         <Auth />
+      {:else if displayModal === "shortcuts"}
+        <Shortcuts />
       {:else if _state.entry}
-        <WorkspaceEntry />
+        <WorkspaceEntry bind:requestPane bind:responsePane />
       {:else}{/if}
     </main>
   </Resizable.Pane>
 </Resizable.PaneGroup>
 
-<Sidebar.Menu class="bg-sidebar rounded-xl p-1 my-2 mr-2 w-fit items-center">
-  <Sidebar.MenuItem class="pt-2">
-    <Button onclick={toggleMode} variant="ghost" size="icon-sm">
+<Sidebar.Menu
+  class="flex flex-col items-center bg-sidebar rounded-xl p-1 my-2 mr-2 w-fit"
+>
+  <Sidebar.MenuItem class="relative pt-2">
+    {@render floatingBadge("Toggle theme")}
+    <Button
+      onclick={toggleMode}
+      variant="ghost"
+      size="icon-sm"
+      onmouseenter={() => (hoveringOverButton = true)}
+      onmouseleave={() => (hoveringOverButton = false)}
+    >
       <SunIcon
         class="h-1 w-1 scale-100 rotate-0 transition-all! dark:scale-0 dark:-rotate-90"
       />
@@ -98,7 +186,12 @@
     </Button>
   </Sidebar.MenuItem>
 
-  <Sidebar.MenuItem class="pt-2">
+  <Sidebar.MenuItem
+    class="relative pt-2"
+    onmouseenter={() => (hoveringOverButton = true)}
+    onmouseleave={() => (hoveringOverButton = false)}
+  >
+    {@render floatingBadge("Edit environment")}
     <Button
       onclick={() => (displayModal = displayModal === "env" ? null : "env")}
       variant={displayModal === "env" ? "default" : "ghost"}
@@ -109,7 +202,12 @@
     </Button>
   </Sidebar.MenuItem>
 
-  <Sidebar.MenuItem class="pt-2">
+  <Sidebar.MenuItem
+    class="relative pt-2"
+    onmouseenter={() => (hoveringOverButton = true)}
+    onmouseleave={() => (hoveringOverButton = false)}
+  >
+    {@render floatingBadge("Manage authentication schemes")}
     <Button
       onclick={() => (displayModal = displayModal === "auth" ? null : "auth")}
       variant={displayModal === "auth" ? "default" : "ghost"}
@@ -119,4 +217,24 @@
       <span class="sr-only">Display auth</span>
     </Button>
   </Sidebar.MenuItem>
+
+  <Sidebar.MenuItem class="relative pt-2 flex-1"></Sidebar.MenuItem>
+  <Sidebar.Separator />
+
+  <Sidebar.MenuItem
+    class="relative pt-2"
+    onmouseenter={() => (hoveringOverButton = true)}
+    onmouseleave={() => (hoveringOverButton = false)}
+  >
+    {@render floatingBadge("Toggle help")}
+    <Button
+      onclick={() =>
+        (displayModal = displayModal === "shortcuts" ? null : "shortcuts")}
+      variant={displayModal === "shortcuts" ? "default" : "ghost"}
+      size="icon-sm"
+    >
+      <CircleQuestionMarkIcon />
+      <span class="sr-only">Display shortcuts</span>
+    </Button>
+  </Sidebar.MenuItem>
 </Sidebar.Menu>