codetrail-cli 1.4.1__py3-none-any.whl

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 (171) hide show
  1. codetrail/__init__.py +3 -0
  2. codetrail/__main__.py +5 -0
  3. codetrail/agents/__init__.py +4 -0
  4. codetrail/agents/convert.py +92 -0
  5. codetrail/agents/export.py +284 -0
  6. codetrail/agents/instructions.py +42 -0
  7. codetrail/agents/protocol.py +111 -0
  8. codetrail/agents/tools.py +185 -0
  9. codetrail/assistant/__init__.py +161 -0
  10. codetrail/assistant/claude_code.py +330 -0
  11. codetrail/assistant/codex.py +260 -0
  12. codetrail/assistant/environment.py +66 -0
  13. codetrail/assistant/estimate.py +225 -0
  14. codetrail/assistant/fake.py +74 -0
  15. codetrail/assistant/guard.py +80 -0
  16. codetrail/assistant/guard_hook.py +58 -0
  17. codetrail/assistant/local.py +245 -0
  18. codetrail/assistant/local_tools.py +177 -0
  19. codetrail/assistant/prompts.py +404 -0
  20. codetrail/assistant/routing.py +86 -0
  21. codetrail/assistant/runner.py +120 -0
  22. codetrail/assistant/status.py +186 -0
  23. codetrail/assistant/usage.py +93 -0
  24. codetrail/bridge.py +292 -0
  25. codetrail/cli.py +407 -0
  26. codetrail/config.py +485 -0
  27. codetrail/database/__init__.py +60 -0
  28. codetrail/database/migrations/0001_facts.sql +34 -0
  29. codetrail/database/migrations/0002_settings.sql +5 -0
  30. codetrail/database/migrations/0003_learning.sql +34 -0
  31. codetrail/database/migrations/0004_assistant_usage.sql +24 -0
  32. codetrail/database/migrations/0005_page_failures.sql +7 -0
  33. codetrail/database/migrations/0006_metric_values.sql +9 -0
  34. codetrail/errors.py +5 -0
  35. codetrail/extract/__init__.py +249 -0
  36. codetrail/extract/adr.py +98 -0
  37. codetrail/extract/github_actions.py +140 -0
  38. codetrail/extract/openapi.py +101 -0
  39. codetrail/extract/python.py +221 -0
  40. codetrail/extract/swift.py +204 -0
  41. codetrail/extract/terraform.py +166 -0
  42. codetrail/extract/typescript.py +464 -0
  43. codetrail/facts/__init__.py +112 -0
  44. codetrail/facts/store.py +209 -0
  45. codetrail/generate/__init__.py +1 -0
  46. codetrail/generate/failures.py +31 -0
  47. codetrail/generate/outline.py +159 -0
  48. codetrail/generate/revise.py +72 -0
  49. codetrail/generate/run.py +530 -0
  50. codetrail/generate/scope.py +135 -0
  51. codetrail/generate/validate.py +218 -0
  52. codetrail/guide/__init__.py +147 -0
  53. codetrail/learn/__init__.py +143 -0
  54. codetrail/learn/routes.py +139 -0
  55. codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
  56. codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
  57. codetrail/locales/codetrail.pot +1942 -0
  58. codetrail/lock.py +114 -0
  59. codetrail/metrics/__init__.py +6 -0
  60. codetrail/metrics/activity.py +96 -0
  61. codetrail/metrics/churn.py +156 -0
  62. codetrail/metrics/commits.py +45 -0
  63. codetrail/metrics/coverage.py +114 -0
  64. codetrail/metrics/decisions.py +77 -0
  65. codetrail/metrics/history.py +47 -0
  66. codetrail/metrics/inventory.py +63 -0
  67. codetrail/metrics/rationale.py +74 -0
  68. codetrail/metrics/releases.py +26 -0
  69. codetrail/metrics/report.py +111 -0
  70. codetrail/metrics/repository.py +128 -0
  71. codetrail/metrics/size.py +95 -0
  72. codetrail/py.typed +0 -0
  73. codetrail/remove.py +50 -0
  74. codetrail/repo/__init__.py +4 -0
  75. codetrail/repo/git.py +56 -0
  76. codetrail/repo/gitleaks.toml +6 -0
  77. codetrail/repo/history.py +262 -0
  78. codetrail/repo/mirror.py +137 -0
  79. codetrail/repo/refresh.py +41 -0
  80. codetrail/repo/rules.py +83 -0
  81. codetrail/repo/secrets.py +145 -0
  82. codetrail/repo/signal.py +65 -0
  83. codetrail/repo/source.py +166 -0
  84. codetrail/search.py +178 -0
  85. codetrail/server.py +45 -0
  86. codetrail/system.py +484 -0
  87. codetrail/update.py +276 -0
  88. codetrail/web/__init__.py +1 -0
  89. codetrail/web/app.py +897 -0
  90. codetrail/web/diagrams.py +414 -0
  91. codetrail/web/documentation.py +146 -0
  92. codetrail/web/i18n.py +44 -0
  93. codetrail/web/navigation.py +127 -0
  94. codetrail/web/render.py +126 -0
  95. codetrail/web/repository_view.py +230 -0
  96. codetrail/web/security.py +171 -0
  97. codetrail/web/static/brand/favicon.svg +1 -0
  98. codetrail/web/static/brand/mark.svg +1 -0
  99. codetrail/web/static/css/base.css +192 -0
  100. codetrail/web/static/css/components.css +2254 -0
  101. codetrail/web/static/css/content.css +310 -0
  102. codetrail/web/static/css/layout.css +410 -0
  103. codetrail/web/static/css/print.css +42 -0
  104. codetrail/web/static/css/tokens.css +228 -0
  105. codetrail/web/static/js/api.js +49 -0
  106. codetrail/web/static/js/ask.js +182 -0
  107. codetrail/web/static/js/d2-worker.js +34 -0
  108. codetrail/web/static/js/d2.js +28 -0
  109. codetrail/web/static/js/diagram.js +52 -0
  110. codetrail/web/static/js/documentation.js +24 -0
  111. codetrail/web/static/js/learning.js +47 -0
  112. codetrail/web/static/js/main.js +38 -0
  113. codetrail/web/static/js/outline.js +44 -0
  114. codetrail/web/static/js/palette.js +211 -0
  115. codetrail/web/static/js/repository.js +528 -0
  116. codetrail/web/static/js/resize.js +126 -0
  117. codetrail/web/static/js/shortcuts.js +87 -0
  118. codetrail/web/static/js/sidebar.js +194 -0
  119. codetrail/web/static/js/theme-init.js +27 -0
  120. codetrail/web/static/js/theme.js +42 -0
  121. codetrail/web/static/js/update.js +107 -0
  122. codetrail/web/static/vendor/d2/LICENSE +375 -0
  123. codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
  124. codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
  125. codetrail/web/static/vendor/d2/VERSION +12 -0
  126. codetrail/web/static/vendor/d2/d2.wasm +0 -0
  127. codetrail/web/static/vendor/d2/elk.js +105914 -0
  128. codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
  129. codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
  130. codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
  131. codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
  132. codetrail/web/static/vendor/inter/VERSION +6 -0
  133. codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
  134. codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
  135. codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
  136. codetrail/web/target_view.py +63 -0
  137. codetrail/web/templates/answers.html +25 -0
  138. codetrail/web/templates/area.html +46 -0
  139. codetrail/web/templates/base.html +37 -0
  140. codetrail/web/templates/decisions.html +30 -0
  141. codetrail/web/templates/diagram.html +10 -0
  142. codetrail/web/templates/digests.html +24 -0
  143. codetrail/web/templates/documentation.html +247 -0
  144. codetrail/web/templates/error.html +9 -0
  145. codetrail/web/templates/estimate.html +48 -0
  146. codetrail/web/templates/fact.html +29 -0
  147. codetrail/web/templates/home.html +196 -0
  148. codetrail/web/templates/layout/ask_panel.html +27 -0
  149. codetrail/web/templates/layout/header.html +32 -0
  150. codetrail/web/templates/layout/icons.html +27 -0
  151. codetrail/web/templates/layout/outline.html +4 -0
  152. codetrail/web/templates/layout/palette.html +27 -0
  153. codetrail/web/templates/layout/shortcuts.html +27 -0
  154. codetrail/web/templates/layout/sidebar.html +79 -0
  155. codetrail/web/templates/layout/update_dialog.html +18 -0
  156. codetrail/web/templates/macros.html +13 -0
  157. codetrail/web/templates/page.html +93 -0
  158. codetrail/web/templates/progress.html +46 -0
  159. codetrail/web/templates/repository.html +369 -0
  160. codetrail/web/templates/search.html +31 -0
  161. codetrail/web/templates/source.html +15 -0
  162. codetrail/web/templates/system.html +43 -0
  163. codetrail/web/templates/system_arrows.html +17 -0
  164. codetrail/web/templates/system_legend.html +9 -0
  165. codetrail/web/templates/update_log.html +30 -0
  166. codetrail/web/trend.py +22 -0
  167. codetrail_cli-1.4.1.dist-info/METADATA +372 -0
  168. codetrail_cli-1.4.1.dist-info/RECORD +171 -0
  169. codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
  170. codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
  171. codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,87 @@
