Compare commits

...

3 commits
v0.1.3 ... main

Author SHA1 Message Date
Breadway
6fda461133 gitignore untracked .freebuff/ local tool state
All checks were successful
dev release / build (push) Successful in 1m10s
Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-31 14:45:15 +08:00
Breadway
8e0b95899c Guard unsaved edits against refresh and failed apply; coalesce undo
- Refresh (r) no longer clears the dirty flag or clobbers in-progress
  edits: it skips with a status message while changes are uncommitted,
  keeping the unsaved-changes quit guard intact.
- Config-tab apply keeps dirty set until the hyprctl eval and
  monitors.json write both succeed, so a failed apply no longer drops
  the modified marker.
- Undo snapshots coalesce runs of nudges/value cycles into one step via
  burst tracking in AppState, so 20 px of nudges undo as a single step.
- Replace the `date` subprocess timestamp with an in-process ISO 8601
  formatter (Hinnant days-from-civil); remove dead row_height code.
2026-08-30 19:17:13 +08:00
Breadway
40d28d3ec4 CI: refuse unsigned bakery index on stable tag releases
Some checks failed
check / check (push) Failing after 3s
dev release / build (push) Successful in 59s
beta (rc) release / build (push) Has been skipped
release / build (push) Successful in 37s
2026-08-16 00:50:22 +08:00
7 changed files with 154 additions and 40 deletions

View file

@ -17,7 +17,16 @@ jobs:
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build - name: build
run: cd src && bash ci/build.sh cargo build --release --locked run: |
set -euo pipefail
if [ ! -f src/ci/build.sh ]; then
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
exit 1
fi
cd src && bash ci/build.sh cargo build --release --locked || {
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
exit 1
}
- name: prepare artifacts - name: prepare artifacts
run: | run: |
@ -33,8 +42,14 @@ jobs:
ln -sfn "${VERSION}" "/srv/breadway-dl/breadmon/latest" ln -sfn "${VERSION}" "/srv/breadway-dl/breadmon/latest"
- name: regenerate index.json - name: regenerate index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: | run: |
set -euo pipefail set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci rm -rf /tmp/bread-ecosystem-ci
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh

3
.gitignore vendored
View file

@ -35,3 +35,6 @@ CLAUDE.md
# graphify knowledge-graph output (local tool cache, not for commit) # graphify knowledge-graph output (local tool cache, not for commit)
graphify-out/ graphify-out/
# .freebuff local tool state (not for commit)
.freebuff/

View file

@ -173,12 +173,20 @@ async fn run(
} }
crossterm::event::KeyCode::Char('r') => match monitor::load_monitors().await { crossterm::event::KeyCode::Char('r') => match monitor::load_monitors().await {
Ok(monitors) => { Ok(monitors) => {
if state.dirty {
// Don't clobber unsaved edits (or silently drop the
// unsaved-changes quit guard) on a refresh.
state.set_status(
"Refresh skipped: unsaved changes present.",
StatusLevel::Info,
);
} else {
state.monitors = monitors; state.monitors = monitors;
state.layout.clamp_selected(state.monitors.len()); state.layout.clamp_selected(state.monitors.len());
state.dirty = false;
state.active_profile = None; state.active_profile = None;
state.set_status("Monitors refreshed.", StatusLevel::Success); state.set_status("Monitors refreshed.", StatusLevel::Success);
} }
}
Err(e) => { Err(e) => {
state.set_status(format!("Refresh failed: {}", e), StatusLevel::Error); state.set_status(format!("Refresh failed: {}", e), StatusLevel::Error);
} }

View file

@ -129,15 +129,39 @@ pub fn apply_to_monitors(profile: &Profile, monitors: &mut [Monitor]) {
} }
fn chrono_now() -> String { fn chrono_now() -> String {
// Simple ISO 8601 timestamp without pulling in chrono // In-process ISO 8601 (UTC) timestamp — no chrono crate, and no shelling
// Uses date command; falls back to a placeholder if unavailable // out to `date`. `civil_from_days` is the Hinnant days-from-civil epoch
std::process::Command::new("date") // algorithm. Falls back to the Unix epoch instant if the clock is broken.
.arg("+%Y-%m-%dT%H:%M:%SZ") let secs = std::time::SystemTime::now()
.output() .duration_since(std::time::UNIX_EPOCH)
.ok() .map(|d| d.as_secs())
.and_then(|o| String::from_utf8(o.stdout).ok()) .unwrap_or(0);
.map(|s| s.trim().to_owned()) let (h, m, s) = secs_of_day(secs % 86_400);
.unwrap_or_else(|| "unknown".to_owned()) let (y, mo, d) = civil_from_days((secs / 86_400) as i64);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
/// Convert days since 1970-01-01 to a (year, month, day) civil date.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
/// Seconds within the day -> (hours, minutes, seconds).
fn secs_of_day(secs: u64) -> (u32, u32, u32) {
(
((secs / 3600) % 24) as u32,
((secs / 60) % 60) as u32,
(secs % 60) as u32,
)
} }
#[cfg(test)] #[cfg(test)]
@ -184,4 +208,19 @@ mod tests {
assert_eq!(deserialized.monitors[0].mode, "1920x1200@60.00"); assert_eq!(deserialized.monitors[0].mode, "1920x1200@60.00");
assert_eq!(deserialized.monitors[1].x, 1920); assert_eq!(deserialized.monitors[1].x, 1920);
} }
#[test]
fn chrono_now_helpers() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
// 2024-01-01 is epoch day 19723.
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
assert_eq!(secs_of_day(0), (0, 0, 0));
assert_eq!(secs_of_day(86_399), (23, 59, 59));
// Spot-check the formatted output shape.
let s = chrono_now();
assert_eq!(s.len(), 20);
assert!(s.ends_with('Z'));
assert!(s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-');
}
} }

