Skip to content

Commit

Permalink
refactor(terminal): rewrite terminal virtualization (#2916)
Browse files Browse the repository at this point in the history
Signed-off-by: Evan Song <[email protected]>
  • Loading branch information
ferothefox authored Nov 5, 2024
1 parent d321843 commit d0efa44
Showing 1 changed file with 115 additions and 56 deletions.
171 changes: 115 additions & 56 deletions apps/frontend/src/components/ui/servers/PanelTerminal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
<ul
class="m-0 list-none p-0"
data-pyro-terminal-virtual-list
:style="{ transform: `translateY(${offsetY}px)` }"
:style="virtualListStyle"
aria-live="polite"
role="listbox"
>
Expand Down Expand Up @@ -120,7 +120,7 @@

<Transition name="scroll-to-bottom">
<button
v-if="bottomThreshold > 0"
v-if="bottomThreshold > 0 && !isScrolledToBottom"
data-pyro-scrolltobottom
label="Scroll to bottom"
class="scroll-to-bottom-btn experimental-styles-within absolute bottom-[4.5rem] right-4 z-[3] grid h-12 w-12 place-content-center rounded-lg border-[1px] border-solid border-button-border bg-bg-raised text-contrast transition-all duration-200 hover:scale-110 active:scale-95"
Expand All @@ -146,11 +146,12 @@ const props = defineProps<{
}>();
const scrollContainer = ref<HTMLElement | null>(null);
const itemRefs = ref<HTMLElement[]>([]);
const itemHeights = ref<number[]>([]);
const averageItemHeight = ref(36);
const bottomThreshold = ref(0);
const bufferSize = 5;
const cachedHeights = ref<Map<string, number>>(new Map());
const isAutoScrolling = ref(false);
const progressiveBlurIterations = ref(8);
Expand All @@ -173,10 +174,12 @@ const totalHeight = computed(
);
watch(totalHeight, () => {
if (!initial.value) {
if (isScrolledToBottom.value) {
scrollToBottom();
}
initial.value = true;
if (!initial.value) {
initial.value = true;
}
});
const lerp = (start: number, end: number, t: number) => start * (1 - t) + end * t;
Expand Down Expand Up @@ -249,38 +252,37 @@ const visibleItems = computed(() =>
const offsetY = computed(() => getItemOffset(visibleStartIndex.value));
const handleListScroll = () => {
if (scrollContainer.value) {
scrollTop.value = scrollContainer.value.scrollTop;
clientHeight.value = scrollContainer.value.clientHeight;
if (!scrollContainer.value) return;
const scrollHeight = scrollContainer.value.scrollHeight;
isScrolledToBottom.value = scrollTop.value + clientHeight.value >= scrollHeight - 32; // threshold
const container = scrollContainer.value;
scrollTop.value = container.scrollTop;
clientHeight.value = container.clientHeight;
if (!isScrolledToBottom.value) {
userHasScrolled.value = true;
}
const scrollHeight = container.scrollHeight;
const threshold = 32;
isScrolledToBottom.value = scrollHeight - scrollTop.value - clientHeight.value <= threshold;
if (!isScrolledToBottom.value && !isAutoScrolling.value) {
userHasScrolled.value = true;
}
const maxBottom = 256;
bottomThreshold.value = Math.min(
1,
((scrollContainer.value?.scrollHeight || 1) - scrollTop.value - clientHeight.value) / maxBottom,
);
bottomThreshold.value = Math.min(1, (scrollHeight - scrollTop.value - clientHeight.value) / 256);
};
const updateItemHeights = () => {
nextTick(() => {
itemRefs.value.forEach((el, index) => {
if (el) {
const actualIndex = visibleStartIndex.value + index;
itemHeights.value[actualIndex] = el.offsetHeight;
}
});
const measuredHeights = itemHeights.value.filter((h) => h > 0);
if (measuredHeights.length > 0) {
averageItemHeight.value =
measuredHeights.reduce((sum, height) => sum + height, 0) / measuredHeights.length;
const updateItemHeights = async () => {
if (!scrollContainer.value) return;
await nextTick();
const items =
scrollContainer.value?.querySelectorAll("[data-pyro-terminal-virtual-list] li") || [];
items.forEach((el, idx) => {
const index = visibleStartIndex.value + idx;
const height = el.getBoundingClientRect().height;
itemHeights.value[index] = height;
const content = props.consoleOutput[index];
if (content) {
cachedHeights.value.set(content, height);
}
});
};
Expand All @@ -292,16 +294,24 @@ const updateClientHeight = () => {
};
const scrollToBottom = () => {
if (scrollContainer.value) {
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight + 99999999;
userHasScrolled.value = false;
isScrolledToBottom.value = true;
}
};
if (!scrollContainer.value) return;
isAutoScrolling.value = true;
const container = scrollContainer.value;
const debouncedScrollToBottom = () => {
requestAnimationFrame(() => {
setTimeout(scrollToBottom, 0);
nextTick(() => {
const maxScroll = container.scrollHeight - container.clientHeight;
container.scrollTop = maxScroll;
setTimeout(() => {
if (container.scrollTop < maxScroll) {
container.scrollTop = maxScroll;
}
isAutoScrolling.value = false;
userHasScrolled.value = false;
isScrolledToBottom.value = true;
handleListScroll();
}, 50);
});
};
Expand Down Expand Up @@ -442,13 +452,30 @@ const handleKeydown = (event: KeyboardEvent) => {
}
};
onMounted(() => {
const initializeTerminal = async () => {
if (!scrollContainer.value) return;
updateClientHeight();
updateItemHeights();
nextTick(() => {
updateItemHeights();
setTimeout(scrollToBottom, 200);
});
const initialHeights = props.consoleOutput.map(
(content) => cachedHeights.value.get(content) || averageItemHeight.value,
);
itemHeights.value = initialHeights;
await nextTick();
await updateItemHeights();
await nextTick();
const container = scrollContainer.value;
container.scrollTop = container.scrollHeight;
handleListScroll();
initial.value = true;
};
onMounted(async () => {
await initializeTerminal();
window.addEventListener("resize", updateClientHeight);
window.addEventListener("keydown", handleKeydown);
});
Expand All @@ -461,21 +488,37 @@ onUnmounted(() => {
watch(
() => props.consoleOutput,
() => {
const newItemsCount = props.consoleOutput.length - itemHeights.value.length;
async (newOutput) => {
const newItemsCount = newOutput.length - itemHeights.value.length;
if (newItemsCount > 0) {
itemHeights.value.push(...Array(newItemsCount).fill(averageItemHeight.value));
}
const shouldScroll = isScrolledToBottom.value || !userHasScrolled.value;
nextTick(() => {
updateItemHeights();
if (!userHasScrolled.value || isScrolledToBottom.value) {
debouncedScrollToBottom();
const newHeights = Array(newItemsCount)
.fill(0)
.map((_, i) => {
const index = itemHeights.value.length + i;
const content = newOutput[index];
return cachedHeights.value.get(content) || averageItemHeight.value;
});
itemHeights.value.push(...newHeights);
if (shouldScroll) {
await nextTick();
scrollToBottom();
await nextTick();
await updateItemHeights();
scrollToBottom();
}
});
}
},
{ deep: true, immediate: true },
{ deep: true },
);
const virtualListStyle = computed(() => ({
transform: `translateY(${offsetY.value}px)`,
}));
watch([visibleStartIndex, visibleEndIndex], updateItemHeights);
Expand All @@ -496,6 +539,15 @@ watch(isFullScreen, () => {
updateItemHeights();
});
});
watch(
itemHeights,
() => {
const totalHeight = itemHeights.value.reduce((sum, height) => sum + height, 0);
averageItemHeight.value = totalHeight / itemHeights.value.length || averageItemHeight.value;
},
{ deep: true },
);
</script>

<style scoped>
Expand Down Expand Up @@ -611,6 +663,13 @@ html.dark-mode .progressive-gradient {
overflow: hidden !important;
}
[data-pyro-terminal-root] {
will-change: transform;
backface-visibility: hidden;
transform: translateZ(0);
-webkit-font-smoothing: subpixel-antialiased;
}
[data-pyro-terminal-root] {
user-select: none;
}
Expand Down

0 comments on commit d0efa44

Please sign in to comment.