profiling-explorer 1.0.0__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. profiling_explorer-1.1.0/CHANGELOG.rst +29 -0
  2. {profiling_explorer-1.0.0/src/profiling_explorer.egg-info → profiling_explorer-1.1.0}/PKG-INFO +7 -2
  3. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/README.rst +6 -1
  4. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/pyproject.toml +14 -17
  5. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/main.py +1 -1
  6. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/static/script.js +52 -21
  7. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/views.py +62 -53
  8. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0/src/profiling_explorer.egg-info}/PKG-INFO +7 -2
  9. profiling_explorer-1.0.0/CHANGELOG.rst +0 -8
  10. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/LICENSE +0 -0
  11. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/MANIFEST.in +0 -0
  12. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/setup.cfg +0 -0
  13. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/__init__.py +0 -0
  14. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/__main__.py +0 -0
  15. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/management/__init__.py +0 -0
  16. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/management/commands/__init__.py +0 -0
  17. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/management/commands/runserver.py +0 -0
  18. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/py.typed +0 -0
  19. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/settings.py +0 -0
  20. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/static/styles.css +0 -0
  21. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/templates/_row.html +0 -0
  22. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/templates/base.html +0 -0
  23. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/templates/callers_callees.html +0 -0
  24. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/templates/index.html +0 -0
  25. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/templatetags/__init__.py +0 -0
  26. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/templatetags/profiling_explorer_tags.py +0 -0
  27. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer/urls.py +0 -0
  28. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer.egg-info/SOURCES.txt +0 -0
  29. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer.egg-info/dependency_links.txt +0 -0
  30. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer.egg-info/entry_points.txt +0 -0
  31. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer.egg-info/requires.txt +0 -0
  32. {profiling_explorer-1.0.0 → profiling_explorer-1.1.0}/src/profiling_explorer.egg-info/top_level.txt +0 -0
@@ -0,0 +1,29 @@
1
+ =========
2
+ Changelog
3
+ =========
4
+
5
+ 1.1.0 (2026-04-13)
6
+ ------------------
7
+
8
+ * Extend search to support multiple terms.
9
+
10
+ `PR #33 <https://github.com/adamchainz/profiling-explorer/pull/33>`__.
11
+
12
+ * Add keyboard shortcuts to focus the search box.
13
+
14
+ `PR #35 <https://github.com/adamchainz/profiling-explorer/pull/35>`__.
15
+
16
+ * Fix callers/callees views for built-in functions.
17
+
18
+ `PR #34 <https://github.com/adamchainz/profiling-explorer/pull/34>`__.
19
+ Thanks to David Szotten for the report in `Issue #32 <https://github.com/adamchainz/profiling-explorer/issues/32>`__.
20
+
21
+ 1.0.1 (2026-04-02)
22
+ ------------------
23
+
24
+ * Fix crash when a record has an empty filename.
25
+
26
+ 1.0.0 (2026-04-02)
27
+ ------------------
28
+
29
+ * Initial release.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: profiling-explorer
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Table-based exploration tool for Python profiling data (pstats files).
5
5
  Author-email: Adam Johnson <me@adamj.eu>
6
6
  License-Expression: MIT
@@ -55,9 +55,11 @@ Table-based exploration tool for Python profiling data (pstats files).
55
55
  .. …then in another tab:
56
56
  .. uvx shot-scraper --width 1280 --height 720 --retina --wait 1000 http://127.0.0.1:8099/ -o screenshot.png
57
57
 
58
- .. figure:: https://raw.githubusercontent.com/adamchainz/profiling-explorer/screenshot/screenshot.png
58
+ .. figure:: https://raw.githubusercontent.com/adamchainz/profiling-explorer/main/screenshot.png
59
59
  :alt: profiling-explorer screenshot
60
60
 
61
+ Read more in `the introductory blog post <https://adamj.eu/tech/2026/04/03/python-introducing-profiling-explorer/>`__.
62
+
61
63
  ----
