From 5ecb2adcd8a8a7e9067dbf9eb2a119a3983eae39 Mon Sep 17 00:00:00 2001 From: Mitch Capper Date: Tue, 26 Sep 2023 15:31:48 -0700 Subject: [PATCH 1/2] IncludeExcludeList model for managing and serializing favorites/exclude/pin items --- src/model/IncludeExcludeList.ts | 96 +++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/model/IncludeExcludeList.ts diff --git a/src/model/IncludeExcludeList.ts b/src/model/IncludeExcludeList.ts new file mode 100644 index 00000000..0360e9aa --- /dev/null +++ b/src/model/IncludeExcludeList.ts @@ -0,0 +1,96 @@ +import * as _ from 'lodash'; + +export enum IEList { //these are flags so no direct equals, nothing requires them to be used as flags, but it would allow you to have an entry on both Include and Favorite at the same type + Invalid = 0, + Include = 1 << 1, + Exclude = 1 << 2, + Favorite = 1 << 3, +} + +function* mapIterableImpl(f: (val: T) => U, iterable: Iterable): Iterable { + for (const item of iterable) { + yield f(item); + } +} +const mapIterable = (f: (val: T) => U) => (iterable: Iterable) => mapIterableImpl(f, iterable); +function* filterIterableImpl(f: (item: T) => boolean, iterable: Iterable): Iterable { + for (const item of iterable) { + if (f(item)) { + yield item; + } + } + } +const filterIterable = (f: (val: T) => boolean) => (iterable: Iterable) => filterIterableImpl(f, iterable); + +interface SerializeData{ + known : Map; +} +type ItemResolver = (value: T) => TResult; + +export class IncludeExcludeList { + + protected known = new Map(); + + GetSaveDataObject() : Object { + return {known:new Map(this.known)} as SerializeData; + } + static LoadFromSaveDataObject(object : object) : IncludeExcludeList { + let ret = new IncludeExcludeList(); + ret.known = new Map((object as SerializeData).known); + return ret; + } + + AddOrUpdateToList = (key: ListKeyType, list: IEList) => this.known.set(key, list); + RemoveFromLists = (key: ListKeyType) => this.known.delete(key); + ClearList(list: IEList){ + let keys = this.GetKeysOnList(list); + for (const key of keys) + this.RemoveFromList(key,list); + } + RemoveFromList(key: ListKeyType, list: IEList){ + let current = this.GetKeyList(key); + if (current == undefined) + return; + current &= ~list; + if (current == IEList.Invalid) + this.known.delete(key); + else + this.AddOrUpdateToList(key, current); + } + + GetKeyList = (key: ListKeyType) => this.known.get(key); + GetKeysOnList(list: IEList) : ListKeyType[] { + let filtered = filterIterable((kvp : [ListKeyType, IEList]) => (kvp[1] & list) != 0)(this.known.entries()); + let mapped = mapIterable((kvp : [ListKeyType, IEList]) => kvp[0])(filtered); + return Array.from( mapped ); + } + IsKeyOnList(key : ListKeyType, list: IEList, trueIfUnknown : boolean = false){ + let val = this.GetKeyList(key); + if (val === undefined) + return trueIfUnknown; + return (val & list) != 0; + } + + /// returnUnknown true if you want items not on any list, false if they should be excluded + FilterArrayAgainstList(arr: Iterable, list: IEList, returnUnknown : boolean = false, resolver : ItemResolver|undefined=undefined) : Iterable{ + if (!resolver) + resolver = (itm) => itm as any; + return filterIterable( (key : T) => this.IsKeyOnList(resolver!(key),list,returnUnknown))(arr); + } + + /// return the list in the same order passed in except all items that are on the specified list are returned first + * SortArrayAgainstList(arr: Iterable, list: IEList, resolver : ItemResolver|undefined=undefined) : IterableIterator { + let after = new Array() as Array; + if (! resolver) + resolver = (itm) => itm as any; + for (const item of arr) { + if (this.IsKeyOnList(resolver(item),list)) + yield item; + else + after.push(item); + } + for (const itm of after) + yield itm; + } + +} \ No newline at end of file From d57ca72359c7a3fe1cdf094e1d59cca5272573b2 Mon Sep 17 00:00:00 2001 From: Mitch Capper Date: Tue, 26 Sep 2023 15:33:27 -0700 Subject: [PATCH 2/2] Ability to pin or exclude headers from display --- src/components/send/sent-response-headers.tsx | 5 +- src/components/view/http/header-details.tsx | 60 +++++++++- .../view/http/headers-context-menu-builder.ts | 111 ++++++++++++++++++ .../view/http/http-request-card.tsx | 23 ++-- .../view/http/http-response-card.tsx | 21 +++- 5 files changed, 201 insertions(+), 19 deletions(-) create mode 100644 src/components/view/http/headers-context-menu-builder.ts diff --git a/src/components/send/sent-response-headers.tsx b/src/components/send/sent-response-headers.tsx index baf89695..74ab242d 100644 --- a/src/components/send/sent-response-headers.tsx +++ b/src/components/send/sent-response-headers.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; import { RawHeaders } from '../../types'; +import {IncludeExcludeList} from '../../model/IncludeExcludeList'; + import { CollapsibleCardHeading, @@ -16,6 +18,7 @@ export interface ResponseHeaderSectionProps extends ExpandableCardProps { requestUrl: URL; headers: RawHeaders; } +const HeadersIncludeExcludeList = new IncludeExcludeList(); export const SentResponseHeaderSection = ({ requestUrl, @@ -34,7 +37,7 @@ export const SentResponseHeaderSection = ({ Response Headers - diff --git a/src/components/view/http/header-details.tsx b/src/components/view/http/header-details.tsx index 3ef18297..5d226b91 100644 --- a/src/components/view/http/header-details.tsx +++ b/src/components/view/http/header-details.tsx @@ -18,6 +18,11 @@ import { import { CookieHeaderDescription } from './set-cookie-header-description'; import { UserAgentHeaderDescription } from './user-agent-header-description'; +import { IEList, IncludeExcludeList } from '../../../model/IncludeExcludeList'; +import { HeadersContextMenuBuilder } from './headers-context-menu-builder'; +import { filterProps } from '../../component-utils'; +import { ArrowIcon, Icon, WarningIcon } from '../../../icons'; +import { UiStore } from '../../../model/ui/ui-store'; const HeadersGrid = styled.section` display: grid; @@ -77,12 +82,49 @@ const getHeaderDescription = (

}; -export const HeaderDetails = inject('accountStore')(observer((props: { + +const RowPin = styled( + filterProps(Icon, 'pinned') +).attrs((p: { pinned: boolean }) => ({ + icon: ['fas', 'thumbtack'], + title: p.pinned ? "This header is pinned, it will appear at the top of the list by default" : '' +}))` + font-size: 90%; + background-color: ${p => p.theme.containerBackground}; + /* Without this, 0 width pins create a large & invisible but still clickable icon */ + overflow: hidden; + transition: width 0.1s, padding 0.1s, margin 0.1s; + ${(p: { pinned: boolean }) => + p.pinned + ? ` + width: auto; + padding: 4px; + height: 40%; + && { margin-right: -3px; } + ` + : ` + padding: 0px 0; + width: 0 !important; + margin: 0 !important; + ` + } +`; + +export const HeaderDetails = inject('accountStore', 'uiStore')(observer((props: { headers: RawHeaders, requestUrl: URL, - accountStore?: AccountStore + HeadersIncludeExcludeList: IncludeExcludeList, + accountStore?: AccountStore, + uiStore?: UiStore }) => { - const sortedHeaders = _.sortBy(props.headers, ([key]) => key.toLowerCase()); + const contextMenuBuilder = new HeadersContextMenuBuilder( + props.accountStore!, + props.uiStore! + ); + const filtered = props.HeadersIncludeExcludeList.FilterArrayAgainstList(_.sortBy(props.headers, ([key]) => key.toLowerCase()), IEList.Favorite, true, ([key]) => key); + const sortedHeaders = Array.from(props.HeadersIncludeExcludeList.SortArrayAgainstList(filtered, IEList.Favorite, ([key]) => key)); + let hiddenCount = props.headers.length - sortedHeaders.length; + return sortedHeaders.length === 0 ? (None) @@ -98,8 +140,8 @@ export const HeaderDetails = inject('accountStore')(observer((props: { ) return - - { key }: + + {key}: { value } @@ -111,5 +153,13 @@ export const HeaderDetails = inject('accountStore')(observer((props: { } }) } + { +hiddenCount > 0 ? + +Plus {hiddenCount} hidden... + + + : + } ; })); \ No newline at end of file diff --git a/src/components/view/http/headers-context-menu-builder.ts b/src/components/view/http/headers-context-menu-builder.ts new file mode 100644 index 00000000..847a0e27 --- /dev/null +++ b/src/components/view/http/headers-context-menu-builder.ts @@ -0,0 +1,111 @@ +import { action, runInAction } from 'mobx'; + + +import { AccountStore } from '../../../model/account/account-store'; +import { UiStore } from '../../../model/ui/ui-store'; + +import { IEList, IncludeExcludeList } from '../../../model/IncludeExcludeList'; + +import { copyToClipboard } from '../../../util/ui'; +import { ContextMenuItem } from '../../../model/ui/context-menu'; + + + + +export interface HeadersHeaderClickedData { + HeadersIncludeExcludeList : IncludeExcludeList +} + +export interface HeaderEvent { + HeadersIncludeExcludeList: IncludeExcludeList, + header_name: string; + header_value: string[]; +} + +export class HeadersHeaderContextMenuBuilder { + + constructor( + private uiStore: UiStore + ) {} + + getContextMenuCallback(event: HeadersHeaderClickedData) { + return (mouseEvent: React.MouseEvent) => { + let excluded = event.HeadersIncludeExcludeList.GetKeysOnList(IEList.Exclude); + + this.uiStore.handleContextMenuEvent(mouseEvent, [ + + { + type: 'submenu', + enabled: excluded.length > 0, + label: `Excluded`, + items: [ + { + type: 'option', + label: `Clear All Excluded Headers`, + callback: async (data) => data.HeadersIncludeExcludeList.ClearList(IEList.Exclude) + + }, + ... + (excluded.map((headerName) => ({ + + type: 'option', + label: `Clear '${headerName}'`, + callback: async (data: HeadersHeaderClickedData) => + data.HeadersIncludeExcludeList.RemoveFromList(headerName,IEList.Exclude) + + + } + )) + ) as ContextMenuItem[] + ] + } + + + ], event + + ); + }; + } +} + +export class HeadersContextMenuBuilder { + + constructor( + private accountStore: AccountStore, + private uiStore: UiStore + ) {} + + getContextMenuCallback(event: HeaderEvent) { + return (mouseEvent: React.MouseEvent) => { + const { isPaidUser } = this.accountStore; + let isPinned = event.HeadersIncludeExcludeList.IsKeyOnList(event.header_name,IEList.Favorite); + + this.uiStore.handleContextMenuEvent(mouseEvent, [ + { + type: 'option', + label: (isPinned ? `Unpin` : `Pin`) + ` This Header`, + callback: async (data) => { + isPinned ? data.HeadersIncludeExcludeList.RemoveFromList(data.header_name,IEList.Favorite) : data.HeadersIncludeExcludeList.AddOrUpdateToList(data.header_name,IEList.Favorite); + } + }, + { + type: 'option', + label: `Exclude This Header`, + callback: async (data) => { + data.HeadersIncludeExcludeList.AddOrUpdateToList(data.header_name,IEList.Exclude); + } + }, + { + type: 'option', + label: `Copy Header Value`, + callback: async (data) => copyToClipboard( data.header_value.join("\n" ) ) + + + } + + ], event + + ); + }; + } +} \ No newline at end of file diff --git a/src/components/view/http/http-request-card.tsx b/src/components/view/http/http-request-card.tsx index e7d4edd5..8f98f517 100644 --- a/src/components/view/http/http-request-card.tsx +++ b/src/components/view/http/http-request-card.tsx @@ -12,6 +12,8 @@ import { getSummaryColour } from '../../../model/events/categorization'; import { getMethodDocs } from '../../../model/http/http-docs'; import { nameHandlerClass } from '../../../model/rules/rule-descriptions'; import { HandlerClassKey } from '../../../model/rules/rules'; +import {IEList, IncludeExcludeList} from '../../../model/IncludeExcludeList'; +import { HeadersHeaderContextMenuBuilder, HeaderEvent } from './headers-context-menu-builder'; import { CollapsibleCardHeading, @@ -87,7 +89,10 @@ const MatchedRulePill = styled(inject('uiStore')((p: { } `; -const RawRequestDetails = (p: { request: HtkRequest }) => { +const HeadersIncludeExcludeList = new IncludeExcludeList(); + +const RawRequestDetails = (p: { request: HtkRequest, contextMenuBuilder: HeadersHeaderContextMenuBuilder, uiStore : UiStore }) => { + const methodDocs = getMethodDocs(p.request.method); const methodDetails = [ methodDocs && { } - - Headers - + Headers + ; } interface HttpRequestCardProps extends CollapsibleCardProps { exchange: HttpExchange; + uiStore?: UiStore; matchedRuleData: { stepTypes: HandlerClassKey[], status: 'unchanged' | 'modified-types' | 'deleted' @@ -146,9 +151,13 @@ interface HttpRequestCardProps extends CollapsibleCardProps { onRuleClicked: () => void; } -export const HttpRequestCard = observer((props: HttpRequestCardProps) => { +export const HttpRequestCard = inject('uiStore') (observer((props: HttpRequestCardProps) => { const { exchange, matchedRuleData, onRuleClicked } = props; const { request } = exchange; + const contextMenuBuilder = new HeadersHeaderContextMenuBuilder( + props.uiStore! + ); + // We consider passthrough as a no-op, and so don't show anything in that case. const noopRule = matchedRuleData?.stepTypes.every( @@ -177,6 +186,6 @@ export const HttpRequestCard = observer((props: HttpRequestCardProps) => { - + ; -}); \ No newline at end of file +})); \ No newline at end of file diff --git a/src/components/view/http/http-response-card.tsx b/src/components/view/http/http-response-card.tsx index bc123ccd..b4db8806 100644 --- a/src/components/view/http/http-response-card.tsx +++ b/src/components/view/http/http-response-card.tsx @@ -1,12 +1,14 @@ import * as _ from 'lodash'; import * as React from 'react'; -import { observer } from 'mobx-react'; + +import { inject, observer } from 'mobx-react'; import { get } from 'typesafe-get'; import { HtkResponse, Omit } from '../../../types'; import { Theme } from '../../../styles'; import { ApiExchange } from '../../../model/api/api-interfaces'; +import { UiStore } from '../../../model/ui/ui-store'; import { getStatusColor } from '../../../model/events/categorization'; import { getStatusDocs, getStatusMessage } from '../../../model/http/http-docs'; @@ -17,6 +19,8 @@ import { } from '../../common/card'; import { Pill } from '../../common/pill'; import { HeaderDetails } from './header-details'; +import { HeadersHeaderContextMenuBuilder, HeaderEvent } from './headers-context-menu-builder'; +import {IEList, IncludeExcludeList} from '../../../model/IncludeExcludeList'; import { } from '../../common/card'; import { @@ -35,13 +39,19 @@ import { DocsLink } from '../../common/docs-link'; interface HttpResponseCardProps extends CollapsibleCardProps { theme: Theme; requestUrl: URL; + uiStore?: UiStore; response: HtkResponse; apiExchange: ApiExchange | undefined; } +const HeadersIncludeExcludeList = new IncludeExcludeList(); -export const HttpResponseCard = observer((props: HttpResponseCardProps) => { +export const HttpResponseCard = inject('uiStore') (observer((props: HttpResponseCardProps) => { const { response, requestUrl, theme, apiExchange } = props; + const contextMenuBuilder = new HeadersHeaderContextMenuBuilder( + props.uiStore! + ); + const apiResponseDescription = get(apiExchange, 'response', 'description'); const statusDocs = getStatusDocs(response.statusCode); @@ -84,9 +94,8 @@ export const HttpResponseCard = observer((props: HttpResponseCardProps) => { : null } - - Headers - + Headers + ; -}); \ No newline at end of file +})); \ No newline at end of file