Compare commits

...

3 Commits

Author SHA1 Message Date
Julian Freeman
bf9f08788e fix 3 2026-04-19 09:27:58 -04:00
Julian Freeman
f9a4c0e64a fix 2 2026-04-19 09:14:26 -04:00
Julian Freeman
e256e596c5 fix 1 2026-04-19 09:04:12 -04:00
10 changed files with 624 additions and 204 deletions

View File

@@ -1,6 +1,7 @@
# Generated by Cargo # Generated by Cargo
# will have compiled files and executables # will have compiled files and executables
/target/ /target/
/target*/
# Generated by Tauri # Generated by Tauri
# will have schema files for capabilities auto-completion # will have schema files for capabilities auto-completion

View File

@@ -1,6 +1,8 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Instant; use std::time::Instant;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use tauri::State;
use tokio::sync::{Mutex, oneshot};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct HttpResponse { struct HttpResponse {
@@ -8,6 +10,15 @@ struct HttpResponse {
headers: HashMap<String, String>, headers: HashMap<String, String>,
body: String, body: String,
time_elapsed: u128, // milliseconds time_elapsed: u128, // milliseconds
headers_size: usize,
body_size: usize,
total_size: usize,
}
#[derive(Serialize, Deserialize)]
struct AppError {
code: String,
message: String,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
@@ -39,23 +50,87 @@ struct AuthConfig {
api_key: ApiKeyAuth, api_key: ApiKeyAuth,
} }
struct AppState {
client: reqwest::Client,
pending_requests: Mutex<HashMap<String, oneshot::Sender<()>>>,
}
impl AppError {
fn new(code: &str, message: impl Into<String>) -> Self {
Self {
code: code.to_string(),
message: message.into(),
}
}
}
fn map_reqwest_error(error: reqwest::Error) -> AppError {
if error.is_timeout() {
return AppError::new("timeout", "The request timed out after 30 seconds.");
}
if error.is_connect() {
return AppError::new("connect", format!("Failed to connect: {error}"));
}
if error.is_decode() {
return AppError::new("decode", format!("Failed to decode response body: {error}"));
}
if error.is_request() {
return AppError::new("request", format!("Failed to build or send the request: {error}"));
}
AppError::new("network", format!("Request failed: {error}"))
}
async fn perform_request(
request_builder: reqwest::RequestBuilder,
) -> Result<HttpResponse, AppError> {
let start_time = Instant::now();
let response = request_builder.send().await.map_err(map_reqwest_error)?;
let time_elapsed = start_time.elapsed().as_millis();
let status = response.status().as_u16();
let mut response_headers = HashMap::new();
let mut headers_size = 2usize;
for (key, value) in response.headers() {
let val_bytes = value.as_bytes();
let val_str = value.to_str().unwrap_or("").to_string();
headers_size += key.as_str().len() + 2 + val_bytes.len() + 2;
response_headers.insert(key.to_string(), val_str);
}
let body_text = response.text().await.map_err(map_reqwest_error)?;
let body_size = body_text.as_bytes().len();
let total_size = headers_size + body_size;
Ok(HttpResponse {
status,
headers: response_headers,
body: body_text,
time_elapsed,
headers_size,
body_size,
total_size,
})
}
#[tauri::command] #[tauri::command]
async fn execute_request( async fn execute_request(
state: State<'_, AppState>,
request_id: String,
method: String, method: String,
url: String, url: String,
headers: HashMap<String, String>, headers: HashMap<String, String>,
body: Option<String>, body: Option<String>,
query_params: Option<HashMap<String, String>>, query_params: Option<HashMap<String, String>>,
auth: Option<AuthConfig>, auth: Option<AuthConfig>,
) -> Result<HttpResponse, String> { ) -> Result<HttpResponse, AppError> {
let client = reqwest::Client::builder() let req_method = method.parse::<reqwest::Method>()
.timeout(std::time::Duration::from_secs(30)) .map_err(|e| AppError::new("invalid_method", format!("Invalid HTTP method: {e}")))?;
.build() let mut request_builder = state.client.request(req_method, &url);
.map_err(|e| e.to_string())?;
let req_method = method.parse::<reqwest::Method>().map_err(|e| e.to_string())?;
let mut request_builder = client.request(req_method, &url);
// Add Query Params // Add Query Params
if let Some(params) = query_params { if let Some(params) = query_params {
@@ -99,36 +174,56 @@ async fn execute_request(
} }
} }
let start_time = Instant::now(); let (cancel_tx, mut cancel_rx) = oneshot::channel::<()>();
// Execute request {
let response = request_builder.send().await.map_err(|e| e.to_string())?; let mut pending_requests = state.pending_requests.lock().await;
let time_elapsed = start_time.elapsed().as_millis(); pending_requests.insert(request_id.clone(), cancel_tx);
let status = response.status().as_u16();
let mut response_headers = HashMap::new();
for (key, value) in response.headers() {
// Handle header value to string conversion (skipping non-utf8 for simplicity or lossy conv)
let val_str = value.to_str().unwrap_or("").to_string();
// Capitalize or keep standard key format? keeping standard.
response_headers.insert(key.to_string(), val_str);
} }
let body_text = response.text().await.map_err(|e| e.to_string())?; let result = tokio::select! {
_ = &mut cancel_rx => Err(AppError::new("canceled", "The request was canceled.")),
result = perform_request(request_builder) => result,
};
Ok(HttpResponse { let mut pending_requests = state.pending_requests.lock().await;
status, pending_requests.remove(&request_id);
headers: response_headers,
body: body_text, result
time_elapsed, }
})
#[tauri::command]
async fn cancel_request(
state: State<'_, AppState>,
request_id: String,
) -> Result<bool, AppError> {
let sender = {
let mut pending_requests = state.pending_requests.lock().await;
pending_requests.remove(&request_id)
};
match sender {
Some(cancel_tx) => {
let _ = cancel_tx.send(());
Ok(true)
}
None => Ok(false),
}
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("failed to create HTTP client");
tauri::Builder::default() tauri::Builder::default()
.manage(AppState {
client,
pending_requests: Mutex::new(HashMap::new()),
})
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![execute_request]) .invoke_handler(tauri::generate_handler![execute_request, cancel_request])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@@ -1,15 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineAsyncComponent, onUnmounted, ref, watch } from 'vue';
import RequestPanel from './components/RequestPanel.vue'; import RequestPanel from './components/RequestPanel.vue';
import ResponsePanel from './components/ResponsePanel.vue'; import ResponsePanel from './components/ResponsePanel.vue';
import Sidebar from './components/Sidebar.vue'; import Sidebar from './components/Sidebar.vue';
import SettingsModal from './components/SettingsModal.vue';
import { ref, onUnmounted } from 'vue'; const SettingsModal = defineAsyncComponent(() => import('./components/SettingsModal.vue'));
const PANEL_HEIGHT_STORAGE_KEY = 'topPanelHeight';
const DEFAULT_TOP_PANEL_HEIGHT = 50;
const clampPanelHeight = (value: number) => {
if (Number.isNaN(value)) return DEFAULT_TOP_PANEL_HEIGHT;
return Math.min(80, Math.max(20, value));
};
const showSettings = ref(false); const showSettings = ref(false);
const topPanelHeight = ref(50); // Percentage const topPanelHeight = ref(
clampPanelHeight(Number(localStorage.getItem(PANEL_HEIGHT_STORAGE_KEY)) || DEFAULT_TOP_PANEL_HEIGHT)
);
const isDragging = ref(false); const isDragging = ref(false);
const startDrag = () => { const startDrag = (event: MouseEvent) => {
event.preventDefault();
isDragging.value = true; isDragging.value = true;
window.addEventListener('mousemove', onDrag); window.addEventListener('mousemove', onDrag);
window.addEventListener('mouseup', stopDrag); window.addEventListener('mouseup', stopDrag);
@@ -19,13 +30,8 @@ const onDrag = (e: MouseEvent) => {
if (!isDragging.value) return; if (!isDragging.value) return;
const containerHeight = window.innerHeight; const containerHeight = window.innerHeight;
let newPercentage = (e.clientY / containerHeight) * 100; const newPercentage = (e.clientY / containerHeight) * 100;
topPanelHeight.value = clampPanelHeight(newPercentage);
// Clamp between 20% and 80%
if (newPercentage < 20) newPercentage = 20;
if (newPercentage > 80) newPercentage = 80;
topPanelHeight.value = newPercentage;
}; };
const stopDrag = () => { const stopDrag = () => {
@@ -38,6 +44,10 @@ onUnmounted(() => {
window.removeEventListener('mousemove', onDrag); window.removeEventListener('mousemove', onDrag);
window.removeEventListener('mouseup', stopDrag); window.removeEventListener('mouseup', stopDrag);
}); });
watch(topPanelHeight, (value) => {
localStorage.setItem(PANEL_HEIGHT_STORAGE_KEY, clampPanelHeight(value).toString());
});
</script> </script>
<template> <template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Codemirror } from 'vue-codemirror';
import { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView } from '@codemirror/view';
import { useSettingsStore } from '../stores/settingsStore';
const props = withDefaults(defineProps<{
modelValue?: string;
placeholder?: string;
readOnly?: boolean;
language?: 'json' | 'text';
}>(), {
modelValue: '',
placeholder: '',
readOnly: false,
language: 'text',
});
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const settings = useSettingsStore();
const extensions = computed(() => {
const theme = EditorView.theme({
'&': {
backgroundColor: 'transparent !important',
height: '100%',
},
'.cm-content, .cm-gutter': {
fontFamily: `${settings.editorFontFamily} !important`,
},
'.cm-gutters': {
backgroundColor: 'transparent !important',
},
});
return [
oneDark,
theme,
...(props.language === 'json' ? [json()] : []),
...(props.readOnly ? [EditorView.editable.of(false)] : []),
];
});
</script>
<template>
<Codemirror
:model-value="props.modelValue"
:placeholder="props.placeholder"
:style="{
height: '100%',
fontSize: settings.editorFontSize + 'px',
fontFamily: settings.editorFontFamily,
}"
:autofocus="false"
:indent-with-tab="true"
:tab-size="2"
:extensions="extensions"
@update:model-value="emit('update:modelValue', $event)"
/>
</template>