62
64
 
63
65
  **Get better at command line Git** with my book `Boost Your Git DX <https://adamchainz.gumroad.com/l/bygdx>`__.
@@ -110,6 +112,9 @@ Features:
110
112
 
111
113
  * Click the **calls**, **internal ms**, or **cumulative ms** column headers to sort by that column.
112
114
  * Use the search box to filter by filename or function name.
115
+ Separate multiple search terms by spaces to match records that contain all terms.
116
+ * Press a keyboard shortcut to focus the search box: ``/``, ``Control+K``, or ``Command+K``.
117
+ Select some text first, like a filename, to pre-fill the search box with that text.
113
118
  * Hover by a filename + line number pair to reveal the copy button, which copies the location to your clipboard for faster opening.
114
119
  * Click the **callers** or **callees** links on the right of a row to see the callers or callees of that function.
115
120
 
@@ -28,9 +28,11 @@ Table-based exploration tool for Python profiling data (pstats files).
28
28
  .. …then in another tab:
29
29
  .. uvx shot-scraper --width 1280 --height 720 --retina --wait 1000 http://127.0.0.1:8099/ -o screenshot.png
30
30
 
31
- .. figure:: https://raw.githubusercontent.com/adamchainz/profiling-explorer/screenshot/screenshot.png
31
+ .. figure:: https://raw.githubusercontent.com/adamchainz/profiling-explorer/main/screenshot.png
32
32
  :alt: profiling-explorer screenshot
33
33
 
34
+ Read more in `the introductory blog post <https://adamj.eu/tech/2026/04/03/python-introducing-profiling-explorer/>`__.
35
+
34
36
  ----
35
37
 
36
38
  **Get better at command line Git** with my book `Boost Your Git DX <https://adamchainz.gumroad.com/l/bygdx>`__.
@@ -83,6 +85,9 @@ Features:
83
85
 
84
86
  * Click the **calls**, **internal ms**, or **cumulative ms** column headers to sort by that column.
85
87
  * Use the search box to filter by filename or function name.
88
+ Separate multiple search terms by spaces to match records that contain all terms.
89
+ * Press a keyboard shortcut to focus the search box: ``/``, ``Control+K``, or ``Command+K``.
90
+ Select some text first, like a filename, to pre-fill the search box with that text.
86
91
  * Hover by a filename + line number pair to reveal the copy button, which copies the location to your clipboard for faster opening.
87
92
  * Click the **callers** or **callees** links on the right of a row to see the callers or callees of that function.
88
93
 
