//! `koh view` — the history, browsed or printed. //! //! On a terminal it opens the history view on the saves tab, or on the //! checkpoints tab with `--checkpoints`; piped, filtered, or with `--json` it //! prints the line instead, so an agent always gets something it can parse. module cli import json2 import os import time import src.events import src.fmt import src.history import src.storage import src.view // cmd_view is the one way into the history. What it prints is the whole line, // checkpoints included; `--checkpoints` narrows both the view and the print to // the checkpoints. pub fn cmd_view(raw []string) int { args := parse('view', raw, Options{ values: ['since', 'session'] flags: ['checkpoints', 'json'] }) or { return usage_error(err.msg()) } if args.positional.len > 0 { return usage_error('view takes no arguments, not "${args.positional_at(0)}"') } checkpoints := args.flag('checkpoints') view_tab := if checkpoints { view.Tab.checkpoints } else { view.Tab.saves } print_tab := if checkpoints { view.Tab.checkpoints } else { view.Tab.all } store := open_store() or { return refuse(args.json(), err.msg()) } defer { store.close() } // The view has its own search but no date filter, so a filtered list prints // plain text even on a terminal rather than quietly ignoring the filter. filtered := args.value('since') != '' || args.value('session') != '' if !args.json() && !filtered && on_a_terminal() { return history_view(store, view_tab) } mut cutoff := i64(0) if expr := args.value_or_none('since') { cutoff = parse_since(expr) or { return refuse(args.json(), err.msg()) } } session := args.value('session') head_id := history.head_id(store) flags := events.latest_flags(store) or { map[string]events.Flag{} } mut shown := []history.Save{} for save in line_for_tab(store, print_tab) or { return refuse(args.json(), err.msg()) } { if cutoff > 0 && save.created_at < cutoff { continue } if session != '' && save.session != session { continue } shown << save } if args.json() { emit_json(json2.encode(LogJson{ head_id: head_id saves: shown.map(to_json(it)) })) return 0 } if shown.len == 0 { ok('no saves in that range') return 0 } for save in shown { println(log_line(save, save.id == head_id, flags)) } return 0 } // line_for_tab picks the saves a tab shows: `all` is the whole line, `saves` // leaves checkpoints out, and `checkpoints` keeps only them, so the two lists // never overlap. fn line_for_tab(store &storage.Store, tab view.Tab) ![]history.Save { line := history.line(store)! if tab == .all { return line } want_checkpoints := tab == .checkpoints return line.filter(it.is_checkpoint == want_checkpoints) } fn history_view(store &storage.Store, tab view.Tab) int { view.run(store, tab, history_view_action) or { return fail(err.msg()) } return 0 } // history_view_action is what the view calls when the user picks an action. // The view presents; this changes history. fn history_view_action(store &storage.Store, action view.Action) string { match action.kind { .load { target := history.by_id(store, action.save_id) or { return 'no such save: ${action.save_id}' } restore_now(store, target, 'load') or { return 'could not load: ${err.msg()}' } return 'loaded [${action.save_id}]' } .flag { events.flag(store, action.save_id, action.reason) or { return 'could not flag: ${err.msg()}' } return 'flagged [${action.save_id}]' } .clear_flag { events.clear_flag(store, action.save_id) or { return 'could not clear: ${err.msg()}' } return 'cleared the flag on [${action.save_id}]' } .none { return '' } } } fn on_a_terminal() bool { return os.is_atty(0) != 0 && os.is_atty(1) != 0 } struct SaveJson { // Every field is always present, so a consumer never has to tell "absent" // from "missing key". An empty string means the save recorded none. id string parent string message string timestamp i64 machine string agent string session string is_checkpoint bool restores string replayed_from string } struct LogJson { head_id string saves []SaveJson } fn to_json(save history.Save) SaveJson { return SaveJson{ id: save.id parent: save.parent message: save.message timestamp: save.created_at machine: save.machine agent: save.agent session: save.session is_checkpoint: save.is_checkpoint restores: save.restores replayed_from: save.replayed_from } } fn log_line(save history.Save, is_head bool, flags map[string]events.Flag) string { marker := if is_head { fmt.amber('▸') } else { ' ' } mut line := ' ${marker} ${fmt.dim('[' + save.id + ']')} ${fmt.stamp(save.created_at)} ${save.message}' mut tags := []string{} if save.agent != '' || save.session != '' { mut inner := []string{} if save.agent != '' { inner << save.agent } if save.session != '' { inner << save.session } tags << '[${inner.join(' · ')}]' } if save.is_checkpoint { tags << '◐' } if (flags[save.id] or { events.Flag{ save_id: save.id } }).flagged { tags << '⚑' } if save.restores != '' { tags << '↩ ${save.restores}' } if save.replayed_from != '' { tags << 'replayed' } if tags.len > 0 { line += ' ${fmt.dim(tags.join(' '))}' } return line } // parse_since turns the natural-language expressions Koh has always accepted // into a unix timestamp. pub fn parse_since(expr string) !i64 { now := time.now().unix() text := expr.trim_space().to_lower() match text { 'today' { return start_of_day(now) } 'yesterday' { return start_of_day(now) - 86400 } 'last week' { return now - 7 * 86400 } 'last month' { return now - 30 * 86400 } 'last year' { return now - 365 * 86400 } else {} } if date := parse_date(text) { return date } parts := text.split(' ') if parts.len >= 2 { amount := parts[0].int() if amount > 0 { if unit := unit_seconds(parts[1]) { return now - i64(amount) * unit } } return error('cannot understand "--since ${expr}"') } days := text.int() if days > 0 { return now - i64(days) * 86400 } return error('cannot understand "--since ${expr}"') } fn unit_seconds(unit string) ?i64 { return match unit { 'm', 'min', 'mins', 'minute', 'minutes' { i64(60) } 'h', 'hr', 'hrs', 'hour', 'hours' { i64(3600) } 'd', 'day', 'days' { i64(86400) } 'w', 'week', 'weeks' { i64(7 * 86400) } 'month', 'months' { i64(30 * 86400) } 'y', 'year', 'years' { i64(365 * 86400) } else { none } } } fn parse_date(text string) ?i64 { parts := text.split('-') if parts.len != 3 || parts[0].len != 4 { return none } if parts[0].int() == 0 || parts[1].int() == 0 || parts[2].int() == 0 { return none } return midnight(parts[0].int(), parts[1].int(), parts[2].int()) } fn start_of_day(unix_seconds i64) i64 { moment := time.unix(unix_seconds).local() return midnight(moment.year, moment.month, moment.day) } // midnight builds a local-time timestamp for a calendar day. fn midnight(year int, month int, day int) i64 { return time.Time{ year: year month: month day: day is_local: true }.unix() }