tracking app
No files matched your search
@@ -0,0 +1,7 @@
|
||||
*~
|
||||
/target
|
||||
**/gen/schemas
|
||||
*.log
|
||||
.DS_Store
|
||||
node_modules/
|
||||
ui/dev-snapshot.json
|
||||
@@ -0,0 +1,27 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/craft-core", "crates/craft-github", "crates/craft-status-app"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1"
|
||||
dirs = "7"
|
||||
thiserror = "2"
|
||||
chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
@@ -0,0 +1,60 @@
|
||||
# Convenience targets. Tauri CLI resolves the project from crates/craft-status-app.
|
||||
APP_DIR := crates/craft-status-app
|
||||
APP_NAME := Craft Status
|
||||
BUNDLE := target/release/bundle/macos/$(APP_NAME).app
|
||||
INSTALLED := /Applications/$(APP_NAME).app
|
||||
AGENT_ID := ai.storyteller.craft-status
|
||||
AGENT := $(HOME)/Library/LaunchAgents/$(AGENT_ID).plist
|
||||
|
||||
.PHONY: dev run build bundle install uninstall test check snapshot preview icons clean
|
||||
|
||||
dev: ## tauri dev (live-reloads ui/)
|
||||
cd $(APP_DIR) && cargo tauri dev
|
||||
|
||||
run: ## fast debug binary, no bundling
|
||||
cargo run -p craft-status-app
|
||||
|
||||
build: ## release build + every installer for this OS
|
||||
cd $(APP_DIR) && cargo tauri build
|
||||
|
||||
bundle: ## release .app only (macOS)
|
||||
cd $(APP_DIR) && cargo tauri build --bundles app
|
||||
|
||||
install: bundle ## copy to /Applications, start at login, launch now (macOS)
|
||||
-pkill -x craft-status-app; sleep 1
|
||||
rm -rf "$(INSTALLED)" && cp -R "$(BUNDLE)" "$(INSTALLED)"
|
||||
mkdir -p "$(HOME)/Library/LaunchAgents"
|
||||
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>' \
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
|
||||
'<plist version="1.0"><dict>' \
|
||||
'<key>Label</key><string>$(AGENT_ID)</string>' \
|
||||
'<key>ProgramArguments</key><array><string>/usr/bin/open</string><string>-a</string><string>$(INSTALLED)</string></array>' \
|
||||
'<key>RunAtLoad</key><true/>' \
|
||||
'</dict></plist>' > "$(AGENT)"
|
||||
open "$(INSTALLED)"
|
||||
|
||||
uninstall: ## quit, remove the app and the login item (keeps config + cache)
|
||||
-pkill -x craft-status-app
|
||||
rm -rf "$(INSTALLED)" "$(AGENT)"
|
||||
|
||||
test: ## Rust + UI unit tests
|
||||
cargo test --workspace
|
||||
node --test ui/*.test.mjs
|
||||
|
||||
check: ## lints
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo fmt --all -- --check
|
||||
|
||||
snapshot: ## fetch once from GitHub into ui/dev-snapshot.json (prints a table)
|
||||
cargo run -q -p craft-github --bin craft-fetch -- --snapshot ui/dev-snapshot.json
|
||||
|
||||
preview: snapshot ## open the UI in a browser against the dev snapshot
|
||||
@echo "http://127.0.0.1:8766/index.html (Ctrl+C to stop)"
|
||||
python3 -m http.server 8766 --bind 127.0.0.1 --directory ui
|
||||
|
||||
icons: ## regenerate icons from the SVG sources
|
||||
cd $(APP_DIR) && cargo tauri icon icons-src/app-icon.svg -o icons && rm -rf icons/android icons/ios
|
||||
cd $(APP_DIR) && cargo tauri icon icons-src/tray.svg -o /tmp/craft-tray -p 64 && cp /tmp/craft-tray/64x64.png icons/tray.png
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
@@ -0,0 +1,128 @@
|
||||
Craft Repo Status App
|
||||
=====================
|
||||
|
||||
Find out the status of the craft apps: a tray dashboard ("Craft Status") that
|
||||
polls the storytold `*craft` repositories on GitHub every 5 minutes, keeps a
|
||||
cache, and tells you what to fix first. Everything runs locally: no server.
|
||||
|
||||
- **Rust workspace**
|
||||
- `craft-core`: the model, release/download accounting, the urgency
|
||||
heuristic, config, cache and window state. No network or UI dependencies,
|
||||
and it is unit-tested.
|
||||
- `craft-github`: the GitHub GraphQL/REST fetcher, plus a `craft-fetch` CLI
|
||||
that prints a table, for debugging.
|
||||
- `craft-status-app`: the Tauri 2 shell (poll thread, tray, global
|
||||
shortcut, window).
|
||||
- **Plain HTML/CSS/ES modules** in `ui/`. There is no bundler and no Node at
|
||||
build time. `ui/model.js` is pure and is tested with `node --test`.
|
||||
|
||||
## What it shows
|
||||
|
||||
Every column sorts. Click a header; click it again to reverse the order. Repos
|
||||
with no data always sort last.
|
||||
|
||||
| Group | Columns |
|
||||
| --- | --- |
|
||||
| Fix first | **Urgency** (the sum of the top five open-issue scores) · **Critical** (open issues naming a crash, hang, freeze, data loss, launch failure or security problem) |
|
||||
| Open | open PRs · open issues · oldest open PR |
|
||||
| Latest | last commit to `main` · newest issue · newest PR |
|
||||
| Last 4 h | commits to `main` · PRs opened · issues opened |
|
||||
| All time | commits on `main` · contributors · issues (open + closed) · PRs (open + closed + merged) |
|
||||
| Builds | most recent release tag · its publish date · its downloads · downloads across all releases |
|
||||
|
||||
- **Builds** are GitHub releases. Drafts are ignored. Prereleases count, and
|
||||
they carry a `PRE` tag.
|
||||
- **Downloads** add up every OS and package type: dmg, msi, AppImage, deb,
|
||||
rpm, flatpak, zips and tarballs. Checksum lists, signatures, `.zsync` delta
|
||||
files and updater manifests are left out, because machines fetch them, not
|
||||
people.
|
||||
- **Fix first** (`⌘2`) ranks open issues across every repo. Each issue shows
|
||||
the reasons behind its score. Click a row in the Repos table to filter the
|
||||
list to that repo. Click an issue to open it on GitHub.
|
||||
- **Freshness**: the header shows when the oldest data on screen was fetched,
|
||||
and a pill that reads `FRESH`, `REFRESHING` or **`STALE`**. Data is stale
|
||||
when any repo has gone `stale_minutes` (default 15) without a successful
|
||||
fetch. A stale row is dimmed and gets an amber dot; a failed row gets a red
|
||||
dot, and its error appears in the banner. The tray tooltip also says
|
||||
whether the data is stale.
|
||||
|
||||
### How issues are scored
|
||||
|
||||
The score is deterministic and simple to read (`crates/craft-core/src/urgency.rs`):
|
||||
|
||||
| Signal | Points |
|
||||
| --- | --- |
|
||||
| Crash, panic, freeze, hang, data loss, corruption, won't open or launch, blank screen, security | +40 |
|
||||
| Broken, but not severe ("doesn't work", error, regression…) | +15 |
|
||||
| `bug` label | +15 |
|
||||
| Filed by someone outside the team | +10 |
|
||||
| Reactions ×3 and comments ×2, log-scaled | up to +40 |
|
||||
| Names the latest build's version | +10 |
|
||||
| Opened in the last 24 h | +8 |
|
||||
| Opened in the last 72 h | +4 |
|
||||
| Feature request, question or docs nit | −25 |
|
||||
| Untouched for 30 days | −5 |
|
||||
|
||||
Issues labelled `wontfix`, `duplicate` or `invalid` are dropped. For each
|
||||
repo, the 100 newest open issues and the 30 most-commented open issues are
|
||||
scored.
|
||||
|
||||
## Polling and cache
|
||||
|
||||
- The app polls every `poll_minutes` (default 5), four repos at a time. Each
|
||||
repo costs about three GraphQL requests and one REST request: roughly 50 of
|
||||
the 5,000 GraphQL points GitHub allows per hour per cycle.
|
||||
- If fewer than 150 points remain, polling slows to every 30 minutes. If
|
||||
GitHub rate-limits the app, it waits three intervals. If every repo fails
|
||||
(offline, or just woke from sleep), it retries after one minute.
|
||||
- Timing follows the wall clock, so a laptop that wakes from sleep polls
|
||||
within about 15 s.
|
||||
- `⌘R` or the ⟳ button refreshes immediately.
|
||||
- The last snapshot is cached in `~/Library/Caches/craft-status/snapshot.json`.
|
||||
On launch the window shows the cached data at once, marked `STALE` if it's
|
||||
old. If the cache is less than one interval old, the app skips the poll it
|
||||
would otherwise run at launch.
|
||||
- The GitHub token comes from `github_token` in the config. If that is empty,
|
||||
it comes from `$GITHUB_TOKEN` or `$GH_TOKEN`, and if those are unset, from
|
||||
`gh auth token`.
|
||||
|
||||
## Tray and window
|
||||
|
||||
The app behaves like the Todo app:
|
||||
|
||||
- It lives in the menu bar, with no Dock icon.
|
||||
- Left-click the tray icon to show or hide the window. Right-click it to get
|
||||
Refresh Now, Always on Top, Edit Config… and Quit.
|
||||
- `⌘⇧G` shows or hides the window from anywhere.
|
||||
- The window is chrome-less and semi-transparent, and it appears on every
|
||||
Space. It remembers its position and size in `~/.craft_status_state.toml`.
|
||||
- `Esc` hides the window.
|
||||
|
||||
## Configuration: `~/.craft_status_config.toml`
|
||||
|
||||
The app creates this file from
|
||||
[`craft_status_config.template.toml`](craft_status_config.template.toml) the
|
||||
first time it launches. It re-reads the file before each poll, so edits to the
|
||||
repo list or the intervals take effect without a restart.
|
||||
|
||||
## Build, install, test
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Rust stable
|
||||
- `cargo install tauri-cli --version "^2"`
|
||||
- Xcode CLT on macOS
|
||||
- `gh auth login`, or a token in the config
|
||||
|
||||
```sh
|
||||
make install # release .app → /Applications/Craft Status.app, start at login, launch
|
||||
make uninstall # quit, remove the app and the login item (keeps config + cache)
|
||||
make run # debug binary
|
||||
make test # Rust + UI unit tests
|
||||
make check # clippy + rustfmt
|
||||
make snapshot # fetch once, print a table, write ui/dev-snapshot.json
|
||||
make preview # serve ui/ in a browser against that snapshot (no Tauri needed)
|
||||
```
|
||||
|
||||
To start the app at login, `make install` adds
|
||||
`~/Library/LaunchAgents/ai.storyteller.craft-status.plist`.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Craft Status — ~/.craft_status_config.toml
|
||||
# The app re-reads this file before every poll; changes apply without a restart
|
||||
# (window/tray/shortcut changes apply on the next poll or relaunch).
|
||||
|
||||
# Repositories to watch: "owner/name" or full GitHub URLs.
|
||||
repos = [
|
||||
"storytold/photocraft",
|
||||
"storytold/lightcraft",
|
||||
"storytold/filmcraft",
|
||||
"storytold/designcraft",
|
||||
"storytold/vectorcraft",
|
||||
"storytold/pdfcraft",
|
||||
"storytold/effectcraft",
|
||||
"storytold/wordcraft",
|
||||
"storytold/deckcraft",
|
||||
"storytold/gridcraft",
|
||||
"storytold/cadcraft",
|
||||
"storytold/soundcraft",
|
||||
]
|
||||
|
||||
# How often to poll GitHub (minutes, at least 1). Each poll costs roughly
|
||||
# 3 GraphQL requests + 1 REST request per repo, far below GitHub's 5000/hour.
|
||||
poll_minutes = 5.0
|
||||
|
||||
# Data older than this is flagged STALE in the window (never less than poll_minutes).
|
||||
stale_minutes = 15.0
|
||||
|
||||
# Leave empty to use $GITHUB_TOKEN / $GH_TOKEN, or else `gh auth token`.
|
||||
github_token = ""
|
||||
|
||||
[window]
|
||||
width = 1340.0
|
||||
height = 560.0
|
||||
always_on_top = false
|
||||
start_hidden = false
|
||||
opacity = 0.96
|
||||
|
||||
[tray]
|
||||
hide_dock_icon = true # macOS: menu-bar-only app (restart to apply)
|
||||
visible_on_all_workspaces = true # summon onto the current desktop/space
|
||||
close_to_tray = true
|
||||
|
||||
[shortcuts]
|
||||
toggle_window = "CmdOrCtrl+Shift+G" # "" disables
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "craft-core"
|
||||
description = "Model, urgency scoring, config and cache for Craft Status"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
dirs.workspace = true
|
||||
thiserror.workspace = true
|
||||
chrono.workspace = true
|
||||
@@ -0,0 +1,65 @@
|
||||
//! The last snapshot, persisted so the window has numbers the instant it opens
|
||||
//! (flagged stale until the next poll lands). Corrupt or missing → empty.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::model::Snapshot;
|
||||
use crate::{atomic_write, Error, Result};
|
||||
|
||||
pub struct Cache {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
/// `~/Library/Caches/craft-status/snapshot.json` (or the platform equivalent).
|
||||
pub fn default_location() -> Result<Cache> {
|
||||
let dir = dirs::cache_dir()
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join(".cache")))
|
||||
.ok_or(Error::NoHome)?;
|
||||
Ok(Cache::at(dir.join("craft-status").join("snapshot.json")))
|
||||
}
|
||||
|
||||
pub fn at(path: PathBuf) -> Cache {
|
||||
Cache { path }
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Option<Snapshot> {
|
||||
let text = std::fs::read_to_string(&self.path).ok()?;
|
||||
let mut s: Snapshot = serde_json::from_str(&text).ok()?;
|
||||
s.refreshing = false;
|
||||
Some(s)
|
||||
}
|
||||
|
||||
pub fn save(&self, s: &Snapshot) -> Result<()> {
|
||||
atomic_write(&self.path, &serde_json::to_string(s)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_and_corrupt_fallback() {
|
||||
let dir = std::env::temp_dir().join(format!("craft-cache-{}", std::process::id()));
|
||||
let cache = Cache::at(dir.join("s.json"));
|
||||
assert!(cache.load().is_none());
|
||||
let mut s = Snapshot {
|
||||
refreshing: true,
|
||||
poll_secs: 300,
|
||||
..Default::default()
|
||||
};
|
||||
s.sync_repos(&["o/r".into()]);
|
||||
cache.save(&s).unwrap();
|
||||
let back = cache.load().unwrap();
|
||||
assert!(!back.refreshing, "a loaded cache is never mid-refresh");
|
||||
assert_eq!(back.repos, s.repos);
|
||||
std::fs::write(cache.path(), "{not json").unwrap();
|
||||
assert!(cache.load().is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//! `~/.craft_status_config.toml` (created from a commented template on first
|
||||
//! launch) and `~/.craft_status_state.toml` (remembered window geometry).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{atomic_write, Error, Result};
|
||||
|
||||
const CONFIG_FILE_NAME: &str = ".craft_status_config.toml";
|
||||
const STATE_FILE_NAME: &str = ".craft_status_state.toml";
|
||||
pub const TEMPLATE: &str = include_str!("../../../craft_status_config.template.toml");
|
||||
|
||||
pub const DEFAULT_REPOS: &[&str] = &[
|
||||
"storytold/photocraft",
|
||||
"storytold/lightcraft",
|
||||
"storytold/filmcraft",
|
||||
"storytold/designcraft",
|
||||
"storytold/vectorcraft",
|
||||
"storytold/pdfcraft",
|
||||
"storytold/effectcraft",
|
||||
"storytold/wordcraft",
|
||||
"storytold/deckcraft",
|
||||
"storytold/gridcraft",
|
||||
"storytold/cadcraft",
|
||||
"storytold/soundcraft",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
/// `owner/name` or full `https://github.com/owner/name` URLs.
|
||||
pub repos: Vec<String>,
|
||||
pub poll_minutes: f64,
|
||||
pub stale_minutes: f64,
|
||||
/// Optional. Otherwise `GITHUB_TOKEN` / `GH_TOKEN`, otherwise `gh auth token`.
|
||||
pub github_token: String,
|
||||
pub window: WindowConfig,
|
||||
pub tray: TrayConfig,
|
||||
pub shortcuts: Shortcuts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WindowConfig {
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub always_on_top: bool,
|
||||
pub start_hidden: bool,
|
||||
pub opacity: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct TrayConfig {
|
||||
pub hide_dock_icon: bool,
|
||||
pub visible_on_all_workspaces: bool,
|
||||
pub close_to_tray: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Shortcuts {
|
||||
pub toggle_window: String,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
repos: DEFAULT_REPOS.iter().map(|s| s.to_string()).collect(),
|
||||
poll_minutes: 5.0,
|
||||
stale_minutes: 15.0,
|
||||
github_token: String::new(),
|
||||
window: WindowConfig::default(),
|
||||
tray: TrayConfig::default(),
|
||||
shortcuts: Shortcuts::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WindowConfig {
|
||||
fn default() -> Self {
|
||||
WindowConfig {
|
||||
width: 1340.0,
|
||||
height: 560.0,
|
||||
always_on_top: false,
|
||||
start_hidden: false,
|
||||
opacity: 0.96,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TrayConfig {
|
||||
fn default() -> Self {
|
||||
TrayConfig {
|
||||
hide_dock_icon: true,
|
||||
visible_on_all_workspaces: true,
|
||||
close_to_tray: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Shortcuts {
|
||||
fn default() -> Self {
|
||||
Shortcuts {
|
||||
toggle_window: "CmdOrCtrl+Shift+G".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `https://github.com/o/r(.git)(/…)` or `o/r` → `o/r`.
|
||||
pub fn normalize_repo(s: &str) -> Option<String> {
|
||||
let s = s.trim();
|
||||
let s = s
|
||||
.strip_prefix("https://github.com/")
|
||||
.or_else(|| s.strip_prefix("http://github.com/"))
|
||||
.or_else(|| s.strip_prefix("github.com/"))
|
||||
.unwrap_or(s);
|
||||
let mut parts = s.split('/').filter(|p| !p.is_empty());
|
||||
let owner = parts.next()?;
|
||||
let name = parts.next()?.trim_end_matches(".git");
|
||||
let ok = |p: &str| {
|
||||
!p.is_empty()
|
||||
&& p.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
|
||||
};
|
||||
(ok(owner) && ok(name)).then(|| format!("{owner}/{name}"))
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn path() -> Result<PathBuf> {
|
||||
Ok(dirs::home_dir()
|
||||
.ok_or(Error::NoHome)?
|
||||
.join(CONFIG_FILE_NAME))
|
||||
}
|
||||
|
||||
pub fn parse(text: &str) -> Result<Config> {
|
||||
let mut c: Config = toml::from_str(text)?;
|
||||
c.poll_minutes = c.poll_minutes.clamp(1.0, 24.0 * 60.0);
|
||||
c.stale_minutes = c.stale_minutes.max(c.poll_minutes);
|
||||
c.window.opacity = c.window.opacity.clamp(0.2, 1.0);
|
||||
let mut repos: Vec<String> = Vec::new();
|
||||
for r in c.repos.iter().filter_map(|r| normalize_repo(r)) {
|
||||
if !repos.iter().any(|x| x.eq_ignore_ascii_case(&r)) {
|
||||
repos.push(r);
|
||||
}
|
||||
}
|
||||
c.repos = repos;
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
pub fn load_from(path: &Path) -> Result<Config> {
|
||||
let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
|
||||
path: path.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
Self::parse(&text)
|
||||
}
|
||||
|
||||
/// Load the config, writing the template first if there is none.
|
||||
pub fn load_or_create() -> Result<Config> {
|
||||
let path = Self::path()?;
|
||||
if !path.exists() {
|
||||
atomic_write(&path, TEMPLATE)?;
|
||||
}
|
||||
Self::load_from(&path)
|
||||
}
|
||||
|
||||
pub fn poll_secs(&self) -> i64 {
|
||||
(self.poll_minutes * 60.0) as i64
|
||||
}
|
||||
|
||||
pub fn stale_secs(&self) -> i64 {
|
||||
(self.stale_minutes * 60.0) as i64
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WindowState {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct StateFile {
|
||||
window: Option<WindowState>,
|
||||
}
|
||||
|
||||
impl WindowState {
|
||||
pub fn path() -> Result<PathBuf> {
|
||||
Ok(dirs::home_dir().ok_or(Error::NoHome)?.join(STATE_FILE_NAME))
|
||||
}
|
||||
|
||||
pub fn load() -> Option<WindowState> {
|
||||
let text = std::fs::read_to_string(Self::path().ok()?).ok()?;
|
||||
let f: StateFile = toml::from_str(&text).ok()?;
|
||||
f.window.filter(|w| w.width > 0 && w.height > 0)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let text = format!(
|
||||
"# Remembered by Craft Status (window position and size). Safe to delete.\n{}",
|
||||
toml::to_string(&StateFile {
|
||||
window: Some(*self)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
);
|
||||
atomic_write(&Self::path()?, &text)
|
||||
}
|
||||
|
||||
/// Whether enough of the window to grab is on one of the monitors `(x, y, w, h)`.
|
||||
pub fn visible_on(&self, monitors: &[(i32, i32, u32, u32)]) -> bool {
|
||||
const GRAB: i32 = 40;
|
||||
monitors.iter().any(|&(mx, my, mw, mh)| {
|
||||
let (mr, mb) = (mx + mw as i32, my + mh as i32);
|
||||
let (wr, wb) = (self.x + self.width as i32, self.y + self.height as i32);
|
||||
wr.min(mr) - self.x.max(mx) >= GRAB && wb.min(mb) - self.y.max(my) >= GRAB
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn template_parses_to_defaults() {
|
||||
assert_eq!(Config::parse(TEMPLATE).unwrap(), Config::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_is_defaults() {
|
||||
assert_eq!(Config::parse("").unwrap(), Config::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repos_are_normalized_and_deduped() {
|
||||
let c = Config::parse(
|
||||
r#"repos = ["https://github.com/storytold/photocraft", "storytold/photocraft.git",
|
||||
"github.com/a/b/pulls", "nonsense", "x/y z"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(c.repos, vec!["storytold/photocraft", "a/b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intervals_are_clamped() {
|
||||
let c = Config::parse("poll_minutes = 0.1\nstale_minutes = 0.5").unwrap();
|
||||
assert_eq!(c.poll_minutes, 1.0);
|
||||
assert_eq!(
|
||||
c.stale_minutes, 1.0,
|
||||
"never stale before the next poll is due"
|
||||
);
|
||||
assert_eq!(c.poll_secs(), 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_state_visibility() {
|
||||
let w = WindowState {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 800,
|
||||
height: 500,
|
||||
};
|
||||
assert!(w.visible_on(&[(0, 0, 1920, 1080)]));
|
||||
assert!(!w.visible_on(&[(2000, 0, 1920, 1080)]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Everything Craft Status knows that isn't network or UI: the per-repo
|
||||
//! statistics model, release/download accounting, the urgency heuristic for
|
||||
//! open issues, the config file, the on-disk cache and window geometry.
|
||||
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod model;
|
||||
pub mod releases;
|
||||
pub mod urgency;
|
||||
|
||||
pub use cache::Cache;
|
||||
pub use config::{Config, WindowState};
|
||||
pub use model::{RepoEntry, RepoStats, Snapshot, UrgentIssue};
|
||||
pub use releases::{summarize_releases, Asset, Release, ReleaseSummary};
|
||||
pub use urgency::{rank_issues, score_issue, IssueInput, Scored};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("no home directory")]
|
||||
NoHome,
|
||||
#[error("{path}: {source}")]
|
||||
Io {
|
||||
path: String,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("config: {0}")]
|
||||
Toml(#[from] toml::de::Error),
|
||||
#[error("cache: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Write via a temp file + rename so readers never see a half-written file.
|
||||
pub fn atomic_write(path: &std::path::Path, text: &str) -> Result<()> {
|
||||
let io = |source| Error::Io {
|
||||
path: path.display().to_string(),
|
||||
source,
|
||||
};
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(io)?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, text).map_err(io)?;
|
||||
std::fs::rename(&tmp, path).map_err(io)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! The data the UI renders. Everything here is serialized to the frontend
|
||||
//! and to the cache file, so field names are part of both contracts.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub type Time = DateTime<Utc>;
|
||||
|
||||
/// One poll's worth of numbers for a repository.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct RepoStats {
|
||||
pub open_prs: u64,
|
||||
pub open_issues: u64,
|
||||
/// Committer date of the head of the default branch (`main`).
|
||||
pub last_commit_at: Option<Time>,
|
||||
/// Newest issue, any state.
|
||||
pub last_issue_at: Option<Time>,
|
||||
/// Oldest PR that is still open.
|
||||
pub oldest_open_pr_at: Option<Time>,
|
||||
/// Newest PR, any state.
|
||||
pub newest_pr_at: Option<Time>,
|
||||
pub commits_4h: u64,
|
||||
pub prs_4h: u64,
|
||||
pub issues_4h: u64,
|
||||
pub commits_total: u64,
|
||||
pub contributors: u64,
|
||||
pub issues_total: u64,
|
||||
pub prs_total: u64,
|
||||
/// Newest published (non-draft) release.
|
||||
pub latest_build: Option<String>,
|
||||
pub latest_build_at: Option<Time>,
|
||||
pub latest_build_prerelease: bool,
|
||||
pub latest_build_downloads: u64,
|
||||
pub downloads_total: u64,
|
||||
/// Sum of the top five urgent-issue scores; the repo-level sort key.
|
||||
pub urgency: f64,
|
||||
/// Open issues whose title or labels name a crash, hang, data loss…
|
||||
pub critical_issues: u64,
|
||||
/// Highest-scoring open issues, best first.
|
||||
pub urgent: Vec<UrgentIssue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct UrgentIssue {
|
||||
pub number: u64,
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub created_at: Option<Time>,
|
||||
pub updated_at: Option<Time>,
|
||||
pub comments: u64,
|
||||
pub reactions: u64,
|
||||
pub labels: Vec<String>,
|
||||
pub score: f64,
|
||||
pub critical: bool,
|
||||
/// Short human explanations of the score ("crash", "12 reactions"…).
|
||||
pub reasons: Vec<String>,
|
||||
}
|
||||
|
||||
/// A configured repository and whatever we last managed to learn about it.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct RepoEntry {
|
||||
/// `owner/name`
|
||||
pub repo: String,
|
||||
pub stats: Option<RepoStats>,
|
||||
/// When `stats` was fetched. Kept across failed polls so stale data stays visible.
|
||||
pub fetched_at: Option<Time>,
|
||||
pub last_attempt_at: Option<Time>,
|
||||
/// Error from the most recent attempt, cleared on success.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl RepoEntry {
|
||||
pub fn new(repo: &str) -> Self {
|
||||
RepoEntry {
|
||||
repo: repo.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
self.repo.rsplit('/').next().unwrap_or(&self.repo)
|
||||
}
|
||||
|
||||
pub fn is_stale(&self, now: Time, stale_after_secs: i64) -> bool {
|
||||
match self.fetched_at {
|
||||
Some(t) => (now - t).num_seconds() > stale_after_secs,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the UI receives.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Snapshot {
|
||||
pub repos: Vec<RepoEntry>,
|
||||
/// End of the last poll cycle that refreshed every repository.
|
||||
pub last_full_refresh_at: Option<Time>,
|
||||
pub refreshing: bool,
|
||||
pub next_refresh_at: Option<Time>,
|
||||
pub poll_secs: i64,
|
||||
pub stale_after_secs: i64,
|
||||
pub rate_limit_remaining: Option<u64>,
|
||||
/// Problems that aren't about one repository (no token, rate limited…).
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
/// The time of the oldest data on screen; `None` if some repo has none.
|
||||
pub fn oldest_data_at(&self) -> Option<Time> {
|
||||
self.repos
|
||||
.iter()
|
||||
.map(|r| r.fetched_at)
|
||||
.try_fold(None::<Time>, |acc, t| {
|
||||
let t = t?;
|
||||
Some(Some(acc.map_or(t, |a| a.min(t))))
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub fn is_stale(&self, now: Time) -> bool {
|
||||
self.repos.is_empty()
|
||||
|| self
|
||||
.repos
|
||||
.iter()
|
||||
.any(|r| r.is_stale(now, self.stale_after_secs))
|
||||
}
|
||||
|
||||
/// Make `repos` match the configured list, keeping data for repos that remain.
|
||||
pub fn sync_repos(&mut self, wanted: &[String]) {
|
||||
let mut old = std::mem::take(&mut self.repos);
|
||||
self.repos = wanted
|
||||
.iter()
|
||||
.map(|w| {
|
||||
old.iter()
|
||||
.position(|e| e.repo.eq_ignore_ascii_case(w))
|
||||
.map(|i| old.swap_remove(i))
|
||||
.unwrap_or_else(|| RepoEntry::new(w))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
|
||||
fn at(mins_ago: i64, now: Time) -> Option<Time> {
|
||||
Some(now - Duration::minutes(mins_ago))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staleness_uses_the_oldest_repo() {
|
||||
let now = Utc::now();
|
||||
let mut s = Snapshot {
|
||||
stale_after_secs: 15 * 60,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(s.is_stale(now), "nothing configured counts as stale");
|
||||
s.sync_repos(&["a/x".into(), "a/y".into()]);
|
||||
assert!(s.is_stale(now), "never fetched");
|
||||
s.repos[0].fetched_at = at(2, now);
|
||||
s.repos[1].fetched_at = at(5, now);
|
||||
assert!(!s.is_stale(now));
|
||||
assert_eq!(s.oldest_data_at(), at(5, now));
|
||||
s.repos[1].fetched_at = at(16, now);
|
||||
assert!(s.is_stale(now));
|
||||
assert!(!s.repos[0].is_stale(now, s.stale_after_secs));
|
||||
s.repos[1].fetched_at = None;
|
||||
assert_eq!(s.oldest_data_at(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_repos_keeps_data_and_order() {
|
||||
let mut s = Snapshot::default();
|
||||
s.sync_repos(&["o/a".into(), "o/b".into()]);
|
||||
s.repos[1].error = Some("kept".into());
|
||||
s.sync_repos(&["o/c".into(), "O/B".into()]);
|
||||
assert_eq!(s.repos.len(), 2);
|
||||
assert_eq!(s.repos[0].repo, "o/c");
|
||||
assert_eq!(s.repos[1].error.as_deref(), Some("kept"));
|
||||
assert_eq!(s.repos[1].name(), "b");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! "Builds" are GitHub releases. Downloads are summed over the assets people
|
||||
//! actually install (installers, archives, packages), not over checksum lists,
|
||||
//! signatures, AppImage delta files or updater manifests, which are fetched by
|
||||
//! machines and would inflate the numbers.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Asset {
|
||||
pub name: String,
|
||||
pub download_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Release {
|
||||
pub tag: String,
|
||||
pub draft: bool,
|
||||
pub prerelease: bool,
|
||||
pub published_at: Option<DateTime<Utc>>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct ReleaseSummary {
|
||||
pub latest_tag: Option<String>,
|
||||
pub latest_at: Option<DateTime<Utc>>,
|
||||
pub latest_prerelease: bool,
|
||||
pub latest_downloads: u64,
|
||||
pub total_downloads: u64,
|
||||
}
|
||||
|
||||
/// Assets that are not something a person downloads to run the app.
|
||||
pub fn is_counted_asset(name: &str) -> bool {
|
||||
let n = name.to_ascii_lowercase();
|
||||
const SKIP_SUFFIXES: &[&str] = &[
|
||||
".zsync",
|
||||
".sha256",
|
||||
".sha512",
|
||||
".sha256sum",
|
||||
".sig",
|
||||
".asc",
|
||||
".minisig",
|
||||
".blockmap",
|
||||
".json",
|
||||
".yml",
|
||||
".yaml",
|
||||
".txt",
|
||||
".intoto.jsonl",
|
||||
];
|
||||
!(n.starts_with("sha256sums")
|
||||
|| n.starts_with("checksums")
|
||||
|| SKIP_SUFFIXES.iter().any(|s| n.ends_with(s)))
|
||||
}
|
||||
|
||||
fn downloads(r: &Release) -> u64 {
|
||||
r.assets
|
||||
.iter()
|
||||
.filter(|a| is_counted_asset(&a.name))
|
||||
.map(|a| a.download_count)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Latest = newest published, non-draft release (prereleases count: they are
|
||||
/// builds too). Drafts are invisible to users and are ignored entirely.
|
||||
pub fn summarize_releases(releases: &[Release]) -> ReleaseSummary {
|
||||
let published: Vec<&Release> = releases.iter().filter(|r| !r.draft).collect();
|
||||
let latest = published
|
||||
.iter()
|
||||
.max_by_key(|r| r.published_at.or(r.created_at));
|
||||
ReleaseSummary {
|
||||
latest_tag: latest.map(|r| r.tag.clone()),
|
||||
latest_at: latest.and_then(|r| r.published_at.or(r.created_at)),
|
||||
latest_prerelease: latest.is_some_and(|r| r.prerelease),
|
||||
latest_downloads: latest.map_or(0, |r| downloads(r)),
|
||||
total_downloads: published.iter().map(|r| downloads(r)).sum(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rel(tag: &str, draft: bool, day: u32, assets: &[(&str, u64)]) -> Release {
|
||||
Release {
|
||||
tag: tag.into(),
|
||||
draft,
|
||||
prerelease: tag.contains("rc"),
|
||||
published_at: (!draft).then(|| format!("2026-10-{day:02}T00:00:00Z").parse().unwrap()),
|
||||
created_at: Some(format!("2026-10-{day:02}T00:00:00Z").parse().unwrap()),
|
||||
assets: assets
|
||||
.iter()
|
||||
.map(|(n, c)| Asset {
|
||||
name: n.to_string(),
|
||||
download_count: *c,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_machine_assets() {
|
||||
assert!(is_counted_asset("photocraft-0.5.0-macos-universal.dmg"));
|
||||
assert!(is_counted_asset("photocraft-web-0.5.0.zip"));
|
||||
assert!(is_counted_asset("x-linux-x86_64.AppImage"));
|
||||
assert!(!is_counted_asset("x-linux-x86_64.AppImage.zsync"));
|
||||
assert!(!is_counted_asset("SHA256SUMS.txt"));
|
||||
assert!(!is_counted_asset("latest.json"));
|
||||
assert!(!is_counted_asset("x.dmg.sig"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_skips_drafts_and_totals_published() {
|
||||
let rs = vec![
|
||||
rel("v0.4.1", true, 8, &[("a.dmg", 0)]),
|
||||
rel(
|
||||
"v0.5.0",
|
||||
false,
|
||||
7,
|
||||
&[("a.dmg", 10), ("a.msi", 5), ("SHA256SUMS.txt", 99)],
|
||||
),
|
||||
rel(
|
||||
"v0.3.0",
|
||||
false,
|
||||
3,
|
||||
&[("a.dmg", 100), ("a.AppImage.zsync", 50)],
|
||||
),
|
||||
];
|
||||
let s = summarize_releases(&rs);
|
||||
assert_eq!(s.latest_tag.as_deref(), Some("v0.5.0"));
|
||||
assert_eq!(s.latest_downloads, 15);
|
||||
assert_eq!(s.total_downloads, 115);
|
||||
assert!(!s.latest_prerelease);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prerelease_can_be_latest_and_empty_is_empty() {
|
||||
let s = summarize_releases(&[
|
||||
rel("v0.1.0", false, 1, &[]),
|
||||
rel("v0.2.0-rc.1", false, 2, &[]),
|
||||
]);
|
||||
assert_eq!(s.latest_tag.as_deref(), Some("v0.2.0-rc.1"));
|
||||
assert!(s.latest_prerelease);
|
||||
assert_eq!(summarize_releases(&[]), ReleaseSummary::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
//! A transparent, deterministic heuristic for "what should be fixed first".
|
||||
//!
|
||||
//! Every open issue gets points for what it says (crash / hang / data loss
|
||||
//! beats "doesn't work" beats everything else), for who filed it (a user
|
||||
//! report outranks the team's own automated findings), for how many people
|
||||
//! are piling on (reactions, comments), for naming the latest build, and for
|
||||
//! being fresh. Feature requests, questions and docs nits are pushed down.
|
||||
//! Each contribution is recorded as a short reason so the UI can show *why*.
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
|
||||
use crate::model::UrgentIssue;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct IssueInput {
|
||||
pub number: u64,
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
pub comments: u64,
|
||||
pub reactions: u64,
|
||||
pub labels: Vec<String>,
|
||||
/// GitHub's `authorAssociation` (`NONE`, `CONTRIBUTOR`, `MEMBER`…).
|
||||
pub author_association: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Scored {
|
||||
pub score: f64,
|
||||
pub critical: bool,
|
||||
pub reasons: Vec<String>,
|
||||
}
|
||||
|
||||
/// Phrases that mean the app is unusable or user data is at risk.
|
||||
const CRITICAL: &[(&str, &str)] = &[
|
||||
("crash", "crash"),
|
||||
("crashes", "crash"),
|
||||
("crashed", "crash"),
|
||||
("crashing", "crash"),
|
||||
("panic", "panic"),
|
||||
("panics", "panic"),
|
||||
("segfault", "crash"),
|
||||
("data loss", "data loss"),
|
||||
("lost my", "data loss"),
|
||||
("lost work", "data loss"),
|
||||
("corrupt", "corruption"),
|
||||
("corrupted", "corruption"),
|
||||
("corrupts", "corruption"),
|
||||
("freeze", "freeze"),
|
||||
("freezes", "freeze"),
|
||||
("frozen", "freeze"),
|
||||
("hang", "hang"),
|
||||
("hangs", "hang"),
|
||||
("unresponsive", "hang"),
|
||||
("not responding", "hang"),
|
||||
("won't open", "won't open"),
|
||||
("wont open", "won't open"),
|
||||
("can't open", "won't open"),
|
||||
("cannot open", "won't open"),
|
||||
("won't launch", "won't launch"),
|
||||
("wont launch", "won't launch"),
|
||||
("won't start", "won't launch"),
|
||||
("doesn't start", "won't launch"),
|
||||
("does not start", "won't launch"),
|
||||
("fails to launch", "won't launch"),
|
||||
("fails to start", "won't launch"),
|
||||
("not launching", "won't launch"),
|
||||
("black screen", "blank screen"),
|
||||
("blank screen", "blank screen"),
|
||||
("white screen", "blank screen"),
|
||||
("security", "security"),
|
||||
("vulnerability", "security"),
|
||||
("malware", "security"),
|
||||
("virus", "security"),
|
||||
("trojan", "security"),
|
||||
];
|
||||
|
||||
/// Phrases that mean something is broken, but not catastrophically.
|
||||
const BROKEN: &[&str] = &[
|
||||
"bug",
|
||||
"broken",
|
||||
"regression",
|
||||
"doesn't work",
|
||||
"does not work",
|
||||
"not working",
|
||||
"doesn't",
|
||||
"does not",
|
||||
"don't",
|
||||
"fails",
|
||||
"failed",
|
||||
"failure",
|
||||
"error",
|
||||
"can't",
|
||||
"cannot",
|
||||
"unable",
|
||||
"wrong",
|
||||
"lost",
|
||||
"install",
|
||||
"installer",
|
||||
"slow",
|
||||
"lag",
|
||||
"glitch",
|
||||
];
|
||||
|
||||
const FEATURE_LABELS: &[&str] = &[
|
||||
"enhancement",
|
||||
"feature",
|
||||
"feature request",
|
||||
"question",
|
||||
"documentation",
|
||||
"docs",
|
||||
];
|
||||
const EXCLUDE_LABELS: &[&str] = &["wontfix", "duplicate", "invalid", "not planned"];
|
||||
const FEATURE_PREFIXES: &[&str] = &[
|
||||
"[feature",
|
||||
"feature request",
|
||||
"feature:",
|
||||
"request:",
|
||||
"[request",
|
||||
"suggestion",
|
||||
"[suggestion",
|
||||
"idea:",
|
||||
"add ",
|
||||
"support ",
|
||||
"please add",
|
||||
"fix(docs)",
|
||||
"docs:",
|
||||
"docs(",
|
||||
"[docs",
|
||||
"question",
|
||||
"[question",
|
||||
];
|
||||
|
||||
/// Lowercase, normalize apostrophes, and turn punctuation into spaces, padded
|
||||
/// so `" phrase "` matches whole words only.
|
||||
fn normalize(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push(' ');
|
||||
let mut last_space = true;
|
||||
for c in s.chars().flat_map(char::to_lowercase) {
|
||||
let c = if c == '\u{2019}' || c == '`' { '\'' } else { c };
|
||||
if c.is_alphanumeric() || c == '\'' {
|
||||
out.push(c);
|
||||
last_space = false;
|
||||
} else if !last_space {
|
||||
out.push(' ');
|
||||
last_space = true;
|
||||
}
|
||||
}
|
||||
if !last_space {
|
||||
out.push(' ');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn has_phrase(text: &str, phrase: &str) -> bool {
|
||||
text.contains(&format!(" {phrase} "))
|
||||
}
|
||||
|
||||
/// `"v0.5.0"` → `"0.5"`: issues name the version loosely ("0.5", "0.50", "v0.5.0").
|
||||
fn major_minor(tag: &str) -> Option<String> {
|
||||
let t = tag.trim_start_matches(['v', 'V']);
|
||||
let mut parts = t.split(['.', '-']);
|
||||
let major = parts
|
||||
.next()
|
||||
.filter(|p| p.chars().all(|c| c.is_ascii_digit()))?;
|
||||
let minor = parts
|
||||
.next()
|
||||
.filter(|p| p.chars().all(|c| c.is_ascii_digit()))?;
|
||||
(!major.is_empty() && !minor.is_empty()).then(|| format!("{major}.{minor}"))
|
||||
}
|
||||
|
||||
fn mentions_version(title: &str, mm: &str) -> bool {
|
||||
let b = title.as_bytes();
|
||||
title.match_indices(mm).any(|(i, _)| {
|
||||
let before_ok = i == 0 || !b[i - 1].is_ascii_digit();
|
||||
let after = b.get(i + mm.len()).copied();
|
||||
// "0.5" matches "0.5", "0.5.0", "0.50" (a common typo for 0.5.0) but not "0.51".
|
||||
let after_ok = match after {
|
||||
None => true,
|
||||
Some(c) if !c.is_ascii_digit() => true,
|
||||
Some(b'0') => !b.get(i + mm.len() + 1).is_some_and(|c| c.is_ascii_digit()),
|
||||
_ => false,
|
||||
};
|
||||
before_ok && after_ok
|
||||
})
|
||||
}
|
||||
|
||||
/// `None` when the issue is closed-in-spirit (wontfix, duplicate, invalid).
|
||||
pub fn score_issue(
|
||||
issue: &IssueInput,
|
||||
now: DateTime<Utc>,
|
||||
latest_tag: Option<&str>,
|
||||
) -> Option<Scored> {
|
||||
let labels: Vec<String> = issue.labels.iter().map(|l| l.to_lowercase()).collect();
|
||||
if labels.iter().any(|l| EXCLUDE_LABELS.contains(&l.as_str())) {
|
||||
return None;
|
||||
}
|
||||
let title_lc = issue.title.to_lowercase();
|
||||
let text = normalize(&format!("{} {}", issue.title, issue.labels.join(" ")));
|
||||
|
||||
let mut score = 0.0;
|
||||
let mut reasons: Vec<String> = Vec::new();
|
||||
|
||||
let feature_like = labels.iter().any(|l| FEATURE_LABELS.contains(&l.as_str()))
|
||||
|| FEATURE_PREFIXES
|
||||
.iter()
|
||||
.any(|p| title_lc.trim_start().starts_with(p));
|
||||
|
||||
let mut critical_hits: Vec<&str> = Vec::new();
|
||||
for (phrase, why) in CRITICAL {
|
||||
if has_phrase(&text, phrase) && !critical_hits.contains(why) {
|
||||
critical_hits.push(why);
|
||||
}
|
||||
}
|
||||
let critical = !critical_hits.is_empty() && !feature_like;
|
||||
if !critical_hits.is_empty() {
|
||||
score += 40.0;
|
||||
reasons.push(critical_hits.join(", "));
|
||||
} else if BROKEN.iter().any(|p| has_phrase(&text, p)) {
|
||||
score += 15.0;
|
||||
reasons.push("broken".into());
|
||||
}
|
||||
|
||||
if labels.iter().any(|l| l == "bug" || l == "regression") {
|
||||
score += 15.0;
|
||||
reasons.push("bug label".into());
|
||||
}
|
||||
if labels
|
||||
.iter()
|
||||
.any(|l| l == "reliability" || l == "file-compat" || l == "performance")
|
||||
{
|
||||
score += 6.0;
|
||||
}
|
||||
if feature_like {
|
||||
score -= 25.0;
|
||||
reasons.push("request/docs".into());
|
||||
}
|
||||
|
||||
match issue.author_association.as_str() {
|
||||
"NONE" | "FIRST_TIMER" | "FIRST_TIME_CONTRIBUTOR" => {
|
||||
score += 10.0;
|
||||
reasons.push("user report".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let engagement = 3.0 * issue.reactions as f64 + 2.0 * issue.comments as f64;
|
||||
if engagement > 0.0 {
|
||||
score += (10.0 * (1.0 + engagement).ln()).min(40.0);
|
||||
if issue.reactions > 0 {
|
||||
reasons.push(format!("{} 👍", issue.reactions));
|
||||
}
|
||||
if issue.comments > 0 {
|
||||
reasons.push(format!("{} 💬", issue.comments));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mm) = latest_tag.and_then(major_minor) {
|
||||
if mentions_version(&title_lc, &mm) {
|
||||
score += 10.0;
|
||||
reasons.push("latest build".into());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(created) = issue.created_at {
|
||||
let age = now - created;
|
||||
if age < Duration::hours(24) {
|
||||
score += 8.0;
|
||||
reasons.push("new".into());
|
||||
} else if age < Duration::hours(72) {
|
||||
score += 4.0;
|
||||
}
|
||||
}
|
||||
if let Some(updated) = issue.updated_at {
|
||||
if now - updated > Duration::days(30) {
|
||||
score -= 5.0;
|
||||
}
|
||||
}
|
||||
|
||||
Some(Scored {
|
||||
score: score.max(0.0),
|
||||
critical,
|
||||
reasons,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the top `keep` issues (score > 0, best first, ties → newest), the
|
||||
/// repo urgency (sum of the top five scores) and the count of critical issues.
|
||||
pub fn rank_issues(
|
||||
issues: &[IssueInput],
|
||||
now: DateTime<Utc>,
|
||||
latest_tag: Option<&str>,
|
||||
keep: usize,
|
||||
) -> (Vec<UrgentIssue>, f64, u64) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut ranked: Vec<UrgentIssue> = issues
|
||||
.iter()
|
||||
.filter(|i| seen.insert(i.number))
|
||||
.filter_map(|i| {
|
||||
let s = score_issue(i, now, latest_tag)?;
|
||||
Some(UrgentIssue {
|
||||
number: i.number,
|
||||
title: i.title.clone(),
|
||||
url: i.url.clone(),
|
||||
created_at: i.created_at,
|
||||
updated_at: i.updated_at,
|
||||
comments: i.comments,
|
||||
reactions: i.reactions,
|
||||
labels: i.labels.clone(),
|
||||
score: s.score.round(),
|
||||
critical: s.critical,
|
||||
reasons: s.reasons,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let critical = ranked.iter().filter(|u| u.critical).count() as u64;
|
||||
ranked.retain(|u| u.score > 0.0);
|
||||
ranked.sort_by(|a, b| {
|
||||
b.score
|
||||
.total_cmp(&a.score)
|
||||
.then(b.created_at.cmp(&a.created_at))
|
||||
});
|
||||
let urgency = ranked.iter().take(5).map(|u| u.score).sum();
|
||||
ranked.truncate(keep);
|
||||
(ranked, urgency, critical)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn issue(title: &str, assoc: &str) -> IssueInput {
|
||||
IssueInput {
|
||||
number: title.len() as u64,
|
||||
title: title.into(),
|
||||
author_association: assoc.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn score(i: &IssueInput) -> f64 {
|
||||
score_issue(i, Utc::now(), Some("v0.5.0")).unwrap().score
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_is_word_bounded() {
|
||||
let t = normalize("App CRASHES—on start’s (Windows)");
|
||||
assert!(has_phrase(&t, "crashes"));
|
||||
assert!(has_phrase(&t, "start's"));
|
||||
assert!(!has_phrase(&normalize("hangar"), "hang"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_report_beats_automated_docs_fix() {
|
||||
let crash = issue(
|
||||
"[Windows 11 0.50] Merge Down freezes on large canvas",
|
||||
"NONE",
|
||||
);
|
||||
let docs = issue(
|
||||
"fix(docs): layer-type errors say \"a Adjustment layer\"",
|
||||
"CONTRIBUTOR",
|
||||
);
|
||||
let s = score_issue(&crash, Utc::now(), Some("v0.5.0")).unwrap();
|
||||
assert!(s.critical);
|
||||
assert!(
|
||||
s.reasons.iter().any(|r| r == "latest build"),
|
||||
"{:?}",
|
||||
s.reasons
|
||||
);
|
||||
assert!(s.reasons.iter().any(|r| r == "user report"));
|
||||
assert!(score(&crash) > score(&docs) + 40.0);
|
||||
assert!(!score_issue(&docs, Utc::now(), None).unwrap().critical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_requests_sink_and_wontfix_vanishes() {
|
||||
let bug = issue("delete button doesn't delete a layer on windows", "NONE");
|
||||
let feat = issue("Request: Gradient support for shape fill", "NONE");
|
||||
assert!(score(&bug) > score(&feat));
|
||||
let mut dup = issue("App crashes", "NONE");
|
||||
dup.labels = vec!["duplicate".into()];
|
||||
assert!(score_issue(&dup, Utc::now(), None).is_none());
|
||||
let feat_crash = issue("Add crash reporting", "NONE");
|
||||
assert!(!score_issue(&feat_crash, Utc::now(), None).unwrap().critical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engagement_is_capped_and_counted() {
|
||||
let quiet = issue("Bug: layer locked but editing still works", "NONE");
|
||||
let mut loud = quiet.clone();
|
||||
loud.reactions = 15;
|
||||
loud.comments = 13;
|
||||
let mut louder = quiet.clone();
|
||||
louder.reactions = 5000;
|
||||
assert!(score(&loud) > score(&quiet) + 30.0);
|
||||
assert!(score(&louder) - score(&quiet) <= 40.0 + 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_matching() {
|
||||
assert_eq!(major_minor("v0.5.0").as_deref(), Some("0.5"));
|
||||
assert_eq!(major_minor("v0.1.0-rc.3").as_deref(), Some("0.1"));
|
||||
assert_eq!(major_minor("nightly"), None);
|
||||
assert!(mentions_version("[windows 0.5.0] x", "0.5"));
|
||||
assert!(mentions_version("[windows 11 0.50] x", "0.5"));
|
||||
assert!(!mentions_version("v10.5 x", "0.5"));
|
||||
assert!(!mentions_version("0.51 x", "0.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranking_dedupes_sorts_and_sums_top_five() {
|
||||
let now = Utc::now();
|
||||
let mut v: Vec<IssueInput> = (1..=8)
|
||||
.map(|n| IssueInput {
|
||||
number: n,
|
||||
title: if n % 2 == 0 {
|
||||
"App crashes"
|
||||
} else {
|
||||
"Add gradients"
|
||||
}
|
||||
.into(),
|
||||
author_association: "NONE".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
v.push(v[1].clone());
|
||||
let (ranked, urgency, critical) = rank_issues(&v, now, None, 3);
|
||||
assert_eq!(critical, 4);
|
||||
assert_eq!(ranked.len(), 3);
|
||||
assert!(ranked.iter().all(|u| u.critical));
|
||||
assert!(ranked.windows(2).all(|w| w[0].score >= w[1].score));
|
||||
assert_eq!(
|
||||
urgency,
|
||||
4.0 * 50.0,
|
||||
"4 crashes at 50 (40 + user 10), features score 0"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "craft-github"
|
||||
description = "GitHub GraphQL/REST fetcher that fills craft-core's RepoStats"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-core = { path = "../craft-core" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
reqwest.workspace = true
|
||||
thiserror.workspace = true
|
||||
@@ -0,0 +1,77 @@
|
||||
//! `craft-fetch [owner/name …] [--snapshot PATH]` — fetch stats once and print
|
||||
//! a table (debugging aid). `--snapshot` also writes a Snapshot JSON the UI
|
||||
//! can load in a plain browser (`ui/dev-snapshot.json`).
|
||||
|
||||
use chrono::Utc;
|
||||
use craft_core::{Config, RepoEntry, Snapshot};
|
||||
use craft_github::{resolve_token, GitHub};
|
||||
|
||||
fn main() {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let snapshot_path = args.iter().position(|a| a == "--snapshot").map(|i| {
|
||||
args.remove(i);
|
||||
args.remove(i)
|
||||
});
|
||||
let cfg = Config::load_from(&Config::path().unwrap()).unwrap_or_default();
|
||||
let repos = if args.is_empty() {
|
||||
cfg.repos.clone()
|
||||
} else {
|
||||
args
|
||||
};
|
||||
let gh = match resolve_token(&cfg.github_token).and_then(GitHub::new) {
|
||||
Ok(gh) => gh,
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let now = Utc::now();
|
||||
let mut snap = Snapshot {
|
||||
poll_secs: cfg.poll_secs(),
|
||||
stale_after_secs: cfg.stale_secs(),
|
||||
..Default::default()
|
||||
};
|
||||
println!(
|
||||
"{:<12} {:>5} {:>6} {:>4} {:>4} {:>4} {:>7} {:>5} {:>7} {:>7} {:<12} {:>7} {:>8} {:>6} {:>4}",
|
||||
"repo", "oPR", "oIss", "c4h", "p4h", "i4h", "commits", "ctrb", "issues", "prs", "build", "bld dl",
|
||||
"total dl", "urg", "crit"
|
||||
);
|
||||
for repo in &repos {
|
||||
let mut entry = RepoEntry::new(repo);
|
||||
entry.last_attempt_at = Some(now);
|
||||
match gh.fetch_repo(repo, now) {
|
||||
Ok((s, budget)) => {
|
||||
println!(
|
||||
"{:<12} {:>5} {:>6} {:>4} {:>4} {:>4} {:>7} {:>5} {:>7} {:>7} {:<12} {:>7} {:>8} {:>6} {:>4}",
|
||||
entry.name(), s.open_prs, s.open_issues, s.commits_4h, s.prs_4h, s.issues_4h,
|
||||
s.commits_total, s.contributors, s.issues_total, s.prs_total,
|
||||
s.latest_build.clone().unwrap_or_default(), s.latest_build_downloads,
|
||||
s.downloads_total, s.urgency, s.critical_issues
|
||||
);
|
||||
for u in s.urgent.iter().take(3) {
|
||||
println!(
|
||||
" {:>4} #{:<5} {} [{}]",
|
||||
u.score,
|
||||
u.number,
|
||||
u.title,
|
||||
u.reasons.join(", ")
|
||||
);
|
||||
}
|
||||
snap.rate_limit_remaining = budget.graphql_remaining;
|
||||
entry.stats = Some(s);
|
||||
entry.fetched_at = Some(now);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{:<12} ERROR {e}", entry.name());
|
||||
entry.error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
snap.repos.push(entry);
|
||||
}
|
||||
snap.last_full_refresh_at = Some(Utc::now());
|
||||
eprintln!("graphql budget remaining: {:?}", snap.rate_limit_remaining);
|
||||
if let Some(p) = snapshot_path {
|
||||
std::fs::write(&p, serde_json::to_string_pretty(&snap).unwrap()).unwrap();
|
||||
eprintln!("wrote {p}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
//! Fetches one repository's [`RepoStats`] from GitHub.
|
||||
//!
|
||||
//! Per repo: one GraphQL query for counts, dates, the 4-hour windows and the
|
||||
//! open issues to rank; one (usually) GraphQL query for releases + asset
|
||||
//! download counts; one REST call for the contributor count (GraphQL has no
|
||||
//! such field — we ask for one per page and read the last page number from
|
||||
//! the `Link` header). Blocking I/O: callers run this on worker threads.
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use craft_core::{rank_issues, summarize_releases, Asset, IssueInput, Release, RepoStats};
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, USER_AGENT};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const API: &str = "https://api.github.com";
|
||||
/// The "last N hours" window the UI reports.
|
||||
pub const RECENT_HOURS: i64 = 4;
|
||||
/// How many ranked issues each repo keeps for the Urgent view.
|
||||
const KEEP_URGENT: usize = 20;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("no GitHub token: set github_token in ~/.craft_status_config.toml, export GITHUB_TOKEN, or run `gh auth login`")]
|
||||
NoToken,
|
||||
#[error("GitHub rejected the token (401); check github_token / `gh auth status`")]
|
||||
Unauthorized,
|
||||
#[error("rate limited by GitHub until {0}")]
|
||||
RateLimited(String),
|
||||
#[error("GitHub HTTP {0}: {1}")]
|
||||
Http(u16, String),
|
||||
#[error("network: {0}")]
|
||||
Network(#[from] reqwest::Error),
|
||||
#[error("GitHub: {0}")]
|
||||
GraphQl(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Token from config, then `GITHUB_TOKEN` / `GH_TOKEN`, then the GitHub CLI.
|
||||
/// Apps launched from Finder don't inherit the shell PATH, so the usual
|
||||
/// install locations of `gh` are tried explicitly.
|
||||
pub fn resolve_token(configured: &str) -> Result<String> {
|
||||
let t = configured.trim();
|
||||
if !t.is_empty() {
|
||||
return Ok(t.to_string());
|
||||
}
|
||||
for var in ["GITHUB_TOKEN", "GH_TOKEN"] {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
if !v.trim().is_empty() {
|
||||
return Ok(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for gh in [
|
||||
"gh",
|
||||
"/opt/homebrew/bin/gh",
|
||||
"/usr/local/bin/gh",
|
||||
"/usr/bin/gh",
|
||||
] {
|
||||
if let Ok(out) = Command::new(gh).args(["auth", "token"]).output() {
|
||||
let tok = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if out.status.success() && !tok.is_empty() {
|
||||
return Ok(tok);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(Error::NoToken)
|
||||
}
|
||||
|
||||
pub struct GitHub {
|
||||
http: Client,
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// Rate-limit bookkeeping returned alongside each repo.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Budget {
|
||||
pub graphql_remaining: Option<u64>,
|
||||
}
|
||||
|
||||
impl GitHub {
|
||||
pub fn new(token: String) -> Result<GitHub> {
|
||||
let http = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.build()?;
|
||||
Ok(GitHub { http, token })
|
||||
}
|
||||
|
||||
fn headers(&self) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(USER_AGENT, "craft-status".parse().unwrap());
|
||||
h.insert(ACCEPT, "application/vnd.github+json".parse().unwrap());
|
||||
if let Ok(v) = format!("Bearer {}", self.token).parse() {
|
||||
h.insert(AUTHORIZATION, v);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn check(resp: reqwest::blocking::Response) -> Result<reqwest::blocking::Response> {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
return Ok(resp);
|
||||
}
|
||||
if status.as_u16() == 401 {
|
||||
return Err(Error::Unauthorized);
|
||||
}
|
||||
let remaining = resp
|
||||
.headers()
|
||||
.get("x-ratelimit-remaining")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let reset = resp
|
||||
.headers()
|
||||
.get("x-ratelimit-reset")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.and_then(|t| DateTime::from_timestamp(t, 0));
|
||||
let body = resp.text().unwrap_or_default();
|
||||
if (status.as_u16() == 403 || status.as_u16() == 429)
|
||||
&& (remaining.as_deref() == Some("0") || body.contains("rate limit"))
|
||||
{
|
||||
return Err(Error::RateLimited(
|
||||
reset.map_or("later".into(), |t| t.to_rfc3339()),
|
||||
));
|
||||
}
|
||||
let msg = serde_json::from_str::<Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v["message"].as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| body.chars().take(200).collect());
|
||||
Err(Error::Http(status.as_u16(), msg))
|
||||
}
|
||||
|
||||
fn graphql(&self, query: &str, variables: Value) -> Result<Value> {
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{API}/graphql"))
|
||||
.headers(self.headers())
|
||||
.json(&json!({ "query": query, "variables": variables }))
|
||||
.send()?;
|
||||
let v: Value = Self::check(resp)?.json()?;
|
||||
if let Some(errs) = v["errors"].as_array().filter(|e| !e.is_empty()) {
|
||||
let msg = errs
|
||||
.iter()
|
||||
.filter_map(|e| e["message"].as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
if errs.iter().any(|e| e["type"] == "RATE_LIMITED") {
|
||||
return Err(Error::RateLimited("the top of the hour".into()));
|
||||
}
|
||||
if v["data"]["repository"].is_null() {
|
||||
return Err(Error::GraphQl(msg));
|
||||
}
|
||||
}
|
||||
Ok(v["data"].clone())
|
||||
}
|
||||
|
||||
/// Everything the UI shows for `owner/name`, measured at `now`.
|
||||
pub fn fetch_repo(&self, repo: &str, now: DateTime<Utc>) -> Result<(RepoStats, Budget)> {
|
||||
let (owner, name) = repo
|
||||
.split_once('/')
|
||||
.ok_or_else(|| Error::GraphQl(format!("bad repo {repo:?}")))?;
|
||||
let since = now - chrono::Duration::hours(RECENT_HOURS);
|
||||
let since_s = since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
let main = self.graphql(
|
||||
MAIN_QUERY,
|
||||
json!({
|
||||
"owner": owner,
|
||||
"name": name,
|
||||
"since": since_s,
|
||||
"prs4h": format!("repo:{repo} is:pr created:>={since_s}"),
|
||||
"issues4h": format!("repo:{repo} is:issue created:>={since_s}"),
|
||||
}),
|
||||
)?;
|
||||
let releases = self.releases(owner, name)?;
|
||||
let contributors = self.contributors(repo)?;
|
||||
let budget = Budget {
|
||||
graphql_remaining: main["rateLimit"]["remaining"].as_u64(),
|
||||
};
|
||||
Ok((build_stats(&main, &releases, contributors, now), budget))
|
||||
}
|
||||
|
||||
fn releases(&self, owner: &str, name: &str) -> Result<Vec<Release>> {
|
||||
let mut out = Vec::new();
|
||||
let mut after: Option<String> = None;
|
||||
// 20 pages × 50 = 1000 releases is plenty; the cap guards against loops.
|
||||
for _ in 0..20 {
|
||||
let v = self.graphql(
|
||||
RELEASES_QUERY,
|
||||
json!({ "owner": owner, "name": name, "after": after }),
|
||||
)?;
|
||||
let conn = &v["repository"]["releases"];
|
||||
out.extend(parse_releases(conn));
|
||||
if conn["pageInfo"]["hasNextPage"].as_bool() != Some(true) {
|
||||
break;
|
||||
}
|
||||
after = conn["pageInfo"]["endCursor"].as_str().map(str::to_string);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn contributors(&self, repo: &str) -> Result<u64> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{API}/repos/{repo}/contributors?per_page=1"))
|
||||
.headers(self.headers())
|
||||
.send()?;
|
||||
let resp = Self::check(resp)?;
|
||||
// 204: empty repository.
|
||||
if resp.status().as_u16() == 204 {
|
||||
return Ok(0);
|
||||
}
|
||||
let link = resp
|
||||
.headers()
|
||||
.get("link")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string);
|
||||
if let Some(n) = link.as_deref().and_then(last_page) {
|
||||
return Ok(n);
|
||||
}
|
||||
let body: Value = resp.json()?;
|
||||
Ok(body.as_array().map_or(0, |a| a.len() as u64))
|
||||
}
|
||||
}
|
||||
|
||||
/// `<…&page=37>; rel="last"` → 37.
|
||||
pub fn last_page(link: &str) -> Option<u64> {
|
||||
link.split(',').find_map(|part| {
|
||||
let (url, rel) = part.split_once(';')?;
|
||||
if !rel.contains("rel=\"last\"") {
|
||||
return None;
|
||||
}
|
||||
let url = url.trim().trim_start_matches('<').trim_end_matches('>');
|
||||
let query = url.split_once('?')?.1;
|
||||
query
|
||||
.split('&')
|
||||
.find_map(|kv| kv.strip_prefix("page="))
|
||||
.and_then(|n| n.parse().ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn time(v: &Value) -> Option<DateTime<Utc>> {
|
||||
v.as_str()?.parse().ok()
|
||||
}
|
||||
|
||||
fn count(v: &Value) -> u64 {
|
||||
v["totalCount"].as_u64().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn first_time(conn: &Value, field: &str) -> Option<DateTime<Utc>> {
|
||||
time(&conn["nodes"][0][field])
|
||||
}
|
||||
|
||||
pub fn parse_releases(conn: &Value) -> Vec<Release> {
|
||||
conn["nodes"]
|
||||
.as_array()
|
||||
.map(|nodes| {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|r| Release {
|
||||
tag: r["tagName"].as_str().unwrap_or_default().to_string(),
|
||||
draft: r["isDraft"].as_bool().unwrap_or(false),
|
||||
prerelease: r["isPrerelease"].as_bool().unwrap_or(false),
|
||||
published_at: time(&r["publishedAt"]),
|
||||
created_at: time(&r["createdAt"]),
|
||||
assets: r["releaseAssets"]["nodes"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|a| Asset {
|
||||
name: a["name"].as_str().unwrap_or_default().to_string(),
|
||||
download_count: a["downloadCount"].as_u64().unwrap_or(0),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn parse_issues(conn: &Value) -> Vec<IssueInput> {
|
||||
conn["nodes"]
|
||||
.as_array()
|
||||
.map(|nodes| {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|i| IssueInput {
|
||||
number: i["number"].as_u64().unwrap_or(0),
|
||||
title: i["title"].as_str().unwrap_or_default().to_string(),
|
||||
url: i["url"].as_str().unwrap_or_default().to_string(),
|
||||
created_at: time(&i["createdAt"]),
|
||||
updated_at: time(&i["updatedAt"]),
|
||||
comments: count(&i["comments"]),
|
||||
reactions: count(&i["reactions"]),
|
||||
labels: i["labels"]["nodes"]
|
||||
.as_array()
|
||||
.map(|l| {
|
||||
l.iter()
|
||||
.filter_map(|l| l["name"].as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
author_association: i["authorAssociation"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Pure: turn the GraphQL payloads into the model (unit-tested below).
|
||||
pub fn build_stats(
|
||||
main: &Value,
|
||||
releases: &[Release],
|
||||
contributors: u64,
|
||||
now: DateTime<Utc>,
|
||||
) -> RepoStats {
|
||||
let r = &main["repository"];
|
||||
let head = &r["defaultBranchRef"]["target"];
|
||||
let rel = summarize_releases(releases);
|
||||
let mut issues = parse_issues(&r["recentOpen"]);
|
||||
issues.extend(parse_issues(&r["hotOpen"]));
|
||||
let (urgent, urgency, critical) =
|
||||
rank_issues(&issues, now, rel.latest_tag.as_deref(), KEEP_URGENT);
|
||||
RepoStats {
|
||||
open_prs: count(&r["openPrs"]),
|
||||
open_issues: count(&r["openIssues"]),
|
||||
last_commit_at: first_time(&head["all"], "committedDate"),
|
||||
last_issue_at: first_time(&r["newestIssue"], "createdAt"),
|
||||
oldest_open_pr_at: first_time(&r["oldestOpenPr"], "createdAt"),
|
||||
newest_pr_at: first_time(&r["newestPr"], "createdAt"),
|
||||
commits_4h: count(&head["recent"]),
|
||||
prs_4h: main["prs4h"]["issueCount"].as_u64().unwrap_or(0),
|
||||
issues_4h: main["issues4h"]["issueCount"].as_u64().unwrap_or(0),
|
||||
commits_total: count(&head["all"]),
|
||||
contributors,
|
||||
issues_total: count(&r["allIssues"]),
|
||||
prs_total: count(&r["allPrs"]),
|
||||
latest_build: rel.latest_tag,
|
||||
latest_build_at: rel.latest_at,
|
||||
latest_build_prerelease: rel.latest_prerelease,
|
||||
latest_build_downloads: rel.latest_downloads,
|
||||
downloads_total: rel.total_downloads,
|
||||
urgency,
|
||||
critical_issues: critical,
|
||||
urgent,
|
||||
}
|
||||
}
|
||||
|
||||
const MAIN_QUERY: &str = r#"
|
||||
query($owner: String!, $name: String!, $since: GitTimestamp!, $prs4h: String!, $issues4h: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
openPrs: pullRequests(states: OPEN) { totalCount }
|
||||
allPrs: pullRequests { totalCount }
|
||||
oldestOpenPr: pullRequests(states: OPEN, first: 1, orderBy: {field: CREATED_AT, direction: ASC}) { nodes { createdAt } }
|
||||
newestPr: pullRequests(first: 1, orderBy: {field: CREATED_AT, direction: DESC}) { nodes { createdAt } }
|
||||
openIssues: issues(states: OPEN) { totalCount }
|
||||
allIssues: issues { totalCount }
|
||||
newestIssue: issues(first: 1, orderBy: {field: CREATED_AT, direction: DESC}) { nodes { createdAt } }
|
||||
recentOpen: issues(states: OPEN, first: 100, orderBy: {field: CREATED_AT, direction: DESC}) { nodes { ...I } }
|
||||
hotOpen: issues(states: OPEN, first: 30, orderBy: {field: COMMENTS, direction: DESC}) { nodes { ...I } }
|
||||
defaultBranchRef {
|
||||
target {
|
||||
... on Commit {
|
||||
all: history(first: 1) { totalCount nodes { committedDate } }
|
||||
recent: history(since: $since) { totalCount }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prs4h: search(query: $prs4h, type: ISSUE) { issueCount }
|
||||
issues4h: search(query: $issues4h, type: ISSUE) { issueCount }
|
||||
rateLimit { remaining resetAt }
|
||||
}
|
||||
fragment I on Issue {
|
||||
number title url createdAt updatedAt authorAssociation
|
||||
comments { totalCount }
|
||||
reactions { totalCount }
|
||||
labels(first: 10) { nodes { name } }
|
||||
}
|
||||
"#;
|
||||
|
||||
const RELEASES_QUERY: &str = r#"
|
||||
query($owner: String!, $name: String!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
releases(first: 50, after: $after, orderBy: {field: CREATED_AT, direction: DESC}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
tagName isDraft isPrerelease publishedAt createdAt
|
||||
releaseAssets(first: 100) { nodes { name downloadCount } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn link_header_last_page() {
|
||||
let l = r#"<https://api.github.com/repositories/1/contributors?per_page=1&page=2>; rel="next", <https://api.github.com/repositories/1/contributors?per_page=1&page=37>; rel="last""#;
|
||||
assert_eq!(last_page(l), Some(37));
|
||||
assert_eq!(last_page(r#"<https://x?page=2>; rel="next""#), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_stats_from_payloads() {
|
||||
let main = json!({
|
||||
"repository": {
|
||||
"openPrs": {"totalCount": 3}, "allPrs": {"totalCount": 40},
|
||||
"oldestOpenPr": {"nodes": [{"createdAt": "2026-09-01T00:00:00Z"}]},
|
||||
"newestPr": {"nodes": [{"createdAt": "2026-10-08T01:00:00Z"}]},
|
||||
"openIssues": {"totalCount": 7}, "allIssues": {"totalCount": 90},
|
||||
"newestIssue": {"nodes": [{"createdAt": "2026-10-08T02:00:00Z"}]},
|
||||
"recentOpen": {"nodes": [
|
||||
{"number": 5, "title": "App crashes on launch", "url": "u5", "createdAt": "2026-10-08T02:00:00Z",
|
||||
"updatedAt": "2026-10-08T02:00:00Z", "authorAssociation": "NONE",
|
||||
"comments": {"totalCount": 2}, "reactions": {"totalCount": 1}, "labels": {"nodes": [{"name": "bug"}]}}
|
||||
]},
|
||||
"hotOpen": {"nodes": []},
|
||||
"defaultBranchRef": {"target": {
|
||||
"all": {"totalCount": 1234, "nodes": [{"committedDate": "2026-10-08T03:00:00Z"}]},
|
||||
"recent": {"totalCount": 9}}}
|
||||
},
|
||||
"prs4h": {"issueCount": 2}, "issues4h": {"issueCount": 4}
|
||||
});
|
||||
let rel = parse_releases(&json!({"nodes": [
|
||||
{"tagName": "v1.1.0", "isDraft": true, "isPrerelease": false, "publishedAt": null, "createdAt": "2026-10-08T00:00:00Z",
|
||||
"releaseAssets": {"nodes": [{"name": "a.dmg", "downloadCount": 0}]}},
|
||||
{"tagName": "v1.0.0", "isDraft": false, "isPrerelease": false, "publishedAt": "2026-10-01T00:00:00Z", "createdAt": "2026-10-01T00:00:00Z",
|
||||
"releaseAssets": {"nodes": [{"name": "a.dmg", "downloadCount": 10}, {"name": "SHA256SUMS.txt", "downloadCount": 4}]}}
|
||||
]}));
|
||||
let now: DateTime<Utc> = "2026-10-08T04:00:00Z".parse().unwrap();
|
||||
let s = build_stats(&main, &rel, 12, now);
|
||||
assert_eq!(
|
||||
(s.open_prs, s.prs_total, s.open_issues, s.issues_total),
|
||||
(3, 40, 7, 90)
|
||||
);
|
||||
assert_eq!(
|
||||
(s.commits_total, s.commits_4h, s.prs_4h, s.issues_4h),
|
||||
(1234, 9, 2, 4)
|
||||
);
|
||||
assert_eq!(s.contributors, 12);
|
||||
assert_eq!(s.latest_build.as_deref(), Some("v1.0.0"));
|
||||
assert_eq!((s.latest_build_downloads, s.downloads_total), (10, 10));
|
||||
assert_eq!(
|
||||
s.last_commit_at,
|
||||
Some("2026-10-08T03:00:00Z".parse().unwrap())
|
||||
);
|
||||
assert_eq!(s.critical_issues, 1);
|
||||
assert_eq!(s.urgent[0].number, 5);
|
||||
assert!(s.urgency > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_repository_is_all_zero() {
|
||||
let s = build_stats(
|
||||
&json!({"repository": {"defaultBranchRef": null}}),
|
||||
&[],
|
||||
0,
|
||||
Utc::now(),
|
||||
);
|
||||
assert_eq!(s, RepoStats::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "craft-status-app"
|
||||
description = "Tray dashboard for the storytold *craft repositories"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "craft_status_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
craft-core = { path = "../craft-core" }
|
||||
craft-github = { path = "../craft-github" }
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png", "macos-private-api"] }
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capabilities for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:allow-start-resize-dragging"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1b2a4a"/>
|
||||
<stop offset="1" stop-color="#0b1220"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="64" y="64" width="896" height="896" rx="200" fill="url(#bg)"/>
|
||||
<rect x="220" y="560" width="120" height="200" rx="24" fill="#5b9dff"/>
|
||||
<rect x="380" y="430" width="120" height="330" rx="24" fill="#7cdbad"/>
|
||||
<rect x="540" y="330" width="120" height="430" rx="24" fill="#f4bc70"/>
|
||||
<rect x="700" y="240" width="120" height="520" rx="24" fill="#ff8f8f"/>
|
||||
<circle cx="300" cy="330" r="70" fill="none" stroke="#dbe4f3" stroke-width="36"/>
|
||||
<path d="M300 285 v45 l30 22" fill="none" stroke="#dbe4f3" stroke-width="30" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 820 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect x="6" y="38" width="10" height="20" rx="2" fill="#000"/>
|
||||
<rect x="20" y="28" width="10" height="30" rx="2" fill="#000"/>
|
||||
<rect x="34" y="18" width="10" height="40" rx="2" fill="#000"/>
|
||||
<rect x="48" y="6" width="10" height="52" rx="2" fill="#000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 867 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 945 B |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 277 B |
@@ -0,0 +1,725 @@
|
||||
//! Tauri shell around `craft-core` + `craft-github`: the poll loop, the
|
||||
//! on-disk cache, tray icon, global shortcut and window management.
|
||||
//!
|
||||
//! One background thread owns polling. Every `poll_minutes` (and on demand)
|
||||
//! it fetches all repositories with a few worker threads, publishing the
|
||||
//! snapshot to the UI after each repository lands and to the cache at the end.
|
||||
//! Failed repositories keep their previous numbers; staleness is derived from
|
||||
//! each repository's `fetched_at`, so old data is always visibly old.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{mpsc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use craft_core::{Cache, Config, Snapshot, WindowState};
|
||||
use craft_github::{resolve_token, Error as GhError, GitHub};
|
||||
use serde::Serialize;
|
||||
use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, Emitter, LogicalSize, Manager, State, WebviewWindow, Wry};
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
|
||||
|
||||
const MAIN: &str = "main";
|
||||
const TRAY: &str = "main";
|
||||
const EV_SNAPSHOT: &str = "craft:snapshot";
|
||||
const EV_FOCUS: &str = "craft:focus";
|
||||
const EV_META: &str = "craft:meta";
|
||||
/// Repositories fetched concurrently. Small on purpose: GitHub frowns on
|
||||
/// bursts (secondary rate limits) and 12 repos finish in well under 15 s.
|
||||
const WORKERS: usize = 4;
|
||||
/// How often the poll thread wakes to check the clock, the config file and
|
||||
/// the tray tooltip. Wall-clock based, so a sleeping laptop polls on wake.
|
||||
const TICK: Duration = Duration::from_secs(15);
|
||||
/// Retry sooner than the poll interval when everything failed (offline, asleep…).
|
||||
const RETRY_AFTER_FAILURE: i64 = 60;
|
||||
/// Ignore manual refreshes this soon after the previous poll finished.
|
||||
const MANUAL_DEBOUNCE: i64 = 15;
|
||||
/// Below this many GraphQL points left, wait for the hourly reset.
|
||||
const BUDGET_FLOOR: u64 = 150;
|
||||
|
||||
pub struct AppState {
|
||||
config: Mutex<Config>,
|
||||
config_mtime: Mutex<Option<SystemTime>>,
|
||||
snapshot: Mutex<Snapshot>,
|
||||
cache: Option<Cache>,
|
||||
refresh_tx: Mutex<Option<mpsc::Sender<()>>>,
|
||||
window_state: Mutex<Option<WindowState>>,
|
||||
window_dirty: AtomicBool,
|
||||
}
|
||||
|
||||
struct TrayItems {
|
||||
always_on_top: CheckMenuItem<Wry>,
|
||||
}
|
||||
|
||||
/// Static-ish facts the UI needs besides the snapshot.
|
||||
#[derive(Serialize, Clone)]
|
||||
struct Meta {
|
||||
config_path: String,
|
||||
cache_path: String,
|
||||
opacity: f64,
|
||||
always_on_top: bool,
|
||||
platform: &'static str,
|
||||
version: &'static str,
|
||||
shortcut: String,
|
||||
}
|
||||
|
||||
fn meta(app: &AppHandle) -> Meta {
|
||||
let state = app.state::<AppState>();
|
||||
let cfg = state.config.lock().unwrap();
|
||||
Meta {
|
||||
config_path: Config::path()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_default(),
|
||||
cache_path: state
|
||||
.cache
|
||||
.as_ref()
|
||||
.map(|c| c.path().display().to_string())
|
||||
.unwrap_or_default(),
|
||||
opacity: cfg.window.opacity,
|
||||
always_on_top: cfg.window.always_on_top,
|
||||
platform: std::env::consts::OS,
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
shortcut: cfg.shortcuts.toggle_window.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── commands ─────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
fn get_snapshot(state: State<AppState>) -> Snapshot {
|
||||
state.snapshot.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_meta(app: AppHandle) -> Meta {
|
||||
meta(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn refresh_now(state: State<AppState>) {
|
||||
request_refresh(&state);
|
||||
}
|
||||
|
||||
/// Only GitHub links leave the app, and they go to the default browser.
|
||||
#[tauri::command]
|
||||
fn open_url(url: String) -> Result<(), String> {
|
||||
if !url.starts_with("https://github.com/") {
|
||||
return Err(format!("refusing to open {url:?}"));
|
||||
}
|
||||
open_external(&url)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_config() -> Result<(), String> {
|
||||
let path = Config::path().map_err(|e| e.to_string())?;
|
||||
open_external(&path.display().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_always_on_top(app: AppHandle, on: bool) {
|
||||
set_pinned(&app, on);
|
||||
}
|
||||
|
||||
/// Called once the UI has painted, so the window never flashes empty.
|
||||
#[tauri::command]
|
||||
fn window_ready(app: AppHandle, state: State<AppState>) {
|
||||
let start_hidden = state.config.lock().unwrap().window.start_hidden;
|
||||
if !start_hidden {
|
||||
if let Some(w) = app.get_webview_window(MAIN) {
|
||||
show_window(&w);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn hide_window(app: AppHandle) {
|
||||
if let Some(w) = app.get_webview_window(MAIN) {
|
||||
let _ = w.hide();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn log(msg: String) {
|
||||
eprintln!("[ui] {msg}");
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn quit(app: AppHandle) {
|
||||
flush_window_state(&app);
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
// ───────────────────────────── polling ─────────────────────────────
|
||||
|
||||
fn request_refresh(state: &AppState) {
|
||||
if let Some(tx) = state.refresh_tx.lock().unwrap().as_ref() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_snapshot(app: &AppHandle) {
|
||||
let snap = app.state::<AppState>().snapshot.lock().unwrap().clone();
|
||||
let _ = app.emit(EV_SNAPSHOT, &snap);
|
||||
}
|
||||
|
||||
fn config_mtime() -> Option<SystemTime> {
|
||||
Config::path()
|
||||
.ok()
|
||||
.and_then(|p| std::fs::metadata(p).ok())
|
||||
.and_then(|m| m.modified().ok())
|
||||
}
|
||||
|
||||
/// Re-read the config if it changed on disk. A broken file keeps the old
|
||||
/// config and surfaces the parse error in the window.
|
||||
fn reload_config_if_changed(app: &AppHandle) {
|
||||
let state = app.state::<AppState>();
|
||||
let mtime = config_mtime();
|
||||
if *state.config_mtime.lock().unwrap() == mtime {
|
||||
return;
|
||||
}
|
||||
*state.config_mtime.lock().unwrap() = mtime;
|
||||
match Config::load_or_create() {
|
||||
Ok(cfg) => {
|
||||
{
|
||||
let mut snap = state.snapshot.lock().unwrap();
|
||||
snap.sync_repos(&cfg.repos);
|
||||
snap.poll_secs = cfg.poll_secs();
|
||||
snap.stale_after_secs = cfg.stale_secs();
|
||||
if snap
|
||||
.error
|
||||
.as_deref()
|
||||
.is_some_and(|e| e.starts_with("Config"))
|
||||
{
|
||||
snap.error = None;
|
||||
}
|
||||
}
|
||||
*state.config.lock().unwrap() = cfg.clone();
|
||||
apply_window_config(app, &cfg);
|
||||
let _ = app.emit(EV_META, meta(app));
|
||||
}
|
||||
Err(e) => {
|
||||
state.snapshot.lock().unwrap().error = Some(format!("Config error: {e}"));
|
||||
}
|
||||
}
|
||||
emit_snapshot(app);
|
||||
}
|
||||
|
||||
/// Fetch every configured repository once. Returns seconds until the next poll.
|
||||
fn run_cycle(app: &AppHandle) -> i64 {
|
||||
let state = app.state::<AppState>();
|
||||
let cfg = state.config.lock().unwrap().clone();
|
||||
{
|
||||
let mut snap = state.snapshot.lock().unwrap();
|
||||
snap.sync_repos(&cfg.repos);
|
||||
snap.refreshing = true;
|
||||
// Measured afresh each cycle (the minimum any repo reported).
|
||||
snap.rate_limit_remaining = None;
|
||||
snap.poll_secs = cfg.poll_secs();
|
||||
snap.stale_after_secs = cfg.stale_secs();
|
||||
}
|
||||
emit_snapshot(app);
|
||||
|
||||
let finish = |error: Option<String>, next_in: i64, all_ok: bool| {
|
||||
{
|
||||
let mut snap = state.snapshot.lock().unwrap();
|
||||
snap.refreshing = false;
|
||||
snap.error = error;
|
||||
if all_ok {
|
||||
snap.last_full_refresh_at = Some(Utc::now());
|
||||
}
|
||||
snap.next_refresh_at = Some(Utc::now() + chrono::Duration::seconds(next_in));
|
||||
if let Some(cache) = &state.cache {
|
||||
if let Err(e) = cache.save(&snap) {
|
||||
eprintln!("could not save cache: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
emit_snapshot(app);
|
||||
update_tray(app);
|
||||
next_in
|
||||
};
|
||||
|
||||
let gh = match resolve_token(&cfg.github_token).and_then(GitHub::new) {
|
||||
Ok(gh) => gh,
|
||||
Err(e) => return finish(Some(e.to_string()), RETRY_AFTER_FAILURE * 5, false),
|
||||
};
|
||||
|
||||
let queue = Mutex::new(cfg.repos.iter().rev().cloned().collect::<Vec<_>>());
|
||||
let failures = Mutex::new(Vec::<GhError>::new());
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..WORKERS.min(cfg.repos.len()) {
|
||||
scope.spawn(|| loop {
|
||||
let Some(repo) = queue.lock().unwrap().pop() else {
|
||||
break;
|
||||
};
|
||||
let now = Utc::now();
|
||||
let result = gh.fetch_repo(&repo, now);
|
||||
{
|
||||
let mut snap = state.snapshot.lock().unwrap();
|
||||
let Some(entry) = snap.repos.iter_mut().find(|e| e.repo == repo) else {
|
||||
continue;
|
||||
};
|
||||
entry.last_attempt_at = Some(now);
|
||||
match result {
|
||||
Ok((stats, budget)) => {
|
||||
entry.stats = Some(stats);
|
||||
entry.fetched_at = Some(now);
|
||||
entry.error = None;
|
||||
if let Some(left) = budget.graphql_remaining {
|
||||
snap.rate_limit_remaining =
|
||||
Some(snap.rate_limit_remaining.map_or(left, |r| r.min(left)));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
entry.error = Some(e.to_string());
|
||||
failures.lock().unwrap().push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
emit_snapshot(app);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let failures = failures.into_inner().unwrap();
|
||||
let poll = cfg.poll_secs();
|
||||
let rate_limited = failures
|
||||
.iter()
|
||||
.find(|e| matches!(e, GhError::RateLimited(_)));
|
||||
if let Some(e) = rate_limited {
|
||||
return finish(Some(e.to_string()), poll * 3, false);
|
||||
}
|
||||
if failures.iter().any(|e| matches!(e, GhError::Unauthorized)) {
|
||||
return finish(Some(GhError::Unauthorized.to_string()), poll, false);
|
||||
}
|
||||
let low_budget = state
|
||||
.snapshot
|
||||
.lock()
|
||||
.unwrap()
|
||||
.rate_limit_remaining
|
||||
.is_some_and(|r| r < BUDGET_FLOOR);
|
||||
if failures.len() == cfg.repos.len() && !cfg.repos.is_empty() {
|
||||
let msg = failures.first().map(|e| e.to_string());
|
||||
return finish(msg, RETRY_AFTER_FAILURE, false);
|
||||
}
|
||||
let next = if low_budget { poll.max(30 * 60) } else { poll };
|
||||
let error = low_budget.then(|| "GitHub API budget low; polling less often".to_string());
|
||||
finish(error, next, failures.is_empty())
|
||||
}
|
||||
|
||||
/// When the cached data is younger than one interval, wait out the rest of it
|
||||
/// instead of polling right at launch (relaunching shouldn't cost a poll).
|
||||
fn first_due(snap: &Snapshot, poll_secs: i64) -> DateTime<Utc> {
|
||||
let now = Utc::now();
|
||||
match snap.oldest_data_at() {
|
||||
Some(t) if (now - t).num_seconds() < poll_secs => t + chrono::Duration::seconds(poll_secs),
|
||||
_ => now,
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_poller(app: AppHandle, rx: mpsc::Receiver<()>) {
|
||||
std::thread::Builder::new()
|
||||
.name("craft-poller".into())
|
||||
.spawn(move || {
|
||||
let state = app.state::<AppState>();
|
||||
let mut next_due = {
|
||||
let poll = state.config.lock().unwrap().poll_secs();
|
||||
let mut snap = state.snapshot.lock().unwrap();
|
||||
let due = first_due(&snap, poll);
|
||||
snap.next_refresh_at = Some(due);
|
||||
due
|
||||
};
|
||||
let mut last_done: Option<DateTime<Utc>> = None;
|
||||
let mut manual = false;
|
||||
loop {
|
||||
reload_config_if_changed(&app);
|
||||
let now = Utc::now();
|
||||
let debounced =
|
||||
last_done.is_some_and(|t| (now - t).num_seconds() < MANUAL_DEBOUNCE);
|
||||
if now >= next_due || (manual && !debounced) {
|
||||
let next_in = run_cycle(&app);
|
||||
last_done = Some(Utc::now());
|
||||
next_due = Utc::now() + chrono::Duration::seconds(next_in);
|
||||
} else {
|
||||
// Staleness changes with the clock alone.
|
||||
update_tray(&app);
|
||||
}
|
||||
manual = false;
|
||||
flush_window_state(&app);
|
||||
match rx.recv_timeout(TICK) {
|
||||
Ok(()) => {
|
||||
manual = true;
|
||||
while rx.try_recv().is_ok() {}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => std::thread::sleep(TICK),
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn poll thread");
|
||||
}
|
||||
|
||||
// ───────────────────────────── window & tray ─────────────────────────────
|
||||
|
||||
fn open_external(target: &str) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("open");
|
||||
c.arg(target);
|
||||
c
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("cmd");
|
||||
c.args(["/C", "start", ""]).arg(target);
|
||||
c
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("xdg-open");
|
||||
c.arg(target);
|
||||
c
|
||||
};
|
||||
cmd.spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn show_window(w: &WebviewWindow) {
|
||||
let _ = w.show();
|
||||
let _ = w.unminimize();
|
||||
let _ = w.set_focus();
|
||||
let _ = w.emit(EV_FOCUS, true);
|
||||
}
|
||||
|
||||
/// Tray click / global hotkey: hide if focused, otherwise bring to front on
|
||||
/// the current desktop.
|
||||
fn toggle_window(app: &AppHandle) {
|
||||
let Some(w) = app.get_webview_window(MAIN) else {
|
||||
return;
|
||||
};
|
||||
if w.is_visible().unwrap_or(false) && w.is_focused().unwrap_or(false) {
|
||||
let _ = w.hide();
|
||||
} else {
|
||||
show_window(&w);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_pinned(app: &AppHandle, on: bool) {
|
||||
let state = app.state::<AppState>();
|
||||
state.config.lock().unwrap().window.always_on_top = on;
|
||||
if let Some(w) = app.get_webview_window(MAIN) {
|
||||
let _ = w.set_always_on_top(on);
|
||||
}
|
||||
if let Some(items) = app.try_state::<TrayItems>() {
|
||||
let _ = items.always_on_top.set_checked(on);
|
||||
}
|
||||
let _ = app.emit(EV_META, meta(app));
|
||||
}
|
||||
|
||||
fn apply_window_config(app: &AppHandle, cfg: &Config) {
|
||||
if let Some(w) = app.get_webview_window(MAIN) {
|
||||
let _ = w.set_always_on_top(cfg.window.always_on_top);
|
||||
let _ = w.set_visible_on_all_workspaces(cfg.tray.visible_on_all_workspaces);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = w.set_skip_taskbar(true);
|
||||
}
|
||||
if let Some(items) = app.try_state::<TrayItems>() {
|
||||
let _ = items.always_on_top.set_checked(cfg.window.always_on_top);
|
||||
}
|
||||
apply_shortcut(app, cfg);
|
||||
}
|
||||
|
||||
fn apply_shortcut(app: &AppHandle, cfg: &Config) {
|
||||
let gs = app.global_shortcut();
|
||||
let _ = gs.unregister_all();
|
||||
let accel = cfg.shortcuts.toggle_window.trim();
|
||||
if accel.is_empty() {
|
||||
return;
|
||||
}
|
||||
let result = gs.on_shortcut(accel, |app, _shortcut, event| {
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
toggle_window(app);
|
||||
}
|
||||
});
|
||||
if let Err(e) = result {
|
||||
eprintln!("Could not register shortcut {accel:?}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// "Craft Status — updated 14:05" or "… — STALE (updated 13:20)".
|
||||
fn tray_tooltip(snap: &Snapshot, now: DateTime<Utc>) -> String {
|
||||
let when = snap
|
||||
.oldest_data_at()
|
||||
.map(|t| t.with_timezone(&Local).format("%H:%M").to_string());
|
||||
match (when, snap.is_stale(now)) {
|
||||
(None, _) => "Craft Status — no data yet".into(),
|
||||
(Some(w), true) => format!("Craft Status — STALE (data from {w})"),
|
||||
(Some(w), false) => format!("Craft Status — updated {w}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_tray(app: &AppHandle) {
|
||||
let tip = tray_tooltip(
|
||||
&app.state::<AppState>().snapshot.lock().unwrap(),
|
||||
Utc::now(),
|
||||
);
|
||||
if let Some(tray) = app.tray_by_id(TRAY) {
|
||||
let _ = tray.set_tooltip(Some(tip));
|
||||
}
|
||||
}
|
||||
|
||||
fn note_window_geometry(app: &AppHandle) {
|
||||
let Some(w) = app.get_webview_window(MAIN) else {
|
||||
return;
|
||||
};
|
||||
let (Ok(pos), Ok(size)) = (w.outer_position(), w.outer_size()) else {
|
||||
return;
|
||||
};
|
||||
if size.width == 0 || size.height == 0 || !w.is_visible().unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
let state = app.state::<AppState>();
|
||||
*state.window_state.lock().unwrap() = Some(WindowState {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
});
|
||||
state.window_dirty.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn flush_window_state(app: &AppHandle) {
|
||||
let state = app.state::<AppState>();
|
||||
if !state.window_dirty.swap(false, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let saved = *state.window_state.lock().unwrap();
|
||||
if let Some(ws) = saved {
|
||||
if let Err(e) = ws.save() {
|
||||
eprintln!("could not save window state: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_window(app: &tauri::App, cfg: &Config) {
|
||||
let Some(w) = app.get_webview_window(MAIN) else {
|
||||
return;
|
||||
};
|
||||
let monitors: Vec<(i32, i32, u32, u32)> = app
|
||||
.available_monitors()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|m| {
|
||||
(
|
||||
m.position().x,
|
||||
m.position().y,
|
||||
m.size().width,
|
||||
m.size().height,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if let Some(ws) = WindowState::load().filter(|ws| ws.visible_on(&monitors)) {
|
||||
let _ = w.set_size(tauri::PhysicalSize::new(ws.width, ws.height));
|
||||
let _ = w.set_position(tauri::PhysicalPosition::new(ws.x, ws.y));
|
||||
*app.state::<AppState>().window_state.lock().unwrap() = Some(ws);
|
||||
} else {
|
||||
let _ = w.set_size(LogicalSize::new(cfg.window.width, cfg.window.height));
|
||||
let _ = w.center();
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tray(app: &tauri::App, cfg: &Config) -> tauri::Result<()> {
|
||||
let toggle = MenuItem::with_id(app, "toggle", "Show / Hide", true, None::<&str>)?;
|
||||
let refresh = MenuItem::with_id(app, "refresh", "Refresh Now", true, None::<&str>)?;
|
||||
let on_top = CheckMenuItem::with_id(
|
||||
app,
|
||||
"on_top",
|
||||
"Always on Top",
|
||||
true,
|
||||
cfg.window.always_on_top,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let open_cfg = MenuItem::with_id(app, "open_config", "Edit Config…", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "Quit Craft Status", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(
|
||||
app,
|
||||
&[
|
||||
&toggle,
|
||||
&refresh,
|
||||
&on_top,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&open_cfg,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&quit,
|
||||
],
|
||||
)?;
|
||||
app.manage(TrayItems {
|
||||
always_on_top: on_top.clone(),
|
||||
});
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let icon = app
|
||||
.default_window_icon()
|
||||
.cloned()
|
||||
.unwrap_or(tauri::image::Image::from_bytes(include_bytes!(
|
||||
"../icons/32x32.png"
|
||||
))?);
|
||||
|
||||
TrayIconBuilder::with_id(TRAY)
|
||||
.icon(icon)
|
||||
.icon_as_template(true)
|
||||
.tooltip("Craft Status")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"toggle" => toggle_window(app),
|
||||
"refresh" => request_refresh(&app.state::<AppState>()),
|
||||
"on_top" => {
|
||||
let checked = app
|
||||
.try_state::<TrayItems>()
|
||||
.and_then(|t| t.always_on_top.is_checked().ok())
|
||||
.unwrap_or(false);
|
||||
set_pinned(app, checked);
|
||||
}
|
||||
"open_config" => {
|
||||
let _ = open_config();
|
||||
}
|
||||
"quit" => {
|
||||
flush_window_state(app);
|
||||
app.exit(0)
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
toggle_window(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let config = Config::load_or_create().unwrap_or_else(|e| {
|
||||
eprintln!("Could not load ~/.craft_status_config.toml ({e}); using defaults");
|
||||
Config::default()
|
||||
});
|
||||
let cache = Cache::default_location().ok();
|
||||
let mut snapshot = cache.as_ref().and_then(Cache::load).unwrap_or_default();
|
||||
snapshot.sync_repos(&config.repos);
|
||||
snapshot.poll_secs = config.poll_secs();
|
||||
snapshot.stale_after_secs = config.stale_secs();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let state = AppState {
|
||||
config: Mutex::new(config),
|
||||
config_mtime: Mutex::new(config_mtime()),
|
||||
snapshot: Mutex::new(snapshot),
|
||||
cache,
|
||||
refresh_tx: Mutex::new(Some(tx)),
|
||||
window_state: Mutex::new(None),
|
||||
window_dirty: AtomicBool::new(false),
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.manage(state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_snapshot,
|
||||
get_meta,
|
||||
refresh_now,
|
||||
open_url,
|
||||
open_config,
|
||||
set_always_on_top,
|
||||
window_ready,
|
||||
hide_window,
|
||||
log,
|
||||
quit,
|
||||
])
|
||||
.setup(move |app| {
|
||||
let cfg = app.state::<AppState>().config.lock().unwrap().clone();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if cfg.tray.hide_dock_icon {
|
||||
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
|
||||
}
|
||||
|
||||
restore_window(app, &cfg);
|
||||
build_tray(app, &cfg)?;
|
||||
apply_window_config(app.handle(), &cfg);
|
||||
update_tray(app.handle());
|
||||
spawn_poller(app.handle().clone(), rx);
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
let close_to_tray = window
|
||||
.state::<AppState>()
|
||||
.config
|
||||
.lock()
|
||||
.unwrap()
|
||||
.tray
|
||||
.close_to_tray;
|
||||
flush_window_state(window.app_handle());
|
||||
if close_to_tray {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
tauri::WindowEvent::Focused(focused) => {
|
||||
let _ = window.emit(EV_FOCUS, *focused);
|
||||
}
|
||||
tauri::WindowEvent::Resized(_) | tauri::WindowEvent::Moved(_) => {
|
||||
note_window_geometry(window.app_handle())
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Craft Status");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration as CDuration;
|
||||
|
||||
fn snap_with(ages_mins: &[i64], now: DateTime<Utc>) -> Snapshot {
|
||||
let mut s = Snapshot {
|
||||
stale_after_secs: 15 * 60,
|
||||
..Default::default()
|
||||
};
|
||||
let names: Vec<String> = (0..ages_mins.len()).map(|i| format!("o/r{i}")).collect();
|
||||
s.sync_repos(&names);
|
||||
for (e, m) in s.repos.iter_mut().zip(ages_mins) {
|
||||
e.fetched_at = Some(now - CDuration::minutes(*m));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_poll_waits_for_fresh_cache_only() {
|
||||
let now = Utc::now();
|
||||
let fresh = snap_with(&[1, 2], now);
|
||||
let due = first_due(&fresh, 300);
|
||||
assert!(due > now + CDuration::seconds(150) && due <= now + CDuration::seconds(181));
|
||||
let old = snap_with(&[1, 20], now);
|
||||
assert!(first_due(&old, 300) <= Utc::now());
|
||||
assert!(first_due(&Snapshot::default(), 300) <= Utc::now());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooltip_reports_staleness() {
|
||||
let now = Utc::now();
|
||||
assert!(tray_tooltip(&Snapshot::default(), now).contains("no data"));
|
||||
assert!(tray_tooltip(&snap_with(&[1], now), now).contains("updated"));
|
||||
assert!(tray_tooltip(&snap_with(&[1, 30], now), now).contains("STALE"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents an extra console window on Windows in release builds.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
craft_status_app_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Craft Status",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ai.storyteller.craft-status",
|
||||
"build": {
|
||||
"frontendDist": "../../ui"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"macOSPrivateApi": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Craft Status",
|
||||
"width": 1340,
|
||||
"height": 560,
|
||||
"minWidth": 520,
|
||||
"minHeight": 240,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"shadow": true,
|
||||
"resizable": true,
|
||||
"visible": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Status of the storytold craft repositories",
|
||||
"longDescription": "A tray dashboard that polls the craft repositories on GitHub every few minutes: PRs, issues, commits, builds, downloads, and the most urgent fixes.",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// Craft Status UI: renders the snapshot pushed by the Rust poller.
|
||||
// Outside Tauri (plain browser) it loads ./dev-snapshot.json, written by
|
||||
// `make snapshot`, so the UI can be iterated on without the app.
|
||||
|
||||
import { COLUMNS, value, sortEntries, defaultDir, freshness, ago, totals, urgentAcross } from './model.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const TAURI = window.__TAURI__;
|
||||
|
||||
const api = TAURI
|
||||
? {
|
||||
invoke: (cmd, args) => TAURI.core.invoke(cmd, args),
|
||||
listen: (ev, fn) => TAURI.event.listen(ev, (e) => fn(e.payload)),
|
||||
startDrag: () => TAURI.window.getCurrentWindow().startDragging(),
|
||||
startResize: () => TAURI.window.getCurrentWindow().startResizeDragging('SouthEast'),
|
||||
}
|
||||
: {
|
||||
async invoke(cmd, args) {
|
||||
if (cmd === 'get_snapshot') return (await fetch('dev-snapshot.json')).json();
|
||||
if (cmd === 'get_meta') return { opacity: 0.96, always_on_top: false, shortcut: 'CmdOrCtrl+Shift+G' };
|
||||
if (cmd === 'open_url') window.open(args.url, '_blank');
|
||||
return null;
|
||||
},
|
||||
listen() {},
|
||||
startDrag() {},
|
||||
startResize() {},
|
||||
};
|
||||
|
||||
const load = (k, d) => {
|
||||
try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; }
|
||||
};
|
||||
const save = (k, v) => {
|
||||
try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* private mode */ }
|
||||
};
|
||||
|
||||
const state = {
|
||||
snap: null,
|
||||
meta: {},
|
||||
view: new URLSearchParams(location.search).get('view') ?? load('view', 'repos'),
|
||||
sort: load('sort', { key: 'urgency', dir: 'desc' }),
|
||||
urgentRepo: '',
|
||||
criticalOnly: load('criticalOnly', false),
|
||||
};
|
||||
|
||||
// ───────────────────────────── formatting ─────────────────────────────
|
||||
|
||||
const el = (tag, props = {}, ...kids) => {
|
||||
const n = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (v === undefined || v === null || v === false) continue;
|
||||
if (k === 'class') n.className = v;
|
||||
else if (k === 'dataset') Object.assign(n.dataset, v);
|
||||
else if (k.startsWith('on')) n.addEventListener(k.slice(2), v);
|
||||
else n.setAttribute(k, v);
|
||||
}
|
||||
for (const kid of kids.flat()) if (kid !== null && kid !== undefined && kid !== false) n.append(kid);
|
||||
return n;
|
||||
};
|
||||
|
||||
const localTime = (iso) => (iso ? new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : '');
|
||||
const clock = (t) => new Date(t).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
const num = (n) => (n ?? 0).toLocaleString('en-US');
|
||||
const gh = (repo, path = '') => `https://github.com/${repo}${path}`;
|
||||
const DAY = 86400e3;
|
||||
|
||||
function openUrl(url, ev) {
|
||||
ev?.stopPropagation();
|
||||
api.invoke('open_url', { url }).catch((e) => console.warn(e));
|
||||
}
|
||||
|
||||
const link = (text, url, cls = '') => el('span', { class: `link ${cls}`, onclick: (e) => openUrl(url, e) }, text);
|
||||
|
||||
/** Cell content for a column. */
|
||||
function cell(entry, col) {
|
||||
const v = value(entry, col.key);
|
||||
const s = entry.stats;
|
||||
if (col.key === 'name') {
|
||||
const tip = entry.error
|
||||
? `Last attempt failed: ${entry.error}`
|
||||
: entry.fetched_at ? `Fetched ${localTime(entry.fetched_at)}` : 'Not fetched yet';
|
||||
return el('span', { class: 'repo', title: tip }, el('span', { class: 'dot' }), link(v, gh(entry.repo)));
|
||||
}
|
||||
if (!s) return el('span', { class: 'muted' }, '—');
|
||||
switch (col.key) {
|
||||
case 'urgency': {
|
||||
const a = Math.min(1, v / 350) * 0.45;
|
||||
return el('span', { class: 'urgency', style: `background: rgb(var(--hot-rgb) / ${a.toFixed(2)})` }, Math.round(v));
|
||||
}
|
||||
case 'critical_issues': return el('span', { class: v ? 'crit' : 'zero' }, num(v));
|
||||
case 'open_prs': return link(num(v), gh(entry.repo, '/pulls'));
|
||||
case 'open_issues': return link(num(v), gh(entry.repo, '/issues'));
|
||||
case 'commits_4h': case 'prs_4h': case 'issues_4h':
|
||||
return el('span', { class: v ? 'active' : 'zero' }, num(v));
|
||||
case 'latest_build':
|
||||
if (!v) return el('span', { class: 'muted' }, '—');
|
||||
return el('span', {}, link(v, gh(entry.repo, `/releases/tag/${encodeURIComponent(v)}`)),
|
||||
s.latest_build_prerelease ? el('span', { class: 'pre' }, 'PRE') : null);
|
||||
}
|
||||
if (col.kind === 'date') {
|
||||
if (!v) return el('span', { class: 'muted' }, '—');
|
||||
const old = col.key === 'oldest_open_pr_at' && Date.now() - Date.parse(v) > 14 * DAY;
|
||||
return el('span', { title: localTime(v), class: old ? 'old' : '' }, ago(v));
|
||||
}
|
||||
return document.createTextNode(num(v));
|
||||
}
|
||||
|
||||
// ───────────────────────────── rendering ─────────────────────────────
|
||||
|
||||
function renderHead() {
|
||||
const groups = el('tr', { class: 'groups' });
|
||||
let prev = null;
|
||||
for (const c of COLUMNS) {
|
||||
if (c.group === prev) { groups.lastChild.colSpan += 1; continue; }
|
||||
prev = c.group;
|
||||
groups.append(el('th', { class: c.group ? 'group-start' : '' }, c.group));
|
||||
}
|
||||
const cols = el('tr', { class: 'cols' });
|
||||
COLUMNS.forEach((c, i) => {
|
||||
const sorted = state.sort.key === c.key;
|
||||
const start = i > 0 && COLUMNS[i - 1].group !== c.group;
|
||||
cols.append(el('th', {
|
||||
title: c.title ?? `Sort by ${c.label}`,
|
||||
class: [sorted && 'sorted', sorted && state.sort.dir === 'asc' && 'asc', start && 'group-start'].filter(Boolean).join(' '),
|
||||
onclick: () => {
|
||||
state.sort = state.sort.key === c.key
|
||||
? { key: c.key, dir: state.sort.dir === 'asc' ? 'desc' : 'asc' }
|
||||
: { key: c.key, dir: defaultDir(c.key) };
|
||||
save('sort', state.sort);
|
||||
render();
|
||||
},
|
||||
}, c.label));
|
||||
});
|
||||
$('thead').replaceChildren(groups, cols);
|
||||
}
|
||||
|
||||
function groupStart(i) {
|
||||
return i > 0 && COLUMNS[i - 1].group !== COLUMNS[i].group ? 'group-start' : '';
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
renderHead();
|
||||
const snap = state.snap;
|
||||
const rows = sortEntries(snap.repos, state.sort.key, state.sort.dir);
|
||||
const now = Date.now();
|
||||
$('tbody').replaceChildren(...rows.map((e) => {
|
||||
const stale = !e.fetched_at || now - Date.parse(e.fetched_at) > snap.stale_after_secs * 1000;
|
||||
return el('tr', {
|
||||
class: [stale && 'stale', e.error && 'error'].filter(Boolean).join(' '),
|
||||
title: 'Click for this repo’s most urgent issues',
|
||||
onclick: () => { state.urgentRepo = e.repo; setView('urgent'); },
|
||||
}, COLUMNS.map((c, i) => el('td', { class: groupStart(i) }, cell(e, c))));
|
||||
}));
|
||||
if (!rows.length) {
|
||||
$('tbody').replaceChildren(el('tr', {}, el('td', { colspan: COLUMNS.length, class: 'empty' }, 'No repositories configured — Edit config.')));
|
||||
}
|
||||
const t = totals(snap.repos);
|
||||
$('tfoot').replaceChildren(el('tr', {}, COLUMNS.map((c, i) =>
|
||||
el('td', { class: groupStart(i) }, i === 0 ? `${snap.repos.length} repos` : c.kind === 'num' ? num(Math.round(t[c.key])) : ''))));
|
||||
}
|
||||
|
||||
function renderUrgent() {
|
||||
const snap = state.snap;
|
||||
const sel = $('urgent-repo');
|
||||
sel.replaceChildren(el('option', { value: '' }, 'All repositories'),
|
||||
...snap.repos.map((r) => el('option', { value: r.repo }, r.repo.split('/').pop())));
|
||||
sel.value = state.urgentRepo;
|
||||
$('critical-only').checked = state.criticalOnly;
|
||||
const items = urgentAcross(snap.repos, { repo: state.urgentRepo, criticalOnly: state.criticalOnly });
|
||||
const SEVERE = /crash|panic|freeze|hang|data loss|corruption|won't|blank|security/;
|
||||
$('urgent-list').replaceChildren(...items.map((u) => {
|
||||
const a = Math.min(1, u.score / 90) * 0.5;
|
||||
return el('li', { class: `urgent-item${u.critical ? ' critical' : ''}`, onclick: () => openUrl(u.url), title: u.url },
|
||||
el('span', { class: 'score', style: `background: rgb(var(--hot-rgb) / ${a.toFixed(2)})` }, u.score),
|
||||
el('span', { class: 'u-repo' }, u.repo.split('/').pop()),
|
||||
el('div', { class: 'u-title' },
|
||||
el('span', { class: 'num' }, `#${u.number}`), el('span', { class: 'text' }, u.title),
|
||||
el('div', { class: 'reasons' }, u.reasons.map((r) => el('span', { class: `reason${SEVERE.test(r) ? ' sev' : ''}` }, r)),
|
||||
u.labels.filter((l) => !u.reasons.includes(`${l} label`)).map((l) => el('span', { class: 'reason' }, l)))),
|
||||
el('span', { class: 'u-age', title: `Opened ${localTime(u.created_at)}` }, ago(u.created_at)));
|
||||
}));
|
||||
if (!items.length) {
|
||||
$('urgent-list').replaceChildren(el('li', { class: 'empty' }, snap.repos.some((r) => r.stats) ? 'Nothing urgent. 🎉' : 'Waiting for the first poll…'));
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
const snap = state.snap;
|
||||
const { oldest, stale } = freshness(snap);
|
||||
const pill = $('fresh-pill');
|
||||
if (snap.refreshing) { pill.className = 'pill refreshing'; pill.textContent = 'REFRESHING'; }
|
||||
else if (stale) { pill.className = 'pill stale'; pill.textContent = 'STALE'; }
|
||||
else { pill.className = 'pill fresh'; pill.textContent = 'FRESH'; }
|
||||
const last = snap.last_full_refresh_at ? Date.parse(snap.last_full_refresh_at) : null;
|
||||
let text;
|
||||
if (oldest === null) text = last ? `Partial data · last full refresh ${clock(last)}` : 'No data yet';
|
||||
else {
|
||||
text = `Data as of ${clock(oldest)} (${ago(new Date(oldest).toISOString())} ago)`;
|
||||
if (last && Math.abs(last - oldest) > 60e3) text += ` · last full refresh ${clock(last)}`;
|
||||
}
|
||||
$('refreshed').textContent = text;
|
||||
$('refreshed').title = [
|
||||
`Oldest data on screen: ${oldest ? localTime(new Date(oldest).toISOString()) : '—'}`,
|
||||
`Last full refresh: ${localTime(snap.last_full_refresh_at) || '—'}`,
|
||||
`Stale after ${Math.round(snap.stale_after_secs / 60)} min without new data`,
|
||||
].join('\n');
|
||||
$('refresh').classList.toggle('spinning', !!snap.refreshing);
|
||||
|
||||
const failing = snap.repos.filter((r) => r.error);
|
||||
const msgs = [snap.error, failing.length ? `${failing.length} repo${failing.length > 1 ? 's' : ''} failed last poll: ${failing.map((r) => `${r.repo.split('/').pop()} (${r.error})`).join('; ')}` : null].filter(Boolean);
|
||||
$('banner').hidden = !msgs.length;
|
||||
$('banner').textContent = msgs.join(' — ');
|
||||
|
||||
$('next').textContent = snap.refreshing ? 'Polling GitHub…'
|
||||
: snap.next_refresh_at ? `Next poll ${clock(Date.parse(snap.next_refresh_at))} · every ${Math.round(snap.poll_secs / 60)} min` : '';
|
||||
$('budget').textContent = snap.rate_limit_remaining != null ? `GitHub budget ${num(snap.rate_limit_remaining)}` : '';
|
||||
const critical = snap.repos.reduce((n, r) => n + (r.stats?.critical_issues ?? 0), 0);
|
||||
$('urgent-count').textContent = critical ? String(critical) : '';
|
||||
$('urgent-count').title = `${critical} critical open issues`;
|
||||
$('repo-count').textContent = String(snap.repos.length);
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state.snap) return;
|
||||
renderStatus();
|
||||
document.querySelectorAll('.view').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.view === state.view)));
|
||||
$('repos-view').hidden = state.view !== 'repos';
|
||||
$('urgent-view').hidden = state.view !== 'urgent';
|
||||
if (state.view === 'repos') renderTable(); else renderUrgent();
|
||||
}
|
||||
|
||||
function setView(v) {
|
||||
state.view = v;
|
||||
save('view', v);
|
||||
render();
|
||||
}
|
||||
|
||||
function applyMeta(m) {
|
||||
state.meta = m ?? {};
|
||||
document.documentElement.style.setProperty('--opacity', String(state.meta.opacity ?? 0.96));
|
||||
$('pin').setAttribute('aria-pressed', String(!!state.meta.always_on_top));
|
||||
const mac = navigator.platform.toLowerCase().includes('mac');
|
||||
$('shortcut').textContent = (state.meta.shortcut ?? '').replace('CmdOrCtrl', mac ? '⌘' : 'Ctrl').replace('Shift', '⇧').replaceAll('+', '');
|
||||
}
|
||||
|
||||
// ───────────────────────────── wiring ─────────────────────────────
|
||||
|
||||
document.querySelectorAll('.view').forEach((b) => b.addEventListener('click', () => {
|
||||
if (b.dataset.view === 'urgent' && state.view !== 'urgent') state.urgentRepo = '';
|
||||
setView(b.dataset.view);
|
||||
}));
|
||||
$('urgent-repo').addEventListener('change', (e) => { state.urgentRepo = e.target.value; render(); });
|
||||
$('critical-only').addEventListener('change', (e) => { state.criticalOnly = e.target.checked; save('criticalOnly', state.criticalOnly); render(); });
|
||||
$('refresh').addEventListener('click', () => api.invoke('refresh_now'));
|
||||
$('pin').addEventListener('click', () => api.invoke('set_always_on_top', { on: !state.meta.always_on_top }));
|
||||
$('hide').addEventListener('click', () => api.invoke('hide_window'));
|
||||
$('edit-config').addEventListener('click', () => api.invoke('open_config'));
|
||||
$('resize').addEventListener('mousedown', (e) => { e.preventDefault(); api.startResize(); });
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const mod = e.metaKey || e.ctrlKey;
|
||||
if (e.key === 'Escape') api.invoke('hide_window');
|
||||
else if (mod && e.key.toLowerCase() === 'r') { e.preventDefault(); api.invoke('refresh_now'); }
|
||||
else if (mod && e.key === '1') setView('repos');
|
||||
else if (mod && e.key === '2') setView('urgent');
|
||||
});
|
||||
|
||||
api.listen('craft:snapshot', (s) => { state.snap = s; render(); });
|
||||
api.listen('craft:meta', applyMeta);
|
||||
// Relative times and staleness change with the clock alone.
|
||||
setInterval(render, 15_000);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
applyMeta(await api.invoke('get_meta'));
|
||||
state.snap = await api.invoke('get_snapshot');
|
||||
render();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
// Not requestAnimationFrame: WebKit never fires it while the window is hidden.
|
||||
api.invoke('window_ready');
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Craft Status</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app" id="app">
|
||||
<header data-tauri-drag-region>
|
||||
<div class="brand" data-tauri-drag-region>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 14V9M6 14V6M10 14V4M14 14V1"/></svg>
|
||||
<span data-tauri-drag-region>Craft Status</span>
|
||||
</div>
|
||||
<nav class="views" role="tablist">
|
||||
<button class="view" data-view="repos" role="tab">Repos <span class="badge" id="repo-count"></span></button>
|
||||
<button class="view" data-view="urgent" role="tab">Fix first <span class="badge hot" id="urgent-count"></span></button>
|
||||
</nav>
|
||||
<div class="status" data-tauri-drag-region>
|
||||
<span id="fresh-pill" class="pill"></span>
|
||||
<span id="refreshed" class="refreshed" data-tauri-drag-region></span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="icon-button" id="refresh" title="Refresh now (⌘R)">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2v3h-3"/></svg>
|
||||
</button>
|
||||
<button class="icon-button" id="pin" title="Always on top">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 2h4l-.5 4 2.5 2.5H4L6.5 6zM8 8.5V14"/></svg>
|
||||
</button>
|
||||
<button class="icon-button" id="hide" title="Hide (Esc)">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="banner" id="banner" hidden></div>
|
||||
|
||||
<main id="repos-view">
|
||||
<table id="table">
|
||||
<thead id="thead"></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
<tfoot id="tfoot"></tfoot>
|
||||
</table>
|
||||
</main>
|
||||
|
||||
<main id="urgent-view" hidden>
|
||||
<div class="urgent-controls">
|
||||
<select id="urgent-repo" aria-label="Repository"></select>
|
||||
<label class="toggle"><input type="checkbox" id="critical-only"> Critical only</label>
|
||||
<span class="hint">Scored by severity words, labels, user reports, 👍/💬, the latest build and freshness. Click to open on GitHub.</span>
|
||||
</div>
|
||||
<ol class="urgent-list" id="urgent-list"></ol>
|
||||
</main>
|
||||
|
||||
<footer data-tauri-drag-region>
|
||||
<span id="next" data-tauri-drag-region></span>
|
||||
<span id="budget" data-tauri-drag-region></span>
|
||||
<span class="spacer" data-tauri-drag-region></span>
|
||||
<button class="footer-link" id="edit-config">Edit config</button>
|
||||
<span id="shortcut"></span>
|
||||
</footer>
|
||||
<div id="resize" title="Resize"></div>
|
||||
</div>
|
||||
<script>
|
||||
// Surface load/runtime errors in the app's stderr (module errors never reach app.js).
|
||||
const report = (m) => window.__TAURI__?.core.invoke('log', { msg: String(m) });
|
||||
addEventListener('error', (e) => report(`${e.message} @ ${e.filename}:${e.lineno}`));
|
||||
addEventListener('unhandledrejection', (e) => report(`unhandled: ${e.reason}`));
|
||||
</script>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,141 @@
|
||||
// Pure helpers: columns, sorting, formatting, staleness. No DOM, so
|
||||
// `node --test ui/` can exercise them (see model.test.mjs).
|
||||
|
||||
/** @typedef {'text'|'num'|'date'|'version'} Kind */
|
||||
|
||||
/**
|
||||
* Table columns, in display order. `get` reads from a RepoEntry; numbers and
|
||||
* dates sort descending first (biggest / newest on top), text ascending.
|
||||
*/
|
||||
export const COLUMNS = [
|
||||
{ key: 'name', label: 'Repo', kind: 'text', group: '', get: (e) => e.repo.split('/').pop() },
|
||||
{ key: 'urgency', label: 'Urgency', kind: 'num', group: 'Fix first', title: 'Sum of the top five open-issue urgency scores' },
|
||||
{ key: 'critical_issues', label: 'Critical', kind: 'num', group: 'Fix first', title: 'Open issues that mention a crash, hang, freeze, data loss, launch failure or security problem' },
|
||||
{ key: 'open_prs', label: 'PRs', kind: 'num', group: 'Open', title: 'Open pull requests' },
|
||||
{ key: 'open_issues', label: 'Issues', kind: 'num', group: 'Open', title: 'Open issues' },
|
||||
{ key: 'oldest_open_pr_at', label: 'Oldest PR', kind: 'date', group: 'Open', title: 'Oldest pull request still open', asc: true },
|
||||
{ key: 'last_commit_at', label: 'Last commit', kind: 'date', group: 'Latest', title: 'Last commit on main' },
|
||||
{ key: 'last_issue_at', label: 'Last issue', kind: 'date', group: 'Latest', title: 'Newest issue (any state)' },
|
||||
{ key: 'newest_pr_at', label: 'Newest PR', kind: 'date', group: 'Latest', title: 'Newest pull request (any state)' },
|
||||
{ key: 'commits_4h', label: 'Commits', kind: 'num', group: 'Last 4 h', title: 'Commits to main in the last 4 hours' },
|
||||
{ key: 'prs_4h', label: 'PRs', kind: 'num', group: 'Last 4 h', title: 'Pull requests opened in the last 4 hours' },
|
||||
{ key: 'issues_4h', label: 'Issues', kind: 'num', group: 'Last 4 h', title: 'Issues opened in the last 4 hours' },
|
||||
{ key: 'commits_total', label: 'Commits', kind: 'num', group: 'All time', title: 'Commits on main, all time' },
|
||||
{ key: 'contributors', label: 'People', kind: 'num', group: 'All time', title: 'Contributors' },
|
||||
{ key: 'issues_total', label: 'Issues', kind: 'num', group: 'All time', title: 'Issues, all time, open + closed' },
|
||||
{ key: 'prs_total', label: 'PRs', kind: 'num', group: 'All time', title: 'Pull requests, all time, open + closed + merged' },
|
||||
{ key: 'latest_build', label: 'Build', kind: 'version', group: 'Builds', title: 'Most recent published release' },
|
||||
{ key: 'latest_build_at', label: 'Built', kind: 'date', group: 'Builds', title: 'When the most recent release was published' },
|
||||
{ key: 'latest_build_downloads', label: 'Build DLs', kind: 'num', group: 'Builds', title: 'Downloads of the most recent release, all OSes and package types (checksums and update deltas excluded)' },
|
||||
{ key: 'downloads_total', label: 'Total DLs', kind: 'num', group: 'Builds', title: 'Downloads across every published release' },
|
||||
];
|
||||
|
||||
export const COLUMN = Object.fromEntries(COLUMNS.map((c) => [c.key, c]));
|
||||
|
||||
/** The value a column shows for an entry; `null` when there is no data. */
|
||||
export function value(entry, key) {
|
||||
const col = COLUMN[key];
|
||||
if (col?.get) return col.get(entry);
|
||||
const v = entry.stats ? entry.stats[key] : null;
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
/** `v0.10.2-rc.1` → [0, 10, 2, -1, 1] so pre-releases sort below the release. */
|
||||
export function versionKey(tag) {
|
||||
const m = String(tag).replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([A-Za-z]*)\.?(\d+)?)?/);
|
||||
if (!m) return [-1];
|
||||
return [+m[1], +(m[2] ?? 0), +(m[3] ?? 0), m[4] !== undefined ? -1 : 0, +(m[5] ?? 0)];
|
||||
}
|
||||
|
||||
function cmpArrays(a, b) {
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const d = (a[i] ?? 0) - (b[i] ?? 0);
|
||||
if (d) return d;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cmpValues(kind, a, b) {
|
||||
switch (kind) {
|
||||
case 'text': return String(a).localeCompare(String(b), undefined, { sensitivity: 'base', numeric: true });
|
||||
case 'date': return Date.parse(a) - Date.parse(b);
|
||||
case 'version': return cmpArrays(versionKey(a), versionKey(b));
|
||||
default: return a - b;
|
||||
}
|
||||
}
|
||||
|
||||
/** The direction a column sorts in when first clicked. */
|
||||
export function defaultDir(key) {
|
||||
const col = COLUMN[key];
|
||||
return col.kind === 'text' || col.asc ? 'asc' : 'desc';
|
||||
}
|
||||
|
||||
/** Sorted copy. Missing values always sink to the bottom; ties fall back to name. */
|
||||
export function sortEntries(entries, key, dir) {
|
||||
const col = COLUMN[key] ?? COLUMN.urgency;
|
||||
const sign = dir === 'asc' ? 1 : -1;
|
||||
return [...entries].sort((a, b) => {
|
||||
const va = value(a, col.key);
|
||||
const vb = value(b, col.key);
|
||||
if (va === null && vb === null) return cmpValues('text', value(a, 'name'), value(b, 'name'));
|
||||
if (va === null) return 1;
|
||||
if (vb === null) return -1;
|
||||
return sign * cmpValues(col.kind, va, vb) || cmpValues('text', value(a, 'name'), value(b, 'name'));
|
||||
});
|
||||
}
|
||||
|
||||
export function isStale(entry, staleAfterSecs, now = Date.now()) {
|
||||
if (!entry.fetched_at) return true;
|
||||
return now - Date.parse(entry.fetched_at) > staleAfterSecs * 1000;
|
||||
}
|
||||
|
||||
/** `{ oldest, stale }` for the whole snapshot: the age of the oldest data on screen. */
|
||||
export function freshness(snap, now = Date.now()) {
|
||||
const times = snap.repos.map((r) => (r.fetched_at ? Date.parse(r.fetched_at) : null));
|
||||
const oldest = times.length && times.every((t) => t !== null) ? Math.min(...times) : null;
|
||||
const stale = !snap.repos.length || snap.repos.some((r) => isStale(r, snap.stale_after_secs, now));
|
||||
return { oldest, stale };
|
||||
}
|
||||
|
||||
/** "now", "45s", "12m", "3h", "5d", "2y" — compact for table cells. */
|
||||
export function ago(iso, now = Date.now()) {
|
||||
if (!iso) return '';
|
||||
const s = Math.max(0, (now - Date.parse(iso)) / 1000);
|
||||
if (s < 10) return 'now';
|
||||
if (s < 60) return `${Math.floor(s)}s`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
||||
if (s < 86400 * 365) return `${Math.floor(s / 86400)}d`;
|
||||
return `${Math.floor(s / (86400 * 365))}y`;
|
||||
}
|
||||
|
||||
export function compact(n) {
|
||||
if (n === null || n === undefined) return '';
|
||||
if (n < 10000) return n.toLocaleString('en-US');
|
||||
if (n < 1e6) return `${(n / 1000).toFixed(n < 1e5 ? 1 : 0)}k`;
|
||||
return `${(n / 1e6).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
/** Column totals for the footer row (counts only; dates and versions blank). */
|
||||
export function totals(entries) {
|
||||
const out = {};
|
||||
for (const c of COLUMNS) {
|
||||
if (c.kind !== 'num') continue;
|
||||
out[c.key] = entries.reduce((sum, e) => sum + (value(e, c.key) ?? 0), 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every repo's urgent issues merged, best first. */
|
||||
export function urgentAcross(entries, { repo = '', criticalOnly = false, limit = 60 } = {}) {
|
||||
const all = [];
|
||||
for (const e of entries) {
|
||||
if (repo && e.repo !== repo) continue;
|
||||
for (const u of e.stats?.urgent ?? []) {
|
||||
if (criticalOnly && !u.critical) continue;
|
||||
all.push({ ...u, repo: e.repo });
|
||||
}
|
||||
}
|
||||
all.sort((a, b) => b.score - a.score || Date.parse(b.created_at ?? 0) - Date.parse(a.created_at ?? 0));
|
||||
return all.slice(0, limit);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { sortEntries, versionKey, freshness, ago, compact, totals, urgentAcross, defaultDir } from './model.js';
|
||||
|
||||
const entry = (repo, stats, fetched_at = '2026-10-08T12:00:00Z') => ({ repo: `o/${repo}`, stats, fetched_at });
|
||||
|
||||
const rows = [
|
||||
entry('b', { urgency: 10, latest_build: 'v0.10.0', last_commit_at: '2026-10-08T10:00:00Z' }),
|
||||
entry('a', { urgency: 50, latest_build: 'v0.9.0', last_commit_at: '2026-10-08T11:00:00Z' }),
|
||||
entry('c', null, null),
|
||||
entry('d', { urgency: 50, latest_build: 'v0.10.0-rc.2', last_commit_at: null }),
|
||||
];
|
||||
|
||||
const names = (list) => list.map((e) => e.repo.slice(2)).join('');
|
||||
|
||||
test('numbers sort descending with ties by name, missing last', () => {
|
||||
assert.equal(names(sortEntries(rows, 'urgency', 'desc')), 'adbc');
|
||||
assert.equal(names(sortEntries(rows, 'urgency', 'asc')), 'badc');
|
||||
});
|
||||
|
||||
test('versions sort semantically, prereleases below their release', () => {
|
||||
assert.equal(names(sortEntries(rows, 'latest_build', 'desc')), 'bdac');
|
||||
assert.deepEqual(versionKey('v0.10.0-rc.2'), [0, 10, 0, -1, 2]);
|
||||
assert.deepEqual(versionKey('nightly'), [-1]);
|
||||
});
|
||||
|
||||
test('dates and names', () => {
|
||||
assert.equal(names(sortEntries(rows, 'last_commit_at', 'desc')), 'abcd');
|
||||
assert.equal(names(sortEntries(rows, 'name', 'asc')), 'abcd');
|
||||
assert.equal(defaultDir('name'), 'asc');
|
||||
assert.equal(defaultDir('downloads_total'), 'desc');
|
||||
assert.equal(defaultDir('oldest_open_pr_at'), 'asc');
|
||||
});
|
||||
|
||||
test('freshness', () => {
|
||||
const now = Date.parse('2026-10-08T12:10:00Z');
|
||||
const snap = { stale_after_secs: 900, repos: [entry('a', {}), entry('b', {}, '2026-10-08T12:05:00Z')] };
|
||||
assert.deepEqual(freshness(snap, now), { oldest: Date.parse('2026-10-08T12:00:00Z'), stale: false });
|
||||
assert.equal(freshness(snap, now + 6 * 60_000).stale, true);
|
||||
assert.equal(freshness({ ...snap, repos: [...snap.repos, entry('x', null, null)] }, now).oldest, null);
|
||||
assert.equal(freshness({ stale_after_secs: 1, repos: [] }, now).stale, true);
|
||||
});
|
||||
|
||||
test('formatting', () => {
|
||||
const now = Date.parse('2026-10-08T12:00:00Z');
|
||||
assert.equal(ago('2026-10-08T11:58:00Z', now), '2m');
|
||||
assert.equal(ago('2026-10-06T12:00:00Z', now), '2d');
|
||||
assert.equal(ago(null, now), '');
|
||||
assert.equal(compact(279524), '280k');
|
||||
assert.equal(compact(57247), '57.2k');
|
||||
assert.equal(compact(950), '950');
|
||||
});
|
||||
|
||||
test('totals and urgent merge', () => {
|
||||
assert.equal(totals(rows).urgency, 110);
|
||||
const e = [
|
||||
entry('a', { urgent: [{ number: 1, score: 30, critical: false }, { number: 2, score: 80, critical: true }] }),
|
||||
entry('b', { urgent: [{ number: 3, score: 50, critical: true }] }),
|
||||
];
|
||||
assert.deepEqual(urgentAcross(e).map((u) => u.number), [2, 3, 1]);
|
||||
assert.deepEqual(urgentAcross(e, { criticalOnly: true }).map((u) => u.repo), ['o/a', 'o/b']);
|
||||
assert.deepEqual(urgentAcross(e, { repo: 'o/b' }).map((u) => u.number), [3]);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: 246 248 251;
|
||||
--surface: #eceff5;
|
||||
--fg: #1a2233;
|
||||
--muted: #687389;
|
||||
--border: #d9dee8;
|
||||
--accent: #2f6fe0;
|
||||
--good: #188a5a;
|
||||
--warn: #b7741a;
|
||||
--bad: #d23c3c;
|
||||
--hot-rgb: 210 60 60;
|
||||
--row-hover: rgba(47, 111, 224, .06);
|
||||
--opacity: .96;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: 11 18 32;
|
||||
--surface: #141d31;
|
||||
--fg: #dbe4f3;
|
||||
--muted: #91a0bc;
|
||||
--border: #22304c;
|
||||
--accent: #5b9dff;
|
||||
--good: #7cdbad;
|
||||
--warn: #f4bc70;
|
||||
--bad: #ff8f8f;
|
||||
--hot-rgb: 255 110 110;
|
||||
--row-hover: rgba(91, 157, 255, .07);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; }
|
||||
body { font: 12px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--fg); -webkit-font-smoothing: antialiased; }
|
||||
button, select { color: inherit; font: inherit; cursor: pointer; }
|
||||
button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
svg { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.app { height: 100%; display: flex; flex-direction: column; background: rgb(var(--bg) / var(--opacity)); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; position: relative; }
|
||||
|
||||
header { display: flex; align-items: center; gap: 14px; padding: 8px 10px 8px 15px; border-bottom: 1px solid var(--border); user-select: none; -webkit-user-select: none; }
|
||||
.brand { display: flex; gap: 8px; align-items: center; color: var(--muted); font-weight: 600; white-space: nowrap; }
|
||||
.brand svg { width: 15px; height: 15px; stroke-width: 2.2; color: var(--accent); }
|
||||
.views { display: flex; gap: 2px; }
|
||||
.view { border: 0; background: none; color: var(--muted); padding: 3px 9px; border-radius: 12px; white-space: nowrap; }
|
||||
.view:hover { color: var(--fg); }
|
||||
.view[aria-selected="true"] { background: var(--surface); color: var(--fg); }
|
||||
.badge { font-variant-numeric: tabular-nums; opacity: .75; margin-left: 2px; }
|
||||
.badge.hot:not(:empty) { color: var(--bad); opacity: 1; font-weight: 600; }
|
||||
.status { flex: 1; display: flex; align-items: center; gap: 8px; min-width: 0; justify-content: flex-end; }
|
||||
.refreshed { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
|
||||
.pill { font-size: 10px; font-weight: 700; letter-spacing: .05em; padding: 1px 7px; border-radius: 9px; white-space: nowrap; }
|
||||
.pill.fresh { color: var(--good); background: color-mix(in srgb, var(--good) 14%, transparent); }
|
||||
.pill.stale { color: #fff; background: var(--bad); }
|
||||
.pill.refreshing { color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); }
|
||||
.pill.refreshing::before { content: ''; display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: currentColor; vertical-align: 1px; animation: pulse 1.2s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: .2; } }
|
||||
@media (prefers-reduced-motion: reduce) { .pill.refreshing::before { animation: none; } }
|
||||
.actions { display: flex; gap: 2px; }
|
||||
.icon-button { background: none; border: 0; border-radius: 6px; width: 26px; height: 26px; display: grid; place-items: center; color: var(--muted); padding: 0; }
|
||||
.icon-button svg { width: 15px; height: 15px; }
|
||||
.icon-button:hover:not(:disabled) { background: var(--surface); color: var(--fg); }
|
||||
.icon-button[aria-pressed="true"] { color: var(--accent); }
|
||||
.icon-button.spinning svg { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.banner { padding: 6px 15px; font-size: 11.5px; color: var(--bad); background: color-mix(in srgb, var(--bad) 10%, transparent); border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
||||
|
||||
main { flex: 1; min-height: 0; overflow: auto; }
|
||||
|
||||
/* ───── table ───── */
|
||||
table { border-collapse: separate; border-spacing: 0; width: 100%; font-variant-numeric: tabular-nums; }
|
||||
th, td { padding: 5px 8px; text-align: right; white-space: nowrap; border-bottom: 1px solid var(--border); }
|
||||
th:first-child, td:first-child { text-align: left; position: sticky; left: 0; z-index: 1; background: rgb(var(--bg)); padding-left: 15px; }
|
||||
thead th { position: sticky; background: rgb(var(--bg)); z-index: 2; }
|
||||
thead tr.groups th { top: 0; color: var(--muted); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; text-align: center; border-bottom: 0; padding-bottom: 0; }
|
||||
thead tr.groups th:not(:empty) { box-shadow: inset 0 -1px 0 var(--border); }
|
||||
thead tr.cols th { top: 21px; color: var(--muted); font-weight: 600; cursor: pointer; user-select: none; -webkit-user-select: none; }
|
||||
thead th:first-child { z-index: 3; }
|
||||
thead tr.cols th:hover { color: var(--fg); }
|
||||
thead tr.cols th.sorted { color: var(--accent); }
|
||||
thead tr.cols th.sorted::after { content: ' ↓'; }
|
||||
thead tr.cols th.sorted.asc::after { content: ' ↑'; }
|
||||
th.group-start, td.group-start { border-left: 1px solid var(--border); }
|
||||
tbody tr { cursor: pointer; }
|
||||
tbody tr:hover td { background-color: var(--row-hover); }
|
||||
tbody tr:hover td:first-child { background-image: linear-gradient(var(--row-hover), var(--row-hover)); }
|
||||
.repo { font-weight: 600; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.repo .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--good); flex-shrink: 0; }
|
||||
tr.stale .repo .dot { background: var(--warn); }
|
||||
tr.error .repo .dot { background: var(--bad); }
|
||||
tr.stale td:not(:first-child) { opacity: .55; }
|
||||
.muted { color: var(--muted); }
|
||||
.zero { color: var(--muted); opacity: .6; }
|
||||
.active { color: var(--accent); font-weight: 600; }
|
||||
.link:hover { text-decoration: underline; color: var(--accent); }
|
||||
.urgency { display: inline-block; min-width: 40px; padding: 0 6px; border-radius: 4px; font-weight: 600; }
|
||||
.crit { color: var(--bad); font-weight: 700; }
|
||||
.pre { font-size: 9px; color: var(--warn); margin-left: 3px; vertical-align: 1px; }
|
||||
.old { color: var(--warn); }
|
||||
tfoot td { color: var(--muted); font-weight: 600; border-bottom: 0; position: sticky; bottom: 0; background: rgb(var(--bg)); box-shadow: inset 0 1px 0 var(--border); }
|
||||
.empty { padding: 40px; text-align: center; color: var(--muted); }
|
||||
|
||||
/* ───── urgent view ───── */
|
||||
.urgent-controls { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 12px; padding: 7px 15px; background: rgb(var(--bg)); border-bottom: 1px solid var(--border); }
|
||||
.urgent-controls select { border: 1px solid var(--border); background: var(--surface); border-radius: 6px; padding: 2px 6px; }
|
||||
.toggle { display: flex; gap: 5px; align-items: center; color: var(--muted); white-space: nowrap; }
|
||||
.hint { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.urgent-list { list-style: none; margin: 0; padding: 0; }
|
||||
.urgent-item { display: grid; grid-template-columns: 44px 92px 1fr auto; gap: 10px; align-items: baseline; padding: 7px 15px; border-bottom: 1px solid var(--border); cursor: pointer; }
|
||||
.urgent-item:hover { background: var(--row-hover); }
|
||||
.score { font-weight: 700; text-align: center; border-radius: 4px; font-variant-numeric: tabular-nums; }
|
||||
.u-repo { color: var(--muted); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.u-title { min-width: 0; }
|
||||
.u-title .num { color: var(--muted); margin-right: 5px; font-variant-numeric: tabular-nums; }
|
||||
.u-title .text { font-weight: 500; }
|
||||
.urgent-item.critical .u-title .text { color: var(--bad); }
|
||||
.reasons { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
|
||||
.reason { font-size: 10px; padding: 0 6px; border-radius: 8px; background: var(--surface); color: var(--muted); }
|
||||
.reason.sev { color: var(--bad); background: color-mix(in srgb, var(--bad) 12%, transparent); }
|
||||
.u-age { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
|
||||
footer { display: flex; align-items: center; gap: 14px; padding: 6px 15px; border-top: 1px solid var(--border); color: var(--muted); font-size: 10.5px; user-select: none; -webkit-user-select: none; font-variant-numeric: tabular-nums; }
|
||||
.spacer { flex: 1; }
|
||||
.footer-link { padding: 0; border: 0; background: none; color: var(--muted); font-size: 10.5px; }
|
||||
.footer-link:hover { color: var(--fg); text-decoration: underline; }
|
||||
#resize { position: absolute; bottom: 0; right: 0; width: 14px; height: 14px; cursor: nwse-resize; }
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-thumb { border-radius: 6px; background: var(--border); }
|
||||
::-webkit-scrollbar-corner { background: transparent; }
|
||||