diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts
index 842a38da88..4f580e50e0 100644
--- a/integration/__tests__/cluster-pages.tests.ts
+++ b/integration/__tests__/cluster-pages.tests.ts
@@ -105,7 +105,7 @@ describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => {
// Check if controls are available
await frame.waitForSelector(".Dock.isOpen");
- await frame.waitForSelector(".LogList .VirtualList");
+ await frame.waitForSelector("[data-testid=pod-log-list]");
await frame.waitForSelector(".LogResourceSelector");
const logSearchInput = await frame.waitForSelector(
@@ -113,19 +113,25 @@ describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => {
);
await logSearchInput.type(":");
- await frame.waitForSelector(".LogList .list span.active");
+ await frame.waitForSelector("[data-testid=search-overlay-active]");
- const showTimestampsButton = await frame.waitForSelector(
+ const showTimestampsCheckbox = await frame.waitForSelector(
"[data-testid='log-controls'] .show-timestamps",
);
- await showTimestampsButton.click();
+ await showTimestampsCheckbox.click();
- const showPreviousButton = await frame.waitForSelector(
+ const showPreviousCheckbox = await frame.waitForSelector(
"[data-testid='log-controls'] .show-previous",
);
- await showPreviousButton.click();
+ await showPreviousCheckbox.click();
+
+ const showWrapLogsCheckbox = await frame.waitForSelector(
+ "[data-testid='log-controls'] .wrap-logs",
+ );
+
+ await showWrapLogsCheckbox.click();
},
10 * 60 * 1000,
);
diff --git a/package.json b/package.json
index 23bcebe136..500eb7a5d5 100644
--- a/package.json
+++ b/package.json
@@ -210,6 +210,7 @@
"@sentry/electron": "^3.0.8",
"@sentry/integrations": "^6.19.3",
"@side/jest-runtime": "^1.0.1",
+ "@tanstack/react-virtual": "3.0.0-beta.23",
"@types/circular-dependency-plugin": "5.0.5",
"abort-controller": "^3.0.0",
"auto-bind": "^4.0.0",
diff --git a/src/features/pod-logs/__snapshots__/download-logs.test.tsx.snap b/src/features/pod-logs/__snapshots__/download-logs.test.tsx.snap
index b9bec84de6..42f2495a58 100644
--- a/src/features/pod-logs/__snapshots__/download-logs.test.tsx.snap
+++ b/src/features/pod-logs/__snapshots__/download-logs.test.tsx.snap
@@ -748,31 +748,37 @@ exports[`download logs options in logs dock tab opening pod logs when logs avail
-
-
+
+
+
-
-
- some-logs
-
-
-
+
+ some-logs
+
+
+
+
- There are no logs available for container
-
- docker-exporter
+
+
+
{
+ return (tabId: TabId) => {
+ const getLogTabData = di.inject(getLogTabDataInjectable);
+ const getTimestampSplitLogs = di.inject(getTimestampSplitLogsInjectable);
+ const userStore = di.inject(userStoreInjectable);
+ const logTabData = getLogTabData(tabId);
+
+ if (!logTabData) {
+ return [];
+ }
+
+ const { showTimestamps } = logTabData;
+
+ if (!showTimestamps) {
+ const getLogsWithoutTimestamps = di.inject(getLogsWithoutTimestampsInjectable);
+
+ return getLogsWithoutTimestamps(tabId);
+ }
+
+ return getTimestampSplitLogs(tabId).map(([logTimestamp, log]) => (
+ `${logTimestamp && moment.tz(logTimestamp, userStore.localeTimezone).format()}${log}`
+ ));
+ };
+ },
+});
+
+export default getVisibleLogsInjectable;
diff --git a/src/renderer/components/dock/logs/list.scss b/src/renderer/components/dock/logs/list.scss
deleted file mode 100644
index 58b49f4ab7..0000000000
--- a/src/renderer/components/dock/logs/list.scss
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * Copyright (c) OpenLens Authors. All rights reserved.
- * Licensed under MIT License. See LICENSE in root directory for more information.
- */
-
-.LogList {
- --overlay-bg: #8cc474b8;
- --overlay-active-bg: orange;
-
- // fix for `this.logsElement.scrollTop = this.logsElement.scrollHeight`
- // `overflow: overlay` don't allow scroll to the last line
- overflow: auto;
-
- position: relative;
- color: var(--logsForeground);
- background: var(--logsBackground);
- flex-grow: 1;
-
- .VirtualList {
- height: 100%;
-
- .list {
- overflow-x: scroll!important;
-
- .LogRow {
- padding: 2px 16px;
- height: 18px; // Must be equal to lineHeight variable in pod-log-list.tsx
- font-family: var(--font-monospace);
- font-size: smaller;
- white-space: nowrap;
-
- &:hover {
- background: var(--logRowHoverBackground);
- }
-
- span {
- -webkit-font-smoothing: auto; // Better readability on non-retina screens
- white-space: pre;
- }
-
- span.overlay {
- border-radius: 2px;
- -webkit-font-smoothing: auto;
- background-color: var(--overlay-bg);
-
- span {
- background-color: var(--overlay-bg)!important; // Rewriting inline styles from AnsiUp library
- }
-
- &.active {
- background-color: var(--overlay-active-bg);
-
- span {
- background-color: var(--overlay-active-bg)!important; // Rewriting inline styles from AnsiUp library
- }
- }
- }
- }
- }
- }
-
- &.isLoading {
- cursor: wait;
- }
-
- &.isScrollHidden {
- .VirtualList .list {
- overflow-x: hidden!important; // fixing scroll to bottom issues in PodLogs
- }
- }
-}
diff --git a/src/renderer/components/dock/logs/list.tsx b/src/renderer/components/dock/logs/list.tsx
deleted file mode 100644
index 2976a258fa..0000000000
--- a/src/renderer/components/dock/logs/list.tsx
+++ /dev/null
@@ -1,278 +0,0 @@
-/**
- * Copyright (c) OpenLens Authors. All rights reserved.
- * Licensed under MIT License. See LICENSE in root directory for more information.
- */
-
-import "./list.scss";
-
-import type { ForwardedRef } from "react";
-import React from "react";
-import AnsiUp from "ansi_up";
-import DOMPurify from "dompurify";
-import debounce from "lodash/debounce";
-import { action, computed, observable, makeObservable, reaction } from "mobx";
-import { disposeOnUnmount, observer } from "mobx-react";
-import moment from "moment-timezone";
-import type { Align, ListOnScrollProps } from "react-window";
-import { SearchStore } from "../../../search-store/search-store";
-import { UserStore } from "../../../../common/user-store";
-import { array, autoBind, cssNames } from "../../../utils";
-import type { VirtualListRef } from "../../virtual-list";
-import { VirtualList } from "../../virtual-list";
-import { ToBottom } from "./to-bottom";
-import type { LogTabViewModel } from "../logs/logs-view-model";
-import { Spinner } from "../../spinner";
-
-export interface LogListProps {
- model: LogTabViewModel;
-}
-
-const colorConverter = new AnsiUp();
-
-export interface LogListRef {
- scrollToItem: (index: number, align: Align) => void;
-}
-
-@observer
-class NonForwardedLogList extends React.Component }> {
- @observable isJumpButtonVisible = false;
- @observable isLastLineVisible = true;
-
- private virtualListDiv = React.createRef(); // A reference for outer container in VirtualList
- private virtualListRef = React.createRef(); // A reference for VirtualList component
- private lineHeight = 18; // Height of a log line. Should correlate with styles in pod-log-list.scss
-
- constructor(props: any) {
- super(props);
- makeObservable(this);
- autoBind(this);
- }
-
- componentDidMount() {
- disposeOnUnmount(this, [
- reaction(() => this.props.model.logs.get(), (logs, prevLogs) => {
- this.onLogsInitialLoad(logs, prevLogs);
- this.onLogsUpdate();
- this.onUserScrolledUp(logs, prevLogs);
- }),
- ]);
- this.bindInnerRef({
- scrollToItem: this.scrollToItem,
- });
- }
-
- componentDidUpdate() {
- this.bindInnerRef({
- scrollToItem: this.scrollToItem,
- });
- }
-
- componentWillUnmount() {
- this.bindInnerRef(null);
- }
-
- private bindInnerRef(value: LogListRef | null) {
- if (typeof this.props.innerRef === "function") {
- this.props.innerRef(value);
- } else if (this.props.innerRef && typeof this.props.innerRef === "object") {
- this.props.innerRef.current = value;
- }
- }
-
- onLogsInitialLoad(logs: string[], prevLogs: string[]) {
- if (!prevLogs.length && logs.length) {
- this.isLastLineVisible = true;
- }
- }
-
- onLogsUpdate() {
- if (this.isLastLineVisible) {
- setTimeout(() => {
- this.scrollToBottom();
- }, 500); // Giving some time to VirtualList to prepare its outerRef (this.virtualListDiv) element
- }
- }
-
- onUserScrolledUp(logs: string[], prevLogs: string[]) {
- if (!this.virtualListDiv.current) return;
-
- const newLogsAdded = prevLogs.length < logs.length;
- const scrolledToBeginning = this.virtualListDiv.current.scrollTop === 0;
-
- if (newLogsAdded && scrolledToBeginning) {
- const firstLineContents = prevLogs[0];
- const lineToScroll = logs.findIndex((value) => value == firstLineContents);
-
- if (lineToScroll !== -1) {
- this.scrollToItem(lineToScroll, "start");
- }
- }
- }
-
- /**
- * Returns logs with or without timestamps regarding to showTimestamps prop
- */
- @computed
- get logs(): string[] {
- const { showTimestamps } = this.props.model.logTabData.get() ?? {};
-
- if (!showTimestamps) {
- return this.props.model.logsWithoutTimestamps.get();
- }
-
- return this.props.model.timestampSplitLogs
- .get()
- .map(([logTimestamp, log]) => (`${logTimestamp && moment.tz(logTimestamp, UserStore.getInstance().localeTimezone).format()}${log}`));
- }
-
- /**
- * Checks if JumpToBottom button should be visible and sets its observable
- * @param props Scrolling props from virtual list core
- */
- setButtonVisibility = action(({ scrollOffset }: ListOnScrollProps, { scrollHeight }: HTMLDivElement) => {
- const offset = 100 * this.lineHeight;
-
- if (scrollHeight - scrollOffset < offset) {
- this.isJumpButtonVisible = false;
- } else {
- this.isJumpButtonVisible = true;
- }
- });
-
- /**
- * Checks if last log line considered visible to user, setting its observable
- * @param props Scrolling props from virtual list core
- */
- setLastLineVisibility = action(({ scrollOffset }: ListOnScrollProps, { scrollHeight, clientHeight }: HTMLDivElement) => {
- this.isLastLineVisible = (clientHeight + scrollOffset) === scrollHeight;
- });
-
- /**
- * Check if user scrolled to top and new logs should be loaded
- * @param props Scrolling props from virtual list core
- */
- checkLoadIntent = (props: ListOnScrollProps) => {
- const { scrollOffset } = props;
-
- if (scrollOffset === 0) {
- this.props.model.loadLogs();
- }
- };
-
- scrollToBottom = () => {
- if (!this.virtualListDiv.current) return;
- this.virtualListDiv.current.scrollTop = this.virtualListDiv.current.scrollHeight;
- };
-
- scrollToItem = (index: number, align: Align) => {
- this.virtualListRef.current?.scrollToItem(index, align);
- };
-
- onScroll = (props: ListOnScrollProps) => {
- this.isLastLineVisible = false;
- this.onScrollDebounced(props);
- };
-
- onScrollDebounced = debounce((props: ListOnScrollProps) => {
- const virtualList = this.virtualListDiv.current;
-
- if (virtualList) {
- this.setButtonVisibility(props, virtualList);
- this.setLastLineVisibility(props, virtualList);
- this.checkLoadIntent(props);
- }
- }, 700); // Increasing performance and giving some time for virtual list to settle down
-
- /**
- * A function is called by VirtualList for rendering each of the row
- * @param rowIndex index of the log element in logs array
- * @returns A react element with a row itself
- */
- getLogRow = (rowIndex: number) => {
- const { searchQuery, isActiveOverlay } = this.props.model.searchStore;
- const item = this.logs[rowIndex];
- const contents: React.ReactElement[] = [];
- const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi));
-
- if (searchQuery) { // If search is enabled, replace keyword with backgrounded
- // Case-insensitive search (lowercasing query and keywords in line)
- const regex = new RegExp(SearchStore.escapeRegex(searchQuery), "gi");
- const matches = item.matchAll(regex);
- const modified = item.replace(regex, match => match.toLowerCase());
- // Splitting text line by keyword
- const pieces = modified.split(searchQuery.toLowerCase());
-
- pieces.forEach((piece, index) => {
- const active = isActiveOverlay(rowIndex, index);
- const lastItem = index === pieces.length - 1;
- const overlayValue = matches.next().value;
- const overlay = !lastItem
- ? (
-
- )
- : null;
-
- contents.push(
-
-
- {overlay}
- ,
- );
- });
- }
-
- return (
-
- {contents.length > 1 ? contents : (
-
- )}
- {/* For preserving copy-paste experience and keeping line breaks */}
-
-
- );
- };
-
- render() {
- if (this.props.model.isLoading.get()) {
- return (
-
-
-
- );
- }
-
- if (!this.logs.length) {
- return (
-
- There are no logs available for container
- {" "}
- {this.props.model.logTabData.get()?.selectedContainer}
-
- );
- }
-
- return (
-
-
- {this.isJumpButtonVisible && (
-
- )}
-
- );
- }
-}
-
-export const LogList = React.forwardRef((props, ref) => (
-
-));
diff --git a/src/renderer/components/dock/logs/log-list.module.scss b/src/renderer/components/dock/logs/log-list.module.scss
new file mode 100644
index 0000000000..27e2389884
--- /dev/null
+++ b/src/renderer/components/dock/logs/log-list.module.scss
@@ -0,0 +1,37 @@
+.LogList {
+ flex-grow: 1;
+ overflow: auto;
+ color: var(--logsForeground);
+ background: var(--logsBackground);
+}
+
+.virtualizer {
+ width: 100%;
+ position: relative;
+}
+
+.rowWrapper {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ font-family: var(--font-monospace);
+ font-size: smaller;
+ line-height: 120%;
+ white-space: nowrap;
+
+ > * {
+ -webkit-font-smoothing: auto; // Better readability on non-retina screens
+ }
+
+ &.wrap {
+ white-space: normal;
+ }
+}
+
+.anchorLine {
+ width: 100%;
+ height: 1px;
+ background-color: var(--logsBackground);
+ position: absolute;
+}
diff --git a/src/renderer/components/dock/logs/log-list.tsx b/src/renderer/components/dock/logs/log-list.tsx
new file mode 100644
index 0000000000..3c16c12fce
--- /dev/null
+++ b/src/renderer/components/dock/logs/log-list.tsx
@@ -0,0 +1,106 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+
+import styles from "./log-list.module.scss";
+
+import { useVirtualizer } from "@tanstack/react-virtual";
+import { observer } from "mobx-react";
+import React, { useRef } from "react";
+import { cssNames } from "../../../utils";
+import { LogRow } from "./log-row";
+import type { LogTabViewModel } from "./logs-view-model";
+import { ToBottom } from "./to-bottom";
+import { useInitialScrollToBottom } from "./use-initial-scroll-to-bottom";
+import { useOnScrollTop } from "./use-on-scroll-top";
+import { useRefreshListOnDataChange } from "./use-refresh-list-on-data-change";
+import { useScrollOnSearch } from "./use-scroll-on-search";
+import { useJumpToBottomButton } from "./use-scroll-to-bottom";
+import { useStickToBottomOnLogsLoad } from "./use-stick-to-bottom-on-logs-load";
+
+export interface LogListProps {
+ model: LogTabViewModel;
+}
+
+export const LogList = observer(({ model }: LogListProps) => {
+ const { visibleLogs } = model;
+ const parentRef = useRef(null);
+ const topLineRef = useRef(null);
+ const bottomLineRef = useRef(null);
+ const [toBottomVisible, setButtonVisibility] = useJumpToBottomButton(parentRef.current);
+ const uniqRowKey = useRefreshListOnDataChange(model.logTabData.get());
+
+ const rowVirtualizer = useVirtualizer({
+ count: visibleLogs.get().length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => 38,
+ overscan: 5,
+ enableSmoothScroll: false,
+ });
+
+ const scrollTo = (index: number) => {
+ rowVirtualizer.scrollToIndex(index, { align: "start", smoothScroll: false });
+ };
+
+ const scrollToBottom = () => {
+ scrollTo(visibleLogs.get().length - 1);
+ };
+
+ const onScroll = () => {
+ if (!parentRef.current) return;
+
+ setButtonVisibility();
+ };
+
+ useInitialScrollToBottom(model, scrollToBottom);
+ useScrollOnSearch(model.searchStore, scrollTo);
+ useStickToBottomOnLogsLoad({ bottomLineRef, model, scrollToBottom });
+ useOnScrollTop({ topLineRef, model, scrollTo });
+
+ return (
+
+
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => (
+
+ ))}
+
+
+ {toBottomVisible && (
+
+ )}
+
+ );
+});
+
diff --git a/src/renderer/components/dock/logs/log-row.module.scss b/src/renderer/components/dock/logs/log-row.module.scss
new file mode 100644
index 0000000000..be01df5f6d
--- /dev/null
+++ b/src/renderer/components/dock/logs/log-row.module.scss
@@ -0,0 +1,29 @@
+.LogRow {
+ padding: 2px calc(var(--padding) * 2);
+ line-height: 150%;
+
+ &:hover {
+ background: var(--logRowHoverBackground);
+ }
+}
+
+.overlay {
+ --overlay-bg: #8cc474b8;
+ --overlay-active-bg: orange;
+
+ border-radius: 2px;
+ -webkit-font-smoothing: auto;
+ background-color: var(--overlay-bg);
+
+ span {
+ background-color: var(--overlay-bg)!important; // Rewriting inline styles from AnsiUp library
+ }
+
+ &.active {
+ background-color: var(--overlay-active-bg);
+
+ span {
+ background-color: var(--overlay-active-bg)!important; // Rewriting inline styles from AnsiUp library
+ }
+ }
+}
diff --git a/src/renderer/components/dock/logs/log-row.tsx b/src/renderer/components/dock/logs/log-row.tsx
new file mode 100644
index 0000000000..c9ce632808
--- /dev/null
+++ b/src/renderer/components/dock/logs/log-row.tsx
@@ -0,0 +1,62 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+import styles from "./log-row.module.scss";
+
+import AnsiUp from "ansi_up";
+import DOMPurify from "dompurify";
+import React from "react";
+import { SearchStore } from "../../../search-store/search-store";
+import { cssNames } from "../../../utils";
+import type { LogTabViewModel } from "./logs-view-model";
+
+const colorConverter = new AnsiUp();
+
+export function LogRow({ rowIndex, model }: { rowIndex: number; model: LogTabViewModel }) {
+ const { searchQuery, isActiveOverlay } = model.searchStore;
+ const log = model.visibleLogs.get()[rowIndex];
+ const contents: React.ReactElement[] = [];
+ const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi));
+
+ if (searchQuery) { // If search is enabled, replace keyword with backgrounded
+ // Case-insensitive search (lowercasing query and keywords in line)
+ const regex = new RegExp(SearchStore.escapeRegex(searchQuery), "gi");
+ const matches = log.matchAll(regex);
+ const modified = log.replace(regex, match => match.toLowerCase());
+ // Splitting text line by keyword
+ const pieces = modified.split(searchQuery.toLowerCase());
+
+ pieces.forEach((piece, index) => {
+ const active = isActiveOverlay(rowIndex, index);
+ const lastItem = index === pieces.length - 1;
+ const overlayValue = matches.next().value;
+ const overlay = !lastItem
+ ? (
+
+ )
+ : null;
+
+ contents.push(
+
+
+ {overlay}
+ ,
+ );
+ });
+ }
+
+ return (
+
+ {contents.length > 1 ? contents : (
+
+ )}
+ {/* For preserving copy-paste experience and keeping line breaks */}
+
+
+ );
+}
diff --git a/src/renderer/components/dock/logs/logs-view-model.injectable.ts b/src/renderer/components/dock/logs/logs-view-model.injectable.ts
index 77e4ca983c..a783db1032 100644
--- a/src/renderer/components/dock/logs/logs-view-model.injectable.ts
+++ b/src/renderer/components/dock/logs/logs-view-model.injectable.ts
@@ -20,6 +20,7 @@ import getPodsByOwnerIdInjectable from "../../+workloads-pods/get-pods-by-owner-
import getPodByIdInjectable from "../../+workloads-pods/get-pod-by-id.injectable";
import downloadLogsInjectable from "./download-logs.injectable";
import downloadAllLogsInjectable from "./download-all-logs.injectable";
+import getVisibleLogsInjectable from "./get-visible-logs.injectable";
export interface InstantiateArgs {
tabId: TabId;
@@ -32,6 +33,7 @@ const logsViewModelInjectable = getInjectable({
getLogs: di.inject(getLogsInjectable),
getLogsWithoutTimestamps: di.inject(getLogsWithoutTimestampsInjectable),
getTimestampSplitLogs: di.inject(getTimestampSplitLogsInjectable),
+ getVisibleLogs: di.inject(getVisibleLogsInjectable),
reloadLogs: di.inject(reloadLogsInjectable),
getLogTabData: di.inject(getLogTabDataInjectable),
setLogTabData: di.inject(setLogTabDataInjectable),
diff --git a/src/renderer/components/dock/logs/logs-view-model.ts b/src/renderer/components/dock/logs/logs-view-model.ts
index 85b8502df3..b32fd340e3 100644
--- a/src/renderer/components/dock/logs/logs-view-model.ts
+++ b/src/renderer/components/dock/logs/logs-view-model.ts
@@ -19,6 +19,7 @@ export interface LogTabViewModelDependencies {
getLogs: (tabId: TabId) => string[];
getLogsWithoutTimestamps: (tabId: TabId) => string[];
getTimestampSplitLogs: (tabId: TabId) => [string, string][];
+ getVisibleLogs: (tabId: TabId) => string[];
getLogTabData: (tabId: TabId) => LogTabData | undefined;
setLogTabData: (tabId: TabId, data: LogTabData) => void;
loadLogs: LoadLogs;
@@ -45,6 +46,7 @@ export class LogTabViewModel {
readonly logsWithoutTimestamps = computed(() => this.dependencies.getLogsWithoutTimestamps(this.tabId));
readonly timestampSplitLogs = computed(() => this.dependencies.getTimestampSplitLogs(this.tabId));
readonly logTabData = computed(() => this.dependencies.getLogTabData(this.tabId));
+ readonly visibleLogs = computed(() => this.dependencies.getVisibleLogs(this.tabId));
readonly pods = computed(() => {
const data = this.logTabData.get();
diff --git a/src/renderer/components/dock/logs/search.tsx b/src/renderer/components/dock/logs/search.tsx
index 892874c53a..a2200e1a4b 100644
--- a/src/renderer/components/dock/logs/search.tsx
+++ b/src/renderer/components/dock/logs/search.tsx
@@ -13,18 +13,13 @@ import type { LogTabViewModel } from "./logs-view-model";
export interface PodLogSearchProps {
onSearch?: (query: string) => void;
- scrollToOverlay: (lineNumber: number | undefined) => void;
model: LogTabViewModel;
}
-export const LogSearch = observer(({ onSearch, scrollToOverlay, model: { logTabData, searchStore, ...model }}: PodLogSearchProps) => {
+export const LogSearch = observer(({ onSearch, model: { logTabData, searchStore, ...model }}: PodLogSearchProps) => {
const tabData = logTabData.get();
- if (!tabData) {
- return null;
- }
-
- const logs = tabData.showTimestamps
+ const logs = tabData?.showTimestamps
? model.logs.get()
: model.logsWithoutTimestamps.get();
const { setNextOverlayActive, setPrevOverlayActive, searchQuery, occurrences, activeFind, totalFinds } = searchStore;
@@ -33,17 +28,14 @@ export const LogSearch = observer(({ onSearch, scrollToOverlay, model: { logTabD
const setSearch = (query: string) => {
searchStore.onSearch(logs, query);
onSearch?.(query);
- scrollToOverlay(searchStore.activeOverlayLine);
};
const onPrevOverlay = () => {
setPrevOverlayActive();
- scrollToOverlay(searchStore.activeOverlayLine);
};
const onNextOverlay = () => {
setNextOverlayActive();
- scrollToOverlay(searchStore.activeOverlayLine);
};
const onClear = () => {
diff --git a/src/renderer/components/dock/logs/tab-store.ts b/src/renderer/components/dock/logs/tab-store.ts
index 9cc83b9610..c50322880e 100644
--- a/src/renderer/components/dock/logs/tab-store.ts
+++ b/src/renderer/components/dock/logs/tab-store.ts
@@ -54,6 +54,11 @@ export interface LogTabData {
* Whether to show the logs of the previous container instance
*/
showPrevious: boolean;
+
+ /**
+ * Whether to wrap logs lines to avoid horizontal scrolling.
+ */
+ wrap: boolean;
}
interface Dependencies {
diff --git a/src/renderer/components/dock/logs/to-bottom.module.scss b/src/renderer/components/dock/logs/to-bottom.module.scss
new file mode 100644
index 0000000000..e4aaae65cd
--- /dev/null
+++ b/src/renderer/components/dock/logs/to-bottom.module.scss
@@ -0,0 +1,8 @@
+.ToBottom {
+ position: absolute;
+ top: 64px;
+ right: 16px;
+ border-radius: 4px;
+ padding: 4px 6px;
+ background-color: var(--colorInfo);
+}
\ No newline at end of file
diff --git a/src/renderer/components/dock/logs/to-bottom.tsx b/src/renderer/components/dock/logs/to-bottom.tsx
index f9b5cbb03e..d7ca86b1bd 100644
--- a/src/renderer/components/dock/logs/to-bottom.tsx
+++ b/src/renderer/components/dock/logs/to-bottom.tsx
@@ -2,20 +2,20 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
+import styles from "./to-bottom.module.scss";
+
import React from "react";
import { Icon } from "../../icon";
export function ToBottom({ onClick }: { onClick: () => void }) {
return (
);
diff --git a/src/renderer/components/dock/logs/use-initial-scroll-to-bottom.ts b/src/renderer/components/dock/logs/use-initial-scroll-to-bottom.ts
new file mode 100644
index 0000000000..c412d50425
--- /dev/null
+++ b/src/renderer/components/dock/logs/use-initial-scroll-to-bottom.ts
@@ -0,0 +1,15 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+import { useEffect } from "react";
+import type { LogTabViewModel } from "./logs-view-model";
+
+export function useInitialScrollToBottom(model: LogTabViewModel, callback: () => void) {
+ useEffect(() => {
+ // TODO: Consider more precise way to check when list ready to scroll
+ setTimeout(() => {
+ callback();
+ }, 500); // Giving some time virtual library to render its rows
+ }, [model.logTabData.get()?.selectedPodId]);
+}
diff --git a/src/renderer/components/dock/logs/use-on-scroll-top.ts b/src/renderer/components/dock/logs/use-on-scroll-top.ts
new file mode 100644
index 0000000000..95e20ca378
--- /dev/null
+++ b/src/renderer/components/dock/logs/use-on-scroll-top.ts
@@ -0,0 +1,37 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+
+import type { RefObject } from "react";
+import { useEffect } from "react";
+import { useIntersectionObserver } from "../../../hooks";
+import type { LogTabViewModel } from "./logs-view-model";
+
+interface UseStickToBottomProps {
+ topLineRef: RefObject;
+ model: LogTabViewModel;
+ scrollTo: (index: number) => void;
+}
+
+export function useOnScrollTop({ topLineRef, model, scrollTo }: UseStickToBottomProps) {
+ const topLineEntry = useIntersectionObserver(topLineRef.current, {});
+
+ function getPreviouslyFirstLogIndex(firstLog: string) {
+ return model.logs.get().findIndex(log => log === firstLog);
+ }
+
+ async function onScrolledTop() {
+ const firstLog = model.logs.get()[0];
+ const scrollIndex = () => getPreviouslyFirstLogIndex(firstLog);
+
+ await model.loadLogs();
+ scrollTo(scrollIndex());
+ }
+
+ useEffect(() => {
+ if (topLineEntry?.isIntersecting) {
+ onScrolledTop();
+ }
+ }, [topLineEntry?.isIntersecting]);
+}
diff --git a/src/renderer/components/dock/logs/use-refresh-list-on-data-change.ts b/src/renderer/components/dock/logs/use-refresh-list-on-data-change.ts
new file mode 100644
index 0000000000..983b83a323
--- /dev/null
+++ b/src/renderer/components/dock/logs/use-refresh-list-on-data-change.ts
@@ -0,0 +1,19 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+
+import { useEffect, useState } from "react";
+import type { LogTabData } from "./tab-store";
+import { v4 as getRandomId } from "uuid";
+
+export function useRefreshListOnDataChange(data: LogTabData | undefined) {
+ const [rowKeySuffix, setRowKeySuffix] = useState(getRandomId());
+
+ useEffect(() => {
+ // Refresh virtualizer list rows by changing their keys
+ setRowKeySuffix(getRandomId());
+ }, [data]);
+
+ return rowKeySuffix;
+}
diff --git a/src/renderer/components/dock/logs/use-scroll-on-search.ts b/src/renderer/components/dock/logs/use-scroll-on-search.ts
new file mode 100644
index 0000000000..8d6d0ad9ff
--- /dev/null
+++ b/src/renderer/components/dock/logs/use-scroll-on-search.ts
@@ -0,0 +1,17 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+
+import { useEffect } from "react";
+import type { SearchStore } from "../../../search-store/search-store";
+
+export function useScrollOnSearch(store: SearchStore, scrollTo: (index: number) => void) {
+ const { occurrences, searchQuery, activeOverlayIndex } = store;
+
+ useEffect(() => {
+ if (!occurrences.length) return;
+
+ scrollTo(occurrences[activeOverlayIndex]);
+ }, [searchQuery, activeOverlayIndex]);
+}
diff --git a/src/renderer/components/dock/logs/use-scroll-to-bottom.ts b/src/renderer/components/dock/logs/use-scroll-to-bottom.ts
new file mode 100644
index 0000000000..8a8cb72cc9
--- /dev/null
+++ b/src/renderer/components/dock/logs/use-scroll-to-bottom.ts
@@ -0,0 +1,23 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+import { useState } from "react";
+
+export function useJumpToBottomButton(scrolledParent: HTMLDivElement | null): [isVisible: boolean, setVisibility: () => void] {
+ const [isVisible, setToBottomVisible] = useState(false);
+
+ const setVisibility = () => {
+ if (!scrolledParent) return;
+
+ const { scrollTop, scrollHeight } = scrolledParent;
+
+ if (scrollHeight - scrollTop > 4000) {
+ setToBottomVisible(true);
+ } else {
+ setToBottomVisible(false);
+ }
+ };
+
+ return [isVisible, setVisibility];
+}
diff --git a/src/renderer/components/dock/logs/use-stick-to-bottom-on-logs-load.ts b/src/renderer/components/dock/logs/use-stick-to-bottom-on-logs-load.ts
new file mode 100644
index 0000000000..e59290ac68
--- /dev/null
+++ b/src/renderer/components/dock/logs/use-stick-to-bottom-on-logs-load.ts
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+
+import type { RefObject } from "react";
+import { useEffect } from "react";
+import { useIntersectionObserver } from "../../../hooks";
+import type { LogTabViewModel } from "./logs-view-model";
+
+interface UseStickToBottomProps {
+ bottomLineRef: RefObject;
+ model: LogTabViewModel;
+ scrollToBottom: () => void;
+}
+
+export function useStickToBottomOnLogsLoad({ bottomLineRef, model, scrollToBottom }: UseStickToBottomProps) {
+ const bottomLineEntry = useIntersectionObserver(bottomLineRef.current, {});
+
+ useEffect(() => {
+ if (bottomLineEntry?.isIntersecting) {
+ scrollToBottom();
+ }
+ }, [model.visibleLogs.get().length]);
+}
diff --git a/src/renderer/components/dock/logs/view.tsx b/src/renderer/components/dock/logs/view.tsx
index 157ccfa778..4c42b08ff0 100644
--- a/src/renderer/components/dock/logs/view.tsx
+++ b/src/renderer/components/dock/logs/view.tsx
@@ -3,12 +3,10 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
-import React, { createRef, useEffect } from "react";
+import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { InfoPanel } from "../info-panel";
import { LogResourceSelector } from "./resource-selector";
-import type { LogListRef } from "./list";
-import { LogList } from "./list";
import { LogSearch } from "./search";
import { LogControls } from "./controls";
import { withInjectables } from "@ogre-tools/injectable-react";
@@ -20,6 +18,7 @@ import type { SubscribeStores } from "../../../kube-watch-api/kube-watch-api";
import subscribeStoresInjectable from "../../../kube-watch-api/subscribe-stores.injectable";
import type { PodStore } from "../../+workloads-pods/store";
import podStoreInjectable from "../../+workloads-pods/store.injectable";
+import { LogList } from "./log-list";
export interface LogsDockTabProps {
className?: string;
@@ -39,7 +38,6 @@ const NonInjectedLogsDockTab = observer(({
subscribeStores,
podStore,
}: Dependencies & LogsDockTabProps) => {
- const logListElement = createRef();
const data = model.logTabData.get();
useEffect(() => {
@@ -53,27 +51,6 @@ const NonInjectedLogsDockTab = observer(({
namespaces: data ? [data.namespace] : [],
}), [data?.namespace]);
- const scrollToOverlay = (overlayLine: number | undefined) => {
- if (!logListElement.current || overlayLine === undefined) {
- return;
- }
-
- // Scroll vertically
- logListElement.current.scrollToItem(overlayLine, "center");
- // Scroll horizontally in timeout since virtual list need some time to prepare its contents
- setTimeout(() => {
- const overlay = document.querySelector(".PodLogs .list span.active");
-
- if (!overlay) return;
- // Note: .scrollIntoViewIfNeeded() is non-standard and thus not present in js-dom.
- overlay?.scrollIntoViewIfNeeded?.();
- }, 100);
- };
-
- if (!data) {
- return null;
- }
-
return (
-
+
)}
showSubmitClose={false}
showButtons={false}
showStatusPanel={false}
/>
-
+
);
diff --git a/src/renderer/hooks/index.ts b/src/renderer/hooks/index.ts
index 852f4d64d0..d6b06e416b 100644
--- a/src/renderer/hooks/index.ts
+++ b/src/renderer/hooks/index.ts
@@ -10,3 +10,4 @@ export * from "./useInterval";
export * from "./useMutationObserver";
export * from "./useResizeObserver";
export * from "./use-toggle";
+export * from "./useIntersectionObserver";
diff --git a/src/renderer/hooks/useIntersectionObserver.ts b/src/renderer/hooks/useIntersectionObserver.ts
new file mode 100644
index 0000000000..41d51ac8a4
--- /dev/null
+++ b/src/renderer/hooks/useIntersectionObserver.ts
@@ -0,0 +1,66 @@
+/**
+ * Copyright (c) OpenLens Authors. All rights reserved.
+ * Licensed under MIT License. See LICENSE in root directory for more information.
+ */
+
+import { useEffect, useState } from "react";
+
+/**
+ * Intersection Observer configuratiopn options.
+ */
+interface IntersectionObserverOptions {
+ /**
+ * If `true`, check for intersection only once. Will
+ * disconnect the IntersectionObserver instance after
+ * intersection.
+ */
+ triggerOnce?: boolean;
+
+ /**
+ * Number from 0 to 1 representing the percentage
+ * of the element that needs to be visible to be
+ * considered as visible. Can also be an array of
+ * thresholds.
+ */
+ threshold?: number | number[];
+
+ /**
+ * Element that is used as the viewport for checking visibility
+ * of the provided `ref` or `element`.
+ */
+ root?: Element;
+
+ /**
+ * Margin around the root. Can have values similar to
+ * the CSS margin property.
+ */
+ rootMargin?: string;
+}
+
+export function useIntersectionObserver(
+ element: Element | null,
+ {
+ threshold = 0,
+ rootMargin = "0%",
+ root,
+ }: IntersectionObserverOptions,
+): IntersectionObserverEntry | undefined {
+ const [entry, setEntry] = useState
();
+
+ const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {
+ setEntry(entry);
+ };
+
+ useEffect(() => {
+ if (!element) return;
+
+ const observer = new IntersectionObserver(updateEntry, { threshold, root, rootMargin });
+
+ observer.observe(element);
+
+ return () => observer.disconnect();
+
+ }, [element, threshold, root, rootMargin]);
+
+ return entry;
+}
diff --git a/src/renderer/search-store/search-store.ts b/src/renderer/search-store/search-store.ts
index aa5b74dc97..8d9594dc2a 100644
--- a/src/renderer/search-store/search-store.ts
+++ b/src/renderer/search-store/search-store.ts
@@ -123,6 +123,8 @@ export class SearchStore {
* @returns A line index within the text/logs array
*/
@computed get activeOverlayLine(): number {
+ if (!this.occurrences.length) return -1;
+
return this.occurrences[this.activeOverlayIndex];
}
diff --git a/yarn.lock b/yarn.lock
index cd23c803c4..0f9d60ab5f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1822,6 +1822,18 @@
dependencies:
defer-to-connect "^2.0.0"
+"@tanstack/react-virtual@3.0.0-beta.23":
+ version "3.0.0-beta.23"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.0.0-beta.23.tgz#f3d3e049d6b49e2b91c46ec3d35f48d9fdf7426a"
+ integrity sha512-FurqZJD7oSXLtL5NP0YQKXNEY+2qczXjzxmxkD51ZO8ykyr2z62IRNfvpOyly2CPLg+M346mA/Ul2hlx4R3KPw==
+ dependencies:
+ "@tanstack/virtual-core" "3.0.0-beta.23"
+
+"@tanstack/virtual-core@3.0.0-beta.23":
+ version "3.0.0-beta.23"
+ resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.23.tgz#2e586256bdb239e35c8d1ca4e8d66c1c55151419"
+ integrity sha512-xQfJgz4mdaxifPjOqUBYAar60UAQTrED2SpS0VI5AZvxYI4NDTxx5cFrjaP4baKY3UnVc8IsGFbqr8Q/LZNMag==
+
"@testing-library/dom@>=7", "@testing-library/dom@^8.0.0":
version "8.13.0"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.13.0.tgz#bc00bdd64c7d8b40841e27a70211399ad3af46f5"