From f02a1c5732250d6ee5a3073f2f81379d96c2d79f Mon Sep 17 00:00:00 2001 From: metheos Date: Sun, 15 Jun 2025 12:43:56 -0500 Subject: [PATCH 1/7] Enhance view state management by integrating UiStore for scroll position and selected event persistence addresses https://github.com/httptoolkit/httptoolkit/issues/470 and further adds selected event persistence --- src/components/view/view-event-list.tsx | 57 +- src/components/view/view-page.tsx | 30 +- src/model/ui/ui-store.ts | 930 +++++++++++++----------- 3 files changed, 570 insertions(+), 447 deletions(-) diff --git a/src/components/view/view-event-list.tsx b/src/components/view/view-event-list.tsx index 3393d425..915c84fe 100644 --- a/src/components/view/view-event-list.tsx +++ b/src/components/view/view-event-list.tsx @@ -17,6 +17,7 @@ import { RTCConnection, TlsTunnel } from '../../types'; +import { UiStore } from '../../model/ui/ui-store'; import { getSummaryColor, @@ -54,6 +55,7 @@ interface ViewEventListProps { isPaused: boolean; contextMenuBuilder: ViewEventContextMenuBuilder; + uiStore: UiStore; moveSelection: (distance: number) => void; onSelected: (event: CollectedEvent | undefined) => void; @@ -879,19 +881,37 @@ export class ViewEventList extends React.Component { if (!listWindow) return true; // This means no rows, so we are effectively at the bottom else return (listWindow.scrollTop + SCROLL_BOTTOM_MARGIN) >= (listWindow.scrollHeight - listWindow.offsetHeight); } - private wasListAtBottom = true; private updateScrolledState = () => { requestAnimationFrame(() => { // Measure async, once the scroll has actually happened this.wasListAtBottom = this.isListAtBottom(); + + // Only save scroll position after we've restored the initial state + if (this.hasRestoredInitialState) { + const listWindow = this.listBodyRef.current?.parentElement; + if (listWindow) { + this.props.uiStore.setViewScrollPosition(listWindow.scrollTop); + } + } }); } + private hasRestoredInitialState = false; componentDidMount() { - this.updateScrolledState(); + // Don't save scroll state immediately - wait until we've restored first + + // Use a more aggressive delay to ensure DOM is fully ready + setTimeout(() => { + this.restoreScrollPosition(); + + // Only start tracking scroll changes after we've restored + setTimeout(() => { + this.hasRestoredInitialState = true; + }, 100); + }, 100); } - componentDidUpdate() { + componentDidUpdate(prevProps: ViewEventListProps) { if (this.listBodyRef.current?.parentElement?.contains(document.activeElement)) { // If we previously had something here focused, and we've updated, update // the focus too, to make sure it's in the right place. @@ -901,7 +921,29 @@ export class ViewEventList extends React.Component { // If we previously were scrolled to the bottom of the list, but now we're not, // scroll there again ourselves now. if (this.wasListAtBottom && !this.isListAtBottom()) { - this.listRef.current?.scrollToItem(this.props.events.length - 1); + this.listRef.current?.scrollToItem(this.props.events.length - 1); + } else if (prevProps.selectedEvent !== this.props.selectedEvent && this.props.selectedEvent) { + // If the selected event changed and we have a selected event, scroll to it + // This handles restoring the selected event when returning to the tab + this.scrollToEvent(this.props.selectedEvent); + } else if (prevProps.filteredEvents.length !== this.props.filteredEvents.length) { + // If the filtered events changed (e.g., new events loaded), try to restore scroll position + setTimeout(() => { + this.restoreScrollPosition(); + }, 50); + } + } + + private restoreScrollPosition = () => { + // Only restore if we have a saved position + const savedPosition = this.props.uiStore.viewScrollPosition; + if (savedPosition > 0) { + const listWindow = this.listBodyRef.current?.parentElement; + if (listWindow) { // Only restore if we're not close to the current position (avoid unnecessary scrolling) + if (Math.abs(listWindow.scrollTop - savedPosition) > 10) { + listWindow.scrollTop = savedPosition; + } + } } } @@ -1005,5 +1047,12 @@ export class ViewEventList extends React.Component { } event.preventDefault(); + } // Public method to force scroll and selection restoration + public restoreViewState = () => { + if (this.props.selectedEvent) { + this.scrollToEvent(this.props.selectedEvent); + } else { + this.restoreScrollPosition(); + } } } \ No newline at end of file diff --git a/src/components/view/view-page.tsx b/src/components/view/view-page.tsx index c1618c20..8611227c 100644 --- a/src/components/view/view-page.tsx +++ b/src/components/view/view-page.tsx @@ -207,11 +207,13 @@ class ViewPage extends React.Component { filteredEventCount: [filteredEvents.length, events.length] }; } - @computed get selectedEvent() { + // First try to use the URL-based eventId, then fallback to the persisted selection + const targetEventId = this.props.eventId || this.props.uiStore.selectedEventId; + return _.find(this.props.eventsStore.events, { - id: this.props.eventId + id: targetEventId }); } @@ -240,12 +242,16 @@ class ViewPage extends React.Component { this.onBuildRuleFromExchange, this.onPrepareToResendRequest ); - componentDidMount() { // After first render, scroll to the selected event (or the end of the list) by default: requestAnimationFrame(() => { if (this.props.eventId && this.selectedEvent) { this.onScrollToCenterEvent(this.selectedEvent); + } else if (!this.props.eventId && this.props.uiStore.selectedEventId) { + // If no URL eventId but we have a persisted selection, restore it + setTimeout(() => { + this.listRef.current?.restoreViewState(); + }, 100); } else { this.onScrollToEnd(); } @@ -327,6 +333,18 @@ class ViewPage extends React.Component { }) ); } + componentWillUnmount() { + // Component is unmounting + } + + componentDidUpdate(prevProps: ViewPageProps) { + // Only clear persisted selection if we're explicitly navigating to a different event via URL + // Don't clear it when going from eventId to no eventId (which happens when clearing selection) + if (this.props.eventId && prevProps.eventId && this.props.eventId !== prevProps.eventId) { + // Clear persisted selection only when explicitly navigating between different events via URL + this.props.uiStore.setSelectedEventId(undefined); + } + } isSendAvailable() { return versionSatisfies(serverVersion.value as string, SERVER_SEND_API_SUPPORTED); @@ -447,8 +465,8 @@ class ViewPage extends React.Component { moveSelection={this.moveSelection} onSelected={this.onSelected} - contextMenuBuilder={this.contextMenuBuilder} + uiStore={this.props.uiStore} ref={this.listRef} /> @@ -488,9 +506,11 @@ class ViewPage extends React.Component { onSearchFiltersConsidered(filters: FilterSet | undefined) { this.searchFiltersUnderConsideration = filters; } - @action.bound onSelected(event: CollectedEvent | undefined) { + // Persist the selected event to UiStore for tab switching + this.props.uiStore.setSelectedEventId(event?.id); + this.props.navigate(event ? `/view/${event.id}` : '/view' diff --git a/src/model/ui/ui-store.ts b/src/model/ui/ui-store.ts index 8a40120a..ae5a6ebb 100644 --- a/src/model/ui/ui-store.ts +++ b/src/model/ui/ui-store.ts @@ -1,496 +1,550 @@ -import * as _ from 'lodash'; -import * as React from 'react'; -import { observable, action, autorun, computed, observe } from 'mobx'; - -import { Theme, ThemeName, Themes } from '../../styles'; -import { lazyObservablePromise } from '../../util/observable'; -import { persist, hydrate } from '../../util/mobx-persist/persist'; -import { unreachableCheck, UnreachableCheck } from '../../util/error'; - -import { AccountStore } from '../account/account-store'; -import { emptyFilterSet, FilterSet } from '../filters/search-filters'; -import { DesktopApi } from '../../services/desktop-api'; +import * as _ from "lodash"; +import * as React from "react"; +import { observable, action, autorun, computed, observe } from "mobx"; + +import { Theme, ThemeName, Themes } from "../../styles"; +import { lazyObservablePromise } from "../../util/observable"; +import { persist, hydrate } from "../../util/mobx-persist/persist"; +import { unreachableCheck, UnreachableCheck } from "../../util/error"; + +import { AccountStore } from "../account/account-store"; +import { emptyFilterSet, FilterSet } from "../filters/search-filters"; +import { DesktopApi } from "../../services/desktop-api"; import { - ContextMenuState, - ContextMenuItem, - ContextMenuOption, - buildNativeContextMenuItems -} from './context-menu'; -import { tryParseJson } from '../../util'; + ContextMenuState, + ContextMenuItem, + ContextMenuOption, + buildNativeContextMenuItems, +} from "./context-menu"; +import { tryParseJson } from "../../util"; const VIEW_CARD_KEYS = [ - 'api', + "api", - 'request', - 'requestBody', - 'requestTrailers', - 'response', - 'responseBody', - 'responseTrailers', + "request", + "requestBody", + "requestTrailers", + "response", + "responseBody", + "responseTrailers", - 'webSocketMessages', - 'webSocketClose', + "webSocketMessages", + "webSocketClose", - 'rtcConnection', - 'rtcSessionOffer', - 'rtcSessionAnswer', + "rtcConnection", + "rtcSessionOffer", + "rtcSessionAnswer", - 'performance', - 'export' + "performance", + "export", ] as const; -type ViewCardKey = typeof VIEW_CARD_KEYS[number]; +type ViewCardKey = (typeof VIEW_CARD_KEYS)[number]; const EXPANDABLE_VIEW_CARD_KEYS = [ - 'requestBody', - 'responseBody', - 'webSocketMessages' + "requestBody", + "responseBody", + "webSocketMessages", ] as const; -export type ExpandableViewCardKey = typeof EXPANDABLE_VIEW_CARD_KEYS[number]; +export type ExpandableViewCardKey = (typeof EXPANDABLE_VIEW_CARD_KEYS)[number]; const isExpandableViewCard = (key: any): key is ExpandableViewCardKey => - EXPANDABLE_VIEW_CARD_KEYS.includes(key); + EXPANDABLE_VIEW_CARD_KEYS.includes(key); const SEND_CARD_KEYS = [ - 'requestHeaders', - 'requestBody', - 'responseHeaders', - 'responseBody' + "requestHeaders", + "requestBody", + "responseHeaders", + "responseBody", ] as const; -type SendCardKey = typeof SEND_CARD_KEYS[number]; +type SendCardKey = (typeof SEND_CARD_KEYS)[number]; -const isSendRequestCard = (key: SendCardKey): key is 'requestHeaders' | 'requestBody' => - key.startsWith('request'); +const isSendRequestCard = ( + key: SendCardKey +): key is "requestHeaders" | "requestBody" => key.startsWith("request"); -const isSentResponseCard = (key: SendCardKey): key is 'responseHeaders' | 'responseBody' => - key.startsWith('response'); +const isSentResponseCard = ( + key: SendCardKey +): key is "responseHeaders" | "responseBody" => key.startsWith("response"); export type ContentPerspective = - | 'client' // What did the client send (original) & receive (after transform) - | 'server' // What did the server receive (after transform) & send (original)? - | 'transformed' // What was the request & resposne after both transforms? - | 'original' // What was the request & response before transforms? + | "client" // What did the client send (original) & receive (after transform) + | "server" // What did the server receive (after transform) & send (original)? + | "transformed" // What was the request & resposne after both transforms? + | "original"; // What was the request & response before transforms? const SEND_REQUEST_CARD_KEYS = SEND_CARD_KEYS.filter(isSendRequestCard); const SENT_RESPONSE_CARD_KEYS = SEND_CARD_KEYS.filter(isSentResponseCard); const EXPANDABLE_SEND_REQUEST_CARD_KEYS = [ - 'requestHeaders', - 'requestBody', + "requestHeaders", + "requestBody", ] as const; -type ExpandableSendRequestCardKey = typeof EXPANDABLE_SEND_REQUEST_CARD_KEYS[number]; +type ExpandableSendRequestCardKey = + (typeof EXPANDABLE_SEND_REQUEST_CARD_KEYS)[number]; const EXPANDABLE_SENT_RESPONSE_CARD_KEYS = [ - 'responseHeaders', - 'responseBody' + "responseHeaders", + "responseBody", ] as const; -type ExpandableSentResponseCardKey = typeof EXPANDABLE_SENT_RESPONSE_CARD_KEYS[number]; +type ExpandableSentResponseCardKey = + (typeof EXPANDABLE_SENT_RESPONSE_CARD_KEYS)[number]; -type ExpandableSendCardKey = ExpandableSendRequestCardKey | ExpandableSentResponseCardKey; +type ExpandableSendCardKey = + | ExpandableSendRequestCardKey + | ExpandableSentResponseCardKey; const isExpandableSendCard = (key: any): key is ExpandableSendCardKey => - EXPANDABLE_SEND_REQUEST_CARD_KEYS.includes(key) || - EXPANDABLE_SENT_RESPONSE_CARD_KEYS.includes(key); - -const SETTINGS_CARD_KEYS =[ - 'account', - 'proxy', - 'connection', - 'api', - 'themes' + EXPANDABLE_SEND_REQUEST_CARD_KEYS.includes(key) || + EXPANDABLE_SENT_RESPONSE_CARD_KEYS.includes(key); + +const SETTINGS_CARD_KEYS = [ + "account", + "proxy", + "connection", + "api", + "themes", ] as const; -type SettingsCardKey = typeof SETTINGS_CARD_KEYS[number]; +type SettingsCardKey = (typeof SETTINGS_CARD_KEYS)[number]; type CustomTheme = Partial & { - name: string; - extends: ThemeName; + name: string; + extends: ThemeName; }; export class UiStore { - - constructor( - private accountStore: AccountStore - ) { } - - readonly initialized = lazyObservablePromise(async () => { - await this.accountStore.initialized; - - autorun(() => { - // Any time the theme changes, update the HTML background to match - document.querySelector('html')!.style.backgroundColor = this.theme.containerBackground; - - // Persist the background colour standalone, so we can easily access it - // from the index.html loading script, whether it's custom or computed - localStorage.setItem('theme-background-color', this.theme.containerBackground); - }); - - // Every time the user account data is updated from the server, consider resetting - // paid settings to the free defaults. This ensures that they're reset on - // logout & subscription expiration (even if that happened while the app was - // closed), but don't get reset when the app starts with stale account data. - observe(this.accountStore, 'accountDataLastUpdated', () => { - if (!this.accountStore.isPaidUser) { - this.setTheme('automatic'); - } - }); - - await hydrate({ - key: 'ui-store', - store: this - }); - - const darkThemeMq = window.matchMedia("(prefers-color-scheme: dark)"); - this._setPrefersDarkTheme(darkThemeMq.matches); - darkThemeMq.addEventListener('change', e => { - this._setPrefersDarkTheme(e.matches); - }); - - console.log('UI store initialized'); + constructor(private accountStore: AccountStore) {} + + readonly initialized = lazyObservablePromise(async () => { + await this.accountStore.initialized; + + autorun(() => { + // Any time the theme changes, update the HTML background to match + document.querySelector("html")!.style.backgroundColor = + this.theme.containerBackground; + + // Persist the background colour standalone, so we can easily access it + // from the index.html loading script, whether it's custom or computed + localStorage.setItem( + "theme-background-color", + this.theme.containerBackground + ); }); - @action.bound - setTheme(themeNameOrObject: Theme | ThemeName | 'automatic') { - if (typeof themeNameOrObject === 'string') { - this._themeName = themeNameOrObject; - this.customTheme = undefined; - } else { - this._themeName = 'custom'; - this.customTheme = themeNameOrObject; - } - } - - buildCustomTheme(themeFile: string) { - const themeData: Partial | undefined = tryParseJson(themeFile); - if (!themeData) throw new Error("Could not parse theme JSON"); - - if (!themeData.name) throw new Error('Theme must contain a `name` field'); - if ( - !themeData.extends || - Themes[themeData.extends as ThemeName] === undefined - ) { - throw new Error('Theme must contain an `extends` field with a built-in theme name (dark/light/high-contrast)'); - } - - const baseTheme = Themes[themeData.extends]; - return { - ...baseTheme, - ...themeData - } as Theme; - } - - @persist @observable - private _themeName: ThemeName | 'automatic' | 'custom' = 'automatic'; - - get themeName() { - return this._themeName; - } - - /** - * Stores if user prefers a dark color theme (for example when set in system settings). - * Used if automatic theme is enabled. - */ - @observable - private _prefersDarkTheme: boolean = false; - - @action.bound - private _setPrefersDarkTheme(value: boolean) { - this._prefersDarkTheme = value; - } - - @persist('object') @observable - private customTheme: Theme | undefined = undefined; - - @computed - get theme(): Theme { - switch(this.themeName) { - case 'automatic': - return {...Themes[this._prefersDarkTheme ? 'dark' : 'light']} - case 'custom': - return this.customTheme!; - default: - return Themes[this.themeName]; - } - } - - // Set briefly at the moment any card expansion is toggled, to trigger animation for the expansion as it's - // applied, not always (to avoid animating expanded cards when they're rendered e.g. when selecting a request). - @observable - private animatedExpansionCard: string | undefined; - - /** - * For both requests & responses, there are two different ways to look at them (=4 perspectives in total). It - * depends on the use case (mostly: are you collecting data, or exploring behaviours) but this field changes which - * format is shown in the right-hand UI pane. Note that the list view always still shows the original values. - */ - @observable - contentPerspective: ContentPerspective = 'transformed'; - - // Store the view details cards state here, so that they persist - // when moving away from the page or deselecting all traffic. - @observable - private readonly viewCardStates = { - 'api': { collapsed: true }, - - 'request': { collapsed: false }, - 'requestBody': { collapsed: false }, - 'requestTrailers': { collapsed: false }, - 'response': { collapsed: false }, - 'responseBody': { collapsed: false }, - 'responseTrailers': { collapsed: false }, - - 'webSocketMessages': { collapsed: false }, - 'webSocketClose': { collapsed: false }, - - 'rtcConnection': { collapsed: false }, - 'rtcSessionOffer': { collapsed: false }, - 'rtcSessionAnswer': { collapsed: false }, - - 'performance': { collapsed: true }, - 'export': { collapsed: true } - }; - - @observable - expandedViewCard: ExpandableViewCardKey | undefined; - - @computed - get viewCardProps() { - return _.mapValues(this.viewCardStates, (state, key) => ({ - key, - ariaLabel: `${_.startCase(key)} section`, - expanded: key === this.animatedExpansionCard - ? 'starting' as const - : key === this.expandedViewCard, - collapsed: state.collapsed && key !== this.expandedViewCard, - onCollapseToggled: this.toggleViewCardCollapsed.bind(this, key as ViewCardKey), - onExpandToggled: isExpandableViewCard(key) - ? this.toggleViewCardExpanded.bind(this, key) - : _.noop - })); - } + // Every time the user account data is updated from the server, consider resetting + // paid settings to the free defaults. This ensures that they're reset on + // logout & subscription expiration (even if that happened while the app was + // closed), but don't get reset when the app starts with stale account data. + observe(this.accountStore, "accountDataLastUpdated", () => { + if (!this.accountStore.isPaidUser) { + this.setTheme("automatic"); + } + }); - @action - toggleViewCardCollapsed(key: ViewCardKey) { - const cardState = this.viewCardStates[key]; - cardState.collapsed = !cardState.collapsed; - this.expandedViewCard = undefined; - } + await hydrate({ + key: "ui-store", + store: this, + }); - @action - private toggleViewCardExpanded(key: ExpandableViewCardKey) { - if (this.expandedViewCard === key) { - this.expandedViewCard = undefined; - } else if (isExpandableViewCard(key)) { - this.viewCardStates[key].collapsed = false; - this.expandedViewCard = key; - - // Briefly set animatedExpansionCard, to trigger animation for this expansion: - this.animatedExpansionCard = key; - requestAnimationFrame(action(() => { - this.animatedExpansionCard = undefined; - })); - } - } + const darkThemeMq = window.matchMedia("(prefers-color-scheme: dark)"); + this._setPrefersDarkTheme(darkThemeMq.matches); + darkThemeMq.addEventListener("change", (e) => { + this._setPrefersDarkTheme(e.matches); + }); - // Store the send details cards state here - @observable - private readonly sendCardStates = { - 'requestHeaders': { collapsed: false }, - 'requestBody': { collapsed: false }, - 'responseHeaders': { collapsed: false }, - 'responseBody': { collapsed: false } - }; - - @observable - expandedSendRequestCard: ExpandableSendRequestCardKey | undefined; - - @observable - expandedSentResponseCard: ExpandableSentResponseCardKey | undefined; - - @computed - get sendCardProps() { - return _.mapValues(this.sendCardStates, (state, key) => { - const expandedState = key === this.expandedSendRequestCard - || key === this.expandedSentResponseCard; - - return { - key, - ariaLabel: `${_.startCase(key)} section`, - expanded: expandedState, - collapsed: state.collapsed && !expandedState, - onCollapseToggled: this.toggleSendCardCollapsed.bind(this, key as SendCardKey), - onExpandToggled: isExpandableSendCard(key) - ? this.toggleSendCardExpanded.bind(this, key) - : _.noop - }; - }); + console.log("UI store initialized"); + }); + + @action.bound + setTheme(themeNameOrObject: Theme | ThemeName | "automatic") { + if (typeof themeNameOrObject === "string") { + this._themeName = themeNameOrObject; + this.customTheme = undefined; + } else { + this._themeName = "custom"; + this.customTheme = themeNameOrObject; } - - @action - toggleSendCardCollapsed(key: SendCardKey) { - const cardState = this.sendCardStates[key]; - cardState.collapsed = !cardState.collapsed; - - const siblingCards: SendCardKey[] = isSendRequestCard(key) - ? SEND_REQUEST_CARD_KEYS - : SENT_RESPONSE_CARD_KEYS; - - // If you collapse all cards, pop open an alternative, just because it looks a bit weird - // if you don't, and it makes it easier to quickly switch like an accordion in some cases. - if (siblingCards.every((k) => this.sendCardStates[k].collapsed)) { - const keyIndex = siblingCards.indexOf(key); - const bestAlternativeCard = (keyIndex === siblingCards.length - 1) - ? siblingCards[keyIndex - 1] // For last card, look back one - : siblingCards[keyIndex + 1] // Otherwise, look at next card - - this.toggleSendCardCollapsed(bestAlternativeCard); - } - - if (isSendRequestCard(key)) { - this.expandedSendRequestCard = undefined; - } else if (isSentResponseCard(key)) { - this.expandedSentResponseCard = undefined; - } else { - throw new UnreachableCheck(key); - } + } + + buildCustomTheme(themeFile: string) { + const themeData: Partial | undefined = tryParseJson(themeFile); + if (!themeData) throw new Error("Could not parse theme JSON"); + + if (!themeData.name) throw new Error("Theme must contain a `name` field"); + if ( + !themeData.extends || + Themes[themeData.extends as ThemeName] === undefined + ) { + throw new Error( + "Theme must contain an `extends` field with a built-in theme name (dark/light/high-contrast)" + ); } - @action - private toggleSendCardExpanded(key: ExpandableSendCardKey) { - const expandedCardField = isSendRequestCard(key) - ? 'expandedSendRequestCard' - : isSentResponseCard(key) - ? 'expandedSentResponseCard' - : unreachableCheck(key); - - if (this[expandedCardField] === key) { - this[expandedCardField] = undefined; - } else if (isExpandableSendCard(key)) { - this.sendCardStates[key].collapsed = false; - this[expandedCardField] = key as any; // We ensured key matches the field already above - - // We don't bother with animatedExpansionCard - not required for Send (we just - // animate top-line margin, not expanded card padding) - } + const baseTheme = Themes[themeData.extends]; + return { + ...baseTheme, + ...themeData, + } as Theme; + } + + @persist + @observable + private _themeName: ThemeName | "automatic" | "custom" = "automatic"; + + get themeName() { + return this._themeName; + } + + /** + * Stores if user prefers a dark color theme (for example when set in system settings). + * Used if automatic theme is enabled. + */ + @observable + private _prefersDarkTheme: boolean = false; + + @action.bound + private _setPrefersDarkTheme(value: boolean) { + this._prefersDarkTheme = value; + } + + @persist("object") + @observable + private customTheme: Theme | undefined = undefined; + + @computed + get theme(): Theme { + switch (this.themeName) { + case "automatic": + return { ...Themes[this._prefersDarkTheme ? "dark" : "light"] }; + case "custom": + return this.customTheme!; + default: + return Themes[this.themeName]; } - - @observable - private settingsCardStates = { - 'account': { collapsed: false }, - 'proxy': { collapsed: false }, - 'connection': { collapsed: false }, - 'api': { collapsed: false }, - 'themes': { collapsed: false } - }; - - @computed - get settingsCardProps() { - return _.mapValues(this.settingsCardStates, (state, key) => ({ - key, - ariaLabel: `${_.startCase(key)} section`, - collapsed: state.collapsed, - onCollapseToggled: this.toggleSettingsCardCollapsed.bind(this, key as SettingsCardKey) - })); + } + + // Set briefly at the moment any card expansion is toggled, to trigger animation for the expansion as it's + // applied, not always (to avoid animating expanded cards when they're rendered e.g. when selecting a request). + @observable + private animatedExpansionCard: string | undefined; + + /** + * For both requests & responses, there are two different ways to look at them (=4 perspectives in total). It + * depends on the use case (mostly: are you collecting data, or exploring behaviours) but this field changes which + * format is shown in the right-hand UI pane. Note that the list view always still shows the original values. + */ + @observable + contentPerspective: ContentPerspective = "transformed"; + + // Store the view details cards state here, so that they persist + // when moving away from the page or deselecting all traffic. + @observable + private readonly viewCardStates = { + api: { collapsed: true }, + + request: { collapsed: false }, + requestBody: { collapsed: false }, + requestTrailers: { collapsed: false }, + response: { collapsed: false }, + responseBody: { collapsed: false }, + responseTrailers: { collapsed: false }, + + webSocketMessages: { collapsed: false }, + webSocketClose: { collapsed: false }, + + rtcConnection: { collapsed: false }, + rtcSessionOffer: { collapsed: false }, + rtcSessionAnswer: { collapsed: false }, + + performance: { collapsed: true }, + export: { collapsed: true }, + }; + + @observable + expandedViewCard: ExpandableViewCardKey | undefined; + + // Store view list scroll position and selected entry to persist when switching tabs + @observable + viewScrollPosition: number = 0; + + @observable + selectedEventId: string | undefined; + + @computed + get viewCardProps() { + return _.mapValues(this.viewCardStates, (state, key) => ({ + key, + ariaLabel: `${_.startCase(key)} section`, + expanded: + key === this.animatedExpansionCard + ? ("starting" as const) + : key === this.expandedViewCard, + collapsed: state.collapsed && key !== this.expandedViewCard, + onCollapseToggled: this.toggleViewCardCollapsed.bind( + this, + key as ViewCardKey + ), + onExpandToggled: isExpandableViewCard(key) + ? this.toggleViewCardExpanded.bind(this, key) + : _.noop, + })); + } + + @action + toggleViewCardCollapsed(key: ViewCardKey) { + const cardState = this.viewCardStates[key]; + cardState.collapsed = !cardState.collapsed; + this.expandedViewCard = undefined; + } + + @action + private toggleViewCardExpanded(key: ExpandableViewCardKey) { + if (this.expandedViewCard === key) { + this.expandedViewCard = undefined; + } else if (isExpandableViewCard(key)) { + this.viewCardStates[key].collapsed = false; + this.expandedViewCard = key; + + // Briefly set animatedExpansionCard, to trigger animation for this expansion: + this.animatedExpansionCard = key; + requestAnimationFrame( + action(() => { + this.animatedExpansionCard = undefined; + }) + ); } - - @action - toggleSettingsCardCollapsed(key: SettingsCardKey) { - const cardState = this.settingsCardStates[key]; - cardState.collapsed = !cardState.collapsed; + } + + // Store the send details cards state here + @observable + private readonly sendCardStates = { + requestHeaders: { collapsed: false }, + requestBody: { collapsed: false }, + responseHeaders: { collapsed: false }, + responseBody: { collapsed: false }, + }; + + @observable + expandedSendRequestCard: ExpandableSendRequestCardKey | undefined; + + @observable + expandedSentResponseCard: ExpandableSentResponseCardKey | undefined; + + @computed + get sendCardProps() { + return _.mapValues(this.sendCardStates, (state, key) => { + const expandedState = + key === this.expandedSendRequestCard || + key === this.expandedSentResponseCard; + + return { + key, + ariaLabel: `${_.startCase(key)} section`, + expanded: expandedState, + collapsed: state.collapsed && !expandedState, + onCollapseToggled: this.toggleSendCardCollapsed.bind( + this, + key as SendCardKey + ), + onExpandToggled: isExpandableSendCard(key) + ? this.toggleSendCardExpanded.bind(this, key) + : _.noop, + }; + }); + } + + @action + toggleSendCardCollapsed(key: SendCardKey) { + const cardState = this.sendCardStates[key]; + cardState.collapsed = !cardState.collapsed; + + const siblingCards: SendCardKey[] = isSendRequestCard(key) + ? SEND_REQUEST_CARD_KEYS + : SENT_RESPONSE_CARD_KEYS; + + // If you collapse all cards, pop open an alternative, just because it looks a bit weird + // if you don't, and it makes it easier to quickly switch like an accordion in some cases. + if (siblingCards.every((k) => this.sendCardStates[k].collapsed)) { + const keyIndex = siblingCards.indexOf(key); + const bestAlternativeCard = + keyIndex === siblingCards.length - 1 + ? siblingCards[keyIndex - 1] // For last card, look back one + : siblingCards[keyIndex + 1]; // Otherwise, look at next card + + this.toggleSendCardCollapsed(bestAlternativeCard); } - @action.bound - rememberElectronPath(path: string) { - if (!this.previousElectronAppPaths.includes(path)) { - this.previousElectronAppPaths.unshift(path); - } - - // Keep only the most recent 3 electron paths used - this.previousElectronAppPaths = this.previousElectronAppPaths.slice(0, 3); + if (isSendRequestCard(key)) { + this.expandedSendRequestCard = undefined; + } else if (isSentResponseCard(key)) { + this.expandedSentResponseCard = undefined; + } else { + throw new UnreachableCheck(key); } - - @action.bound - forgetElectronPath(path: string) { - this.previousElectronAppPaths = this.previousElectronAppPaths.filter(p => p != path); + } + + @action + private toggleSendCardExpanded(key: ExpandableSendCardKey) { + const expandedCardField = isSendRequestCard(key) + ? "expandedSendRequestCard" + : isSentResponseCard(key) + ? "expandedSentResponseCard" + : unreachableCheck(key); + + if (this[expandedCardField] === key) { + this[expandedCardField] = undefined; + } else if (isExpandableSendCard(key)) { + this.sendCardStates[key].collapsed = false; + this[expandedCardField] = key as any; // We ensured key matches the field already above + + // We don't bother with animatedExpansionCard - not required for Send (we just + // animate top-line margin, not expanded card padding) } - - @persist('list') @observable - previousElectronAppPaths: string[] = []; - - @observable - activeFilterSet: FilterSet = emptyFilterSet(); - - @persist('object') @observable - _customFilters: { [name: string]: string } = {}; - - @computed - get customFilters() { - if (this.accountStore.isPaidUser) { - return this._customFilters; - } else { - return {}; - } + } + + @observable + private settingsCardStates = { + account: { collapsed: false }, + proxy: { collapsed: false }, + connection: { collapsed: false }, + api: { collapsed: false }, + themes: { collapsed: false }, + }; + + @computed + get settingsCardProps() { + return _.mapValues(this.settingsCardStates, (state, key) => ({ + key, + ariaLabel: `${_.startCase(key)} section`, + collapsed: state.collapsed, + onCollapseToggled: this.toggleSettingsCardCollapsed.bind( + this, + key as SettingsCardKey + ), + })); + } + + @action + toggleSettingsCardCollapsed(key: SettingsCardKey) { + const cardState = this.settingsCardStates[key]; + cardState.collapsed = !cardState.collapsed; + } + + @action.bound + rememberElectronPath(path: string) { + if (!this.previousElectronAppPaths.includes(path)) { + this.previousElectronAppPaths.unshift(path); } - @persist @observable - preferredShell: string | undefined = 'Bash'; - - @persist @observable - exportSnippetFormat: string | undefined; - - /** - * This tracks the context menu state *only if it's not handled natively*. This state - * is rendered by React as a fallback when DesktopApi.openContextMenu is not available. - */ - @observable.ref // This shouldn't be mutated - contextMenuState: ContextMenuState | undefined; - - handleContextMenuEvent( - event: React.MouseEvent, - items: readonly ContextMenuItem[], - data: T - ): void; - handleContextMenuEvent( - event: React.MouseEvent, - items: readonly ContextMenuItem[] - ): void; - @action.bound - handleContextMenuEvent( - event: React.MouseEvent, - items: readonly ContextMenuItem[], - data?: T - ): void { - if (!items.length) return; - - event.preventDefault(); - - if (DesktopApi.openContextMenu) { - const position = { x: event.pageX, y: event.pageY }; - this.contextMenuState = undefined; // Should be set already, but let's be explicit - - DesktopApi.openContextMenu({ - position, - items: buildNativeContextMenuItems(items) - }).then((result) => { - if (result) { - const selectedItem = _.get(items, result) as ContextMenuOption; - selectedItem.callback(data!); - } - }).catch((error) => { - console.log(error); - throw new Error('Error opening context menu'); - }); - } else { - event.persist(); - this.contextMenuState = { - data, - event, - items - }; - } + // Keep only the most recent 3 electron paths used + this.previousElectronAppPaths = this.previousElectronAppPaths.slice(0, 3); + } + + @action.bound + forgetElectronPath(path: string) { + this.previousElectronAppPaths = this.previousElectronAppPaths.filter( + (p) => p != path + ); + } + + @persist("list") + @observable + previousElectronAppPaths: string[] = []; + + @observable + activeFilterSet: FilterSet = emptyFilterSet(); + + @persist("object") + @observable + _customFilters: { [name: string]: string } = {}; + + @computed + get customFilters() { + if (this.accountStore.isPaidUser) { + return this._customFilters; + } else { + return {}; } - - @action.bound - clearHtmlContextMenu() { - this.contextMenuState = undefined; + } + + @persist + @observable + preferredShell: string | undefined = "Bash"; + + @persist + @observable + exportSnippetFormat: string | undefined; + + @observable + searchFilters: FilterSet = emptyFilterSet(); + + @action.bound + setSearchFiltersValue(value: FilterSet) { + this.searchFilters = value; + } + + // Actions for persisting view state when switching tabs + @action.bound + setViewScrollPosition(position: number) { + this.viewScrollPosition = position; + } + + @action.bound + setSelectedEventId(eventId: string | undefined) { + this.selectedEventId = eventId; + } + + @observable.ref // This shouldn't be mutated + contextMenuState: ContextMenuState | undefined; + + handleContextMenuEvent( + event: React.MouseEvent, + items: readonly ContextMenuItem[], + data: T + ): void; + handleContextMenuEvent( + event: React.MouseEvent, + items: readonly ContextMenuItem[] + ): void; + @action.bound + handleContextMenuEvent( + event: React.MouseEvent, + items: readonly ContextMenuItem[], + data?: T + ): void { + if (!items.length) return; + + event.preventDefault(); + + if (DesktopApi.openContextMenu) { + const position = { x: event.pageX, y: event.pageY }; + this.contextMenuState = undefined; // Should be set already, but let's be explicit + + DesktopApi.openContextMenu({ + position, + items: buildNativeContextMenuItems(items), + }) + .then((result) => { + if (result) { + const selectedItem = _.get(items, result) as ContextMenuOption; + selectedItem.callback(data!); + } + }) + .catch((error) => { + console.log(error); + throw new Error("Error opening context menu"); + }); + } else { + event.persist(); + this.contextMenuState = { + data, + event, + items, + }; } + } + @action.bound + clearHtmlContextMenu() { + this.contextMenuState = undefined; + } } From 661a7ba67d0db927cfd672f8478356a68ce5e962 Mon Sep 17 00:00:00 2001 From: Metheos Date: Thu, 19 Jun 2025 19:52:40 -0500 Subject: [PATCH 2/7] revert formatter changes --- src/model/ui/ui-store.ts | 948 +++++++++++++++++++-------------------- 1 file changed, 456 insertions(+), 492 deletions(-) diff --git a/src/model/ui/ui-store.ts b/src/model/ui/ui-store.ts index ae5a6ebb..f9bc00b5 100644 --- a/src/model/ui/ui-store.ts +++ b/src/model/ui/ui-store.ts @@ -1,550 +1,514 @@ -import * as _ from "lodash"; -import * as React from "react"; -import { observable, action, autorun, computed, observe } from "mobx"; - -import { Theme, ThemeName, Themes } from "../../styles"; -import { lazyObservablePromise } from "../../util/observable"; -import { persist, hydrate } from "../../util/mobx-persist/persist"; -import { unreachableCheck, UnreachableCheck } from "../../util/error"; - -import { AccountStore } from "../account/account-store"; -import { emptyFilterSet, FilterSet } from "../filters/search-filters"; -import { DesktopApi } from "../../services/desktop-api"; +import * as _ from 'lodash'; +import * as React from 'react'; +import { observable, action, autorun, computed, observe } from 'mobx'; + +import { Theme, ThemeName, Themes } from '../../styles'; +import { lazyObservablePromise } from '../../util/observable'; +import { persist, hydrate } from '../../util/mobx-persist/persist'; +import { unreachableCheck, UnreachableCheck } from '../../util/error'; + +import { AccountStore } from '../account/account-store'; +import { emptyFilterSet, FilterSet } from '../filters/search-filters'; +import { DesktopApi } from '../../services/desktop-api'; import { - ContextMenuState, - ContextMenuItem, - ContextMenuOption, - buildNativeContextMenuItems, -} from "./context-menu"; -import { tryParseJson } from "../../util"; + ContextMenuState, + ContextMenuItem, + ContextMenuOption, + buildNativeContextMenuItems +} from './context-menu'; +import { tryParseJson } from '../../util'; const VIEW_CARD_KEYS = [ - "api", + 'api', - "request", - "requestBody", - "requestTrailers", - "response", - "responseBody", - "responseTrailers", + 'request', + 'requestBody', + 'requestTrailers', + 'response', + 'responseBody', + 'responseTrailers', - "webSocketMessages", - "webSocketClose", + 'webSocketMessages', + 'webSocketClose', - "rtcConnection", - "rtcSessionOffer", - "rtcSessionAnswer", + 'rtcConnection', + 'rtcSessionOffer', + 'rtcSessionAnswer', - "performance", - "export", + 'performance', + 'export' ] as const; -type ViewCardKey = (typeof VIEW_CARD_KEYS)[number]; +type ViewCardKey = typeof VIEW_CARD_KEYS[number]; const EXPANDABLE_VIEW_CARD_KEYS = [ - "requestBody", - "responseBody", - "webSocketMessages", + 'requestBody', + 'responseBody', + 'webSocketMessages' ] as const; -export type ExpandableViewCardKey = (typeof EXPANDABLE_VIEW_CARD_KEYS)[number]; +export type ExpandableViewCardKey = typeof EXPANDABLE_VIEW_CARD_KEYS[number]; const isExpandableViewCard = (key: any): key is ExpandableViewCardKey => - EXPANDABLE_VIEW_CARD_KEYS.includes(key); + EXPANDABLE_VIEW_CARD_KEYS.includes(key); const SEND_CARD_KEYS = [ - "requestHeaders", - "requestBody", - "responseHeaders", - "responseBody", + 'requestHeaders', + 'requestBody', + 'responseHeaders', + 'responseBody' ] as const; -type SendCardKey = (typeof SEND_CARD_KEYS)[number]; +type SendCardKey = typeof SEND_CARD_KEYS[number]; -const isSendRequestCard = ( - key: SendCardKey -): key is "requestHeaders" | "requestBody" => key.startsWith("request"); +const isSendRequestCard = (key: SendCardKey): key is 'requestHeaders' | 'requestBody' => + key.startsWith('request'); -const isSentResponseCard = ( - key: SendCardKey -): key is "responseHeaders" | "responseBody" => key.startsWith("response"); +const isSentResponseCard = (key: SendCardKey): key is 'responseHeaders' | 'responseBody' => + key.startsWith('response'); export type ContentPerspective = - | "client" // What did the client send (original) & receive (after transform) - | "server" // What did the server receive (after transform) & send (original)? - | "transformed" // What was the request & resposne after both transforms? - | "original"; // What was the request & response before transforms? + | 'client' // What did the client send (original) & receive (after transform) + | 'server' // What did the server receive (after transform) & send (original)? + | 'transformed' // What was the request & resposne after both transforms? + | 'original' // What was the request & response before transforms? const SEND_REQUEST_CARD_KEYS = SEND_CARD_KEYS.filter(isSendRequestCard); const SENT_RESPONSE_CARD_KEYS = SEND_CARD_KEYS.filter(isSentResponseCard); const EXPANDABLE_SEND_REQUEST_CARD_KEYS = [ - "requestHeaders", - "requestBody", + 'requestHeaders', + 'requestBody', ] as const; -type ExpandableSendRequestCardKey = - (typeof EXPANDABLE_SEND_REQUEST_CARD_KEYS)[number]; +type ExpandableSendRequestCardKey = typeof EXPANDABLE_SEND_REQUEST_CARD_KEYS[number]; const EXPANDABLE_SENT_RESPONSE_CARD_KEYS = [ - "responseHeaders", - "responseBody", + 'responseHeaders', + 'responseBody' ] as const; -type ExpandableSentResponseCardKey = - (typeof EXPANDABLE_SENT_RESPONSE_CARD_KEYS)[number]; +type ExpandableSentResponseCardKey = typeof EXPANDABLE_SENT_RESPONSE_CARD_KEYS[number]; -type ExpandableSendCardKey = - | ExpandableSendRequestCardKey - | ExpandableSentResponseCardKey; +type ExpandableSendCardKey = ExpandableSendRequestCardKey | ExpandableSentResponseCardKey; const isExpandableSendCard = (key: any): key is ExpandableSendCardKey => - EXPANDABLE_SEND_REQUEST_CARD_KEYS.includes(key) || - EXPANDABLE_SENT_RESPONSE_CARD_KEYS.includes(key); - -const SETTINGS_CARD_KEYS = [ - "account", - "proxy", - "connection", - "api", - "themes", + EXPANDABLE_SEND_REQUEST_CARD_KEYS.includes(key) || + EXPANDABLE_SENT_RESPONSE_CARD_KEYS.includes(key); + +const SETTINGS_CARD_KEYS =[ + 'account', + 'proxy', + 'connection', + 'api', + 'themes' ] as const; -type SettingsCardKey = (typeof SETTINGS_CARD_KEYS)[number]; +type SettingsCardKey = typeof SETTINGS_CARD_KEYS[number]; type CustomTheme = Partial & { - name: string; - extends: ThemeName; + name: string; + extends: ThemeName; }; export class UiStore { - constructor(private accountStore: AccountStore) {} - - readonly initialized = lazyObservablePromise(async () => { - await this.accountStore.initialized; - - autorun(() => { - // Any time the theme changes, update the HTML background to match - document.querySelector("html")!.style.backgroundColor = - this.theme.containerBackground; - - // Persist the background colour standalone, so we can easily access it - // from the index.html loading script, whether it's custom or computed - localStorage.setItem( - "theme-background-color", - this.theme.containerBackground - ); - }); - // Every time the user account data is updated from the server, consider resetting - // paid settings to the free defaults. This ensures that they're reset on - // logout & subscription expiration (even if that happened while the app was - // closed), but don't get reset when the app starts with stale account data. - observe(this.accountStore, "accountDataLastUpdated", () => { - if (!this.accountStore.isPaidUser) { - this.setTheme("automatic"); - } - }); + constructor( + private accountStore: AccountStore + ) { } - await hydrate({ - key: "ui-store", - store: this, - }); + readonly initialized = lazyObservablePromise(async () => { + await this.accountStore.initialized; + + autorun(() => { + // Any time the theme changes, update the HTML background to match + document.querySelector('html')!.style.backgroundColor = this.theme.containerBackground; - const darkThemeMq = window.matchMedia("(prefers-color-scheme: dark)"); - this._setPrefersDarkTheme(darkThemeMq.matches); - darkThemeMq.addEventListener("change", (e) => { - this._setPrefersDarkTheme(e.matches); + // Persist the background colour standalone, so we can easily access it + // from the index.html loading script, whether it's custom or computed + localStorage.setItem('theme-background-color', this.theme.containerBackground); + }); + + // Every time the user account data is updated from the server, consider resetting + // paid settings to the free defaults. This ensures that they're reset on + // logout & subscription expiration (even if that happened while the app was + // closed), but don't get reset when the app starts with stale account data. + observe(this.accountStore, 'accountDataLastUpdated', () => { + if (!this.accountStore.isPaidUser) { + this.setTheme('automatic'); + } + }); + + await hydrate({ + key: 'ui-store', + store: this + }); + + const darkThemeMq = window.matchMedia("(prefers-color-scheme: dark)"); + this._setPrefersDarkTheme(darkThemeMq.matches); + darkThemeMq.addEventListener('change', e => { + this._setPrefersDarkTheme(e.matches); + }); + + console.log('UI store initialized'); }); - console.log("UI store initialized"); - }); - - @action.bound - setTheme(themeNameOrObject: Theme | ThemeName | "automatic") { - if (typeof themeNameOrObject === "string") { - this._themeName = themeNameOrObject; - this.customTheme = undefined; - } else { - this._themeName = "custom"; - this.customTheme = themeNameOrObject; - } - } - - buildCustomTheme(themeFile: string) { - const themeData: Partial | undefined = tryParseJson(themeFile); - if (!themeData) throw new Error("Could not parse theme JSON"); - - if (!themeData.name) throw new Error("Theme must contain a `name` field"); - if ( - !themeData.extends || - Themes[themeData.extends as ThemeName] === undefined - ) { - throw new Error( - "Theme must contain an `extends` field with a built-in theme name (dark/light/high-contrast)" - ); + @action.bound + setTheme(themeNameOrObject: Theme | ThemeName | 'automatic') { + if (typeof themeNameOrObject === 'string') { + this._themeName = themeNameOrObject; + this.customTheme = undefined; + } else { + this._themeName = 'custom'; + this.customTheme = themeNameOrObject; + } } - const baseTheme = Themes[themeData.extends]; - return { - ...baseTheme, - ...themeData, - } as Theme; - } - - @persist - @observable - private _themeName: ThemeName | "automatic" | "custom" = "automatic"; - - get themeName() { - return this._themeName; - } - - /** - * Stores if user prefers a dark color theme (for example when set in system settings). - * Used if automatic theme is enabled. - */ - @observable - private _prefersDarkTheme: boolean = false; - - @action.bound - private _setPrefersDarkTheme(value: boolean) { - this._prefersDarkTheme = value; - } - - @persist("object") - @observable - private customTheme: Theme | undefined = undefined; - - @computed - get theme(): Theme { - switch (this.themeName) { - case "automatic": - return { ...Themes[this._prefersDarkTheme ? "dark" : "light"] }; - case "custom": - return this.customTheme!; - default: - return Themes[this.themeName]; + buildCustomTheme(themeFile: string) { + const themeData: Partial | undefined = tryParseJson(themeFile); + if (!themeData) throw new Error("Could not parse theme JSON"); + + if (!themeData.name) throw new Error('Theme must contain a `name` field'); + if ( + !themeData.extends || + Themes[themeData.extends as ThemeName] === undefined + ) { + throw new Error('Theme must contain an `extends` field with a built-in theme name (dark/light/high-contrast)'); + } + + const baseTheme = Themes[themeData.extends]; + return { + ...baseTheme, + ...themeData + } as Theme; } - } - - // Set briefly at the moment any card expansion is toggled, to trigger animation for the expansion as it's - // applied, not always (to avoid animating expanded cards when they're rendered e.g. when selecting a request). - @observable - private animatedExpansionCard: string | undefined; - - /** - * For both requests & responses, there are two different ways to look at them (=4 perspectives in total). It - * depends on the use case (mostly: are you collecting data, or exploring behaviours) but this field changes which - * format is shown in the right-hand UI pane. Note that the list view always still shows the original values. - */ - @observable - contentPerspective: ContentPerspective = "transformed"; - - // Store the view details cards state here, so that they persist - // when moving away from the page or deselecting all traffic. - @observable - private readonly viewCardStates = { - api: { collapsed: true }, - - request: { collapsed: false }, - requestBody: { collapsed: false }, - requestTrailers: { collapsed: false }, - response: { collapsed: false }, - responseBody: { collapsed: false }, - responseTrailers: { collapsed: false }, - - webSocketMessages: { collapsed: false }, - webSocketClose: { collapsed: false }, - - rtcConnection: { collapsed: false }, - rtcSessionOffer: { collapsed: false }, - rtcSessionAnswer: { collapsed: false }, - - performance: { collapsed: true }, - export: { collapsed: true }, - }; - - @observable - expandedViewCard: ExpandableViewCardKey | undefined; - - // Store view list scroll position and selected entry to persist when switching tabs - @observable - viewScrollPosition: number = 0; - - @observable - selectedEventId: string | undefined; - - @computed - get viewCardProps() { - return _.mapValues(this.viewCardStates, (state, key) => ({ - key, - ariaLabel: `${_.startCase(key)} section`, - expanded: - key === this.animatedExpansionCard - ? ("starting" as const) - : key === this.expandedViewCard, - collapsed: state.collapsed && key !== this.expandedViewCard, - onCollapseToggled: this.toggleViewCardCollapsed.bind( - this, - key as ViewCardKey - ), - onExpandToggled: isExpandableViewCard(key) - ? this.toggleViewCardExpanded.bind(this, key) - : _.noop, - })); - } - - @action - toggleViewCardCollapsed(key: ViewCardKey) { - const cardState = this.viewCardStates[key]; - cardState.collapsed = !cardState.collapsed; - this.expandedViewCard = undefined; - } - - @action - private toggleViewCardExpanded(key: ExpandableViewCardKey) { - if (this.expandedViewCard === key) { - this.expandedViewCard = undefined; - } else if (isExpandableViewCard(key)) { - this.viewCardStates[key].collapsed = false; - this.expandedViewCard = key; - - // Briefly set animatedExpansionCard, to trigger animation for this expansion: - this.animatedExpansionCard = key; - requestAnimationFrame( - action(() => { - this.animatedExpansionCard = undefined; - }) - ); + + @persist @observable + private _themeName: ThemeName | 'automatic' | 'custom' = 'automatic'; + + get themeName() { + return this._themeName; } - } - - // Store the send details cards state here - @observable - private readonly sendCardStates = { - requestHeaders: { collapsed: false }, - requestBody: { collapsed: false }, - responseHeaders: { collapsed: false }, - responseBody: { collapsed: false }, - }; - - @observable - expandedSendRequestCard: ExpandableSendRequestCardKey | undefined; - - @observable - expandedSentResponseCard: ExpandableSentResponseCardKey | undefined; - - @computed - get sendCardProps() { - return _.mapValues(this.sendCardStates, (state, key) => { - const expandedState = - key === this.expandedSendRequestCard || - key === this.expandedSentResponseCard; - - return { - key, - ariaLabel: `${_.startCase(key)} section`, - expanded: expandedState, - collapsed: state.collapsed && !expandedState, - onCollapseToggled: this.toggleSendCardCollapsed.bind( - this, - key as SendCardKey - ), - onExpandToggled: isExpandableSendCard(key) - ? this.toggleSendCardExpanded.bind(this, key) - : _.noop, - }; - }); - } - - @action - toggleSendCardCollapsed(key: SendCardKey) { - const cardState = this.sendCardStates[key]; - cardState.collapsed = !cardState.collapsed; - - const siblingCards: SendCardKey[] = isSendRequestCard(key) - ? SEND_REQUEST_CARD_KEYS - : SENT_RESPONSE_CARD_KEYS; - - // If you collapse all cards, pop open an alternative, just because it looks a bit weird - // if you don't, and it makes it easier to quickly switch like an accordion in some cases. - if (siblingCards.every((k) => this.sendCardStates[k].collapsed)) { - const keyIndex = siblingCards.indexOf(key); - const bestAlternativeCard = - keyIndex === siblingCards.length - 1 - ? siblingCards[keyIndex - 1] // For last card, look back one - : siblingCards[keyIndex + 1]; // Otherwise, look at next card - - this.toggleSendCardCollapsed(bestAlternativeCard); + + /** + * Stores if user prefers a dark color theme (for example when set in system settings). + * Used if automatic theme is enabled. + */ + @observable + private _prefersDarkTheme: boolean = false; + + @action.bound + private _setPrefersDarkTheme(value: boolean) { + this._prefersDarkTheme = value; } - if (isSendRequestCard(key)) { - this.expandedSendRequestCard = undefined; - } else if (isSentResponseCard(key)) { - this.expandedSentResponseCard = undefined; - } else { - throw new UnreachableCheck(key); + @persist('object') @observable + private customTheme: Theme | undefined = undefined; + + @computed + get theme(): Theme { + switch(this.themeName) { + case 'automatic': + return {...Themes[this._prefersDarkTheme ? 'dark' : 'light']} + case 'custom': + return this.customTheme!; + default: + return Themes[this.themeName]; + } } - } - - @action - private toggleSendCardExpanded(key: ExpandableSendCardKey) { - const expandedCardField = isSendRequestCard(key) - ? "expandedSendRequestCard" - : isSentResponseCard(key) - ? "expandedSentResponseCard" - : unreachableCheck(key); - - if (this[expandedCardField] === key) { - this[expandedCardField] = undefined; - } else if (isExpandableSendCard(key)) { - this.sendCardStates[key].collapsed = false; - this[expandedCardField] = key as any; // We ensured key matches the field already above - - // We don't bother with animatedExpansionCard - not required for Send (we just - // animate top-line margin, not expanded card padding) + + // Set briefly at the moment any card expansion is toggled, to trigger animation for the expansion as it's + // applied, not always (to avoid animating expanded cards when they're rendered e.g. when selecting a request). + @observable + private animatedExpansionCard: string | undefined; + + /** + * For both requests & responses, there are two different ways to look at them (=4 perspectives in total). It + * depends on the use case (mostly: are you collecting data, or exploring behaviours) but this field changes which + * format is shown in the right-hand UI pane. Note that the list view always still shows the original values. + */ + @observable + contentPerspective: ContentPerspective = 'transformed'; + + // Store the view details cards state here, so that they persist + // when moving away from the page or deselecting all traffic. + @observable + private readonly viewCardStates = { + 'api': { collapsed: true }, + + 'request': { collapsed: false }, + 'requestBody': { collapsed: false }, + 'requestTrailers': { collapsed: false }, + 'response': { collapsed: false }, + 'responseBody': { collapsed: false }, + 'responseTrailers': { collapsed: false }, + + 'webSocketMessages': { collapsed: false }, + 'webSocketClose': { collapsed: false }, + + 'rtcConnection': { collapsed: false }, + 'rtcSessionOffer': { collapsed: false }, + 'rtcSessionAnswer': { collapsed: false }, + + 'performance': { collapsed: true }, + 'export': { collapsed: true } + }; + + @observable + expandedViewCard: ExpandableViewCardKey | undefined; + + // Store view list scroll position and selected entry to persist when switching tabs + @observable + viewScrollPosition: number = 0; + + @observable + selectedEventId: string | undefined; + + @computed + get viewCardProps() { + return _.mapValues(this.viewCardStates, (state, key) => ({ + key, + ariaLabel: `${_.startCase(key)} section`, + expanded: key === this.animatedExpansionCard + ? 'starting' as const + : key === this.expandedViewCard, + collapsed: state.collapsed && key !== this.expandedViewCard, + onCollapseToggled: this.toggleViewCardCollapsed.bind(this, key as ViewCardKey), + onExpandToggled: isExpandableViewCard(key) + ? this.toggleViewCardExpanded.bind(this, key) + : _.noop + })); } - } - - @observable - private settingsCardStates = { - account: { collapsed: false }, - proxy: { collapsed: false }, - connection: { collapsed: false }, - api: { collapsed: false }, - themes: { collapsed: false }, - }; - - @computed - get settingsCardProps() { - return _.mapValues(this.settingsCardStates, (state, key) => ({ - key, - ariaLabel: `${_.startCase(key)} section`, - collapsed: state.collapsed, - onCollapseToggled: this.toggleSettingsCardCollapsed.bind( - this, - key as SettingsCardKey - ), - })); - } - - @action - toggleSettingsCardCollapsed(key: SettingsCardKey) { - const cardState = this.settingsCardStates[key]; - cardState.collapsed = !cardState.collapsed; - } - - @action.bound - rememberElectronPath(path: string) { - if (!this.previousElectronAppPaths.includes(path)) { - this.previousElectronAppPaths.unshift(path); + + @action + toggleViewCardCollapsed(key: ViewCardKey) { + const cardState = this.viewCardStates[key]; + cardState.collapsed = !cardState.collapsed; + this.expandedViewCard = undefined; } - // Keep only the most recent 3 electron paths used - this.previousElectronAppPaths = this.previousElectronAppPaths.slice(0, 3); - } - - @action.bound - forgetElectronPath(path: string) { - this.previousElectronAppPaths = this.previousElectronAppPaths.filter( - (p) => p != path - ); - } - - @persist("list") - @observable - previousElectronAppPaths: string[] = []; - - @observable - activeFilterSet: FilterSet = emptyFilterSet(); - - @persist("object") - @observable - _customFilters: { [name: string]: string } = {}; - - @computed - get customFilters() { - if (this.accountStore.isPaidUser) { - return this._customFilters; - } else { - return {}; + @action + private toggleViewCardExpanded(key: ExpandableViewCardKey) { + if (this.expandedViewCard === key) { + this.expandedViewCard = undefined; + } else if (isExpandableViewCard(key)) { + this.viewCardStates[key].collapsed = false; + this.expandedViewCard = key; + + // Briefly set animatedExpansionCard, to trigger animation for this expansion: + this.animatedExpansionCard = key; + requestAnimationFrame(action(() => { + this.animatedExpansionCard = undefined; + })); + } } - } - - @persist - @observable - preferredShell: string | undefined = "Bash"; - - @persist - @observable - exportSnippetFormat: string | undefined; - - @observable - searchFilters: FilterSet = emptyFilterSet(); - - @action.bound - setSearchFiltersValue(value: FilterSet) { - this.searchFilters = value; - } - - // Actions for persisting view state when switching tabs - @action.bound - setViewScrollPosition(position: number) { - this.viewScrollPosition = position; - } - - @action.bound - setSelectedEventId(eventId: string | undefined) { - this.selectedEventId = eventId; - } - - @observable.ref // This shouldn't be mutated - contextMenuState: ContextMenuState | undefined; - - handleContextMenuEvent( - event: React.MouseEvent, - items: readonly ContextMenuItem[], - data: T - ): void; - handleContextMenuEvent( - event: React.MouseEvent, - items: readonly ContextMenuItem[] - ): void; - @action.bound - handleContextMenuEvent( - event: React.MouseEvent, - items: readonly ContextMenuItem[], - data?: T - ): void { - if (!items.length) return; - - event.preventDefault(); - - if (DesktopApi.openContextMenu) { - const position = { x: event.pageX, y: event.pageY }; - this.contextMenuState = undefined; // Should be set already, but let's be explicit - - DesktopApi.openContextMenu({ - position, - items: buildNativeContextMenuItems(items), - }) - .then((result) => { - if (result) { - const selectedItem = _.get(items, result) as ContextMenuOption; - selectedItem.callback(data!); - } - }) - .catch((error) => { - console.log(error); - throw new Error("Error opening context menu"); + + // Store the send details cards state here + @observable + private readonly sendCardStates = { + 'requestHeaders': { collapsed: false }, + 'requestBody': { collapsed: false }, + 'responseHeaders': { collapsed: false }, + 'responseBody': { collapsed: false } + }; + + @observable + expandedSendRequestCard: ExpandableSendRequestCardKey | undefined; + + @observable + expandedSentResponseCard: ExpandableSentResponseCardKey | undefined; + + @computed + get sendCardProps() { + return _.mapValues(this.sendCardStates, (state, key) => { + const expandedState = key === this.expandedSendRequestCard + || key === this.expandedSentResponseCard; + + return { + key, + ariaLabel: `${_.startCase(key)} section`, + expanded: expandedState, + collapsed: state.collapsed && !expandedState, + onCollapseToggled: this.toggleSendCardCollapsed.bind(this, key as SendCardKey), + onExpandToggled: isExpandableSendCard(key) + ? this.toggleSendCardExpanded.bind(this, key) + : _.noop + }; }); - } else { - event.persist(); - this.contextMenuState = { - data, - event, - items, - }; } - } - @action.bound - clearHtmlContextMenu() { - this.contextMenuState = undefined; - } + @action + toggleSendCardCollapsed(key: SendCardKey) { + const cardState = this.sendCardStates[key]; + cardState.collapsed = !cardState.collapsed; + + const siblingCards: SendCardKey[] = isSendRequestCard(key) + ? SEND_REQUEST_CARD_KEYS + : SENT_RESPONSE_CARD_KEYS; + + // If you collapse all cards, pop open an alternative, just because it looks a bit weird + // if you don't, and it makes it easier to quickly switch like an accordion in some cases. + if (siblingCards.every((k) => this.sendCardStates[k].collapsed)) { + const keyIndex = siblingCards.indexOf(key); + const bestAlternativeCard = (keyIndex === siblingCards.length - 1) + ? siblingCards[keyIndex - 1] // For last card, look back one + : siblingCards[keyIndex + 1] // Otherwise, look at next card + + this.toggleSendCardCollapsed(bestAlternativeCard); + } + + if (isSendRequestCard(key)) { + this.expandedSendRequestCard = undefined; + } else if (isSentResponseCard(key)) { + this.expandedSentResponseCard = undefined; + } else { + throw new UnreachableCheck(key); + } + } + + @action + private toggleSendCardExpanded(key: ExpandableSendCardKey) { + const expandedCardField = isSendRequestCard(key) + ? 'expandedSendRequestCard' + : isSentResponseCard(key) + ? 'expandedSentResponseCard' + : unreachableCheck(key); + + if (this[expandedCardField] === key) { + this[expandedCardField] = undefined; + } else if (isExpandableSendCard(key)) { + this.sendCardStates[key].collapsed = false; + this[expandedCardField] = key as any; // We ensured key matches the field already above + + // We don't bother with animatedExpansionCard - not required for Send (we just + // animate top-line margin, not expanded card padding) + } + } + + @observable + private settingsCardStates = { + 'account': { collapsed: false }, + 'proxy': { collapsed: false }, + 'connection': { collapsed: false }, + 'api': { collapsed: false }, + 'themes': { collapsed: false } + }; + + @computed + get settingsCardProps() { + return _.mapValues(this.settingsCardStates, (state, key) => ({ + key, + ariaLabel: `${_.startCase(key)} section`, + collapsed: state.collapsed, + onCollapseToggled: this.toggleSettingsCardCollapsed.bind(this, key as SettingsCardKey) + })); + } + + @action + toggleSettingsCardCollapsed(key: SettingsCardKey) { + const cardState = this.settingsCardStates[key]; + cardState.collapsed = !cardState.collapsed; + } + + @action.bound + rememberElectronPath(path: string) { + if (!this.previousElectronAppPaths.includes(path)) { + this.previousElectronAppPaths.unshift(path); + } + + // Keep only the most recent 3 electron paths used + this.previousElectronAppPaths = this.previousElectronAppPaths.slice(0, 3); + } + + @action.bound + forgetElectronPath(path: string) { + this.previousElectronAppPaths = this.previousElectronAppPaths.filter(p => p != path); + } + + @persist('list') @observable + previousElectronAppPaths: string[] = []; + + @observable + activeFilterSet: FilterSet = emptyFilterSet(); + + @persist('object') @observable + _customFilters: { [name: string]: string } = {}; + + @computed + get customFilters() { + if (this.accountStore.isPaidUser) { + return this._customFilters; + } else { + return {}; + } + } + + @persist @observable + preferredShell: string | undefined = 'Bash'; + + @persist @observable + exportSnippetFormat: string | undefined; + + // Actions for persisting view state when switching tabs + @action.bound + setViewScrollPosition(position: number) { + this.viewScrollPosition = position; + } + + @action.bound + setSelectedEventId(eventId: string | undefined) { + this.selectedEventId = eventId; + } + + /** + * This tracks the context menu state *only if it's not handled natively*. This state + * is rendered by React as a fallback when DesktopApi.openContextMenu is not available. + */ + @observable.ref // This shouldn't be mutated + contextMenuState: ContextMenuState | undefined; + + handleContextMenuEvent( + event: React.MouseEvent, + items: readonly ContextMenuItem[], + data: T + ): void; + handleContextMenuEvent( + event: React.MouseEvent, + items: readonly ContextMenuItem[] + ): void; + @action.bound + handleContextMenuEvent( + event: React.MouseEvent, + items: readonly ContextMenuItem[], + data?: T + ): void { + if (!items.length) return; + + event.preventDefault(); + + if (DesktopApi.openContextMenu) { + const position = { x: event.pageX, y: event.pageY }; + this.contextMenuState = undefined; // Should be set already, but let's be explicit + + DesktopApi.openContextMenu({ + position, + items: buildNativeContextMenuItems(items) + }).then((result) => { + if (result) { + const selectedItem = _.get(items, result) as ContextMenuOption; + selectedItem.callback(data!); + } + }).catch((error) => { + console.log(error); + throw new Error('Error opening context menu'); + }); + } else { + event.persist(); + this.contextMenuState = { + data, + event, + items + }; + } + } + + @action.bound + clearHtmlContextMenu() { + this.contextMenuState = undefined; + } + } From 9cbe8bad034eaf8a5ed841e910b53a52265c017d Mon Sep 17 00:00:00 2001 From: Matt Nelson Date: Wed, 25 Jun 2025 16:44:40 -0500 Subject: [PATCH 3/7] change setTimeout usages to callback functions and remove vestigial code Signed-off-by: Matt Nelson --- src/components/view/view-event-list.tsx | 30 ++++++++++++------------- src/components/view/view-page.tsx | 27 +++++++++++++++------- 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/components/view/view-event-list.tsx b/src/components/view/view-event-list.tsx index 915c84fe..95afaa8d 100644 --- a/src/components/view/view-event-list.tsx +++ b/src/components/view/view-event-list.tsx @@ -769,6 +769,17 @@ export class ViewEventList extends React.Component { private listBodyRef = React.createRef(); private listRef = React.createRef(); + private setListBodyRef = (element: HTMLDivElement | null) => { + // Update the ref + (this.listBodyRef as any).current = element; + + // If the element is being mounted and we haven't restored state yet, do it now + if (element && !this.hasRestoredInitialState) { + this.restoreScrollPosition(); + this.hasRestoredInitialState = true; + } + }; + private KeyBoundListWindow = observer( React.forwardRef( (props: any, ref) =>
{ : {({ height, width }) => {() => { }); } - private hasRestoredInitialState = false; - componentDidMount() { - // Don't save scroll state immediately - wait until we've restored first - - // Use a more aggressive delay to ensure DOM is fully ready - setTimeout(() => { - this.restoreScrollPosition(); - - // Only start tracking scroll changes after we've restored - setTimeout(() => { - this.hasRestoredInitialState = true; - }, 100); - }, 100); - } - + private hasRestoredInitialState = false; + componentDidUpdate(prevProps: ViewEventListProps) { if (this.listBodyRef.current?.parentElement?.contains(document.activeElement)) { // If we previously had something here focused, and we've updated, update diff --git a/src/components/view/view-page.tsx b/src/components/view/view-page.tsx index 8611227c..740b7e08 100644 --- a/src/components/view/view-page.tsx +++ b/src/components/view/view-page.tsx @@ -171,6 +171,9 @@ class ViewPage extends React.Component { private listRef = React.createRef(); private splitPaneRef = React.createRef(); + + @observable + private shouldRestoreViewStateOnRefSet = false; @observable private searchFiltersUnderConsideration: FilterSet | undefined; @@ -248,10 +251,8 @@ class ViewPage extends React.Component { if (this.props.eventId && this.selectedEvent) { this.onScrollToCenterEvent(this.selectedEvent); } else if (!this.props.eventId && this.props.uiStore.selectedEventId) { - // If no URL eventId but we have a persisted selection, restore it - setTimeout(() => { - this.listRef.current?.restoreViewState(); - }, 100); + // If no URL eventId but we have a persisted selection, restore it when ref is set + this.shouldRestoreViewStateOnRefSet = true; } else { this.onScrollToEnd(); } @@ -333,9 +334,6 @@ class ViewPage extends React.Component { }) ); } - componentWillUnmount() { - // Component is unmounting - } componentDidUpdate(prevProps: ViewPageProps) { // Only clear persisted selection if we're explicitly navigating to a different event via URL @@ -468,7 +466,7 @@ class ViewPage extends React.Component { contextMenuBuilder={this.contextMenuBuilder} uiStore={this.props.uiStore} - ref={this.listRef} + ref={this.setListRef} /> { onScrollToEnd() { this.listRef.current?.scrollToEnd(); } + + @action.bound + setListRef = (ref: ViewEventList | null) => { + if (ref) { + (this.listRef as any).current = ref; + if (this.shouldRestoreViewStateOnRefSet) { + this.shouldRestoreViewStateOnRefSet = false; + ref.restoreViewState(); + } + } else { + (this.listRef as any).current = null; + } + }; } const LeftPane = styled.div` From adb5faf5722e188a37e563dc1d028027a259ca32 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Fri, 4 Jul 2025 14:01:02 +0200 Subject: [PATCH 4/7] Tweak some view-state persistence formatting --- src/components/view/view-event-list.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/view/view-event-list.tsx b/src/components/view/view-event-list.tsx index 95afaa8d..af877c84 100644 --- a/src/components/view/view-event-list.tsx +++ b/src/components/view/view-event-list.tsx @@ -892,11 +892,13 @@ export class ViewEventList extends React.Component { if (!listWindow) return true; // This means no rows, so we are effectively at the bottom else return (listWindow.scrollTop + SCROLL_BOTTOM_MARGIN) >= (listWindow.scrollHeight - listWindow.offsetHeight); } + private wasListAtBottom = true; + private updateScrolledState = () => { requestAnimationFrame(() => { // Measure async, once the scroll has actually happened this.wasListAtBottom = this.isListAtBottom(); - + // Only save scroll position after we've restored the initial state if (this.hasRestoredInitialState) { const listWindow = this.listBodyRef.current?.parentElement; @@ -908,7 +910,7 @@ export class ViewEventList extends React.Component { } private hasRestoredInitialState = false; - + componentDidUpdate(prevProps: ViewEventListProps) { if (this.listBodyRef.current?.parentElement?.contains(document.activeElement)) { // If we previously had something here focused, and we've updated, update @@ -919,7 +921,7 @@ export class ViewEventList extends React.Component { // If we previously were scrolled to the bottom of the list, but now we're not, // scroll there again ourselves now. if (this.wasListAtBottom && !this.isListAtBottom()) { - this.listRef.current?.scrollToItem(this.props.events.length - 1); + this.listRef.current?.scrollToItem(this.props.events.length - 1); } else if (prevProps.selectedEvent !== this.props.selectedEvent && this.props.selectedEvent) { // If the selected event changed and we have a selected event, scroll to it // This handles restoring the selected event when returning to the tab @@ -937,7 +939,8 @@ export class ViewEventList extends React.Component { const savedPosition = this.props.uiStore.viewScrollPosition; if (savedPosition > 0) { const listWindow = this.listBodyRef.current?.parentElement; - if (listWindow) { // Only restore if we're not close to the current position (avoid unnecessary scrolling) + if (listWindow) { + // Only restore if we're not close to the current position (avoid unnecessary scrolling) if (Math.abs(listWindow.scrollTop - savedPosition) > 10) { listWindow.scrollTop = savedPosition; } @@ -1045,7 +1048,9 @@ export class ViewEventList extends React.Component { } event.preventDefault(); - } // Public method to force scroll and selection restoration + } + + // Public method to force scroll and selection restoration public restoreViewState = () => { if (this.props.selectedEvent) { this.scrollToEvent(this.props.selectedEvent); From ebeb8a215aba045e5d337e0c3dd04e956700d6b8 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Fri, 4 Jul 2025 14:01:17 +0200 Subject: [PATCH 5/7] Drop filter-related scroll persistence logic I don't think we have a clear case where this is needed - can re-evalate if we find this does have some specific missing edge cases though. --- src/components/view/view-event-list.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/components/view/view-event-list.tsx b/src/components/view/view-event-list.tsx index af877c84..3b1b61f3 100644 --- a/src/components/view/view-event-list.tsx +++ b/src/components/view/view-event-list.tsx @@ -926,11 +926,6 @@ export class ViewEventList extends React.Component { // If the selected event changed and we have a selected event, scroll to it // This handles restoring the selected event when returning to the tab this.scrollToEvent(this.props.selectedEvent); - } else if (prevProps.filteredEvents.length !== this.props.filteredEvents.length) { - // If the filtered events changed (e.g., new events loaded), try to restore scroll position - setTimeout(() => { - this.restoreScrollPosition(); - }, 50); } } From c300f1043d0d48c113d10aca5a22c3d3bed9c361 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Fri, 4 Jul 2025 14:12:57 +0200 Subject: [PATCH 6/7] Try to simplify view list scroll-restore logic --- src/components/view/view-page.tsx | 30 ++++++------------------------ 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/src/components/view/view-page.tsx b/src/components/view/view-page.tsx index 740b7e08..153611c2 100644 --- a/src/components/view/view-page.tsx +++ b/src/components/view/view-page.tsx @@ -171,9 +171,6 @@ class ViewPage extends React.Component { private listRef = React.createRef(); private splitPaneRef = React.createRef(); - - @observable - private shouldRestoreViewStateOnRefSet = false; @observable private searchFiltersUnderConsideration: FilterSet | undefined; @@ -214,7 +211,7 @@ class ViewPage extends React.Component { get selectedEvent() { // First try to use the URL-based eventId, then fallback to the persisted selection const targetEventId = this.props.eventId || this.props.uiStore.selectedEventId; - + return _.find(this.props.eventsStore.events, { id: targetEventId }); @@ -245,15 +242,13 @@ class ViewPage extends React.Component { this.onBuildRuleFromExchange, this.onPrepareToResendRequest ); + componentDidMount() { // After first render, scroll to the selected event (or the end of the list) by default: requestAnimationFrame(() => { - if (this.props.eventId && this.selectedEvent) { + if ((this.props.eventId || this.props.uiStore.selectedEventId) && this.selectedEvent) { this.onScrollToCenterEvent(this.selectedEvent); - } else if (!this.props.eventId && this.props.uiStore.selectedEventId) { - // If no URL eventId but we have a persisted selection, restore it when ref is set - this.shouldRestoreViewStateOnRefSet = true; - } else { + } else if (!this.props.uiStore.viewScrollPosition) { this.onScrollToEnd(); } }); @@ -466,7 +461,7 @@ class ViewPage extends React.Component { contextMenuBuilder={this.contextMenuBuilder} uiStore={this.props.uiStore} - ref={this.setListRef} + ref={this.listRef} /> { onSelected(event: CollectedEvent | undefined) { // Persist the selected event to UiStore for tab switching this.props.uiStore.setSelectedEventId(event?.id); - + this.props.navigate(event ? `/view/${event.id}` : '/view' @@ -640,19 +635,6 @@ class ViewPage extends React.Component { onScrollToEnd() { this.listRef.current?.scrollToEnd(); } - - @action.bound - setListRef = (ref: ViewEventList | null) => { - if (ref) { - (this.listRef as any).current = ref; - if (this.shouldRestoreViewStateOnRefSet) { - this.shouldRestoreViewStateOnRefSet = false; - ref.restoreViewState(); - } - } else { - (this.listRef as any).current = null; - } - }; } const LeftPane = styled.div` From 5f78f2807ddf75eda88f65ffd7e7aa137721c57e Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Tue, 15 Jul 2025 17:56:29 +0200 Subject: [PATCH 7/7] Don't automatically scroll to restored view list selections Also preserves scrolling to zero & the end, instead of resetting you unexpectedly, and preserves selected events even if selected by links from elsewhere. --- src/components/view/view-event-list.tsx | 34 +++++++++++-------------- src/components/view/view-page.tsx | 9 +++---- src/model/ui/ui-store.ts | 5 ++-- 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/components/view/view-event-list.tsx b/src/components/view/view-event-list.tsx index 3b1b61f3..3adf4d55 100644 --- a/src/components/view/view-event-list.tsx +++ b/src/components/view/view-event-list.tsx @@ -772,11 +772,11 @@ export class ViewEventList extends React.Component { private setListBodyRef = (element: HTMLDivElement | null) => { // Update the ref (this.listBodyRef as any).current = element; - + // If the element is being mounted and we haven't restored state yet, do it now - if (element && !this.hasRestoredInitialState) { + if (element && !this.hasRestoredScrollState) { this.restoreScrollPosition(); - this.hasRestoredInitialState = true; + this.hasRestoredScrollState = true; } }; @@ -900,16 +900,20 @@ export class ViewEventList extends React.Component { this.wasListAtBottom = this.isListAtBottom(); // Only save scroll position after we've restored the initial state - if (this.hasRestoredInitialState) { + if (this.hasRestoredScrollState) { const listWindow = this.listBodyRef.current?.parentElement; + + const scrollPosition = this.wasListAtBottom + ? 'end' + : (listWindow?.scrollTop ?? 'end'); if (listWindow) { - this.props.uiStore.setViewScrollPosition(listWindow.scrollTop); + this.props.uiStore.setViewScrollPosition(scrollPosition); } } }); } - private hasRestoredInitialState = false; + private hasRestoredScrollState = false; componentDidUpdate(prevProps: ViewEventListProps) { if (this.listBodyRef.current?.parentElement?.contains(document.activeElement)) { @@ -930,11 +934,12 @@ export class ViewEventList extends React.Component { } private restoreScrollPosition = () => { - // Only restore if we have a saved position const savedPosition = this.props.uiStore.viewScrollPosition; - if (savedPosition > 0) { - const listWindow = this.listBodyRef.current?.parentElement; - if (listWindow) { + const listWindow = this.listBodyRef.current?.parentElement; + if (listWindow) { + if (savedPosition === 'end') { + listWindow.scrollTop = listWindow.scrollHeight; + } else { // Only restore if we're not close to the current position (avoid unnecessary scrolling) if (Math.abs(listWindow.scrollTop - savedPosition) > 10) { listWindow.scrollTop = savedPosition; @@ -1044,13 +1049,4 @@ export class ViewEventList extends React.Component { event.preventDefault(); } - - // Public method to force scroll and selection restoration - public restoreViewState = () => { - if (this.props.selectedEvent) { - this.scrollToEvent(this.props.selectedEvent); - } else { - this.restoreScrollPosition(); - } - } } \ No newline at end of file diff --git a/src/components/view/view-page.tsx b/src/components/view/view-page.tsx index 153611c2..cbb4d4f7 100644 --- a/src/components/view/view-page.tsx +++ b/src/components/view/view-page.tsx @@ -244,12 +244,11 @@ class ViewPage extends React.Component { ); componentDidMount() { - // After first render, scroll to the selected event (or the end of the list) by default: + // After first render, if we're jumping to an event, then scroll to it: requestAnimationFrame(() => { - if ((this.props.eventId || this.props.uiStore.selectedEventId) && this.selectedEvent) { + if (this.props.eventId && this.selectedEvent) { + this.props.uiStore.setSelectedEventId(this.props.eventId); this.onScrollToCenterEvent(this.selectedEvent); - } else if (!this.props.uiStore.viewScrollPosition) { - this.onScrollToEnd(); } }); @@ -499,9 +498,9 @@ class ViewPage extends React.Component { onSearchFiltersConsidered(filters: FilterSet | undefined) { this.searchFiltersUnderConsideration = filters; } + @action.bound onSelected(event: CollectedEvent | undefined) { - // Persist the selected event to UiStore for tab switching this.props.uiStore.setSelectedEventId(event?.id); this.props.navigate(event diff --git a/src/model/ui/ui-store.ts b/src/model/ui/ui-store.ts index f9bc00b5..607eb89b 100644 --- a/src/model/ui/ui-store.ts +++ b/src/model/ui/ui-store.ts @@ -251,9 +251,8 @@ export class UiStore { @observable expandedViewCard: ExpandableViewCardKey | undefined; - // Store view list scroll position and selected entry to persist when switching tabs @observable - viewScrollPosition: number = 0; + viewScrollPosition: number | 'end' = 'end'; @observable selectedEventId: string | undefined; @@ -445,7 +444,7 @@ export class UiStore { // Actions for persisting view state when switching tabs @action.bound - setViewScrollPosition(position: number) { + setViewScrollPosition(position: number | 'end') { this.viewScrollPosition = position; }