Repository: PrecursorApp/om-i
Branch: master
Commit: 30c1f248a046
Files: 10
Total size: 23.2 KB
Directory structure:
gitextract_8uedg_ze/
├── .gitignore
├── CHANGES.md
├── README.md
├── circle.yml
├── project.clj
├── resources/
│ ├── om-i.css
│ └── om-i.less
└── src/
└── om_i/
├── core.cljs
├── hacks.cljs
└── keyboard.cljs
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
pom.xml
*jar
/lib/
/classes/
/out/
/target/
.lein-deps-sum
.lein-repl-history
.lein-plugins/
/script/
================================================
FILE: CHANGES.md
================================================
## 0.1.8
### Changes
* Stop depending on _rootNodeId so that we can support React 0.13.
## 0.1.7
### Changes
* Fix ordering of render-ms and mount-ms
## 0.1.6
### Changes
* fix borked release to Clojars of 0.1.5
## 0.1.5
### Changes
* Note: this released was messed up somehow, use 0.1.6 instead
* fix un-required goog.string.format. Thanks [@cldwalker](https://github.com/cldwalker)!
## 0.1.4
### Changes
* fixes compilation error from requiring `goog`
## 0.1.3
### Changes
* remove unused cljs-time requires
================================================
FILE: README.md
================================================
# Om-instrumentation
Instrumentation helper for Om applications.
[](https://circleci.com/gh/PrecursorApp/om-i)
## Overview
Om-i (pronounced "Oh, my!") helps you identify the components in your [Om](https://github.com/omcljs/om) application that are being passed too much of your app state and rendering unnecessarily. It provides useful statistics about render times and frequencies for all of your components.
You can see it live on [Precursor](https://precursorapp.com), a collaborative drawing application. Use Ctrl+Alt+Shift+j to toggle it.
<a href="https://precursorapp.com">
<img src="http://dtwdl3ecuoduc.cloudfront.net/om-i/instrumentation.gif" />
</a>
## Setup
### Dependencies
Add Om-i to your project's dependencies
```
[precursor/om-i "0.1.8"]
```
### Enable the instrumentation
Use Om-i's custom descriptor so that it can gather render times for your components. To enable it globally, use the `:instrument` opt in `om/root`
```clojure
(om/root
app-component
app-state
{:target container
:instrument (fn [f cursor m]
(om/build* f cursor
(assoc m
:descriptor om-i.core/instrumentation-methods)))})
```
### Mount the component
Add the following somewhere in your setup code. If you're using figwheel, place it somewhere that won't get reloaded.
```clojure
(om-i.core/setup-component-stats!)
```
Om-i renders its statistics in a separate root so that it doesn't interact with your application.
It will create a `div` in the body with classname "om-instrumentation" by default and assign three keyboard shortcuts: Ctrl+Alt+Shift+j to bring down the statistics menu, Ctrl+Alt+Shift+k to clear the statistics, and Ctrl+Alt+Shift+s to switch the sort order.
You can override the defaults with:
```clojure
(om-i.core/setup-component-stats! {:class "om-instrumentation"
:clear-shortcut #{"ctrl" "alt" "shift" "k"}
:toggle-shortcut #{"ctrl" "alt" "shift" "j"}
:sort-shorcut #{"ctrl" "alt" "shift" "s"}})
```
### Styles
You need to set up css styles to handle displaying the instrumentation when it's opened. There are sample less and css files in the resources directory.
If you want to try out Om-i, or just use it in development, we've provided a helper that will embed a style tag with the syles from resources/om-i.min.css.
```clojure
(om-i.hacks/insert-styles)
```
It's not recommended to use this in production.
### Wrapping a pre-existing descriptor
If you're already using a custom descriptor, you can still use Om-i. Here's an example wrapping Om's `no-local-descriptor`.
```clojure
(let [methods (om-i.core/instrument-methods om/no-local-state-methods)
descriptor (om/no-local-descriptor methods)]
(om/root
app-component
app-state
{:target container
:instrument (fn [f cursor m]
(om/build* f cursor (assoc m :descriptor descriptor)))}))
```
## Acknowledgements
Thanks to [@sgrove](https://github.com/sgrove) for his keyboard handling code. Om-i uses a minimal version of the code he wrote for Precursor. There is an older, [public version of the code in Omchaya](https://github.com/sgrove/omchaya/blob/master/src/omchaya/components/key_queue.cljs).
Thanks to [@brandonbloom](https://github.com/brandonbloom) for demonstrating how to use descriptors in Om. [Related blog post](http://blog.circleci.com/local-state-global-concerns/).
Thanks to [@swannodette](https://github.com/swannodette) for releasing Om.
## License
Copyright © 2015 PrecursorApp
Distributed under the Eclipse Public License either version 1.0 or (at your option) any later version.
================================================
FILE: circle.yml
================================================
dependencies:
post:
- lein cljsbuild once
test:
override:
- echo no tests
================================================
FILE: project.clj
================================================
(defproject precursor/om-i "0.1.8"
:description "Instrumentation helpers for Om applications"
:url "https://github.com/PrecursorApp/om-i"
:license {:name "Eclipse Public License"
:url "http://www.eclipse.org/legal/epl-v10.html"}
:dependencies [[org.clojure/clojure "1.6.0"]
[org.clojure/clojurescript "0.0-2755" :scope "provided"]
[org.omcljs/om "0.8.8" :scope "provided"]]
:plugins [[lein-cljsbuild "1.0.4"]]
:cljsbuild {:builds [{:id "test"
:source-paths ["src"]
:compiler {:output-to "script/tests.simple.js"
:output-dir "script/out"
:source-map "script/tests.simple.js.map"
:output-wrapper false
:optimizations :simple}}]}
:source-paths ["src"])
================================================
FILE: resources/om-i.css
================================================
@keyframes in-fade-top-soft {
0% {
opacity: 0;
transform: translate3d(0, -4rem, 0);
}
100% {
opacity: 1;
transform: none;
}
}
@-webkit-keyframes in-fade-top-soft {
0% {
opacity: 0;
-webkit-transform: translate3d(0, -4rem, 0);
}
100% {
opacity: 1;
-webkit-transform: none;
}
}
.om-instrumentation {
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
pointer-events: none;
color: #888888;
position: fixed;
z-index: 1000;
top: 0;
left: 0;
width: 100%;
}
.instrumentation-table {
-webkit-animation: in-fade-top-soft 500ms;
animation: in-fade-top-soft 500ms;
background-color: rgba(0, 0, 0, 0.6);
font-family: Monaco, monospace;
font-size: .75rem;
line-height: 2;
width: 100%;
}
.instrumentation-table th {
color: #ffffff;
line-height: 1rem;
padding: 1.5rem 0;
text-transform: uppercase;
text-align: left;
}
.instrumentation-table th:not(:first-child) {
text-align: center;
}
.instrumentation-table th.left {
text-align: left;
}
.instrumentation-table th.right {
text-align: right;
}
.instrumentation-table th,
.instrumentation-table td {
white-space: pre-wrap;
}
.instrumentation-table th:first-child,
.instrumentation-table td:first-child {
padding-left: 1.5rem;
}
.instrumentation-table tbody tr:nth-child(odd) {
background-color: rgba(0, 0, 0, 0.4);
}
.instrumentation-table tbody td:nth-child(even) {
text-align: right;
border-right: 1px dashed;
padding-right: .5em;
}
.instrumentation-table tbody td:nth-child(odd) {
text-align: left;
padding-left: .5em;
}
.instrumentation-table tbody td:first-child {
padding-left: 1.5rem;
}
.instrumentation-table tfoot td {
line-height: 1rem;
text-align: center;
padding: 1.5rem 0;
}
.instrumentation-table small {
font-size: 1em;
opacity: .5;
}
================================================
FILE: resources/om-i.less
================================================
@black: #000;
@white: #fff;
@gray: #888;
@font_mono: Monaco, monospace;
@zindex-instrumentation: 1000;
@tile: (1rem * 4);
@menu_padding: 1.5rem;
@keyframes in-fade-top-soft {
0% { opacity: 0; transform: translate3d(0, -@tile, 0);}
100% { opacity: 1; transform: none;}
}
@-webkit-keyframes in-fade-top-soft {
0% { opacity: 0; -webkit-transform: translate3d(0, -@tile, 0);}
100% { opacity: 1; -webkit-transform: none;}
}
.om-instrumentation {
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
pointer-events: none;
color: @gray;
position: fixed;
z-index: @zindex-instrumentation;
top: 0;
left: 0;
width: 100%;
}
.instrumentation-table {
-webkit-animation: in-fade-top-soft 500ms;
animation: in-fade-top-soft 500ms;
background-color: fade(@black, 60);
font-family: @font_mono;
font-size: .75rem;
line-height: 2;
width: 100%;
th {
color: @white;
line-height: 1rem;
padding: @menu_padding 0;
text-transform: uppercase;
text-align: left;
&:not(:first-child) {
text-align: center;
}
&.left {
text-align: left;
}
&.right {
text-align: right;
}
}
th,
td {
white-space: pre-wrap;
&:first-child {
padding-left: @menu_padding;
}
}
tbody {
tr {
&:nth-child(odd) {
background-color: fade(@black, 40);
}
}
td {
&:nth-child(even) {
text-align: right;
border-right: 1px dashed;
padding-right: .5em;
}
&:nth-child(odd) {
text-align: left;
padding-left: .5em;
}
&:first-child {
padding-left: @menu_padding;
}
}
}
tfoot {
td {
line-height: 1rem;
text-align: center;
padding: @menu_padding 0;
}
}
small {
font-size: 1em;
opacity: .5;
}
}
================================================
FILE: src/om_i/core.cljs
================================================
(ns om-i.core
(:require [clojure.string :as str]
[goog.dom]
[goog.string :as gstring]
[goog.string.format]
[om.core :as om :include-macros true]
[om.dom :as dom :include-macros true]
[om-i.keyboard :as keyboard]))
;; map of display name to component render stats, e.g.
;; {"App" {:last-will-update <time 3pm> :display-name "App" :last-did-update <time 3pm> :render-ms [10 39 20 40]}}
(defonce component-stats (atom {}))
(defn wrap-will-update
"Tracks last call time of componentWillUpdate for each component, then calls
the original componentWillUpdate."
[f]
(fn [next-props next-state]
(this-as this
(swap! component-stats update-in [((aget this "getDisplayName"))]
merge {:display-name ((aget this "getDisplayName"))
:last-will-update (goog/now)})
(.call f this next-props next-state))))
(defn wrap-did-update
"Tracks last call time of componentDidUpdate for each component and updates
the render times (using start time provided by wrap-will-update), then
calls the original componentDidUpdate."
[f]
(fn [prev-props prev-state]
(this-as this
(swap! component-stats update-in [((aget this "getDisplayName"))]
(fn [stats]
(let [now (goog/now)]
(-> stats
(assoc :last-did-update now)
(update-in [:render-ms] (fnil conj [])
(max (- now (:last-will-update stats now)) 0))))))
(.call f this prev-props prev-state))))
(defn wrap-will-mount
"Tracks last call time of componentWillMount for each component, then calls
the original componentWillMount."
[f]
(fn []
(this-as this
(swap! component-stats update-in [((aget this "getDisplayName"))]
merge {:display-name ((aget this "getDisplayName"))
:last-will-mount (goog/now)})
(.call f this))))
(defn wrap-did-mount
"Tracks last call time of componentDidMount for each component and updates
the render times (using start time provided by wrap-will-mount), then
calls the original componentDidMount."
[f]
(fn []
(this-as this
(swap! component-stats update-in [((aget this "getDisplayName"))]
(fn [stats]
(let [now (goog/now)]
(-> stats
(assoc :last-did-mount now)
(update-in [:mount-ms] (fnil conj [])
(max (- now (:last-will-mount stats now)) 0))))))
(.call f this))))
(defn instrument-methods [methods]
(-> methods
(update-in [:componentWillUpdate] wrap-will-update)
(update-in [:componentDidUpdate] wrap-did-update)
(update-in [:componentWillMount] wrap-will-mount)
(update-in [:componentDidMount] wrap-did-mount)))
(def instrumentation-methods
(om/specify-state-methods!
(-> om/pure-methods
(instrument-methods)
(clj->js))))
(defn avg [coll]
(/ (reduce + coll)
(count coll)))
(defn std-dev [coll]
(let [a (avg coll)]
(Math/sqrt (avg (map #(Math/pow (- % a) 2) coll)))))
(defn compare-display-name [a b]
(compare (:display-name b)
(:display-name a)))
(defn compare-last-update [a b]
(let [res (compare (max (:last-will-update a) (:last-will-mount a))
(max (:last-will-update b) (:last-will-mount b)))]
(if (zero? res)
(compare-display-name a b)
res)))
(defn format-shortcut [key-set]
(str/join "+" (sort-by (comp - count) key-set)))
(defn stats-view [data owner {:keys [clear-shortcut toggle-shortcut sort-shortcut]}]
(reify
om/IDisplayName (display-name [_] "Om Instrumentation")
om/IInitState (init-state [_] {:shown? false
:sort-orders (cycle [:last-update :display-name
:mount-count :render-count])})
om/IDidMount
(did-mount [_]
(keyboard/register-key-handler owner {clear-shortcut #(om/transact! data (constantly {}))
toggle-shortcut #(om/update-state! owner :shown? not)
sort-shortcut #(om/update-state! owner :sort-orders rest)}))
om/IWillUnmount
(will-unmount [_] (keyboard/dispose-key-handler owner))
om/IRenderState
(render-state [_ {:keys [shown? sort-orders]}]
(dom/figure nil
(when shown?
(let [sort-order (first sort-orders)
stats-compare (case sort-order
:last-update compare-last-update
:display-name compare-display-name
(fn [x y] (compare (sort-order x) (sort-order y))))
stats (map (fn [[display-name renders]]
(let [render-times (filter identity (mapcat :render-ms renders))
mount-times (filter identity (mapcat :mount-ms renders))]
{:display-name (or display-name "Unknown")
:render-count (count render-times)
:mount-count (count mount-times)
:last-will-update (last (sort (map :last-will-update renders)))
:last-will-mount (last (sort (map :last-will-mount renders)))
:last-render-ms (last (:render-ms (last (sort-by :last-did-update renders))))
:last-mount-ms (last (:mount-ms (last (sort-by :last-did-mount renders))))
:average-render-ms (when (seq render-times) (int (avg render-times)))
:average-mount-ms (when (seq mount-times) (int (avg mount-times)))
:max-render-ms (when (seq render-times) (apply max render-times))
:max-mount-ms (when (seq mount-times) (apply max mount-times))
:min-render-ms (when (seq render-times) (apply min render-times))
:min-mount-ms (when (seq mount-times) (apply min mount-times))
:render-std-dev (when (seq render-times) (int (std-dev render-times)))
:mount-std-dev (when (seq mount-times) (int (std-dev mount-times)))}))
(reduce (fn [acc [display-name data]]
(update-in acc [(:display-name data)] (fnil conj []) data))
{} data))]
(dom/table #js {:className "instrumentation-table"}
(dom/thead nil
(dom/tr nil
(dom/th nil "component")
(dom/th #js {:className "number right"} "render ")
(dom/th #js {:className "number left"} "/ mount")
(dom/th #js {:className "number" :colSpan "2"} "last-ms")
(dom/th #js {:className "number" :colSpan "2"} "average-ms")
(dom/th #js {:className "number" :colSpan "2"} "max-ms")
(dom/th #js {:className "number" :colSpan "2"} "min-ms")
(dom/th #js {:className "number" :colSpan "2"} "std-ms")))
(apply dom/tbody nil
(for [{:keys [display-name
last-will-update last-will-mount
average-render-ms average-mount-ms
max-render-ms max-mount-ms
min-render-ms min-mount-ms
render-std-dev mount-std-dev
render-count mount-count
last-render-ms last-mount-ms] :as stat}
(reverse (sort stats-compare stats))]
(dom/tr nil
(dom/td nil display-name)
(dom/td #js {:className "number" } render-count)
(dom/td #js {:className "number" } (when mount-count (gstring/format "%4d" mount-count)))
(dom/td #js {:className "number" } last-render-ms)
(dom/td #js {:className "number" } (when last-mount-ms (gstring/format "%3d" last-mount-ms)))
(dom/td #js {:className "number" } average-render-ms)
(dom/td #js {:className "number" } (when average-mount-ms (gstring/format "%3d" average-mount-ms)))
(dom/td #js {:className "number" } max-render-ms)
(dom/td #js {:className "number" } (when max-mount-ms (gstring/format "%3d" max-mount-ms)))
(dom/td #js {:className "number" } min-render-ms)
(dom/td #js {:className "number" } (when min-mount-ms (gstring/format "%3d" min-mount-ms)))
(dom/td #js {:className "number" } render-std-dev)
(dom/td #js {:className "number" } (when mount-std-dev (gstring/format "%3d" mount-std-dev))))))
(dom/tfoot nil
(dom/tr nil
(dom/td #js {:className "instrumentation-info" :colSpan "13"}
(gstring/format "Component render stats, sorted by %s (%s). Clicks go through. %s to toggle, %s to clear."
sort-order
(format-shortcut sort-shortcut)
(format-shortcut toggle-shortcut)
(format-shortcut clear-shortcut))))))))))))
(defn prepend-stats-node [classname]
(let [node (goog.dom/htmlToDocumentFragment (gstring/format "<div class='%s'></div>" classname))
body js/document.body]
(.insertBefore body node (.-firstChild body))
node))
(defn setup-component-stats!
([]
(setup-component-stats! {}))
([{:keys [class clear-shortcut toggle-shortcut sort-shortcut]
:or {class "om-instrumentation"
clear-shortcut #{"shift" "ctrl" "alt" "k"}
toggle-shortcut #{"shift" "ctrl" "alt" "j"}
sort-shortcut #{"shift" "ctrl" "alt" "s"}}}]
(let [stats-node (or (goog.dom/getElementByClass class)
(prepend-stats-node class))]
(om/root
stats-view
component-stats
{:target stats-node
:opts {:clear-shortcut clear-shortcut
:toggle-shortcut toggle-shortcut
:sort-shortcut sort-shortcut}}))))
================================================
FILE: src/om_i/hacks.cljs
================================================
(ns om-i.hacks
(:require [goog.dom :as dom]))
(def om-i-css "@keyframes in-fade-top-soft{0%{opacity:0;transform:translate3d(0, -4rem, 0)}100%{opacity:1;transform:none}}@-webkit-keyframes in-fade-top-soft{0%{opacity:0;-webkit-transform:translate3d(0, -4rem, 0)}100%{opacity:1;-webkit-transform:none}}.om-instrumentation{user-select:none;-moz-user-select:none;-webkit-user-select:none;pointer-events:none;color:#888;position:fixed;z-index:1000;top:0;left:0;width:100%}.instrumentation-table{-webkit-animation:in-fade-top-soft 500ms;animation:in-fade-top-soft 500ms;background-color:rgba(0,0,0,0.6);font-family:Monaco,monospace;font-size:.75rem;line-height:2;width:100%}.instrumentation-table th{color:#fff;line-height:1rem;padding:1.5rem 0;text-transform:uppercase;text-align:left}.instrumentation-table th:not(:first-child){text-align:center}.instrumentation-table th.left{text-align:left}.instrumentation-table th.right{text-align:right}.instrumentation-table th,.instrumentation-table td{white-space:pre-wrap}.instrumentation-table th:first-child,.instrumentation-table td:first-child{padding-left:1.5rem}.instrumentation-table tbody tr:nth-child(odd){background-color:rgba(0,0,0,0.4)}.instrumentation-table tbody td:nth-child(even){text-align:right;border-right:1px dashed;padding-right:.5em}.instrumentation-table tbody td:nth-child(odd){text-align:left;padding-left:.5em}.instrumentation-table tbody td:first-child{padding-left:1.5rem}.instrumentation-table tfoot td{line-height:1rem;text-align:center;padding:1.5rem 0}.instrumentation-table small{font-size:1em;opacity:.5}")
(defn insert-styles
"This shouldn't be used in real code, but it can be useful when
exploring the code for the first time. Closure will eliminate this
function in production unless you're using it in production. You're not
using it in production, are you?"
[]
(let [s (goog.dom/createElement "style")]
(goog.dom/setTextContent s om-i-css)
(goog.dom/appendChild js/document.head s)))
================================================
FILE: src/om_i/keyboard.cljs
================================================
(ns om-i.keyboard
(:require [goog.events]
[om.core :as om])
(:import [goog.ui IdGenerator]))
(def code->key
"map from a character code (read from events with event.which)
to a string representation of it.
Only need to add 'special' things here."
{ 8 "backspace"
13 "enter"
16 "shift"
17 "ctrl"
18 "alt"
27 "esc"
33 "pageup"
34 "pagedown"
36 "home"
37 "left"
38 "up"
39 "right"
40 "down"
46 "del"
91 "meta"
32 "space"
186 ";"
191 "/"
219 "["
221 "]"
187 "="
189 "-"
190 "."
220 "\\"})
(def mod-translation
{"shiftKey" "shift"
"altKey" "alt"
"ctrlKey" "ctrl"
"metaKey" "meta"})
(defn event-modifiers
"Given a keydown event, return the set of modifier keys that were being held."
[e]
(reduce (fn [acc [modifier key-name]]
(if (aget e modifier)
(conj acc key-name)
acc))
#{} mod-translation))
(defn event->key-set
"Given an event, return a set of keys string like #{\"up\"} or #{\"shift\" \"l\"}
describing the keys that were pressed. Will return lone modifier keys, like shift or ctrl"
[e]
(let [code (.-keyCode e)
key (or (code->key code) (.toLowerCase (js/String.fromCharCode code)))]
(conj (event-modifiers e) key)))
(defn match-keys
"Given a keymap for the component and the most recent series of keys
that were pressed (not the codes, but sets of keys like #{'shift' 'r'})
return a handler fn associated with a key combo in the keys
list or nil."
[keymap keys]
(->> keymap
(keep (fn [[key-set f]]
(when (= keys key-set) f)))
first))
(defn register-key-handler [owner keymap]
(om/set-state-nr! owner ::event-key
(goog.events/listen
js/window
"keydown"
(fn [e]
(when-let [f (match-keys keymap (event->key-set e))]
(f))))))
(defn dispose-key-handler [owner]
(goog.events/unlistenByKey (om/get-state owner ::event-key)))
gitextract_8uedg_ze/
├── .gitignore
├── CHANGES.md
├── README.md
├── circle.yml
├── project.clj
├── resources/
│ ├── om-i.css
│ └── om-i.less
└── src/
└── om_i/
├── core.cljs
├── hacks.cljs
└── keyboard.cljs
Condensed preview — 10 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (25K chars).
[
{
"path": ".gitignore",
"chars": 102,
"preview": "pom.xml\n*jar\n/lib/\n/classes/\n/out/\n/target/\n.lein-deps-sum\n.lein-repl-history\n.lein-plugins/\n/script/\n"
},
{
"path": "CHANGES.md",
"chars": 515,
"preview": "## 0.1.8\n### Changes\n* Stop depending on _rootNodeId so that we can support React 0.13.\n\n## 0.1.7\n### Changes\n* Fix orde"
},
{
"path": "README.md",
"chars": 3822,
"preview": "# Om-instrumentation\n\nInstrumentation helper for Om applications.\n\n[;\n }\n 100% {\n opacity: "
},
{
"path": "resources/om-i.less",
"chars": 1843,
"preview": "@black: #000;\n@white: #fff;\n@gray: #888;\n\n@font_mono: Monaco, monospace;\n@zindex-instrumentation: 1000;\n\n@tile: (1rem *"
},
{
"path": "src/om_i/core.cljs",
"chars": 10641,
"preview": "(ns om-i.core\n (:require [clojure.string :as str]\n [goog.dom]\n [goog.string :as gstring]\n "
},
{
"path": "src/om_i/hacks.cljs",
"chars": 1990,
"preview": "(ns om-i.hacks\n (:require [goog.dom :as dom]))\n\n(def om-i-css \"@keyframes in-fade-top-soft{0%{opacity:0;transform:trans"
},
{
"path": "src/om_i/keyboard.cljs",
"chars": 1997,
"preview": "(ns om-i.keyboard\n (:require [goog.events]\n [om.core :as om])\n (:import [goog.ui IdGenerator]))\n\n(def code-"
}
]
About this extraction
This page contains the full source code of the PrecursorApp/om-i GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 10 files (23.2 KB), approximately 6.4k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.