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, };