diff --git a/src/components/view/http/http-details-footer.tsx b/src/components/view/http/http-details-footer.tsx index 11061fe4..df089f37 100644 --- a/src/components/view/http/http-details-footer.tsx +++ b/src/components/view/http/http-details-footer.tsx @@ -93,6 +93,7 @@ export const HttpDetailsFooter = inject('rulesStore')( event: CollectedEvent, onDelete: (event: CollectedEvent) => void, + onPin: (event: CollectedEvent) => void, onScrollToEvent: (event: CollectedEvent) => void, onBuildRuleFromExchange: (event: HttpExchange) => void, isPaidUser: boolean, @@ -107,9 +108,7 @@ export const HttpDetailsFooter = inject('rulesStore')( /> { - event.pinned = !event.pinned; - })} + onClick={() => props.onPin(event)} /> void, onDelete: (event: CollectedEvent) => void, + onPin: (event: CollectedEvent) => void, onScrollToEvent: (event: CollectedEvent) => void, onBuildRuleFromExchange: (exchange: HttpExchange) => void, @@ -83,6 +84,7 @@ export class HttpDetailsPane extends React.Component<{ const { exchange, onDelete, + onPin, onScrollToEvent, onBuildRuleFromExchange, uiStore, @@ -129,6 +131,7 @@ export class HttpDetailsPane extends React.Component<{ { @@ -45,8 +46,9 @@ export const ExportAsHarButton = inject('accountStore')(observer((props: { } disabled={!isPaidUser || props.events.length === 0} onClick={async () => { + const serialize = props.isMultiSelectEnabled ? props.events.filter(evt=> evt.mulitSelected) : props.events; const harContent = JSON.stringify( - await generateHar(props.events) + await generateHar(serialize) ); const filename = `HTTPToolkit_${ dateFns.format(Date.now(), 'YYYY-MM-DD_HH-mm') diff --git a/src/components/view/view-event-list-footer.tsx b/src/components/view/view-event-list-footer.tsx index d3a9e59b..bcacc129 100644 --- a/src/components/view/view-event-list-footer.tsx +++ b/src/components/view/view-event-list-footer.tsx @@ -61,6 +61,7 @@ export const ViewEventListFooter = styled(observer((props: { onClear: () => void, onFiltersConsidered: (filters: FilterSet | undefined) => void, onScrollToEnd: () => void, + isMultiSelectEnabled: boolean, allEvents: CollectedEvent[], filteredEvents: CollectedEvent[], @@ -87,7 +88,7 @@ export const ViewEventListFooter = styled(observer((props: { - + void; contextMenuBuilder: ViewEventContextMenuBuilder; @@ -156,6 +161,15 @@ const Status = styled(Column)` flex-shrink: 0; flex-grow: 0; `; +const MultiSelect = styled(Column)` + flex-basis: 20px; + ${(p: { isMultiSelectEnabled: boolean }) => p.isMultiSelectEnabled && USE_MULTI_SELECT_CHECKBOXES ? "margin-right: 25px !important;" : "margin-right: 15px !important;" } + flex-shrink: 0; + margin-left: -20px !important; + + title: "Multi-select events"; + flex-grow: 0; +`; const Source = styled(Column)` flex-basis: 49px; @@ -229,6 +243,10 @@ const EventListRow = styled.div` user-select: none; cursor: pointer; + &.multiSelected { + background-color: ${p => p.theme.highlightBackground}; + color: ${p => p.theme.highlightColor}; + } &.selected { background-color: ${p => p.theme.highlightBackground}; color: ${p => p.theme.highlightColor}; @@ -322,6 +340,7 @@ interface EventRowProps extends ListChildComponentProps { selectedEvent: CollectedEvent | undefined; events: CollectedEvent[]; contextMenuBuilder: ViewEventContextMenuBuilder; + isMultiSelectEnabled: boolean; } } @@ -352,6 +371,7 @@ const EventRow = observer((props: EventRowProps) => { return { } }); +interface RowCheckboxProps { + checked:boolean; + whenChecked: React.ChangeEventHandler; + isMultiSelectEnabled: boolean; +} + + +const RowCheckbox = styled.input.attrs( (props : RowCheckboxProps) => ({ + type: "checkbox", checked: props.checked, onChange: props.whenChecked + + }))` + ${props => props.isMultiSelectEnabled && USE_MULTI_SELECT_CHECKBOXES ? `` : `width: 0 !important;`} + `; + const ExchangeRow = inject('uiStore')(observer(({ index, isSelected, style, exchange, - contextMenuBuilder + contextMenuBuilder, + isMultiSelectEnabled }: { index: number, isSelected: boolean, + isMultiSelectEnabled: boolean, style: {}, exchange: HttpExchange, contextMenuBuilder: ViewEventContextMenuBuilder @@ -403,10 +439,11 @@ const ExchangeRow = inject('uiStore')(observer(({ data-event-id={exchange.id} tabIndex={isSelected ? 0 : -1} onContextMenu={contextMenuBuilder.getContextMenuCallback(exchange)} - className={isSelected ? 'selected' : ''} + className={isSelected ? 'selected' : exchange.mulitSelected ? MULTI_SELECT_ROW_CLASSNAME : ''} style={style} > + { request.method } @@ -492,7 +529,7 @@ const RTCConnectionRow = observer(({ data-event-id={event.id} tabIndex={isSelected ? 0 : -1} - className={isSelected ? 'selected' : ''} + className={isSelected ? 'selected' : event.mulitSelected ? MULTI_SELECT_ROW_CLASSNAME : ''} style={style} > @@ -536,7 +573,7 @@ const RTCStreamRow = observer(({ data-event-id={event.id} tabIndex={isSelected ? 0 : -1} - className={isSelected ? 'selected' : ''} + className={isSelected ? 'selected' : event.mulitSelected ? MULTI_SELECT_ROW_CLASSNAME : ''} style={style} > @@ -604,7 +641,7 @@ const BuiltInApiRow = observer((p: { tabIndex={p.isSelected ? 0 : -1} onContextMenu={p.contextMenuBuilder.getContextMenuCallback(p.exchange)} - className={p.isSelected ? 'selected' : ''} + className={p.isSelected ? 'selected' : p.exchange.mulitSelected ? MULTI_SELECT_ROW_CLASSNAME : ''} style={p.style} > @@ -659,7 +696,7 @@ const TlsRow = observer((p: { data-event-id={tlsEvent.id} tabIndex={p.isSelected ? 0 : -1} - className={p.isSelected ? 'selected' : ''} + className={p.isSelected ? 'selected' : tlsEvent.mulitSelected ? MULTI_SELECT_ROW_CLASSNAME : ''} style={p.style} > { @@ -686,12 +723,14 @@ export class ViewEventList extends React.Component { return { selectedEvent: this.props.selectedEvent, events: this.props.filteredEvents, + isMultiSelectEnabled: this.props.isMultiSelectEnabled, contextMenuBuilder: this.props.contextMenuBuilder }; } private listBodyRef = React.createRef(); private listRef = React.createRef(); + private AreMultipleEventsSelected = false; private KeyBoundListWindow = observer( React.forwardRef( @@ -714,6 +753,7 @@ export class ViewEventList extends React.Component { return + this.props.onMultiSelectToggled()} /> Method Status Source @@ -876,7 +916,7 @@ export class ViewEventList extends React.Component { const eventIndex = parseInt(ariaRowIndex, 10) - 1; const event = this.props.filteredEvents[eventIndex]; if (event !== this.props.selectedEvent) { - this.onEventSelected(eventIndex); + this.onEventSelected(eventIndex, mouseEvent); } else { // Clicking the selected row deselects it this.onEventDeselected(); @@ -884,12 +924,46 @@ export class ViewEventList extends React.Component { } @action.bound - onEventSelected(index: number) { + onEventSelected(index: number, mouseEvent: React.MouseEvent) { + if (this.props.isMultiSelectEnabled){ + const eventIndex = index; + const event = this.props.filteredEvents[eventIndex]; + if ( (! USE_MULTI_SELECT_CHECKBOXES || mouseEvent.shiftKey) && ! mouseEvent.ctrlKey && this.AreMultipleEventsSelected){ //if using the checkboxes then only clear otehr checkboxes when shift key is hit + this.props.filteredEvents.forEach(evt => evt.mulitSelected = false);//to increase the perf here we should cache the selected events in a list, then we can uncheck them quickly and clear the list rather than doing this every click + this.AreMultipleEventsSelected=false; + } + if (! USE_MULTI_SELECT_CHECKBOXES){ + if (! mouseEvent.ctrlKey){ + if (this.props.selectedEvent){ + this.props.selectedEvent.mulitSelected = false; + } + event.mulitSelected = true; + } + } + + + if (mouseEvent.ctrlKey){ + event.mulitSelected = ! event.mulitSelected; + this.AreMultipleEventsSelected=true; //even if technically only one is selected we are safe to set this to true it just does the above reset first + } + if (mouseEvent.shiftKey){ + this.AreMultipleEventsSelected = true; //even if technically only one is selected we are safe to set this to true it just does the above reset first + if (this.props.selectedEvent){ + let curIndex = this.props.filteredEvents.indexOf(this.props.selectedEvent); + for(let x = curIndex < eventIndex ? curIndex : eventIndex; x <= (curIndex < eventIndex ? eventIndex : curIndex); x++){ + this.props.filteredEvents[x].mulitSelected = true; + } + } + } + } this.props.onSelected(this.props.filteredEvents[index]); } @action.bound onEventDeselected() { + if (! USE_MULTI_SELECT_CHECKBOXES && this.props.selectedEvent){ + this.props.selectedEvent.mulitSelected = false; + } this.props.onSelected(undefined); } diff --git a/src/components/view/view-page.tsx b/src/components/view/view-page.tsx index c00ce000..361bad38 100644 --- a/src/components/view/view-page.tsx +++ b/src/components/view/view-page.tsx @@ -41,6 +41,7 @@ import { TlsTunnelDetailsPane } from './tls/tls-tunnel-details-pane'; import { RTCDataChannelDetailsPane } from './rtc/rtc-data-channel-details-pane'; import { RTCMediaDetailsPane } from './rtc/rtc-media-details-pane'; import { RTCConnectionDetailsPane } from './rtc/rtc-connection-details-pane'; +import { HtkMockRule } from '../../model/rules/rules'; interface ViewPageProps { className?: string; @@ -283,6 +284,7 @@ class ViewPage extends React.Component { navigate={this.props.navigate} onDelete={this.onDelete} + onPin={this.onPin} onScrollToEvent={this.onScrollToCenterEvent} onBuildRuleFromExchange={this.onBuildRuleFromExchange} />; @@ -343,6 +345,7 @@ class ViewPage extends React.Component { { filteredEvents={filteredEvents} selectedEvent={this.selectedEvent} isPaused={isPaused} + isMultiSelectEnabled={this.props.eventsStore.isMultiSelectEnabled} moveSelection={this.moveSelection} onSelected={this.onSelected} + onMultiSelectToggled={this.onMultiSelectToggled} contextMenuBuilder={this.contextMenuBuilder} @@ -392,6 +397,17 @@ class ViewPage extends React.Component { : '/view' ); } + @action.bound + onMultiSelectToggled(){ + this.props.eventsStore.isMultiSelectEnabled = ! this.props.eventsStore.isMultiSelectEnabled; + const bulk = this.GetMultiselectSelectedBulkEvents(); + if (! bulk) + return; + bulk.forEach( evt => evt.mulitSelected=false); + if (this.selectedEvent) + this.selectedEvent.mulitSelected = true; + + } @action.bound moveSelection(distance: number) { @@ -418,25 +434,64 @@ class ViewPage extends React.Component { @action.bound onPin(event: CollectedEvent) { + const bulkEvents = this.GetMultiselectSelectedBulkEvents(); + if (bulkEvents){ + let doUnpin=false; + if (bulkEvents.every(evt => evt.pinned))//if we cant find any unpinned events then we will unpin all + doUnpin=true; + bulkEvents.forEach(evt => evt.pinned = ! doUnpin); + } + if (! bulkEvents || bulkEvents.length == 0) + this.doActualPin(event); + } + private doActualPin(event: CollectedEvent){ event.pinned = !event.pinned; } @action.bound onBuildRuleFromExchange(exchange: HttpExchange) { - const { rulesStore, navigate } = this.props; + const { navigate } = this.props; + let navRule : HtkMockRule; + const bulkEvents = this.GetMultiselectSelectedBulkEvents(); + if (bulkEvents){ + let toAdd = bulkEvents.filter( evt => evt.isHttp() && evt != exchange) as HttpExchange[]; + toAdd.forEach(evt => this.DoActualBuildRuleFromExchange(evt)); + } + const rule = this.DoActualBuildRuleFromExchange(exchange); + navigate(`/mock/${rule.id}`); + } + private DoActualBuildRuleFromExchange(exchange: HttpExchange) : HtkMockRule { + const { rulesStore } = this.props; const rule = buildRuleFromExchange(exchange); rulesStore!.draftRules.items.unshift(rule); - navigate(`/mock/${rule.id}`); + return rule; } @action.bound onDelete(event: CollectedEvent) { - const { filteredEvents } = this.filteredEventState; - // Prompt before deleting pinned events: if (event.pinned && !confirm("Delete this pinned exchange?")) return; + this.doActualDelete(event); + const bulkEvents = this.GetMultiselectSelectedBulkEvents(); + if (bulkEvents){ + if (! event.pinned){ + if (bulkEvents.some(evt => evt.pinned) && !confirm("Delete selected events even though one or more are pinned?")) return; + } + + bulkEvents.forEach( evt => this.doActualDelete(evt)); + } + } + private GetMultiselectSelectedBulkEvents() : CollectedEvent[] | null { + if (! this.props.eventsStore.isMultiSelectEnabled) + return null; + return this.filteredEventState.filteredEvents.filter(evt=> evt.mulitSelected); + } + private doActualDelete(event: CollectedEvent) { + const { filteredEvents } = this.filteredEventState; + + const rowIndex = filteredEvents.indexOf(event); const wasSelected = event === this.selectedEvent; diff --git a/src/model/events/event-base.ts b/src/model/events/event-base.ts index e6b8d683..fcff74de 100644 --- a/src/model/events/event-base.ts +++ b/src/model/events/event-base.ts @@ -1,4 +1,4 @@ -import { observable, computed } from 'mobx'; +import { observable, computed, action } from 'mobx'; import { FailedTlsConnection, @@ -38,6 +38,14 @@ export abstract class HTKEventBase { @observable public pinned: boolean = false; + @observable + public mulitSelected: boolean = false; + + @action.bound + onMultiSelected(evt : any){ + this.mulitSelected = ! this.mulitSelected; + } + // Logic elsewhere can put values into these caches to cache calculations // about this event weakly, so they GC with the event. // Keyed by symbols only, so we know we never have conflicts. diff --git a/src/model/events/events-store.ts b/src/model/events/events-store.ts index 84b3514b..1ea8fbdf 100644 --- a/src/model/events/events-store.ts +++ b/src/model/events/events-store.ts @@ -169,6 +169,9 @@ export class EventsStore { @observable isPaused = false; + @observable + isMultiSelectEnabled = false; + private eventQueue: Array = []; private orphanedEvents: { [id: string]: OrphanableQueuedEvent } = {};