Skip to content

Commit 8a0c670

Browse files
committed
Implement column sorting in the marker table.
The sort is also persisted in the URL.
1 parent 0e1521d commit 8a0c670

10 files changed

Lines changed: 247 additions & 20 deletions

File tree

src/actions/profile-view.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import {
8282
import { changeStoredProfileNameInDb } from 'firefox-profiler/app-logic/uploaded-profiles-db';
8383
import type { TabSlug } from '../app-logic/tabs-handling';
8484
import type { CallNodeInfo } from '../profile-logic/call-node-info';
85+
import type { SingleColumnSortState } from '../components/shared/TreeView';
8586
import { intersectSets } from 'firefox-profiler/utils/set';
8687

8788
/**
@@ -1615,6 +1616,13 @@ export function changeMarkersSearchString(searchString: string): Action {
16151616
};
16161617
}
16171618

1619+
export function changeMarkerTableSort(sort: SingleColumnSortState[]): Action {
1620+
return {
1621+
type: 'CHANGE_MARKER_TABLE_SORT',
1622+
sort,
1623+
};
1624+
}
1625+
16181626
export function changeNetworkSearchString(searchString: string): Action {
16191627
return {
16201628
type: 'CHANGE_NETWORK_SEARCH_STRING',

src/app-logic/url-handling.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ import { tabSlugs } from '../app-logic/tabs-handling';
5252
import { StringTable } from 'firefox-profiler/utils/string-table';
5353
import type { ProfileUpgradeInfo } from 'firefox-profiler/profile-logic/processed-profile-versioning';
5454
import type { ProfileAndProfileUpgradeInfo } from 'firefox-profiler/actions/receive-profile';
55+
import type { SingleColumnSortState } from '../components/shared/TreeView';
5556

56-
export const CURRENT_URL_VERSION = 16;
57+
export const CURRENT_URL_VERSION = 17;
5758

5859
/**
5960
* This static piece of state might look like an anti-pattern, but it's a relatively
@@ -190,6 +191,7 @@ type CallTreeQuery = BaseQuery & {
190191
type MarkersQuery = BaseQuery & {
191192
markerSearch: string; // "DOMEvent"
192193
marker?: MarkerIndex; // Selected marker index for the current thread, e.g. 42
194+
markerSort?: string; // "duration:desc,start:asc" — primary first
193195
};
194196

195197
type NetworkQuery = BaseQuery & {
@@ -228,6 +230,7 @@ type Query = BaseQuery & {
228230
// Markers specific
229231
markerSearch?: string;
230232
marker?: MarkerIndex;
233+
markerSort?: string;
231234

232235
// Network specific
233236
networkSearch?: string;
@@ -394,6 +397,9 @@ export function getQueryStringFromUrlState(urlState: UrlState): string {
394397
urlState.profileSpecific.selectedMarkers[selectedThreadsKey] !== null
395398
? urlState.profileSpecific.selectedMarkers[selectedThreadsKey]
396399
: undefined;
400+
query.markerSort = convertMarkerTableSortToString(
401+
urlState.profileSpecific.markerTableSort
402+
);
397403
break;
398404
case 'network-chart':
399405
query = baseQuery as NetworkQueryShape;
@@ -632,10 +638,59 @@ export function stateFromLocation(
632638
? query.hiddenThreads.split('-').map((index) => Number(index))
633639
: null,
634640
selectedMarkers,
641+
markerTableSort: convertMarkerTableSortFromString(query.markerSort),
635642
},
636643
};
637644
}
638645

646+
// MarkerTable sort URL encoding. The internal ColumnSortState stores the
647+
// primary-sorted column last (newest click wins as primary); the URL puts the
648+
// primary first for human readability.
649+
const VALID_MARKER_SORT_COLUMNS = new Set(['start', 'duration', 'name']);
650+
651+
function convertMarkerTableSortToString(
652+
sort: SingleColumnSortState[]
653+
): string | undefined {
654+
if (sort.length === 0) {
655+
return undefined;
656+
}
657+
// Omit when it matches the marker table's own default.
658+
if (sort.length === 1 && sort[0].column === 'start' && sort[0].ascending) {
659+
return undefined;
660+
}
661+
return sort
662+
.slice()
663+
.reverse()
664+
.map((s) => `${s.column}-${s.ascending ? 'asc' : 'desc'}`)
665+
.join('~');
666+
}
667+
668+
function convertMarkerTableSortFromString(
669+
raw: string | null | void
670+
): SingleColumnSortState[] {
671+
if (!raw) {
672+
return [];
673+
}
674+
const parsed: SingleColumnSortState[] = [];
675+
for (const part of raw.split('~')) {
676+
const dashIndex = part.lastIndexOf('-');
677+
if (dashIndex === -1) {
678+
return [];
679+
}
680+
const column = part.slice(0, dashIndex);
681+
const dir = part.slice(dashIndex + 1);
682+
if (
683+
!VALID_MARKER_SORT_COLUMNS.has(column) ||
684+
(dir !== 'asc' && dir !== 'desc')
685+
) {
686+
return [];
687+
}
688+
parsed.push({ column, ascending: dir === 'asc' });
689+
}
690+
// URL is primary-first; internal storage is primary-last.
691+
return parsed.reverse();
692+
}
693+
639694
function convertGlobalTrackOrderFromString(
640695
rawString: string | null | void
641696
): TrackIndex[] {
@@ -1443,6 +1498,11 @@ const _upgraders: {
14431498
.join('~');
14441499
}
14451500
},
1501+
[17]: (_processedLocation: ProcessedLocationBeforeUpgrade) => {
1502+
// Adds the optional `markerSort` query parameter for the marker table.
1503+
// No migration is necessary: older URLs simply omit it and the default
1504+
// (sort by start ascending) is used.
1505+
},
14461506
};
14471507

14481508
/**

src/components/marker-table/index.tsx

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { PureComponent } from 'react';
66
import memoize from 'memoize-immutable';
77

88
import explicitConnect from '../../utils/connect';
9-
import { type SortableColumn, type Tree, TreeView } from '../shared/TreeView';
9+
import { ColumnSortState, TreeView } from '../shared/TreeView';
1010
import { MarkerTableEmptyReasons } from './MarkerTableEmptyReasons';
1111
import {
1212
getZeroAt,
@@ -15,11 +15,15 @@ import {
1515
getCurrentTableViewOptions,
1616
} from '../../selectors/profile';
1717
import { selectedThreadSelectors } from '../../selectors/per-thread';
18-
import { getSelectedThreadsKey } from '../../selectors/url-state';
18+
import {
19+
getSelectedThreadsKey,
20+
getMarkerTableSort,
21+
} from '../../selectors/url-state';
1922
import {
2023
changeSelectedMarker,
2124
changeRightClickedMarker,
2225
changeTableViewOptions,
26+
changeMarkerTableSort,
2327
} from '../../actions/profile-view';
2428
import { MarkerSettings } from '../shared/MarkerSettings';
2529
import { formatSeconds, formatTimestamp } from '../../utils/format-numbers';
@@ -37,12 +41,21 @@ import type {
3741
TableViewOptions,
3842
SelectionContext,
3943
} from 'firefox-profiler/types';
44+
import type {
45+
SingleColumnSortState,
46+
Tree,
47+
SortableColumn,
48+
} from '../shared/TreeView';
4049

4150
import type { ConnectedProps } from '../../utils/connect';
4251

4352
// Limit how many characters in the description get sent to the DOM.
4453
const MAX_DESCRIPTION_CHARACTERS = 500;
4554

55+
const DEFAULT_MARKER_TABLE_SORT: SingleColumnSortState[] = [
56+
{ column: 'start', ascending: true },
57+
];
58+
4659
type MarkerDisplayData = {
4760
start: string;
4861
duration: string | null;
@@ -73,8 +86,14 @@ class MarkerTree implements Tree<MarkerDisplayData> {
7386
this._getMarkerLabel = getMarkerLabel;
7487
}
7588

89+
static _sortableColumns: SortableColumn[] = [
90+
{ name: 'start', prefersDescending: false },
91+
{ name: 'duration', prefersDescending: true },
92+
{ name: 'name', prefersDescending: false },
93+
];
94+
7695
getSortableColumns(): SortableColumn[] {
77-
return [];
96+
return MarkerTree._sortableColumns;
7897
}
7998

8099
copyTable = (
@@ -171,12 +190,49 @@ class MarkerTree implements Tree<MarkerDisplayData> {
171190
copy(text);
172191
};
173192

174-
getRoots(): MarkerIndex[] {
193+
getRoots(sort: ColumnSortState | null = null): MarkerIndex[] {
194+
if (sort !== null) {
195+
return sort.sortItemsHelper(
196+
this._markerIndexes,
197+
(first: MarkerIndex, second: MarkerIndex, column: string) => {
198+
const firstValue = this._getSortValueForColumn(first, column);
199+
const secondValue = this._getSortValueForColumn(second, column);
200+
if (typeof firstValue === 'string') {
201+
return firstValue.localeCompare(secondValue as string);
202+
}
203+
return (firstValue as number) - (secondValue as number);
204+
}
205+
);
206+
}
175207
return this._markerIndexes;
176208
}
177209

178-
getChildren(markerIndex: MarkerIndex): MarkerIndex[] {
179-
return markerIndex === -1 ? this.getRoots() : [];
210+
getChildren(
211+
markerIndex: MarkerIndex,
212+
sort: ColumnSortState | null = null
213+
): MarkerIndex[] {
214+
return markerIndex === -1 ? this.getRoots(sort) : [];
215+
}
216+
217+
_getSortValueForColumn(
218+
markerIndex: MarkerIndex,
219+
column: string
220+
): string | number {
221+
const marker = this._getMarker(markerIndex);
222+
switch (column) {
223+
case 'start':
224+
return marker.start;
225+
case 'duration': {
226+
if (marker.incomplete || marker.end === null) {
227+
return -Infinity;
228+
}
229+
return marker.end - marker.start;
230+
}
231+
case 'name':
232+
return marker.name;
233+
default:
234+
throw new Error('Invalid column ' + column);
235+
}
180236
}
181237

182238
hasChildren(_markerIndex: MarkerIndex): boolean {
@@ -209,10 +265,11 @@ class MarkerTree implements Tree<MarkerDisplayData> {
209265
}
210266

211267
let duration = null;
268+
const markerEnd = marker.end;
212269
if (marker.incomplete) {
213270
duration = 'unknown';
214-
} else if (marker.end !== null) {
215-
duration = formatTimestamp(marker.end - marker.start);
271+
} else if (markerEnd !== null) {
272+
duration = formatTimestamp(markerEnd - marker.start);
216273
}
217274

218275
displayData = {
@@ -242,12 +299,14 @@ type StateProps = {
242299
readonly markerSchemaByName: MarkerSchemaByName;
243300
readonly getMarkerLabel: (param: MarkerIndex) => string;
244301
readonly tableViewOptions: TableViewOptions;
302+
readonly sort: SingleColumnSortState[];
245303
};
246304

247305
type DispatchProps = {
248306
readonly changeSelectedMarker: typeof changeSelectedMarker;
249307
readonly changeRightClickedMarker: typeof changeRightClickedMarker;
250308
readonly onTableViewOptionsChange: (param: TableViewOptions) => any;
309+
readonly changeMarkerTableSort: typeof changeMarkerTableSort;
251310
};
252311

253312
type Props = ConnectedProps<{}, StateProps, DispatchProps>;
@@ -284,6 +343,11 @@ class MarkerTableImpl extends PureComponent<Props> {
284343
this._treeView = treeView;
285344
};
286345

346+
_getSortedColumns = memoize(
347+
(sort: SingleColumnSortState[]) =>
348+
new ColumnSortState(sort.length > 0 ? sort : DEFAULT_MARKER_TABLE_SORT)
349+
);
350+
287351
getMarkerTree = memoize(
288352
(
289353
getMarker: any,
@@ -335,6 +399,10 @@ class MarkerTableImpl extends PureComponent<Props> {
335399
changeSelectedMarker(threadsKey, selectedMarker, context);
336400
};
337401

402+
_onColumnSortChange = (sortedColumns: ColumnSortState) => {
403+
this.props.changeMarkerTableSort(sortedColumns.sortedColumns);
404+
};
405+
338406
_onRightClickSelection = (selectedMarker: MarkerIndex) => {
339407
const { threadsKey, changeRightClickedMarker } = this.props;
340408
changeRightClickedMarker(threadsKey, selectedMarker);
@@ -385,6 +453,8 @@ class MarkerTableImpl extends PureComponent<Props> {
385453
indentWidth={10}
386454
viewOptions={this.props.tableViewOptions}
387455
onViewOptionsChange={this.props.onTableViewOptionsChange}
456+
sortedColumns={this._getSortedColumns(this.props.sort)}
457+
onColumnSortChange={this._onColumnSortChange}
388458
/>
389459
)}
390460
</div>
@@ -405,12 +475,14 @@ export const MarkerTable = explicitConnect<{}, StateProps, DispatchProps>({
405475
markerSchemaByName: getMarkerSchemaByName(state),
406476
getMarkerLabel: selectedThreadSelectors.getMarkerTableLabelGetter(state),
407477
tableViewOptions: getCurrentTableViewOptions(state),
478+
sort: getMarkerTableSort(state),
408479
}),
409480
mapDispatchToProps: {
410481
changeSelectedMarker,
411482
changeRightClickedMarker,
412483
onTableViewOptionsChange: (tableViewOptions) =>
413484
changeTableViewOptions('marker-table', tableViewOptions),
485+
changeMarkerTableSort,
414486
},
415487
component: MarkerTableImpl,
416488
});

src/reducers/url-state.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type {
2727
} from 'firefox-profiler/types';
2828

2929
import type { TabSlug } from '../app-logic/tabs-handling';
30+
import type { SingleColumnSortState } from '../components/shared/TreeView';
3031
import { translateThreadsKey } from 'firefox-profiler/profile-logic/profile-data';
3132
import { translateTransformStack } from 'firefox-profiler/profile-logic/transforms';
3233

@@ -193,6 +194,18 @@ const markersSearchString: Reducer<string> = (state = '', action) => {
193194
}
194195
};
195196

197+
const markerTableSort: Reducer<SingleColumnSortState[]> = (
198+
state = [],
199+
action
200+
) => {
201+
switch (action.type) {
202+
case 'CHANGE_MARKER_TABLE_SORT':
203+
return action.sort;
204+
default:
205+
return state;
206+
}
207+
};
208+
196209
const networkSearchString: Reducer<string> = (state = '', action) => {
197210
switch (action.type) {
198211
case 'CHANGE_NETWORK_SEARCH_STRING':
@@ -793,6 +806,7 @@ const profileSpecific = combineReducers({
793806
showJsTracerSummary,
794807
tabFilter,
795808
selectedMarkers,
809+
markerTableSort,
796810
// The timeline tracks used to be hidden and sorted by thread indexes, rather than
797811
// track indexes. The only way to migrate this information to tracks-based data is to
798812
// first retrieve the profile, so they can't be upgraded by the normal url upgrading

src/selectors/url-state.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import type {
3636

3737
import type { TabSlug } from '../app-logic/tabs-handling';
3838
import type { MarkerRegExps } from '../profile-logic/marker-data';
39+
import type { SingleColumnSortState } from '../components/shared/TreeView';
3940

4041
import urlStateReducer from '../reducers/url-state';
4142
import { formatMetaInfoString } from '../profile-logic/profile-metainfo';
@@ -117,6 +118,8 @@ export const getCurrentSearchString: Selector<string> = (state) =>
117118
getProfileSpecificState(state).callTreeSearchString;
118119
export const getMarkersSearchString: Selector<string> = (state) =>
119120
getProfileSpecificState(state).markersSearchString;
121+
export const getMarkerTableSort: Selector<SingleColumnSortState[]> = (state) =>
122+
getProfileSpecificState(state).markerTableSort;
120123
export const getNetworkSearchString: Selector<string> = (state) =>
121124
getProfileSpecificState(state).networkSearchString;
122125
export const getSelectedTab: Selector<TabSlug> = (state) =>

src/test/components/MarkerTable.test.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,9 @@ describe('MarkerTable', function () {
478478
let dividerForFirstColumn = ensureExists(
479479
document.querySelector('.treeViewHeaderColumnDivider')
480480
);
481-
let firstColumn = screen.getByText('Start');
481+
let firstColumn = ensureExists(
482+
document.querySelector('.treeViewHeaderColumn.start')
483+
);
482484
expect(firstColumn).toHaveStyle({ width: '90px' });
483485
fireEvent.mouseDown(dividerForFirstColumn, { clientX: 90 });
484486

@@ -505,7 +507,9 @@ describe('MarkerTable', function () {
505507
);
506508

507509
// Make sure the first column kept its width
508-
firstColumn = screen.getByText('Start');
510+
firstColumn = ensureExists(
511+
document.querySelector('.treeViewHeaderColumn.start')
512+
);
509513
expect(firstColumn).toHaveStyle({ width: '80px' });
510514

511515
// Now double click to reset the style.

0 commit comments

Comments
 (0)