view.v

19.4 KB · raw

1//! The history view: the saves and checkpoints list, full screen.2//!3//! On a terminal `koh view` opens this instead of printing; `--checkpoints`4//! opens it on the checkpoints tab. The look and the keys are Koh 2's5//! (`src/commands/history.zig`): a tab bar, evenly spaced entries between6//! separators, and a status bar with the hints for the current mode.7//!8//! The view only reads — history, flags and diffs — and hands any decision back9//! to the caller as an `Action`, so the command layer stays the only thing that10//! changes anything. `frame_lines` turns a `Session` into the rows of one frame,11//! which keeps the interesting part testable without a terminal.1213module view1415import encoding.base6416import os17import strings18import src.events19import src.fmt20import src.history21import src.snapshot22import src.storage2324pub enum Tab {25	all26	saves27	checkpoints28}2930pub enum ActionKind {31	none32	load33	flag34	clear_flag35}3637pub struct Action {38pub mut:39	kind    ActionKind40	save_id string41	reason  string42}4344// TabCounts is what the tab bar shows: how many saves are in each list before45// the search narrows anything.46pub struct TabCounts {47pub mut:48	all         int49	saves       int50	checkpoints int51}5253// Screen is what the list shows.54pub struct Screen {55pub mut:56	tab      Tab57	items    []history.Save58	counts   TabCounts59	selected int60	query    string61	status   string62	head     string63	flags    map[string]events.Flag64	on_line  map[string]bool65	rows     int66	cols     int67}6869enum Mode {70	list71	search72	detail73	reason74	confirm75}7677// Session is everything the event loop changes: what is on screen, which mode78// it is in, and what the current action is about.79struct Session {80mut:81	screen  Screen82	mode    Mode83	target  history.Save84	typed   string85	pending Action86	detail  []string87	// set when an action changed history, so the lists are read again88	stale bool89	// the detail is worked out once per save rather than per key90	detail_for string91}9293// run shows the view until the user leaves it. `act` performs a confirmed94// action and returns the message for the status bar, so the view itself never95// changes history.96pub fn run(store &storage.Store, start Tab, act fn (store &storage.Store, action Action) string) ! {97	enter_screen()98	defer {99		leave_screen()100	}101	mut every := []history.Save{}102	mut session := Session{103		screen: Screen{104			tab: start105		}106		stale:  true107	}108	for {109		if session.stale {110			// A load appends a save and a flag changes a mark: read both again.111			every = history.all_saves(store)!112			session.screen.head = history.head_id(store)113			session.screen.on_line = on_line_set(store)114			session.screen.flags = events.latest_flags(store)!115			session.screen.counts = count_tabs(every, session.screen.on_line)116			session.detail_for = ''117			session.stale = false118		}119		session.screen.rows, session.screen.cols = terminal_size()120		session.screen.items = filter_saves(every, session.screen.tab, session.screen.on_line,121			session.screen.query)122		session.screen.selected = move_selection(session.screen.selected, 0, session.screen.items.len)123		if session.mode in [.detail, .reason, .confirm] && session.detail_for != session.target.id {124			session.detail = detail_lines(session.target, session.screen.head, diff_of(store,125				session.target), session.screen.flags[session.target.id] or { events.Flag{} })126			session.detail_for = session.target.id127		}128		paint(render(frame_lines(session)))129		// A status message is shown for one frame, as in Koh 2.130		session.screen.status = ''131		key := read_key()132		if key.kind in [.interrupt, .eof] {133			return134		}135		if handle_key(mut session, store, key, act) {136			return137		}138	}139}140141// handle_key applies one key and reports whether to leave the view.142fn handle_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {143	return match session.mode {144		.list { list_key(mut session, key) }145		.search { search_key(mut session, key) }146		.detail { detail_key(mut session, key) }147		.reason { reason_key(mut session, key) }148		.confirm { confirm_key(mut session, store, key, act) }149	}150}151152// list_key: Tab switches tabs; up/down and j/k move; g is the oldest save, G the newest (the list is newest153// first); Enter opens the detail; / searches; c copies the ID; q leaves, and Esc drops a kept filter before154// it leaves. Only selection, tab, query and mode change here.155fn list_key(mut session Session, key Key) bool {156	count := session.screen.items.len157	match key.kind {158		.tab {159			session.screen.tab = next_tab(session.screen.tab)160			session.screen.selected = 0161		}162		.down {163			session.screen.selected = move_selection(session.screen.selected, 1, count)164		}165		.up {166			session.screen.selected = move_selection(session.screen.selected, -1, count)167		}168		.enter {169			if count > 0 {170				session.target = session.screen.items[session.screen.selected]171				session.mode = .detail172			}173		}174		// Esc goes back one step: a kept search filter first, then the view.175		.escape {176			if session.screen.query == '' {177				return true178			}179			session.screen.query = ''180			session.screen.selected = 0181		}182		.rune {183			match key.text {184				'q' {185					return true186				}187				'j' {188					session.screen.selected = move_selection(session.screen.selected, 1, count)189				}190				'k' {191					session.screen.selected = move_selection(session.screen.selected, -1,192						count)193				}194				// The list is newest first: g is the oldest save, G the newest.195				'g' {196					session.screen.selected = move_selection(count, -1, count)197				}198				'G' {199					session.screen.selected = 0200				}201				'/' {202					session.mode = .search203				}204				'c' {205					if count > 0 {206						copy_selected(mut session.screen,207							session.screen.items[session.screen.selected].id)208					}209				}210				else {}211			}212		}213		else {}214	}215	return false216}217218// search_key edits the query. Every key is text here, q included, so a219// search can begin with q; Esc is the way out.220fn search_key(mut session Session, key Key) bool {221	match key.kind {222		.rune {223			session.screen.query += key.text224			session.screen.selected = 0225		}226		.backspace {227			session.screen.query = trim_last(session.screen.query)228			session.screen.selected = 0229		}230		.escape {231			session.screen.query = ''232			session.screen.selected = 0233			session.mode = .list234		}235		.enter {236			session.mode = .list237		}238		else {}239	}240	return false241}242243// detail_key: l loads this save, and f flags it, or clears an existing flag. Both go on to confirm_key, which244// is the only place an action runs; an unflagged save asks for a reason first. c copies the ID, Esc goes back.245fn detail_key(mut session Session, key Key) bool {246	match key.kind {247		.escape {248			session.mode = .list249		}250		.rune {251			match key.text {252				'q' {253					return true254				}255				'l' {256					session.pending = Action{257						kind:    .load258						save_id: session.target.id259					}260					session.mode = .confirm261				}262				'f' {263					if is_flagged(session.screen, session.target.id) {264						session.pending = Action{265							kind:    .clear_flag266							save_id: session.target.id267						}268						session.mode = .confirm269					} else {270						session.typed = ''271						session.mode = .reason272					}273				}274				'c' {275					copy_selected(mut session.screen, session.target.id)276				}277				else {}278			}279		}280		else {}281	}282	return false283}284285// reason_key collects a flag's reason. Every key is text here, q included:286// a reason may well say "quick hack".287fn reason_key(mut session Session, key Key) bool {288	match key.kind {289		.rune {290			session.typed += key.text291		}292		.backspace {293			session.typed = trim_last(session.typed)294		}295		.escape {296			session.typed = ''297			session.mode = .detail298		}299		.enter {300			if session.typed != '' {301				session.pending = Action{302					kind:    .flag303					save_id: session.target.id304					reason:  session.typed305				}306				session.typed = ''307				session.mode = .confirm308			}309		}310		else {}311	}312	return false313}314315// confirm_key is the strict confirm: only y or Y goes ahead.316fn confirm_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {317	if key.kind == .rune && key.text in ['y', 'Y'] {318		session.screen.status = act(store, session.pending)319		session.stale = true320	}321	session.pending = Action{}322	session.mode = .list323	return false324}325326// filter_saves applies a tab and the search text. `on_line` marks the saves327// still on the line: a replayed-away save exists in history but belongs to328// neither the saves nor the checkpoints list.329pub fn filter_saves(items []history.Save, tab Tab, on_line map[string]bool, query string) []history.Save {330	mut out := []history.Save{}331	needle := query.to_lower()332	for save in items {333		live := save.id in on_line334		if tab == .saves && (!live || save.is_checkpoint) {335			continue336		}337		if tab == .checkpoints && (!live || !save.is_checkpoint) {338			continue339		}340		if needle != '' && !searchable(save).contains(needle) {341			continue342		}343		out << save344	}345	return out346}347348// searchable is what a search matches: ID, message, agent and session, without regard to case.349fn searchable(save history.Save) string {350	return '${save.id} ${save.message} ${save.agent} ${save.session}'.to_lower()351}352353pub fn count_tabs(items []history.Save, on_line map[string]bool) TabCounts {354	return TabCounts{355		all:         filter_saves(items, .all, on_line, '').len356		saves:       filter_saves(items, .saves, on_line, '').len357		checkpoints: filter_saves(items, .checkpoints, on_line, '').len358	}359}360361// move_selection clamps a move to the ends of the list.362pub fn move_selection(index int, delta int, length int) int {363	if length <= 0 {364		return 0365	}366	moved := index + delta367	if moved < 0 {368		return 0369	}370	if moved >= length {371		return length - 1372	}373	return moved374}375376// render writes a frame over the last one: each row is cleared before it is377// drawn, and the last row has no newline, so the terminal never scrolls.378pub fn render(lines []string) string {379	mut out := strings.new_builder(4096)380	out.write_string('\x1b[H')381	for i, line in lines {382		out.write_string('\x1b[2K${line}')383		if i < lines.len - 1 {384			out.write_string('\r\n')385		}386	}387	return out.str()388}389390// frame_lines is one whole frame, exactly as tall as the screen and no line391// wider than it: the tab bar, a gap, the body, the status bar.392fn frame_lines(session Session) []string {393	screen := session.screen394	rows := if screen.rows > 4 { screen.rows } else { 4 }395	cols := if screen.cols > 10 { screen.cols } else { 10 }396	body := rows - 3397	mut lines := [tab_bar(screen, session.mode), '']398	if session.mode in [.detail, .reason, .confirm] {399		lines << session.detail#[..body]400	} else {401		lines << list_body(screen, body, cols)402	}403	for lines.len < rows - 1 {404		lines << ''405	}406	lines << status_bar(session, cols)407	return lines.map(fmt.fit(it, cols))408}409410// list_body lays the entries out in up to seven equal blocks, each a header,411// a message, the selected entry's details and a separator, with the spare rows412// split above and below so the list fills the screen. Koh 2 had six; seven is413// the 15% tighter spacing June asked for, without leaving an empty band.414fn list_body(screen Screen, body int, cols int) []string {415	if screen.items.len == 0 {416		return ['  ${fmt.muted('no results')}']417	}418	slots := if body / 4 < 7 { body / 4 } else { 7 }419	if slots == 0 {420		return [entry_header(screen.items[screen.selected], true, screen)]421	}422	separator := fmt.muted('─'.repeat(cols))423	start := window_start(screen.selected, slots, screen.items.len)424	mut lines := []string{}425	for slot in 0 .. slots {426		height := body / slots + if slot < body % slots { 1 } else { 0 }427		spare := if height > 4 { height - 4 } else { 0 }428		for _ in 0 .. spare / 2 {429			lines << ''430		}431		index := start + slot432		if index < screen.items.len {433			entry := screen.items[index]434			selected := index == screen.selected435			lines << entry_header(entry, selected, screen)436			lines << entry_message(entry, screen)437			lines << if selected { entry_meta(entry) } else { '' }438		} else {439			lines << ['', '', '']440		}441		lines << separator442		for _ in 0 .. spare - spare / 2 {443			lines << ''444		}445	}446	return lines447}448449// window_start keeps the selected entry near the middle of the visible blocks.450fn window_start(selected int, slots int, count int) int {451	if count <= slots {452		return 0453	}454	mut start := if selected > slots / 2 { selected - slots / 2 } else { 0 }455	if start + slots > count {456		start = count - slots457	}458	return start459}460461// entry_header is the mark, ID, time and date. HEAD has its own mark; every462// other save is a dot. As in Koh 2, only the ID tells the selected save apart:463// gold when selected, grey otherwise, while time, date and message stay plain.464fn entry_header(save history.Save, selected bool, screen Screen) string {465	head := save.id == screen.head466	flagged := is_flagged(screen, save.id)467	mark := if head { fmt.blue('▸') } else { fmt.muted('·') }468	mut line := '  ${mark} '469	line += if flagged {470		fmt.red(save.id)471	} else if selected {472		fmt.gold(save.id)473	} else {474		fmt.muted(save.id)475	}476	if head {477		line += '  ${fmt.blue('HEAD')}'478	}479	mut labels := []string{}480	if selected && save.is_checkpoint {481		labels << '[checkpoint]'482	}483	if flagged {484		labels << '[flagged]'485	}486	if save.id !in screen.on_line {487		labels << '[replayed away]'488	}489	if labels.len > 0 {490		line += '  ${labels.join(' ')}'491	}492	return line + '  ${fmt.hour_of(save.created_at)}  ${fmt.date_of(save.created_at)}'493}494495fn entry_message(save history.Save, screen Screen) string {496	warning := if is_flagged(screen, save.id) { fmt.red('⚠ ') } else { '' }497	return '    ${warning}${save.message}'498}499500fn entry_meta(save history.Save) string {501	agent := if save.agent == '' { 'unknown' } else { save.agent }502	session := if save.session == '' { 'no session' } else { save.session }503	return '    ${fmt.human_bytes(save.size_bytes)} · ${agent} · ${session}'504}505506fn tab_bar(screen Screen, mode Mode) string {507	names := ['all', 'saves', 'checkpoints']508	counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]509	mut bar := ''510	for i, name in names {511		if tab_at(i) == screen.tab {512			bar += fmt.gold(' [${name} (${counts[i]})] ')513		} else {514			bar += fmt.muted(' ${name} (${counts[i]}) ')515		}516	}517	bar += fmt.muted('│ ')518	if mode == .search {519		bar += '/${screen.query}_'520	} else if screen.query != '' {521		bar += '/${screen.query}'522	} else {523		bar += fmt.muted('/ search')524	}525	return bar526}527528fn status_bar(session Session, cols int) string {529	hint := match session.mode {530		.search {531			'type to filter · enter keep · esc clear'532		}533		.detail {534			flag_word := if is_flagged(session.screen, session.target.id) {535				'clear flag'536			} else {537				'flag'538			}539			'esc back · l load · f ${flag_word} · c copy · q'540		}541		.reason {542			'flag reason: ${session.typed}_ · enter confirm · esc cancel'543		}544		.confirm {545			'${action_word(session.pending.kind)} [${session.pending.save_id}]? y yes · n/esc cancel'546		}547		.list {548			'↑↓ nav · / search · tab mode · c copy · enter detail · q/esc'549		}550	}551	left := ' view: ${tab_name(session.screen.tab)} · ${hint}'552	right := if session.screen.status != '' {553		session.screen.status554	} else if session.mode == .list || session.mode == .search {555		'${session.screen.items.len} shown'556	} else {557		''558	}559	if right == '' {560		return fmt.muted(left)561	}562	// The hints give way to the count or the result of an action, which is563	// the one thing worth reading right after a key.564	room := cols - fmt.visible_width(right) - 1565	shown := fmt.fit(left, if room > 0 { room } else { 0 })566	gap := cols - fmt.visible_width(shown) - fmt.visible_width(right)567	return fmt.muted('${shown}${' '.repeat(if gap > 0 { gap } else { 0 })}${right}')568}569570fn action_word(kind ActionKind) string {571	return match kind {572		.load { 'load' }573		.flag { 'flag' }574		.clear_flag { 'clear the flag on' }575		.none { '' }576	}577}578579// detail_lines describes one save: what it was, and what it changed.580pub fn detail_lines(save history.Save, head string, deltas []snapshot.Delta, flag events.Flag) []string {581	mut title := '  ${fmt.gold(save.id)}'582	if save.id == head {583		title += '  ${fmt.blue('HEAD')}'584	}585	if save.is_checkpoint {586		title += '  [checkpoint]'587	}588	if flag.flagged {589		title += '  [flagged]'590	}591	mut lines := [592		title,593		'  ${save.message}',594		'',595		detail_row('time', '${fmt.date_of(save.created_at)} · ${fmt.clock_of(save.created_at)}'),596		detail_row('size', fmt.human_bytes(save.size_bytes)),597		detail_row('agent', if save.agent == '' { 'unknown' } else { save.agent }),598		detail_row('session', if save.session == '' { 'none' } else { save.session }),599		detail_row('parent', if save.parent == '' { 'root' } else { save.parent }),600		detail_row('checksum', save.id),601	]602	if flag.flagged {603		lines << detail_row('flag', flag.reason)604	}605	lines << ''606	if save.parent == '' {607		lines << '  initial save — no parent to diff against'608		return lines609	}610	lines << '  ${deltas.len} files changed'611	mut totals := snapshot.Delta{}612	for delta in deltas {613		lines << '  ${change_mark(delta)} ${delta.path}  ${line_stats(delta)}'614		if !delta.binary {615			totals.added += delta.added616			totals.removed += delta.removed617		}618	}619	lines << ['', '  total  ${line_stats(totals)}']620	return lines621}622623fn detail_row(label string, value string) string {624	return '  ${fmt.muted(label + ' '.repeat(9 - label.len))}${value}'625}626627fn line_stats(delta snapshot.Delta) string {628	if delta.binary {629		return fmt.muted('(binary)')630	}631	return '${fmt.green('+' + delta.added.str())}  ${fmt.red('-' + delta.removed.str())}'632}633634fn change_mark(delta snapshot.Delta) string {635	return match delta.kind {636		'added' { fmt.green('A') }637		'removed' { fmt.red('R') }638		else { fmt.gold('M') }639	}640}641642fn is_flagged(screen Screen, save_id string) bool {643	return (screen.flags[save_id] or { events.Flag{} }).flagged644}645646// diff_of compares a save with its parent. A failed read shows an empty diff instead of closing the screen.647fn diff_of(store &storage.Store, save history.Save) []snapshot.Delta {648	return snapshot.diff_trees(store, parent_tree(store, save), child_tree(store, save)) or {649		[]snapshot.Delta{}650	}651}652653fn trim_last(text string) string {654	runes := text.runes()655	if runes.len == 0 {656		return text657	}658	return runes[..runes.len - 1].string()659}660661// copy_selected puts the ID on the clipboard through OSC 52, which works over662// ssh and in any terminal without a helper program.663fn copy_selected(mut screen Screen, save_id string) {664	paint('\x1b]52;c;${base64.encode(save_id.bytes())}\a')665	screen.status = 'copied ${save_id}'666}667668fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {669	if save.parent == '' {670		return snapshot.Tree{}671	}672	return snapshot.Tree{673		files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }674	}675}676677fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {678	return snapshot.Tree{679		files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }680	}681}682683// on_line_set marks the saves on the current line; the all tab also lists those replayed off it.684fn on_line_set(store &storage.Store) map[string]bool {685	mut ids := map[string]bool{}686	for save in history.line(store) or { []history.Save{} } {687		ids[save.id] = true688	}689	return ids690}691692// enter_screen and leave_screen are a pair: run defers leave_screen so every exit restores the terminal.693fn enter_screen() {694	paint('\x1b[?1049h\x1b[?25l')695	raw_mode(true)696}697698fn leave_screen() {699	raw_mode(false)700	paint('\x1b[?25h\x1b[?1049l')701}702703fn paint(text string) {704	os.fd_write(1, text)705}706707fn tab_at(index int) Tab {708	return match index {709		0 { Tab.all }710		1 { Tab.saves }711		else { Tab.checkpoints }712	}713}714715fn tab_name(tab Tab) string {716	return match tab {717		.all { 'all' }718		.saves { 'saves' }719		else { 'checkpoints' }720	}721}722723fn next_tab(tab Tab) Tab {724	return match tab {725		.all { Tab.saves }726		.saves { Tab.checkpoints }727		else { Tab.all }728	}729}