This commit is contained in:
Julian Freeman
2026-04-19 09:27:58 -04:00
parent f9a4c0e64a
commit bf9f08788e
4 changed files with 306 additions and 50 deletions

View File

@@ -1,22 +1,120 @@
<script setup lang="ts">
import { defineAsyncComponent, ref } from 'vue';
import { useRequestStore } from '../stores/requestStore';
import { computed, defineAsyncComponent, ref } from 'vue';
import { useRequestStore, type RequestError, type KeyValue } from '../stores/requestStore';
import KeyValueEditor from './KeyValueEditor.vue';
import AuthPanel from './AuthPanel.vue';
import CustomSelect from './CustomSelect.vue';
import { invoke } from '@tauri-apps/api/core';
import { Play, Loader2 } from 'lucide-vue-next';
import { Play, Loader2, Square } from 'lucide-vue-next';
const CodeEditor = defineAsyncComponent(() => import('./CodeEditor.vue'));
const store = useRequestStore();
const activeTab = ref('params');
const isLoading = ref(false);
const currentRequestId = ref<string | null>(null);
const bodyMode = ref<'auto' | 'json' | 'text'>('auto');
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
const bodyModeOptions = [
{ value: 'auto', label: 'Auto' },
{ value: 'json', label: 'JSON' },
{ value: 'text', label: 'Text' },
];
const methodSupportsBody = computed(() => !['GET', 'HEAD'].includes(store.activeRequest.method));
const inferredBodyLanguage = computed<'json' | 'text'>(() => {
if (bodyMode.value !== 'auto') return bodyMode.value;
const contentTypeHeader = store.activeRequest.headers.find((header) => (
header.enabled && header.key.trim().toLowerCase() === 'content-type'
));
const contentType = contentTypeHeader?.value.trim().toLowerCase() || '';
return contentType.includes('json') ? 'json' : 'text';
});
const requestBodyPlaceholder = computed(() => (
inferredBodyLanguage.value === 'json'
? 'Request Body (JSON)'
: 'Request Body'
));
const normalizeError = (error: unknown): RequestError => {
if (error && typeof error === 'object' && 'code' in error && 'message' in error) {
return {
code: String((error as { code: unknown }).code),
message: String((error as { message: unknown }).message),
};
}
return {
code: 'unknown',
message: String(error),
};
};
const findDuplicateKey = (items: KeyValue[]) => {
const seen = new Set<string>();
for (const item of items) {
const key = item.key.trim().toLowerCase();
if (!item.enabled || !key) continue;
if (seen.has(key)) return item.key.trim();
seen.add(key);
}
return null;
};
const validateRequest = (): RequestError | null => {
const url = store.activeRequest.url.trim();
if (!url) {
return { code: 'validation', message: 'URL is required.' };
}
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
return { code: 'invalid_url', message: 'URL is invalid. Use a full http:// or https:// address.' };
}
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return { code: 'invalid_url', message: 'Only http:// and https:// URLs are supported.' };
}
const duplicateHeader = findDuplicateKey(store.activeRequest.headers);
if (duplicateHeader) {
return { code: 'duplicate_header', message: `Duplicate enabled header: ${duplicateHeader}` };
}
const duplicateParam = findDuplicateKey(store.activeRequest.params);
if (duplicateParam) {
return { code: 'duplicate_query_param', message: `Duplicate enabled query param: ${duplicateParam}` };
}
if (inferredBodyLanguage.value === 'json' && store.activeRequest.body.trim()) {
try {
JSON.parse(store.activeRequest.body);
} catch {
return { code: 'invalid_json', message: 'Request body is set to JSON mode but contains invalid JSON.' };
}
}
return null;
};
const executeRequest = async () => {
if (isLoading.value || !store.activeRequest.url) return;
const validationError = validateRequest();
if (validationError) {
store.setRequestError(validationError);
return;
}
const requestId = crypto.randomUUID();
currentRequestId.value = requestId;
isLoading.value = true;
store.setRequestError(null);
@@ -28,10 +126,11 @@ const executeRequest = async () => {
store.activeRequest.params.filter(p => p.enabled && p.key).forEach(p => params[p.key] = p.value);
const response: any = await invoke('execute_request', {
requestId,
method: store.activeRequest.method,
url: store.activeRequest.url,
headers,
body: store.activeRequest.body || null,
body: store.activeRequest.body.trim() ? store.activeRequest.body : null,
queryParams: params,
auth: store.activeRequest.auth,
});
@@ -42,7 +141,9 @@ const executeRequest = async () => {
headers: response.headers,
body: response.body,
time: response.time_elapsed,
size: new Blob([response.body]).size
size: response.total_size,
headersSize: response.headers_size,
bodySize: response.body_size,
};
store.addToHistory(store.activeRequest);
@@ -50,11 +151,23 @@ const executeRequest = async () => {
} catch (error) {
console.error(error);
store.activeRequest.response = undefined;
store.setRequestError(String(error));
store.setRequestError(normalizeError(error));
} finally {
currentRequestId.value = null;
isLoading.value = false;
}
};
const cancelRequest = async () => {
if (!currentRequestId.value) return;
try {
await invoke('cancel_request', { requestId: currentRequestId.value });
} catch (error) {
console.error(error);
store.setRequestError(normalizeError(error));
}
};
</script>
<template>
@@ -76,21 +189,30 @@ const executeRequest = async () => {
>
</div>
<button
v-if="!isLoading"
@click="executeRequest"
:disabled="isLoading"
class="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 py-2 rounded-lg font-medium text-sm flex items-center gap-2 transition-all shadow-lg shadow-indigo-900/20"
class="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg font-medium text-sm flex items-center gap-2 transition-all shadow-lg shadow-indigo-900/20"
>
<Loader2 v-if="isLoading" class="w-4 h-4 animate-spin" />
<Play v-else class="w-4 h-4 fill-current" />
<Play class="w-4 h-4 fill-current" />
Send
</button>
<button
v-else
@click="cancelRequest"
class="bg-rose-600 hover:bg-rose-500 text-white px-4 py-2 rounded-lg font-medium text-sm flex items-center gap-2 transition-all shadow-lg shadow-rose-900/20"
>
<Loader2 class="w-4 h-4 animate-spin" />
<Square class="w-3.5 h-3.5 fill-current" />
Cancel
</button>
</div>
<div
v-if="store.requestError"
class="mx-4 mt-4 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200"
>
{{ store.requestError }}
<div class="font-medium uppercase tracking-wide text-[11px] text-rose-300/80">{{ store.requestError.code }}</div>
<div class="mt-1">{{ store.requestError.message }}</div>
</div>
<!-- Configuration Tabs -->
@@ -121,10 +243,26 @@ const executeRequest = async () => {
/>
</KeepAlive>
<div v-if="activeTab === 'body'" class="h-full w-full overflow-hidden">
<div class="flex items-center justify-between border-b border-slate-800 px-4 py-2 text-xs">
<div class="text-slate-500">
Mode: <span class="text-slate-300 uppercase">{{ inferredBodyLanguage }}</span>
</div>
<CustomSelect
v-model="bodyMode"
:options="bodyModeOptions"
triggerClass="bg-slate-950 text-xs font-medium px-3 py-1.5 text-slate-200 border border-slate-800 rounded-md focus:outline-none hover:bg-slate-900 cursor-pointer min-w-[88px]"
/>
</div>
<div
v-if="!methodSupportsBody"
class="mx-4 mt-4 rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-sm text-amber-100"
>
{{ store.activeRequest.method }} requests usually do not send a body. The editor stays available, but some servers may ignore it.
</div>
<CodeEditor
v-model="store.activeRequest.body"
placeholder="Request Body (JSON)"
language="json"
:placeholder="requestBodyPlaceholder"
:language="inferredBodyLanguage"
/>
</div>
<KeepAlive>

View File

@@ -8,23 +8,35 @@ const store = useRequestStore();
const isCopied = ref(false);
const activeTab = ref<'body' | 'headers'>('body');
const isJsonResponse = computed(() => {
const responseFormat = computed<'json' | 'xml' | 'html' | 'text'>(() => {
const response = store.activeRequest.response;
if (!response) return false;
if (!response) return 'text';
const contentType = response.headers['content-type'] || response.headers['Content-Type'] || '';
if (contentType.toLowerCase().includes('json')) {
return true;
const normalizedContentType = contentType.toLowerCase();
if (normalizedContentType.includes('json')) {
return 'json';
}
if (normalizedContentType.includes('xml')) {
return 'xml';
}
if (normalizedContentType.includes('html')) {
return 'html';
}
try {
JSON.parse(response.body);
return true;
return 'json';
} catch {
return false;
return 'text';
}
});
const isJsonResponse = computed(() => responseFormat.value === 'json');
const formattedHeaders = computed(() => {
const response = store.activeRequest.response;
if (!response) return '';
@@ -83,7 +95,14 @@ const copyToClipboard = async () => {
Time: <span class="text-slate-300">{{ store.activeRequest.response.time }}ms</span>
</div>
<div class="text-slate-500">
Size: <span class="text-slate-300">{{ (store.activeRequest.response.size / 1024).toFixed(2) }} KB</span>
Size:
<span class="text-slate-300">{{ (store.activeRequest.response.size / 1024).toFixed(2) }} KB</span>
<span class="ml-1 text-slate-500">
(H {{ (store.activeRequest.response.headersSize / 1024).toFixed(2) }} / B {{ (store.activeRequest.response.bodySize / 1024).toFixed(2) }} KB)
</span>
</div>
<div class="text-slate-500">
Format: <span class="text-slate-300 uppercase">{{ responseFormat }}</span>
</div>
</div>
@@ -120,14 +139,15 @@ const copyToClipboard = async () => {
<div class="flex-1 overflow-hidden">
<CodeEditor
:model-value="displayedContent"
:language="activeTab === 'body' && isJsonResponse ? 'json' : 'text'"
:language="activeTab === 'body' && responseFormat === 'json' ? 'json' : 'text'"
:read-only="true"
/>
</div>
</div>
<div v-else-if="store.requestError" class="flex-1 flex items-center justify-center p-6">
<div class="max-w-2xl rounded-xl border border-rose-500/30 bg-rose-500/10 p-4 text-sm text-rose-200">
{{ store.requestError }}
<div class="font-medium uppercase tracking-wide text-[11px] text-rose-300/80">{{ store.requestError.code }}</div>
<div class="mt-1">{{ store.requestError.message }}</div>
</div>
</div>
<div v-else class="flex-1 flex items-center justify-center text-slate-600 text-sm">

View File

@@ -29,10 +29,17 @@ export interface RequestData {
body: string;
time: number;
size: number;
headersSize: number;
bodySize: number;
};
timestamp: number;
}
export interface RequestError {
code: string;
message: string;
}
const createEmptyKeyValue = (): KeyValue => ({
id: crypto.randomUUID(),
key: '',
@@ -99,6 +106,8 @@ const cloneRequest = (req: Partial<RequestData>): RequestData => ({
body: req.response.body,
time: req.response.time,
size: req.response.size,
headersSize: req.response.headersSize ?? 0,
bodySize: req.response.bodySize ?? req.response.body.length,
}
: undefined,
timestamp: typeof req.timestamp === 'number' ? req.timestamp : Date.now(),
@@ -128,7 +137,7 @@ export const useRequestStore = defineStore('request', () => {
// State
const history = ref<RequestData[]>([]);
const activeRequest = ref<RequestData>(createDefaultRequest());
const requestError = ref<string | null>(null);
const requestError = ref<RequestError | null>(null);
// Load history from local storage
const savedHistory = localStorage.getItem('request_history');
@@ -183,8 +192,8 @@ export const useRequestStore = defineStore('request', () => {
requestError.value = null;
};
const setRequestError = (message: string | null) => {
requestError.value = message;
const setRequestError = (error: RequestError | null) => {
requestError.value = error;
};
// Watch and Save