View file

@ -133,27 +133,30 @@ impl ConfigState {
} }
pub fn handle_key(event: KeyEvent, state: &mut AppState) { pub fn handle_key(event: KeyEvent, state: &mut AppState) {
let cfg = &mut state.config;
match event.code { match event.code {
KeyCode::Char('j') | KeyCode::Down => { KeyCode::Char('j') | KeyCode::Down => {
cfg.scale_editing = false; state.clear_burst();
cfg.next_field(); state.config.scale_editing = false;
state.config.next_field();
} }
KeyCode::Char('k') | KeyCode::Up => { KeyCode::Char('k') | KeyCode::Up => {
cfg.scale_editing = false; state.clear_burst();
cfg.prev_field(); state.config.scale_editing = false;
state.config.prev_field();
} }
KeyCode::Tab => { KeyCode::Tab => {
cfg.scale_editing = false; state.clear_burst();
cfg.next_field(); state.config.scale_editing = false;
state.config.next_field();
} }
KeyCode::BackTab => { KeyCode::BackTab => {
cfg.scale_editing = false; state.clear_burst();
cfg.prev_field(); state.config.scale_editing = false;
state.config.prev_field();
} }
// Navigate between monitors // Navigate between monitors
KeyCode::Char('[') => { KeyCode::Char('[') => {
state.clear_burst();
let count = state.monitors.len(); let count = state.monitors.len();
if count > 0 { if count > 0 {
let new_idx = state.config.monitor_idx.checked_sub(1).unwrap_or(count - 1); let new_idx = state.config.monitor_idx.checked_sub(1).unwrap_or(count - 1);
@ -162,6 +165,7 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
} }
} }
KeyCode::Char(']') => { KeyCode::Char(']') => {
state.clear_burst();
let count = state.monitors.len(); let count = state.monitors.len();
if count > 0 { if count > 0 {
let new_idx = (state.config.monitor_idx + 1) % count; let new_idx = (state.config.monitor_idx + 1) % count;
@ -176,19 +180,20 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
crate::ui::layout_view::trigger_save(state); crate::ui::layout_view::trigger_save(state);
} }
KeyCode::Esc => { KeyCode::Esc => {
state.clear_burst();
state.config.scale_editing = false; state.config.scale_editing = false;
// Re-sync from live monitor to discard pending edits // Re-sync from live monitor to discard pending edits
let idx = state.config.monitor_idx; let idx = state.config.monitor_idx;
state.config.sync_from_monitor(idx, &state.monitors); state.config.sync_from_monitor(idx, &state.monitors);
} }
KeyCode::Enter => { KeyCode::Enter => {
state.clear_burst();
if state.config.current_field() == ConfigField::Scale { if state.config.current_field() == ConfigField::Scale {
commit_scale(state); commit_scale(state);
} }
apply_current(state); apply_current(state);
} }
_ => { _ => {
state.push_undo();
handle_field_key(event, state); handle_field_key(event, state);
} }
} }
@ -214,12 +219,10 @@ pub fn handle_mouse(event: MouseEvent, state: &mut AppState) {
} }
} }
MouseEventKind::ScrollUp => { MouseEventKind::ScrollUp => {
state.push_undo();
let fake_right = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE); let fake_right = KeyEvent::new(KeyCode::Right, crossterm::event::KeyModifiers::NONE);
handle_field_key(fake_right, state); handle_field_key(fake_right, state);
} }
MouseEventKind::ScrollDown => { MouseEventKind::ScrollDown => {
state.push_undo();
let fake_left = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE); let fake_left = KeyEvent::new(KeyCode::Left, crossterm::event::KeyModifiers::NONE);
handle_field_key(fake_left, state); handle_field_key(fake_left, state);
} }
@ -233,6 +236,8 @@ fn handle_field_key(event: KeyEvent, state: &mut AppState) {
return; return;
} }
let idx = state.config.monitor_idx.min(monitors_len - 1); let idx = state.config.monitor_idx.min(monitors_len - 1);
// Coalesce consecutive value cycles (and scroll) into one undo step.
state.micro_edit();
match state.config.current_field() { match state.config.current_field() {
ConfigField::Resolution => match event.code { ConfigField::Resolution => match event.code {
@ -394,9 +399,12 @@ fn commit_scale(state: &mut AppState) {
} }
fn apply_current(state: &mut AppState) { fn apply_current(state: &mut AppState) {
state.clear_burst();
state.pending_apply = true; state.pending_apply = true;
state.set_status("Applying...", StatusLevel::Info); state.set_status("Applying...", StatusLevel::Info);
state.dirty = false; // Don't clear `dirty` here: it must survive until the apply actually
// succeeds (main.rs clears it on a successful apply + save). Otherwise a
// failed `hyprctl` apply would silently drop the unsaved-changes guard.
} }
pub fn render(f: &mut Frame, area: Rect, state: &AppState) { pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
@ -432,7 +440,6 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
); );
let form_area = chunks[1]; let form_area = chunks[1];
let row_height = 1u16;
let fields = ConfigField::ALL; let fields = ConfigField::ALL;
let items: Vec<ListItem> = fields let items: Vec<ListItem> = fields
@ -454,7 +461,6 @@ pub fn render(f: &mut Frame, area: Rect, state: &AppState) {
}) })
.collect(); .collect();
let _ = row_height; // used implicitly via ListItem heights
let list = List::new(items).block( let list = List::new(items).block(
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)

View file

@ -23,35 +23,48 @@ pub fn handle_key(event: KeyEvent, state: &mut AppState) {
match event.code { match event.code {
KeyCode::Char('h') | KeyCode::Left => { KeyCode::Char('h') | KeyCode::Left => {
state.push_undo(); state.micro_edit();
move_selected(&state.layout, &mut state.monitors, -step, 0); move_selected(&state.layout, &mut state.monitors, -step, 0);
state.mark_dirty(); state.mark_dirty();
} }
KeyCode::Char('l') | KeyCode::Right => { KeyCode::Char('l') | KeyCode::Right => {
state.push_undo(); state.micro_edit();
move_selected(&state.layout, &mut state.monitors, step, 0); move_selected(&state.layout, &mut state.monitors, step, 0);
state.mark_dirty(); state.mark_dirty();
} }
KeyCode::Char('k') | KeyCode::Up => { KeyCode::Char('k') | KeyCode::Up => {
state.push_undo(); state.micro_edit();
move_selected(&state.layout, &mut state.monitors, 0, -step); move_selected(&state.layout, &mut state.monitors, 0, -step);
state.mark_dirty(); state.mark_dirty();
} }
KeyCode::Char('j') | KeyCode::Down => { KeyCode::Char('j') | KeyCode::Down => {
state.push_undo(); state.micro_edit();
move_selected(&state.layout, &mut state.monitors, 0, step); move_selected(&state.layout, &mut state.monitors, 0, step);
state.mark_dirty(); state.mark_dirty();
} }
KeyCode::Tab | KeyCode::Char('n') => state.layout.next(count), KeyCode::Tab | KeyCode::Char('n') => {
KeyCode::BackTab | KeyCode::Char('p') => state.layout.prev(count), state.clear_burst();
KeyCode::Char('[') => state.layout.zoom = (state.layout.zoom - 0.1).max(0.1), state.layout.next(count);
KeyCode::Char(']') => state.layout.zoom = (state.layout.zoom + 0.1).min(5.0), }
KeyCode::BackTab | KeyCode::Char('p') => {
state.clear_burst();
state.layout.prev(count);
}
KeyCode::Char('[') => {
state.clear_burst();
state.layout.zoom = (state.layout.zoom - 0.1).max(0.1);
}
KeyCode::Char(']') => {
state.clear_burst();
state.layout.zoom = (state.layout.zoom + 0.1).min(5.0);
}
KeyCode::Char('0') => { KeyCode::Char('0') => {
state.push_undo(); state.push_undo();
auto_arrange(&mut state.monitors); auto_arrange(&mut state.monitors);
state.mark_dirty(); state.mark_dirty();
} }
KeyCode::Enter => { KeyCode::Enter => {
state.clear_burst();
state state
.config .config
.sync_from_monitor(state.layout.selected, &state.monitors); .sync_from_monitor(state.layout.selected, &state.monitors);

View file

@ -114,6 +114,9 @@ pub struct AppState {
pub active_profile: Option<String>, pub active_profile: Option<String>,
/// Snapshots for Ctrl+Z undo (up to 20 deep). /// Snapshots for Ctrl+Z undo (up to 20 deep).
pub undo_stack: Vec<Vec<Monitor>>, pub undo_stack: Vec<Vec<Monitor>>,
/// True while a run of small incremental edits (nudges / value cycles)
/// is ongoing, so undo coalesces the whole burst into one snapshot.
undo_in_burst: bool,
} }
impl AppState { impl AppState {
@ -134,6 +137,7 @@ impl AppState {
pending_apply: false, pending_apply: false,
active_profile: None, active_profile: None,
undo_stack: Vec::new(), undo_stack: Vec::new(),
undo_in_burst: false,
} }
} }
@ -162,6 +166,7 @@ impl AppState {
} }
pub fn switch_tab(&mut self, tab: Tab) { pub fn switch_tab(&mut self, tab: Tab) {
self.undo_in_burst = false;
self.tab = tab; self.tab = tab;
if tab == Tab::Config { if tab == Tab::Config {
self.config self.config
@ -169,8 +174,32 @@ impl AppState {
} }
} }
/// Save a monitor snapshot for undo (max 20 entries). /// Save a monitor snapshot for undo (max 20 entries) and end any
/// in-progress edit burst.
pub fn push_undo(&mut self) { pub fn push_undo(&mut self) {
self.push_snapshot();
self.undo_in_burst = false;
}
/// Start (or continue) a run of small incremental edits. Only the first
/// edit in the run actually snapshots, so nudging a monitor 20 px (or
/// cycling a value repeatedly) collapses to a single undo step rather
/// than consuming 20 of the 20-step undo stack.
pub fn micro_edit(&mut self) {
if !self.undo_in_burst {
self.push_snapshot();
self.undo_in_burst = true;
}
}
/// End a coalesced-edit burst without snapping. Called on navigation
/// (tab switches, monitor/field changes, zoom) so bursts don't bleed
/// across distinct actions.
pub fn clear_burst(&mut self) {
self.undo_in_burst = false;
}
fn push_snapshot(&mut self) {
self.undo_stack.push(self.monitors.clone()); self.undo_stack.push(self.monitors.clone());
if self.undo_stack.len() > 20 { if self.undo_stack.len() > 20 {
self.undo_stack.remove(0); self.undo_stack.remove(0);
@ -181,6 +210,7 @@ impl AppState {
pub fn undo(&mut self) { pub fn undo(&mut self) {
if let Some(snapshot) = self.undo_stack.pop() { if let Some(snapshot) = self.undo_stack.pop() {
self.monitors = snapshot; self.monitors = snapshot;
self.undo_in_burst = false;
self.mark_dirty(); self.mark_dirty();
self.layout.clamp_selected(self.monitors.len()); self.layout.clamp_selected(self.monitors.len());
// Re-sync config view to the restored state // Re-sync config view to the restored state