diff --git a/src/App.tsx b/src/App.tsx
index e158a7907..6ef5affde 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,6 +1,5 @@
import AppController from "./components/AppController";
import Layout from "./components/Layout";
-import UrlContextProvider from "./contexts/UrlContextProvider";
/**
@@ -10,11 +9,9 @@ import UrlContextProvider from "./contexts/UrlContextProvider";
*/
const App = () => {
return (
-
-
-
-
-
+
+
+
);
};
diff --git a/src/components/AppController.tsx b/src/components/AppController.tsx
index 0edc16ef0..5ab15563d 100644
--- a/src/components/AppController.tsx
+++ b/src/components/AppController.tsx
@@ -1,23 +1,17 @@
import React, {
- useContext,
useEffect,
useRef,
} from "react";
-import {
- updateWindowUrlHashParams,
- URL_HASH_PARAMS_DEFAULT,
- URL_SEARCH_PARAMS_DEFAULT,
- UrlContext,
-} from "../contexts/UrlContextProvider";
-import useContextStore from "../stores/contextStore";
import useLogFileManagerStore from "../stores/logFileManagerProxyStore";
import useLogFileStore from "../stores/logFileStore";
import {handleErrorWithNotification} from "../stores/notificationStore";
import useQueryStore from "../stores/queryStore";
import useUiStore from "../stores/uiStore";
import useViewStore from "../stores/viewStore";
+import {Nullable} from "../typings/common";
import {UI_STATE} from "../typings/states";
+import {UrlHashParams} from "../typings/url";
import {
CURSOR_CODE,
CursorType,
@@ -27,6 +21,13 @@ import {
isWithinBounds,
} from "../utils/data";
import {clamp} from "../utils/math";
+import {
+ getWindowUrlHashParams,
+ getWindowUrlSearchParams,
+ updateWindowUrlHashParams,
+ URL_HASH_PARAMS_DEFAULT,
+ URL_SEARCH_PARAMS_DEFAULT,
+} from "../utils/url";
/**
@@ -67,6 +68,73 @@ const updateUrlIfEventOnPage = (
return true;
};
+/**
+ * Updates view-related parameters from URL hash.
+ *
+ * @param hashParams
+ */
+const updateViewHashParams = (hashParams: UrlHashParams): void => {
+ const {isPrettified, logEventNum} = hashParams;
+ const {updateIsPrettified, setLogEventNum} = useViewStore.getState();
+
+ updateIsPrettified(isPrettified);
+ setLogEventNum(logEventNum);
+};
+
+/**
+ * Updates query-related parameters from URL hash.
+ *
+ * @param hashParams
+ * @return Whether any query parameters were modified.
+ */
+const updateQueryHashParams = (hashParams: UrlHashParams): boolean => {
+ const {queryIsCaseSensitive, queryIsRegex, queryString} = hashParams;
+ const {
+ queryIsCaseSensitive: currentQueryIsCaseSensitive,
+ queryIsRegex: currentQueryIsRegex,
+ queryString: currentQueryString,
+ setQueryIsCaseSensitive,
+ setQueryIsRegex,
+ setQueryString,
+ } = useQueryStore.getState();
+
+ let isQueryModified = false;
+ isQueryModified ||= queryIsCaseSensitive !== currentQueryIsCaseSensitive;
+ setQueryIsCaseSensitive(queryIsCaseSensitive);
+
+ isQueryModified ||= queryIsRegex !== currentQueryIsRegex;
+ setQueryIsRegex(queryIsRegex);
+
+ isQueryModified ||= queryString !== currentQueryString;
+ setQueryString(queryString);
+
+ return isQueryModified;
+};
+
+/**
+ * Handles hash change events by updating the application state based on the URL hash parameters.
+ *
+ * @param [ev] The hash change event, or `null` when called on application initialization.
+ * @return The parsed URL hash parameters.
+ */
+const handleHashChange = (ev: Nullable): UrlHashParams => {
+ const hashParams = getWindowUrlHashParams();
+ updateViewHashParams(hashParams);
+ const isQueryModified = updateQueryHashParams(hashParams);
+ const isTriggeredByHashChange = null !== ev;
+ if (isTriggeredByHashChange && isQueryModified) {
+ const {startQuery} = useQueryStore.getState();
+ startQuery();
+ }
+
+ // Remove empty or falsy parameters.
+ updateWindowUrlHashParams({
+ ...hashParams,
+ });
+
+ return hashParams;
+};
+
interface AppControllerProps {
children: React.ReactNode;
}
@@ -79,127 +147,74 @@ interface AppControllerProps {
* @return
*/
const AppController = ({children}: AppControllerProps) => {
- const {
- filePath, isPrettified, logEventNum, queryString, queryIsRegex, queryIsCaseSensitive,
- } = useContext(UrlContext);
-
// States
- const setLogEventNum = useContextStore((state) => state.setLogEventNum);
- const logFileManagerProxy = useLogFileManagerStore((state) => state.logFileManagerProxy);
- const loadFile = useLogFileStore((state) => state.loadFile);
- const numEvents = useLogFileStore((state) => state.numEvents);
- const beginLineNumToLogEventNum = useViewStore((state) => state.beginLineNumToLogEventNum);
- const setIsPrettified = useViewStore((state) => state.updateIsPrettified);
- const updatePageData = useViewStore((state) => state.updatePageData);
- const uiState = useUiStore((state) => state.uiState);
- const setUiState = useUiStore((state) => state.setUiState);
+ const logEventNum = useViewStore((state) => state.logEventNum);
// Refs
- const isPrettifiedRef = useRef(isPrettified ?? false);
- const logEventNumRef = useRef(logEventNum);
+ const isInitialized = useRef(false);
- // Synchronize `logEventNumRef` with `logEventNum`.
+ // On app init, register hash change handler, and handle hash and search parameters.
useEffect(() => {
- if (null !== logEventNum) {
- logEventNumRef.current = logEventNum;
- setLogEventNum(logEventNum);
+ window.addEventListener("hashchange", handleHashChange);
+
+ // Prevent re-initialization on re-renders.
+ if (isInitialized.current) {
+ return () => null;
+ }
+ isInitialized.current = true;
+
+ // Handle initial page load and maintain full URL state
+ const hashParams = handleHashChange(null);
+ const searchParams = getWindowUrlSearchParams();
+ if (URL_SEARCH_PARAMS_DEFAULT.filePath !== searchParams.filePath) {
+ let cursor: CursorType = {code: CURSOR_CODE.LAST_EVENT, args: null};
+
+ if (URL_HASH_PARAMS_DEFAULT.logEventNum !== hashParams.logEventNum) {
+ cursor = {
+ code: CURSOR_CODE.EVENT_NUM,
+ args: {eventNum: hashParams.logEventNum},
+ };
+ }
+ const {loadFile} = useLogFileStore.getState();
+ loadFile(searchParams.filePath, cursor);
}
- }, [
- logEventNum,
- setLogEventNum,
- ]);
- // Synchronize `isPrettifiedRef` with `isPrettified`.
- useEffect(() => {
- isPrettifiedRef.current = isPrettified ?? false;
- setIsPrettified(isPrettifiedRef.current);
- }, [
- isPrettified,
- setIsPrettified,
- ]);
+ return () => {
+ window.removeEventListener("hashchange", handleHashChange);
+ };
+ }, []);
// On `logEventNum` update, clamp it then switch page if necessary or simply update the URL.
useEffect(() => {
+ const {numEvents} = useLogFileStore.getState();
if (0 === numEvents || URL_HASH_PARAMS_DEFAULT.logEventNum === logEventNum) {
return;
}
const clampedLogEventNum = clamp(logEventNum, 1, numEvents);
- const logEventNumsOnPage: number [] =
- Array.from(beginLineNumToLogEventNum.values());
-
+ const {beginLineNumToLogEventNum} = useViewStore.getState();
+ const logEventNumsOnPage: number [] = Array.from(beginLineNumToLogEventNum.values());
if (updateUrlIfEventOnPage(clampedLogEventNum, logEventNumsOnPage)) {
// No need to request a new page since the log event is on the current page.
return;
}
+ // If the log event is not on the current page, request a new page.
+ const {setUiState} = useUiStore.getState();
setUiState(UI_STATE.FAST_LOADING);
-
(async () => {
+ const {logFileManagerProxy} = useLogFileManagerStore.getState();
const cursor: CursorType = {
code: CURSOR_CODE.EVENT_NUM,
args: {eventNum: clampedLogEventNum},
};
- const pageData = await logFileManagerProxy.loadPage(cursor, isPrettifiedRef.current);
+ const {isPrettified} = useViewStore.getState();
+
+ const pageData = await logFileManagerProxy.loadPage(cursor, isPrettified);
+ const {updatePageData} = useViewStore.getState();
updatePageData(pageData);
})().catch(handleErrorWithNotification);
- }, [
- beginLineNumToLogEventNum,
- logEventNum,
- logFileManagerProxy,
- numEvents,
- setUiState,
- updatePageData,
- ]);
-
- // On `filePath` update, load file.
- useEffect(() => {
- if (URL_SEARCH_PARAMS_DEFAULT.filePath === filePath) {
- return;
- }
-
- let cursor: CursorType = {code: CURSOR_CODE.LAST_EVENT, args: null};
- if (URL_HASH_PARAMS_DEFAULT.logEventNum !== logEventNumRef.current) {
- cursor = {
- code: CURSOR_CODE.EVENT_NUM,
- args: {eventNum: logEventNumRef.current},
- };
- }
- loadFile(filePath, cursor);
- }, [
- filePath,
- loadFile,
- ]);
-
- // Synchronize `queryIsCaseSensitive` with the Zustand QueryStore.
- useEffect(() => {
- if (null !== queryIsCaseSensitive) {
- const {setQueryIsCaseSensitive} = useQueryStore.getState();
- setQueryIsCaseSensitive(queryIsCaseSensitive);
- }
- }, [queryIsCaseSensitive]);
-
- // Synchronize `queryIsRegex` with the Zustand QueryStore.
- useEffect(() => {
- if (null !== queryIsRegex) {
- const {setQueryIsRegex} = useQueryStore.getState();
- setQueryIsRegex(queryIsRegex);
- }
- }, [queryIsRegex]);
-
- useEffect(() => {
- if (null !== queryString) {
- const {setQueryString} = useQueryStore.getState();
- setQueryString(queryString);
- }
- if (UI_STATE.READY === uiState) {
- const {startQuery} = useQueryStore.getState();
- startQuery();
- }
- }, [
- uiState,
- queryString,
- ]);
+ }, [logEventNum]);
return children;
};
diff --git a/src/components/CentralContainer/Sidebar/SidebarTabs/SearchTabPanel/QueryInputBox.tsx b/src/components/CentralContainer/Sidebar/SidebarTabs/SearchTabPanel/QueryInputBox.tsx
index 27ba08ac0..a074b79e9 100644
--- a/src/components/CentralContainer/Sidebar/SidebarTabs/SearchTabPanel/QueryInputBox.tsx
+++ b/src/components/CentralContainer/Sidebar/SidebarTabs/SearchTabPanel/QueryInputBox.tsx
@@ -1,4 +1,4 @@
-import React from "react";
+import React, {useCallback} from "react";
import {
LinearProgress,
@@ -11,6 +11,7 @@ import useUiStore from "../../../../../stores/uiStore";
import {QUERY_PROGRESS_VALUE_MAX} from "../../../../../typings/query";
import {UI_ELEMENT} from "../../../../../typings/states";
import {isDisabled} from "../../../../../utils/states";
+import {updateWindowUrlHashParams} from "../../../../../utils/url";
import ToggleIconButton from "./ToggleIconButton";
import "./QueryInputBox.css";
@@ -25,27 +26,32 @@ const QueryInputBox = () => {
const isCaseSensitive = useQueryStore((state) => state.queryIsCaseSensitive);
const isRegex = useQueryStore((state) => state.queryIsRegex);
const querystring = useQueryStore((state) => state.queryString);
- const setQueryIsCaseSensitive = useQueryStore((state) => state.setQueryIsCaseSensitive);
- const setQueryIsRegex = useQueryStore((state) => state.setQueryIsRegex);
- const setQueryString = useQueryStore((state) => state.setQueryString);
const queryProgress = useQueryStore((state) => state.queryProgress);
- const startQuery = useQueryStore((state) => state.startQuery);
const uiState = useUiStore((state) => state.uiState);
- const handleQueryInputChange = (ev: React.ChangeEvent) => {
- setQueryString(ev.target.value);
+ const handleQueryInputChange = useCallback((ev: React.ChangeEvent) => {
+ const newQueryString = ev.target.value;
+ updateWindowUrlHashParams({queryString: newQueryString});
+ const {setQueryString, startQuery} = useQueryStore.getState();
+ setQueryString(newQueryString);
startQuery();
- };
+ }, []);
- const handleCaseSensitivityButtonClick = () => {
- setQueryIsCaseSensitive(!isCaseSensitive);
+ const handleCaseSensitivityButtonClick = useCallback(() => {
+ const newQueryIsSensitive = !isCaseSensitive;
+ updateWindowUrlHashParams({queryIsCaseSensitive: newQueryIsSensitive});
+ const {setQueryIsCaseSensitive, startQuery} = useQueryStore.getState();
+ setQueryIsCaseSensitive(newQueryIsSensitive);
startQuery();
- };
+ }, [isCaseSensitive]);
- const handleRegexButtonClick = () => {
- setQueryIsRegex(!isRegex);
+ const handleRegexButtonClick = useCallback(() => {
+ const newQueryIsRegex = !isRegex;
+ updateWindowUrlHashParams({queryIsRegex: newQueryIsRegex});
+ const {setQueryIsRegex, startQuery} = useQueryStore.getState();
+ setQueryIsRegex(newQueryIsRegex);
startQuery();
- };
+ }, [isRegex]);
const isQueryInputBoxDisabled = isDisabled(uiState, UI_ELEMENT.QUERY_INPUT_BOX);
diff --git a/src/components/CentralContainer/Sidebar/SidebarTabs/SearchTabPanel/Result.tsx b/src/components/CentralContainer/Sidebar/SidebarTabs/SearchTabPanel/Result.tsx
index d38316a2f..2cefbe7eb 100644
--- a/src/components/CentralContainer/Sidebar/SidebarTabs/SearchTabPanel/Result.tsx
+++ b/src/components/CentralContainer/Sidebar/SidebarTabs/SearchTabPanel/Result.tsx
@@ -1,9 +1,12 @@
+import {useCallback} from "react";
+
import {
ListItemButton,
Typography,
} from "@mui/joy";
-import {updateWindowUrlHashParams} from "../../../../../contexts/UrlContextProvider";
+import useViewStore from "../../../../../stores/viewStore";
+import {updateWindowUrlHashParams} from "../../../../../utils/url";
import "./Result.css";
@@ -36,9 +39,12 @@ const Result = ({logEventNum, message, matchRange}: ResultProps) => {
message.slice(...matchRange),
message.slice(matchRange[1]),
];
- const handleResultButtonClick = () => {
+
+ const handleResultButtonClick = useCallback(() => {
updateWindowUrlHashParams({logEventNum});
- };
+ const {setLogEventNum} = useViewStore.getState();
+ setLogEventNum(logEventNum);
+ }, [logEventNum]);
return (
{
- const queryIsCaseSensitive = useQueryStore((state) => state.queryIsCaseSensitive);
- const queryIsRegex = useQueryStore((state) => state.queryIsRegex);
- const queryString = useQueryStore((state) => state.queryString);
const queryResults = useQueryStore((state) => state.queryResults);
const [isAllExpanded, setIsAllExpanded] = useState(true);
@@ -44,19 +41,26 @@ const SearchTabPanel = () => {
}, []);
const handleShareButtonClick = useCallback(() => {
+ const {
+ queryIsCaseSensitive,
+ queryIsRegex,
+ queryString,
+ setQueryIsCaseSensitive,
+ setQueryIsRegex,
+ setQueryString,
+ } = useQueryStore.getState();
+
+ setQueryIsCaseSensitive(queryIsCaseSensitive);
+ setQueryIsRegex(queryIsRegex);
+ setQueryString(queryString);
+
copyPermalinkToClipboard({}, {
logEventNum: null,
- queryString: "" === queryString ?
- null :
- queryString,
+ queryString: queryString,
queryIsCaseSensitive: queryIsCaseSensitive,
queryIsRegex: queryIsRegex,
});
- }, [
- queryIsCaseSensitive,
- queryIsRegex,
- queryString,
- ]);
+ }, []);
return (
{
+ switch (actionName) {
+ case ACTION_NAME.FIRST_PAGE:
+ case ACTION_NAME.PREV_PAGE:
+ case ACTION_NAME.NEXT_PAGE:
+ case ACTION_NAME.LAST_PAGE: {
+ const {loadPageByAction} = useViewStore.getState();
+ loadPageByAction({code: actionName, args: null});
+ break;
+ }
+ case ACTION_NAME.PAGE_TOP:
+ goToPositionAndCenter(editor, {lineNumber: 1, column: 1});
+ break;
+ case ACTION_NAME.PAGE_BOTTOM: {
+ const lineCount = editor.getModel()?.getLineCount();
+ if ("undefined" === typeof lineCount) {
+ break;
+ }
+ goToPositionAndCenter(editor, {lineNumber: lineCount, column: 1});
+ break;
+ }
+ case ACTION_NAME.COPY_LOG_EVENT: {
+ const {beginLineNumToLogEventNum} = useViewStore.getState();
+ handleCopyLogEventAction(editor, beginLineNumToLogEventNum);
+ break;
+ }
+ case ACTION_NAME.TOGGLE_PRETTIFY: {
+ const {isPrettified, updateIsPrettified} = useViewStore.getState();
+ const newIsPrettified = !isPrettified;
+ updateWindowUrlHashParams({
+ [HASH_PARAM_NAMES.IS_PRETTIFIED]: newIsPrettified,
+ });
+ updateIsPrettified(newIsPrettified);
+ break;
+ }
+ case ACTION_NAME.TOGGLE_WORD_WRAP:
+ handleToggleWordWrapAction(editor);
+ break;
+ default:
+ break;
+ }
+};
+
/**
* Renders a read-only editor for viewing logs.
*
@@ -139,56 +186,16 @@ const Editor = () => {
const beginLineNumToLogEventNum = useViewStore((state) => state.beginLineNumToLogEventNum);
const logData = useViewStore((state) => state.logData);
- const loadPageByAction = useViewStore((state) => state.loadPageByAction);
- const {isPrettified, logEventNum} = useContext(UrlContext);
+ const logEventNum = useViewStore((state) => state.logEventNum);
const [lineNum, setLineNum] = useState(1);
const beginLineNumToLogEventNumRef = useRef(
beginLineNumToLogEventNum
);
const editorRef = useRef>(null);
- const isPrettifiedRef = useRef(isPrettified ?? false);
const isMouseDownRef = useRef(false);
const pageSizeRef = useRef(getConfig(CONFIG_KEY.PAGE_SIZE));
- const handleEditorCustomAction = useCallback((
- editor: monaco.editor.IStandaloneCodeEditor,
- actionName: ACTION_NAME
- ) => {
- switch (actionName) {
- case ACTION_NAME.FIRST_PAGE:
- case ACTION_NAME.PREV_PAGE:
- case ACTION_NAME.NEXT_PAGE:
- case ACTION_NAME.LAST_PAGE:
- loadPageByAction({code: actionName, args: null});
- break;
- case ACTION_NAME.PAGE_TOP:
- goToPositionAndCenter(editor, {lineNumber: 1, column: 1});
- break;
- case ACTION_NAME.PAGE_BOTTOM: {
- const lineCount = editor.getModel()?.getLineCount();
- if ("undefined" === typeof lineCount) {
- break;
- }
- goToPositionAndCenter(editor, {lineNumber: lineCount, column: 1});
- break;
- }
- case ACTION_NAME.COPY_LOG_EVENT:
- handleCopyLogEventAction(editor, beginLineNumToLogEventNumRef.current);
- break;
- case ACTION_NAME.TOGGLE_PRETTIFY:
- updateWindowUrlHashParams({
- [HASH_PARAM_NAMES.IS_PRETTIFIED]: !isPrettifiedRef.current,
- });
- break;
- case ACTION_NAME.TOGGLE_WORD_WRAP:
- handleToggleWordWrapAction(editor);
- break;
- default:
- break;
- }
- }, [loadPageByAction]);
-
/**
* Sets `editorRef` and configures callbacks for mouse down detection.
*/
@@ -255,6 +262,8 @@ const Editor = () => {
return;
}
updateWindowUrlHashParams({logEventNum: newLogEventNum});
+ const {setLogEventNum} = useViewStore.getState();
+ setLogEventNum(newLogEventNum);
}, []);
// Synchronize `beginLineNumToLogEventNumRef` with `beginLineNumToLogEventNum`.
@@ -262,11 +271,6 @@ const Editor = () => {
beginLineNumToLogEventNumRef.current = beginLineNumToLogEventNum;
}, [beginLineNumToLogEventNum]);
- // Synchronize `isPrettifiedRef` with `isPrettified`.
- useEffect(() => {
- isPrettifiedRef.current = isPrettified ?? false;
- }, [isPrettified]);
-
// On `logEventNum` update, update line number in the editor.
useEffect(() => {
if (null === editorRef.current || isMouseDownRef.current) {
diff --git a/src/components/StatusBar/index.tsx b/src/components/StatusBar/index.tsx
index 37864985c..2f231f64b 100644
--- a/src/components/StatusBar/index.tsx
+++ b/src/components/StatusBar/index.tsx
@@ -1,4 +1,4 @@
-import React, {useContext} from "react";
+import React from "react";
import {
Button,
@@ -10,17 +10,17 @@ import {
import AutoFixHighRoundedIcon from "@mui/icons-material/AutoFixHighRounded";
import AutoFixOffRoundedIcon from "@mui/icons-material/AutoFixOffRounded";
-import {
- copyPermalinkToClipboard,
- updateWindowUrlHashParams,
- UrlContext,
-} from "../../contexts/UrlContextProvider";
import useLogFileStore from "../../stores/logFileStore";
import useUiStore from "../../stores/uiStore";
+import useViewStore from "../../stores/viewStore";
import {UI_ELEMENT} from "../../typings/states";
import {HASH_PARAM_NAMES} from "../../typings/url";
import {ACTION_NAME} from "../../utils/actions";
import {isDisabled} from "../../utils/states";
+import {
+ copyPermalinkToClipboard,
+ updateWindowUrlHashParams,
+} from "../../utils/url";
import LogLevelSelect from "./LogLevelSelect";
import StatusBarToggleButton from "./StatusBarToggleButton";
@@ -40,9 +40,11 @@ const handleCopyLinkButtonClick = () => {
* @return
*/
const StatusBar = () => {
+ const isPrettified = useViewStore((state) => state.isPrettified);
+ const logEventNum = useViewStore((state) => state.logEventNum);
const numEvents = useLogFileStore((state) => state.numEvents);
const uiState = useUiStore((state) => state.uiState);
- const {isPrettified, logEventNum} = useContext(UrlContext);
+ const updateIsPrettified = useViewStore((state) => state.updateIsPrettified);
const handleStatusButtonClick = (ev: React.MouseEvent) => {
const {actionName} = ev.currentTarget.dataset;
@@ -50,8 +52,9 @@ const StatusBar = () => {
switch (actionName) {
case ACTION_NAME.TOGGLE_PRETTIFY:
updateWindowUrlHashParams({
- [HASH_PARAM_NAMES.IS_PRETTIFIED]: !isPrettified,
+ [HASH_PARAM_NAMES.IS_PRETTIFIED]: false === isPrettified,
});
+ updateIsPrettified(!isPrettified);
break;
default:
console.error(`Unexpected action: ${actionName}`);
@@ -89,14 +92,14 @@ const StatusBar = () => {
,
inactive: ,
}}
- tooltipTitle={isPrettified ?? false ?
- "Turn off Prettify" :
- "Turn on Prettify"}
+ tooltipTitle={false === isPrettified ?
+ "Turn on Prettify" :
+ "Turn off Prettify"}
onClick={handleStatusButtonClick}/>
);
diff --git a/src/contexts/UrlContextProvider.tsx b/src/contexts/UrlContextProvider.tsx
deleted file mode 100644
index f7fe7998d..000000000
--- a/src/contexts/UrlContextProvider.tsx
+++ /dev/null
@@ -1,307 +0,0 @@
-/* eslint max-lines: ["error", 350] */
-import React, {
- createContext,
- useEffect,
- useState,
-} from "react";
-
-import {NullableProperties} from "../typings/common";
-import {
- HASH_PARAM_NAMES,
- SEARCH_PARAM_NAMES,
- UrlHashParams,
- UrlHashParamUpdatesType,
- UrlParamsType,
- UrlSearchParams,
- UrlSearchParamUpdatesType,
-} from "../typings/url";
-import {getAbsoluteUrl} from "../utils/url";
-
-
-const UrlContext = createContext ({} as UrlParamsType);
-
-/**
- * Default values of the search parameters.
- */
-const URL_SEARCH_PARAMS_DEFAULT = Object.freeze({
- [SEARCH_PARAM_NAMES.FILE_PATH]: null,
-});
-
-/**
- * Default values of the hash parameters.
- */
-const URL_HASH_PARAMS_DEFAULT = Object.freeze({
- [HASH_PARAM_NAMES.IS_PRETTIFIED]: false,
- [HASH_PARAM_NAMES.LOG_EVENT_NUM]: null,
- [HASH_PARAM_NAMES.QUERY_IS_CASE_SENSITIVE]: false,
- [HASH_PARAM_NAMES.QUERY_IS_REGEX]: false,
- [HASH_PARAM_NAMES.QUERY_STRING]: null,
-});
-
-/**
- * Regular expression pattern for identifying ambiguous characters in a URL.
- */
-const AMBIGUOUS_URL_CHARS_REGEX =
- new RegExp(`${encodeURIComponent("#")}|${encodeURIComponent("&")}`);
-
-/**
- * Computes updated URL search parameters based on the provided key-value pairs.
- *
- * @param updates An object containing key-value pairs to update the search parameters. If a value
- * is `null`, the corresponding kv-pair will be removed from the updated search parameters.
- * @return The updated search parameters string.
- */
-const getUpdatedSearchParams = (updates: UrlSearchParamUpdatesType) => {
- const newSearchParams = new URLSearchParams(window.location.search.substring(1));
- const {filePath: newFilePath} = updates;
-
- for (const [key, value] of Object.entries(updates)) {
- if (SEARCH_PARAM_NAMES.FILE_PATH as string === key) {
- // Updates to `filePath` should be handled last.
- continue;
- }
- if (null === value) {
- newSearchParams.delete(key);
- } else {
- newSearchParams.set(key, String(value));
- }
- }
-
- // `filePath` should always be the last search parameter so that:
- // 1. Users can specify a remote filePath (a URL) that itself contains URL parameters without
- // encoding them. E.g. "/?filePath=https://example.com/log/?p1=v1&p2=v2"
- // 2. Users can easily modify it in the URL
- //
- // NOTE: We're relying on URLSearchParams.set() and URLSearchParams.toString() to store and
- // serialize the parameters in the order that they were set.
- const originalFilePath = newSearchParams.get(SEARCH_PARAM_NAMES.FILE_PATH);
- newSearchParams.delete(SEARCH_PARAM_NAMES.FILE_PATH);
- if ("undefined" === typeof newFilePath && null !== originalFilePath) {
- // If no change in `filePath` is specified, put the original `filePath` back.
- newSearchParams.set(SEARCH_PARAM_NAMES.FILE_PATH, originalFilePath);
- } else if ("undefined" !== typeof newFilePath && null !== newFilePath) {
- // If the new `filePath` has a non-null value, set the value.
- newSearchParams.set(SEARCH_PARAM_NAMES.FILE_PATH, newFilePath);
- }
-
- // If the stringified search params doesn't contain characters that would make the URL ambiguous
- // to parse, URL-decode it so that the `filePath` remains human-readable. E.g.
- // "filePath=https://example.com/log/?s1=1&s2=2#h1=0" would make the final URL ambiguous to
- // parse since `filePath` itself contains URL parameters.
- let searchString = newSearchParams.toString();
- if (false === AMBIGUOUS_URL_CHARS_REGEX.test(searchString)) {
- searchString = decodeURIComponent(searchString);
- }
-
- return searchString;
-};
-
-/**
- * Computes updated URL hash parameters based on the provided key-value pairs.
- *
- * @param updates An object containing key-value pairs to update the hash parameters. If a key's
- * value is `null`, the key will be removed from the updated hash parameters.
- * @return The updated hash parameters string.
- */
-const getUpdatedHashParams = (updates: UrlHashParamUpdatesType) => {
- const newHashParams = new URLSearchParams(window.location.hash.substring(1));
- for (const [key, value] of Object.entries(updates)) {
- if (null === value || false === value) {
- newHashParams.delete(key);
- } else {
- newHashParams.set(key, String(value));
- }
- }
-
- return newHashParams.toString();
-};
-
-/**
- * Updates search parameters in the current window's URL with the given key-value pairs.
- *
- * @param updates An object containing key-value pairs to update the search parameters. If a value
- * is `null`, the corresponding kv-pair will be removed from the URL's search parameters.
- */
-const updateWindowUrlSearchParams = (updates: UrlSearchParamUpdatesType) => {
- const newUrl = new URL(window.location.href);
- newUrl.search = getUpdatedSearchParams(updates);
- window.history.pushState({}, "", newUrl);
-};
-
-/**
- * Updates hash parameters in the current window's URL with the given key-value pairs.
- *
- * @param updates An object containing key-value pairs to update the hash parameters. If a value is
- * `null`, the corresponding kv-pair will be removed from the URL's hash parameters.
- */
-const updateWindowUrlHashParams = (updates: UrlHashParamUpdatesType) => {
- const newHash = getUpdatedHashParams(updates);
- const currHash = window.location.hash.substring(1);
- if (newHash === currHash) {
- return;
- }
-
- const newUrl = new URL(window.location.href);
- newUrl.hash = newHash;
- window.history.pushState({}, "", newUrl);
-
- // `history.pushState` doesn't trigger a `hashchange`, so we need to dispatch one manually.
- window.dispatchEvent(new HashChangeEvent("hashchange"));
-};
-
-/**
- * Copies the current window's URL to the clipboard. If any `updates` parameters are specified,
- * the copied URL will include these modifications, but the original window's URL will not be
- * changed.
- *
- * @param searchParamUpdates An object containing key-value pairs to update the search parameters.
- * If a value is `null`, the corresponding kv-pair will be removed from the URL's search parameters.
- * @param hashParamsUpdates An object containing key-value pairs to update the hash parameters.
- * If a value is `null`, the corresponding kv-pair will be removed from the URL's hash parameters.
- */
-const copyPermalinkToClipboard = (
- searchParamUpdates: UrlSearchParamUpdatesType,
- hashParamsUpdates: UrlHashParamUpdatesType,
-) => {
- const newUrl = new URL(window.location.href);
- newUrl.search = getUpdatedSearchParams(searchParamUpdates);
- newUrl.hash = getUpdatedHashParams(hashParamsUpdates);
- navigator.clipboard.writeText(newUrl.toString())
- .then(() => {
- console.log("URL copied to clipboard.");
- })
- .catch((error: unknown) => {
- console.error("Failed to copy URL to clipboard:", error);
- });
-};
-
-/**
- * Retrieves all search parameters from the current window's URL.
- *
- * @return An object containing the search parameters.
- */
-const getWindowUrlSearchParams = () => {
- const searchParams : NullableProperties = structuredClone(
- URL_SEARCH_PARAMS_DEFAULT
- );
- const urlSearchParams = new URLSearchParams(window.location.search.substring(1));
-
- urlSearchParams.forEach((value, key) => {
- searchParams[key as keyof UrlSearchParams] = value;
- });
-
- if (urlSearchParams.has(SEARCH_PARAM_NAMES.FILE_PATH)) {
- // Split the search string and take everything after as `filePath` value.
- // This ensures any parameters following `filePath=` are incorporated into the `filePath`.
- const [, filePath] = window.location.search.split("filePath=");
- if ("undefined" !== typeof filePath && 0 !== filePath.length) {
- let resolvedFilePath = decodeURIComponent(filePath);
- try {
- resolvedFilePath = getAbsoluteUrl(resolvedFilePath);
- } catch (e) {
- console.error("Unable to get absolute URL from filePath:", e);
- }
- searchParams[SEARCH_PARAM_NAMES.FILE_PATH] = resolvedFilePath;
- }
- }
-
- return searchParams;
-};
-
-/**
- * Retrieves all hash parameters from the current window's URL.
- *
- * @return An object containing the hash parameters.
- */
-const getWindowUrlHashParams = () => {
- const urlHashParams: NullableProperties =
- structuredClone(URL_HASH_PARAMS_DEFAULT);
- const hashParams = new URLSearchParams(window.location.hash.substring(1));
-
- const logEventNum = hashParams.get(HASH_PARAM_NAMES.LOG_EVENT_NUM);
- if (null !== logEventNum) {
- const parsed = Number(logEventNum);
- urlHashParams[HASH_PARAM_NAMES.LOG_EVENT_NUM] = Number.isNaN(parsed) ?
- null :
- parsed;
- }
-
- const queryString = hashParams.get(HASH_PARAM_NAMES.QUERY_STRING);
- if (null !== queryString) {
- urlHashParams[HASH_PARAM_NAMES.QUERY_STRING] = queryString;
- }
-
- const queryIsCaseSensitive = hashParams.get(HASH_PARAM_NAMES.QUERY_IS_CASE_SENSITIVE);
- if (null !== queryIsCaseSensitive) {
- urlHashParams[HASH_PARAM_NAMES.QUERY_IS_CASE_SENSITIVE] =
- "true" === queryIsCaseSensitive;
- }
-
- const queryIsRegex = hashParams.get(HASH_PARAM_NAMES.QUERY_IS_REGEX);
- if (null !== queryIsRegex) {
- urlHashParams[HASH_PARAM_NAMES.QUERY_IS_REGEX] = "true" === queryIsRegex;
- }
-
- const isPrettified = hashParams.get(HASH_PARAM_NAMES.IS_PRETTIFIED);
- if (null !== isPrettified) {
- urlHashParams[HASH_PARAM_NAMES.IS_PRETTIFIED] = "true" === isPrettified;
- }
-
- return urlHashParams;
-};
-
-const searchParams = getWindowUrlSearchParams();
-
-interface UrlContextProviderProps {
- children: React.ReactNode;
-}
-
-/**
- * Provides a context for managing URL search and hash parameters including utilities for setting
- * each, and copying the current URL with these parameters to the clipboard.
- *
- * @param props
- * @param props.children The child components that will have access to the context.
- * @return
- */
-const UrlContextProvider = ({children}: UrlContextProviderProps) => {
- const [urlParams, setUrlParams] = useState({
- ...URL_SEARCH_PARAMS_DEFAULT,
- ...URL_HASH_PARAMS_DEFAULT,
- ...searchParams,
- ...getWindowUrlHashParams(),
- });
-
- useEffect(() => {
- const handleHashChange = () => {
- setUrlParams({
- ...URL_SEARCH_PARAMS_DEFAULT,
- ...URL_HASH_PARAMS_DEFAULT,
- ...searchParams,
- ...getWindowUrlHashParams(),
- });
- };
-
- window.addEventListener("hashchange", handleHashChange);
-
- return () => {
- window.removeEventListener("hashchange", handleHashChange);
- };
- }, []);
-
- return (
-
- {children}
-
- );
-};
-
-export default UrlContextProvider;
-export {
- copyPermalinkToClipboard,
- updateWindowUrlHashParams,
- updateWindowUrlSearchParams,
- URL_HASH_PARAMS_DEFAULT,
- URL_SEARCH_PARAMS_DEFAULT,
- UrlContext,
-};
diff --git a/src/stores/contextStore.ts b/src/stores/contextStore.ts
deleted file mode 100644
index 0f409bcc5..000000000
--- a/src/stores/contextStore.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import {create} from "zustand";
-
-
-interface ContextValues {
- logEventNum: number;
-}
-
-interface ContextActions {
- setLogEventNum: (newLogEventNum: number) => void;
-}
-
-type ContextState = ContextValues & ContextActions;
-
-const CONTEXT_STORE_DEFAULT: ContextValues = {
- logEventNum: 0,
-};
-
-const useContextStore = create((set) => ({
- ...CONTEXT_STORE_DEFAULT,
- setLogEventNum: (newLogEventNum) => {
- set({logEventNum: newLogEventNum});
- },
-}));
-
-export default useContextStore;
-export {CONTEXT_STORE_DEFAULT};
diff --git a/src/stores/logFileStore.ts b/src/stores/logFileStore.ts
index f46bcda46..a9d98db37 100644
--- a/src/stores/logFileStore.ts
+++ b/src/stores/logFileStore.ts
@@ -1,7 +1,7 @@
+/* eslint max-lines-per-function: ["error", 70] */
import * as Comlink from "comlink";
import {create} from "zustand";
-import {updateWindowUrlSearchParams} from "../contexts/UrlContextProvider";
import {FILE_TYPE} from "../services/LogFileManager";
import {Nullable} from "../typings/common";
import {CONFIG_KEY} from "../typings/config";
@@ -21,6 +21,7 @@ import {
FileSrcType,
} from "../typings/worker";
import {getConfig} from "../utils/config";
+import {updateWindowUrlSearchParams} from "../utils/url";
import useLogExportStore, {LOG_EXPORT_STORE_DEFAULT} from "./logExportStore";
import useLogFileManagerProxyStore from "./logFileManagerProxyStore";
import useNotificationStore, {handleErrorWithNotification} from "./notificationStore";
@@ -109,9 +110,6 @@ const useLogFileStore = create((set, get) => ({
const {setExportProgress} = useLogExportStore.getState();
setExportProgress(LOG_EXPORT_STORE_DEFAULT.exportProgress);
- const {clearQuery} = useQueryStore.getState();
- clearQuery();
-
const {setLogData} = useViewStore.getState();
setLogData("Loading...");
@@ -139,6 +137,8 @@ const useLogFileStore = create((set, get) => ({
const pageData = await logFileManagerProxy.loadPage(cursor, isPrettified);
updatePageData(pageData);
+ const {startQuery} = useQueryStore.getState();
+ startQuery();
const canFormat = fileInfo.fileType === FILE_TYPE.CLP_KV_IR ||
fileInfo.fileType === FILE_TYPE.JSONL;
diff --git a/src/stores/viewStore.ts b/src/stores/viewStore.ts
index 99ade85a2..a3f4e2f4b 100644
--- a/src/stores/viewStore.ts
+++ b/src/stores/viewStore.ts
@@ -1,6 +1,5 @@
import {create} from "zustand";
-import {updateWindowUrlHashParams} from "../contexts/UrlContextProvider";
import {Nullable} from "../typings/common";
import {LogLevelFilter} from "../typings/logs";
import {UI_STATE} from "../typings/states";
@@ -16,7 +15,7 @@ import {
NavigationAction,
} from "../utils/actions";
import {clamp} from "../utils/math";
-import useContextStore, {CONTEXT_STORE_DEFAULT} from "./contextStore";
+import {updateWindowUrlHashParams} from "../utils/url";
import useLogFileManagerStore from "./logFileManagerProxyStore";
import useLogFileStore from "./logFileStore";
import {handleErrorWithNotification} from "./notificationStore";
@@ -28,6 +27,7 @@ interface ViewStoreValues {
beginLineNumToLogEventNum: BeginLineNumToLogEventNumMap;
isPrettified: boolean;
logData: string;
+ logEventNum: number;
numPages: number;
pageNum: number;
}
@@ -35,6 +35,7 @@ interface ViewStoreValues {
interface ViewStoreActions {
setBeginLineNumToLogEventNum: (newMap: BeginLineNumToLogEventNumMap) => void;
setLogData: (newLogData: string) => void;
+ setLogEventNum: (newLogEventNum: number) => void;
setNumPages: (newNumPages: number) => void;
setPageNum: (newPageNum: number) => void;
filterLogs: (filter: LogLevelFilter) => void;
@@ -50,6 +51,7 @@ const VIEW_STORE_DEFAULT: ViewStoreValues = {
beginLineNumToLogEventNum: new Map(),
isPrettified: false,
logData: "No file is open.",
+ logEventNum: 0,
numPages: 0,
pageNum: 0,
};
@@ -109,13 +111,9 @@ const useViewStore = create((set, get) => ({
const {setUiState} = useUiStore.getState();
setUiState(UI_STATE.FAST_LOADING);
- const {clearQuery} = useQueryStore.getState();
- clearQuery();
-
(async () => {
const {logFileManagerProxy} = useLogFileManagerStore.getState();
- const {logEventNum} = useContextStore.getState();
- const {isPrettified} = get();
+ const {isPrettified, logEventNum} = get();
const pageData = await logFileManagerProxy.setFilter(
{
code: CURSOR_CODE.EVENT_NUM,
@@ -137,8 +135,8 @@ const useViewStore = create((set, get) => ({
loadPageByAction: (navAction: NavigationAction) => {
if (navAction.code === ACTION_NAME.RELOAD) {
const {fileSrc, loadFile} = useLogFileStore.getState();
- const {logEventNum} = useContextStore.getState();
- if (null === fileSrc || CONTEXT_STORE_DEFAULT.logEventNum === logEventNum) {
+ const {logEventNum} = get();
+ if (null === fileSrc || VIEW_STORE_DEFAULT.logEventNum === logEventNum) {
throw new Error(
`Unexpected fileSrc=${JSON.stringify(
fileSrc
@@ -184,6 +182,9 @@ const useViewStore = create((set, get) => ({
setLogData: (newLogData) => {
set({logData: newLogData});
},
+ setLogEventNum: (newLogEventNum) => {
+ set({logEventNum: newLogEventNum});
+ },
setNumPages: (newNumPages) => {
set({numPages: newNumPages});
},
@@ -201,9 +202,9 @@ const useViewStore = create((set, get) => ({
set({isPrettified: newIsPrettified});
- const {logEventNum} = useContextStore.getState();
+ const {logEventNum} = get();
let cursor: CursorType = {code: CURSOR_CODE.LAST_EVENT, args: null};
- if (CONTEXT_STORE_DEFAULT.logEventNum !== logEventNum) {
+ if (VIEW_STORE_DEFAULT.logEventNum !== logEventNum) {
cursor = {
code: CURSOR_CODE.EVENT_NUM,
args: {eventNum: logEventNum},
@@ -225,9 +226,12 @@ const useViewStore = create((set, get) => ({
pageNum: pageData.pageNum,
beginLineNumToLogEventNum: pageData.beginLineNumToLogEventNum,
});
+ const newLogEventNum = pageData.logEventNum;
updateWindowUrlHashParams({
- logEventNum: pageData.logEventNum,
+ logEventNum: newLogEventNum,
});
+ const {setLogEventNum} = get();
+ setLogEventNum(newLogEventNum);
const {setUiState} = useUiStore.getState();
setUiState(UI_STATE.READY);
},
diff --git a/src/typings/common.ts b/src/typings/common.ts
index ea386a4ed..99b65ff85 100644
--- a/src/typings/common.ts
+++ b/src/typings/common.ts
@@ -1,13 +1,8 @@
type Nullable = T | null;
-type NullableProperties = {
- [P in keyof T]: Nullable;
-};
-
type WithId = T & {id: number};
export type {
Nullable,
- NullableProperties,
WithId,
};
diff --git a/src/typings/url.ts b/src/typings/url.ts
index 210b23fe3..d8f5e4903 100644
--- a/src/typings/url.ts
+++ b/src/typings/url.ts
@@ -33,12 +33,6 @@ type UrlHashParamUpdatesType = {
[T in keyof UrlHashParams]?: Nullable;
};
-type UrlParamsType = {
- [T in keyof UrlSearchParams]: Nullable;
-} & {
- [T in keyof UrlHashParams]: Nullable;
-};
-
export {
HASH_PARAM_NAMES,
SEARCH_PARAM_NAMES,
@@ -46,7 +40,6 @@ export {
export type {
UrlHashParams,
UrlHashParamUpdatesType,
- UrlParamsType,
UrlSearchParams,
UrlSearchParamUpdatesType,
};
diff --git a/src/typings/worker.ts b/src/typings/worker.ts
index 07dba509a..aaae6df36 100644
--- a/src/typings/worker.ts
+++ b/src/typings/worker.ts
@@ -1,5 +1,4 @@
import {FILE_TYPE} from "../services/LogFileManager";
-import {Nullable} from "./common";
import {ActiveLogCollectionEventIdx} from "./decoders";
@@ -78,7 +77,7 @@ type LogFileInfo = {
type PageData = {
beginLineNumToLogEventNum: BeginLineNumToLogEventNumMap;
cursorLineNum: number;
- logEventNum: Nullable;
+ logEventNum: number;
logs: string;
numPages: number;
pageNum: number;
@@ -90,7 +89,7 @@ type PageData = {
const EMPTY_PAGE_RESP: PageData = Object.freeze({
beginLineNumToLogEventNum: new Map(),
cursorLineNum: 1,
- logEventNum: null,
+ logEventNum: 0,
logs: "",
numPages: 1,
pageNum: 1,
diff --git a/src/utils/url.ts b/src/utils/url.ts
index afd439632..5ae3ea68d 100644
--- a/src/utils/url.ts
+++ b/src/utils/url.ts
@@ -1,3 +1,38 @@
+/* eslint max-lines: ["error", 350] */
+import {
+ HASH_PARAM_NAMES,
+ SEARCH_PARAM_NAMES,
+ UrlHashParams,
+ UrlHashParamUpdatesType,
+ UrlSearchParams,
+ UrlSearchParamUpdatesType,
+} from "../typings/url";
+
+
+/**
+ * Default values of the search parameters.
+ */
+const URL_SEARCH_PARAMS_DEFAULT = Object.freeze({
+ [SEARCH_PARAM_NAMES.FILE_PATH]: "",
+});
+
+/**
+ * Default values of the hash parameters.
+ */
+const URL_HASH_PARAMS_DEFAULT = Object.freeze({
+ [HASH_PARAM_NAMES.IS_PRETTIFIED]: false,
+ [HASH_PARAM_NAMES.LOG_EVENT_NUM]: 0,
+ [HASH_PARAM_NAMES.QUERY_IS_CASE_SENSITIVE]: false,
+ [HASH_PARAM_NAMES.QUERY_IS_REGEX]: false,
+ [HASH_PARAM_NAMES.QUERY_STRING]: "",
+});
+
+/**
+ * Regular expression pattern for identifying ambiguous characters in a URL.
+ */
+const AMBIGUOUS_URL_CHARS_REGEX =
+ new RegExp(`${encodeURIComponent("#")}|${encodeURIComponent("&")}`);
+
/**
* Gets an absolute URL composed of a given path relative to the
* window.location, if the given path is a relative reference; otherwise
@@ -18,6 +53,207 @@ const getAbsoluteUrl = (path: string) => {
return path;
};
+/**
+ * Checks if a value is empty or falsy.
+ *
+ * @param value
+ * @return `true` if the value is empty or falsy, otherwise `false`.
+ */
+const isEmptyOrFalsy = (value: unknown): boolean => (
+ null === value ||
+ false === value ||
+ ("string" === typeof value && 0 === value.length)
+);
+
+/**
+ * Parses the URL search parameters from the current window's URL.
+ *
+ * @return An object containing the parsed search parameters.
+ */
+const parseWindowUrlSearchParams = () : Partial => {
+ const parsedSearchParams : Partial = {};
+ const searchParams = new URLSearchParams(window.location.search.substring(1));
+
+ searchParams.forEach((value, key) => {
+ parsedSearchParams[key as keyof UrlSearchParams] = value;
+ });
+
+ if (searchParams.has(SEARCH_PARAM_NAMES.FILE_PATH)) {
+ // Extract filePath value by finding the parameter and taking everything after it
+ const filePathIndex = window.location.search.indexOf("filePath=");
+ if (-1 !== filePathIndex) {
+ const filePath = window.location.search.substring(filePathIndex + "filePath=".length);
+ if (0 !== filePath.length) {
+ let resolvedFilePath = filePath;
+ try {
+ resolvedFilePath = getAbsoluteUrl(filePath);
+ } catch (e) {
+ console.error("Unable to get absolute URL from filePath:", e);
+ }
+ parsedSearchParams[SEARCH_PARAM_NAMES.FILE_PATH] = resolvedFilePath;
+ }
+ }
+ }
+
+ return parsedSearchParams;
+};
+
+/**
+ * Computes updated URL search parameters based on the provided key-value pairs.
+ *
+ * @param updates An object containing key-value pairs to update the search parameters. If a value
+ * is `null`, the corresponding kv-pair will be removed from the updated search parameters.
+ * @return The updated search parameters string.
+ * @private
+ */
+const getUpdatedSearchParams = (updates: UrlSearchParamUpdatesType) => {
+ const currentSearchParams = parseWindowUrlSearchParams();
+ const newSearchParams = new URLSearchParams(currentSearchParams);
+ const {filePath: newFilePath} = updates;
+
+ for (const [key, value] of Object.entries(updates)) {
+ if (SEARCH_PARAM_NAMES.FILE_PATH as string === key) {
+ // Updates to `filePath` should be handled last.
+ continue;
+ }
+ if (isEmptyOrFalsy(value)) {
+ newSearchParams.delete(key);
+ } else {
+ newSearchParams.set(key, String(value));
+ }
+ }
+
+ // `filePath` should always be the last search parameter so that:
+ // 1. Users can specify a remote filePath (a URL) that itself contains URL parameters without
+ // encoding them. E.g. "/?filePath=https://example.com/log/?p1=v1&p2=v2"
+ // 2. Users can easily modify it in the URL
+ //
+ // NOTE: We're relying on URLSearchParams.set() and URLSearchParams.toString() to store and
+ // serialize the parameters in the order that they were set.
+ const originalFilePath = newSearchParams.get(SEARCH_PARAM_NAMES.FILE_PATH);
+ newSearchParams.delete(SEARCH_PARAM_NAMES.FILE_PATH);
+ if ("undefined" === typeof newFilePath && null !== originalFilePath) {
+ // If no change in `filePath` is specified, put the original `filePath` back.
+ newSearchParams.set(SEARCH_PARAM_NAMES.FILE_PATH, originalFilePath);
+ } else if ("undefined" !== typeof newFilePath && null !== newFilePath) {
+ // If the new `filePath` has a non-null value, set the value.
+ newSearchParams.set(SEARCH_PARAM_NAMES.FILE_PATH, newFilePath);
+ }
+
+ // If the stringified search params doesn't contain characters that would make the URL ambiguous
+ // to parse, URL-decode it so that the `filePath` remains human-readable. E.g.
+ // "filePath=https://example.com/log/?s1=1&s2=2#h1=0" would make the final URL ambiguous to
+ // parse since `filePath` itself contains URL parameters.
+ let searchString = newSearchParams.toString();
+ if (false === AMBIGUOUS_URL_CHARS_REGEX.test(searchString)) {
+ searchString = decodeURIComponent(searchString);
+ }
+
+ return searchString;
+};
+
+/**
+ * Retrieves all search parameters from the current window's URL.
+ *
+ * @return An object containing the search parameters.
+ */
+const getWindowUrlSearchParams = (): UrlSearchParams => ({
+ ...URL_SEARCH_PARAMS_DEFAULT,
+ ...parseWindowUrlSearchParams(),
+});
+
+/**
+ * Parses the URL hash parameters from the current window's URL.
+ *
+ * @return An object containing the parsed hash parameters.
+ */
+const parseWindowUrlHashParams = () : Partial => {
+ const hashParams = new URLSearchParams(window.location.hash.substring(1));
+ const parsedHashParams: Partial = {};
+
+ hashParams.forEach((value, _key) => {
+ const key = _key as HASH_PARAM_NAMES;
+ if (HASH_PARAM_NAMES.IS_PRETTIFIED === key) {
+ parsedHashParams[HASH_PARAM_NAMES.IS_PRETTIFIED] = "true" === value;
+ } else if (HASH_PARAM_NAMES.LOG_EVENT_NUM === key) {
+ const parsed = Number(value);
+ parsedHashParams[HASH_PARAM_NAMES.LOG_EVENT_NUM] = Number.isNaN(parsed) ?
+ 0 :
+ parsed;
+ } else if (HASH_PARAM_NAMES.QUERY_STRING === key) {
+ parsedHashParams[HASH_PARAM_NAMES.QUERY_STRING] = value;
+ } else if (HASH_PARAM_NAMES.QUERY_IS_CASE_SENSITIVE === key) {
+ parsedHashParams[HASH_PARAM_NAMES.QUERY_IS_CASE_SENSITIVE] = "true" === value;
+ } else {
+ // (HASH_PARAM_NAMES.QUERY_IS_REGEX === key)
+ parsedHashParams[HASH_PARAM_NAMES.QUERY_IS_REGEX] = "true" === value;
+ }
+ });
+
+ return parsedHashParams;
+};
+
+/**
+ * Computes updated URL hash parameters based on the provided key-value pairs.
+ *
+ * @param updates An object containing key-value pairs to update the hash parameters. If a key's
+ * value is `null`, the key will be removed from the updated hash parameters.
+ * @return The updated hash parameters string.
+ * @private
+ */
+const getUpdatedHashParams = (updates: UrlHashParamUpdatesType) => {
+ const currentHashParams = parseWindowUrlHashParams();
+
+ // For non-string values, URLSearchParams::new() will convert them to strings.
+ const newHashParams = new URLSearchParams(currentHashParams as Record);
+
+ for (const [key, value] of Object.entries(updates)) {
+ if (isEmptyOrFalsy(value)) {
+ newHashParams.delete(key);
+ } else {
+ newHashParams.set(key, String(value));
+ }
+ }
+
+ return newHashParams.toString();
+};
+
+/**
+ * Retrieves all hash parameters from the current window's URL.
+ *
+ * @return An object containing the hash parameters, including the default values.
+ */
+const getWindowUrlHashParams = (): UrlHashParams => ({
+ ...URL_HASH_PARAMS_DEFAULT,
+ ...parseWindowUrlHashParams(),
+});
+
+/**
+ * Copies the current window's URL to the clipboard. If any `updates` parameters are specified,
+ * the copied URL will include these modifications, but the original window's URL will not be
+ * changed.
+ *
+ * @param searchParamUpdates An object containing key-value pairs to update the search parameters.
+ * If a value is `null`, the corresponding kv-pair will be removed from the URL's search parameters.
+ * @param hashParamsUpdates An object containing key-value pairs to update the hash parameters.
+ * If a value is `null`, the corresponding kv-pair will be removed from the URL's hash parameters.
+ */
+const copyPermalinkToClipboard = (
+ searchParamUpdates: UrlSearchParamUpdatesType,
+ hashParamsUpdates: UrlHashParamUpdatesType,
+) => {
+ const newUrl = new URL(window.location.href);
+ newUrl.search = getUpdatedSearchParams(searchParamUpdates);
+ newUrl.hash = getUpdatedHashParams(hashParamsUpdates);
+ navigator.clipboard.writeText(newUrl.toString())
+ .then(() => {
+ console.log("URL copied to clipboard.");
+ })
+ .catch((error: unknown) => {
+ console.error("Failed to copy URL to clipboard:", error);
+ });
+};
+
/**
* Extracts the basename (filename) from a given string containing a URL.
*
@@ -53,8 +289,52 @@ const openInNewTab = (url: string): void => {
window.open(url, "_blank", "noopener");
};
+/**
+ * Updates hash parameters in the current window's URL with the given key-value pairs.
+ *
+ * @param updates An object containing key-value pairs to update the hash parameters. If a value is
+ * `null`, the corresponding kv-pair will be removed from the URL's hash parameters.
+ */
+const updateWindowUrlHashParams = (updates: UrlHashParamUpdatesType) => {
+ const newHash = getUpdatedHashParams(updates);
+ const currHash = window.location.hash.substring(1);
+ if (newHash === currHash) {
+ return;
+ }
+
+ const newUrl = new URL(window.location.href);
+ newUrl.hash = newHash;
+ window.history.pushState({}, "", newUrl);
+};
+
+/**
+ * Updates search parameters in the current window's URL with the given key-value pairs.
+ *
+ * @param updates An object containing key-value pairs to update the search parameters. If a value
+ * is `null`, the corresponding kv-pair will be removed from the URL's search parameters.
+ */
+const updateWindowUrlSearchParams = (updates: UrlSearchParamUpdatesType) => {
+ const newSearch = getUpdatedSearchParams(updates);
+ const currSearch = window.location.search.substring(1);
+ if (newSearch === currSearch) {
+ return;
+ }
+ const newUrl = new URL(window.location.href);
+ newUrl.search = newSearch;
+ window.history.pushState({}, "", newUrl);
+};
+
export {
+ copyPermalinkToClipboard,
getAbsoluteUrl,
getBasenameFromUrlOrDefault,
+ getWindowUrlHashParams,
+ getWindowUrlSearchParams,
openInNewTab,
+ parseWindowUrlHashParams,
+ parseWindowUrlSearchParams,
+ updateWindowUrlHashParams,
+ updateWindowUrlSearchParams,
+ URL_HASH_PARAMS_DEFAULT,
+ URL_SEARCH_PARAMS_DEFAULT,
};