@@ -6,7 +6,7 @@ requires = [
6
6
 
7
7
  [project]
8
8
  name = "profiling-explorer"
9
- version = "1.0.0"
9
+ version = "1.1.0"
10
10
  description = "Table-based exploration tool for Python profiling data (pstats files)."
11
11
  readme = "README.rst"
12
12
  keywords = [
@@ -44,7 +44,7 @@ scripts.profiling-explorer = "profiling_explorer.main:main"
44
44
  [dependency-groups]
45
45
  test = [
46
46
  "cogapp>=3.6",
47
- "coverage[toml]",
47
+ "coverage[toml]>=7.10",
48
48
  "pytest",
49
49
  "pytest-django",
50
50
  "pytest-randomly",
@@ -90,6 +90,18 @@ lint.isort.required-imports = [ "from __future__ import annotations" ]
90
90
  [tool.pyproject-fmt]
91
91
  max_supported_python = "3.14"
92
92
 
93
+ [tool.mypy]
94
+ enable_error_code = [
95
+ "ignore-without-code",
96
+ "redundant-expr",
97
+ "truthy-bool",
98
+ ]
99
+ mypy_path = "src/"
100
+ namespace_packages = false
101
+ strict = true
102
+ warn_unreachable = true
103
+ overrides = [ { module = "tests.*", allow_untyped_defs = true } ]
104
+
93
105
  [tool.pytest]
94
106
  ini_options.addopts = """\
95
107
  --strict-config
@@ -111,24 +123,9 @@ paths.source = [
111
123
  "src",
112
124
  ".tox/**/site-packages",
113
125
  ]
114
- report.exclude_also = [
115
- "if TYPE_CHECKING:",
116
- ]
117
126
  report.show_missing = true
118
127
  report.skip_covered = true
119
128
  report.skip_empty = true
120
129
 
121
- [tool.mypy]
122
- enable_error_code = [
123
- "ignore-without-code",
124
- "redundant-expr",
125
- "truthy-bool",
126
- ]
127
- mypy_path = "src/"
128
- namespace_packages = false
129
- strict = true
130
- warn_unreachable = true
131
- overrides = [ { module = "tests.*", allow_untyped_defs = true } ]
132
-
133
130
  [tool.rstcheck]
134
131
  report_level = "ERROR"
@@ -17,7 +17,7 @@ def main(argv: Sequence[str] | None = None) -> int:
17
17
  argv = sys.argv[1:]
18
18
 
19
19
  parser = argparse.ArgumentParser(prog="profiling-explorer", allow_abbrev=False)
20
- parser.suggest_on_error = True # type: ignore[attr-defined]
20
+ parser.suggest_on_error = True
21
21
  parser.add_argument(
22
22
  "filename",
23
23
  metavar="FILE",
@@ -6,32 +6,63 @@ async function fetchDoc(url, options) {
6
6
 
7
7
  // Filter box
8
8
  const searchInput = document.getElementById('pe-search');
9
- let searchTimeout = null;
10
- let searchAbort = null;
11
9
 
10
+ document.addEventListener('keydown', (e) => {
11
+ const target = event.composedPath()?.[0] || event.target;
12
+ const isTextField =
13
+ target instanceof HTMLElement &&
14
+ (["TEXTAREA", "INPUT"].includes(target.tagName) ||
15
+ target.isContentEditable);
16
+
17
+ if (isTextField) {
18
+ return;
19
+ }
20
+
21
+ const selection = globalThis.getSelection()?.toString();
22
+ const keyPressed = event.key;
23
+ const ctrlOrMetaPressed = event.ctrlKey || event.metaKey;
24
+ const isSlash = keyPressed === "/" && !ctrlOrMetaPressed;
25
+ const isCtrlK = keyPressed === "k" && ctrlOrMetaPressed && !event.shiftKey;
26
+
27
+ if (isSlash || isCtrlK) {
28
+ event.preventDefault();
29
+ searchInput.focus();
30
+ if (selection) {
31
+ searchInput.value = selection;
32
+ clearTimeout(searchTimeout);
33
+ searchTimeout = setTimeout(doSearch, 0);
34
+ }
35
+ }
36
+ });
37
+
38
+ let searchTimeout = null;
12
39
  searchInput.addEventListener('input', () => {
13
40
  clearTimeout(searchTimeout);
14
- searchTimeout = setTimeout(async () => {
15
- const q = searchInput.value.trim();
16
- const url = new URL(window.location.href);
17
- if (q) {
18
- url.searchParams.set('q', q);
19
- } else {
20
- url.searchParams.delete('q');
21
- }
22
- history.replaceState(null, '', url);
23
-
24
- searchAbort?.abort();
25
- searchAbort = new AbortController();
26
- try {
27
- const doc = await fetchDoc(url, { signal: searchAbort.signal });
28
- document.querySelector('main').replaceWith(doc.querySelector('main'));
29
- } catch (e) {
30
- if (e.name !== 'AbortError') throw e;
31
- }
32
- }, 300);
41
+ searchTimeout = setTimeout(doSearch, 300);
33
42
  });
34
43
 
44
+ let searchAbort = null;
45
+
46
+ async function doSearch() {
47
+ const q = searchInput.value.trim();
48
+ const url = new URL(window.location.href);
49
+ if (q) {
50
+ url.searchParams.set('q', q);
51
+ } else {
52
+ url.searchParams.delete('q');
53
+ }
54
+ history.replaceState(null, '', url);
55
+
56
+ searchAbort?.abort();
57
+ searchAbort = new AbortController();
58
+ try {
59
+ const doc = await fetchDoc(url, { signal: searchAbort.signal });
60
+ document.querySelector('main').replaceWith(doc.querySelector('main'));
61
+ } catch (e) {
62
+ if (e.name !== 'AbortError') throw e;
63
+ }
64
+ }
65
+
35
66
  // Copy buttons
36
67
  document.addEventListener('click', (e) => {
37
68
  const btn = e.target.closest('button.copy-btn');
@@ -4,6 +4,7 @@ import hashlib
4
4
  import os
5
5
  import pstats
6
6
  import re
7
+ from collections import defaultdict
7
8
  from dataclasses import dataclass
8
9
  from importlib.resources import files as resource_files
9
10
  from typing import Any
@@ -23,11 +24,6 @@ class RowStats:
23
24
  cumulative_ms_pct: float
24
25
 
25
26
 
26
- @dataclass(slots=True)
27
- class EdgeStats(RowStats):
28
- pass
29
-
30
-
31
27
  @dataclass(slots=True)
32
28
  class Row(RowStats):
33
29
  id: str
@@ -44,8 +40,8 @@ class Profile:
44
40
  total_time_ms: int
45
41
  rows: list[Row]
46
42
  rows_by_id: dict[str, Row]
47
- callers_map: dict[str, dict[str, EdgeStats]]
48
- callees_map: dict[str, dict[str, EdgeStats]]
43
+ callers_map: defaultdict[str, dict[str, RowStats]]
44
+ callees_map: defaultdict[str, dict[str, RowStats]]
49
45
 
50
46
 
51
47
  # Populated by main()
@@ -68,11 +64,21 @@ _STRIP_PREFIX_RE = re.compile(
68
64
  )
69
65
 
70
66
 
71
- def _shorten_filename(filename: str) -> str:
72
- match = _STRIP_PREFIX_RE.match(filename)
73
- if match:
74
- return filename[match.end() :]
75
- return os.path.relpath(filename)
67
+ def _shorten_filename_function(filename: str, funcname: str) -> tuple[str, str, str]:
68
+ if filename == "~":
69
+ if funcname.startswith("<") and funcname.endswith(">"):
70
+ funcname = f"{{{funcname[1:-1]}}}"
71
+ short_filename = ""
72
+ full_filename = ""
73
+ else:
74
+ if filename == "":
75
+ short_filename = ""
76
+ match = _STRIP_PREFIX_RE.match(filename)
77
+ if match:
78
+ short_filename = filename[match.end() :]
79
+ short_filename = os.path.relpath(filename)
80
+ full_filename = filename
81
+ return full_filename, short_filename, funcname
76
82
 
77
83
 
78
84
  def _row_id(full_filename: str, lineno: int, funcname: str) -> str:
@@ -93,17 +99,14 @@ def build_profile(s: pstats.Stats, path: str) -> Profile:
93
99
  total_time_ms = round(s.total_tt * 1000) # type: ignore[attr-defined]
94
100
  rows = []
95
101
  rows_by_id = {}
102
+ callers_map: defaultdict[str, dict[str, RowStats]] = defaultdict(dict)
103
+ callees_map: defaultdict[str, dict[str, RowStats]] = defaultdict(dict)
96
104
  for key in s.fcn_list: # type: ignore[attr-defined]
97
105
  filename, lineno, funcname = key
98
- _, calls, tottime, cumtime, _ = s.stats[key] # type: ignore[attr-defined]
99
- if filename == "~":
100
- if funcname.startswith("<") and funcname.endswith(">"):
101
- funcname = f"{{{funcname[1:-1]}}}"
102
- short_filename = ""
103
- full_filename = ""
104
- else:
105
- short_filename = _shorten_filename(filename)
106
- full_filename = filename
106
+ _, calls, tottime, cumtime, callers = s.stats[key] # type: ignore[attr-defined]
107
+ full_filename, short_filename, funcname = _shorten_filename_function(
108
+ filename, funcname
109
+ )
107
110
  row_id = _row_id(full_filename, lineno, funcname)
108
111
  cumulative_ms = round(cumtime * 1_000)
109
112
  row = Row(
@@ -122,31 +125,32 @@ def build_profile(s: pstats.Stats, path: str) -> Profile:
122
125
  )
123
126
  rows.append(row)
124
127
  rows_by_id[row_id] = row
125
-
126
- def make_edge(enc: int, etc: float) -> EdgeStats:
127
- cumulative_ms = round(etc * 1_000)
128
- return EdgeStats(
129
- calls=enc,
130
- calls_pct=min(100.0, enc / total_calls * 100) if total_calls else 0.0,
131
- internal_ms=None,
132
- cumulative_ms=cumulative_ms,
133
- cumulative_ms_pct=(
134
- min(100.0, cumulative_ms / total_time_ms * 100)
135
- if total_time_ms
136
- else 0.0
137
- ),
138
- )
139
-
140
- callers_map: dict[str, dict[str, EdgeStats]] = {}
141
- callees_map: dict[str, dict[str, EdgeStats]] = {}
142
- for (filename, lineno, funcname), (*_, callers) in s.stats.items(): # type: ignore[attr-defined]
143
- full_filename = "" if filename == "~" else filename
144
- callee_id = _row_id(full_filename, lineno, funcname)
145
- for caller_key, (enc, _, __, etc) in callers.items():
146
- caller_id = _row_id_from_pstats_key(caller_key)
147
- edge = make_edge(enc, etc)
148
- callers_map.setdefault(callee_id, {})[caller_id] = edge
149
- callees_map.setdefault(caller_id, {})[callee_id] = edge
128
+ for (caller_filename, caller_lineno, caller_funcname), (
129
+ callee_calls,
130
+ _,
131
+ __,
132
+ callee_cumtime,
133
+ ) in callers.items():
134
+ caller_filename, _, caller_funcname = _shorten_filename_function(
135
+ caller_filename, caller_funcname
136
+ )
137
+ caller_id = _row_id(caller_filename, caller_lineno, caller_funcname)
138
+ callee_cumulative_ms = round(callee_cumtime * 1_000)
139
+ edge = RowStats(
140
+ calls=callee_calls,
141
+ calls_pct=min(100.0, callee_calls / total_calls * 100)
142
+ if total_calls
143
+ else 0.0,
144
+ internal_ms=None,
145
+ cumulative_ms=callee_cumulative_ms,
146
+ cumulative_ms_pct=(
147
+ min(100.0, cumulative_ms / total_time_ms * 100)
148
+ if total_time_ms
149
+ else 0.0
150
+ ),
151
+ )
152
+ callers_map[row_id][caller_id] = edge
153
+ callees_map[caller_id][row_id] = edge
150
154
 
151
155
  return Profile(
152
156
  filename=path,
@@ -160,8 +164,8 @@ def build_profile(s: pstats.Stats, path: str) -> Profile:
160
164
 
161
165
 
162
166
  def _build_edge_rows(
163
- edges: dict[str, EdgeStats],
164
- ) -> list[tuple[Row, EdgeStats]]:
167
+ edges: dict[str, RowStats],
168
+ ) -> list[tuple[Row, RowStats]]:
165
169
  result = [
166
170
  (row, edge)
167
171
  for row_id, edge in edges.items()
@@ -181,7 +185,7 @@ def _render_table(
181
185
  rows: list[Row],
182
186
  template: str,
183
187
  extra_context: dict[str, Any],
184
- edge_stats: dict[str, EdgeStats] | None = None,
188
+ edge_stats: dict[str, RowStats] | None = None,
185
189
  ) -> HttpResponse:
186
190
  sort_param = request.GET.get("sort", "-cumulative_ms")
187
191
  sort_desc = not sort_param.startswith("+")
@@ -192,9 +196,14 @@ def _render_table(
192
196
  sort_param = "-cumulative_ms"
193
197
 
194
198
  q = request.GET.get("q", "").strip()
199
+ queries = [query.strip() for query in q.split() if q.strip()]
195
200
  filtered_rows = rows
196
- if q:
197
- filtered_rows = [r for r in rows if q in r.filename or q in r.funcname]
201
+ if queries:
202
+ filtered_rows = [
203
+ r
204
+ for r in rows
205
+ if all(query in r.filename or query in r.funcname for query in queries)
206
+ ]
198
207
 
199
208
  if edge_stats is None:
200
209
  sorted_rows = sorted(
@@ -255,14 +264,14 @@ def index(request: HttpRequest) -> HttpResponse:
255
264
  def _callers_callees_view(
256
265
  request: HttpRequest,
257
266
  focal_row: Row,
258
- edges: dict[str, EdgeStats],
267
+ edges: dict[str, RowStats],
259
268
  opposite_url: str,
260
269
  heading: str,
261
270
  opposite_label: str,
262
271
  ) -> HttpResponse:
263
272
  rows_with_edges = _build_edge_rows(edges)
264
273
  rows = [r for r, _ in rows_with_edges]
265
- edge_stats: dict[str, EdgeStats] = {r.id: e for r, e in rows_with_edges}
274
+ edge_stats: dict[str, RowStats] = {r.id: e for r, e in rows_with_edges}
266
275
 
267
276
  return _render_table(
268
277
  request,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: profiling-explorer
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Table-based exploration tool for Python profiling data (pstats files).
5
5
  Author-email: Adam Johnson <me@adamj.eu>
6
6
  License-Expression: MIT
@@ -55,9 +55,11 @@ Table-based exploration tool for Python profiling data (pstats files).
55
55
  .. …then in another tab:
56
56
  .. uvx shot-scraper --width 1280 --height 720 --retina --wait 1000 http://127.0.0.1:8099/ -o screenshot.png
57
57
 
58
- .. figure:: https://raw.githubusercontent.com/adamchainz/profiling-explorer/screenshot/screenshot.png
58
+ .. figure:: https://raw.githubusercontent.com/adamchainz/profiling-explorer/main/screenshot.png
59
59
  :alt: profiling-explorer screenshot
60
60
 
61
+ Read more in `the introductory blog post <https://adamj.eu/tech/2026/04/03/python-introducing-profiling-explorer/>`__.
62
+
61
63
  ----
62
64
 
63
65
  **Get better at command line Git** with my book `Boost Your Git DX <https://adamchainz.gumroad.com/l/bygdx>`__.
@@ -110,6 +112,9 @@ Features:
110
112
 
111
113
  * Click the **calls**, **internal ms**, or **cumulative ms** column headers to sort by that column.
112
114
  * Use the search box to filter by filename or function name.
115
+ Separate multiple search terms by spaces to match records that contain all terms.
116
+ * Press a keyboard shortcut to focus the search box: ``/``, ``Control+K``, or ``Command+K``.
117
+ Select some text first, like a filename, to pre-fill the search box with that text.
113
118
  * Hover by a filename + line number pair to reveal the copy button, which copies the location to your clipboard for faster opening.
114
119
  * Click the **callers** or **callees** links on the right of a row to see the callers or callees of that function.
115
120
 
@@ -1,8 +0,0 @@
1
- =========
2
- Changelog
3
- =========
4
-
5
- 1.0.0 (2026-04-02)
6
- ------------------
7
-
8
- * Initial release.