mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +00:00
PR #221 unified the period logic but missed the TUI hotkey bar, GNOME indicator popup, and macOS menubar app. All surfaces now consistently show '6 Months' instead of 'All' or 'all time'.
1002 lines
38 KiB
JavaScript
1002 lines
38 KiB
JavaScript
import GObject from 'gi://GObject';
|
|
import St from 'gi://St';
|
|
import Gio from 'gi://Gio';
|
|
import GLib from 'gi://GLib';
|
|
import Clutter from 'gi://Clutter';
|
|
import Soup from 'gi://Soup?version=3.0';
|
|
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
|
|
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
|
|
import { DataClient } from './dataClient.js';
|
|
|
|
const CACHE_TTL_MS = 300_000;
|
|
const TOP_ACTIVITIES = 10;
|
|
const CHART_HEIGHT = 52;
|
|
const BAR_TRACK_WIDTH = 240;
|
|
|
|
const PERIODS = [
|
|
{ id: 'today', label: 'Today' },
|
|
{ id: 'week', label: '7 Days' },
|
|
{ id: '30days', label: '30 Days' },
|
|
{ id: 'month', label: 'Month' },
|
|
{ id: 'all', label: '6 Months' },
|
|
];
|
|
|
|
const INSIGHTS = [
|
|
{ id: 'activity', label: 'Activity' },
|
|
{ id: 'trend', label: 'Trend' },
|
|
{ id: 'forecast', label: 'Forecast' },
|
|
{ id: 'pulse', label: 'Pulse' },
|
|
{ id: 'stats', label: 'Stats' },
|
|
];
|
|
|
|
const PROVIDERS = [
|
|
{ id: 'all', label: 'All' },
|
|
{ id: 'claude', label: 'Claude' },
|
|
{ id: 'codex', label: 'Codex' },
|
|
{ id: 'cursor', label: 'Cursor' },
|
|
{ id: 'copilot', label: 'Copilot' },
|
|
{ id: 'opencode', label: 'OpenCode' },
|
|
{ id: 'pi', label: 'Pi' },
|
|
{ id: 'droid', label: 'Droid' },
|
|
{ id: 'gemini', label: 'Gemini' },
|
|
{ id: 'kilo-code', label: 'Kilo Code' },
|
|
{ id: 'kiro', label: 'Kiro' },
|
|
{ id: 'roo-code', label: 'Roo Code' },
|
|
];
|
|
|
|
const CURRENCIES = [
|
|
{ code: 'USD', symbol: '$' },
|
|
{ code: 'EUR', symbol: '€' },
|
|
{ code: 'GBP', symbol: '£' },
|
|
{ code: 'CAD', symbol: 'C$' },
|
|
{ code: 'AUD', symbol: 'A$' },
|
|
{ code: 'JPY', symbol: '¥' },
|
|
{ code: 'INR', symbol: '₹' },
|
|
{ code: 'BRL', symbol: 'R$' },
|
|
{ code: 'CHF', symbol: 'CHF ' },
|
|
{ code: 'SEK', symbol: 'kr ' },
|
|
{ code: 'SGD', symbol: 'S$' },
|
|
{ code: 'HKD', symbol: 'HK$' },
|
|
{ code: 'KRW', symbol: '₩' },
|
|
{ code: 'MXN', symbol: 'MX$' },
|
|
{ code: 'ZAR', symbol: 'R ' },
|
|
{ code: 'DKK', symbol: 'kr ' },
|
|
{ code: 'CNY', symbol: '¥' },
|
|
];
|
|
|
|
const PROVIDER_PATHS = {
|
|
claude: '.claude/projects',
|
|
codex: '.codex/sessions',
|
|
cursor: '.config/Cursor/User/globalStorage/state.vscdb',
|
|
copilot: '.copilot/session-state',
|
|
pi: '.pi/agent/sessions',
|
|
};
|
|
|
|
function formatCost(value, currency, rate = 1, exact = false) {
|
|
const n = (Number(value) || 0) * (Number(rate) || 1);
|
|
const abs = Math.abs(n);
|
|
const symbol = currency?.symbol || '$';
|
|
if (!exact && abs >= 1000) return `${symbol}${(n / 1000).toFixed(abs >= 10000 ? 0 : 1)}k`;
|
|
const parts = n.toFixed(2).split('.');
|
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
return `${symbol}${parts.join('.')}`;
|
|
}
|
|
|
|
function formatTokensCompact(n) {
|
|
const v = Number(n) || 0;
|
|
if (v >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(1)}B`;
|
|
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
|
if (v >= 1000) return `${(v / 1000).toFixed(1)}k`;
|
|
return String(v);
|
|
}
|
|
|
|
function formatTime(date) {
|
|
if (!date || Number.isNaN(date.getTime())) return '';
|
|
const now = new Date();
|
|
const diffSec = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
if (diffSec < 60) return 'just now';
|
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
|
|
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
export const CodeBurnIndicator = GObject.registerClass(
|
|
class CodeBurnIndicator extends PanelMenu.Button {
|
|
_init(extension) {
|
|
super._init(0.0, 'CodeBurn');
|
|
|
|
this._extension = extension;
|
|
this._settings = extension.getSettings();
|
|
this._dataClient = new DataClient(this._settings.get_string('codeburn-path'));
|
|
this._settingsChangedIds = [];
|
|
|
|
this._period = this._settings.get_string('default-period') || 'today';
|
|
this._insight = 'activity';
|
|
this._availableProviders = this._detectProviders();
|
|
this._provider = this._availableProviders.length === 1 ? this._availableProviders[0] : 'all';
|
|
|
|
this._currency = this._loadCurrency();
|
|
this._exactCosts = this._settings.get_boolean('show-exact-costs');
|
|
this._fxRate = 1;
|
|
this._fxCache = { USD: 1 };
|
|
this._soupSession = new Soup.Session();
|
|
this._payload = null;
|
|
this._payloadCache = new Map();
|
|
this._inFlightKeys = new Set();
|
|
this._refreshGen = 0;
|
|
this._refreshSourceId = 0;
|
|
this._chartSummaryText = '';
|
|
this._destroyed = false;
|
|
|
|
this._themeSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
|
|
this._themeSignal = this._themeSettings.connect('changed::color-scheme', () => this._applyThemeClass());
|
|
this._applyThemeClass();
|
|
this._updateFxRate();
|
|
|
|
this._buildPanelButton();
|
|
this._buildPopup();
|
|
this._connectSettings();
|
|
this._startRefreshLoop();
|
|
this._refresh();
|
|
}
|
|
|
|
// -- Panel button --
|
|
|
|
_buildPanelButton() {
|
|
const box = new St.BoxLayout({ style_class: 'panel-status-menu-box codeburn-panel' });
|
|
this._panelIcon = new St.Label({
|
|
text: '🔥',
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
style_class: 'codeburn-flame',
|
|
});
|
|
this._panelLabel = new St.Label({
|
|
text: '...',
|
|
y_align: Clutter.ActorAlign.CENTER,
|
|
style_class: 'codeburn-label',
|
|
});
|
|
box.add_child(this._panelIcon);
|
|
box.add_child(this._panelLabel);
|
|
this._panelLabel.visible = !this._settings.get_boolean('compact-mode');
|
|
this.add_child(box);
|
|
}
|
|
|
|
// -- Popup --
|
|
|
|
_buildPopup() {
|
|
try {
|
|
this.menu.box.add_style_class_name('codeburn-menu');
|
|
this._popupHost = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false });
|
|
this._popupHost.add_style_class_name('codeburn-host');
|
|
this.menu.addMenuItem(this._popupHost);
|
|
|
|
this._root = new St.BoxLayout({ vertical: true, style_class: 'codeburn-root', x_expand: true });
|
|
this._popupHost.add_child(this._root);
|
|
|
|
this._buildBrandHeader();
|
|
|
|
this._scrollView = new St.ScrollView({
|
|
style_class: 'codeburn-scroll',
|
|
hscrollbar_policy: St.PolicyType.NEVER,
|
|
vscrollbar_policy: St.PolicyType.AUTOMATIC,
|
|
y_expand: true,
|
|
});
|
|
this._scrollContent = new St.BoxLayout({ vertical: true, x_expand: true });
|
|
this._scrollView.set_child(this._scrollContent);
|
|
this._root.add_child(this._scrollView);
|
|
|
|
this._buildAgentTabs();
|
|
this._buildHero();
|
|
this._buildPeriodTabs();
|
|
this._buildInsightPills();
|
|
this._buildTokenChart();
|
|
this._buildLoadingIndicator();
|
|
this._buildContentArea();
|
|
this._buildBudgetAlert();
|
|
this._buildFindingsSection();
|
|
this._buildFooter();
|
|
} catch (e) {
|
|
log(`CodeBurn: popup build error: ${e.message}\n${e.stack}`);
|
|
}
|
|
}
|
|
|
|
_buildBrandHeader() {
|
|
const header = new St.BoxLayout({ vertical: true, style_class: 'codeburn-brand-header' });
|
|
const title = new St.BoxLayout({ style_class: 'codeburn-brand-row' });
|
|
title.add_child(new St.Label({ text: 'Code', style_class: 'codeburn-brand-primary' }));
|
|
title.add_child(new St.Label({ text: 'Burn', style_class: 'codeburn-brand-accent' }));
|
|
header.add_child(title);
|
|
header.add_child(new St.Label({ text: 'AI Coding Cost Tracker', style_class: 'codeburn-brand-subhead' }));
|
|
this._root.add_child(header);
|
|
}
|
|
|
|
_buildAgentTabs() {
|
|
const detected = this._availableProviders;
|
|
this._agentTabs = new Map();
|
|
this._agentTabRow = null;
|
|
if (detected.length === 0) return;
|
|
|
|
const disabled = this._getDisabledProviders();
|
|
const tabs = detected.length === 1
|
|
? PROVIDERS.filter(p => p.id === detected[0])
|
|
: [PROVIDERS[0], ...PROVIDERS.slice(1).filter(p => detected.includes(p.id) && !disabled.has(p.id))];
|
|
|
|
if (tabs.length === 1) {
|
|
const badge = new St.Label({ text: tabs[0].label, style_class: 'codeburn-agent-badge' });
|
|
const row = new St.BoxLayout({ style_class: 'codeburn-tab-row' });
|
|
row.add_child(badge);
|
|
this._scrollContent.add_child(row);
|
|
return;
|
|
}
|
|
|
|
const useScroll = tabs.length > 5;
|
|
this._agentTabRow = new St.BoxLayout({ style_class: 'codeburn-tab-row' });
|
|
for (const p of tabs) {
|
|
const btn = new St.Button({ label: p.label, style_class: 'codeburn-tab', can_focus: true, x_expand: !useScroll });
|
|
btn.connect('clicked', () => {
|
|
this._provider = p.id;
|
|
this._updateAgentTabStyle();
|
|
this._refresh();
|
|
});
|
|
this._agentTabRow.add_child(btn);
|
|
this._agentTabs.set(p.id, btn);
|
|
}
|
|
if (useScroll) {
|
|
const agentScroll = new St.ScrollView({
|
|
style_class: 'codeburn-agent-scroll',
|
|
hscrollbar_policy: St.PolicyType.AUTOMATIC,
|
|
vscrollbar_policy: St.PolicyType.NEVER,
|
|
});
|
|
agentScroll.set_child(this._agentTabRow);
|
|
this._scrollContent.add_child(agentScroll);
|
|
} else {
|
|
this._scrollContent.add_child(this._agentTabRow);
|
|
}
|
|
this._updateAgentTabStyle();
|
|
}
|
|
|
|
_updateAgentTabStyle() {
|
|
for (const [id, btn] of this._agentTabs) {
|
|
if (id === this._provider) btn.add_style_class_name('codeburn-tab-active');
|
|
else btn.remove_style_class_name('codeburn-tab-active');
|
|
}
|
|
}
|
|
|
|
_buildHero() {
|
|
const hero = new St.BoxLayout({ vertical: true, style_class: 'codeburn-hero' });
|
|
const topLine = new St.BoxLayout({ style_class: 'codeburn-hero-top' });
|
|
this._heroDot = new St.Widget({ style_class: 'codeburn-hero-dot' });
|
|
this._heroLabel = new St.Label({ text: 'Loading...', style_class: 'codeburn-hero-label' });
|
|
topLine.add_child(this._heroDot);
|
|
topLine.add_child(this._heroLabel);
|
|
this._heroAmount = new St.Label({ text: '$0.00', style_class: 'codeburn-hero-amount' });
|
|
this._heroMeta = new St.Label({ text: '', style_class: 'codeburn-hero-meta' });
|
|
hero.add_child(topLine);
|
|
hero.add_child(this._heroAmount);
|
|
hero.add_child(this._heroMeta);
|
|
this._scrollContent.add_child(hero);
|
|
}
|
|
|
|
_buildPeriodTabs() {
|
|
const row = new St.BoxLayout({ style_class: 'codeburn-tab-row codeburn-period-row' });
|
|
this._periodTabs = new Map();
|
|
for (const p of PERIODS) {
|
|
const btn = new St.Button({ label: p.label, style_class: 'codeburn-period', can_focus: true, x_expand: true });
|
|
btn.connect('clicked', () => {
|
|
this._period = p.id;
|
|
this._updatePeriodTabStyle();
|
|
this._refresh();
|
|
});
|
|
row.add_child(btn);
|
|
this._periodTabs.set(p.id, btn);
|
|
}
|
|
this._scrollContent.add_child(row);
|
|
this._updatePeriodTabStyle();
|
|
}
|
|
|
|
_updatePeriodTabStyle() {
|
|
for (const [id, btn] of this._periodTabs) {
|
|
if (id === this._period) btn.add_style_class_name('codeburn-period-active');
|
|
else btn.remove_style_class_name('codeburn-period-active');
|
|
}
|
|
}
|
|
|
|
_buildInsightPills() {
|
|
const row = new St.BoxLayout({ style_class: 'codeburn-insight-row' });
|
|
this._insightPills = new Map();
|
|
for (const i of INSIGHTS) {
|
|
const btn = new St.Button({ label: i.label, style_class: 'codeburn-insight-pill', can_focus: true, x_expand: true });
|
|
btn.connect('clicked', () => {
|
|
this._insight = i.id;
|
|
this._updateInsightPillStyle();
|
|
this._renderContent();
|
|
});
|
|
row.add_child(btn);
|
|
this._insightPills.set(i.id, btn);
|
|
}
|
|
this._scrollContent.add_child(row);
|
|
this._updateInsightPillStyle();
|
|
}
|
|
|
|
_updateInsightPillStyle() {
|
|
for (const [id, btn] of this._insightPills) {
|
|
if (id === this._insight) btn.add_style_class_name('codeburn-insight-pill-active');
|
|
else btn.remove_style_class_name('codeburn-insight-pill-active');
|
|
}
|
|
}
|
|
|
|
_buildTokenChart() {
|
|
this._chartContainer = new St.BoxLayout({ vertical: true, style_class: 'codeburn-chart' });
|
|
const header = new St.BoxLayout({ style_class: 'codeburn-chart-header' });
|
|
this._chartLabel = new St.Label({ text: 'Tokens', style_class: 'codeburn-chart-label', x_expand: true });
|
|
this._chartTotal = new St.Label({ text: '', style_class: 'codeburn-chart-total' });
|
|
header.add_child(this._chartLabel);
|
|
header.add_child(this._chartTotal);
|
|
this._chartContainer.add_child(header);
|
|
this._chartBars = new St.BoxLayout({ style_class: 'codeburn-chart-bars' });
|
|
this._chartContainer.add_child(this._chartBars);
|
|
this._scrollContent.add_child(this._chartContainer);
|
|
}
|
|
|
|
_buildContentArea() {
|
|
this._scrollContent.add_child(new St.Widget({ style_class: 'codeburn-divider' }));
|
|
this._contentArea = new St.BoxLayout({ vertical: true, style_class: 'codeburn-content' });
|
|
this._scrollContent.add_child(this._contentArea);
|
|
}
|
|
|
|
_buildBudgetAlert() {
|
|
this._budgetLabel = new St.Label({ text: '', style_class: 'codeburn-budget-warning', visible: false });
|
|
this._scrollContent.add_child(this._budgetLabel);
|
|
}
|
|
|
|
_buildFindingsSection() {
|
|
this._findingsBtn = new St.Button({ style_class: 'codeburn-findings', visible: false });
|
|
const box = new St.BoxLayout({ style_class: 'codeburn-findings-inner' });
|
|
this._findingsCount = new St.Label({ text: '', style_class: 'codeburn-findings-count' });
|
|
this._findingsSavings = new St.Label({ text: '', style_class: 'codeburn-findings-savings' });
|
|
box.add_child(this._findingsCount);
|
|
box.add_child(this._findingsSavings);
|
|
this._findingsBtn.set_child(box);
|
|
this._findingsBtn.connect('clicked', () => this._spawnTerminal(['codeburn', 'optimize']));
|
|
this._scrollContent.add_child(this._findingsBtn);
|
|
}
|
|
|
|
_buildLoadingIndicator() {
|
|
this._loadingBox = new St.BoxLayout({ vertical: true, style_class: 'codeburn-loading', visible: false, x_expand: true });
|
|
const widths = [0.85, 0.6, 0.92, 0.5, 0.75, 0.45];
|
|
for (const w of widths) {
|
|
const bar = new St.Widget({ style_class: 'codeburn-skeleton-bar', x_expand: false });
|
|
bar.set_width(Math.round(308 * w));
|
|
bar.set_height(10);
|
|
this._loadingBox.add_child(bar);
|
|
}
|
|
this._scrollContent.add_child(this._loadingBox);
|
|
}
|
|
|
|
_showLoading() {
|
|
if (!this._loadingBox) return;
|
|
this._loadingBox.visible = true;
|
|
this._loadingBox.get_children().forEach((bar, i) => {
|
|
bar.opacity = 255;
|
|
bar.ease({
|
|
opacity: 60,
|
|
duration: 900,
|
|
delay: i * 120,
|
|
mode: Clutter.AnimationMode.EASE_IN_OUT_SINE,
|
|
repeatCount: -1,
|
|
autoReverse: true,
|
|
});
|
|
});
|
|
}
|
|
|
|
_hideLoading() {
|
|
if (!this._loadingBox) return;
|
|
this._loadingBox.visible = false;
|
|
this._loadingBox.get_children().forEach(bar => {
|
|
bar.remove_all_transitions();
|
|
bar.opacity = 255;
|
|
});
|
|
}
|
|
|
|
_buildFooter() {
|
|
this._currencyPicker = new St.ScrollView({
|
|
style_class: 'codeburn-currency-picker',
|
|
visible: false,
|
|
hscrollbar_policy: St.PolicyType.NEVER,
|
|
vscrollbar_policy: St.PolicyType.AUTOMATIC,
|
|
});
|
|
const pickerList = new St.BoxLayout({ vertical: true, style_class: 'codeburn-currency-list' });
|
|
for (const c of CURRENCIES) {
|
|
const item = new St.Button({ label: `${c.symbol} ${c.code}`, style_class: 'codeburn-currency-item', can_focus: true });
|
|
if (c.code === this._currency.code) item.add_style_class_name('codeburn-currency-item-active');
|
|
item.connect('clicked', () => {
|
|
this._setCurrency(c.code);
|
|
this._currencyPicker.hide();
|
|
pickerList.get_children().forEach(ch => ch.remove_style_class_name('codeburn-currency-item-active'));
|
|
item.add_style_class_name('codeburn-currency-item-active');
|
|
});
|
|
pickerList.add_child(item);
|
|
}
|
|
this._currencyPicker.set_child(pickerList);
|
|
this._root.add_child(this._currencyPicker);
|
|
|
|
const footer = new St.BoxLayout({ style_class: 'codeburn-footer' });
|
|
|
|
this._currencyBtn = new St.Button({
|
|
label: `${this._currency.code} ⌄`,
|
|
style_class: 'codeburn-footer-btn codeburn-currency-btn',
|
|
can_focus: true,
|
|
});
|
|
this._currencyBtn.connect('clicked', () => this._toggleCurrencyPicker());
|
|
footer.add_child(this._currencyBtn);
|
|
|
|
const refreshBtn = new St.Button({ label: 'Refresh', style_class: 'codeburn-footer-btn', can_focus: true, x_expand: true });
|
|
refreshBtn.connect('clicked', () => this._refresh(true));
|
|
footer.add_child(refreshBtn);
|
|
|
|
const reportBtn = new St.Button({ label: 'Full Report', style_class: 'codeburn-footer-btn codeburn-footer-cta', can_focus: true, x_expand: true });
|
|
reportBtn.connect('clicked', () => this._spawnTerminal(['codeburn', 'report', '--period', this._period, '--provider', this._provider]));
|
|
footer.add_child(reportBtn);
|
|
|
|
const prefsBtn = new St.Button({ label: '⚙', style_class: 'codeburn-footer-btn codeburn-prefs-btn', can_focus: true });
|
|
prefsBtn.connect('clicked', () => {
|
|
this._extension.openPreferences();
|
|
this.menu.close();
|
|
});
|
|
footer.add_child(prefsBtn);
|
|
|
|
this._root.add_child(footer);
|
|
this._updatedLabel = new St.Label({ text: '', style_class: 'codeburn-updated' });
|
|
this._root.add_child(this._updatedLabel);
|
|
}
|
|
|
|
// -- Settings --
|
|
|
|
_connectSettings() {
|
|
const watch = (key, cb) => {
|
|
const id = this._settings.connect(`changed::${key}`, cb);
|
|
this._settingsChangedIds.push(id);
|
|
};
|
|
watch('refresh-interval', () => this._restartRefreshLoop());
|
|
watch('compact-mode', () => { this._panelLabel.visible = !this._settings.get_boolean('compact-mode'); });
|
|
watch('codeburn-path', () => {
|
|
this._dataClient.setCodeburnPath(this._settings.get_string('codeburn-path'));
|
|
this._refresh(true);
|
|
});
|
|
watch('default-period', () => {
|
|
this._period = this._settings.get_string('default-period');
|
|
this._updatePeriodTabStyle();
|
|
this._refresh();
|
|
});
|
|
watch('budget-threshold', () => this._updateBudget());
|
|
watch('budget-alert-enabled', () => this._updateBudget());
|
|
watch('force-dark-mode', () => this._applyThemeClass());
|
|
watch('show-exact-costs', () => {
|
|
this._exactCosts = this._settings.get_boolean('show-exact-costs');
|
|
if (this._payload) this._render(this._payload);
|
|
});
|
|
watch('disabled-providers', () => {
|
|
if (this._payload) this._render(this._payload);
|
|
});
|
|
}
|
|
|
|
_getDisabledProviders() {
|
|
return new Set(this._settings.get_strv('disabled-providers'));
|
|
}
|
|
|
|
// -- Provider detection --
|
|
|
|
_detectProviders() {
|
|
const home = GLib.get_home_dir();
|
|
const xdgData = GLib.getenv('XDG_DATA_HOME') || `${home}/.local/share`;
|
|
const checks = Object.fromEntries(
|
|
Object.entries(PROVIDER_PATHS).map(([id, rel]) => [id, `${home}/${rel}`])
|
|
);
|
|
checks.opencode = `${xdgData}/opencode`;
|
|
const out = [];
|
|
for (const [id, path] of Object.entries(checks)) {
|
|
if (Gio.File.new_for_path(path).query_exists(null)) out.push(id);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// -- Refresh loop --
|
|
|
|
_startRefreshLoop() {
|
|
const interval = this._settings.get_uint('refresh-interval') || 30;
|
|
this._refreshSourceId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, interval, () => {
|
|
this._refresh();
|
|
return GLib.SOURCE_CONTINUE;
|
|
});
|
|
}
|
|
|
|
_restartRefreshLoop() {
|
|
if (this._refreshSourceId) {
|
|
GLib.Source.remove(this._refreshSourceId);
|
|
this._refreshSourceId = 0;
|
|
}
|
|
this._startRefreshLoop();
|
|
}
|
|
|
|
// -- Data fetching with cache --
|
|
|
|
_cacheKey() {
|
|
return `${this._period}|${this._provider}`;
|
|
}
|
|
|
|
async _refresh(force = false) {
|
|
const key = this._cacheKey();
|
|
const cached = this._payloadCache.get(key);
|
|
const cacheAge = cached ? Date.now() - cached.fetchedAt : Infinity;
|
|
|
|
if (!force && cached && cacheAge < CACHE_TTL_MS) {
|
|
this._payload = cached.payload;
|
|
this._render(this._payload);
|
|
return;
|
|
}
|
|
|
|
if (this._inFlightKeys.has(key)) return;
|
|
this._inFlightKeys.add(key);
|
|
const gen = ++this._refreshGen;
|
|
|
|
if (cached) {
|
|
this._payload = cached.payload;
|
|
this._render(this._payload);
|
|
} else {
|
|
this._showLoading();
|
|
if (this._contentArea) this._contentArea.opacity = 120;
|
|
}
|
|
|
|
try {
|
|
const payload = await this._dataClient.fetch(this._period, this._provider);
|
|
this._inFlightKeys.delete(key);
|
|
if (this._destroyed || gen !== this._refreshGen) return;
|
|
this._payloadCache.set(key, { payload, fetchedAt: Date.now() });
|
|
if (this._cacheKey() === key) {
|
|
this._payload = payload;
|
|
this._hideLoading();
|
|
if (this._contentArea) this._contentArea.opacity = 255;
|
|
this._render(this._payload);
|
|
}
|
|
} catch (e) {
|
|
this._inFlightKeys.delete(key);
|
|
if (this._destroyed) return;
|
|
this._hideLoading();
|
|
if (this._contentArea) this._contentArea.opacity = 255;
|
|
if (gen !== this._refreshGen) return;
|
|
if (e.message?.includes('cancelled')) return;
|
|
log(`CodeBurn: refresh error: ${e.message}`);
|
|
if (!this._payload) this._renderError(e.message);
|
|
}
|
|
}
|
|
|
|
// -- Rendering --
|
|
|
|
_render(payload) {
|
|
const current = payload?.current ?? {};
|
|
const cost = Number(current.cost ?? 0);
|
|
|
|
this._panelLabel.set_text(this._fmt(cost));
|
|
this._heroLabel.set_text(current.label || '');
|
|
this._heroAmount.set_text(this._fmt(cost));
|
|
|
|
const calls = Number(current.calls ?? 0);
|
|
const sessions = Number(current.sessions ?? 0);
|
|
this._heroMeta.set_text(`${calls.toLocaleString()} calls ${sessions} sessions`);
|
|
|
|
this._renderChart(payload?.history?.daily ?? []);
|
|
this._renderContent();
|
|
this._renderFindings(payload?.optimize ?? {});
|
|
this._updateBudget();
|
|
|
|
const updated = payload?.generated ? formatTime(new Date(payload.generated)) : '';
|
|
this._updatedLabel.set_text(updated ? `Updated ${updated}` : '');
|
|
}
|
|
|
|
_renderChart(daily) {
|
|
this._chartBars.destroy_all_children();
|
|
const days = Array.isArray(daily) ? daily.slice(-19) : [];
|
|
if (days.length === 0) {
|
|
this._chartContainer.visible = false;
|
|
return;
|
|
}
|
|
const inTotals = days.map(d => Number(d?.inputTokens) || 0);
|
|
const outTotals = days.map(d => Number(d?.outputTokens) || 0);
|
|
const totals = inTotals.map((v, i) => v + outTotals[i]);
|
|
let maxTotal = 1;
|
|
let totalIn = 0;
|
|
let totalOut = 0;
|
|
let hasAnyTokens = false;
|
|
for (let i = 0; i < days.length; i++) {
|
|
if (totals[i] > maxTotal) maxTotal = totals[i];
|
|
if (totals[i] > 0) hasAnyTokens = true;
|
|
totalIn += inTotals[i];
|
|
totalOut += outTotals[i];
|
|
}
|
|
if (!hasAnyTokens) {
|
|
this._chartContainer.visible = false;
|
|
return;
|
|
}
|
|
this._chartContainer.visible = true;
|
|
const summaryText = `In: ${formatTokensCompact(totalIn)} Out: ${formatTokensCompact(totalOut)}`;
|
|
this._chartTotal.set_text(summaryText);
|
|
this._chartSummaryText = summaryText;
|
|
|
|
const chartWidth = 308;
|
|
const gap = 2;
|
|
const barW = Math.max(4, Math.floor((chartWidth - gap * (days.length - 1)) / days.length));
|
|
|
|
for (let i = 0; i < days.length; i++) {
|
|
const h = Math.max(2, Math.round((totals[i] / maxTotal) * CHART_HEIGHT));
|
|
const col = new St.BoxLayout({ vertical: true, style_class: 'codeburn-chart-col', reactive: true });
|
|
col.set_width(barW);
|
|
col.set_height(CHART_HEIGHT);
|
|
const spacer = new St.Widget({ style_class: 'codeburn-chart-spacer' });
|
|
spacer.set_height(CHART_HEIGHT - h);
|
|
const bar = new St.Widget({ style_class: 'codeburn-chart-bar' });
|
|
bar.set_width(barW);
|
|
bar.set_height(h);
|
|
col.add_child(spacer);
|
|
col.add_child(bar);
|
|
|
|
const date = days[i]?.date || '';
|
|
const inTok = formatTokensCompact(inTotals[i]);
|
|
const outTok = formatTokensCompact(outTotals[i]);
|
|
const cost = days[i]?.cost != null ? this._fmt(days[i].cost) : '';
|
|
col.connect('enter-event', () => {
|
|
this._chartTotal.set_text(`${date} ${inTok}/${outTok} ${cost}`);
|
|
this._chartTotal.add_style_class_name('codeburn-chart-total-hover');
|
|
bar.add_style_class_name('codeburn-chart-bar-hover');
|
|
return Clutter.EVENT_PROPAGATE;
|
|
});
|
|
col.connect('leave-event', () => {
|
|
this._chartTotal.set_text(this._chartSummaryText);
|
|
this._chartTotal.remove_style_class_name('codeburn-chart-total-hover');
|
|
bar.remove_style_class_name('codeburn-chart-bar-hover');
|
|
return Clutter.EVENT_PROPAGATE;
|
|
});
|
|
|
|
this._chartBars.add_child(col);
|
|
}
|
|
}
|
|
|
|
_renderContent() {
|
|
this._contentArea.destroy_all_children();
|
|
switch (this._insight) {
|
|
case 'trend': return this._renderTrendView();
|
|
case 'forecast': return this._renderForecastView();
|
|
case 'pulse': return this._renderPulseView();
|
|
case 'stats': return this._renderStatsView();
|
|
default: return this._renderActivityView();
|
|
}
|
|
}
|
|
|
|
_renderActivityView() {
|
|
const current = this._payload?.current ?? {};
|
|
this._contentArea.add_child(this._sectionTitle('Activity'));
|
|
const actHeader = new St.BoxLayout({ style_class: 'codeburn-table-header' });
|
|
actHeader.add_child(new St.Label({ text: 'Name', style_class: 'codeburn-th', x_expand: true }));
|
|
actHeader.add_child(new St.Label({ text: 'Cost', style_class: 'codeburn-th codeburn-th-right codeburn-th-cost' }));
|
|
actHeader.add_child(new St.Label({ text: 'Turns', style_class: 'codeburn-th codeburn-th-right codeburn-th-turns' }));
|
|
actHeader.add_child(new St.Label({ text: '1-shot', style_class: 'codeburn-th codeburn-th-right codeburn-th-turns' }));
|
|
this._contentArea.add_child(actHeader);
|
|
const rows = new St.BoxLayout({ vertical: true, style_class: 'codeburn-activity-rows' });
|
|
const activities = Array.isArray(current.topActivities) ? current.topActivities : [];
|
|
if (!activities.length) {
|
|
rows.add_child(new St.Label({ text: 'No activity for this period', style_class: 'codeburn-empty' }));
|
|
} else {
|
|
const maxCost = activities.reduce((m, a) => Math.max(m, Number(a.cost) || 0), 0) || 1;
|
|
for (const a of activities.slice(0, TOP_ACTIVITIES)) {
|
|
rows.add_child(this._buildActivityRow(a, maxCost));
|
|
}
|
|
}
|
|
this._contentArea.add_child(rows);
|
|
|
|
const models = Array.isArray(current.topModels) ? current.topModels : [];
|
|
if (models.length) {
|
|
this._contentArea.add_child(this._sectionTitle('Models'));
|
|
const modHeader = new St.BoxLayout({ style_class: 'codeburn-table-header' });
|
|
modHeader.add_child(new St.Label({ text: 'Model', style_class: 'codeburn-th', x_expand: true }));
|
|
modHeader.add_child(new St.Label({ text: 'Cost', style_class: 'codeburn-th codeburn-th-right codeburn-th-cost' }));
|
|
modHeader.add_child(new St.Label({ text: 'Calls', style_class: 'codeburn-th codeburn-th-right codeburn-th-calls' }));
|
|
this._contentArea.add_child(modHeader);
|
|
const mrows = new St.BoxLayout({ vertical: true, style_class: 'codeburn-models-rows' });
|
|
for (const m of models.slice(0, 3)) mrows.add_child(this._buildModelRow(m));
|
|
this._contentArea.add_child(mrows);
|
|
}
|
|
}
|
|
|
|
_renderTrendView() {
|
|
const daily = this._payload?.history?.daily ?? [];
|
|
if (!daily.length) {
|
|
this._contentArea.add_child(new St.Label({ text: 'Not enough history yet', style_class: 'codeburn-empty' }));
|
|
return;
|
|
}
|
|
for (const d of daily.slice(-7).reverse()) {
|
|
const row = new St.BoxLayout({ style_class: 'codeburn-trend-row' });
|
|
row.add_child(new St.Label({ text: d.date, style_class: 'codeburn-trend-date', x_expand: true }));
|
|
const costLabel = new St.Label({ text: this._fmt(d.cost), style_class: 'codeburn-trend-cost' });
|
|
costLabel.clutter_text.x_align = Clutter.ActorAlign.END;
|
|
row.add_child(costLabel);
|
|
const callsLabel = new St.Label({ text: `${Number(d.calls).toLocaleString()} calls`, style_class: 'codeburn-trend-calls' });
|
|
callsLabel.clutter_text.x_align = Clutter.ActorAlign.END;
|
|
row.add_child(callsLabel);
|
|
this._contentArea.add_child(row);
|
|
}
|
|
}
|
|
|
|
_renderForecastView() {
|
|
const daily = this._payload?.history?.daily ?? [];
|
|
if (daily.length < 3) {
|
|
this._contentArea.add_child(new St.Label({ text: 'Need at least 3 days of history', style_class: 'codeburn-empty' }));
|
|
return;
|
|
}
|
|
const last7 = daily.slice(-7);
|
|
const avg = last7.reduce((s, d) => s + Number(d.cost || 0), 0) / last7.length;
|
|
const yesterday = daily.at(-2);
|
|
const yestCost = Number(yesterday?.cost || 0);
|
|
const todCost = Number(daily.at(-1)?.cost || 0);
|
|
const dod = yestCost > 0 ? ((todCost - yestCost) / yestCost) * 100 : 0;
|
|
const now = new Date();
|
|
const dayOfMonth = now.getUTCDate();
|
|
const daysInMonth = new Date(now.getUTCFullYear(), now.getUTCMonth() + 1, 0).getUTCDate();
|
|
|
|
this._contentArea.add_child(this._kvRow('7-day avg', this._fmt(avg)));
|
|
this._contentArea.add_child(this._kvRow('Yesterday', yesterday ? this._fmt(yestCost) : '-'));
|
|
this._contentArea.add_child(this._kvRow('Day-over-day', `${dod > 0 ? '+' : ''}${dod.toFixed(1)}%`));
|
|
this._contentArea.add_child(this._kvRow('Month projection', this._fmt(avg * daysInMonth)));
|
|
this._contentArea.add_child(this._kvRow('Days elapsed', `${dayOfMonth} of ${daysInMonth}`));
|
|
}
|
|
|
|
_renderPulseView() {
|
|
const current = this._payload?.current ?? {};
|
|
const daily = this._payload?.history?.daily ?? [];
|
|
this._contentArea.add_child(this._sectionTitle('Pulse'));
|
|
const row = new St.BoxLayout({ style_class: 'codeburn-pulse-row' });
|
|
row.add_child(this._pulseTile(this._fmt(current.cost), 'cost'));
|
|
row.add_child(this._pulseTile(Number(current.calls || 0).toLocaleString(), 'calls'));
|
|
row.add_child(this._pulseTile(`${Number(current.cacheHitPercent || 0).toFixed(0)}%`, 'cache hit'));
|
|
this._contentArea.add_child(row);
|
|
|
|
if (daily.length) {
|
|
this._contentArea.add_child(this._sectionTitle('Last 7 days'));
|
|
const last7 = daily.slice(-7);
|
|
const sumCost = last7.reduce((s, d) => s + Number(d.cost || 0), 0);
|
|
const sumCalls = last7.reduce((s, d) => s + Number(d.calls || 0), 0);
|
|
const peakDay = last7.reduce((best, d) => Number(d.cost || 0) > Number(best.cost || 0) ? d : best, last7[0]);
|
|
this._contentArea.add_child(this._kvRow('Total spend', this._fmt(sumCost)));
|
|
this._contentArea.add_child(this._kvRow('Total calls', Number(sumCalls).toLocaleString()));
|
|
this._contentArea.add_child(this._kvRow('Peak day', `${peakDay?.date || '-'} ${this._fmt(peakDay?.cost)}`));
|
|
}
|
|
}
|
|
|
|
_renderStatsView() {
|
|
const current = this._payload?.current ?? {};
|
|
const daily = this._payload?.history?.daily ?? [];
|
|
this._contentArea.add_child(this._sectionTitle('Stats'));
|
|
const models = Array.isArray(current.topModels) ? current.topModels : [];
|
|
const favModel = models[0]?.name ?? '-';
|
|
const activeDays = daily.filter(d => Number(d.cost || 0) > 0).length;
|
|
const peakDay = daily.reduce((best, d) => Number(d.cost || 0) > Number((best || {}).cost || 0) ? d : best, null);
|
|
let streak = 0;
|
|
for (let i = daily.length - 1; i >= 0; i--) {
|
|
if (Number(daily[i].cost || 0) > 0) streak++;
|
|
else break;
|
|
}
|
|
this._contentArea.add_child(this._kvRow('Favorite model', favModel));
|
|
this._contentArea.add_child(this._kvRow('Active days', `${activeDays}`));
|
|
this._contentArea.add_child(this._kvRow('Current streak', `${streak} days`));
|
|
if (peakDay) this._contentArea.add_child(this._kvRow('Peak day', `${peakDay.date} ${this._fmt(peakDay.cost)}`));
|
|
}
|
|
|
|
_renderFindings(optimize) {
|
|
const count = Number(optimize?.findingCount ?? 0);
|
|
if (count === 0) {
|
|
this._findingsBtn.hide();
|
|
return;
|
|
}
|
|
const savings = Number(optimize?.savingsUSD ?? 0);
|
|
this._findingsCount.set_text(`${count} optimize findings`);
|
|
this._findingsSavings.set_text(`save ~${this._fmt(savings)}`);
|
|
this._findingsBtn.show();
|
|
}
|
|
|
|
_renderError(message) {
|
|
this._panelLabel.set_text('!');
|
|
if (message?.includes('not found') || message?.includes('No such file')) {
|
|
this._heroLabel.set_text('CodeBurn CLI not found');
|
|
this._heroMeta.set_text('Install: npm i -g codeburn');
|
|
} else {
|
|
this._heroLabel.set_text('Error loading data');
|
|
this._heroMeta.set_text(message?.substring(0, 80) || 'Unknown error');
|
|
}
|
|
this._heroAmount.set_text('');
|
|
this._findingsBtn.hide();
|
|
}
|
|
|
|
// -- Budget --
|
|
|
|
_updateBudget() {
|
|
const enabled = this._settings.get_boolean('budget-alert-enabled');
|
|
const threshold = this._settings.get_double('budget-threshold');
|
|
if (!enabled || threshold <= 0 || !this._payload?.current) {
|
|
this._budgetLabel.visible = false;
|
|
return;
|
|
}
|
|
const cost = Number(this._payload.current.cost ?? 0) * this._fxRate;
|
|
const thresholdConverted = threshold * this._fxRate;
|
|
if (cost >= thresholdConverted) {
|
|
this._budgetLabel.set_text(`Budget exceeded: ${this._fmt(cost)} / ${this._fmt(thresholdConverted)}`);
|
|
this._budgetLabel.visible = true;
|
|
} else {
|
|
this._budgetLabel.visible = false;
|
|
}
|
|
}
|
|
|
|
// -- Currency --
|
|
|
|
_loadCurrency() {
|
|
const configPath = GLib.build_filenamev([GLib.get_home_dir(), '.config', 'codeburn', 'config.json']);
|
|
try {
|
|
const [ok, contents] = GLib.file_get_contents(configPath);
|
|
if (ok) {
|
|
const config = JSON.parse(new TextDecoder().decode(contents));
|
|
if (config.currency?.code) {
|
|
const known = CURRENCIES.find(c => c.code === config.currency.code);
|
|
if (known) return known;
|
|
return { code: config.currency.code, symbol: config.currency.symbol || `${config.currency.code} ` };
|
|
}
|
|
}
|
|
} catch (_) { /* default */ }
|
|
return CURRENCIES[0];
|
|
}
|
|
|
|
_toggleCurrencyPicker() {
|
|
this._currencyPicker.visible = !this._currencyPicker.visible;
|
|
}
|
|
|
|
_setCurrency(code) {
|
|
try {
|
|
Gio.Subprocess.new(['codeburn', 'currency', code], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
|
|
} catch (_) { /* CLI missing */ }
|
|
const known = CURRENCIES.find(c => c.code === code);
|
|
this._currency = known || { code, symbol: `${code} ` };
|
|
this._currencyBtn.set_label(`${this._currency.code} ⌄`);
|
|
this._updateFxRate();
|
|
}
|
|
|
|
_updateFxRate() {
|
|
const code = this._currency?.code || 'USD';
|
|
if (this._fxCache[code] !== undefined) {
|
|
this._fxRate = this._fxCache[code];
|
|
if (this._payload) this._render(this._payload);
|
|
return;
|
|
}
|
|
const url = `https://api.frankfurter.app/latest?from=USD&to=${code}`;
|
|
const msg = Soup.Message.new('GET', url);
|
|
this._soupSession.send_and_read_async(msg, GLib.PRIORITY_DEFAULT, null, (session, result) => {
|
|
if (this._destroyed) return;
|
|
try {
|
|
const bytes = session.send_and_read_finish(result);
|
|
if (!bytes) return;
|
|
const json = JSON.parse(new TextDecoder().decode(bytes.get_data()));
|
|
const rate = json?.rates?.[code];
|
|
if (typeof rate === 'number' && rate > 0) {
|
|
this._fxCache[code] = rate;
|
|
this._fxRate = rate;
|
|
if (this._payload) this._render(this._payload);
|
|
}
|
|
} catch (_) { /* FX fetch failed */ }
|
|
});
|
|
}
|
|
|
|
_fmt(value) {
|
|
return formatCost(value, this._currency, this._fxRate, this._exactCosts);
|
|
}
|
|
|
|
// -- UI helpers --
|
|
|
|
_sectionTitle(text) {
|
|
return new St.Label({ text, style_class: 'codeburn-section-title' });
|
|
}
|
|
|
|
_kvRow(label, value) {
|
|
const row = new St.BoxLayout({ style_class: 'codeburn-kv-row' });
|
|
row.add_child(new St.Label({ text: label, style_class: 'codeburn-kv-label', x_expand: true }));
|
|
row.add_child(new St.Label({ text: String(value ?? '-'), style_class: 'codeburn-kv-value' }));
|
|
return row;
|
|
}
|
|
|
|
_pulseTile(value, label) {
|
|
const tile = new St.BoxLayout({ vertical: true, style_class: 'codeburn-pulse-tile', x_expand: true });
|
|
tile.add_child(new St.Label({ text: value, style_class: 'codeburn-pulse-value' }));
|
|
tile.add_child(new St.Label({ text: label, style_class: 'codeburn-pulse-label' }));
|
|
return tile;
|
|
}
|
|
|
|
_buildActivityRow(activity, maxCost) {
|
|
const row = new St.BoxLayout({ vertical: true, style_class: 'codeburn-activity-row' });
|
|
const topLine = new St.BoxLayout({ style_class: 'codeburn-activity-top' });
|
|
topLine.add_child(new St.Label({ text: activity.name, style_class: 'codeburn-activity-name', x_expand: true }));
|
|
const costLabel = new St.Label({ text: this._fmt(activity.cost), style_class: 'codeburn-activity-cost' });
|
|
costLabel.clutter_text.x_align = Clutter.ActorAlign.END;
|
|
topLine.add_child(costLabel);
|
|
const turnsLabel = new St.Label({ text: `${Number(activity.turns) || 0}`, style_class: 'codeburn-activity-turns' });
|
|
turnsLabel.clutter_text.x_align = Clutter.ActorAlign.END;
|
|
topLine.add_child(turnsLabel);
|
|
const osText = activity.oneShotRate != null ? `${Math.round(Number(activity.oneShotRate) * 100)}%` : '--';
|
|
const osLabel = new St.Label({ text: osText, style_class: 'codeburn-activity-oneshot' });
|
|
osLabel.clutter_text.x_align = Clutter.ActorAlign.END;
|
|
topLine.add_child(osLabel);
|
|
row.add_child(topLine);
|
|
|
|
const track = new St.BoxLayout({ style_class: 'codeburn-bar-track' });
|
|
const pct = Math.max(0.02, Math.min(1, Number(activity.cost) / maxCost));
|
|
const fill = new St.Widget({ style_class: 'codeburn-bar-fill' });
|
|
fill.set_width(Math.round(BAR_TRACK_WIDTH * pct));
|
|
track.add_child(fill);
|
|
row.add_child(track);
|
|
return row;
|
|
}
|
|
|
|
_buildModelRow(model) {
|
|
const row = new St.BoxLayout({ style_class: 'codeburn-model-row' });
|
|
row.add_child(new St.Label({ text: model.name, style_class: 'codeburn-model-name', x_expand: true }));
|
|
const mc = new St.Label({ text: this._fmt(model.cost), style_class: 'codeburn-model-cost' });
|
|
mc.clutter_text.x_align = Clutter.ActorAlign.END;
|
|
row.add_child(mc);
|
|
const mcalls = new St.Label({ text: `${Number(model.calls || 0).toLocaleString()}`, style_class: 'codeburn-model-calls' });
|
|
mcalls.clutter_text.x_align = Clutter.ActorAlign.END;
|
|
row.add_child(mcalls);
|
|
return row;
|
|
}
|
|
|
|
// -- Theme --
|
|
|
|
_applyThemeClass() {
|
|
const forceDark = this._settings.get_boolean('force-dark-mode');
|
|
const scheme = this._themeSettings.get_string('color-scheme');
|
|
const isDark = forceDark || scheme === 'prefer-dark';
|
|
if (isDark) {
|
|
this._root?.add_style_class_name('codeburn-dark');
|
|
this._root?.remove_style_class_name('codeburn-light');
|
|
} else {
|
|
this._root?.add_style_class_name('codeburn-light');
|
|
this._root?.remove_style_class_name('codeburn-dark');
|
|
}
|
|
}
|
|
|
|
// -- Terminal spawning --
|
|
|
|
_spawnTerminal(argv) {
|
|
const command = `${argv.join(' ')}; echo; read -n 1 -s -r -p 'Press any key to close...'`;
|
|
try {
|
|
Gio.Subprocess.new(['gnome-terminal', '--', 'bash', '-lc', command], Gio.SubprocessFlags.NONE);
|
|
} catch (e) {
|
|
log(`CodeBurn: terminal spawn error: ${e.message}`);
|
|
}
|
|
this.menu.close();
|
|
}
|
|
|
|
// -- Cleanup --
|
|
|
|
destroy() {
|
|
this._destroyed = true;
|
|
if (this._refreshSourceId) {
|
|
GLib.Source.remove(this._refreshSourceId);
|
|
this._refreshSourceId = 0;
|
|
}
|
|
if (this._themeSettings && this._themeSignal) {
|
|
this._themeSettings.disconnect(this._themeSignal);
|
|
this._themeSignal = null;
|
|
this._themeSettings = null;
|
|
}
|
|
for (const id of this._settingsChangedIds) this._settings.disconnect(id);
|
|
this._settingsChangedIds = [];
|
|
this._dataClient?.destroy();
|
|
if (this._soupSession) {
|
|
this._soupSession.abort();
|
|
this._soupSession = null;
|
|
}
|
|
super.destroy();
|
|
}
|
|
});
|