View File

@@ -9,6 +9,14 @@ const props = defineProps<{
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
const updateRow = (index: number, key: keyof KeyValue, value: string | boolean) => {
const newData = props.modelValue.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return { ...item, [key]: value };
});
emit('update:modelValue', newData);
};
const addRow = () => { const addRow = () => {
const newData = [...props.modelValue, { const newData = [...props.modelValue, {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -47,14 +55,16 @@ const removeRow = (index: number) => {
<td class="py-1 pl-2 align-middle"> <td class="py-1 pl-2 align-middle">
<input <input
type="checkbox" type="checkbox"
v-model="item.enabled" :checked="item.enabled"
@change="updateRow(index, 'enabled', ($event.target as HTMLInputElement).checked)"
class="rounded bg-slate-800 border-slate-700 text-indigo-500 focus:ring-indigo-500/50 focus:ring-offset-0" class="rounded bg-slate-800 border-slate-700 text-indigo-500 focus:ring-indigo-500/50 focus:ring-offset-0"
> >
</td> </td>
<td class="py-1 pr-2 align-middle"> <td class="py-1 pr-2 align-middle">
<input <input
type="text" type="text"
v-model="item.key" :value="item.key"
@input="updateRow(index, 'key', ($event.target as HTMLInputElement).value)"
placeholder="Key" placeholder="Key"
class="w-full bg-transparent border-b border-transparent hover:border-slate-700 focus:border-indigo-500 focus:outline-none py-1 px-1 text-sm text-slate-300 transition-colors" class="w-full bg-transparent border-b border-transparent hover:border-slate-700 focus:border-indigo-500 focus:outline-none py-1 px-1 text-sm text-slate-300 transition-colors"
> >
@@ -62,7 +72,8 @@ const removeRow = (index: number) => {
<td class="py-1 pr-2 align-middle"> <td class="py-1 pr-2 align-middle">
<input <input
type="text" type="text"
v-model="item.value" :value="item.value"
@input="updateRow(index, 'value', ($event.target as HTMLInputElement).value)"
placeholder="Value" placeholder="Value"
class="w-full bg-transparent border-b border-transparent hover:border-slate-700 focus:border-indigo-500 focus:outline-none py-1 px-1 text-sm text-slate-300 transition-colors" class="w-full bg-transparent border-b border-transparent hover:border-slate-700 focus:border-indigo-500 focus:outline-none py-1 px-1 text-sm text-slate-300 transition-colors"
> >

View File

@@ -1,84 +1,173 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { computed, defineAsyncComponent, ref } from 'vue';
import { useRequestStore } from '../stores/requestStore'; import { useRequestStore, type RequestError, type KeyValue } from '../stores/requestStore';
import { useSettingsStore } from '../stores/settingsStore';
import KeyValueEditor from './KeyValueEditor.vue'; import KeyValueEditor from './KeyValueEditor.vue';
import AuthPanel from './AuthPanel.vue'; import AuthPanel from './AuthPanel.vue';
import CustomSelect from './CustomSelect.vue'; import CustomSelect from './CustomSelect.vue';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { Codemirror } from 'vue-codemirror'; import { Play, Loader2, Square } from 'lucide-vue-next';
import { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView } from '@codemirror/view';
import { Play, Loader2 } from 'lucide-vue-next';
const CodeEditor = defineAsyncComponent(() => import('./CodeEditor.vue'));
const store = useRequestStore(); const store = useRequestStore();
const settings = useSettingsStore();
const activeTab = ref('params'); const activeTab = ref('params');
const isLoading = ref(false); const isLoading = ref(false);
const currentRequestId = ref<string | null>(null);
const extensions = computed(() => { const bodyMode = ref<'auto' | 'json' | 'text'>('auto');
const theme = EditorView.theme({
"&": {
backgroundColor: "transparent !important",
height: "100%",
},
".cm-content, .cm-gutter": {
fontFamily: `${settings.editorFontFamily} !important`
},
".cm-gutters": {
backgroundColor: "transparent !important"
}
});
return [json(), oneDark, theme];
});
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']; 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 () => { const executeRequest = async () => {
if (!store.activeRequest.url) return; 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; isLoading.value = true;
store.setRequestError(null);
try { try {
// Prepare headers and params map
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
store.activeRequest.headers.filter(h => h.enabled && h.key).forEach(h => headers[h.key] = h.value); store.activeRequest.headers.filter(h => h.enabled && h.key).forEach(h => headers[h.key] = h.value);
const params: Record<string, string> = {}; const params: Record<string, string> = {};
store.activeRequest.params.filter(p => p.enabled && p.key).forEach(p => params[p.key] = p.value); store.activeRequest.params.filter(p => p.enabled && p.key).forEach(p => params[p.key] = p.value);
// Execute
const response: any = await invoke('execute_request', { const response: any = await invoke('execute_request', {
requestId,
method: store.activeRequest.method, method: store.activeRequest.method,
url: store.activeRequest.url, url: store.activeRequest.url,
headers, headers,
body: store.activeRequest.body || null, body: store.activeRequest.body.trim() ? store.activeRequest.body : null,
queryParams: params, queryParams: params,
auth: store.activeRequest.auth, auth: store.activeRequest.auth,
}); });
// Update Store store.setRequestError(null);
store.activeRequest.response = { store.activeRequest.response = {
status: response.status, status: response.status,
headers: response.headers, headers: response.headers,
body: response.body, body: response.body,
time: response.time_elapsed, time: response.time_elapsed,
size: new Blob([response.body]).size size: response.total_size,
headersSize: response.headers_size,
bodySize: response.body_size,
}; };
// Add to history if successful
store.addToHistory(store.activeRequest); store.addToHistory(store.activeRequest);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
// Simple alert for now, or could use a toast store.activeRequest.response = undefined;
alert('Request Failed: ' + error); store.setRequestError(normalizeError(error));
} finally { } finally {
currentRequestId.value = null;
isLoading.value = false; 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> </script>
<template> <template>
@@ -100,14 +189,30 @@ const executeRequest = async () => {
> >
</div> </div>
<button <button
v-if="!isLoading"
@click="executeRequest" @click="executeRequest"
:disabled="isLoading" 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"
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"
> >
<Loader2 v-if="isLoading" class="w-4 h-4 animate-spin" /> <Play class="w-4 h-4 fill-current" />
<Play v-else class="w-4 h-4 fill-current" />
Send Send
</button> </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"
>
<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>
<!-- Configuration Tabs --> <!-- Configuration Tabs -->
@@ -138,18 +243,26 @@ const executeRequest = async () => {
/> />
</KeepAlive> </KeepAlive>
<div v-if="activeTab === 'body'" class="h-full w-full overflow-hidden"> <div v-if="activeTab === 'body'" class="h-full w-full overflow-hidden">
<Codemirror <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" v-model="store.activeRequest.body"
placeholder="Request Body (JSON)" :placeholder="requestBodyPlaceholder"
:style="{ :language="inferredBodyLanguage"
height: '100%',
fontSize: settings.editorFontSize + 'px',
fontFamily: settings.editorFontFamily
}"
:autofocus="true"
:indent-with-tab="true"
:tab-size="2"
:extensions="extensions"
/> />
</div> </div>
<KeepAlive> <KeepAlive>

View File

@@ -1,44 +1,66 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, defineAsyncComponent, ref } from 'vue';
import { useRequestStore } from '../stores/requestStore'; import { useRequestStore } from '../stores/requestStore';
import { useSettingsStore } from '../stores/settingsStore';
import { Codemirror } from 'vue-codemirror';
import { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView } from '@codemirror/view';
import { Clipboard, Check } from 'lucide-vue-next'; import { Clipboard, Check } from 'lucide-vue-next';
const CodeEditor = defineAsyncComponent(() => import('./CodeEditor.vue'));
const store = useRequestStore(); const store = useRequestStore();
const settings = useSettingsStore();
const isCopied = ref(false); const isCopied = ref(false);
const activeTab = ref<'body' | 'headers'>('body');
const extensions = computed(() => { const responseFormat = computed<'json' | 'xml' | 'html' | 'text'>(() => {
const theme = EditorView.theme({ const response = store.activeRequest.response;
"&": { if (!response) return 'text';
backgroundColor: "transparent !important",
height: "100%" const contentType = response.headers['content-type'] || response.headers['Content-Type'] || '';
}, const normalizedContentType = contentType.toLowerCase();
".cm-content, .cm-gutter": {
fontFamily: `${settings.editorFontFamily} !important` if (normalizedContentType.includes('json')) {
}, return 'json';
".cm-gutters": { }
backgroundColor: "transparent !important"
if (normalizedContentType.includes('xml')) {
return 'xml';
}
if (normalizedContentType.includes('html')) {
return 'html';
}
try {
JSON.parse(response.body);
return 'json';
} catch {
return 'text';
} }
}); });
return [json(), oneDark, theme, EditorView.editable.of(false)]; const isJsonResponse = computed(() => responseFormat.value === 'json');
const formattedHeaders = computed(() => {
const response = store.activeRequest.response;
if (!response) return '';
return Object.entries(response.headers)
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
}); });
const formattedBody = computed(() => { const formattedBody = computed(() => {
if (!store.activeRequest.response) return ''; if (!store.activeRequest.response) return '';
if (!isJsonResponse.value) return store.activeRequest.response.body;
try { try {
// Auto pretty print
return JSON.stringify(JSON.parse(store.activeRequest.response.body), null, 2); return JSON.stringify(JSON.parse(store.activeRequest.response.body), null, 2);
} catch (e) { } catch {
return store.activeRequest.response.body; return store.activeRequest.response.body;
} }
}); });
const displayedContent = computed(() => (
activeTab.value === 'headers' ? formattedHeaders.value : formattedBody.value
));
const statusColor = computed(() => { const statusColor = computed(() => {
const s = store.activeRequest.response?.status || 0; const s = store.activeRequest.response?.status || 0;
if (s >= 200 && s < 300) return 'text-emerald-400'; if (s >= 200 && s < 300) return 'text-emerald-400';
@@ -47,9 +69,9 @@ const statusColor = computed(() => {
}); });
const copyToClipboard = async () => { const copyToClipboard = async () => {
if (!formattedBody.value) return; if (!displayedContent.value) return;
try { try {
await navigator.clipboard.writeText(formattedBody.value); await navigator.clipboard.writeText(displayedContent.value);
isCopied.value = true; isCopied.value = true;
setTimeout(() => { setTimeout(() => {
isCopied.value = false; isCopied.value = false;
@@ -73,33 +95,61 @@ const copyToClipboard = async () => {
Time: <span class="text-slate-300">{{ store.activeRequest.response.time }}ms</span> Time: <span class="text-slate-300">{{ store.activeRequest.response.time }}ms</span>
</div> </div>
<div class="text-slate-500"> <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>
<div class="text-slate-500">
Format: <span class="text-slate-300 uppercase">{{ responseFormat }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<div class="flex rounded-lg border border-slate-800 bg-slate-900 p-0.5">
<button
@click="activeTab = 'body'"
class="rounded-md px-2.5 py-1 transition-colors"
:class="activeTab === 'body' ? 'bg-indigo-500/15 text-indigo-300' : 'text-slate-400 hover:text-slate-200'"
>
Body
</button>
<button
@click="activeTab = 'headers'"
class="rounded-md px-2.5 py-1 transition-colors"
:class="activeTab === 'headers' ? 'bg-indigo-500/15 text-indigo-300' : 'text-slate-400 hover:text-slate-200'"
>
Headers
</button>
</div> </div>
<button <button
@click="copyToClipboard" @click="copyToClipboard"
class="p-1.5 text-slate-400 hover:text-indigo-400 hover:bg-indigo-500/10 rounded transition-colors" class="p-1.5 text-slate-400 hover:text-indigo-400 hover:bg-indigo-500/10 rounded transition-colors"
title="Copy Body" :title="activeTab === 'headers' ? 'Copy Headers' : 'Copy Body'"
> >
<Check v-if="isCopied" class="w-3.5 h-3.5" /> <Check v-if="isCopied" class="w-3.5 h-3.5" />
<Clipboard v-else class="w-3.5 h-3.5" /> <Clipboard v-else class="w-3.5 h-3.5" />
</button> </button>
</div> </div>
</div>
<!-- Output --> <!-- Output -->
<div class="flex-1 overflow-hidden"> <div class="flex-1 overflow-hidden">
<Codemirror <CodeEditor
:model-value="formattedBody" :model-value="displayedContent"
:style="{ :language="activeTab === 'body' && responseFormat === 'json' ? 'json' : 'text'"
height: '100%', :read-only="true"
fontSize: settings.editorFontSize + 'px',
fontFamily: settings.editorFontFamily
}"
:extensions="extensions"
/> />
</div> </div>
</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">
<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"> <div v-else class="flex-1 flex items-center justify-center text-slate-600 text-sm">
No response yet No response yet
</div> </div>

View File

@@ -68,7 +68,6 @@ const displayFontOptions = computed(() => {
placeholder="Select or enter font family" placeholder="Select or enter font family"
/> />
<input <input
v-if="!fontOptions.includes(settings.editorFontFamily)"
type="text" type="text"
v-model="settings.editorFontFamily" v-model="settings.editorFontFamily"
placeholder="e.g. 'JetBrains Mono', monospace" placeholder="e.g. 'JetBrains Mono', monospace"

View File

@@ -29,38 +29,124 @@ export interface RequestData {
body: string; body: string;
time: number; time: number;
size: number; size: number;
headersSize: number;
bodySize: number;
}; };
timestamp: number; timestamp: number;
} }
export const useRequestStore = defineStore('request', () => { export interface RequestError {
// State code: string;
const history = ref<RequestData[]>([]); message: string;
const activeRequest = ref<RequestData>({ }
const createEmptyKeyValue = (): KeyValue => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
method: 'GET', key: '',
url: '', value: '',
params: [ enabled: true,
{ id: crypto.randomUUID(), key: '', value: '', enabled: true } });
],
headers: [ const createDefaultAuthState = (): AuthState => ({
{ id: crypto.randomUUID(), key: '', value: '', enabled: true }
],
body: '',
auth: {
type: 'none', type: 'none',
basic: { username: '', password: '' }, basic: { username: '', password: '' },
bearer: { token: '' }, bearer: { token: '' },
apiKey: { key: '', value: '', addTo: 'header' } apiKey: { key: '', value: '', addTo: 'header' },
}, });
const createDefaultRequest = (): RequestData => ({
id: crypto.randomUUID(),
method: 'GET',
url: '',
params: [createEmptyKeyValue()],
headers: [createEmptyKeyValue()],
body: '',
auth: createDefaultAuthState(),
timestamp: Date.now(), timestamp: Date.now(),
}); });
const cloneKeyValueList = (items: KeyValue[] | undefined): KeyValue[] => {
if (!Array.isArray(items) || items.length === 0) return [];
return items.map((item) => ({
id: item.id || crypto.randomUUID(),
key: item.key || '',
value: item.value || '',
enabled: item.enabled ?? true,
}));
};
const cloneAuthState = (auth: Partial<AuthState> | undefined): AuthState => ({
type: auth?.type || 'none',
basic: {
username: auth?.basic?.username || '',
password: auth?.basic?.password || '',
},
bearer: {
token: auth?.bearer?.token || '',
},
apiKey: {
key: auth?.apiKey?.key || '',
value: auth?.apiKey?.value || '',
addTo: auth?.apiKey?.addTo || 'header',
},
});
const cloneRequest = (req: Partial<RequestData>): RequestData => ({
id: typeof req.id === 'string' && req.id ? req.id : crypto.randomUUID(),
method: req.method || 'GET',
url: req.url || '',
params: cloneKeyValueList(req.params),
headers: cloneKeyValueList(req.headers),
body: req.body || '',
auth: cloneAuthState(req.auth),
response: req.response
? {
status: req.response.status,
headers: { ...req.response.headers },
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(),
});
const normalizeHistoryItem = (item: Partial<RequestData>): RequestData => {
const cloned = cloneRequest(item);
return {
id: cloned.id,
method: cloned.method,
url: cloned.url,
params: cloned.params.length > 0
? cloned.params
: [createEmptyKeyValue()],
headers: cloned.headers.length > 0
? cloned.headers
: [createEmptyKeyValue()],
body: cloned.body,
auth: cloned.auth,
response: cloned.response,
timestamp: cloned.timestamp,
};
};
export const useRequestStore = defineStore('request', () => {
// State
const history = ref<RequestData[]>([]);
const activeRequest = ref<RequestData>(createDefaultRequest());
const requestError = ref<RequestError | null>(null);
// Load history from local storage // Load history from local storage
const savedHistory = localStorage.getItem('request_history'); const savedHistory = localStorage.getItem('request_history');
if (savedHistory) { if (savedHistory) {
try { try {
history.value = JSON.parse(savedHistory); const parsed = JSON.parse(savedHistory);
history.value = Array.isArray(parsed)
? parsed.map((item) => normalizeHistoryItem(item))
: [];
} catch (e) { } catch (e) {
console.error('Failed to parse history', e); console.error('Failed to parse history', e);
} }
@@ -68,12 +154,11 @@ export const useRequestStore = defineStore('request', () => {
// Actions // Actions
const addToHistory = (req: RequestData) => { const addToHistory = (req: RequestData) => {
// Add to beginning, limit to 50 const newEntry = cloneRequest(req);
const newEntry = { ...req, timestamp: Date.now() }; newEntry.id = crypto.randomUUID();
// Don't store the empty trailing rows in history to save space? newEntry.timestamp = Date.now();
// Actually keep them for consistency or filter them. Let's filter empty keys. newEntry.params = newEntry.params.filter((p) => p.key.trim() !== '');
newEntry.params = newEntry.params.filter(p => p.key.trim() !== ''); newEntry.headers = newEntry.headers.filter((h) => h.key.trim() !== '');
newEntry.headers = newEntry.headers.filter(h => h.key.trim() !== '');
history.value.unshift(newEntry); history.value.unshift(newEntry);
if (history.value.length > 50) { if (history.value.length > 50) {
@@ -93,48 +178,22 @@ export const useRequestStore = defineStore('request', () => {
}; };
const resetActiveRequest = () => { const resetActiveRequest = () => {
activeRequest.value = { activeRequest.value = createDefaultRequest();
id: crypto.randomUUID(), requestError.value = null;
method: 'GET',
url: '',
params: [
{ id: crypto.randomUUID(), key: '', value: '', enabled: true }
],
headers: [
{ id: crypto.randomUUID(), key: '', value: '', enabled: true }
],
body: '',
auth: {
type: 'none',
basic: { username: '', password: '' },
bearer: { token: '' },
apiKey: { key: '', value: '', addTo: 'header' }
},
timestamp: Date.now(),
};
}; };
const loadRequest = (req: RequestData) => { const loadRequest = (req: RequestData) => {
// Deep copy const loaded = normalizeHistoryItem(cloneRequest(req));
const loaded = JSON.parse(JSON.stringify(req));
loaded.id = crypto.randomUUID(); loaded.id = crypto.randomUUID();
// loaded.response = undefined; // Keep response for history viewing if (loaded.params.length === 0) loaded.params.push(createEmptyKeyValue());
if (loaded.headers.length === 0) loaded.headers.push(createEmptyKeyValue());
// Ensure at least one empty row for editing
if (loaded.params.length === 0) loaded.params.push({ id: crypto.randomUUID(), key: '', value: '', enabled: true });
if (loaded.headers.length === 0) loaded.headers.push({ id: crypto.randomUUID(), key: '', value: '', enabled: true });
// Ensure auth object exists (migration for old history)
if (!loaded.auth) {
loaded.auth = {
type: 'none',
basic: { username: '', password: '' },
bearer: { token: '' },
apiKey: { key: '', value: '', addTo: 'header' }
};
}
activeRequest.value = loaded; activeRequest.value = loaded;
requestError.value = null;
};
const setRequestError = (error: RequestError | null) => {
requestError.value = error;
}; };
// Watch and Save // Watch and Save
@@ -145,10 +204,12 @@ export const useRequestStore = defineStore('request', () => {
return { return {
history, history,
activeRequest, activeRequest,
requestError,
addToHistory, addToHistory,
clearHistory, clearHistory,
deleteHistoryItem, deleteHistoryItem,
loadRequest, loadRequest,
resetActiveRequest resetActiveRequest,
setRequestError,
}; };
}); });

View File

@@ -7,6 +7,21 @@ const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig(async () => ({ export default defineConfig(async () => ({
plugins: [vue()], plugins: [vue()],
build: {
rollupOptions: {
output: {
manualChunks: {
codemirror: [
'vue-codemirror',
'@codemirror/lang-json',
'@codemirror/theme-one-dark',
'@codemirror/view',
],
vue: ['vue', 'pinia'],
},
},
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// //