diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 288a375..e37d537 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -9,6 +9,7 @@ on: branches: - main - 'v[0-9]+.[0-9]+.[0-9]+' + workflow_dispatch: jobs: build-test: diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts new file mode 100644 index 0000000..0748213 --- /dev/null +++ b/docs/.vitepress/config.mts @@ -0,0 +1,32 @@ +import { defineConfig } from 'vitepress' + +// https://vitepress.dev/reference/site-config +export default defineConfig({ + title: "Relay", + description: "docs for relay", + themeConfig: { + nav: [ + { text: 'Home', link: '/' }, + ], + + sidebar: [ + { + text: 'Quick Start', + items: [ + { text: 'Installation', link: '/quick-start/installation' }, + { text: 'First Steps', link: '/quick-start/first-steps' }, + ] + }, + { + text: 'Make Relay your own', + items: [ + { text: 'Theme', link: '/customization/theme' }, + ] + } + ], + + socialLinks: [ + { icon: 'github', link: 'https://github.com/yashranjan1/relay' } + ] + } +}) diff --git a/docs/customization/theme.md b/docs/customization/theme.md new file mode 100644 index 0000000..7be671c --- /dev/null +++ b/docs/customization/theme.md @@ -0,0 +1,28 @@ +# Theme Configuration + +Relay allows you to customize its visual appearance by updating your configuration file. + +## Changing the Theme + +You can change the active theme color by updating the `name` field under the `[Theme]` section in your configuration file. + +### Configuration Location +The configuration file is formatted in **TOML** and is stored in your system's standard configuration directory, determined by Go's [`os.UserConfigDir()`](https://pkg.go.dev/os#UserConfigDir): + +* **Linux:** `~/.config/` +* **macOS:** `~/Library/Application Support/` +* **Windows:** `%AppData%` + +### Available Themes +Set the `name` variable to one of the following supported options: + +* `"default"` — Default theme +* `"cyberpunk"` — Cyberpunk theme +* `"light-paper"` — Light Paper theme +* `"forest"` — Forest theme + +### Example (`config.toml`) + +```toml +[Theme] +name = "forest" diff --git a/docs/gifs/startup.gif b/docs/gifs/startup.gif new file mode 100644 index 0000000..1eb8549 Binary files /dev/null and b/docs/gifs/startup.gif differ diff --git a/docs/gifs/tapes/startup.tape b/docs/gifs/tapes/startup.tape new file mode 100644 index 0000000..1635315 --- /dev/null +++ b/docs/gifs/tapes/startup.tape @@ -0,0 +1,66 @@ +# Where should we write the GIF? +Output ../startup.gif + +# Set up a 1200x600 terminal with 46px font. +Set FontSize 18 +Set Width 1920 +Set Height 1080 + +Type "relay" + +Enter + +Sleep 100ms + +Type "a" + +Sleep 200ms + +Type "New Collection" + +Enter + +Sleep 200ms + +Enter + +Sleep 200ms + +Type "a" + +Type "New Endpoint" + +Enter + +Sleep 200ms + +Enter + +Sleep 200ms + +Ctrl+l + +Type "https://jsonplaceholder.typicode.com/users" + +Ctrl+S + +Ctrl+r + +Sleep 500ms + +Ctrl+d + +Sleep 50ms + +Ctrl+d + +Sleep 50ms + +Ctrl+d + +Sleep 50ms + +Enter + +Sleep 2 + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..b0bb089 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,22 @@ +--- +# https://vitepress.dev/reference/default-theme-home-page +layout: home + +hero: + name: "Relay" + text: "Ditch the Electron bloat with style" + tagline: Terminal-native API client built for developers who live in the CLI + actions: + - theme: brand + text: Quick start + link: /quick-start/installation + +features: + - title: Vim-like keybinds + details: Keep your hands on the keyboard. Relay is an extension of your editor, not another tool you have to tab into. + - title: Zero Lag + details: Built natively in Go with zero Electron bloat. + - title: Built in the Open + details: No hidden paywalls, no enterprise-tier lockouts, and no telemetry tracking your requests. +--- + diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..38cbd3a --- /dev/null +++ b/docs/package.json @@ -0,0 +1,10 @@ +{ + "devDependencies": { + "vitepress": "2.0.0-alpha.19" + }, + "scripts": { + "docs:dev": "vitepress dev", + "docs:build": "vitepress build", + "docs:preview": "vitepress preview" + } +} \ No newline at end of file diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml new file mode 100644 index 0000000..b272ed3 --- /dev/null +++ b/docs/pnpm-lock.yaml @@ -0,0 +1,1197 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + vitepress: + specifier: 2.0.0-alpha.19 + version: 2.0.0-alpha.19(postcss@8.5.26) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@docsearch/css@4.7.0': + resolution: {integrity: sha512-Sk5xkdRFeE7PeWjG9l4AfTwdvMfr9wHiwNNCpHXT4v4SNyNMKdHGvEILc31BgaVFGDDNbv5u/a73tofRiwbEZw==} + + '@docsearch/js@4.7.0': + resolution: {integrity: sha512-x5lCqu1tetgsJFkjQ6VSocbHldsRkGEgwg5N98Vx21sq/V5wcmj4u226PY9k+TEpIgQ772zlYbPLTPicWyGnpA==} + + '@docsearch/sidepanel-js@4.7.0': + resolution: {integrity: sha512-A8r34jCU8kcIk2viECEn2msA28ojUF1BLi/3v5OWWc5G2N3jOuuumBXoeYjfr8dA0UxgFSy5R2bt12dnFJQSyA==} + + '@iconify-json/simple-icons@1.2.94': + resolution: {integrity: sha512-l8UWzVxKaqZd9ABsE/M/9p6NyGkQnmCnOoZyhQmjlXCtY5PuL2rcWxOFk2l9pk7ux3ERMPkTLE4jl6kQpTkwxA==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + + '@rolldown/binding-android-arm-eabi@1.2.6': + resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.6': + resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.6': + resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.6': + resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.6': + resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.6': + resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.6': + resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.6': + resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.6': + resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.6': + resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.6': + resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.6': + resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.6': + resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@shikijs/core@4.4.3': + resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.4.3': + resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.4.3': + resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} + engines: {node: '>=20'} + + '@shikijs/langs@4.4.3': + resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.4.3': + resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} + engines: {node: '>=20'} + + '@shikijs/themes@4.4.3': + resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} + engines: {node: '>=20'} + + '@shikijs/transformers@4.4.3': + resolution: {integrity: sha512-oJSARV6NaWd+rnNJbtnpAdj3Zg0ZVyzsnMgb3vi3HA+35y8lBWUCpOnWsmyiXZIikY+x1BDqrQUgmxfzWh7Jvw==} + engines: {node: '>=20'} + + '@shikijs/types@4.4.3': + resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.2.0': + resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + '@vitejs/plugin-vue@6.0.8': + resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.42': + resolution: {integrity: sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==} + + '@vue/compiler-dom@3.5.42': + resolution: {integrity: sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==} + + '@vue/compiler-sfc@3.5.42': + resolution: {integrity: sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==} + + '@vue/compiler-ssr@3.5.42': + resolution: {integrity: sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==} + + '@vue/devtools-api@8.2.1': + resolution: {integrity: sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==} + + '@vue/devtools-kit@8.2.1': + resolution: {integrity: sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==} + + '@vue/devtools-shared@8.2.1': + resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==} + + '@vue/reactivity@3.5.42': + resolution: {integrity: sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==} + + '@vue/runtime-core@3.5.42': + resolution: {integrity: sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==} + + '@vue/runtime-dom@3.5.42': + resolution: {integrity: sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==} + + '@vue/server-renderer@3.5.42': + resolution: {integrity: sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==} + + '@vue/shared@3.5.42': + resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==} + + '@vueuse/core@14.4.0': + resolution: {integrity: sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/integrations@14.4.0': + resolution: {integrity: sha512-oJz9qTgczvA7L1nXQFRU7h8tQbOCoiceqvMMhT9XYMyOGTqLJ2rEa09PON+nD2t48sZUfeOmg4eaWJXV4sZb/w==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 || ^8 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 || ^8 + vue: ^3.5.0 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@14.4.0': + resolution: {integrity: sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==} + + '@vueuse/shared@14.4.0': + resolution: {integrity: sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==} + peerDependencies: + vue: ^3.5.0 + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + focus-trap@8.2.2: + resolution: {integrity: sha512-qV0g8hRYBqgACcFOH3f9wXc4zPKhr/0z9RI2a6ZijZ72EeBi4g8oBy8zAWuUR1TsMpOzwpUMFvjdasrC41Joug==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rolldown@1.2.6: + resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + shiki@4.4.3: + resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} + engines: {node: '>=20'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitepress@2.0.0-alpha.19: + resolution: {integrity: sha512-WnBsb0Bwr43kXKyiis+lld/7ri3hnMbthS8N3hpFtjjwsdLO4IRmiAE08D7aud4q6oMDf9uwRowxzNqRFe/amw==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + + vue@3.5.42: + resolution: {integrity: sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@docsearch/css@4.7.0': {} + + '@docsearch/js@4.7.0': {} + + '@docsearch/sidepanel-js@4.7.0': {} + + '@iconify-json/simple-icons@1.2.94': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@oxc-project/types@0.147.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.6': + optional: true + + '@rolldown/binding-android-arm64@1.2.6': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.6': + optional: true + + '@rolldown/binding-darwin-x64@1.2.6': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.6': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.6': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.6': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.6': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.6': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.6': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@shikijs/core@4.4.3': + dependencies: + '@shikijs/primitive': 4.4.3 + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + + '@shikijs/primitive@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/themes@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + + '@shikijs/transformers@4.4.3': + dependencies: + '@shikijs/core': 4.4.3 + '@shikijs/types': 4.4.3 + + '@shikijs/types@4.4.3': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.2.0': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.21': {} + + '@ungap/structured-clone@1.4.0': {} + + '@vitejs/plugin-vue@6.0.8(vite@8.2.2)(vue@3.5.42)': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.2 + vue: 3.5.42 + + '@vue/compiler-core@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.42 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.42': + dependencies: + '@vue/compiler-core': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/compiler-sfc@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.42 + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-ssr': 3.5.42 + '@vue/shared': 3.5.42 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.42': + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/devtools-api@8.2.1': + dependencies: + '@vue/devtools-kit': 8.2.1 + + '@vue/devtools-kit@8.2.1': + dependencies: + '@vue/devtools-shared': 8.2.1 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@8.2.1': {} + + '@vue/reactivity@3.5.42': + dependencies: + '@vue/shared': 3.5.42 + + '@vue/runtime-core@3.5.42': + dependencies: + '@vue/reactivity': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/runtime-dom@3.5.42': + dependencies: + '@vue/reactivity': 3.5.42 + '@vue/runtime-core': 3.5.42 + '@vue/shared': 3.5.42 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.42': + dependencies: + '@vue/compiler-ssr': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/shared@3.5.42': {} + + '@vueuse/core@14.4.0(vue@3.5.42)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.4.0 + '@vueuse/shared': 14.4.0(vue@3.5.42) + vue: 3.5.42 + + '@vueuse/integrations@14.4.0(focus-trap@8.2.2)(vue@3.5.42)': + dependencies: + '@vueuse/core': 14.4.0(vue@3.5.42) + '@vueuse/shared': 14.4.0(vue@3.5.42) + vue: 3.5.42 + optionalDependencies: + focus-trap: 8.2.2 + + '@vueuse/metadata@14.4.0': {} + + '@vueuse/shared@14.4.0(vue@3.5.42)': + dependencies: + vue: 3.5.42 + + birpc@2.9.0: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + comma-separated-tokens@2.0.3: {} + + csstype@3.2.3: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + entities@7.0.1: {} + + estree-walker@2.0.2: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + focus-trap@8.2.2: + dependencies: + tabbable: 6.5.0 + + fsevents@2.3.3: + optional: true + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hookable@5.5.3: {} + + html-void-elements@3.0.0: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + mark.js@8.11.1: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + minisearch@7.2.0: {} + + nanoid@3.3.18: {} + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + property-information@7.2.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rolldown@1.2.6: + dependencies: + '@oxc-project/types': 0.147.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.6 + '@rolldown/binding-android-arm64': 1.2.6 + '@rolldown/binding-darwin-arm64': 1.2.6 + '@rolldown/binding-darwin-x64': 1.2.6 + '@rolldown/binding-freebsd-x64': 1.2.6 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 + '@rolldown/binding-linux-arm64-gnu': 1.2.6 + '@rolldown/binding-linux-arm64-musl': 1.2.6 + '@rolldown/binding-linux-ppc64-gnu': 1.2.6 + '@rolldown/binding-linux-s390x-gnu': 1.2.6 + '@rolldown/binding-linux-x64-gnu': 1.2.6 + '@rolldown/binding-linux-x64-musl': 1.2.6 + '@rolldown/binding-openharmony-arm64': 1.2.6 + '@rolldown/binding-win32-arm64-msvc': 1.2.6 + '@rolldown/binding-win32-x64-msvc': 1.2.6 + + shiki@4.4.3: + dependencies: + '@shikijs/core': 4.4.3 + '@shikijs/engine-javascript': 4.4.3 + '@shikijs/engine-oniguruma': 4.4.3 + '@shikijs/langs': 4.4.3 + '@shikijs/themes': 4.4.3 + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + tabbable@6.5.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + trim-lines@3.0.1: {} + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@8.2.2: + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.26 + rolldown: 1.2.6 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitepress@2.0.0-alpha.19(postcss@8.5.26): + dependencies: + '@docsearch/css': 4.7.0 + '@docsearch/js': 4.7.0 + '@docsearch/sidepanel-js': 4.7.0 + '@iconify-json/simple-icons': 1.2.94 + '@shikijs/core': 4.4.3 + '@shikijs/transformers': 4.4.3 + '@shikijs/types': 4.4.3 + '@types/markdown-it': 14.2.0 + '@vitejs/plugin-vue': 6.0.8(vite@8.2.2)(vue@3.5.42) + '@vue/devtools-api': 8.2.1 + '@vue/shared': 3.5.42 + '@vueuse/core': 14.4.0(vue@3.5.42) + '@vueuse/integrations': 14.4.0(focus-trap@8.2.2)(vue@3.5.42) + focus-trap: 8.2.2 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 4.4.3 + vite: 8.2.2 + vue: 3.5.42 + optionalDependencies: + postcss: 8.5.26 + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - async-validator + - axios + - change-case + - drauu + - esbuild + - fuse.js + - idb-keyval + - jiti + - jwt-decode + - less + - nprogress + - qrcode + - sass + - sass-embedded + - sortablejs + - stylus + - sugarss + - terser + - tsx + - typescript + - universal-cookie + - yaml + + vue@3.5.42: + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-sfc': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/server-renderer': 3.5.42 + '@vue/shared': 3.5.42 + + zwitch@2.0.4: {} diff --git a/docs/quick-start/first-steps.md b/docs/quick-start/first-steps.md new file mode 100644 index 0000000..05d57f4 --- /dev/null +++ b/docs/quick-start/first-steps.md @@ -0,0 +1,26 @@ +# First steps + + +Relay helps you manage, edit, and test API endpoints through organized collections. + +![thing](../gifs/startup.gif) + +## Collections +Relay uses **Collections** to group related endpoints together. Each collection serves as a workspace for a specific set of API endpoints. + +## Working with Endpoints + +### 1. Editing a Request +* Select an endpoint from your collection to open the **Request Interface**. +* Customize the request details: + * **HTTP Method:** Change between `GET`, `POST`, `PUT`, `DELETE`, etc. + * **URL:** Update the endpoint path or query parameters. + +### 2. Sending Requests and Viewing Responses +* Click **Send** to execute the request. +* You will automatically be redirected to the **Response Page** to view the results: + * **Status:** Displays the HTTP status code (e.g., `200 OK`, `404 Not Found`). + * **Body:** Shows the returned data payload. + * **Headers:** Displays all returned header key-value pairs. + + diff --git a/docs/quick-start/installation.md b/docs/quick-start/installation.md new file mode 100644 index 0000000..9bacab5 --- /dev/null +++ b/docs/quick-start/installation.md @@ -0,0 +1,29 @@ +--- +outline: deep +--- +# Quick start + +## Prerequisites + +This project needs Go 1.24 >= + + +## Installation +As long as you have the right version of a go, use the following command to install +```sh +go install github.com/yashranjan1/relay@latest +``` + +You can also install a specific version by doing +```sh +# replace the version after the @ with whatever version you are looking for +go install github.com/yashranjan1/relay@v0.1.0-alpha2 +``` + + +## Usage + +Run the app using: +```sh +relay +``` diff --git a/go.mod b/go.mod index e0e2d10..c3ae6f7 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 github.com/mattn/go-sqlite3 v1.14.29 + github.com/pelletier/go-toml/v2 v2.4.3 github.com/pressly/goose/v3 v3.24.3 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) diff --git a/go.sum b/go.sum index 8dbf694..4090e98 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM= diff --git a/internal/backend/demo/dummy_data.go b/internal/backend/demo/dummy_data.go deleted file mode 100644 index 1734936..0000000 --- a/internal/backend/demo/dummy_data.go +++ /dev/null @@ -1,221 +0,0 @@ -package demo - -import ( - "context" - - "github.com/yashranjan1/relay/internal/backend/collections" - "github.com/yashranjan1/relay/internal/backend/endpoints" - "github.com/yashranjan1/relay/internal/log" -) - -type DemoGenerator struct { - collectionsManager *collections.CollectionsManager - endpointsManager *endpoints.EndpointsManager -} - -func NewDemoGenerator(collectionsManager *collections.CollectionsManager, endpointsManager *endpoints.EndpointsManager) *DemoGenerator { - return &DemoGenerator{ - collectionsManager: collectionsManager, - endpointsManager: endpointsManager, - } -} - -func (d *DemoGenerator) PopulateDummyData(ctx context.Context) (bool, error) { - log.Info("populating dummy data for demo") - - // Check if we already have collections - result, err := d.collectionsManager.ListPaginated(ctx, 1, 0) - if err != nil { - log.Error("failed to check existing collections", "error", err) - return false, err - } - - if len(result.Collections) > 0 { - log.Debug("dummy data already exists, skipping population", "collections_count", len(result.Collections)) - return false, nil - } - - // Create demo collections and endpoints - if err := d.createJSONPlaceholderCollection(ctx); err != nil { - return false, err - } - - if err := d.createReqresCollection(ctx); err != nil { - return false, err - } - - if err := d.createHTTPBinCollection(ctx); err != nil { - return false, err - } - - log.Info("dummy data populated successfully") - return true, nil -} - -func (d *DemoGenerator) createJSONPlaceholderCollection(ctx context.Context) error { - collection, err := d.collectionsManager.Create(ctx, "JSONPlaceholder API") - if err != nil { - log.Error("failed to create JSONPlaceholder collection", "error", err) - return err - } - - endpoints := []endpoints.EndpointData{ - { - CollectionID: collection.ID, - Name: "Get All Posts", - Method: "GET", - URL: "https://jsonplaceholder.typicode.com/posts", - Headers: `{"Content-Type": "application/json"}`, - QueryParams: map[string]string{}, - RequestBody: "", - }, - { - CollectionID: collection.ID, - Name: "Get Single Post", - Method: "GET", - URL: "https://jsonplaceholder.typicode.com/posts/1", - Headers: `{"Content-Type": "application/json"}`, - QueryParams: map[string]string{}, - RequestBody: "", - }, - { - CollectionID: collection.ID, - Name: "Create Post", - Method: "POST", - URL: "https://jsonplaceholder.typicode.com/posts", - Headers: `{"Content-Type": "application/json"}`, - QueryParams: map[string]string{}, - RequestBody: `{"title": "My New Post", "body": "This is the content of my new post", "userId": 1}`, - }, - { - CollectionID: collection.ID, - Name: "Update Post", - Method: "PUT", - URL: "https://jsonplaceholder.typicode.com/posts/1", - Headers: `{"Content-Type": "application/json"}`, - QueryParams: map[string]string{}, - RequestBody: `{"id": 1, "title": "Updated Post", "body": "This post has been updated", "userId": 1}`, - }, - { - CollectionID: collection.ID, - Name: "Delete Post", - Method: "DELETE", - URL: "https://jsonplaceholder.typicode.com/posts/1", - Headers: `{"Content-Type": "application/json"}`, - QueryParams: map[string]string{}, - RequestBody: "", - }, - } - - return d.createEndpoints(ctx, endpoints) -} - -func (d *DemoGenerator) createReqresCollection(ctx context.Context) error { - collection, err := d.collectionsManager.Create(ctx, "ReqRes API") - if err != nil { - log.Error("failed to create ReqRes collection", "error", err) - return err - } - - endpoints := []endpoints.EndpointData{ - { - CollectionID: collection.ID, - Name: "List Users", - Method: "GET", - URL: "https://reqres.in/api/users", - Headers: `{"Content-Type": "application/json"}`, - QueryParams: map[string]string{"page": "2"}, - RequestBody: "", - }, - { - CollectionID: collection.ID, - Name: "Single User", - Method: "GET", - URL: "https://reqres.in/api/users/2", - Headers: `{"Content-Type": "application/json"}`, - QueryParams: map[string]string{}, - RequestBody: "", - }, - { - CollectionID: collection.ID, - Name: "Create User", - Method: "POST", - URL: "https://reqres.in/api/users", - Headers: `{"Content-Type": "application/json"}`, - QueryParams: map[string]string{}, - RequestBody: `{"name": "morpheus", "job": "leader"}`, - }, - { - CollectionID: collection.ID, - Name: "Login", - Method: "POST", - URL: "https://reqres.in/api/login", - Headers: `{"Content-Type": "application/json"}`, - QueryParams: map[string]string{}, - RequestBody: `{"email": "eve.holt@reqres.in", "password": "cityslicka"}`, - }, - } - - return d.createEndpoints(ctx, endpoints) -} - -func (d *DemoGenerator) createHTTPBinCollection(ctx context.Context) error { - collection, err := d.collectionsManager.Create(ctx, "HTTPBin Testing") - if err != nil { - log.Error("failed to create HTTPBin collection", "error", err) - return err - } - - endpoints := []endpoints.EndpointData{ - { - CollectionID: collection.ID, - Name: "Test GET", - Method: "GET", - URL: "https://httpbin.org/get", - Headers: `{"User-Agent": "Req-Terminal-Client/1.0"}`, - QueryParams: map[string]string{"test": "value", "demo": "true"}, - RequestBody: "", - }, - { - CollectionID: collection.ID, - Name: "Test POST JSON", - Method: "POST", - URL: "https://httpbin.org/post", - Headers: `{"Content-Type": "application/json", "User-Agent": "Req-Terminal-Client/1.0"}`, - QueryParams: map[string]string{}, - RequestBody: `{"message": "Hello from Req!", "timestamp": "2024-01-15T10:30:00Z", "data": {"key": "value"}}`, - }, - { - CollectionID: collection.ID, - Name: "Test Headers", - Method: "GET", - URL: "https://httpbin.org/headers", - Headers: `{"Authorization": "Bearer demo-token", "X-Custom-Header": "req-demo"}`, - QueryParams: map[string]string{}, - RequestBody: "", - }, - { - CollectionID: collection.ID, - Name: "Test Status Codes", - Method: "GET", - URL: "https://httpbin.org/status/200", - Headers: `{"Content-Type": "application/json"}`, - QueryParams: map[string]string{}, - RequestBody: "", - }, - } - - return d.createEndpoints(ctx, endpoints) -} - -func (d *DemoGenerator) createEndpoints(ctx context.Context, endpointData []endpoints.EndpointData) error { - for _, data := range endpointData { - _, err := d.endpointsManager.CreateEndpoint(ctx, data) - if err != nil { - log.Error("failed to create endpoint", "name", data.Name, "error", err) - return err - } - log.Debug("created demo endpoint", "name", data.Name, "method", data.Method, "url", data.URL) - } - return nil -} diff --git a/internal/backend/testutils/database.go b/internal/backend/testutils/database.go index c23f28f..b6f0148 100644 --- a/internal/backend/testutils/database.go +++ b/internal/backend/testutils/database.go @@ -6,8 +6,8 @@ import ( "database/sql" "testing" - "github.com/yashranjan1/relay/internal/backend/database" _ "github.com/mattn/go-sqlite3" + "github.com/yashranjan1/relay/internal/backend/database" ) // SetupTestDB creates an in-memory SQLite database with specified tables diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..da0ce23 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,49 @@ +package config + +import ( + "errors" + "os" + "path" + + "github.com/pelletier/go-toml/v2" + "github.com/yashranjan1/relay/internal/log" + "github.com/yashranjan1/relay/internal/tui/styles" +) + +func LoadConfig() error { + configDir, err := os.UserConfigDir() + + if err != nil { + log.Error("Could not load user config dir") + return errors.New("Could not load user config dir") + } + path := path.Join(configDir, "relay", "config.toml") + + byteData, err := os.ReadFile(path) + if err != nil { + log.Error("Could not load config") + initDefaults() + return nil + } + + var config Config + + toml.Unmarshal(byteData, &config) + + theme, ok := styles.ThemeMap[config.Theme.Name] + + if !ok { + theme = styles.ThemeMap["default"] + } + + styles.SetTheme(theme) + // styles.SetTheme() + + return nil +} + +func initDefaults() { + theme := styles.ThemeMap["default"] + log.Info("setting default config") + styles.SetTheme(theme) +} diff --git a/internal/config/types.go b/internal/config/types.go new file mode 100644 index 0000000..73255d4 --- /dev/null +++ b/internal/config/types.go @@ -0,0 +1,16 @@ +package config + +type Theme struct { +} + +type ThemeType struct { + Name string + Overrides Theme +} + +type ThemeOverrides struct { +} + +type Config struct { + Theme ThemeType `toml:"Theme"` +} diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index d5c856d..708f9d8 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -9,7 +9,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/yashranjan1/relay/internal/log" - optionsProvider "github.com/yashranjan1/relay/internal/tui/components/OptionsProvider" "github.com/yashranjan1/relay/internal/tui/keybinds" "github.com/yashranjan1/relay/internal/tui/messages" "github.com/yashranjan1/relay/internal/tui/styles" @@ -21,6 +20,7 @@ type ViewName string const ( Collections ViewName = "collections" Endpoints ViewName = "endpoints" + Request ViewName = "request" ) type Heading struct { @@ -47,39 +47,37 @@ func (a AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { + case messages.DeleteItem: + a.Views[Collections], cmd = a.Views[Collections].Update(messages.RefreshItemsList{}) + case messages.ItemAdded: + a.Views[Collections], cmd = a.Views[Collections].Update(messages.RefreshItemsList{}) case tea.WindowSizeMsg: a.height = msg.Height a.width = msg.Width - for key, _ := range a.Views { + for key := range a.Views { a.Views[key], cmd = a.Views[key].Update(tea.WindowSizeMsg{Height: a.AvailableHeight(), Width: msg.Width}) cmds = append(cmds, cmd) } - cmds = append(cmds, cmd) return a, tea.Batch(cmds...) - case messages.ChooseItem[optionsProvider.Option]: - switch msg.Source { - case "collections": - return a, func() tea.Msg { - return messages.NavigateToView{ - ViewName: string(Endpoints), - Data: msg.Item, - } - } - } case messages.NavigateToView: - a.Views[a.focusedView].OnBlur() - if msg.Data != nil { err := a.Views[ViewName(msg.ViewName)].SetState(msg.Data) if err != nil { log.Error("failed to set view state during navigation", "target_view", msg.ViewName, "error", err) return a, nil } + } else if msg.Target != views.MainModel { + break } + a.Views[a.focusedView].OnBlur() + a.focusedView = ViewName(msg.ViewName) - a.Views[a.focusedView].OnFocus() - return a, nil + cmd = a.Views[a.focusedView].OnFocus() + cmds = append(cmds, cmd) + + return a, tea.Batch(cmds...) + case messages.ShowError: log.Error("user operation failed", "error", msg.Message) a.errorMsg = msg.Message @@ -89,15 +87,6 @@ func (a AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, keybinds.Keys.Quit): return a, tea.Quit - case key.Matches(msg, keybinds.Keys.Back): - if a.focusedView == Endpoints { - return a, func() tea.Msg { - return messages.NavigateToView{ - ViewName: string(Collections), - Data: nil, - } - } - } } } @@ -191,9 +180,20 @@ func NewAppModel(ctx *Context) AppModel { help: help.New(), keys: appKeybinds, } + + epUpdateFunc := model.ctx.Endpoints.UpdateEndpoint + + reqCfg := views.RequestViewConfig{ + EpManager: model.ctx.Endpoints, + Update: epUpdateFunc, + Order: 3, + Client: model.ctx.HTTP, + } + model.Views = map[ViewName]views.ViewInterface{ Collections: views.NewCollectionsView(model.ctx.Collections, model.ctx.Endpoints, 1), Endpoints: views.NewEndpointsView(model.ctx.Endpoints, 2), + Request: views.NewRequestView(reqCfg), } return model } diff --git a/internal/tui/components/ComponentTypes/types.go b/internal/tui/components/ComponentTypes/types.go new file mode 100644 index 0000000..9bab7d7 --- /dev/null +++ b/internal/tui/components/ComponentTypes/types.go @@ -0,0 +1,20 @@ +package componenttypes + +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/yashranjan1/relay/internal/backend/endpoints" +) + +type ReqViewComponent interface { + Init() tea.Cmd + Update(tea.Msg) (ReqViewComponent, tea.Cmd) + View() string + SetWidth(int) + UpdateState(endpoints.EndpointEntity) endpoints.EndpointEntity + SetState(endpoints.EndpointEntity) + GetWidth() int + Help() []key.Binding + OnFocus() + OnBlur() +} diff --git a/internal/tui/components/MethodPicker/component.go b/internal/tui/components/MethodPicker/component.go new file mode 100644 index 0000000..4a00722 --- /dev/null +++ b/internal/tui/components/MethodPicker/component.go @@ -0,0 +1,119 @@ +package methodpicker + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/yashranjan1/relay/internal/backend/endpoints" + componenttypes "github.com/yashranjan1/relay/internal/tui/components/ComponentTypes" + "github.com/yashranjan1/relay/internal/tui/keybinds" +) + +type MethodPicker[T any] struct { + list list.Model + onSelectAction tea.Msg + delegateGenner func(bool) list.ItemDelegate + keys *keybinds.ListKeyMap + state endpoints.EndpointEntity + width int + height int + itemMapper func([]T) []list.Item +} + +type MethodOption struct { + Name string + Type string +} + +func (m MethodOption) Title() string { return m.Name } +func (m MethodOption) Description() string { return m.Name } +func (m MethodOption) Value() string { return m.Type } +func (m MethodOption) FilterValue() string { return m.Name } + +func (m *MethodPicker[T]) Init() tea.Cmd { + return nil +} + +func (m *MethodPicker[T]) SetWidth(width int) { + m.list.SetWidth(width) +} + +func (m *MethodPicker[T]) GetWidth() int { + return m.list.Width() +} + +func (m *MethodPicker[T]) SetState(ep endpoints.EndpointEntity) { + if index := m.findIndex(ep.Method); index >= 0 { + m.list.Select(index) + m.state = ep + } +} + +func (m *MethodPicker[T]) UpdateState(ep endpoints.EndpointEntity) endpoints.EndpointEntity { + ep.Method = m.list.SelectedItem().(MethodOption).Name + return ep +} + +func (m *MethodPicker[T]) findIndex(method string) int { + items := m.list.Items() + for index, item := range items { + if option, ok := item.(MethodOption); ok && option.Name == method { + return index + } + } + return -1 +} + +func (m *MethodPicker[T]) Update(msg tea.Msg) (componenttypes.ReqViewComponent, tea.Cmd) { + var cmd tea.Cmd + + m.list, cmd = m.list.Update(msg) + + return m, cmd +} + +func (m *MethodPicker[T]) View() string { + return m.list.View() +} + +func (m *MethodPicker[T]) OnFocus() { + m.list.SetDelegate(m.delegateGenner(true)) +} + +func (m *MethodPicker[T]) OnBlur() { + m.list.SetDelegate(m.delegateGenner(false)) +} + +func (m *MethodPicker[T]) GetSelected() MethodOption { + return m.list.SelectedItem().(MethodOption) +} + +func (m *MethodPicker[T]) Help() []key.Binding { + return []key.Binding{ + m.keys.CursorDown, + m.keys.CursorUp, + } +} + +func NewMethodPicker[T any](config MethodPickerConfig[T]) *MethodPicker[T] { + method := &MethodPicker[T]{} + + picker := list.New( + config.ItemMapper(config.Items), + config.Delegate(false), + config.Width, + config.Height, + ) + + picker.SetFilteringEnabled(config.FilteringEnabled) + picker.SetShowStatusBar(config.ShowStatusBar) + picker.SetShowPagination(config.ShowPagination) + picker.SetShowHelp(config.ShowHelp) + picker.SetShowTitle(config.ShowTitle) + + method.list = picker + method.keys = config.KeyMap + method.delegateGenner = config.Delegate + + return method +} diff --git a/internal/tui/components/MethodPicker/config.go b/internal/tui/components/MethodPicker/config.go new file mode 100644 index 0000000..096ad74 --- /dev/null +++ b/internal/tui/components/MethodPicker/config.go @@ -0,0 +1,27 @@ +package methodpicker + +import ( + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/yashranjan1/relay/internal/tui/keybinds" +) + +type MethodPickerConfig[T any] struct { + Items []T + ItemMapper func([]T) []list.Item + FilteringEnabled bool + + Delegate func(bool) list.ItemDelegate + KeyMap *keybinds.ListKeyMap + AdditionalKeymaps *keybinds.ListKeyMap + + OnSelectAction tea.Msg + + ShowPagination bool + ShowStatusBar bool + ShowHelp bool + ShowTitle bool + Width, Height int +} + +func config() {} diff --git a/internal/tui/components/OptionsProvider/component.go b/internal/tui/components/OptionsProvider/component.go index 9a63d9b..dbb6320 100644 --- a/internal/tui/components/OptionsProvider/component.go +++ b/internal/tui/components/OptionsProvider/component.go @@ -23,6 +23,8 @@ type OptionsProvider[T, U any] struct { list list.Model input OptionsInput onSelectAction tea.Msg + onChangeAction func(int64) + delegateGenner func(bool) list.ItemDelegate keys *keybinds.ListKeyMap width int height int @@ -43,7 +45,7 @@ func (o Option) Description() string { return o.Subtext } func (o Option) Value() int64 { return o.ID } func (o Option) FilterValue() string { return o.Name } -func (o OptionsProvider[T, U]) Init() tea.Cmd { +func (o *OptionsProvider[T, U]) Init() tea.Cmd { return nil } @@ -66,6 +68,9 @@ func (o OptionsProvider[T, U]) Update(msg tea.Msg) (OptionsProvider[T, U], tea.C o.focused = textComponent return o, tea.Batch(cmds...) case key.Matches(msg, o.keys.DeleteItem): + if o.GetSelected().ID == -1 { + return o, tea.Batch(cmds...) + } return o, func() tea.Msg { return messages.DeleteItem{ItemID: int64(o.GetSelected().ID)} } case key.Matches(msg, o.keys.Choose): return o, func() tea.Msg { @@ -75,6 +80,9 @@ func (o OptionsProvider[T, U]) Update(msg tea.Msg) (OptionsProvider[T, U], tea.C } } case key.Matches(msg, o.keys.EditItem): + if o.GetSelected().ID == -1 { + return o, tea.Batch(cmds...) + } o.list.SetSize(o.list.Width(), o.height-lipgloss.Height(o.input.View())) o.input.SetInput(o.GetSelected().Name) o.input.OnFocus(o.GetSelected().ID) @@ -95,7 +103,12 @@ func (o OptionsProvider[T, U]) Update(msg tea.Msg) (OptionsProvider[T, U], tea.C } switch o.focused { case listComponent: + index := o.list.Index() o.list, cmd = o.list.Update(msg) + newIndex := o.list.Index() + if newIndex != index && o.onChangeAction != nil { + o.onChangeAction(o.GetSelected().ID) + } case textComponent: o.input, cmd = o.input.Update(msg) } @@ -103,6 +116,16 @@ func (o OptionsProvider[T, U]) Update(msg tea.Msg) (OptionsProvider[T, U], tea.C return o, tea.Batch(cmds...) } +func (o *OptionsProvider[T, U]) UpdateState() tea.Cmd { + rawItems, err := o.getItems(context.Background()) + if err != nil { + // FIX: add err handling + } + + items := o.itemMapper(rawItems) + return o.list.SetItems(items) +} + func (o OptionsProvider[T, U]) View() string { if o.focused == textComponent { return lipgloss.JoinVertical(lipgloss.Left, o.list.View(), o.input.View()) @@ -113,8 +136,7 @@ func (o OptionsProvider[T, U]) View() string { func (o *OptionsProvider[T, U]) OnFocus() { } -func (o OptionsProvider[T, U]) OnBlur() { - +func (o *OptionsProvider[T, U]) OnBlur() { } func (o OptionsProvider[T, U]) GetSelected() Option { @@ -125,7 +147,17 @@ func (o OptionsProvider[T, U]) GetSelected() Option { Subtext: "", } } - return o.list.SelectedItem().(Option) + + item := o.list.SelectedItem() + + if item == nil { + return Option{ + Name: "No selection", + ID: -1, + Subtext: "", + } + } + return item.(Option) } func (o OptionsProvider[T, U]) IsFiltering() bool { @@ -141,6 +173,10 @@ func (o *OptionsProvider[T, U]) RefreshItems() { o.list.SetItems(o.itemMapper(newItems)) } +func (o *OptionsProvider[T, U]) GetSize() (int, int) { + return o.width, o.height +} + func (o *OptionsProvider[T, U]) Help() []key.Binding { var binds []key.Binding switch o.focused { @@ -181,7 +217,7 @@ func initList[T, U any](config *ListConfig[T, U]) list.Model { items := config.ItemMapper(rawItems) - list := list.New(items, config.Delegate, 30, 30) + list := list.New(items, config.Delegate(true), 100, 30) // list configuration list.SetFilteringEnabled(config.FilteringEnabled) @@ -201,12 +237,16 @@ func (o *OptionsProvider[T, U]) SetGetItemsFunc(getItems func(context.Context) ( log.Error("error fetching items") } o.list.SetItems(o.itemMapper(items)) + if mappedItems := o.itemMapper(items); o.onChangeAction != nil && len(mappedItems) > 0 { + o.onChangeAction(mappedItems[0].(Option).ID) + } + } func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider[T, U] { inputConfig := InputConfig{ CharLimit: 100, - Placeholder: "Add A New Collection...", + Placeholder: config.Placeholder, Width: 22, Prompt: "", KeyMap: InputKeyMaps{ @@ -216,12 +256,15 @@ func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider[T, U } return OptionsProvider[T, U]{ - list: initList(config), - focused: listComponent, - input: NewOptionsInput(&inputConfig), - getItems: config.GetItemsFunc, - itemMapper: config.ItemMapper, - keys: config.AdditionalKeymaps, - source: config.Source, + list: initList(config), + focused: listComponent, + input: NewOptionsInput(&inputConfig), + getItems: config.GetItemsFunc, + itemMapper: config.ItemMapper, + keys: config.AdditionalKeymaps, + source: config.Source, + delegateGenner: config.Delegate, + onChangeAction: config.OnChangeAction, } + } diff --git a/internal/tui/components/OptionsProvider/config.go b/internal/tui/components/OptionsProvider/config.go index 9a0a096..7c8461b 100644 --- a/internal/tui/components/OptionsProvider/config.go +++ b/internal/tui/components/OptionsProvider/config.go @@ -12,6 +12,7 @@ import ( type ListConfig[T, U any] struct { OnSelectAction tea.Msg + OnChangeAction func(int64) ShowPagination bool ShowStatusBar bool @@ -21,7 +22,8 @@ type ListConfig[T, U any] struct { FilteringEnabled bool - Delegate list.ItemDelegate + Placeholder string + Delegate func(bool) list.ItemDelegate KeyMap list.KeyMap AdditionalKeymaps *keybinds.ListKeyMap diff --git a/internal/tui/components/UrlInput/component.go b/internal/tui/components/UrlInput/component.go new file mode 100644 index 0000000..55ffb1f --- /dev/null +++ b/internal/tui/components/UrlInput/component.go @@ -0,0 +1,75 @@ +package urlinput + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/yashranjan1/relay/internal/backend/endpoints" + componenttypes "github.com/yashranjan1/relay/internal/tui/components/ComponentTypes" +) + +type UrlInput struct { + text textinput.Model + styleGenner func(bool) lipgloss.Style + width int + height int + active bool +} + +func (u *UrlInput) Init() tea.Cmd { + return nil +} + +func (u *UrlInput) SetWidth(width int) { + u.text.Width = width +} + +func (u *UrlInput) GetWidth() int { + return u.text.Width +} + +func (u *UrlInput) Help() []key.Binding { + return []key.Binding{} +} + +func (u *UrlInput) SetState(ep endpoints.EndpointEntity) { + u.text.SetValue(ep.Url) +} + +func (u *UrlInput) UpdateState(ep endpoints.EndpointEntity) endpoints.EndpointEntity { + ep.Url = u.text.Value() + return ep +} + +func (u *UrlInput) Update(msg tea.Msg) (componenttypes.ReqViewComponent, tea.Cmd) { + var cmd tea.Cmd + + u.text, cmd = u.text.Update(msg) + + return u, cmd +} + +func (u *UrlInput) View() string { + return u.styleGenner(u.active).Render(u.text.View()) +} + +func (u *UrlInput) OnFocus() { + u.text.Focus() + u.active = true +} + +func (u *UrlInput) OnBlur() { + u.text.Blur() + u.active = false +} + +func NewUrlInput(config UrlInputConfig) *UrlInput { + return &UrlInput{ + text: textinput.New(), + width: config.Width, + height: config.Height, + styleGenner: config.StyleGenner, + active: false, + } +} diff --git a/internal/tui/components/UrlInput/config.go b/internal/tui/components/UrlInput/config.go new file mode 100644 index 0000000..7e5b16a --- /dev/null +++ b/internal/tui/components/UrlInput/config.go @@ -0,0 +1,9 @@ +package urlinput + +import "github.com/charmbracelet/lipgloss" + +type UrlInputConfig struct { + StyleGenner func(bool) lipgloss.Style + Width int + Height int +} diff --git a/internal/tui/components/ViewPort/component.go b/internal/tui/components/ViewPort/component.go new file mode 100644 index 0000000..c63180d --- /dev/null +++ b/internal/tui/components/ViewPort/component.go @@ -0,0 +1,102 @@ +package viewport + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/yashranjan1/relay/internal/backend/http" + "github.com/yashranjan1/relay/internal/tui/keybinds" +) + +type Viewport struct { + viewport viewport.Model + width int + height int + ready bool + state string +} + +func (v *Viewport) Init() tea.Cmd { + return nil +} + +func (v *Viewport) SetState(state *http.Response) { + var str strings.Builder + + fmt.Fprintf(&str, "Status code: %d\n", state.StatusCode) + fmt.Fprintf(&str, "ContentType header: %s\n", state.Headers["Content-Type"]) + fmt.Fprintf(&str, "Body: %s\n", state.Body) + + v.state = str.String() + v.viewport.SetContent(v.state) +} + +func (v Viewport) Update(msg tea.Msg) (Viewport, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + if !v.ready { + v.viewport = viewport.New(msg.Width, msg.Height-20) + v.width = msg.Width + v.height = msg.Height + v.ready = true + } else { + v.viewport.Width = msg.Width + v.viewport.Height = msg.Height - 10 + } + } + + v.viewport, cmd = v.viewport.Update(msg) + + cmds = append(cmds, cmd) + + return v, tea.Batch(cmds...) +} + +func (v *Viewport) View() string { + if !v.ready { + return "Waiting.." + } + // FIX: this mess + str := fmt.Sprintf(" %d%% ", int(v.viewport.ScrollPercent()*100)) + + padding := v.height - v.viewport.Height - 7 + + return lipgloss.JoinVertical(lipgloss.Left, + v.viewport.View(), + lipgloss.NewStyle(). + Width(v.width). + PaddingTop(padding). + Align(lipgloss.Right). + Render(str), + ) +} + +func (v *Viewport) OnFocus() { +} + +func (v *Viewport) OnBlur() { +} + +func (v *Viewport) Help() []key.Binding { + // FIX: this + return []key.Binding{ + keybinds.Keys.Down, + keybinds.Keys.Up, + } +} + +func NewViewport() Viewport { + return Viewport{ + viewport: viewport.New(1, 1), + ready: false, + state: "No response received yet :(", + } +} diff --git a/internal/tui/keybinds/keys.go b/internal/tui/keybinds/keys.go index d04f267..dcc1193 100644 --- a/internal/tui/keybinds/keys.go +++ b/internal/tui/keybinds/keys.go @@ -14,6 +14,10 @@ type Keymaps struct { Up key.Binding Down key.Binding NextPage key.Binding + SendRequest key.Binding + Next key.Binding + Prev key.Binding + Save key.Binding PrevPage key.Binding Filter key.Binding ClearFilter key.Binding @@ -24,24 +28,40 @@ type Keymaps struct { var Keys = Keymaps{ Back: key.NewBinding( - key.WithKeys("esc", "q"), - key.WithHelp("esc/q", "back"), + key.WithKeys("ctrl+b"), + key.WithHelp("ctrl+b", "back"), + ), + SendRequest: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r", "send request"), + ), + Save: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "save"), ), Up: key.NewBinding( key.WithKeys("up", "k"), - key.WithHelp("↑/k", "back"), + key.WithHelp("↑/k", "up"), + ), + Prev: key.NewBinding( + key.WithKeys("ctrl+h"), + key.WithHelp("ctrl+h", "prev field"), + ), + Next: key.NewBinding( + key.WithKeys("ctrl+l"), + key.WithHelp("ctrl+l", "next field"), ), Down: key.NewBinding( key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"), ), PrevPage: key.NewBinding( - key.WithKeys("left", "h", "pgup"), - key.WithHelp("←/h/pgup", "prev page"), + key.WithKeys("left", "h"), + key.WithHelp("←/h", "prev page"), ), NextPage: key.NewBinding( key.WithKeys("right", "l", "pgdown"), - key.WithHelp("→/l/pgdn", "next page"), + key.WithHelp("→/l", "next page"), ), Filter: key.NewBinding( key.WithKeys("/"), @@ -61,11 +81,11 @@ var Keys = Keymaps{ ), InsertItem: key.NewBinding( key.WithKeys("a"), - key.WithHelp("a", "add item"), + key.WithHelp("a", "add"), ), EditItem: key.NewBinding( key.WithKeys("e"), - key.WithHelp("e", "edit item"), + key.WithHelp("e", "edit"), ), Choose: key.NewBinding( key.WithKeys("enter"), diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go index a3192b6..38adaa7 100644 --- a/internal/tui/messages/messages.go +++ b/internal/tui/messages/messages.go @@ -1,5 +1,7 @@ package messages +import "github.com/yashranjan1/relay/internal/backend/http" + type ItemAdded struct { Item string } @@ -21,9 +23,15 @@ type DeactivateView struct{} type NavigateToView struct { ViewName string + Target string Data interface{} } +type Response struct { + Data *http.Response + Err error +} + type RefreshItemsList struct{} type ShowError struct { diff --git a/internal/tui/styles/app-styles.go b/internal/tui/styles/app-styles.go index 365ef5d..bf89ec3 100644 --- a/internal/tui/styles/app-styles.go +++ b/internal/tui/styles/app-styles.go @@ -5,13 +5,25 @@ import ( ) var ( - footerNameStyle = lipgloss.NewStyle().Bold(true).Background(footerNameBG) - footerNameBGStyle = lipgloss.NewStyle().Background(footerNameBG).Padding(0, 3, 0) - FooterSegmentStyle = lipgloss.NewStyle().Background(footerSegmentBG).PaddingLeft(2).Foreground(footerSegmentFG) - FooterVersionStyle = lipgloss.NewStyle().Background(footerSegmentBG).AlignHorizontal(lipgloss.Right).PaddingRight(2).Foreground(footerSegmentFG) + footerNameStyle = lipgloss.NewStyle().Bold(true).Background(AppTheme.FooterNameBG) + footerNameBGStyle = lipgloss.NewStyle().Background(AppTheme.FooterNameBG).Padding(0, 3, 0) + FooterSegmentStyle = lipgloss.NewStyle().Background(AppTheme.FooterSegmentBG).PaddingLeft(2).Foreground(AppTheme.FooterSegmentFG) + FooterVersionStyle = lipgloss.NewStyle().Background(AppTheme.FooterSegmentBG).AlignHorizontal(lipgloss.Right).PaddingRight(2).Foreground(AppTheme.FooterSegmentFG) TabHeadingInactive = lipgloss.NewStyle().Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) - TabHeadingActive = lipgloss.NewStyle().Background(accent).Foreground(headingForeground).Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) + TabHeadingActive = lipgloss.NewStyle().Background(AppTheme.Accent).Foreground(AppTheme.HeadingForeground).Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) HelpStyle = lipgloss.NewStyle().Padding(1, 0, 1, 2) - AppHelpStyle = lipgloss.NewStyle().Padding(1, 0).Foreground(helpFG) + AppHelpStyle = lipgloss.NewStyle().Padding(1, 0).Foreground(AppTheme.HelpFG) ErrorBarStyle = lipgloss.NewStyle().Background(lipgloss.Color("#FF0000")).Foreground(lipgloss.Color("#FFFFFF")).Padding(0, 1) ) + +func initAppStyles() { + footerNameStyle = lipgloss.NewStyle().Bold(true).Background(AppTheme.FooterNameBG) + footerNameBGStyle = lipgloss.NewStyle().Background(AppTheme.FooterNameBG).Padding(0, 3, 0) + FooterSegmentStyle = lipgloss.NewStyle().Background(AppTheme.FooterSegmentBG).PaddingLeft(2).Foreground(AppTheme.FooterSegmentFG) + FooterVersionStyle = lipgloss.NewStyle().Background(AppTheme.FooterSegmentBG).AlignHorizontal(lipgloss.Right).PaddingRight(2).Foreground(AppTheme.FooterSegmentFG) + TabHeadingInactive = lipgloss.NewStyle().Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) + TabHeadingActive = lipgloss.NewStyle().Background(AppTheme.Accent).Foreground(AppTheme.HeadingForeground).Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) + HelpStyle = lipgloss.NewStyle().Padding(1, 0, 1, 2) + AppHelpStyle = lipgloss.NewStyle().Padding(1, 0).Foreground(AppTheme.HelpFG) + ErrorBarStyle = lipgloss.NewStyle().Background(lipgloss.Color("#FF0000")).Foreground(lipgloss.Color("#FFFFFF")).Padding(0, 1) +} diff --git a/internal/tui/styles/collections-styles.go b/internal/tui/styles/collections-styles.go index 600e885..daf234e 100644 --- a/internal/tui/styles/collections-styles.go +++ b/internal/tui/styles/collections-styles.go @@ -3,6 +3,18 @@ package styles import "github.com/charmbracelet/lipgloss" var ( - SelectedListStyle = lipgloss.NewStyle().Foreground(accent).PaddingLeft(1).Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(accent) - InputStyle = lipgloss.NewStyle().Padding(1, 2).Border(lipgloss.NormalBorder(), false, false, false, true).Margin(1, 0) + SelectedInactiveListStyle = lipgloss.NewStyle().PaddingLeft(1).Border(lipgloss.NormalBorder(), false, false, false, true) + SelectedActiveListStyle = lipgloss.NewStyle().Foreground(AppTheme.Accent).PaddingLeft(1).Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(AppTheme.Accent) + UnselectedListStyle = lipgloss.NewStyle().PaddingLeft(2) + InputStyle = lipgloss.NewStyle().Padding(1, 2).Border(lipgloss.NormalBorder(), false, false, false, true).Margin(1, 0) + // Grad = lipg ) + +func initCollectionStyles() { + + SelectedInactiveListStyle = lipgloss.NewStyle().PaddingLeft(1).Border(lipgloss.NormalBorder(), false, false, false, true) + SelectedActiveListStyle = lipgloss.NewStyle().Foreground(AppTheme.Accent).PaddingLeft(1).Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(AppTheme.Accent) + UnselectedListStyle = lipgloss.NewStyle().PaddingLeft(2) + InputStyle = lipgloss.NewStyle().Padding(1, 2).Border(lipgloss.NormalBorder(), false, false, false, true).Margin(1, 0) + // Grad = lipg +} diff --git a/internal/tui/styles/colors.go b/internal/tui/styles/colors.go index 9cb8232..12f6f05 100644 --- a/internal/tui/styles/colors.go +++ b/internal/tui/styles/colors.go @@ -2,13 +2,53 @@ package styles import "github.com/charmbracelet/lipgloss" -var ( - footerNameBG = lipgloss.Color("#1a1a1a") - footerNameFGFrom = lipgloss.Color("#41A0AE") - footerNameFGTo = lipgloss.Color("#77F07F") - accent = lipgloss.Color("#77F07F") - headingForeground = lipgloss.Color("#000000") - footerSegmentBG = lipgloss.Color("#262626") - footerSegmentFG = lipgloss.Color("#656565") - helpFG = lipgloss.Color("#3C3C3C") -) +var ThemeMap = map[string]Theme{ + "default": DefaultTheme, + "cyberpunk": CyberpunkTheme, + "light-paper": LightPaperTheme, + "forest": ForestTheme, +} + +var DefaultTheme = Theme{ + FooterNameBG: lipgloss.Color("#1a1a1a"), + FooterNameFGFrom: lipgloss.Color("#41A0AE"), + FooterNameFGTo: lipgloss.Color("#77F07F"), + Accent: lipgloss.Color("#77F07F"), + HeadingForeground: lipgloss.Color("#000000"), + FooterSegmentBG: lipgloss.Color("#262626"), + FooterSegmentFG: lipgloss.Color("#656565"), + HelpFG: lipgloss.Color("#3C3C3C"), +} + +var LightPaperTheme = Theme{ + FooterNameBG: lipgloss.Color("#E5E5E0"), + FooterNameFGFrom: lipgloss.Color("#006680"), + FooterNameFGTo: lipgloss.Color("#2AA198"), + Accent: lipgloss.Color("#2AA198"), + HeadingForeground: lipgloss.Color("#073642"), + FooterSegmentBG: lipgloss.Color("#D3D3CD"), + FooterSegmentFG: lipgloss.Color("#586E75"), + HelpFG: lipgloss.Color("#93A1A1"), +} + +var ForestTheme = Theme{ + FooterNameBG: lipgloss.Color("#18201E"), + FooterNameFGFrom: lipgloss.Color("#40A02B"), // Rich forest green + FooterNameFGTo: lipgloss.Color("#179299"), // Deep teal + Accent: lipgloss.Color("#40A02B"), // Darker green with much higher contrast + HeadingForeground: lipgloss.Color("#E6E9EF"), + FooterSegmentBG: lipgloss.Color("#25322F"), + FooterSegmentFG: lipgloss.Color("#97B6AC"), + HelpFG: lipgloss.Color("#4F6C64"), +} + +var CyberpunkTheme = Theme{ + FooterNameBG: lipgloss.Color("#181425"), + FooterNameFGFrom: lipgloss.Color("#FF2A6D"), + FooterNameFGTo: lipgloss.Color("#05D9E8"), + Accent: lipgloss.Color("#05D9E8"), + HeadingForeground: lipgloss.Color("#FFFFFF"), + FooterSegmentBG: lipgloss.Color("#241734"), + FooterSegmentFG: lipgloss.Color("#8A78A5"), + HelpFG: lipgloss.Color("#62447D"), +} diff --git a/internal/tui/styles/functions.go b/internal/tui/styles/functions.go index 422250b..d031438 100644 --- a/internal/tui/styles/functions.go +++ b/internal/tui/styles/functions.go @@ -39,5 +39,5 @@ func hexToRGB(hex string) (int, int, int) { } func ApplyGradientToFooter(text string) string { - return gradientText("REQ", footerNameFGFrom, footerNameFGTo, footerNameStyle, footerNameBGStyle) + return gradientText("REQ", AppTheme.FooterNameFGFrom, AppTheme.FooterNameFGTo, footerNameStyle, footerNameBGStyle) } diff --git a/internal/tui/styles/request-styles.go b/internal/tui/styles/request-styles.go new file mode 100644 index 0000000..c668d2c --- /dev/null +++ b/internal/tui/styles/request-styles.go @@ -0,0 +1,39 @@ +package styles + +import "github.com/charmbracelet/lipgloss" + +func RequestLayout(height, width int) func(...string) string { + return lipgloss.NewStyle(). + Height(height). + Width(width). + Padding(1, 0, 0, 1). + Render +} + +func UrlInputStyle(active bool) lipgloss.Style { + var color lipgloss.Color + + if active { + color = AppTheme.Accent + } + + return lipgloss.NewStyle(). + Foreground(color). + Margin(0, 0, 0, 5). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(color) +} + +func ResponseStyle(height, width int) func(...string) string { + return lipgloss.NewStyle().Padding(1, 0, 0, 1).Height(height).Width(width).Render +} + +var ( + ActiveRequestItem = lipgloss.NewStyle().Foreground(AppTheme.Accent) + InactiveRequestItem = lipgloss.NewStyle() +) + +func initRequestStyles() { + ActiveRequestItem = lipgloss.NewStyle().Foreground(AppTheme.Accent) + InactiveRequestItem = lipgloss.NewStyle() +} diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go new file mode 100644 index 0000000..649242a --- /dev/null +++ b/internal/tui/styles/theme.go @@ -0,0 +1,33 @@ +package styles + +import ( + "github.com/charmbracelet/lipgloss" +) + +type Theme struct { + FooterNameBG lipgloss.Color + FooterNameFGFrom lipgloss.Color + FooterNameFGTo lipgloss.Color + Accent lipgloss.Color + HeadingForeground lipgloss.Color + FooterSegmentBG lipgloss.Color + FooterSegmentFG lipgloss.Color + HelpFG lipgloss.Color +} + +var ( + AppTheme Theme +) + +func init() { + SetTheme(DefaultTheme) +} + +func SetTheme(newTheme Theme) { + AppTheme = newTheme + + // reset styles + initRequestStyles() + initCollectionStyles() + initAppStyles() +} diff --git a/internal/tui/views/collections-view-helper.go b/internal/tui/views/collections-view-helper.go index 7174dc9..cce4df6 100644 --- a/internal/tui/views/collections-view-helper.go +++ b/internal/tui/views/collections-view-helper.go @@ -7,23 +7,27 @@ import ( "github.com/yashranjan1/relay/internal/tui/styles" ) -func createDelegate() list.DefaultDelegate { +func createCollectionDelegate(active bool) list.ItemDelegate { d := list.NewDefaultDelegate() + if active { + d.Styles.SelectedTitle = styles.SelectedActiveListStyle + } else { + d.Styles.SelectedTitle = styles.SelectedInactiveListStyle - d.Styles.SelectedTitle = styles.SelectedListStyle - d.Styles.SelectedDesc = styles.SelectedListStyle + } + d.Styles.SelectedDesc = styles.SelectedActiveListStyle return d } -func defaultListConfig[T, U any](binds *keybinds.ListKeyMap) *optionsProvider.ListConfig[T, U] { +func defaultListConfig[T, U any](binds *keybinds.ListKeyMap, delegateCreator func(bool) list.ItemDelegate) *optionsProvider.ListConfig[T, U] { config := optionsProvider.ListConfig[T, U]{ ShowPagination: true, ShowStatusBar: false, ShowHelp: false, ShowTitle: false, FilteringEnabled: true, - Delegate: createDelegate(), + Delegate: delegateCreator, KeyMap: list.KeyMap{ CursorUp: binds.CursorUp, CursorDown: binds.CursorDown, diff --git a/internal/tui/views/collections-view.go b/internal/tui/views/collections-view.go index af082f6..54b78f8 100644 --- a/internal/tui/views/collections-view.go +++ b/internal/tui/views/collections-view.go @@ -27,23 +27,23 @@ type CollectionsView struct { order int } -func (c CollectionsView) Init() tea.Cmd { +func (c *CollectionsView) Init() tea.Cmd { return nil } -func (c CollectionsView) Name() string { +func (c *CollectionsView) Name() string { return "Collections" } -func (c CollectionsView) Help() []key.Binding { +func (c *CollectionsView) Help() []key.Binding { return c.list.Help() } -func (c CollectionsView) GetFooterSegment() string { +func (c *CollectionsView) GetFooterSegment() string { return c.list.GetSelected().Title() } -func (c CollectionsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { +func (c *CollectionsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { @@ -55,12 +55,7 @@ func (c CollectionsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { case messages.ItemAdded: _, err := c.manager.Create(context.Background(), msg.Item) if err != nil { - return c, func() tea.Msg { - return messages.ShowError{Message: err.Error()} - } - } - return c, func() tea.Msg { - return messages.RefreshItemsList{} + // TODO: handle this } case messages.RefreshItemsList: c.list.RefreshItems() @@ -70,6 +65,15 @@ func (c CollectionsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { case messages.DeleteItem: c.manager.Delete(context.Background(), msg.ItemID) c.list.RefreshItems() + case messages.ChooseItem[optionsProvider.Option]: + c.list.OnBlur() + return c, func() tea.Msg { + return messages.NavigateToView{ + ViewName: string(Endpoints), + Target: MainModel, + Data: msg.Item, + } + } } c.list, cmd = c.list.Update(msg) @@ -78,23 +82,23 @@ func (c CollectionsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { return c, tea.Batch(cmds...) } -func (c CollectionsView) View() string { +func (c *CollectionsView) View() string { return c.list.View() } -func (c CollectionsView) SetState(items ...any) error { +func (c *CollectionsView) SetState(items ...any) error { return errors.New("This view does not implement set state") } -func (c CollectionsView) Order() int { +func (c *CollectionsView) Order() int { return c.order } -func (c CollectionsView) OnFocus() { - +func (c *CollectionsView) OnFocus() tea.Cmd { + return c.list.UpdateState() } -func (c CollectionsView) OnBlur() { +func (c *CollectionsView) OnBlur() { } @@ -132,7 +136,7 @@ func itemMapper(items []collections.CollectionEntity, endpointsManager *endpoint func NewCollectionsView(collManager *collections.CollectionsManager, endpointsManager *endpoints.EndpointsManager, order int) *CollectionsView { keybinds := keybinds.NewListKeyMap() - config := defaultListConfig[collections.CollectionEntity, string](keybinds) + config := defaultListConfig[collections.CollectionEntity, string](keybinds, createCollectionDelegate) config.GetItemsFunc = collManager.List config.ItemMapper = func(items []collections.CollectionEntity) []list.Item { @@ -140,6 +144,7 @@ func NewCollectionsView(collManager *collections.CollectionsManager, endpointsMa } config.AdditionalKeymaps = keybinds config.Source = "collections" + config.Placeholder = "Add a new collection..." return &CollectionsView{ list: optionsProvider.NewOptionsProvider(config), diff --git a/internal/tui/views/endpoints-view-helper.go b/internal/tui/views/endpoints-view-helper.go new file mode 100644 index 0000000..87d8d4e --- /dev/null +++ b/internal/tui/views/endpoints-view-helper.go @@ -0,0 +1,19 @@ +package views + +import ( + "github.com/charmbracelet/bubbles/list" + "github.com/yashranjan1/relay/internal/tui/styles" +) + +func createEndpointsDelegate(active bool) list.ItemDelegate { + d := list.NewDefaultDelegate() + if active { + d.Styles.SelectedTitle = styles.SelectedActiveListStyle + } else { + d.Styles.SelectedTitle = styles.SelectedInactiveListStyle + + } + d.Styles.SelectedDesc = styles.SelectedActiveListStyle + + return d +} diff --git a/internal/tui/views/endpoints-view.go b/internal/tui/views/endpoints-view.go index 91f1d19..a747310 100644 --- a/internal/tui/views/endpoints-view.go +++ b/internal/tui/views/endpoints-view.go @@ -15,8 +15,15 @@ import ( "github.com/yashranjan1/relay/internal/tui/messages" ) +type epFocused string + +const ( + listView = "listview" +) + type EndpointsView struct { height int + focused epFocused collection optionsProvider.Option width int order int @@ -37,7 +44,7 @@ func (e *EndpointsView) Help() []key.Binding { } func (e *EndpointsView) GetFooterSegment() string { - return fmt.Sprintf("%s/", e.collection.Name) + return fmt.Sprintf("%s/%s", e.collection.Name, e.list.GetSelected().Name) } func (e *EndpointsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { @@ -50,19 +57,61 @@ func (e *EndpointsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { e.list, cmd = e.list.Update(msg) cmds = append(cmds, cmd) case messages.ItemAdded: - e.manager.CreateEndpoint(context.Background(), endpoints.EndpointData{ + _, err := e.manager.CreateEndpoint(context.Background(), endpoints.EndpointData{ CollectionID: e.collection.ID, Name: msg.Item, Method: "GET", }) + if err != nil { + //TODO: handle this + } + case messages.RefreshItemsList: + e.list.RefreshItems() case messages.ItemEdited: e.manager.UpdateEndpointName(context.Background(), msg.ItemID, msg.Item) case messages.DeleteItem: e.manager.Delete(context.Background(), msg.ItemID) e.list.RefreshItems() + case messages.ChooseItem[optionsProvider.Option]: + e.list.OnBlur() + return e, func() tea.Msg { + return messages.NavigateToView{ + ViewName: "request", + Target: MainModel, + Data: EndpointData{ + Collection: e.collection, + EndpointID: msg.Item.ID, + }, + } + } + case messages.NavigateToView: + if msg.Target != Endpoints { + break + } + // FIX: idek + // e.requestView.OnBlur() + e.focused = listView + + case tea.KeyMsg: + switch { + case key.Matches(msg, keybinds.Keys.Back): + if e.focused == listView { + // e.requestView.SetState(endpoints.EndpointEntity{}) + return e, func() tea.Msg { + return messages.NavigateToView{ + ViewName: Collections, + Target: MainModel, + } + } + } + } + } + + switch e.focused { + case listView: + e.list, cmd = e.list.Update(msg) } - e.list, cmd = e.list.Update(msg) cmds = append(cmds, cmd) return e, tea.Batch(cmds...) @@ -72,11 +121,12 @@ func (e *EndpointsView) View() string { return e.list.View() } -func (e *EndpointsView) OnFocus() { - +func (e *EndpointsView) OnFocus() tea.Cmd { + return e.list.UpdateState() } func (e *EndpointsView) SetState(items ...any) error { + // TODO: Something over here should also set the state for the request view if len(items) == 1 { if collection, ok := items[0].(optionsProvider.Option); ok { e.collection = collection @@ -123,7 +173,7 @@ func NewEndpointsView(epManager *endpoints.EndpointsManager, order int) *Endpoin } keybinds := keybinds.NewListKeyMap() - config := defaultListConfig[endpoints.EndpointEntity, database.Endpoint](keybinds) + config := defaultListConfig[endpoints.EndpointEntity, database.Endpoint](keybinds, createEndpointsDelegate) epListFunc := func(ctx context.Context) ([]endpoints.EndpointEntity, error) { return epManager.ListByCollection(ctx, view.collection.ID) @@ -132,9 +182,11 @@ func NewEndpointsView(epManager *endpoints.EndpointsManager, order int) *Endpoin config.GetItemsFunc = epListFunc config.ItemMapper = itemMapperEp config.AdditionalKeymaps = keybinds - config.Source = "collections" + config.Source = "endpoints" + config.Placeholder = "Add a new endpoint..." view.list = optionsProvider.NewOptionsProvider(config) + view.focused = listView return view } diff --git a/internal/tui/views/request-view-helper.go b/internal/tui/views/request-view-helper.go new file mode 100644 index 0000000..711a279 --- /dev/null +++ b/internal/tui/views/request-view-helper.go @@ -0,0 +1,89 @@ +package views + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + methodpicker "github.com/yashranjan1/relay/internal/tui/components/MethodPicker" + urlinput "github.com/yashranjan1/relay/internal/tui/components/UrlInput" + "github.com/yashranjan1/relay/internal/tui/keybinds" + "github.com/yashranjan1/relay/internal/tui/styles" +) + +func createRequestDelegate(active bool) list.ItemDelegate { + d := requestDelegate{} + + if active { + d.SelectedTitleStyle = styles.ActiveRequestItem + } else { + d.SelectedTitleStyle = styles.InactiveRequestItem + } + d.UnselectedTitleStyle = styles.InactiveRequestItem + + return d +} + +type requestDelegate struct { + SelectedTitleStyle lipgloss.Style + UnselectedTitleStyle lipgloss.Style +} + +func (r requestDelegate) Height() int { return 1 } +func (r requestDelegate) Spacing() int { return 1 } +func (r requestDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +func (r requestDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + item, ok := listItem.(methodpicker.MethodOption) + + if !ok { + return + } + + str := fmt.Sprintf("< %s >", item.Name) + + fn := r.UnselectedTitleStyle.Render + if index == m.Index() { + fn = func(s ...string) string { + return r.SelectedTitleStyle.Render(strings.Join(s, " ")) + } + } + + fmt.Fprint(w, fn(str)) +} + +func createMethodPickerConfig() methodpicker.MethodPickerConfig[string] { + methods := []string{ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + } + + keys := keybinds.NewListKeyMap() + + return methodpicker.MethodPickerConfig[string]{ + Items: methods, + ItemMapper: methodItemMapper, + FilteringEnabled: false, + ShowPagination: false, + ShowStatusBar: false, + KeyMap: keys, + Delegate: createRequestDelegate, + ShowHelp: false, + ShowTitle: false, + Width: 30, + Height: 1, + } +} + +func createURLInputConfig() urlinput.UrlInputConfig { + return urlinput.UrlInputConfig{ + Width: 50, + Height: 1, + StyleGenner: styles.UrlInputStyle, + } +} diff --git a/internal/tui/views/request-view.go b/internal/tui/views/request-view.go new file mode 100644 index 0000000..944c2bf --- /dev/null +++ b/internal/tui/views/request-view.go @@ -0,0 +1,295 @@ +package views + +import ( + "context" + "errors" + "fmt" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/yashranjan1/relay/internal/backend/endpoints" + "github.com/yashranjan1/relay/internal/backend/http" + componenttypes "github.com/yashranjan1/relay/internal/tui/components/ComponentTypes" + methodpicker "github.com/yashranjan1/relay/internal/tui/components/MethodPicker" + optionsProvider "github.com/yashranjan1/relay/internal/tui/components/OptionsProvider" + urlinput "github.com/yashranjan1/relay/internal/tui/components/UrlInput" + viewport "github.com/yashranjan1/relay/internal/tui/components/ViewPort" + "github.com/yashranjan1/relay/internal/tui/keybinds" + "github.com/yashranjan1/relay/internal/tui/messages" + "github.com/yashranjan1/relay/internal/tui/styles" +) + +type reqFocused string + +const ( + methodPicker = "method" + urlInput = "input" +) + +var componentList = []reqFocused{ + methodPicker, + urlInput, +} + +type RequestView struct { + width int + focused reqFocused + viewport viewport.Viewport + components map[reqFocused]componenttypes.ReqViewComponent + index int + epManager *endpoints.EndpointsManager + height int + loading bool + help help.Model + keys *keybinds.ListKeyMap + spinner spinner.Model + responsePage bool + client *http.HTTPManager + order int + update func(context.Context, int64, endpoints.EndpointData) (endpoints.EndpointEntity, error) + endpoint endpoints.EndpointEntity + collection optionsProvider.Option +} + +func (r *RequestView) Init() tea.Cmd { + return nil +} + +func (r *RequestView) Name() string { + return "Request" +} + +func (r *RequestView) Help() []key.Binding { + var reqViewBinds []key.Binding + if r.responsePage { + reqViewBinds = r.viewport.Help() + } else { + binds := r.components[r.focused].Help() + reqViewBinds = []key.Binding{ + keybinds.Keys.Prev, + keybinds.Keys.Next, + keybinds.Keys.Save, + keybinds.Keys.SendRequest, + } + reqViewBinds = append(binds, reqViewBinds...) + } + // FIX: should append responsePage binds when necessary + return reqViewBinds +} + +func (r *RequestView) GetFooterSegment() string { + return fmt.Sprintf("%s/%s", r.collection.Name, r.endpoint.Name) +} + +func (r *RequestView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + r.height = msg.Height + r.width = msg.Width + w := r.components[urlInput].GetWidth() + r.components[urlInput].SetWidth(r.width - (w + 30)) + r.viewport, cmd = r.viewport.Update(tea.WindowSizeMsg{ + Height: msg.Height, + Width: r.width - 4, + }) + case messages.Response: + if msg.Err != nil { + // TODO: do something here idek + } + r.viewport.SetState(msg.Data) + r.loading = false + case tea.KeyMsg: + switch { + case key.Matches(msg, keybinds.Keys.Back): + if r.responsePage { + r.responsePage = false + return r, nil + } + return r, func() tea.Msg { + return messages.NavigateToView{ + ViewName: Endpoints, + Target: MainModel, + } + } + case key.Matches(msg, keybinds.Keys.Next): + r.shift(true) + return r, func() tea.Msg { + return messages.RefreshItemsList{} + } + case key.Matches(msg, keybinds.Keys.Prev): + r.shift(false) + return r, func() tea.Msg { + return messages.RefreshItemsList{} + } + case key.Matches(msg, keybinds.Keys.SendRequest): + request := &http.Request{ + Method: r.endpoint.Method, + URL: r.endpoint.Url, + } + r.responsePage = true + r.loading = true + sendMsg := func() tea.Msg { + res, err := r.client.ExecuteRequest(request) + if err != nil { + return messages.Response{ + Err: err, + } + } + return messages.Response{ + Data: res, + } + } + return r, tea.Batch(r.spinner.Tick, sendMsg) + case key.Matches(msg, keybinds.Keys.Save): + for _, val := range r.components { + r.endpoint = val.UpdateState(r.endpoint) + } + r.update( + context.Background(), + r.endpoint.GetID(), + endpoints.EndpointData{ + Name: r.endpoint.Name, + Method: r.endpoint.Method, + URL: r.endpoint.Url, + }) + } + } + + if !r.responsePage { + r.components[r.focused], cmd = r.components[r.focused].Update(msg) + } else { + r.viewport, cmd = r.viewport.Update(msg) + } + + cmds = append(cmds, cmd) + + if r.loading { + r.spinner, cmd = r.spinner.Update(msg) + cmds = append(cmds, cmd) + } + + return r, tea.Batch(cmds...) +} + +func (r *RequestView) shift(next bool) { + if next { + r.index = (r.index + 1) % len(componentList) + } else { + r.index = (r.index - 1 + len(componentList)) % len(componentList) + } + r.endpoint = r.components[r.focused].UpdateState(r.endpoint) + r.components[r.focused].OnBlur() + r.focused = componentList[r.index] + r.components[r.focused].OnFocus() + r.update( + context.Background(), + r.endpoint.GetID(), + endpoints.EndpointData{ + Name: r.endpoint.Name, + Method: r.endpoint.Method, + URL: r.endpoint.Url, + }) +} + +func (r *RequestView) View() string { + if r.responsePage { + if r.loading { + loadingString := fmt.Sprintf("%s Loading...", r.spinner.View()) + return styles.RequestLayout(r.height, r.width)(lipgloss.Place(r.width, r.height-1, lipgloss.Center, lipgloss.Center, loadingString)) + } + view := styles.ResponseStyle(r.height, r.width)(r.viewport.View()) + return view + } else { + if r.endpoint.Name == "" { + return styles.RequestLayout(r.height, r.width)("No Endpoint selected") + } + views := []string{} + for _, val := range componentList { + views = append(views, r.components[val].View()) + } + view := lipgloss.JoinHorizontal( + lipgloss.Left, + views..., + ) + return styles.RequestLayout(r.height, r.width)(view) + } +} + +func (r *RequestView) SetState(items ...any) error { + if len(items) == 1 { + if data, ok := items[0].(EndpointData); ok { + ep, err := r.epManager.Read(context.Background(), data.EndpointID) + r.collection = data.Collection + if err != nil { + // FIX: do something over here + } + r.endpoint = ep + for _, val := range componentList { + r.components[val].SetState(ep) + } + return nil + } + } + return errors.New("Invalid inputs, this function takes 1 input of type endpoints.Entity") +} + +func (r *RequestView) Order() int { + return r.order +} + +func (r *RequestView) OnFocus() tea.Cmd { + r.components[r.focused].OnFocus() + return nil +} + +func (r *RequestView) OnBlur() { + r.components[r.focused].OnBlur() +} + +func methodItemMapper(items []string) []list.Item { + listItems := make([]list.Item, len(items)) + for index, item := range items { + listItems[index] = methodpicker.MethodOption{ + Name: item, + Type: item, + } + } + return listItems +} + +type RequestViewConfig struct { + EpManager *endpoints.EndpointsManager + Update func(context.Context, int64, endpoints.EndpointData) (endpoints.EndpointEntity, error) + Order int + Client *http.HTTPManager +} + +func NewRequestView(cfg RequestViewConfig) *RequestView { + mpConfig := createMethodPickerConfig() + + uiConfig := createURLInputConfig() + s := spinner.New() + s.Spinner = spinner.Dot + + return &RequestView{ + components: map[reqFocused]componenttypes.ReqViewComponent{ + methodPicker: methodpicker.NewMethodPicker(mpConfig), + urlInput: urlinput.NewUrlInput(uiConfig), + }, + + epManager: cfg.EpManager, + focused: methodPicker, + viewport: viewport.NewViewport(), + order: cfg.Order, + update: cfg.Update, + client: cfg.Client, + responsePage: false, + spinner: s, + } +} diff --git a/internal/tui/views/types.go b/internal/tui/views/types.go index 465b14d..e583977 100644 --- a/internal/tui/views/types.go +++ b/internal/tui/views/types.go @@ -3,6 +3,7 @@ package views import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" + optionsProvider "github.com/yashranjan1/relay/internal/tui/components/OptionsProvider" ) type ViewInterface interface { @@ -14,6 +15,11 @@ type ViewInterface interface { View() string Order() int SetState(...any) error - OnFocus() + OnFocus() tea.Cmd OnBlur() } + +type EndpointData struct { + EndpointID int64 + Collection optionsProvider.Option +} diff --git a/internal/tui/views/view-reference.go b/internal/tui/views/view-reference.go new file mode 100644 index 0000000..0457768 --- /dev/null +++ b/internal/tui/views/view-reference.go @@ -0,0 +1,8 @@ +package views + +const ( + Collections = "collections" + Endpoints = "endpoints" + Request = "request" + MainModel = "main" +) diff --git a/main.go b/main.go index 315cf7d..399cba5 100644 --- a/main.go +++ b/main.go @@ -12,16 +12,16 @@ import ( "runtime/debug" tea "github.com/charmbracelet/bubbletea" + _ "github.com/mattn/go-sqlite3" + "github.com/pressly/goose/v3" "github.com/yashranjan1/relay/internal/backend/collections" "github.com/yashranjan1/relay/internal/backend/database" - "github.com/yashranjan1/relay/internal/backend/demo" "github.com/yashranjan1/relay/internal/backend/endpoints" "github.com/yashranjan1/relay/internal/backend/history" "github.com/yashranjan1/relay/internal/backend/http" + "github.com/yashranjan1/relay/internal/config" "github.com/yashranjan1/relay/internal/log" "github.com/yashranjan1/relay/internal/tui/app" - _ "github.com/mattn/go-sqlite3" - "github.com/pressly/goose/v3" ) // Embed migration files into the binary @@ -64,12 +64,13 @@ func initPaths() error { if err != nil { return fmt.Errorf("error reading user's cache path: %w", err) } - APPDIR = filepath.Join(userCacheDir, "req") + APPDIR = filepath.Join(userCacheDir, "relay") if err := os.MkdirAll(APPDIR, 0o755); err != nil { return fmt.Errorf("error creating app directory: %w", err) } DBPATH = filepath.Join(APPDIR, "app.db") - LOGPATH = filepath.Join(APPDIR, "req.log") + LOGPATH = filepath.Join(APPDIR, "relay.log") + return nil } @@ -113,6 +114,13 @@ func runMigrations() error { } func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { // initialize paths first if err := initPaths(); err != nil { fmt.Fprintf(os.Stderr, "Failed to initialize: %v\n", err) @@ -121,7 +129,7 @@ func main() { // initialize logging logLevel := slog.LevelInfo - if os.Getenv("REQ_DEBUG") == "1" || os.Getenv("REQ_LOG_LEVEL") == "debug" { + if os.Getenv("RELAY_DEBUG") == "1" || os.Getenv("RELAY_LOG_LEVEL") == "debug" { logLevel = slog.LevelDebug } log.Initialize(log.Config{ @@ -134,15 +142,17 @@ func main() { } }() - log.Info("starting req application") + log.Info("starting relay application") // run database migrations if err := runMigrations(); err != nil { - log.Fatal("failed to run migrations", "error", err) + return fmt.Errorf("failed to run migrations: %s", err) } // create database client and managers db := database.New(DB) + defer DB.Close() + collectionsManager := collections.NewCollectionsManager(db) endpointsManager := endpoints.NewEndpointsManager(db) httpManager := http.NewHTTPManager() @@ -157,22 +167,18 @@ func main() { getVersion(), ) - // populate dummy data for demo - demoGenerator := demo.NewDemoGenerator(collectionsManager, endpointsManager) - dummyDataCreated, err := demoGenerator.PopulateDummyData(context.Background()) - if err != nil { - log.Error("failed to populate dummy data", "error", err) - } else if dummyDataCreated { - // appContext.SetDummyDataCreated(true) - } - log.Info("application initialized", "components", []string{"database", "collections", "endpoints", "http", "history", "logging", "demo"}) log.Debug("configuration loaded", "collections_manager", collectionsManager != nil, "endpoints", endpointsManager != nil, "database", db != nil, "http_manager", httpManager != nil, "history_manager", historyManager != nil) log.Info("application started successfully") // Entry point for UI + err := config.LoadConfig() + if err != nil { + return fmt.Errorf("fatal error: %w", err) + } program := tea.NewProgram(app.NewAppModel(appContext), tea.WithAltScreen()) - if _, err := program.Run(); err != nil { - log.Fatal("Fatal error:", err) + if _, err = program.Run(); err != nil { + return fmt.Errorf("fatal error: %w", err) } + return nil }