Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ cargo run -p openwith-cli -- set -s http Firefox # set default browser
cargo run -p openwith-cli -- export -o out.toml # export associations to TOML
cargo run -p openwith-cli -- import --dry-run out.toml # preview an import
cargo run -p openwith-cli -- import out.toml # import associations from TOML
cargo run -p openwith-cli -- history # recent changes from CLI + GUI (--json)
cargo run -p openwith-cli -- history # recent changes, last 7 days (--days N, --all, --json)
cargo run -p openwith-cli -- undo # revert the most recent change
cargo check # quick compile check
cargo test # run tests
Expand Down Expand Up @@ -62,7 +62,7 @@ crates/
set.rs -- `openwith set <ext> <app>` with name/bundle-ID resolution
export.rs -- `openwith export` dump associations + schemes to TOML
import.rs -- `openwith import` apply TOML (idempotent, `--dry-run`)
history.rs -- `openwith history` list recent events (relative dates, --json)
history.rs -- `openwith history` list recent events (relative dates, --days/--all, --json)
undo.rs -- `openwith undo` revert last set (drift check, --force)
tui.rs -- ratatui TUI: Extensions + Apps tabs, loading screen, AppPicker + Help
openwith-gui/ -- Tauri v2 GUI ("OpenWith.app")
Expand Down Expand Up @@ -92,7 +92,8 @@ crates/
- Loading screen enters TUI alternate screen immediately, shows ASCII logo + spinner while scanning in background.
- Export/import uses serde + toml crate with `BTreeMap<String, String>` for sorted, human-readable TOML; import validates apps exist and skips associations already set correctly.
- GUI: single `get_snapshot` command returns apps + associations (with sibling-UTI conflict data) + contested schemes in one call; the frontend is a plain render-to-innerHTML loop with `data-action` event delegation, no framework. Versions are lockstep: `tauri.conf.json` omits `version` so the app version comes from `workspace.package` in the root Cargo.toml.
- `openwith-core::history` is the shared change log (capped at 500 events, best-effort writes that never fail the triggering change). CLI, GUI, and core import all record into it; the GUI Profiles panel shows export/import events, the menu-bar popover shows set events with per-entry Undo, and `openwith history`/`openwith undo` read the same file.
- `openwith-core::history` is the shared change log (capped at 500 events / 90 days, best-effort writes that never fail the triggering change). CLI, GUI, and core import all record into it; the GUI Profiles panel shows export/import events, the menu-bar popover shows set events with per-entry Undo, and `openwith history`/`openwith undo` read the same file.
- History retention is a **display window, not deletion**. The 90-day/500-event prune in `record_at` is only a ledger backstop and runs on *write*, so a dormant install would otherwise show months-old rows forever — the window is therefore applied on **read**, via `history::recent_within`. Default is 7 days (`DEFAULT_WINDOW_DAYS`), settable in Settings → Behavior → "Show history for" (1 week / 1 month / All — the ledger's own 90-day cap makes a "3 months" option redundant; persisted as `historyWindowDays` in localStorage and shared by both windows) and via `openwith history --days N` / `--all`. The Profiles HISTORY panel head shows the active window plus a session-only "Show all" toggle (`state.historyShowAll`, deliberately not persisted). `undo_change` and `openwith undo` keep reading the *unwindowed* ledger (`history::recent`) so a hidden event stays revertible.
- The GUI is two windows off one Vite bundle: `main` and a hidden transparent `menubar` popover (requires `macOSPrivateApi: true`). The popover hides on blur and is toggled by the tray icon or a configurable global shortcut (default ⌥⌘O; `set_toggle_shortcut` swaps the registration at runtime, the saved accelerator is re-applied at bootstrap). A **Pin** button suspends hide-on-blur for one showing (backend `PopoverPinned` AtomicBool, reset on every toggle) so a file can be dragged in from Finder — without it the click into Finder blurs and hides the panel. Focus events are unreliable for the transparent panel, so the backend emits `popover-shown` on every open and the popover refreshes from that, not just from focus. A backend `AppsCache` (refreshed by `get_snapshot`) keeps popover lookups instant.
- App icons come from the 2026-07 logo renders in `artifacts/` (`logo-mono-glyph.png`, `logo-icon-dark.png`, `logo-icon-light.png` — AI renders with **no alpha**, so masking is scripted, not manual): light → 1024 master → `tauri icon` set; dark → `icons/icon-dark.png`; mono glyph → `icons/tray-template.png` (64×44 black+alpha template, glyph ≈34px tall). The Dock icon follows the app's *resolved* appearance at runtime — `set_dock_icon_dark` swaps `NSApplication.applicationIconImage` between `icon.png`/`icon-dark.png` (macOS only re-renders bundle icons for the system appearance), invoked from `theme.ts` on every theme change. `icon.icns` is **hand-built**: the ≤64px slots use a legibility variant (header dots inpainted away, no shadow, larger glyph), so rerunning `tauri icon` clobbers it. All of these regenerate with `uv run scripts/gen-icons.py` (run it *after* any `tauri icon` invocation; it prints the `tauri icon` command for the PNG set).
- GUI settings live in localStorage (`openwith.settings`). The Settings pane mirrors the design prototype's full layout; controls whose feature ships in a later 0.5.x phase (launch at login, menu bar) render disabled with an "arrives in v0.5.1" note rather than as silently-dead toggles.
Expand Down Expand Up @@ -174,6 +175,7 @@ Run against the built .app (not just `tauri dev`) before tagging any release wit
- [ ] With the CLI upgraded via brew while the app runs: close and reopen Settings — the Command Line panel shows the new version without an app relaunch
- [ ] Profiles: export; import via choose AND drag-drop; dry-run preview; apply; dismiss
- [ ] History panel scrolls at 50 entries and updates after changes
- [ ] History window: default shows only the last 7 days in both the Profiles panel and the popover; "Show all" reveals older rows and toggles back; switching the Settings segment (1 week/1 month/All) refetches both surfaces; the popover follows a change made in the main window without a relaunch
- [ ] Check Now (updates) reports a sensible result on both channels
- [ ] README screenshots (from the design prototype, `artifacts/gui-*.png`) still match the shipped UI — recapture if the UI changed

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ openwith set md Typora # Set Typora as default for .md
openwith set md abnerworks.Typora # Bundle IDs work too
openwith current -s http # Show the default browser
openwith set -s http Firefox # Set the default browser
openwith history # Recent changes (recorded by CLI and GUI alike)
openwith history # Recent changes, last 7 days (recorded by CLI and GUI alike)
openwith history --all # Everything still retained (90 days / 500 events)
openwith undo # Revert the most recent change
```

Expand Down
8 changes: 7 additions & 1 deletion crates/openwith-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ MANAGE
openwith set <ext> <app> Set the default app (name or bundle ID)
openwith current -s http Show the handler for a URL scheme
openwith set -s http <app> Set the handler for a URL scheme
openwith history Show recent changes (--json for scripts)
openwith history Show recent changes, last 7 days (--all, --json)
openwith undo Revert the most recent change

CONFIG
Expand Down Expand Up @@ -106,6 +106,12 @@ pub enum Commands {
/// Maximum number of events to show
#[arg(short = 'n', long, default_value_t = 20)]
limit: usize,
/// Only show events from the last N days
#[arg(short = 'd', long, default_value_t = openwith_core::history::DEFAULT_WINDOW_DAYS)]
days: u64,
/// Show every retained event, ignoring --days
#[arg(long, conflicts_with = "days")]
all: bool,
/// Print JSON
#[arg(long)]
json: bool,
Expand Down
15 changes: 12 additions & 3 deletions crates/openwith-cli/src/commands/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ use anyhow::Result;
use openwith_core::history::{self, HistoryEvent};
use openwith_core::scanner;

pub fn run(limit: usize, json: bool) -> Result<()> {
let events = history::recent(limit)?;
/// `window_days` bounds how far back events are shown; `None` is `--all`.
pub fn run(limit: usize, window_days: Option<u64>, json: bool) -> Result<()> {
let events = history::recent_within(limit, window_days)?;

if json {
let out: Vec<serde_json::Value> = events
Expand All @@ -28,7 +29,15 @@ pub fn run(limit: usize, json: bool) -> Result<()> {
}

if events.is_empty() {
println!("No history yet — changes made by the CLI or GUI will appear here.");
match window_days {
// The ledger may still hold older events — say so rather than
// implying nothing was ever recorded.
Some(days) => println!(
"No changes in the last {days} day{} — use --all to see everything retained.",
if days == 1 { "" } else { "s" }
),
None => println!("No history yet — changes made by the CLI or GUI will appear here."),
}
return Ok(());
}

Expand Down
9 changes: 7 additions & 2 deletions crates/openwith-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,13 @@ fn main() -> Result<()> {
Some(cli::Commands::Apps) => {
commands::tui::run(commands::tui::InitialView::Apps)?;
}
Some(cli::Commands::History { limit, json }) => {
commands::history::run(limit, json)?;
Some(cli::Commands::History {
limit,
days,
all,
json,
}) => {
commands::history::run(limit, if all { None } else { Some(days) }, json)?;
}
Some(cli::Commands::Undo { force }) => {
commands::undo::run(force)?;
Expand Down
79 changes: 78 additions & 1 deletion crates/openwith-core/src/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,20 @@ use serde::{Deserialize, Serialize};
/// Keep the log bounded; older events fall off the front.
const MAX_EVENTS: usize = 500;

/// Events older than this are pruned on every write.
/// Events older than this are pruned on every write. This is the ledger's
/// hard ceiling, not the default view: surfaces apply their own, much shorter
/// display window (see `DEFAULT_WINDOW_DAYS`) on read.
const MAX_AGE_SECS: u64 = 90 * 24 * 60 * 60;

/// Default display window, in days. Changing a default is a "did I just break
/// my PDFs?" action — the useful lookback is days, not months, so every
/// surface (Profiles panel, popover, `openwith history`) shows this much
/// unless the user widens it.
pub const DEFAULT_WINDOW_DAYS: u64 = 7;

/// Seconds in a day, for turning a window in days into a cutoff.
pub const DAY_SECS: u64 = 24 * 60 * 60;

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub struct HistoryEvent {
/// "set" | "set_scheme" | "export" | "import"
Expand Down Expand Up @@ -75,10 +86,24 @@ pub fn record(event: HistoryEvent) -> Result<()> {
}

/// Newest-first slice of the default history file. Missing file → empty.
///
/// Unwindowed: this is the ledger view, used by undo lookups that must still
/// find an event the display window has scrolled past. Display surfaces want
/// [`recent_within`] instead.
pub fn recent(limit: usize) -> Result<Vec<HistoryEvent>> {
recent_at(&history_path()?, limit)
}

/// Newest-first slice restricted to the last `window_days` days. `None` keeps
/// everything the ledger still holds.
///
/// The window has to be enforced here rather than only in `record_at`: pruning
/// happens on write, so an install that hasn't changed anything in months
/// would otherwise keep showing months-old rows.
pub fn recent_within(limit: usize, window_days: Option<u64>) -> Result<Vec<HistoryEvent>> {
recent_within_at(&history_path()?, limit, window_days)
}

fn load(path: &Path) -> Vec<HistoryEvent> {
// A missing or corrupt log starts fresh rather than blocking changes.
std::fs::read_to_string(path)
Expand All @@ -104,7 +129,19 @@ pub fn record_at(path: &Path, event: HistoryEvent) -> Result<()> {
}

pub fn recent_at(path: &Path, limit: usize) -> Result<Vec<HistoryEvent>> {
recent_within_at(path, limit, None)
}

pub fn recent_within_at(
path: &Path,
limit: usize,
window_days: Option<u64>,
) -> Result<Vec<HistoryEvent>> {
let mut events = load(path);
if let Some(days) = window_days {
let cutoff = now_secs().saturating_sub(days.saturating_mul(DAY_SECS));
events.retain(|e| e.timestamp >= cutoff);
}
events.reverse();
events.truncate(limit);
Ok(events)
Expand Down Expand Up @@ -269,6 +306,46 @@ mod tests {
std::fs::remove_file(&path).unwrap();
}

#[test]
fn display_window_filters_on_read() {
let path = temp_log("window");
let _ = std::fs::remove_file(&path);

// Seeded directly: a dormant install never calls record_at, which is
// exactly the case the read-side window has to cover.
let old = event("set", ".old", now_secs() - 30 * DAY_SECS);
let fresh = event("set", ".fresh", now_secs() - 2 * DAY_SECS);
std::fs::write(&path, serde_json::to_string(&vec![old, fresh]).unwrap()).unwrap();

let windowed = recent_within_at(&path, 10, Some(DEFAULT_WINDOW_DAYS)).unwrap();
assert_eq!(windowed.len(), 1);
assert_eq!(windowed[0].key, ".fresh");

// None keeps everything the ledger still holds...
assert_eq!(recent_within_at(&path, 10, None).unwrap().len(), 2);
// ...and a wide enough window is equivalent.
assert_eq!(recent_within_at(&path, 10, Some(90)).unwrap().len(), 2);
// The unwindowed ledger view (undo lookups) still sees the old event.
assert_eq!(recent_at(&path, 10).unwrap().len(), 2);

std::fs::remove_file(&path).unwrap();
}

#[test]
fn window_does_not_delete_anything() {
let path = temp_log("window-nondestructive");
let _ = std::fs::remove_file(&path);

let old = event("set", ".old", now_secs() - 30 * DAY_SECS);
std::fs::write(&path, serde_json::to_string(&vec![old]).unwrap()).unwrap();

assert!(recent_within_at(&path, 10, Some(7)).unwrap().is_empty());
// Reading through a narrow window must not rewrite the file.
assert_eq!(recent_at(&path, 10).unwrap().len(), 1);

std::fs::remove_file(&path).unwrap();
}

#[test]
fn old_events_are_pruned_on_write() {
let path = temp_log("prune");
Expand Down
13 changes: 10 additions & 3 deletions crates/openwith-gui/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,18 @@ pub fn get_ext_picker(

/// Recent set events for the popover's Recent Changes list, names resolved.
/// Undo-stack view: undone changes and the reverts themselves are hidden.
///
/// `window_days` is the caller's display window (`None` = everything retained);
/// it is applied before the undo-stack filter so a quiet week shows an empty
/// panel rather than months-old rows.
#[tauri::command]
pub fn get_recent_changes(
limit: usize,
window_days: Option<u64>,
cache: State<'_, AppsCache>,
) -> Result<Vec<RecentChangeDto>, String> {
let apps = cached_apps(&cache)?;
let events = history::recent(100).map_err(|e| e.to_string())?;
let events = history::recent_within(100, window_days).map_err(|e| e.to_string())?;
Ok(events
.into_iter()
.filter(|e| matches!(e.kind.as_str(), "set" | "set_scheme") && !e.undone && !e.is_undo)
Expand Down Expand Up @@ -389,14 +394,16 @@ pub struct HistoryEventDto {
pub is_undo: bool,
}

/// Full ledger for the Profiles HISTORY panel, bundle IDs resolved to names.
/// Ledger for the Profiles HISTORY panel, bundle IDs resolved to names,
/// restricted to `window_days` (`None` = everything retained).
#[tauri::command]
pub fn get_history(
limit: usize,
window_days: Option<u64>,
cache: State<'_, AppsCache>,
) -> Result<Vec<HistoryEventDto>, String> {
let apps = cached_apps(&cache)?;
let events = history::recent(limit).map_err(|e| e.to_string())?;
let events = history::recent_within(limit, window_days).map_err(|e| e.to_string())?;
Ok(events
.into_iter()
.map(|e| HistoryEventDto {
Expand Down
8 changes: 4 additions & 4 deletions crates/openwith-gui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,14 @@ export const api = {
invoke<ExportResultDto>("export_toml", { path }),
importToml: (path: string, dryRun: boolean) =>
invoke<ImportPreviewDto>("import_toml", { path, dryRun }),
getHistory: (limit: number) =>
invoke<HistoryEventDto[]>("get_history", { limit }),
getHistory: (limit: number, windowDays: number | null) =>
invoke<HistoryEventDto[]>("get_history", { limit, windowDays }),
searchExtensions: (query: string) =>
invoke<ExtMatchDto[]>("search_extensions", { query }),
getExtPicker: (ext: string) =>
invoke<PickerAppDto[]>("get_ext_picker", { ext }),
getRecentChanges: (limit: number) =>
invoke<RecentChangeDto[]>("get_recent_changes", { limit }),
getRecentChanges: (limit: number, windowDays: number | null) =>
invoke<RecentChangeDto[]>("get_recent_changes", { limit, windowDays }),
undoChange: (kind: string, key: string, timestamp: number) =>
invoke<SetResultDto>("undo_change", { kind, key, timestamp }),
showMainWindow: () => invoke<void>("show_main_window"),
Expand Down
Loading
Loading