Files
nimbus/resources/js/components/domain/Client/Response/ResponseBody/ResponseDumpAndDie.vue
Mazen Touati e1b844cee0 feat(history): add history viewer and rewind (#38)
* feat(ui): add `input group` base component

* feat(history): add history viewer and rewind

* test: update selector snapshot

* test: add PW base page

* style: apply TS style fixes

* chore(history): request history wiki

* chore(history): remove unwanted symbol

* chore: fix type

* style: apply TS style fixes
2026-01-17 20:50:00 +01:00

307 lines
9.1 KiB
Vue

<script setup lang="ts">
import { AppButton } from '@/components/base/button';
import AppRoundIndicator from '@/components/base/round-indicator/AppRoundIndicator.vue';
import {
DumpValue,
SingleDumpRenderer,
} from '@/components/domain/Client/Response/ResponseBody/DumpRenderer';
import PanelSubHeader from '@/components/layout/PanelSubHeader/PanelSubHeader.vue';
import { ChevronLeft, ChevronRight, Trash2Icon } from 'lucide-vue-next';
import { computed, reactive, Ref, ref, watch } from 'vue';
interface DumpSnapshot {
id: string;
timestamp: string;
source: string;
dumps: DumpValue[];
}
interface ResponseDieAndDumpProps {
rawContent: string;
}
const dumpSnapshots: Ref<DumpSnapshot[]> = ref([]);
const props = defineProps<ResponseDieAndDumpProps>();
const emits = defineEmits<{
'update:dumps': [dumps: DumpSnapshot[]];
}>();
const DELETION_CONFIRMATION_TIMEOUT = 1200;
interface DeletionState {
deleting: boolean;
timeoutId?: number;
}
const deletionStatesForDumps = reactive(new Map<string, DeletionState>());
const initiateDumpDeletion = (dumpId: string): boolean => {
const state = deletionStatesForDumps.get(dumpId);
if (state?.deleting) {
clearDumpDeletionState(dumpId);
return true;
}
setDumpDeletionState(dumpId);
return false;
};
const isDumpMarkedForDeletion = (dumpId: string): boolean => {
return deletionStatesForDumps.get(dumpId)?.deleting ?? false;
};
const clearDumpDeletionState = (dumpId: string): void => {
const state = deletionStatesForDumps.get(dumpId);
if (state?.timeoutId) {
clearTimeout(state.timeoutId);
}
deletionStatesForDumps.delete(dumpId);
};
const setDumpDeletionState = (dumpId: string): void => {
const timeoutId: number = window.setTimeout(() => {
deletionStatesForDumps.delete(dumpId);
}, DELETION_CONFIRMATION_TIMEOUT);
deletionStatesForDumps.set(dumpId, {
deleting: true,
timeoutId,
});
};
const handleDeleteDump = (): void => {
const currentDump = selectedDumpsLog.value;
if (!currentDump) {
return;
}
const shouldDelete = initiateDumpDeletion(currentDump.id);
if (!shouldDelete) {
return;
}
const currentIndex = selectedDumpIndex.value;
const dumpsArray = [...dumpSnapshots.value];
dumpsArray.splice(currentIndex, 1);
// Update local state first
dumpSnapshots.value = dumpsArray;
// Then emit the update
emits('update:dumps', dumpsArray);
// Adjust the selected index
if (dumpsArray.length > 0) {
selectedDumpIndex.value = Math.min(currentIndex, dumpsArray.length - 1);
} else {
selectedDumpIndex.value = 0;
}
// Clean up the deletion state
clearDumpDeletionState(currentDump.id);
};
const selectedDumpIndex = ref<number>(0);
watch(
() => props.rawContent,
newValue => {
if (!newValue) {
return;
}
try {
const newDump = JSON.parse(String(newValue)) as DumpSnapshot;
// Check if we already have this dump in our session history
const existingIndex = dumpSnapshots.value.findIndex(
dump => dump.id === newDump.id,
);
// If it exists, we just select it (likely a history rewind)
if (existingIndex !== -1) {
selectedDumpIndex.value = existingIndex;
return;
}
dumpSnapshots.value = [newDump, ...dumpSnapshots.value];
selectedDumpIndex.value = 0;
} catch (error) {
console.error('Failed to parse dump snapshot:', error);
}
},
{ immediate: true },
);
const selectedDumpsLog = computed(() => {
if (dumpSnapshots.value.length === 0) {
return null;
}
return dumpSnapshots.value[selectedDumpIndex.value] ?? null;
});
const hasMultipleDumps = computed(() => {
return dumpSnapshots.value.length > 1;
});
const canGoPrevious = computed(() => {
return selectedDumpIndex.value > 0;
});
const canGoNext = computed(() => {
return selectedDumpIndex.value < dumpSnapshots.value.length - 1;
});
const goToPrevious = () => {
if (canGoPrevious.value) {
selectedDumpIndex.value--;
}
};
const goToNext = () => {
if (canGoNext.value) {
selectedDumpIndex.value++;
}
};
</script>
<template>
<PanelSubHeader class="border-b">
<div class="gap-.5 flex flex-col">
<span class="text-xs text-zinc-900 dark:text-zinc-50">
{{ selectedDumpsLog?.source ?? 'Unknown Source' }}
</span>
</div>
<template #toolbox>
<div class="flex items-center gap-2">
<div v-if="hasMultipleDumps" class="flex items-center gap-1">
<AppButton
variant="ghost"
size="xs"
:disabled="!canGoPrevious"
data-testid="previous-dump-button"
@click="goToPrevious"
>
<ChevronLeft class="size-3" />
</AppButton>
<span class="text-xxs text-zinc-500 select-none dark:text-zinc-400">
{{ selectedDumpIndex + 1 }} /
{{ dumpSnapshots.length }}
</span>
<AppButton
variant="ghost"
size="xs"
:disabled="!canGoNext"
data-testid="next-dump-button"
@click="goToNext"
>
<ChevronRight class="size-3" />
</AppButton>
<AppButton
v-if="selectedDumpsLog"
variant="outline"
size="xs"
class="shadow-none"
data-testid="delete-dump-button"
@click="handleDeleteDump"
>
<Trash2Icon
class="size-3"
:class="{
'text-rose-500 dark:text-rose-700':
isDumpMarkedForDeletion(selectedDumpsLog.id),
}"
/>
</AppButton>
</div>
</div>
</template>
</PanelSubHeader>
<div
class="p-panel bg-subtle-background flex h-full min-h-0 flex-1 flex-col gap-1 overflow-y-auto"
>
<div
v-if="selectedDumpsLog?.timestamp"
class="text-xxs my-1 text-zinc-500 dark:text-zinc-400"
>
Dumped At:
{{ selectedDumpsLog?.timestamp ?? '' }}
</div>
<div
v-if="selectedDumpsLog === undefined || selectedDumpsLog === null"
class="border-subtle bg-card rounded-md border text-sm"
>
<div class="px-panel flex items-center gap-1.5 border-b py-1">
<AppRoundIndicator class="text-zinc-500" />
Info
</div>
<div class="p-panel">Please make sure one dump snapshot is selected.</div>
</div>
<div
v-else-if="selectedDumpsLog.dumps.length === 0"
class="border-subtle bg-card rounded-md border text-sm"
>
<div class="px-panel flex items-center gap-1.5 border-b py-1">
<AppRoundIndicator class="text-rose-500" />
Error
</div>
<div class="p-panel">
<p>
Something Went Wrong! Die and Dump is detected but there is no dumps.
</p>
<small>
If you think this is a bug, please open a
<a
class="underline"
href="https://github.com/sunchayn/nimbus/issues/new/choose"
>
Bug
</a>
card.
</small>
</div>
</div>
<div v-else :key="selectedDumpsLog.id" class="flex flex-col gap-2.5">
<div
v-for="(dump, index) in selectedDumpsLog?.dumps ?? []"
:key="index"
class="border-subtle bg-card rounded-md border"
:data-testid="`dump-value-${index}`"
>
<div class="px-panel relative z-10 flex gap-1.5 border-b py-1">
<AppRoundIndicator class="text-subtle-foreground pt-1" />
<span class="inline-flex flex-col" data-testid="dump-value-title">
<span class="text-xs">Dump #{{ index + 1 }}</span>
</span>
</div>
<div
class="p-panel w-full overflow-x-auto whitespace-nowrap"
data-testid="dump-value-content"
>
<SingleDumpRenderer
v-if="selectedDumpsLog"
class="w-full max-w-full"
:dump="dump"
/>
</div>
</div>
</div>
</div>
</template>