cmd_view.v

7.1 KB · raw

1//! `koh view` — the history, browsed or printed.2//!3//! On a terminal it opens the history view on the saves tab, or on the4//! checkpoints tab with `--checkpoints`; piped, filtered, or with `--json` it5//! prints the line instead, so an agent always gets something it can parse.67module cli89import json210import os11import time12import src.events13import src.fmt14import src.history15import src.storage16import src.view1718// cmd_view is the one way into the history. What it prints is the whole line,19// checkpoints included; `--checkpoints` narrows both the view and the print to20// the checkpoints.21pub fn cmd_view(raw []string) int {22	args := parse('view', raw, Options{23		values: ['since', 'session']24		flags:  ['checkpoints', 'json']25	}) or { return usage_error(err.msg()) }26	if args.positional.len > 0 {27		return usage_error('view takes no arguments, not "${args.positional_at(0)}"')28	}29	checkpoints := args.flag('checkpoints')30	view_tab := if checkpoints { view.Tab.checkpoints } else { view.Tab.saves }31	print_tab := if checkpoints { view.Tab.checkpoints } else { view.Tab.all }32	store := open_store() or { return refuse(args.json(), err.msg()) }33	defer {34		store.close()35	}36	// The view has its own search but no date filter, so a filtered list prints37	// plain text even on a terminal rather than quietly ignoring the filter.38	filtered := args.value('since') != '' || args.value('session') != ''39	if !args.json() && !filtered && on_a_terminal() {40		return history_view(store, view_tab)41	}42	mut cutoff := i64(0)43	if expr := args.value_or_none('since') {44		cutoff = parse_since(expr) or { return refuse(args.json(), err.msg()) }45	}46	session := args.value('session')47	head_id := history.head_id(store)48	flags := events.latest_flags(store) or {49		map[string]events.Flag{}50	}51	mut shown := []history.Save{}52	for save in line_for_tab(store, print_tab) or { return refuse(args.json(), err.msg()) } {53		if cutoff > 0 && save.created_at < cutoff {54			continue55		}56		if session != '' && save.session != session {57			continue58		}59		shown << save60	}61	if args.json() {62		emit_json(json2.encode(LogJson{63			head_id: head_id64			saves:   shown.map(to_json(it))65		}))66		return 067	}68	if shown.len == 0 {69		ok('no saves in that range')70		return 071	}72	for save in shown {73		println(log_line(save, save.id == head_id, flags))74	}75	return 076}7778// line_for_tab picks the saves a tab shows: `all` is the whole line, `saves`79// leaves checkpoints out, and `checkpoints` keeps only them, so the two lists80// never overlap.81fn line_for_tab(store &storage.Store, tab view.Tab) ![]history.Save {82	line := history.line(store)!83	if tab == .all {84		return line85	}86	want_checkpoints := tab == .checkpoints87	return line.filter(it.is_checkpoint == want_checkpoints)88}8990fn history_view(store &storage.Store, tab view.Tab) int {91	view.run(store, tab, history_view_action) or { return fail(err.msg()) }92	return 093}9495// history_view_action is what the view calls when the user picks an action.96// The view presents; this changes history.97fn history_view_action(store &storage.Store, action view.Action) string {98	match action.kind {99		.load {100			target := history.by_id(store, action.save_id) or {101				return 'no such save: ${action.save_id}'102			}103			restore_now(store, target, 'load') or { return 'could not load: ${err.msg()}' }104			return 'loaded [${action.save_id}]'105		}106		.flag {107			events.flag(store, action.save_id, action.reason) or {108				return 'could not flag: ${err.msg()}'109			}110			return 'flagged [${action.save_id}]'111		}112		.clear_flag {113			events.clear_flag(store, action.save_id) or { return 'could not clear: ${err.msg()}' }114			return 'cleared the flag on [${action.save_id}]'115		}116		.none {117			return ''118		}119	}120}121122fn on_a_terminal() bool {123	return os.is_atty(0) != 0 && os.is_atty(1) != 0124}125126struct SaveJson {127	// Every field is always present, so a consumer never has to tell "absent"128	// from "missing key". An empty string means the save recorded none.129	id            string130	parent        string131	message       string132	timestamp     i64133	machine       string134	agent         string135	session       string136	is_checkpoint bool137	restores      string138	replayed_from string139}140141struct LogJson {142	head_id string143	saves   []SaveJson144}145146fn to_json(save history.Save) SaveJson {147	return SaveJson{148		id:            save.id149		parent:        save.parent150		message:       save.message151		timestamp:     save.created_at152		machine:       save.machine153		agent:         save.agent154		session:       save.session155		is_checkpoint: save.is_checkpoint156		restores:      save.restores157		replayed_from: save.replayed_from158	}159}160161fn log_line(save history.Save, is_head bool, flags map[string]events.Flag) string {162	marker := if is_head { fmt.amber('▸') } else { ' ' }163	mut line := '  ${marker} ${fmt.dim('[' + save.id + ']')} ${fmt.stamp(save.created_at)}  ${save.message}'164	mut tags := []string{}165	if save.agent != '' || save.session != '' {166		mut inner := []string{}167		if save.agent != '' {168			inner << save.agent169		}170		if save.session != '' {171			inner << save.session172		}173		tags << '[${inner.join(' · ')}]'174	}175	if save.is_checkpoint {176		tags << '◐'177	}178	if (flags[save.id] or {179		events.Flag{180			save_id: save.id181		}182	}).flagged {183		tags << '⚑'184	}185	if save.restores != '' {186		tags << '↩ ${save.restores}'187	}188	if save.replayed_from != '' {189		tags << 'replayed'190	}191	if tags.len > 0 {192		line += '  ${fmt.dim(tags.join('  '))}'193	}194	return line195}196197// parse_since turns the natural-language expressions Koh has always accepted198// into a unix timestamp.199pub fn parse_since(expr string) !i64 {200	now := time.now().unix()201	text := expr.trim_space().to_lower()202	match text {203		'today' { return start_of_day(now) }204		'yesterday' { return start_of_day(now) - 86400 }205		'last week' { return now - 7 * 86400 }206		'last month' { return now - 30 * 86400 }207		'last year' { return now - 365 * 86400 }208		else {}209	}210211	if date := parse_date(text) {212		return date213	}214	parts := text.split(' ')215	if parts.len >= 2 {216		amount := parts[0].int()217		if amount > 0 {218			if unit := unit_seconds(parts[1]) {219				return now - i64(amount) * unit220			}221		}222		return error('cannot understand "--since ${expr}"')223	}224	days := text.int()225	if days > 0 {226		return now - i64(days) * 86400227	}228	return error('cannot understand "--since ${expr}"')229}230231fn unit_seconds(unit string) ?i64 {232	return match unit {233		'm', 'min', 'mins', 'minute', 'minutes' { i64(60) }234		'h', 'hr', 'hrs', 'hour', 'hours' { i64(3600) }235		'd', 'day', 'days' { i64(86400) }236		'w', 'week', 'weeks' { i64(7 * 86400) }237		'month', 'months' { i64(30 * 86400) }238		'y', 'year', 'years' { i64(365 * 86400) }239		else { none }240	}241}242243fn parse_date(text string) ?i64 {244	parts := text.split('-')245	if parts.len != 3 || parts[0].len != 4 {246		return none247	}248	if parts[0].int() == 0 || parts[1].int() == 0 || parts[2].int() == 0 {249		return none250	}251	return midnight(parts[0].int(), parts[1].int(), parts[2].int())252}253254fn start_of_day(unix_seconds i64) i64 {255	moment := time.unix(unix_seconds).local()256	return midnight(moment.year, moment.month, moment.day)257}258259// midnight builds a local-time timestamp for a calendar day.260fn midnight(year int, month int, day int) i64 {261	return time.Time{262		year:     year263		month:    month264		day:      day265		is_local: true266	}.unix()267}