view.v

15.2 KB · raw

1//! The history view: the saves and checkpoints list, full screen.2//!3//! On a terminal `koh log` opens this instead of printing; `koh saves` and4//! `koh checkpoints` open it on their tab. The view only reads — history, flags5//! and diffs — and hands any decision back to the caller as an `Action`, so the6//! command layer stays the only thing that changes anything.7//!8//! Everything a frame needs is in `Screen`, and `render` turns one into the9//! text of a frame. That split keeps the interesting part testable without a10//! terminal: the tests here never run the event loop.1112module view1314import encoding.base6415import os16import strings17import src.events18import src.fmt19import src.history20import src.snapshot21import src.storage2223pub enum Tab {24	all25	saves26	checkpoints27}2829pub enum ActionKind {30	none31	load32	flag33	clear_flag34}3536pub struct Action {37pub mut:38	kind    ActionKind39	save_id string40	reason  string41}4243// TabCounts is what the tab bar shows: how many saves are in each list before44// the search narrows anything.45pub struct TabCounts {46pub mut:47	all         int48	saves       int49	checkpoints int50}5152// Screen is one frame's worth of state.53pub struct Screen {54pub mut:55	tab      Tab56	items    []history.Save57	counts   TabCounts58	selected int59	query    string60	status   string61	flags    map[string]events.Flag62	on_line  map[string]bool63	rows     int64	cols     int65}6667enum Mode {68	list69	search70	reason71	detail72}7374// Session is everything the event loop changes: what is on screen, which mode75// it is in, and what the current action is about.76struct Session {77mut:78	screen Screen79	mode   Mode80	target history.Save81	typed  string82	// set when an action changed history, so the lists are read again83	stale bool84	// the detail's diff, worked out once per save rather than per key85	deltas     []snapshot.Delta86	deltas_for string87}8889// run shows the view until the user leaves it. `act` performs a chosen action90// and returns the message for the status bar, so the view itself never changes91// history.92pub fn run(store &storage.Store, start Tab, act fn (store &storage.Store, action Action) string) ! {93	enter_screen()94	defer {95		leave_screen()96	}97	mut every := []history.Save{}98	mut session := Session{99		screen: Screen{100			tab: start101		}102		stale:  true103	}104	for {105		if session.stale {106			// A load appends a save and a flag changes a mark: read both again.107			every = history.all_saves(store)!108			session.screen.on_line = on_line_set(store)109			session.screen.flags = events.latest_flags(store)!110			session.screen.counts = count_tabs(every, session.screen.on_line)111			session.stale = false112		}113		session.screen.rows, session.screen.cols = terminal_size()114		session.screen.items = filter_saves(every, session.screen.tab, session.screen.on_line,115			session.screen.query)116		if session.screen.selected >= session.screen.items.len {117			session.screen.selected = move_selection(session.screen.selected, 0,118				session.screen.items.len)119		}120		paint(frame_for(store, mut session))121		key := read_key()122		if key.kind in [.interrupt, .eof] {123			return124		}125		leaving := match session.mode {126			.list { list_key(mut session, key) }127			.search { search_key(mut session, key) }128			.reason { reason_key(mut session, store, key, act) }129			.detail { detail_key(mut session, store, key, act) }130		}131132		if leaving {133			return134		}135	}136}137138fn frame_for(store &storage.Store, mut session Session) string {139	match session.mode {140		.detail, .reason {141			// Typing a flag reason repaints on every key; diffing a large save142			// each time would stall the typing.143			if session.deltas_for != session.target.id {144				session.deltas = snapshot.diff_trees(store, parent_tree(store, session.target), child_tree(store,145					session.target)) or { []snapshot.Delta{} }146				session.deltas_for = session.target.id147			}148			return detail_frame(session.screen, session.target, session.deltas,149				session.mode == .reason, session.typed)150		}151		else {152			return render(session.screen)153		}154	}155}156157// list_key handles one key in the list and reports whether to leave the view.158fn list_key(mut session Session, key Key) bool {159	match key.kind {160		.tab {161			session.screen.tab = next_tab(session.screen.tab)162			session.screen.selected = 0163		}164		.down {165			session.screen.selected = move_selection(session.screen.selected, 1,166				session.screen.items.len)167		}168		.up {169			session.screen.selected = move_selection(session.screen.selected, -1,170				session.screen.items.len)171		}172		.enter {173			if session.screen.items.len > 0 {174				session.target = session.screen.items[session.screen.selected]175				session.mode = .detail176			}177		}178		.rune {179			match key.text {180				'q' {181					return true182				}183				'j' {184					session.screen.selected = move_selection(session.screen.selected, 1,185						session.screen.items.len)186				}187				'k' {188					session.screen.selected = move_selection(session.screen.selected, -1,189						session.screen.items.len)190				}191				'g' {192					session.screen.selected = 0193				}194				'G' {195					session.screen.selected = move_selection(session.screen.items.len, -1,196						session.screen.items.len)197				}198				'/' {199					session.mode = .search200					session.screen.query = ''201				}202				'c' {203					if session.screen.items.len > 0 {204						id := session.screen.items[session.screen.selected].id205						copy_id(id)206						session.screen.status = 'copied ${id}'207					}208				}209				else {}210			}211		}212		else {}213	}214215	return false216}217218fn search_key(mut session Session, key Key) bool {219	match key.kind {220		.rune {221			session.screen.query += key.text222		}223		.backspace {224			session.screen.query = trim_last(session.screen.query)225		}226		.escape {227			session.screen.query = ''228			session.mode = .list229		}230		.enter {231			session.mode = .list232		}233		else {}234	}235236	return false237}238239fn reason_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {240	match key.kind {241		.rune {242			session.typed += key.text243		}244		.backspace {245			session.typed = trim_last(session.typed)246		}247		.escape {248			session.typed = ''249			session.mode = .list250		}251		.enter {252			if session.typed != '' {253				session.stale = true254				session.screen.status = act(store, Action{255					kind:    .flag256					save_id: session.target.id257					reason:  session.typed258				})259			}260			session.typed = ''261			session.mode = .list262		}263		else {}264	}265266	return false267}268269fn detail_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {270	match key.kind {271		.escape {272			session.mode = .list273		}274		.rune {275			match key.text {276				'l' {277					if ask_confirm('load [${session.target.id}]') {278						session.stale = true279						session.screen.status = act(store, Action{280							kind:    .load281							save_id: session.target.id282						})283					}284					session.mode = .list285				}286				'f' {287					state := session.screen.flags[session.target.id] or {288						events.Flag{289							save_id: session.target.id290						}291					}292					if state.flagged {293						session.stale = true294						session.screen.status = act(store, Action{295							kind:    .clear_flag296							save_id: session.target.id297						})298						session.mode = .list299					} else {300						session.typed = ''301						session.mode = .reason302					}303				}304				else {}305			}306		}307		else {}308	}309310	return false311}312313// filter_saves applies a tab and the search text. `on_line` marks the saves314// still on the line: a replayed-away save exists in history but belongs to315// neither the saves nor the checkpoints list.316pub fn filter_saves(items []history.Save, tab Tab, on_line map[string]bool, query string) []history.Save {317	mut out := []history.Save{}318	needle := query.to_lower()319	for save in items {320		live := save.id in on_line321		if tab == .saves && !live {322			continue323		}324		if tab == .checkpoints && (!live || !save.is_checkpoint) {325			continue326		}327		if needle != '' && !searchable(save).contains(needle) {328			continue329		}330		out << save331	}332	return out333}334335fn searchable(save history.Save) string {336	return '${save.id} ${save.message} ${save.agent} ${save.session}'.to_lower()337}338339pub fn count_tabs(items []history.Save, on_line map[string]bool) TabCounts {340	return TabCounts{341		all:         filter_saves(items, .all, on_line, '').len342		saves:       filter_saves(items, .saves, on_line, '').len343		checkpoints: filter_saves(items, .checkpoints, on_line, '').len344	}345}346347// move_selection clamps a move to the ends of the list.348pub fn move_selection(index int, delta int, length int) int {349	if length <= 0 {350		return 0351	}352	moved := index + delta353	if moved < 0 {354		return 0355	}356	if moved >= length {357		return length - 1358	}359	return moved360}361362// detail_lines describes one save: what it was, and what it changed.363pub fn detail_lines(save history.Save, deltas []snapshot.Delta, flag events.Flag) []string {364	mut lines := [365		'  ${fmt.bold('[' + save.id + ']')}  ${save.message}',366		'',367		'  time      ${fmt.stamp(save.created_at)}',368		'  size      ${fmt.human_bytes(save.size_bytes)}',369		'  agent     ${or_dash(save.agent)}',370		'  session   ${or_dash(save.session)}',371		'  parent    ${or_dash(save.parent)}',372		'  checksum  ${save.id}',373	]374	if flag.flagged {375		lines << '  flag      ${or_dash(flag.reason)}'376	}377	lines << ''378	if deltas.len == 0 {379		lines << '  ${fmt.dim('nothing changed against the parent')}'380		return lines381	}382	lines << '  ${fmt.plural(deltas.len, 'file')} changed against the parent:'383	for delta in deltas {384		lines << '  ${change_mark(delta)} ${delta.path}${line_stats(delta)}'385	}386	return lines387}388389fn line_stats(delta snapshot.Delta) string {390	if delta.binary {391		return '  ${fmt.dim('(binary)')}'392	}393	return '  ${fmt.green('+' + delta.added.str())} ${fmt.red('-' + delta.removed.str())}'394}395396fn change_mark(delta snapshot.Delta) string {397	return match delta.kind {398		'added' { fmt.green('+') }399		'removed' { fmt.red('-') }400		else { fmt.amber('~') }401	}402}403404// render is a whole frame of the list: tabs, entries, status bar.405pub fn render(screen Screen) string {406	mut out := strings.Builder{}407	out.write_string('\x1b[H')408	for line in frame_lines(screen) {409		out.write_string('\x1b[2K${line}\r\n')410	}411	return out.str()412}413414pub fn frame_lines(screen Screen) []string {415	mut lines := [tab_bar(screen)]416	mut body := []string{}417	for i, save in screen.items {418		body << entry_line(save, i == screen.selected, screen)419		if i == screen.selected {420			body << meta_line(save)421		}422		body << fmt.dim('  ${'─'.repeat(separator_width(screen.cols))}')423	}424	if body.len == 0 {425		body << fmt.dim('  no results')426	}427	room := body_room(screen.rows)428	start := if screen.selected >= room { screen.selected - room + 1 } else { 0 }429	for i := start; i < body.len && lines.len < room + 1; i++ {430		lines << body[i]431	}432	for lines.len < room + 1 {433		lines << ''434	}435	lines << status_bar(screen)436	return lines437}438439fn body_room(rows int) int {440	// The tab bar and the status bar take one row each; two more rows keep the441	// list off the very bottom of the screen.442	if rows <= 4 {443		return 1444	}445	return rows - 3446}447448fn separator_width(cols int) int {449	if cols <= 6 {450		return 1451	}452	return cols - 4453}454455fn tab_bar(screen Screen) string {456	names := ['all', 'saves', 'checkpoints']457	counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]458	mut parts := []string{}459	for i, name in names {460		label := '${name} (${counts[i]})'461		if tab_at(i) == screen.tab {462			parts << fmt.bold('[${label}]')463		} else {464			parts << fmt.dim(label)465		}466	}467	mut bar := '  ${parts.join('  ')}    '468	if screen.query != '' {469		bar += fmt.bold('/${screen.query}_')470	} else {471		bar += fmt.dim('/ search')472	}473	return bar474}475476fn entry_line(save history.Save, selected bool, screen Screen) string {477	mut line := '  ${if selected { fmt.amber('▸') } else { ' ' }} '478	line += '${save_mark(save, screen)} ${fmt.dim('[' + save.id + ']')} '479	line += '${fmt.date_of(save.created_at)} ${fmt.clock_of(save.created_at)}  ${save.message}'480	mut tags := []string{}481	if save.agent != '' || save.session != '' {482		tags << '${or_dash(save.agent)} · ${or_dash(save.session)}'483	}484	if save.restores != '' {485		tags << 'back to ${save.restores}'486	}487	if save.id !in screen.on_line {488		tags << 'replayed away'489	}490	if tags.len > 0 {491		line += '   ${fmt.dim(tags.join('  '))}'492	}493	if selected {494		return fmt.reverse(line)495	}496	return line497}498499fn meta_line(save history.Save) string {500	return fmt.dim('         ${fmt.human_bytes(save.size_bytes)}  ·  agent ${or_dash(save.agent)}  ·  session ${or_dash(save.session)}')501}502503fn status_bar(screen Screen) string {504	mut hints := 'j/k move · / search · tab mode · c copy · enter detail · q quit'505	if screen.status != '' {506		hints = screen.status507	}508	left := 'view: ${tab_name(screen.tab)} · ${hints}'509	right := '${screen.items.len} shown'510	mut gap := screen.cols - fmt.visible_width(left) - fmt.visible_width(right) - 3511	if gap < 1 {512		gap = 1513	}514	return fmt.dim('  ${left}${' '.repeat(gap)}${right}')515}516517fn save_mark(save history.Save, screen Screen) string {518	// Replayed away: still in history, no longer on the line.519	if save.id !in screen.on_line {520		return fmt.dim('↪')521	}522	if save.is_checkpoint {523		return fmt.amber('◐')524	}525	if (screen.flags[save.id] or {526		events.Flag{527			save_id: save.id528		}529	}).flagged {530		return fmt.red('⚑')531	}532	return ' '533}534535fn detail_frame(screen Screen, target history.Save, deltas []snapshot.Delta, asking_reason bool, typed string) string {536	flag := screen.flags[target.id] or {537		events.Flag{538			save_id: target.id539		}540	}541	mut out := strings.Builder{}542	out.write_string('\x1b[H\x1b[2J')543	for line in detail_lines(target, deltas, flag) {544		out.write_string('${line}\r\n')545	}546	out.write_string('\r\n')547	if asking_reason {548		out.write_string('  ${fmt.amber('flag reason')}: ${typed}_')549		return out.str()550	}551	action := if flag.flagged { 'clear flag' } else { 'flag' }552	out.write_string(fmt.dim('  l load · f ${action} · esc back'))553	return out.str()554}555556fn trim_last(text string) string {557	if text.len == 0 {558		return text559	}560	return text[..text.len - 1]561}562563// ask_confirm is the strict prompt inside the view: only a lowercase y.564fn ask_confirm(what string) bool {565	paint('\x1b[H\x1b[2J  ${what} — continue? [y/N] ')566	return read_key().text == 'y'567}568569// copy_id puts the ID on the clipboard through OSC 52, which works over ssh and570// in any terminal without a helper program.571fn copy_id(save_id string) {572	paint('\x1b]52;c;${base64.encode(save_id.bytes())}\a')573}574575fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {576	if save.parent == '' {577		return snapshot.Tree{}578	}579	return snapshot.Tree{580		files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }581	}582}583584fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {585	return snapshot.Tree{586		files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }587	}588}589590fn on_line_set(store &storage.Store) map[string]bool {591	mut ids := map[string]bool{}592	for save in history.line(store) or { []history.Save{} } {593		ids[save.id] = true594	}595	return ids596}597598fn enter_screen() {599	paint('\x1b[?1049h\x1b[?25l')600	raw_mode(true)601}602603fn leave_screen() {604	raw_mode(false)605	paint('\x1b[?25h\x1b[?1049l')606}607608fn paint(text string) {609	os.fd_write(1, text)610}611612fn tab_at(index int) Tab {613	return match index {614		0 { Tab.all }615		1 { Tab.saves }616		else { Tab.checkpoints }617	}618}619620fn tab_name(tab Tab) string {621	return match tab {622		.all { 'all' }623		.saves { 'saves' }624		else { 'checkpoints' }625	}626}627628fn next_tab(tab Tab) Tab {629	return match tab {630		.all { Tab.saves }631		.saves { Tab.checkpoints }632		else { Tab.all }633	}634}635636fn or_dash(value string) string {637	return if value == '' { '—' } else { value }638}