1
+ // Keyboard shortcuts (design section 16.4). None of them spends anything: Ask and Update only open the panel or the
2
+ // estimate dialog. Single keys are ignored while typing and whenever Command, Control or Alt is held.
3
+ import { closeAsk, isAskOpen, openAsk } from "./ask.js";
4
+ import { toggleRead } from "./learning.js";
5
+ import { openPalette } from "./palette.js";
6
+ import { dismissPeek, isPeeking, toggleSidebar } from "./sidebar.js";
7
+ import { hideUpdatePanel, isUpdatePanelOpen, startUpdate } from "./update.js";
8
+
9
+ const PLACES = { h: "/", p: "/progress", y: "/system", s: "/answers", d: "/digests", r: "/decisions", o: "/documentation", t: "/repository" };
10
+ let awaitingPlace = null;
11
+
12
+ function typing(element) {
13
+ return element instanceof Element && Boolean(element.closest("input, textarea, select, [contenteditable]"));
14
+ }
15
+
16
+ function follow(selector) {
17
+ const link = document.querySelector(selector);
18
+ if (link instanceof HTMLAnchorElement) window.location.href = link.href;
19
+ }
20
+
21
+ export function closeMenu() {
22
+ document.body.classList.remove("menu-open");
23
+ document.querySelector("[data-menu]")?.setAttribute("aria-expanded", "false");
24
+ }
25
+
26
+ export function setUpShortcuts() {
27
+ const help = document.querySelector("[data-shortcuts]");
28
+ for (const button of document.querySelectorAll("[data-shortcuts-open]")) {
29
+ button.addEventListener("click", () => help?.showModal());
30
+ }
31
+ help?.querySelector("[data-shortcuts-close]")?.addEventListener("click", () => help.close());
32
+ const menu = document.querySelector("[data-menu]");
33
+ menu?.addEventListener("click", () => {
34
+ const open = document.body.classList.toggle("menu-open");
35
+ menu.setAttribute("aria-expanded", String(open));
36
+ });
37
+
38
+ document.addEventListener("keydown", (event) => {
39
+ if (event.isComposing || event.defaultPrevented) return;
40
+ const key = event.key;
41
+ if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && key.toLowerCase() === "k") {
42
+ event.preventDefault();
43
+ openPalette();
44
+ return;
45
+ }
46
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
47
+ if (document.querySelector("dialog[open]")) return; // an open dialog handles its own keys
48
+ if (key === "Escape") {
49
+ if (isPeeking()) dismissPeek();
50
+ else if (document.body.classList.contains("menu-open")) closeMenu();
51
+ else if (isUpdatePanelOpen()) hideUpdatePanel(); // it sits over the Ask panel
52
+ else if (isAskOpen()) closeAsk();
53
+ return;
54
+ }
55
+ if (typing(event.target)) return;
56
+ if (awaitingPlace) {
57
+ clearTimeout(awaitingPlace);
58
+ awaitingPlace = null;
59
+ const place = PLACES[key.toLowerCase()];
60
+ if (place) {
61
+ event.preventDefault();
62
+ window.location.href = place;
63
+ }
64
+ return;
65
+ }
66
+ const actions = {
67
+ "/": () => openPalette(),
68
+ a: () => openAsk(),
69
+ g: () => {
70
+ awaitingPlace = setTimeout(() => {
71
+ awaitingPlace = null;
72
+ }, 1000);
73
+ },
74
+ "[": () => follow("[data-pager-previous]"),
75
+ "]": () => follow("[data-pager-next]"),
76
+ "\\": () => toggleSidebar(),
77
+ m: () => toggleRead(),
78
+ u: () => startUpdate(),
79
+ "?": () => help?.showModal(),
80
+ };
81
+ const action = actions[key.length === 1 && key !== "?" ? key.toLowerCase() : key];
82
+ if (action) {
83
+ event.preventDefault();
84
+ action();
85
+ }
86
+ });
87
+ }
@@ -0,0 +1,194 @@
1
+ // The sidebar (design section 22): hiding and showing it, peeking at it while hidden, folding its sections and
2
+ // pinning pages. Hidden and folded are remembered in this browser; pins are kept by Codetrail. Titles are only ever
3
+ // moved with textContent and attributes.
4
+ import { post, remember } from "./api.js";
5
+
6
+ const root = document.documentElement;
7
+ const SECTIONS = ["paths", "areas", "concepts", "answers", "more"];
8
+ const PEEK_DELAY = 150; // ms the pointer rests on the edge before the sidebar slides out
9
+ const LEAVE_DELAY = 300; // ms after the pointer leaves before it slides away
10
+ const narrow = window.matchMedia("(max-width: 900px)");
11
+ let peekTimer = 0;
12
+
13
+ const panel = () => document.querySelector("#sidebar");
14
+ const edge = () => document.querySelector("[data-sidebar-edge]");
15
+ const isHidden = () => root.dataset.sidebar === "hidden";
16
+ export const isPeeking = () => document.body.classList.contains("sidebar-peek");
17
+
18
+ function syncToggle() {
19
+ const button = document.querySelector("[data-sidebar-toggle]");
20
+ if (!button) return;
21
+ button.setAttribute("aria-expanded", String(!isHidden()));
22
+ button.setAttribute("aria-label", isHidden() ? button.dataset.labelShow : button.dataset.labelHide);
23
+ }
24
+
25
+ export function closePeek(returnFocus = false) {
26
+ clearTimeout(peekTimer);
27
+ if (!isPeeking()) return;
28
+ document.body.classList.remove("sidebar-peek");
29
+ edge()?.setAttribute("aria-expanded", "false");
30
+ if (returnFocus) edge()?.focus();
31
+ }
32
+
33
+ function openPeek(focusFirst) {
34
+ clearTimeout(peekTimer);
35
+ if (!isHidden() || narrow.matches) return;
36
+ document.body.classList.add("sidebar-peek");
37
+ edge()?.setAttribute("aria-expanded", "true");
38
+ if (focusFirst) panel()?.querySelector("a[href]")?.focus();
39
+ }
40
+
41
+ function closeSoon() {
42
+ clearTimeout(peekTimer);
43
+ peekTimer = setTimeout(() => closePeek(), LEAVE_DELAY);
44
+ }
45
+
46
+ // Esc: the focus goes back to the edge only when it was in the sidebar; elsewhere it stays where the reader is.
47
+ export function dismissPeek() {
48
+ closePeek(Boolean(panel()?.contains(document.activeElement)));
49
+ }
50
+
51
+ export function toggleSidebar() {
52
+ if (narrow.matches) return;
53
+ const hide = !isHidden(); // while peeking it is hidden, so this docks it
54
+ const focusWasInside = Boolean(panel()?.contains(document.activeElement));
55
+ if (hide) root.dataset.sidebar = "hidden";
56
+ else delete root.dataset.sidebar;
57
+ remember("localStorage", "codetrail.sidebar", hide ? "hidden" : null);
58
+ closePeek();
59
+ syncToggle();
60
+ if (hide && focusWasInside) document.querySelector("[data-sidebar-toggle]")?.focus();
61
+ }
62
+
63
+ function setUpPeek() {
64
+ const strip = edge();
65
+ const sidebar = panel();
66
+ if (!strip || !sidebar) return;
67
+ // The card slides in under the pointer resting on the edge: moving between the two isn't leaving.
68
+ const inside = (node) => node instanceof Node && (node === strip || sidebar.contains(node));
69
+ strip.addEventListener("pointerenter", (event) => {
70
+ clearTimeout(peekTimer);
71
+ // Only a pointer arriving from the page peeks: the card sliding away under it after Esc mustn't reopen it.
72
+ if (!isPeeking() && !inside(event.relatedTarget)) peekTimer = setTimeout(() => openPeek(false), PEEK_DELAY);
73
+ });
74
+ strip.addEventListener("pointerleave", (event) => {
75
+ if (!isPeeking()) clearTimeout(peekTimer);
76
+ else if (!inside(event.relatedTarget)) closeSoon();
77
+ });
78
+ strip.addEventListener("click", () => openPeek(true)); // open, or already open: either way, into the sidebar
79
+ strip.addEventListener("focusout", (event) => {
80
+ const next = event.relatedTarget;
81
+ if (isPeeking() && next instanceof Node && !sidebar.contains(next)) closePeek(); // tabbed on into the page
82
+ });
83
+ sidebar.addEventListener("pointerenter", () => clearTimeout(peekTimer));
84
+ sidebar.addEventListener("pointerleave", (event) => isPeeking() && !inside(event.relatedTarget) && closeSoon());
85
+ narrow.addEventListener("change", () => closePeek()); // the drawer takes over below 900 px
86
+ sidebar.addEventListener("focusout", (event) => {
87
+ const next = event.relatedTarget;
88
+ if (isPeeking() && next instanceof Node && !sidebar.contains(next) && next !== strip) closePeek();
89
+ });
90
+ sidebar.addEventListener("click", (event) => {
91
+ if (isPeeking() && event.target instanceof Element && event.target.closest("a[href]")) closePeek();
92
+ });
93
+ }
94
+
95
+ function folded() {
96
+ return (root.dataset.folded ?? "").split(" ").filter((name) => SECTIONS.includes(name));
97
+ }
98
+
99
+ function setUpFolding() {
100
+ for (const button of document.querySelectorAll("[data-fold]")) {
101
+ const name = button.dataset.fold;
102
+ button.setAttribute("aria-expanded", String(!folded().includes(name)));
103
+ button.addEventListener("click", () => {
104
+ const now = folded();
105
+ const next = now.includes(name) ? now.filter((other) => other !== name) : [...now, name];
106
+ if (next.length) root.dataset.folded = next.join(" ");
107
+ else delete root.dataset.folded;
108
+ remember("localStorage", "codetrail.sidebar-folded", next.length ? next.join(" ") : null);
109
+ button.setAttribute("aria-expanded", String(!next.includes(name)));
110
+ });
111
+ }
112
+ }
113
+
114
+ const pageHref = (id) => `/pages/${id.split("/").map(encodeURIComponent).join("/")}`;
115
+
116
+ function setUpPins() {
117
+ const sidebar = panel();
118
+ const list = sidebar?.querySelector("[data-pins]");
119
+ const template = sidebar?.querySelector("[data-pin-template]");
120
+ if (!sidebar || !list || !template) return;
121
+ const limit = Number(sidebar.dataset.maxPins) || 1;
122
+ // The drawn tiles are the list: a pinned page the guide dropped isn't drawn, so it isn't sent back.
123
+ const pinned = () => [...list.querySelectorAll("[data-unpin]")].map((button) => button.dataset.unpin);
124
+
125
+ function sync(ids) {
126
+ list.hidden = ids.length === 0;
127
+ for (const button of sidebar.querySelectorAll("[data-pin]")) {
128
+ const on = ids.includes(button.dataset.pin);
129
+ button.setAttribute("aria-pressed", String(on));
130
+ button.disabled = ids.length >= limit && !on;
131
+ if (button.disabled) button.title = sidebar.dataset.labelFull;
132
+ else button.removeAttribute("title");
133
+ }
134
+ }
135
+
136
+ function tile(button) {
137
+ const id = button.dataset.pin;
138
+ const title = button.closest("li")?.querySelector(".label")?.textContent.trim() || id;
139
+ const item = template.content.firstElementChild.cloneNode(true);
140
+ const link = item.querySelector("a");
141
+ const href = pageHref(id);
142
+ link.href = href;
143
+ link.title = title;
144
+ link.setAttribute("aria-label", title);
145
+ link.textContent = button.dataset.initials;
146
+ // The guide's pages are English; a saved answer is in whatever language it was asked in.
147
+ if (button.dataset.kind === "answer") link.dir = "auto";
148
+ else Object.assign(link, { lang: "en", dir: "ltr" });
149
+ // Compared as written: the template's link has no base to resolve against until it joins the page.
150
+ if (window.location.pathname === href) link.setAttribute("aria-current", "page");
151
+ const remove = item.querySelector("button");
152
+ remove.dataset.unpin = id;
153
+ remove.setAttribute("aria-label", sidebar.dataset.labelUnpin.replace("{title}", () => title)); // no $ patterns
154
+ return item;
155
+ }
156
+
157
+ async function save(ids) {
158
+ try {
159
+ return (await post("/settings/pins", { pages: ids })).ok;
160
+ } catch {
161
+ return false; // the sidebar stays as it was
162
+ }
163
+ }
164
+
165
+ let saving = false; // one change at a time, so a quick second click can't work from a stale list
166
+ sidebar.addEventListener("click", async (event) => {
167
+ const target = event.target instanceof Element ? event.target : null;
168
+ const pin = target?.closest("[data-pin]");
169
+ const unpin = target?.closest("[data-unpin]");
170
+ if ((!pin && !unpin) || saving) return;
171
+ saving = true;
172
+ try {
173
+ const id = pin ? pin.dataset.pin : unpin.dataset.unpin;
174
+ const ids = pinned();
175
+ const next = ids.includes(id) ? ids.filter((other) => other !== id) : [...ids, id];
176
+ if (!(await save(next))) return;
177
+ if (next.includes(id)) list.append(tile(pin));
178
+ else list.querySelector(`[data-unpin="${CSS.escape(id)}"]`)?.closest("li")?.remove();
179
+ sync(next);
180
+ if (unpin) sidebar.querySelector("a[href]")?.focus();
181
+ } finally {
182
+ saving = false;
183
+ }
184
+ });
185
+ for (const button of sidebar.querySelectorAll("[data-pin], [data-unpin]")) button.hidden = false;
186
+ }
187
+
188
+ export function setUpSidebar() {
189
+ document.querySelector("[data-sidebar-toggle]")?.addEventListener("click", toggleSidebar);
190
+ syncToggle();
191
+ setUpPeek();
192
+ setUpFolding();
193
+ setUpPins();
194
+ }
@@ -0,0 +1,27 @@
1
+ // Applies the reader's theme, panel widths and sidebar state before the page is drawn, so nothing flashes or jumps
2
+ // (design 16.1, 16.2, 22). A classic script, loaded in <head> from Codetrail itself: the security policy allows no
3
+ // inline script. resize.js fits the widths to the window once the page has loaded; sidebar.js runs the sidebar.
4
+ (function () {
5
+ "use strict";
6
+ var root = document.documentElement;
7
+ var widths = [
8
+ ["codetrail.sidebar-width", "--sidebar-width", 200, 420],
9
+ ["codetrail.panel-width", "--panel-width", 320, 720],
10
+ ];
11
+ var sections = ["paths", "areas", "concepts", "answers", "more"];
12
+ try {
13
+ var theme = window.localStorage.getItem("codetrail.theme");
14
+ if (theme === "light" || theme === "dark") root.setAttribute("data-theme", theme);
15
+ for (var index = 0; index < widths.length; index += 1) {
16
+ var width = Number(window.localStorage.getItem(widths[index][0]));
17
+ if (width >= widths[index][2] && width <= widths[index][3]) root.style.setProperty(widths[index][1], width + "px");
18
+ }
19
+ if (window.localStorage.getItem("codetrail.sidebar") === "hidden") root.setAttribute("data-sidebar", "hidden");
20
+ var folded = (window.localStorage.getItem("codetrail.sidebar-folded") || "").split(" ").filter(function (name) {
21
+ return sections.indexOf(name) >= 0;
22
+ });
23
+ if (folded.length) root.setAttribute("data-folded", folded.join(" "));
24
+ } catch (error) {
25
+ // storage refused: the system's theme, the default widths and the docked, open sidebar apply
26
+ }
27
+ })();
@@ -0,0 +1,42 @@
1
+ // The theme switch: system, light or dark, remembered in localStorage when the browser allows it (design 16.2).
2
+ import { recall, remember } from "./api.js";
3
+
4
+ const THEMES = ["system", "light", "dark"];
5
+ const button = document.querySelector("[data-theme-toggle]");
6
+
7
+ function current() {
8
+ const stored = recall("localStorage", "codetrail.theme");
9
+ return THEMES.includes(stored) ? stored : "system";
10
+ }
11
+
12
+ function apply(theme) {
13
+ if (theme === "system") document.documentElement.removeAttribute("data-theme");
14
+ else document.documentElement.setAttribute("data-theme", theme);
15
+ if (button) {
16
+ const label = button.dataset[`label${theme[0].toUpperCase()}${theme.slice(1)}`] ?? theme;
17
+ button.setAttribute("aria-label", label);
18
+ button.title = label;
19
+ button.dataset.theme = theme;
20
+ }
21
+ document.dispatchEvent(new CustomEvent("codetrail:theme"));
22
+ }
23
+
24
+ export function isDark() {
25
+ const chosen = document.documentElement.getAttribute("data-theme");
26
+ if (chosen) return chosen === "dark";
27
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
28
+ }
29
+
30
+ export function cycleTheme() {
31
+ const next = THEMES[(THEMES.indexOf(current()) + 1) % THEMES.length];
32
+ remember("localStorage", "codetrail.theme", next === "system" ? null : next);
33
+ apply(next);
34
+ }
35
+
36
+ export function setUpTheme() {
37
+ apply(current());
38
+ button?.addEventListener("click", cycleTheme);
39
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
40
+ if (current() === "system") apply("system");
41
+ });
42
+ }
@@ -0,0 +1,107 @@
1
+ // Updating the guide from the page (design section 15.4): the facts are refreshed for free, then the estimate waits
2
+ // in a dialog; only Go ahead, with the estimate's single-use id, lets paid work start. The update panel shows each
3
+ // step as the server reports it, closes once the update is done, and stays open with the reason when it fails.
4
+ import { getJSON, post } from "./api.js";
5
+
6
+ const dialog = document.querySelector("[data-estimate-dialog]");
7
+ const panel = document.querySelector("[data-update-panel]");
8
+ const log = panel?.querySelector("[data-update-log]");
9
+ const stateLine = panel?.querySelector("[data-update-state]");
10
+ const ACTIVE = ["preparing", "waiting", "running"];
11
+ const DONE_SHOWN_MS = 1500; // how long "Updated." stays before the panel closes
12
+ let estimateId = null;
13
+ let polling = false;
14
+ let active = false;
15
+ let after = 0; // the number of the last step shown
16
+
17
+ function say(text, failed = false) {
18
+ stateLine.textContent = text;
19
+ stateLine.classList.toggle("failed", failed);
20
+ }
21
+
22
+ export function showUpdatePanel() {
23
+ if (panel) panel.hidden = false;
24
+ }
25
+
26
+ export function hideUpdatePanel() {
27
+ if (panel) panel.hidden = true;
28
+ }
29
+
30
+ export function isUpdatePanelOpen() {
31
+ return Boolean(panel && !panel.hidden);
32
+ }
33
+
34
+ async function decide(url) {
35
+ if (!estimateId) return;
36
+ const id = estimateId;
37
+ estimateId = null;
38
+ dialog.close();
39
+ await post(url, { estimate_id: id });
40
+ poll();
41
+ }
42
+
43
+ async function poll(reloadWhenDone = true) {
44
+ if (polling) return;
45
+ polling = true;
46
+ try {
47
+ const result = await getJSON(`/update/status?after=${after}`);
48
+ const labels = panel.dataset;
49
+ const { state, message } = result;
50
+ active = ACTIVE.includes(state);
51
+ if (result.log_html) log.insertAdjacentHTML("beforeend", result.log_html); // rendered, escaped on the server
52
+ after = result.next ?? after;
53
+ if (active) say(labels.labelRunning);
54
+ else if (state === "done") say(labels.labelDone);
55
+ else if (state === "failed") say(`${labels.labelFailed} ${message ?? ""}`, true);
56
+ else if (state === "declined") say(message ?? "", true);
57
+ if (state === "waiting" && result.estimate_id && result.estimate_id !== estimateId) {
58
+ estimateId = result.estimate_id;
59
+ dialog.querySelector("[data-estimate-body]").innerHTML = result.estimate_html; // rendered, escaped on the server
60
+ dialog.showModal();
61
+ }
62
+ polling = false;
63
+ if (active) setTimeout(() => poll(reloadWhenDone), 1500);
64
+ else if (state === "done") setTimeout(reloadWhenDone ? () => window.location.reload() : hideUpdatePanel, DONE_SHOWN_MS);
65
+ } catch {
66
+ polling = false;
67
+ }
68
+ }
69
+
70
+ export async function startUpdate() {
71
+ if (!dialog) return;
72
+ showUpdatePanel();
73
+ if (active) return; // one runs already: the panel shows it
74
+ log.replaceChildren();
75
+ say(panel.dataset.labelRunning);
76
+ const response = await post("/update", {});
77
+ if (!response.ok) {
78
+ const result = await response.json().catch(() => ({}));
79
+ if (response.status !== 409 && result.message) {
80
+ say(result.message, true);
81
+ return;
82
+ }
83
+ }
84
+ poll();
85
+ }
86
+
87
+ export function setUpUpdate() {
88
+ if (!dialog) return;
89
+ dialog.querySelector("[data-estimate-go]").addEventListener("click", () => decide("/update/confirm"));
90
+ dialog.querySelector("[data-estimate-cancel]").addEventListener("click", () => decide("/update/cancel"));
91
+ dialog.addEventListener("cancel", () => decide("/update/cancel")); // Escape cancels too
92
+ for (const button of document.querySelectorAll("[data-update-button]")) {
93
+ button.addEventListener("click", startUpdate);
94
+ }
95
+ panel.querySelector("[data-update-close]").addEventListener("click", hideUpdatePanel);
96
+ // An update already running or waiting when the page opens shows in the panel, with its steps so far.
97
+ getJSON("/update/status")
98
+ .then((result) => {
99
+ if (ACTIVE.includes(result.state)) {
100
+ showUpdatePanel();
101
+ poll(false);
102
+ } else {
103
+ after = result.next ?? 0; // the steps of an update that has ended aren't shown again
104
+ }
105
+ })
106
+ .catch(() => {});
107
+ }