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.
- codetrail/__init__.py +3 -0
- codetrail/__main__.py +5 -0
- codetrail/agents/__init__.py +4 -0
- codetrail/agents/convert.py +92 -0
- codetrail/agents/export.py +284 -0
- codetrail/agents/instructions.py +42 -0
- codetrail/agents/protocol.py +111 -0
- codetrail/agents/tools.py +185 -0
- codetrail/assistant/__init__.py +161 -0
- codetrail/assistant/claude_code.py +330 -0
- codetrail/assistant/codex.py +260 -0
- codetrail/assistant/environment.py +66 -0
- codetrail/assistant/estimate.py +225 -0
- codetrail/assistant/fake.py +74 -0
- codetrail/assistant/guard.py +80 -0
- codetrail/assistant/guard_hook.py +58 -0
- codetrail/assistant/local.py +245 -0
- codetrail/assistant/local_tools.py +177 -0
- codetrail/assistant/prompts.py +404 -0
- codetrail/assistant/routing.py +86 -0
- codetrail/assistant/runner.py +120 -0
- codetrail/assistant/status.py +186 -0
- codetrail/assistant/usage.py +93 -0
- codetrail/bridge.py +292 -0
- codetrail/cli.py +407 -0
- codetrail/config.py +485 -0
- codetrail/database/__init__.py +60 -0
- codetrail/database/migrations/0001_facts.sql +34 -0
- codetrail/database/migrations/0002_settings.sql +5 -0
- codetrail/database/migrations/0003_learning.sql +34 -0
- codetrail/database/migrations/0004_assistant_usage.sql +24 -0
- codetrail/database/migrations/0005_page_failures.sql +7 -0
- codetrail/database/migrations/0006_metric_values.sql +9 -0
- codetrail/errors.py +5 -0
- codetrail/extract/__init__.py +249 -0
- codetrail/extract/adr.py +98 -0
- codetrail/extract/github_actions.py +140 -0
- codetrail/extract/openapi.py +101 -0
- codetrail/extract/python.py +221 -0
- codetrail/extract/swift.py +204 -0
- codetrail/extract/terraform.py +166 -0
- codetrail/extract/typescript.py +464 -0
- codetrail/facts/__init__.py +112 -0
- codetrail/facts/store.py +209 -0
- codetrail/generate/__init__.py +1 -0
- codetrail/generate/failures.py +31 -0
- codetrail/generate/outline.py +159 -0
- codetrail/generate/revise.py +72 -0
- codetrail/generate/run.py +530 -0
- codetrail/generate/scope.py +135 -0
- codetrail/generate/validate.py +218 -0
- codetrail/guide/__init__.py +147 -0
- codetrail/learn/__init__.py +143 -0
- codetrail/learn/routes.py +139 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
- codetrail/locales/codetrail.pot +1942 -0
- codetrail/lock.py +114 -0
- codetrail/metrics/__init__.py +6 -0
- codetrail/metrics/activity.py +96 -0
- codetrail/metrics/churn.py +156 -0
- codetrail/metrics/commits.py +45 -0
- codetrail/metrics/coverage.py +114 -0
- codetrail/metrics/decisions.py +77 -0
- codetrail/metrics/history.py +47 -0
- codetrail/metrics/inventory.py +63 -0
- codetrail/metrics/rationale.py +74 -0
- codetrail/metrics/releases.py +26 -0
- codetrail/metrics/report.py +111 -0
- codetrail/metrics/repository.py +128 -0
- codetrail/metrics/size.py +95 -0
- codetrail/py.typed +0 -0
- codetrail/remove.py +50 -0
- codetrail/repo/__init__.py +4 -0
- codetrail/repo/git.py +56 -0
- codetrail/repo/gitleaks.toml +6 -0
- codetrail/repo/history.py +262 -0
- codetrail/repo/mirror.py +137 -0
- codetrail/repo/refresh.py +41 -0
- codetrail/repo/rules.py +83 -0
- codetrail/repo/secrets.py +145 -0
- codetrail/repo/signal.py +65 -0
- codetrail/repo/source.py +166 -0
- codetrail/search.py +178 -0
- codetrail/server.py +45 -0
- codetrail/system.py +484 -0
- codetrail/update.py +276 -0
- codetrail/web/__init__.py +1 -0
- codetrail/web/app.py +897 -0
- codetrail/web/diagrams.py +414 -0
- codetrail/web/documentation.py +146 -0
- codetrail/web/i18n.py +44 -0
- codetrail/web/navigation.py +127 -0
- codetrail/web/render.py +126 -0
- codetrail/web/repository_view.py +230 -0
- codetrail/web/security.py +171 -0
- codetrail/web/static/brand/favicon.svg +1 -0
- codetrail/web/static/brand/mark.svg +1 -0
- codetrail/web/static/css/base.css +192 -0
- codetrail/web/static/css/components.css +2254 -0
- codetrail/web/static/css/content.css +310 -0
- codetrail/web/static/css/layout.css +410 -0
- codetrail/web/static/css/print.css +42 -0
- codetrail/web/static/css/tokens.css +228 -0
- codetrail/web/static/js/api.js +49 -0
- codetrail/web/static/js/ask.js +182 -0
- codetrail/web/static/js/d2-worker.js +34 -0
- codetrail/web/static/js/d2.js +28 -0
- codetrail/web/static/js/diagram.js +52 -0
- codetrail/web/static/js/documentation.js +24 -0
- codetrail/web/static/js/learning.js +47 -0
- codetrail/web/static/js/main.js +38 -0
- codetrail/web/static/js/outline.js +44 -0
- codetrail/web/static/js/palette.js +211 -0
- codetrail/web/static/js/repository.js +528 -0
- codetrail/web/static/js/resize.js +126 -0
- codetrail/web/static/js/shortcuts.js +87 -0
- codetrail/web/static/js/sidebar.js +194 -0
- codetrail/web/static/js/theme-init.js +27 -0
- codetrail/web/static/js/theme.js +42 -0
- codetrail/web/static/js/update.js +107 -0
- codetrail/web/static/vendor/d2/LICENSE +375 -0
- codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
- codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
- codetrail/web/static/vendor/d2/VERSION +12 -0
- codetrail/web/static/vendor/d2/d2.wasm +0 -0
- codetrail/web/static/vendor/d2/elk.js +105914 -0
- codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
- codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
- codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
- codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
- codetrail/web/static/vendor/inter/VERSION +6 -0
- codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
- codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
- codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
- codetrail/web/target_view.py +63 -0
- codetrail/web/templates/answers.html +25 -0
- codetrail/web/templates/area.html +46 -0
- codetrail/web/templates/base.html +37 -0
- codetrail/web/templates/decisions.html +30 -0
- codetrail/web/templates/diagram.html +10 -0
- codetrail/web/templates/digests.html +24 -0
- codetrail/web/templates/documentation.html +247 -0
- codetrail/web/templates/error.html +9 -0
- codetrail/web/templates/estimate.html +48 -0
- codetrail/web/templates/fact.html +29 -0
- codetrail/web/templates/home.html +196 -0
- codetrail/web/templates/layout/ask_panel.html +27 -0
- codetrail/web/templates/layout/header.html +32 -0
- codetrail/web/templates/layout/icons.html +27 -0
- codetrail/web/templates/layout/outline.html +4 -0
- codetrail/web/templates/layout/palette.html +27 -0
- codetrail/web/templates/layout/shortcuts.html +27 -0
- codetrail/web/templates/layout/sidebar.html +79 -0
- codetrail/web/templates/layout/update_dialog.html +18 -0
- codetrail/web/templates/macros.html +13 -0
- codetrail/web/templates/page.html +93 -0
- codetrail/web/templates/progress.html +46 -0
- codetrail/web/templates/repository.html +369 -0
- codetrail/web/templates/search.html +31 -0
- codetrail/web/templates/source.html +15 -0
- codetrail/web/templates/system.html +43 -0
- codetrail/web/templates/system_arrows.html +17 -0
- codetrail/web/templates/system_legend.html +9 -0
- codetrail/web/templates/update_log.html +30 -0
- codetrail/web/trend.py +22 -0
- codetrail_cli-1.4.1.dist-info/METADATA +372 -0
- codetrail_cli-1.4.1.dist-info/RECORD +171 -0
- codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
- codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
- codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
// The Repository page's charts (design section 20): the timeline, the day-by-day calendar, the change heat map and the
|
|
2
|
+
// area-by-month grid's read-out, drawn from the page's JSON block. Text from the repository (paths, tag names and
|
|
3
|
+
// notes, package names) is only ever set with textContent or setAttribute, never parsed as markup.
|
|
4
|
+
const SVG = "http://www.w3.org/2000/svg";
|
|
5
|
+
const DAY = 86400000;
|
|
6
|
+
const PLOT_HEIGHT = 180;
|
|
7
|
+
const RANGES = { all: Infinity, "2y": 730, "1y": 365, "90d": 90 }; // days each range shows
|
|
8
|
+
const UNIT_DAYS = { day: 1, week: 7, month: 30.44 };
|
|
9
|
+
|
|
10
|
+
// Bars per day up to 120 days, per week up to 400, per month beyond: enough bars to read, few enough to tell apart.
|
|
11
|
+
function unitFor(span) {
|
|
12
|
+
return span <= 120 ? "day" : span <= 400 ? "week" : "month";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function svg(tag, attributes = {}) {
|
|
16
|
+
const node = document.createElementNS(SVG, tag);
|
|
17
|
+
for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, String(value));
|
|
18
|
+
return node;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function element(tag, className, text) {
|
|
22
|
+
const node = document.createElement(tag);
|
|
23
|
+
if (className) node.className = className;
|
|
24
|
+
if (text !== undefined) node.textContent = text;
|
|
25
|
+
return node;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const dayOf = (iso) => Date.parse(`${iso}T00:00:00Z`);
|
|
29
|
+
const isoOf = (time) => new Date(time).toISOString().slice(0, 10);
|
|
30
|
+
const sourceHref = (path) => `/source/${path.split("/").map(encodeURIComponent).join("/")}`;
|
|
31
|
+
|
|
32
|
+
// The highest of many numbers, without spreading them into arguments (a long history has too many).
|
|
33
|
+
const highestOf = (numbers, floor = 1) => numbers.reduce((highest, value) => Math.max(highest, value), floor);
|
|
34
|
+
|
|
35
|
+
// The five-step ramp of the heat map and calendar: 0 for nothing, then quarters of the highest (as ramp_step does).
|
|
36
|
+
function rampStep(count, highest) {
|
|
37
|
+
if (count <= 0 || highest <= 0) return 0;
|
|
38
|
+
return Math.min(4, 1 + Math.floor((count * 4 - 1) / highest));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class Labels {
|
|
42
|
+
constructor(data) {
|
|
43
|
+
this.labels = data.labels;
|
|
44
|
+
this.rules = new Intl.PluralRules(data.lang);
|
|
45
|
+
this.numbers = new Intl.NumberFormat(`${data.lang}-u-nu-latn`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
number(value) {
|
|
49
|
+
return this.numbers.format(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fill(template, values) {
|
|
53
|
+
return template.replace(/\{(\w+)\}/g, (match, key) => (key in values ? String(values[key]) : match));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The catalog's phrase for a sample number in the same plural category as the value.
|
|
57
|
+
count(key, value, extra = {}) {
|
|
58
|
+
const forms = this.labels[key];
|
|
59
|
+
const category = this.rules.select(value);
|
|
60
|
+
const samples = Object.keys(forms).map(Number).sort((a, b) => a - b);
|
|
61
|
+
const sample = samples.find((number) => this.rules.select(number) === category) ?? samples[samples.length - 1];
|
|
62
|
+
return this.fill(forms[String(sample)], { count: this.number(value), ...extra });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
text(key, values = {}) {
|
|
66
|
+
return this.fill(this.labels[key], values);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
month(time) {
|
|
70
|
+
const date = new Date(time);
|
|
71
|
+
return `${this.labels.months[date.getUTCMonth()]} ${date.getUTCFullYear()}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Commits per day, week (from Monday) and month, each period with its start time.
|
|
76
|
+
function periods(days) {
|
|
77
|
+
const start = dayOf(days.start);
|
|
78
|
+
const daily = days.counts.map((count, index) => ({ time: start + index * DAY, count }));
|
|
79
|
+
const group = (keyOf, startOf) => {
|
|
80
|
+
const grouped = [];
|
|
81
|
+
for (const day of daily) {
|
|
82
|
+
const key = keyOf(day.time);
|
|
83
|
+
const last = grouped[grouped.length - 1];
|
|
84
|
+
if (last && last.key === key) last.count += day.count;
|
|
85
|
+
else grouped.push({ key, time: startOf(day.time), count: day.count });
|
|
86
|
+
}
|
|
87
|
+
return grouped;
|
|
88
|
+
};
|
|
89
|
+
const monday = (time) => time - ((new Date(time).getUTCDay() + 6) % 7) * DAY;
|
|
90
|
+
const monthStart = (time) => { const date = new Date(time); return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1); };
|
|
91
|
+
return { day: daily, week: group(monday, monday), month: group(monthStart, monthStart) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function periodEnd(unit, time) {
|
|
95
|
+
if (unit === "day") return time + DAY;
|
|
96
|
+
if (unit === "week") return time + 7 * DAY;
|
|
97
|
+
const date = new Date(time);
|
|
98
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function setUpTimeline(data, labels) {
|
|
102
|
+
const container = document.querySelector('[data-chart="timeline"]');
|
|
103
|
+
if (!container || !data.days) return;
|
|
104
|
+
const all = periods(data.days);
|
|
105
|
+
const picker = document.querySelector("[data-range-picker]");
|
|
106
|
+
const pan = document.querySelector("[data-pan]");
|
|
107
|
+
const windowText = document.querySelector("[data-window-text]");
|
|
108
|
+
const state = { range: "all", offset: 0, active: -1, release: data.releases?.length ? data.releases.length - 1 : -1 };
|
|
109
|
+
|
|
110
|
+
function view() {
|
|
111
|
+
const span = Math.min(RANGES[state.range], all.day.length);
|
|
112
|
+
const unit = unitFor(span);
|
|
113
|
+
const series = all[unit];
|
|
114
|
+
const shown = state.range === "all" ? series.length : Math.min(Math.ceil(span / UNIT_DAYS[unit]), series.length);
|
|
115
|
+
const step = Math.max(1, Math.floor(shown / 2));
|
|
116
|
+
const maxOffset = Math.max(0, Math.ceil((series.length - shown) / step));
|
|
117
|
+
state.offset = Math.min(state.offset, maxOffset);
|
|
118
|
+
// The earliest window keeps its full width: it starts at the first period rather than running short.
|
|
119
|
+
const start = Math.max(0, series.length - state.offset * step - shown);
|
|
120
|
+
const slice = series.slice(start, start + shown);
|
|
121
|
+
const first = Math.max(slice[0].time, dayOf(data.days.start)); // a week or month may begin before the first commit
|
|
122
|
+
return { unit, slice, maxOffset, from: first, to: periodEnd(unit, slice[slice.length - 1].time), barsFrom: slice[0].time };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function periodLabel(unit, time) {
|
|
126
|
+
if (unit === "month") return labels.month(time);
|
|
127
|
+
if (unit === "week") return labels.text("weekOf", { date: isoOf(time) });
|
|
128
|
+
return isoOf(time);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function draw() {
|
|
132
|
+
const shown = view();
|
|
133
|
+
const { unit, slice, from, to, barsFrom } = shown;
|
|
134
|
+
const position = (time) => ((time - barsFrom) / (to - barsFrom)) * 100;
|
|
135
|
+
const highest = highestOf(slice.map((period) => period.count));
|
|
136
|
+
const total = slice.reduce((sum, period) => sum + period.count, 0);
|
|
137
|
+
container.replaceChildren();
|
|
138
|
+
const shownEnd = Math.min(to - DAY, dayOf(data.today));
|
|
139
|
+
windowText.textContent = `${labels.text("window", { start: isoOf(from), end: isoOf(shownEnd) })} · ${labels.count("commits", total)}`;
|
|
140
|
+
|
|
141
|
+
const unitKey = { month: "perMonth", week: "perWeek", day: "perDay" }[unit];
|
|
142
|
+
container.append(element("span", "timeline-label", labels.text(unitKey)));
|
|
143
|
+
const plot = element("div", "timeline-plot");
|
|
144
|
+
plot.tabIndex = 0;
|
|
145
|
+
plot.setAttribute("role", "group");
|
|
146
|
+
plot.setAttribute("aria-label", `${labels.text(unitKey)}. ${labels.text("plotHelp")}`);
|
|
147
|
+
const chart = svg("svg", { viewBox: `0 0 1000 ${PLOT_HEIGHT}`, preserveAspectRatio: "none", "aria-hidden": "true" });
|
|
148
|
+
for (const y of [1, PLOT_HEIGHT / 2]) chart.append(svg("line", { class: "gridline", x1: 0, x2: 1000, y1: y, y2: y }));
|
|
149
|
+
const width = 1000 / slice.length;
|
|
150
|
+
const gap = slice.length > 120 ? 0.5 : Math.min(2, width * 0.2);
|
|
151
|
+
const bars = slice.map((period, index) => {
|
|
152
|
+
const height = period.count ? Math.max(2, (period.count / highest) * (PLOT_HEIGHT - 10)) : 0;
|
|
153
|
+
const bar = svg("rect", { class: "timeline-bar", x: index * width + gap / 2, y: PLOT_HEIGHT - height, width: Math.max(0.5, width - gap), height, rx: Math.min(3, width / 4) });
|
|
154
|
+
chart.append(bar);
|
|
155
|
+
return bar;
|
|
156
|
+
});
|
|
157
|
+
plot.append(chart);
|
|
158
|
+
const tip = element("div", "timeline-tip");
|
|
159
|
+
tip.hidden = true;
|
|
160
|
+
const tipTitle = element("strong");
|
|
161
|
+
const tipBody = element("span");
|
|
162
|
+
tip.append(tipTitle, tipBody);
|
|
163
|
+
plot.append(tip);
|
|
164
|
+
|
|
165
|
+
const activate = (index) => {
|
|
166
|
+
bars[state.active]?.classList.remove("is-active");
|
|
167
|
+
state.active = index;
|
|
168
|
+
if (index < 0) { tip.hidden = true; return; }
|
|
169
|
+
const period = slice[index];
|
|
170
|
+
bars[index].classList.add("is-active");
|
|
171
|
+
tipTitle.textContent = periodLabel(unit, period.time);
|
|
172
|
+
tipBody.textContent = labels.count("commits", period.count);
|
|
173
|
+
tip.hidden = false;
|
|
174
|
+
tip.style.insetInlineStart = `${Math.min(78, Math.max(0, ((index + 0.5) / slice.length) * 100 - 10))}%`;
|
|
175
|
+
};
|
|
176
|
+
plot.addEventListener("mousemove", (event) => {
|
|
177
|
+
const box = plot.getBoundingClientRect();
|
|
178
|
+
activate(Math.min(slice.length - 1, Math.max(0, Math.floor(((event.clientX - box.left) / box.width) * slice.length))));
|
|
179
|
+
});
|
|
180
|
+
plot.addEventListener("mouseleave", () => activate(-1));
|
|
181
|
+
plot.addEventListener("blur", () => activate(-1));
|
|
182
|
+
plot.addEventListener("keydown", (event) => {
|
|
183
|
+
const moves = { ArrowRight: 1, ArrowLeft: -1, Home: -Infinity, End: Infinity };
|
|
184
|
+
if (!(event.key in moves)) return;
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
const next = state.active < 0 ? slice.length - 1 : state.active + moves[event.key];
|
|
187
|
+
activate(Math.min(slice.length - 1, Math.max(0, Number.isFinite(next) ? next : moves[event.key] < 0 ? 0 : slice.length - 1)));
|
|
188
|
+
});
|
|
189
|
+
container.append(plot);
|
|
190
|
+
|
|
191
|
+
container.append(element("span"));
|
|
192
|
+
const axis = element("div", "timeline-axis");
|
|
193
|
+
axis.setAttribute("aria-hidden", "true");
|
|
194
|
+
slice.forEach((period, index) => {
|
|
195
|
+
const date = new Date(period.time);
|
|
196
|
+
const previous = index ? new Date(slice[index - 1].time) : null;
|
|
197
|
+
let text = null;
|
|
198
|
+
if (unit === "month" && date.getUTCMonth() === 0) text = String(date.getUTCFullYear());
|
|
199
|
+
else if (unit === "month" && slice.length <= 30 && date.getUTCMonth() === 6) text = labels.labels.months[6];
|
|
200
|
+
else if (unit === "week" && (!previous || previous.getUTCMonth() !== date.getUTCMonth()) && date.getUTCMonth() % 2 === 0) text = labels.labels.months[date.getUTCMonth()];
|
|
201
|
+
else if (unit === "day" && date.getUTCDate() === 1) text = labels.labels.months[date.getUTCMonth()];
|
|
202
|
+
if (text) {
|
|
203
|
+
const tick = element("span", "", text);
|
|
204
|
+
tick.style.insetInlineStart = `${(index / slice.length) * 100}%`;
|
|
205
|
+
axis.append(tick);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
container.append(axis);
|
|
209
|
+
|
|
210
|
+
container.append(element("span", "timeline-label", labels.text("releases")));
|
|
211
|
+
const releaseLane = element("div", "timeline-lane");
|
|
212
|
+
const releases = (data.releases ?? []).map((release, index) => ({ ...release, index, time: dayOf(release.date) })).filter((release) => release.time >= from && release.time < to);
|
|
213
|
+
if (!releases.length) releaseLane.append(element("p", "lane-empty", labels.text("noReleases")));
|
|
214
|
+
let lastLabelled = -Infinity;
|
|
215
|
+
for (const release of releases) {
|
|
216
|
+
// A name shows only with room for it; every diamond keeps its name for assistive technology and on hover.
|
|
217
|
+
const crowded = position(release.time) - lastLabelled < 8;
|
|
218
|
+
if (!crowded) lastLabelled = position(release.time);
|
|
219
|
+
const mark = element("button", "release-mark lane-mark", crowded ? "" : release.name);
|
|
220
|
+
mark.setAttribute("title", `${release.name}, ${release.date}`);
|
|
221
|
+
mark.type = "button";
|
|
222
|
+
mark.setAttribute("aria-pressed", String(release.index === state.release));
|
|
223
|
+
mark.setAttribute("aria-label", `${release.name}, ${release.date}`);
|
|
224
|
+
mark.style.insetInlineStart = `calc(${position(release.time)}% - 6px)`;
|
|
225
|
+
mark.addEventListener("click", () => {
|
|
226
|
+
// The marks stay in place, so the focus stays on the one chosen.
|
|
227
|
+
state.release = release.index;
|
|
228
|
+
for (const other of releaseLane.querySelectorAll(".release-mark")) other.setAttribute("aria-pressed", String(other === mark));
|
|
229
|
+
showRelease();
|
|
230
|
+
});
|
|
231
|
+
releaseLane.append(mark);
|
|
232
|
+
}
|
|
233
|
+
container.append(releaseLane);
|
|
234
|
+
|
|
235
|
+
container.append(element("span", "timeline-label", labels.text("dependencies")));
|
|
236
|
+
const depLane = element("div", "timeline-lane");
|
|
237
|
+
let watched = null;
|
|
238
|
+
if (data.watchStart && dayOf(data.watchStart) < to) {
|
|
239
|
+
watched = element("div", "lane-watch");
|
|
240
|
+
watched.style.insetInlineStart = `${Math.max(0, position(dayOf(data.watchStart)))}%`;
|
|
241
|
+
watched.append(element("span", "lane-watch-label", labels.text("watched", { date: data.watchStart })));
|
|
242
|
+
depLane.append(watched);
|
|
243
|
+
}
|
|
244
|
+
const deps = data.deps.map((dep) => ({ ...dep, time: dayOf(dep.date) })).filter((dep) => dep.time >= from && dep.time < to);
|
|
245
|
+
if (!deps.length && !(data.watchStart && dayOf(data.watchStart) < to)) depLane.append(element("p", "lane-empty", labels.text("noDeps")));
|
|
246
|
+
for (const dep of deps) {
|
|
247
|
+
const arrived = dep.sign === "+";
|
|
248
|
+
const mark = element("span", `dep-mark lane-mark ${arrived ? "arrived" : "left"}`, arrived ? "+" : "−");
|
|
249
|
+
const words = labels.text(arrived ? "arrived" : "left", { name: dep.name });
|
|
250
|
+
mark.setAttribute("role", "img");
|
|
251
|
+
mark.setAttribute("aria-label", `${words}, ${dep.date}`);
|
|
252
|
+
mark.setAttribute("title", `${words}, ${dep.date}`);
|
|
253
|
+
mark.style.insetInlineStart = `calc(${position(dep.time)}% - 7px)`;
|
|
254
|
+
depLane.append(mark);
|
|
255
|
+
}
|
|
256
|
+
container.append(depLane);
|
|
257
|
+
// A band too narrow for its label (Codetrail started watching late in the window) shows it just before its edge.
|
|
258
|
+
if (watched) watched.classList.toggle("narrow", watched.firstElementChild.scrollWidth > watched.clientWidth);
|
|
259
|
+
|
|
260
|
+
container.append(element("span", "timeline-label", labels.text("wholeHistory")));
|
|
261
|
+
const mini = element("div", "timeline-mini");
|
|
262
|
+
mini.setAttribute("aria-hidden", "true");
|
|
263
|
+
const wholeUnit = unitFor(all.day.length);
|
|
264
|
+
const whole = all[wholeUnit];
|
|
265
|
+
const miniHighest = highestOf(whole.map((period) => period.count));
|
|
266
|
+
const miniChart = svg("svg", { viewBox: `0 0 ${whole.length * 10} 34`, preserveAspectRatio: "none" });
|
|
267
|
+
whole.forEach((period, index) => {
|
|
268
|
+
const height = Math.max(period.count ? 2 : 0, (period.count / miniHighest) * 30);
|
|
269
|
+
miniChart.append(svg("rect", { x: index * 10 + 1, y: 34 - height, width: 8, height }));
|
|
270
|
+
});
|
|
271
|
+
const first = whole[0].time;
|
|
272
|
+
const last = periodEnd(wholeUnit, whole[whole.length - 1].time);
|
|
273
|
+
const scale = (time) => ((time - first) / (last - first)) * whole.length * 10;
|
|
274
|
+
miniChart.append(svg("rect", { class: "mini-window", x: Math.max(0, scale(from)), y: 1, width: Math.max(2, scale(to) - scale(from)), height: 32, rx: 3 }));
|
|
275
|
+
mini.append(miniChart);
|
|
276
|
+
container.append(mini);
|
|
277
|
+
|
|
278
|
+
if (pan) {
|
|
279
|
+
pan.hidden = shown.maxOffset === 0;
|
|
280
|
+
pan.querySelector("[data-pan-earlier]").disabled = state.offset >= shown.maxOffset;
|
|
281
|
+
pan.querySelector("[data-pan-later]").disabled = state.offset === 0;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function showRelease() {
|
|
286
|
+
const panel = document.querySelector("[data-release-panel]");
|
|
287
|
+
const release = data.releases?.[state.release];
|
|
288
|
+
if (!panel || !release) return;
|
|
289
|
+
const previous = data.releases[state.release - 1];
|
|
290
|
+
panel.querySelector("[data-release-name]").textContent = release.name;
|
|
291
|
+
let meta = release.date;
|
|
292
|
+
if (previous) {
|
|
293
|
+
const start = dayOf(previous.date);
|
|
294
|
+
const end = dayOf(release.date);
|
|
295
|
+
const since = all.day.filter((day) => day.time > start && day.time <= end).reduce((sum, day) => sum + day.count, 0);
|
|
296
|
+
meta += ` · ${labels.count("commitsSince", since, { name: previous.name })} · ${labels.count("daysAfter", Math.round((end - start) / DAY))}`;
|
|
297
|
+
} else {
|
|
298
|
+
meta += ` · ${labels.text("firstRelease")}`;
|
|
299
|
+
}
|
|
300
|
+
panel.querySelector("[data-release-meta]").textContent = meta;
|
|
301
|
+
panel.querySelector("[data-release-notes]").textContent = release.notes || "—";
|
|
302
|
+
panel.hidden = false;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (picker) {
|
|
306
|
+
picker.hidden = false;
|
|
307
|
+
for (const button of picker.querySelectorAll("[data-range]")) {
|
|
308
|
+
button.addEventListener("click", () => {
|
|
309
|
+
state.range = button.dataset.range;
|
|
310
|
+
state.offset = 0;
|
|
311
|
+
state.active = -1;
|
|
312
|
+
for (const other of picker.querySelectorAll("[data-range]")) other.setAttribute("aria-pressed", String(other === button));
|
|
313
|
+
draw();
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
pan?.querySelector("[data-pan-earlier]").addEventListener("click", () => { state.offset += 1; state.active = -1; draw(); });
|
|
318
|
+
pan?.querySelector("[data-pan-later]").addEventListener("click", () => { state.offset = Math.max(0, state.offset - 1); state.active = -1; draw(); });
|
|
319
|
+
draw();
|
|
320
|
+
showRelease();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function setUpCalendar(data, labels) {
|
|
324
|
+
const container = document.querySelector('[data-chart="calendar"]');
|
|
325
|
+
const status = document.querySelector("[data-calendar-status]");
|
|
326
|
+
if (!container || !data.days) return;
|
|
327
|
+
const today = dayOf(data.today);
|
|
328
|
+
const start = dayOf(data.days.start);
|
|
329
|
+
const lastMonday = today - ((new Date(today).getUTCDay() + 6) % 7) * DAY;
|
|
330
|
+
const first = lastMonday - 52 * 7 * DAY;
|
|
331
|
+
const counts = new Map(data.days.counts.map((count, index) => [start + index * DAY, count]));
|
|
332
|
+
const highest = highestOf([...counts.entries()].filter(([time]) => time >= first).map(([, count]) => count));
|
|
333
|
+
const cell = 14;
|
|
334
|
+
const left = 30;
|
|
335
|
+
const top = 16;
|
|
336
|
+
const chart = svg("svg", { width: left + 53 * cell, height: top + 7 * cell, role: "img", "aria-label": labels.text("calendarHelp") });
|
|
337
|
+
[0, 2, 4].forEach((weekday) => {
|
|
338
|
+
const label = svg("text", { x: 0, y: top + weekday * cell + 10 });
|
|
339
|
+
label.textContent = labels.labels.weekdays[weekday];
|
|
340
|
+
chart.append(label);
|
|
341
|
+
});
|
|
342
|
+
for (let week = 0; week < 53; week++) {
|
|
343
|
+
for (let weekday = 0; weekday < 7; weekday++) {
|
|
344
|
+
const time = first + (week * 7 + weekday) * DAY;
|
|
345
|
+
if (time > today) continue;
|
|
346
|
+
const count = counts.get(time) ?? 0;
|
|
347
|
+
if (weekday === 0 && new Date(time).getUTCDate() <= 7) {
|
|
348
|
+
const month = svg("text", { x: left + week * cell, y: 10 });
|
|
349
|
+
month.textContent = labels.labels.months[new Date(time).getUTCMonth()];
|
|
350
|
+
chart.append(month);
|
|
351
|
+
}
|
|
352
|
+
const day = svg("rect", { class: `day heat-${rampStep(count, highest)}`, x: left + week * cell, y: top + weekday * cell, width: 11, height: 11, rx: 2 });
|
|
353
|
+
const title = svg("title");
|
|
354
|
+
title.textContent = `${isoOf(time)}: ${labels.count("commits", count)}`;
|
|
355
|
+
day.append(title);
|
|
356
|
+
day.addEventListener("mouseenter", () => { if (status) status.textContent = title.textContent; });
|
|
357
|
+
chart.append(day);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
container.replaceChildren(chart);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// A squarified treemap: rectangles close to square, the largest first, in a width × height box.
|
|
364
|
+
function squarify(items, width, height) {
|
|
365
|
+
const total = items.reduce((sum, item) => sum + item.value, 0) || 1;
|
|
366
|
+
const scale = (width * height) / total;
|
|
367
|
+
const queue = items.map((item) => ({ ...item, area: Math.max(item.value, 0) * scale })).sort((a, b) => b.area - a.area);
|
|
368
|
+
const placed = [];
|
|
369
|
+
let box = { x: 0, y: 0, w: width, h: height };
|
|
370
|
+
let row = [];
|
|
371
|
+
const worst = (cells, side) => {
|
|
372
|
+
const sum = cells.reduce((acc, cellItem) => acc + cellItem.area, 0);
|
|
373
|
+
const largest = Math.max(...cells.map((cellItem) => cellItem.area));
|
|
374
|
+
const smallest = Math.min(...cells.map((cellItem) => cellItem.area));
|
|
375
|
+
return Math.max((side * side * largest) / (sum * sum), (sum * sum) / (side * side * Math.max(smallest, 1e-9)));
|
|
376
|
+
};
|
|
377
|
+
const lay = (cells) => {
|
|
378
|
+
const sum = cells.reduce((acc, cellItem) => acc + cellItem.area, 0);
|
|
379
|
+
if (box.w >= box.h) {
|
|
380
|
+
const columnWidth = sum / box.h;
|
|
381
|
+
let y = box.y;
|
|
382
|
+
for (const cellItem of cells) { const h = cellItem.area / columnWidth; placed.push({ ...cellItem, x: box.x, y, w: columnWidth, h }); y += h; }
|
|
383
|
+
box = { x: box.x + columnWidth, y: box.y, w: box.w - columnWidth, h: box.h };
|
|
384
|
+
} else {
|
|
385
|
+
const rowHeight = sum / box.w;
|
|
386
|
+
let x = box.x;
|
|
387
|
+
for (const cellItem of cells) { const w = cellItem.area / rowHeight; placed.push({ ...cellItem, x, y: box.y, w, h: rowHeight }); x += w; }
|
|
388
|
+
box = { x: box.x, y: box.y + rowHeight, w: box.w, h: box.h - rowHeight };
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
for (const item of queue) {
|
|
392
|
+
if (item.area <= 0) continue;
|
|
393
|
+
const side = Math.min(box.w, box.h);
|
|
394
|
+
if (!row.length || worst([...row, item], side) <= worst(row, side)) row.push(item);
|
|
395
|
+
else { lay(row); row = [item]; }
|
|
396
|
+
}
|
|
397
|
+
if (row.length) lay(row);
|
|
398
|
+
return placed;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function setUpHeatmap(data, labels) {
|
|
402
|
+
const container = document.querySelector('[data-chart="heatmap"]');
|
|
403
|
+
const panel = document.querySelector("[data-heat-panel]");
|
|
404
|
+
const picker = document.querySelector("[data-level-picker]");
|
|
405
|
+
if (!container || !data.heat) return;
|
|
406
|
+
const state = { level: data.heat.areas.length ? "areas" : "folders", selected: 0 };
|
|
407
|
+
const kinds = { areas: "area", folders: "folder", files: "file" };
|
|
408
|
+
|
|
409
|
+
function select(item) {
|
|
410
|
+
if (!panel) return;
|
|
411
|
+
panel.hidden = false;
|
|
412
|
+
panel.querySelector("[data-panel-kind]").textContent = labels.text(kinds[state.level]);
|
|
413
|
+
panel.querySelector("[data-panel-name]").textContent = item.name;
|
|
414
|
+
panel.querySelector("[data-panel-path]").textContent = item.path;
|
|
415
|
+
panel.querySelector("[data-panel-commits]").textContent = labels.number(item.commits);
|
|
416
|
+
panel.querySelector("[data-panel-lines]").textContent = labels.number(item.lines);
|
|
417
|
+
const rationale = panel.querySelector("[data-panel-rationale]");
|
|
418
|
+
rationale.hidden = state.level !== "areas";
|
|
419
|
+
if (state.level === "areas") {
|
|
420
|
+
panel.querySelector("[data-panel-documented]").textContent = labels.number(item.documented);
|
|
421
|
+
panel.querySelector("[data-panel-inferred]").textContent = labels.number(item.inferred);
|
|
422
|
+
panel.querySelector("[data-panel-documented-bar]").style.flexGrow = String(item.documented || 0.001);
|
|
423
|
+
panel.querySelector("[data-panel-inferred-bar]").style.flexGrow = String(item.inferred || 0.001);
|
|
424
|
+
}
|
|
425
|
+
const list = panel.querySelector("[data-panel-files]");
|
|
426
|
+
list.replaceChildren();
|
|
427
|
+
const files = state.level === "files" ? [] : item.files ?? [];
|
|
428
|
+
if (state.level !== "files" && !files.length) list.append(element("li", "muted", labels.text("noFiles")));
|
|
429
|
+
for (const [path, count] of files) {
|
|
430
|
+
const row = element("li");
|
|
431
|
+
const link = element("a", "grow", path);
|
|
432
|
+
link.href = sourceHref(path);
|
|
433
|
+
row.append(link, element("span", "faint", labels.number(count)));
|
|
434
|
+
list.append(row);
|
|
435
|
+
}
|
|
436
|
+
const link = panel.querySelector("[data-panel-link]");
|
|
437
|
+
link.hidden = !item.href;
|
|
438
|
+
if (item.href) {
|
|
439
|
+
link.href = item.href;
|
|
440
|
+
link.textContent = labels.text(state.level === "areas" ? "openArea" : "openFile");
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function draw() {
|
|
445
|
+
const items = data.heat[state.level];
|
|
446
|
+
container.replaceChildren();
|
|
447
|
+
for (const note of document.querySelectorAll("[data-left-out]")) note.hidden = note.dataset.leftOut !== state.level;
|
|
448
|
+
if (!items.length) {
|
|
449
|
+
container.append(element("p", "chart-fallback muted", labels.text(state.level === "areas" ? "noAreas" : "noCode")));
|
|
450
|
+
panel && (panel.hidden = true);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const width = container.clientWidth || 800;
|
|
454
|
+
const height = container.clientHeight || 430;
|
|
455
|
+
const highest = highestOf(items.map((item) => item.commits));
|
|
456
|
+
const tiles = squarify(items.map((item, index) => ({ ...item, index, value: item.lines })), width, height);
|
|
457
|
+
for (const tile of tiles) {
|
|
458
|
+
const step = rampStep(tile.commits, highest);
|
|
459
|
+
const slot = element("div", "heat-tile");
|
|
460
|
+
Object.assign(slot.style, { left: `${(tile.x / width) * 100}%`, top: `${(tile.y / height) * 100}%`, width: `${(tile.w / width) * 100}%`, height: `${(tile.h / height) * 100}%` });
|
|
461
|
+
const button = element("button", `heat-${step}`);
|
|
462
|
+
button.type = "button";
|
|
463
|
+
button.setAttribute("aria-pressed", String(tile.index === state.selected));
|
|
464
|
+
const words = `${tile.name}: ${labels.count("commits", tile.commits)}, ${labels.count("lines", tile.lines)}${tile.mostlyInferred ? `, ${labels.text("mostlyInferred")}` : ""}`;
|
|
465
|
+
button.setAttribute("aria-label", words);
|
|
466
|
+
button.setAttribute("title", words);
|
|
467
|
+
if (tile.w > 84 && tile.h > 40) {
|
|
468
|
+
button.append(element("span", "heat-tile-name", tile.name));
|
|
469
|
+
button.append(element("span", "heat-tile-sub", `${labels.count("commits", tile.commits)} · ${labels.count("lines", tile.lines)}`));
|
|
470
|
+
}
|
|
471
|
+
if (tile.mostlyInferred) {
|
|
472
|
+
const mark = element("span", "inferred-mark");
|
|
473
|
+
mark.setAttribute("aria-hidden", "true");
|
|
474
|
+
button.append(mark);
|
|
475
|
+
}
|
|
476
|
+
button.addEventListener("click", () => {
|
|
477
|
+
state.selected = tile.index;
|
|
478
|
+
for (const other of container.querySelectorAll("button")) other.setAttribute("aria-pressed", String(other === button));
|
|
479
|
+
select(items[tile.index]);
|
|
480
|
+
});
|
|
481
|
+
slot.append(button);
|
|
482
|
+
container.append(slot);
|
|
483
|
+
}
|
|
484
|
+
select(items[Math.min(state.selected, items.length - 1)]);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (picker) {
|
|
488
|
+
picker.hidden = false;
|
|
489
|
+
for (const button of picker.querySelectorAll("[data-level]")) {
|
|
490
|
+
button.setAttribute("aria-pressed", String(button.dataset.level === state.level));
|
|
491
|
+
button.addEventListener("click", () => {
|
|
492
|
+
state.level = button.dataset.level;
|
|
493
|
+
state.selected = 0;
|
|
494
|
+
for (const other of picker.querySelectorAll("[data-level]")) other.setAttribute("aria-pressed", String(other === button));
|
|
495
|
+
draw();
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
let resizing = 0;
|
|
500
|
+
window.addEventListener("resize", () => { clearTimeout(resizing); resizing = setTimeout(draw, 150); });
|
|
501
|
+
draw();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function setUpGrid() {
|
|
505
|
+
const grid = document.querySelector('[data-chart="grid"]');
|
|
506
|
+
const status = document.querySelector("[data-grid-status]");
|
|
507
|
+
if (!grid || !status) return;
|
|
508
|
+
grid.addEventListener("mouseover", (event) => {
|
|
509
|
+
const cell = event.target.closest?.("[data-cell]");
|
|
510
|
+
if (cell) status.textContent = cell.dataset.cell;
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function setUpRepository() {
|
|
515
|
+
const block = document.querySelector("[data-repository-data]");
|
|
516
|
+
if (!block) return;
|
|
517
|
+
let data;
|
|
518
|
+
try {
|
|
519
|
+
data = JSON.parse(block.textContent);
|
|
520
|
+
} catch {
|
|
521
|
+
return; // the tables below still hold every number
|
|
522
|
+
}
|
|
523
|
+
const labels = new Labels(data);
|
|
524
|
+
setUpTimeline(data, labels);
|
|
525
|
+
setUpCalendar(data, labels);
|
|
526
|
+
setUpHeatmap(data, labels);
|
|
527
|
+
setUpGrid();
|
|
528
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Resizing the sidebar and the Ask panel (design section 16.1): drag a handle, or focus it and use the arrow keys,
|
|
2
|
+
// Home and End; double-click resets. Widths stay within each panel's limits and never squeeze the reading column
|
|
3
|
+
// below MIN_READING; they are remembered in localStorage when the browser allows it.
|
|
4
|
+
import { recall, remember } from "./api.js";
|
|
5
|
+
|
|
6
|
+
const MIN_READING = 480;
|
|
7
|
+
const STEP = 16;
|
|
8
|
+
const NARROW = 900; // below this the sidebar is a menu and the panel is full width, so nothing resizes
|
|
9
|
+
const PANELS = {
|
|
10
|
+
sidebar: { variable: "--sidebar-width", key: "codetrail.sidebar-width", fallback: 272, min: 200, max: 420, share: 0.3 },
|
|
11
|
+
panel: { variable: "--panel-width", key: "codetrail.panel-width", fallback: 420, min: 320, max: 720, share: 0.45 },
|
|
12
|
+
};
|
|
13
|
+
const root = document.documentElement;
|
|
14
|
+
|
|
15
|
+
function rightToLeft() {
|
|
16
|
+
return getComputedStyle(root).direction === "rtl";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function current(name) {
|
|
20
|
+
return parseFloat(getComputedStyle(root).getPropertyValue(PANELS[name].variable)) || PANELS[name].fallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function limits(name) {
|
|
24
|
+
const panel = PANELS[name];
|
|
25
|
+
return { min: panel.min, max: Math.max(panel.min, Math.min(panel.max, Math.floor(window.innerWidth * panel.share))) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function squeezesReading(name) {
|
|
29
|
+
// The panel takes a column of the page only on wide screens; narrower, it floats over the page.
|
|
30
|
+
return name === "sidebar" || (document.body.classList.contains("ask-open") && window.innerWidth > 1200);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readingWidth() {
|
|
34
|
+
return document.querySelector("#content")?.getBoundingClientRect().width ?? Infinity;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function set(name, width) {
|
|
38
|
+
root.style.setProperty(PANELS[name].variable, `${width}px`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function apply(name, wanted) {
|
|
42
|
+
const { min, max } = limits(name);
|
|
43
|
+
let width = Math.round(Math.min(max, Math.max(min, wanted)));
|
|
44
|
+
set(name, width);
|
|
45
|
+
const short = MIN_READING - readingWidth();
|
|
46
|
+
if (short > 0 && squeezesReading(name)) {
|
|
47
|
+
width = Math.max(min, width - Math.ceil(short));
|
|
48
|
+
set(name, width);
|
|
49
|
+
}
|
|
50
|
+
const handle = document.querySelector(`[data-resize="${name}"]`);
|
|
51
|
+
handle?.setAttribute("aria-valuemin", String(min));
|
|
52
|
+
handle?.setAttribute("aria-valuemax", String(max));
|
|
53
|
+
handle?.setAttribute("aria-valuenow", String(width));
|
|
54
|
+
return width;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function saved(name) {
|
|
58
|
+
const value = Number(recall("localStorage", PANELS[name].key));
|
|
59
|
+
return Number.isFinite(value) && value > 0 ? value : PANELS[name].fallback;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function save(name) {
|
|
63
|
+
remember("localStorage", PANELS[name].key, String(Math.round(current(name))));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fit() {
|
|
67
|
+
if (window.innerWidth <= NARROW) return;
|
|
68
|
+
apply("sidebar", saved("sidebar"));
|
|
69
|
+
apply("panel", saved("panel"));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function widthAt(name, x) {
|
|
73
|
+
const fromStart = rightToLeft() ? window.innerWidth - x : x;
|
|
74
|
+
return name === "sidebar" ? fromStart : window.innerWidth - fromStart;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function setUpHandle(handle) {
|
|
78
|
+
const name = handle.dataset.resize;
|
|
79
|
+
if (!PANELS[name]) return;
|
|
80
|
+
handle.addEventListener("pointerdown", (event) => {
|
|
81
|
+
if (event.button !== 0) return;
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
handle.setPointerCapture(event.pointerId);
|
|
84
|
+
handle.classList.add("dragging");
|
|
85
|
+
document.body.classList.add("resizing");
|
|
86
|
+
});
|
|
87
|
+
handle.addEventListener("pointermove", (event) => {
|
|
88
|
+
if (handle.hasPointerCapture(event.pointerId)) apply(name, widthAt(name, event.clientX));
|
|
89
|
+
});
|
|
90
|
+
const stop = (event) => {
|
|
91
|
+
if (!handle.hasPointerCapture(event.pointerId)) return;
|
|
92
|
+
handle.releasePointerCapture(event.pointerId);
|
|
93
|
+
handle.classList.remove("dragging");
|
|
94
|
+
document.body.classList.remove("resizing");
|
|
95
|
+
save(name);
|
|
96
|
+
};
|
|
97
|
+
handle.addEventListener("pointerup", stop);
|
|
98
|
+
handle.addEventListener("pointercancel", stop);
|
|
99
|
+
handle.addEventListener("dblclick", () => {
|
|
100
|
+
remember("localStorage", PANELS[name].key, null);
|
|
101
|
+
apply(name, PANELS[name].fallback);
|
|
102
|
+
});
|
|
103
|
+
handle.addEventListener("keydown", (event) => {
|
|
104
|
+
const { min, max } = limits(name);
|
|
105
|
+
// The sidebar grows toward the page's end, the panel toward its start; right-to-left mirrors both.
|
|
106
|
+
const growKey = (name === "sidebar") !== rightToLeft() ? "ArrowRight" : "ArrowLeft";
|
|
107
|
+
const shrinkKey = growKey === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
|
|
108
|
+
const next = { [growKey]: current(name) + STEP, [shrinkKey]: current(name) - STEP, Home: min, End: max }[event.key];
|
|
109
|
+
if (next === undefined) return;
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
apply(name, next);
|
|
112
|
+
save(name);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function setUpResize() {
|
|
117
|
+
for (const handle of document.querySelectorAll("[data-resize]")) setUpHandle(handle);
|
|
118
|
+
fit();
|
|
119
|
+
let frame = 0;
|
|
120
|
+
window.addEventListener("resize", () => {
|
|
121
|
+
cancelAnimationFrame(frame);
|
|
122
|
+
frame = requestAnimationFrame(fit);
|
|
123
|
+
});
|
|
124
|
+
// Opening or closing the Ask panel changes how much room the page has.
|
|
125
|
+
new MutationObserver(fit).observe(document.body, { attributes: true, attributeFilter: ["class"] });
|
|
126
|
+
}
|