Skip to content

Commit 5465afd

Browse files
committed
#6: Toasts work, sidebar is now virtualized
After getting a few of autosaves and 400 snapshots in the db, it was apparent that this would have performance issues. I mainly blame the ScrollArea from shadcn, but implemented the virtualization nonetheless.
1 parent 53f7ed2 commit 5465afd

File tree

6 files changed

+96
-54
lines changed

6 files changed

+96
-54
lines changed

src/App.vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import YvToasts from './components/toasts/YvToasts.vue';
55
import YvFooter from './components/YvFooter.vue';
66
import YvRepl from './components/YvRepl.vue';
77
import YvSidebar from './components/YvSidebar/YvSidebar.vue';
8-
import 'vue-sonner/style.css'; // vue-sonner v2 requires this import
98
import '@vue/repl/style.css';
109
1110
function setVH() {
@@ -19,7 +18,7 @@ setVH();
1918
<template>
2019
<!-- <Drawer v-model:open="isDo" :default-open="true" direction="right"> -->
2120
<main class="repl-container" font-sans text-primary vaul-drawer-wrapper>
22-
<YvToasts />
21+
<YvToasts :stack-limit="4" />
2322
<YVHeader />
2423
<YvSidebar>
2524
<YvRepl />

src/components/YvSidebar/YvSidebar.vue

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
11
<script setup lang="ts">
2+
import { useVirtualList } from '@vueuse/core';
23
import { AnimatePresence, LayoutGroup, motion } from 'motion-v';
4+
import { computed, ref } from 'vue';
35
import { useFileManagerStore } from '@/composables/useFileManagerStore';
4-
import ScrollArea from '../ui/scroll-area/ScrollArea.vue';
56
import YvSessionSelector from '../YvVersioningDrawer/YvSessionSelector.vue';
67
import YvSnapshot from '../YvVersioningDrawer/YvSnapshot.vue';
78
89
const fm = useFileManagerStore();
10+
11+
const computedList = computed(() => fm.snapshotsList);
12+
13+
const { list, containerProps, wrapperProps } = useVirtualList(computedList, {
14+
itemHeight: 112, // h-28 added to the snapshot component
15+
});
16+
17+
const isScrolling = ref(false);
18+
let scrollTimeout: number;
19+
20+
function handleScroll() {
21+
isScrolling.value = true;
22+
clearTimeout(scrollTimeout);
23+
scrollTimeout = window.setTimeout(() => {
24+
isScrolling.value = false;
25+
}, 100);
26+
}
927
</script>
1028

1129
<template>
@@ -50,19 +68,26 @@ const fm = useFileManagerStore();
5068
No snapshots saved in this session
5169
</div>
5270

53-
<ScrollArea v-else flex="~ grow-1" h-full>
54-
<ul mb-8>
71+
<div
72+
v-else
73+
v-bind="containerProps"
74+
class="p-2 overflow-auto" flex="~ grow-1"
75+
h-full
76+
@scroll="handleScroll"
77+
>
78+
<ul v-bind="wrapperProps" mb-8>
5579
<LayoutGroup>
5680
<AnimatePresence mode="popLayout">
5781
<YvSnapshot
58-
v-for="(snapshot) in fm.snapshotsList"
82+
v-for="({ data: snapshot }) in list"
5983
:key="snapshot.id"
6084
:snapshot="snapshot"
85+
:disable-animation="isScrolling"
6186
/>
6287
</AnimatePresence>
6388
</LayoutGroup>
6489
</ul>
65-
</ScrollArea>
90+
</div>
6691
</div>
6792
</motion.div>
6893
</motion.div>

src/components/YvVersioningDrawer/YvSnapshot.vue

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import YvButton from './YvButton.vue';
1616
1717
type YVTimelineEntryProps = {
1818
snapshot: Snapshot;
19+
disableAnimation?: boolean;
1920
};
2021
2122
type TypeColorConfig = {
@@ -65,7 +66,8 @@ const didFilesChange = computed(() => {
6566
:initial="{ opacity: 0, scale: 0 }"
6667
:animate="{ opacity: 1, scale: 1 }"
6768
:exit="{ opacity: 0, scale: 0 }"
68-
px-4 layout
69+
:transition="props.disableAnimation ? { duration: 0 } : { duration: 0.25, ease: 'easeOut' }"
70+
px-4 layout h-28
6971
class="group/snapshot hover-bg-accent/60 dark:hover-bg-accent/10"
7072
>
7173
<div flex="~ items-center justify-between" mb-1>
@@ -218,16 +220,16 @@ const didFilesChange = computed(() => {
218220
}
219221
220222
.dark .v-popper--theme-diff-card .v-popper__inner {
221-
border: var(--colors-neutral-600);
223+
border: var(--colors-neutral-600) !important;;
222224
}
223225
224226
.dark .v-popper--theme-diff-card .v-popper__arrow-outer {
225-
border-color: var(--colors-neutral-600);
227+
border-color: var(--colors-neutral-600) !important;;
226228
227229
}
228230
229231
.dark .v-popper--theme-diff-card .v-popper__arrow-inner {
230-
border-color: var(--colors-neutral-600);
232+
border-color: var(--colors-neutral-600) !important;;
231233
232234
}
233235
</style>

src/components/toasts/YvToasts.vue

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
<script lang="ts" setup>
2-
import { ref, watchEffect } from 'vue';
2+
import { computed, ref, watchEffect } from 'vue';
33
import { useToastsStore } from './useToastsStore';
44
5+
const props = defineProps<YvToastsProps>();
6+
57
const { activeToasts, toast } = useToastsStore();
68
9+
type YvToastsProps = {
10+
/**
11+
* Number of toasts visible by default
12+
*/
13+
stackLimit?: number;
14+
};
15+
716
// Store timeout data for each toast
817
const toastTimers = ref(new Map<string, {
918
timeoutId: number;
@@ -13,7 +22,6 @@ const toastTimers = ref(new Map<string, {
1322
stop: () => void;
1423
}>());
1524
16-
// Default durations for different toast types (in milliseconds)
1725
const defaultDurations = {
1826
success: 4000,
1927
error: 6000,
@@ -51,17 +59,14 @@ function handleToastClick(toastId: string) {
5159
}
5260
5361
function createAutoDissmiss(toastId: string, type: string, duration?: number) {
54-
// Check if toast should auto-dismiss
5562
const shouldAutoDismiss = duration !== Infinity && duration !== 0;
5663
if (!shouldAutoDismiss)
5764
return null;
5865
59-
// Get duration
6066
const autoDismissDelay = duration
6167
|| defaultDurations[type as keyof typeof defaultDurations]
6268
|| defaultDurations.info;
6369
64-
// Create timeout
6570
const startTime = Date.now();
6671
const timeoutId = window.setTimeout(() => {
6772
toast.dismiss(toastId);
@@ -83,32 +88,23 @@ function createAutoDissmiss(toastId: string, type: string, duration?: number) {
8388
function pauseAutoDissmiss(toastId: string) {
8489
const timer = toastTimers.value.get(toastId);
8590
if (timer) {
86-
// Clear the current timeout
8791
clearTimeout(timer.timeoutId);
88-
89-
// Calculate remaining time
9092
const elapsed = Date.now() - timer.startTime;
9193
timer.remainingTime = Math.max(0, timer.originalDuration - elapsed);
92-
9394
toastTimers.value.set(toastId, timer);
9495
}
9596
}
9697
9798
function resumeAutoDissmiss(toastId: string) {
9899
const timer = toastTimers.value.get(toastId);
99100
if (timer && timer.remainingTime > 0) {
100-
// Create new timeout with remaining time
101101
timer.startTime = Date.now();
102102
timer.originalDuration = timer.remainingTime;
103-
104103
timer.timeoutId = window.setTimeout(() => {
105104
toast.dismiss(toastId);
106105
toastTimers.value.delete(toastId);
107106
}, timer.remainingTime);
108-
109-
// Update the stop function to reference the new timeoutId
110107
timer.stop = () => clearTimeout(timer.timeoutId);
111-
112108
toastTimers.value.set(toastId, timer);
113109
}
114110
}
@@ -117,7 +113,6 @@ function resumeAutoDissmiss(toastId: string) {
117113
watchEffect(() => {
118114
const activeToastIds = new Set(activeToasts.map(t => t.id));
119115
120-
// Set up timers for new toasts
121116
activeToasts.forEach((activeToast) => {
122117
if (!toastTimers.value.has(activeToast.id)) {
123118
createAutoDissmiss(
@@ -128,7 +123,6 @@ watchEffect(() => {
128123
}
129124
});
130125
131-
// Clean up timers for removed toasts
132126
Array.from(toastTimers.value.keys()).forEach((toastId) => {
133127
if (!activeToastIds.has(toastId)) {
134128
const timer = toastTimers.value.get(toastId);
@@ -139,18 +133,37 @@ watchEffect(() => {
139133
}
140134
});
141135
});
136+
137+
// --- STACKING LOGIC ---
138+
const stackLimit = props.stackLimit || 4; // Number of toasts visible by default
139+
const isStackHovered = ref(false);
140+
141+
const visibleToasts = computed(() => {
142+
if (isStackHovered.value)
143+
return activeToasts;
144+
return activeToasts.slice(0, stackLimit);
145+
});
146+
147+
const hiddenCount = computed(() => {
148+
const count = activeToasts.length - stackLimit;
149+
return count > 0 ? count : 0;
150+
});
142151
</script>
143152

144153
<template>
145154
<div class="fixed top-0 left-0 right-0 z-[13] pointer-events-none">
146-
<div class="flex flex-col items-center gap-2 p-4 pt-[55px]">
155+
<div
156+
class="flex flex-col items-center gap-2 p-4 pt-[55px]"
157+
@mouseenter="isStackHovered = true"
158+
@mouseleave="isStackHovered = false"
159+
>
147160
<TransitionGroup
148161
name="toast"
149162
tag="div"
150163
class="flex flex-col items-center gap-2"
151164
>
152165
<div
153-
v-for="activeToast in activeToasts"
166+
v-for="activeToast in visibleToasts"
154167
:key="activeToast.id"
155168
sc-toast-base bg="opacity-90 background"
156169
flex="~ items-center gap-3"
@@ -162,13 +175,10 @@ watchEffect(() => {
162175
@mouseenter="pauseAutoDissmiss(activeToast.id)"
163176
@mouseleave="resumeAutoDissmiss(activeToast.id)"
164177
>
165-
<!-- Icon -->
166178
<span
167179
class="inline-block text-sm align-middle"
168180
:class="getToastIcon(activeToast.type || 'info')"
169181
/>
170-
171-
<!-- Content -->
172182
<div class="flex-1">
173183
<div v-if="activeToast.title" class="font-medium">
174184
{{ activeToast.title }}
@@ -183,6 +193,13 @@ watchEffect(() => {
183193
</div>
184194
</div>
185195
</TransitionGroup>
196+
197+
<div
198+
v-if="hiddenCount > 0 && !isStackHovered"
199+
class="text-xs cursor-pointer text-neutral dark:text-neutral-400/90"
200+
>
201+
+{{ hiddenCount }} more
202+
</div>
186203
</div>
187204
</div>
188205
</template>

src/components/toasts/useToastsStore.ts

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,8 @@
1-
import type { Ref } from 'vue';
2-
31
import { defineStore } from 'pinia';
4-
import { ref } from 'vue';
2+
import { reactive } from 'vue';
53

64
type ToastType = 'success' | 'error' | 'warning' | 'info';
75

8-
type ToastsStore = {
9-
activeToasts: Ref<Toast[]>;
10-
11-
toast: {
12-
success: (title: string, toast?: Omit<Partial<Toast>, 'id'>) => string;
13-
error: (title: string, toast?: Omit<Partial<Toast>, 'id'>) => string;
14-
warning: (title: string, toast?: Omit<Partial<Toast>, 'id'>) => string;
15-
info: (title: string, toast?: Omit<Partial<Toast>, 'id'>) => string;
16-
17-
dismiss: (id: string) => void;
18-
};
19-
};
20-
216
type Toast = {
227
id: string;
238
type: ToastType;
@@ -33,19 +18,23 @@ const defaultToast: Omit<Toast, 'id'> = {
3318
duration: 4000,
3419
};
3520

36-
export const useToastsStore = defineStore<'toastStore', ToastsStore>('toastStore', (): ToastsStore => {
37-
const activeToasts = ref<Toast[]>([]);
21+
export const useToastsStore = defineStore('toastStore', () => {
22+
const activeToasts = reactive<Toast[]>([]);
3823

3924
function addToast(toast: Omit<Partial<Toast>, 'id'>): string {
4025
const id = crypto.randomUUID().slice(0, 8);
41-
activeToasts.value.push({ id, ...defaultToast, ...toast });
26+
activeToasts.push({ id, ...defaultToast, ...toast });
4227
return id;
4328
};
4429

4530
function dismiss(id: string): void {
46-
const index = activeToasts.value.findIndex(t => t.id === id);
31+
const index = activeToasts.findIndex(t => t.id === id);
4732
if (index > -1)
48-
activeToasts.value.splice(index, 1);
33+
activeToasts.splice(index, 1);
34+
};
35+
36+
function dismissAll(): void {
37+
activeToasts.splice(0);
4938
};
5039

5140
function success(title: string, toast?: Omit<Partial<Toast>, 'id'>): string {
@@ -73,6 +62,8 @@ export const useToastsStore = defineStore<'toastStore', ToastsStore>('toastStore
7362
info,
7463
warning,
7564
dismiss,
65+
dismissAll,
7666
},
67+
7768
};
7869
});

src/composables/useFileManagerStore.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const useFileManagerStore = defineStore('file-manager', () => {
4040
currentSession.value = await sessions.create(`Session ${crypto.randomUUID().slice(0, 8)}`);
4141
}
4242

43+
// Use the latest available session from standalone mode
4344
if (!currentSession.value) {
4445
currentSession.value = await sessions.getLatestSession();
4546
}
@@ -51,7 +52,11 @@ export const useFileManagerStore = defineStore('file-manager', () => {
5152

5253
// most likely the yv-lesson.json file is not available
5354
if (error) {
54-
console.warn('[yehyecoa-vue]: Server reported an issue:', error);
55+
// we switched lessons in amoxtli-vue
56+
// clear last lesson's toasts
57+
// toast.dismissAll();
58+
59+
console.error('[yehyecoa-vue]: Server reported an issue:', error);
5560
errorMessage.value = error;
5661
toast.error('Error', {
5762
description: error,
@@ -131,6 +136,9 @@ export const useFileManagerStore = defineStore('file-manager', () => {
131136

132137
watch(currentSession, (newSession, _, onCleanup) => {
133138
if (newSession) {
139+
// console.log('[yehyecoa-vue]: New active session:', newSession);
140+
// toast.dismissAll();
141+
134142
const subscription = snapshots.liveQuerySnapshots(newSession.id).subscribe((snaps) => {
135143
snapshotsList.value = snaps;
136144
});

0 commit comments

Comments
 (0)