Browse Source

fix code editor state management

biblius 2 tuần trước cách đây
mục cha
commit
87be03a172

+ 1 - 0
package-lock.json

@@ -11,6 +11,7 @@
       "dependencies": {
         "@codemirror/lang-javascript": "^6.2.4",
         "@codemirror/lang-json": "^6.0.2",
+        "@codemirror/lint": "^6.9.2",
         "@codemirror/state": "^6.5.3",
         "@replit/codemirror-vim": "^6.3.0",
         "@tailwindcss/vite": "^4.1.17",

+ 1 - 0
package.json

@@ -15,6 +15,7 @@
   "dependencies": {
     "@codemirror/lang-javascript": "^6.2.4",
     "@codemirror/lang-json": "^6.0.2",
+    "@codemirror/lint": "^6.9.2",
     "@codemirror/state": "^6.5.3",
     "@replit/codemirror-vim": "^6.3.0",
     "@tailwindcss/vite": "^4.1.17",

+ 1 - 1
src-tauri/src/db.rs

@@ -224,7 +224,7 @@ pub async fn update_workspace_entry(
 ) -> AppResult<()> {
     match update {
         WorkspaceEntryUpdate::Collection(update) => {
-            let mut sql = sqlx::query_builder::QueryBuilder::new("UPDATE workspace_entries SET");
+            let mut sql = sqlx::query_builder::QueryBuilder::new("UPDATE workspace_entries SET ");
 
             if let Some(parent) = update.parent_id {
                 check_parent(&db, parent.value()).await?;

+ 115 - 0
src/lib/codemirror.svelte.ts

@@ -0,0 +1,115 @@
+import { EditorView, basicSetup } from "codemirror";
+import { Compartment, EditorState, type Extension } from "@codemirror/state";
+import { lineNumbers, ViewUpdate } from "@codemirror/view";
+import { vim } from "@replit/codemirror-vim";
+import { json as cmJson } from "@codemirror/lang-json";
+import { getSetting, setSetting } from "./settings.svelte";
+
+const jsonExt = cmJson();
+const vimExtension = vim();
+const relativeLines = lineNumbersRelative();
+
+let vimEnabled: boolean = $state(true);
+
+export async function initVimMode() {
+  vimEnabled = (await getSetting("vimMode")) ?? false;
+}
+
+export const isVimEnabled = () => vimEnabled;
+
+const stateChangeListener = new Compartment();
+const vimMode = new Compartment();
+
+export function init(): EditorView {
+  return new EditorView({
+    parent: document.querySelector("#editor") ?? undefined,
+    state: EditorState.create({
+      extensions: [
+        basicSetup,
+        jsonExt,
+        vimMode.of([vimExtension, relativeLines]),
+        stateChangeListener.of(EditorView.updateListener.of(() => {})),
+      ],
+    }),
+  });
+}
+
+export function clearContent(view: EditorView) {
+  view.dispatch({
+    changes: {
+      from: 0,
+      to: view.state.doc.length,
+      insert: "",
+    },
+  });
+}
+
+export function setContent(view: EditorView, content?: string) {
+  view.dispatch({
+    changes: {
+      from: 0,
+      to: view.state.doc.length,
+      insert: content ?? "",
+    },
+  });
+}
+
+export function setUpdateHandler(
+  view: EditorView,
+  fn: (update: ViewUpdate) => void,
+) {
+  view.dispatch({
+    effects: stateChangeListener.reconfigure(EditorView.updateListener.of(fn)),
+  });
+}
+
+export function toggleVim(view: EditorView) {
+  vimEnabled = !vimEnabled;
+
+  view.dispatch({
+    effects: vimMode.reconfigure(
+      vimEnabled ? [vimExtension, relativeLines] : [],
+    ),
+  });
+
+  setSetting("vimMode", vimEnabled);
+}
+
+/** Copy the contents of the editor to the system clipboard. */
+export async function copyContent(view: EditorView) {
+  await navigator.clipboard.writeText(view.state.doc.toString());
+}
+
+/** Parse and stringify the editor contents as JSON in pretty mode. */
+export function formatJson(view: EditorView) {
+  try {
+    view.dispatch({
+      changes: {
+        from: 0,
+        to: view.state.doc.length,
+        insert: JSON.stringify(JSON.parse(view.state.doc.toString()), null, 2),
+      },
+    });
+  } catch (e) {
+    console.warn(e);
+  }
+}
+
+/**
+ * Sets the gutter to display relative lines for VIM motions.
+ */
+export function lineNumbersRelative(): Extension {
+  return [lineNumbers({ formatNumber: relativeLineNumbers })];
+}
+
+function relativeLineNumbers(lineNo: number, state: EditorState) {
+  const cursorLine = state.doc.lineAt(
+    state.selection.asSingle().ranges[0].to,
+  ).number;
+
+  // Absolute number for the current line
+  if (lineNo === cursorLine) return lineNo.toString();
+
+  // Relative number for all other lines
+  return Math.abs(cursorLine - lineNo).toString();
+}

+ 0 - 25
src/lib/codemirror/lines.ts

@@ -1,25 +0,0 @@
-import { EditorView } from "codemirror";
-import { EditorState, type Extension } from "@codemirror/state";
-import { lineNumbers, ViewUpdate } from "@codemirror/view";
-
-function relativeLineNumbers(lineNo: number, state: EditorState) {
-  const cursorLine = state.doc.lineAt(
-    state.selection.asSingle().ranges[0].to,
-  ).number;
-
-  // Absolute number for the current line
-  if (lineNo === cursorLine) return lineNo.toString();
-
-  // Relative number for all other lines
-  return Math.abs(cursorLine - lineNo).toString();
-}
-
-export function lineNumbersRelative(): Extension {
-  return [lineNumbers({ formatNumber: relativeLineNumbers })];
-}
-
-export function stateChangeListener(
-  of: (update: ViewUpdate) => void,
-): Extension {
-  return EditorView.updateListener.of(of);
-}

+ 63 - 0
src/lib/components/CodeMirror.svelte

@@ -0,0 +1,63 @@
+<script lang="ts">
+  import { state as _state } from "$lib/state.svelte";
+  import { Button } from "$lib/components/ui/button";
+  import { EditorView } from "codemirror";
+  import { onMount } from "svelte";
+  import { BrushCleaning, Clipboard, FastForward } from "@lucide/svelte";
+  import {
+    copyContent,
+    formatJson,
+    setContent,
+    setUpdateHandler,
+    toggleVim,
+    isVimEnabled,
+    initVimMode,
+  } from "$lib/codemirror.svelte";
+  import { init } from "$lib/codemirror.svelte";
+
+  let { input = null, onStateChange } = $props();
+
+  let view: EditorView;
+
+  onMount(async () => {
+    view = init();
+    setUpdateHandler(view, onStateChange);
+
+    await initVimMode();
+  });
+
+  $effect(() => {
+    if (input !== null && input !== view.state.doc.toString()) {
+      setContent(view, input);
+    }
+  });
+</script>
+
+<div class="w-full flex rounded-md border max-h-60">
+  <!-- EDITOR SIDEBAR -->
+
+  <div class="sticky flex flex-col gap-2 p-2 border-r">
+    <Button
+      onclick={() => toggleVim(view)}
+      variant={isVimEnabled() ? "default" : "outline"}
+      size="icon-sm"
+    >
+      <FastForward />
+    </Button>
+
+    <Button onclick={() => copyContent(view)} variant="outline" size="icon-sm"
+      ><Clipboard /></Button
+    >
+
+    <Button onclick={() => formatJson(view)} variant="outline" size="icon-sm"
+      ><BrushCleaning /></Button
+    >
+  </div>
+
+  <!-- EDITOR -->
+
+  <div
+    id="editor"
+    class="mx-auto w-full rounded-md overflow-scroll max-h-fit"
+  ></div>
+</div>

+ 46 - 3
src/lib/components/Sidebar.svelte

@@ -14,11 +14,12 @@
     createWorkspace,
     selectWorkspace,
     selectEntry,
+    createCollection,
+    createRequest,
+    selectEnvironment,
   } from "$lib/state.svelte";
   import SidebarEntry from "./SidebarEntry.svelte";
-  import SidebarActionButton from "./SidebarActionButton.svelte";
   import { getSetting } from "$lib/settings.svelte";
-  import * as Accordion from "./ui/accordion/index";
 
   let workspaces: Workspace[] = $state([]);
 
@@ -94,10 +95,52 @@
 
         <!-- Workspace actions -->
 
-        <SidebarActionButton />
+        <DropdownMenu.Root>
+          <DropdownMenu.Trigger>
+            {#snippet child({ props })}
+              <Button {...props} variant="outline">...</Button>
+            {/snippet}
+          </DropdownMenu.Trigger>
+
+          <DropdownMenu.Content align="start">
+            <DropdownMenu.Group>
+              <DropdownMenu.Item onclick={() => createCollection()}
+                >New collection</DropdownMenu.Item
+              >
+
+              <DropdownMenu.Item onclick={() => createRequest()}
+                >New request</DropdownMenu.Item
+              >
+            </DropdownMenu.Group>
+
+            <DropdownMenu.Separator />
+
+            <DropdownMenu.Group>
+              <DropdownMenu.Sub>
+                <DropdownMenu.SubTrigger>Environment</DropdownMenu.SubTrigger>
+                <DropdownMenu.SubContent>
+                  <DropdownMenu.Item onSelect={() => selectEnvironment(null)}
+                    >- {_state.environment === null
+                      ? " ✓"
+                      : ""}</DropdownMenu.Item
+                  >
+                  {#each _state.environments as env}
+                    <DropdownMenu.Item
+                      onSelect={() => selectEnvironment(env.id)}
+                      >{env.name}{_state.environment?.id === env.id
+                        ? " ✓"
+                        : ""}</DropdownMenu.Item
+                    >
+                  {/each}
+                </DropdownMenu.SubContent>
+              </DropdownMenu.Sub>
+            </DropdownMenu.Group>
+          </DropdownMenu.Content>
+        </DropdownMenu.Root>
       </Sidebar.MenuItem>
     </Sidebar.Menu>
   </Sidebar.Header>
+
   <Sidebar.Content>
     {#if _state.workspace}
       <!-- Workspace entries -->

+ 0 - 32
src/lib/components/SidebarActionButton.svelte

@@ -1,32 +0,0 @@
-<script lang="ts">
-  import { createCollection, createRequest } from "$lib/state.svelte";
-  import { Button } from "./ui/button";
-  import * as DropdownMenu from "./ui/dropdown-menu";
-  import DropdownMenuItem from "./ui/dropdown-menu/dropdown-menu-item.svelte";
-  import { state as _state } from "$lib/state.svelte";
-
-  const { id = null } = $props();
-  const canAdd = $derived(
-    id === null || _state.indexes[id].type === "Collection",
-  );
-</script>
-
-{#if canAdd}
-  <DropdownMenu.Root>
-    <DropdownMenu.Trigger>
-      {#snippet child({ props })}
-        <Button {...props} variant="outline">...</Button>
-      {/snippet}
-    </DropdownMenu.Trigger>
-
-    <DropdownMenu.Content align="start">
-      <DropdownMenuItem onclick={() => createCollection(id)}
-        >New collection</DropdownMenuItem
-      >
-
-      <DropdownMenuItem onclick={() => createRequest(id)}
-        >New request</DropdownMenuItem
-      >
-    </DropdownMenu.Content>
-  </DropdownMenu.Root>
-{/if}

+ 31 - 3
src/lib/components/SidebarEntry.svelte

@@ -1,10 +1,17 @@
 <script lang="ts">
   const { id, level } = $props();
-  import { state as _state, selectEntry } from "$lib/state.svelte";
+  import {
+    state as _state,
+    createCollection,
+    createRequest,
+    selectEntry,
+  } from "$lib/state.svelte";
   import * as Sidebar from "./ui/sidebar";
   import Self from "./SidebarEntry.svelte";
-  import SidebarActionButton from "./SidebarActionButton.svelte";
   import { setSetting } from "$lib/settings.svelte";
+  import * as DropdownMenu from "./ui/dropdown-menu/index";
+  import { Button } from "./ui/button";
+  import DropdownMenuItem from "./ui/dropdown-menu/dropdown-menu-item.svelte";
 
   let open = $derived(!!_state.indexes[id].open);
 
@@ -30,7 +37,28 @@
             {_state.indexes[id].name ||
               _state.indexes[id].type + "(" + _state.indexes[id].id + ")"}
           </p>
-          <SidebarActionButton {id} />
+
+          <!-- ACTION MENU -->
+
+          {#if _state.indexes[id]?.type === "Collection"}
+            <DropdownMenu.Root>
+              <DropdownMenu.Trigger>
+                {#snippet child({ props })}
+                  <Button {...props} variant="outline">...</Button>
+                {/snippet}
+              </DropdownMenu.Trigger>
+
+              <DropdownMenu.Content align="start">
+                <DropdownMenuItem onclick={() => createCollection(id)}
+                  >New collection</DropdownMenuItem
+                >
+
+                <DropdownMenuItem onclick={() => createRequest(id)}
+                  >New request</DropdownMenuItem
+                >
+              </DropdownMenu.Content>
+            </DropdownMenu.Root>
+          {/if}
         </div>
       {/snippet}
     </Sidebar.MenuButton>

+ 16 - 140
src/lib/components/WorkspaceEntry.svelte

@@ -20,99 +20,8 @@
   import Highlight, { LineNumbers } from "svelte-highlight";
   import json from "svelte-highlight/languages/json";
   import { atelierForest } from "svelte-highlight/styles";
-  import { json as cmJson } from "@codemirror/lang-json";
-  import { EditorView, basicSetup } from "codemirror";
-  // import { dracula, coolGlow } from "thememirror";
-  import { onMount } from "svelte";
-  import {
-    BrushCleaning,
-    Clipboard,
-    FastForward,
-    Loader,
-    PlusIcon,
-    TrashIcon,
-  } from "@lucide/svelte";
-  import { vim } from "@replit/codemirror-vim";
-  import { EditorState, StateEffect } from "@codemirror/state";
-  import {
-    lineNumbersRelative,
-    stateChangeListener,
-  } from "$lib/codemirror/lines";
-
-  let view: EditorView;
-
-  const vimExtension = vim();
-  const relativeLines = lineNumbersRelative();
-  let vimEnabled = $state(true);
-  const jsonExt = cmJson();
-
-  let editorState = EditorState.create({
-    doc: _state.entry.body?.body ?? "",
-    extensions: [
-      basicSetup,
-      jsonExt,
-      vimExtension,
-      relativeLines,
-      stateChangeListener((update) => {
-        console.log(update);
-        if (update.docChanged) {
-          updateBodyContent(update.state.doc.toString(), "Json");
-        }
-      }),
-    ],
-  });
-
-  onMount(() => {
-    view = new EditorView({
-      parent: document.querySelector("#editor"),
-      state: editorState,
-    });
-  });
-
-  function toggleVim() {
-    vimEnabled = !vimEnabled;
-
-    const exts = [basicSetup, jsonExt];
-
-    if (vimEnabled) {
-      exts.push(vimExtension);
-      exts.push(relativeLines);
-    }
-
-    view.dispatch({
-      effects: StateEffect.reconfigure.of([exts]),
-    });
-  }
-
-  async function copyContent() {
-    const content = view.state.doc.toString();
-    await navigator.clipboard.writeText(content);
-  }
-
-  async function formatContent() {
-    const content = view.state.doc.toString();
-
-    view.dispatch({
-      changes: {
-        from: 0,
-        to: view.state.doc.length,
-        insert: JSON.stringify(JSON.parse(content), null, 2),
-      },
-    });
-  }
-
-  // $effect(() => {
-  //   if (_state.entry.body !== null) {
-  //     console.log("triggering effect editor dispatch");
-  //     view.dispatch({
-  //       changes: {
-  //         from: 0,
-  //         to: view.state.doc.length,
-  //         insert: _state.entry.body.body,
-  //       },
-  //     });
-  //   }
-  // });
+  import { Loader, PlusIcon, TrashIcon } from "@lucide/svelte";
+  import CodeMirror from "./CodeMirror.svelte";
 
   let isSending = $state(false);
   let response: any = $state();
@@ -137,10 +46,10 @@
     isSending = true;
     try {
       response = await sendRequest();
-      console.timeEnd("request");
     } catch (e) {
       console.error("error sending request", e);
     } finally {
+      console.timeEnd("request");
       isSending = false;
     }
   }
@@ -405,20 +314,7 @@
                 <Tabs.Trigger value="none" onclick={() => deleteBody()}
                   >None</Tabs.Trigger
                 >
-                <Tabs.Trigger
-                  value="json"
-                  onclick={() =>
-                    updateBodyContent("", "Json").then(() => {
-                      console.log("triggering effect editor dispatch");
-                      view.dispatch({
-                        changes: {
-                          from: 0,
-                          to: view.state.doc.length,
-                          insert: _state.entry.body.body,
-                        },
-                      });
-                    })}>JSON</Tabs.Trigger
-                >
+                <Tabs.Trigger value="json">JSON</Tabs.Trigger>
                 <Tabs.Trigger value="form">Form</Tabs.Trigger>
                 <Tabs.Trigger value="text">Text</Tabs.Trigger>
               </Tabs.List>
@@ -426,38 +322,18 @@
               <Tabs.Content value="none">No body</Tabs.Content>
 
               <Tabs.Content value="json">
-                <div class="w-full flex rounded-md border max-h-60">
-                  <!-- EDITOR SIDEBAR -->
-
-                  <div class="sticky flex flex-col gap-2 p-2 border-r">
-                    <Button
-                      onclick={toggleVim}
-                      variant={vimEnabled ? "default" : "outline"}
-                      size="icon-sm"
-                    >
-                      <FastForward />
-                    </Button>
-
-                    <Button
-                      onclick={copyContent}
-                      variant="outline"
-                      size="icon-sm"><Clipboard /></Button
-                    >
-
-                    <Button
-                      onclick={formatContent}
-                      variant="outline"
-                      size="icon-sm"><BrushCleaning /></Button
-                    >
-                  </div>
-
-                  <!-- EDITOR -->
-
-                  <div
-                    id="editor"
-                    class="mx-auto w-full rounded-md overflow-scroll max-h-fit"
-                  ></div>
-                </div>
+                <CodeMirror
+                  input={_state.entry.body?.body}
+                  onStateChange={(update) => {
+                    // console.log(update);
+                    if (
+                      update.docChanged &&
+                      _state.entry!!.body?.body !== update.state.doc.toString()
+                    ) {
+                      updateBodyContent(update.state.doc.toString(), "Json");
+                    }
+                  }}
+                />
               </Tabs.Content>
 
               <Tabs.Content value="form">

+ 17 - 3
src/lib/settings.svelte.ts

@@ -4,28 +4,42 @@ import type { WorkspaceEntry } from "./types";
 let store: Store;
 
 export async function init() {
-  store = await load("settings.json", {
+  const path = "settings.json";
+  console.log("Initialising store");
+
+  if (store) return;
+
+  store = await load(path, {
     defaults: {
       theme: "dark",
       lastEnvironment: {},
+      vimMode: true,
     },
     autoSave: false,
   });
+
+  console.log("Store initialised at", path);
 }
 
 export type Settings = {
   theme: "dark" | "light";
-  lastEntry?: WorkspaceEntry;
+  lastEntry: WorkspaceEntry | null;
 
   /**
    * Maps workspace IDs to environment IDs.
    */
-  lastEnvironment: Record<number, number>;
+  lastEnvironment: Record<number, number | null>;
+
+  /**
+   * Enables VIM mode in code editors.
+   */
+  vimMode: boolean | null;
 };
 
 export async function getSetting<K extends keyof Settings>(
   key: K,
 ): Promise<Settings[K] | undefined> {
+  console.debug("getting", key);
   return store.get<Settings[K]>(key);
 }
 

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

@@ -86,8 +86,16 @@ function reset() {
 export async function selectEnvironment(id: number | null) {
   if (id === null) {
     state.environment = null;
+    let env = await getSetting("lastEnvironment");
+    if (env) {
+      env[state.workspace!!.id] = null;
+    } else {
+      env = { [state.workspace!!.id]: null };
+    }
+    setSetting("lastEnvironment", env);
     return;
   }
+
   state.environment = state.environments.find((e) => e.id === id) ?? null;
 
   let env = await getSetting("lastEnvironment");