This commit is contained in:
Julian Freeman
2026-04-19 09:14:26 -04:00
parent e256e596c5
commit f9a4c0e64a
6 changed files with 179 additions and 96 deletions

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

@@ -1,55 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { defineAsyncComponent, ref } from 'vue';
import { useRequestStore } from '../stores/requestStore'; import { useRequestStore } 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 { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView } from '@codemirror/view';
import { Play, Loader2 } from 'lucide-vue-next'; 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 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 [json(), oneDark, theme];
});
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']; const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
const executeRequest = async () => { const executeRequest = async () => {
if (isLoading.value || !store.activeRequest.url) return; if (isLoading.value || !store.activeRequest.url) return;
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', {
method: store.activeRequest.method, method: store.activeRequest.method,
url: store.activeRequest.url, url: store.activeRequest.url,
@@ -59,7 +36,7 @@ const executeRequest = async () => {
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,
@@ -68,12 +45,12 @@ const executeRequest = async () => {
size: new Blob([response.body]).size size: new Blob([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);
alert('Request Failed: ' + error); store.activeRequest.response = undefined;
store.setRequestError(String(error));
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
@@ -109,6 +86,13 @@ const executeRequest = async () => {
</button> </button>
</div> </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>
<!-- Configuration Tabs --> <!-- Configuration Tabs -->
<div class="flex border-b border-slate-800"> <div class="flex border-b border-slate-800">
<button <button
@@ -137,18 +121,10 @@ 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 <CodeEditor
v-model="store.activeRequest.body" v-model="store.activeRequest.body"
placeholder="Request Body (JSON)" placeholder="Request Body (JSON)"
:style="{ language="json"
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,16 +1,12 @@
<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 isJsonResponse = computed(() => { const isJsonResponse = computed(() => {
const response = store.activeRequest.response; const response = store.activeRequest.response;
@@ -29,26 +25,14 @@ const isJsonResponse = computed(() => {
} }
}); });
const extensions = computed(() => { const formattedHeaders = computed(() => {
const theme = EditorView.theme({ const response = store.activeRequest.response;
"&": { if (!response) return '';
backgroundColor: "transparent !important",
height: "100%"
},
".cm-content, .cm-gutter": {
fontFamily: `${settings.editorFontFamily} !important`
},
".cm-gutters": {
backgroundColor: "transparent !important"
}
});
return [ return Object.entries(response.headers)
oneDark, .sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
theme, .map(([key, value]) => `${key}: ${value}`)
EditorView.editable.of(false), .join('\n');
...(isJsonResponse.value ? [json()] : []),
];
}); });
const formattedBody = computed(() => { const formattedBody = computed(() => {
@@ -61,6 +45,10 @@ const formattedBody = computed(() => {
} }
}); });
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';
@@ -69,9 +57,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;
@@ -99,29 +87,49 @@ const copyToClipboard = async () => {
</div> </div>
</div> </div>
<button <div class="flex items-center gap-2">
@click="copyToClipboard" <div class="flex rounded-lg border border-slate-800 bg-slate-900 p-0.5">
class="p-1.5 text-slate-400 hover:text-indigo-400 hover:bg-indigo-500/10 rounded transition-colors" <button
title="Copy Body" @click="activeTab = 'body'"
> class="rounded-md px-2.5 py-1 transition-colors"
<Check v-if="isCopied" class="w-3.5 h-3.5" /> :class="activeTab === 'body' ? 'bg-indigo-500/15 text-indigo-300' : 'text-slate-400 hover:text-slate-200'"
<Clipboard v-else class="w-3.5 h-3.5" /> >
</button> 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>
<button
@click="copyToClipboard"
class="p-1.5 text-slate-400 hover:text-indigo-400 hover:bg-indigo-500/10 rounded transition-colors"
:title="activeTab === 'headers' ? 'Copy Headers' : 'Copy Body'"
>
<Check v-if="isCopied" class="w-3.5 h-3.5" />
<Clipboard v-else class="w-3.5 h-3.5" />
</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' && isJsonResponse ? '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">
{{ store.requestError }}
</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

@@ -128,6 +128,7 @@ export const useRequestStore = defineStore('request', () => {
// State // State
const history = ref<RequestData[]>([]); const history = ref<RequestData[]>([]);
const activeRequest = ref<RequestData>(createDefaultRequest()); const activeRequest = ref<RequestData>(createDefaultRequest());
const requestError = ref<string | null>(null);
// Load history from local storage // Load history from local storage
const savedHistory = localStorage.getItem('request_history'); const savedHistory = localStorage.getItem('request_history');
@@ -169,6 +170,7 @@ export const useRequestStore = defineStore('request', () => {
const resetActiveRequest = () => { const resetActiveRequest = () => {
activeRequest.value = createDefaultRequest(); activeRequest.value = createDefaultRequest();
requestError.value = null;
}; };
const loadRequest = (req: RequestData) => { const loadRequest = (req: RequestData) => {
@@ -178,6 +180,11 @@ export const useRequestStore = defineStore('request', () => {
if (loaded.headers.length === 0) loaded.headers.push(createEmptyKeyValue()); if (loaded.headers.length === 0) loaded.headers.push(createEmptyKeyValue());
activeRequest.value = loaded; activeRequest.value = loaded;
requestError.value = null;
};
const setRequestError = (message: string | null) => {
requestError.value = message;
}; };
// Watch and Save // Watch and Save
@@ -188,10 +195,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`
// //