view.v

14.4 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}8384// run shows the view until the user leaves it. `act` performs a chosen action85// and returns the message for the status bar, so the view itself never changes86// history.87pub fn run(store &storage.Store, start Tab, act fn (store &storage.Store, action Action) string) ! {88	enter_screen()89	defer {90		leave_screen()91	}92	every := history.all_saves(store)!93	on_line := on_line_set(store)94	mut session := Session{95		screen: Screen{96			tab:     start97			on_line: on_line98			flags:   events.latest_flags(store)!99			counts:  count_tabs(every, on_line)100		}101	}102	for {103		session.screen.rows, session.screen.cols = terminal_size()104		session.screen.items = filter_saves(every, session.screen.tab, session.screen.on_line,105			session.screen.query)106		if session.screen.selected >= session.screen.items.len {107			session.screen.selected = move_selection(session.screen.selected, 0,108				session.screen.items.len)109		}110		paint(frame_for(store, session))111		key := read_key()112		if key.kind in [.interrupt, .eof] {113			return114		}115		leaving := match session.mode {116			.list { list_key(mut session, key) }117			.search { search_key(mut session, key) }118			.reason { reason_key(mut session, store, key, act) }119			.detail { detail_key(mut session, store, key, act) }120		}121122		if leaving {123			return124		}125	}126}127128fn frame_for(store &storage.Store, session Session) string {129	match session.mode {130		.detail, .reason {131			delta := snapshot.diff_trees(store, parent_tree(store, session.target), child_tree(store,132				session.target)) or { []snapshot.Delta{} }133			return detail_frame(session.screen, session.target, delta, session.mode == .reason,134				session.typed)135		}136		else {137			return render(session.screen)138		}139	}140}141142// list_key handles one key in the list and reports whether to leave the view.143fn list_key(mut session Session, key Key) bool {144	match key.kind {145		.tab {146			session.screen.tab = next_tab(session.screen.tab)147			session.screen.selected = 0148		}149		.down {150			session.screen.selected = move_selection(session.screen.selected, 1,151				session.screen.items.len)152		}153		.up {154			session.screen.selected = move_selection(session.screen.selected, -1,155				session.screen.items.len)156		}157		.enter {158			if session.screen.items.len > 0 {159				session.target = session.screen.items[session.screen.selected]160				session.mode = .detail161			}162		}163		.rune {164			match key.text {165				'q' {166					return true167				}168				'j' {169					session.screen.selected = move_selection(session.screen.selected, 1,170						session.screen.items.len)171				}172				'k' {173					session.screen.selected = move_selection(session.screen.selected, -1,174						session.screen.items.len)175				}176				'g' {177					session.screen.selected = 0178				}179				'G' {180					session.screen.selected = move_selection(session.screen.items.len, -1,181						session.screen.items.len)182				}183				'/' {184					session.mode = .search185					session.screen.query = ''186				}187				'c' {188					if session.screen.items.len > 0 {189						id := session.screen.items[session.screen.selected].id190						copy_id(id)191						session.screen.status = 'copied ${id}'192					}193				}194				else {}195			}196		}197		else {}198	}199200	return false201}202203fn search_key(mut session Session, key Key) bool {204	match key.kind {205		.rune {206			session.screen.query += key.text207		}208		.backspace {209			session.screen.query = trim_last(session.screen.query)210		}211		.escape {212			session.screen.query = ''213			session.mode = .list214		}215		.enter {216			session.mode = .list217		}218		else {}219	}220221	return false222}223224fn reason_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {225	match key.kind {226		.rune {227			session.typed += key.text228		}229		.backspace {230			session.typed = trim_last(session.typed)231		}232		.escape {233			session.typed = ''234			session.mode = .list235		}236		.enter {237			if session.typed != '' {238				session.screen.status = act(store, Action{239					kind:    .flag240					save_id: session.target.id241					reason:  session.typed242				})243			}244			session.typed = ''245			session.mode = .list246		}247		else {}248	}249250	return false251}252253fn detail_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {254	match key.kind {255		.escape {256			session.mode = .list257		}258		.rune {259			match key.text {260				'l' {261					if ask_confirm('load [${session.target.id}]') {262						session.screen.status = act(store, Action{263							kind:    .load264							save_id: session.target.id265						})266					}267					session.mode = .list268				}269				'f' {270					state := session.screen.flags[session.target.id] or {271						events.Flag{272							save_id: session.target.id273						}274					}275					if state.flagged {276						session.screen.status = act(store, Action{277							kind:    .clear_flag278							save_id: session.target.id279						})280						session.mode = .list281					} else {282						session.typed = ''283						session.mode = .reason284					}285				}286				else {}287			}288		}289		else {}290	}291292	return false293}294295// filter_saves applies a tab and the search text. `on_line` marks the saves296// still on the line: a replayed-away save exists in history but belongs to297// neither the saves nor the checkpoints list.298pub fn filter_saves(items []history.Save, tab Tab, on_line map[string]bool, query string) []history.Save {299	mut out := []history.Save{}300	needle := query.to_lower()301	for save in items {302		live := save.id in on_line && save.replayed_from == ''303		if tab == .saves && !live {304			continue305		}306		if tab == .checkpoints && (!live || !save.is_checkpoint) {307			continue308		}309		if needle != '' && !searchable(save).contains(needle) {310			continue311		}312		out << save313	}314	return out315}316317fn searchable(save history.Save) string {318	return '${save.id} ${save.message} ${save.agent} ${save.session}'.to_lower()319}320321pub fn count_tabs(items []history.Save, on_line map[string]bool) TabCounts {322	return TabCounts{323		all:         filter_saves(items, .all, on_line, '').len324		saves:       filter_saves(items, .saves, on_line, '').len325		checkpoints: filter_saves(items, .checkpoints, on_line, '').len326	}327}328329// move_selection clamps a move to the ends of the list.330pub fn move_selection(index int, delta int, length int) int {331	if length <= 0 {332		return 0333	}334	moved := index + delta335	if moved < 0 {336		return 0337	}338	if moved >= length {339		return length - 1340	}341	return moved342}343344// detail_lines describes one save: what it was, and what it changed.345pub fn detail_lines(save history.Save, deltas []snapshot.Delta, flag events.Flag) []string {346	mut lines := [347		'  ${fmt.bold('[' + save.id + ']')}  ${save.message}',348		'',349		'  time      ${fmt.stamp(save.created_at)}',350		'  size      ${fmt.human_bytes(save.size_bytes)}',351		'  agent     ${or_dash(save.agent)}',352		'  session   ${or_dash(save.session)}',353		'  parent    ${or_dash(save.parent)}',354		'  checksum  ${save.id}',355	]356	if flag.flagged {357		lines << '  flag      ${or_dash(flag.reason)}'358	}359	lines << ''360	if deltas.len == 0 {361		lines << '  ${fmt.dim('nothing changed against the parent')}'362		return lines363	}364	lines << '  ${fmt.plural(deltas.len, 'file')} changed against the parent:'365	for delta in deltas {366		lines << '  ${change_mark(delta)} ${delta.path}${line_stats(delta)}'367	}368	return lines369}370371fn line_stats(delta snapshot.Delta) string {372	if delta.binary {373		return '  ${fmt.dim('(binary)')}'374	}375	return '  ${fmt.green('+' + delta.added.str())} ${fmt.red('-' + delta.removed.str())}'376}377378fn change_mark(delta snapshot.Delta) string {379	return match delta.kind {380		'added' { fmt.green('+') }381		'removed' { fmt.red('-') }382		else { fmt.amber('~') }383	}384}385386// render is a whole frame of the list: tabs, entries, status bar.387pub fn render(screen Screen) string {388	mut out := strings.Builder{}389	out.write_string('\x1b[H')390	for line in frame_lines(screen) {391		out.write_string('\x1b[2K${line}\r\n')392	}393	return out.str()394}395396pub fn frame_lines(screen Screen) []string {397	mut lines := [tab_bar(screen)]398	mut body := []string{}399	for i, save in screen.items {400		body << entry_line(save, i == screen.selected, screen)401		if i == screen.selected {402			body << meta_line(save)403		}404		body << fmt.dim('  ${'─'.repeat(separator_width(screen.cols))}')405	}406	if body.len == 0 {407		body << fmt.dim('  no results')408	}409	room := body_room(screen.rows)410	start := if screen.selected >= room { screen.selected - room + 1 } else { 0 }411	for i := start; i < body.len && lines.len < room + 1; i++ {412		lines << body[i]413	}414	for lines.len < room + 1 {415		lines << ''416	}417	lines << status_bar(screen)418	return lines419}420421fn body_room(rows int) int {422	// The tab bar and the status bar take one row each; two more rows keep the423	// list off the very bottom of the screen.424	if rows <= 4 {425		return 1426	}427	return rows - 3428}429430fn separator_width(cols int) int {431	if cols <= 6 {432		return 1433	}434	return cols - 4435}436437fn tab_bar(screen Screen) string {438	names := ['all', 'saves', 'checkpoints']439	counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]440	mut parts := []string{}441	for i, name in names {442		label := '${name} (${counts[i]})'443		if tab_at(i) == screen.tab {444			parts << fmt.bold('[${label}]')445		} else {446			parts << fmt.dim(label)447		}448	}449	mut bar := '  ${parts.join('  ')}    '450	if screen.query != '' {451		bar += fmt.bold('/${screen.query}_')452	} else {453		bar += fmt.dim('/ search')454	}455	return bar456}457458fn entry_line(save history.Save, selected bool, screen Screen) string {459	mut line := '  ${if selected { fmt.amber('▸') } else { ' ' }} '460	line += '${save_mark(save, screen)} ${fmt.dim('[' + save.id + ']')} '461	line += '${fmt.date_of(save.created_at)} ${fmt.clock_of(save.created_at)}  ${save.message}'462	mut tags := []string{}463	if save.agent != '' || save.session != '' {464		tags << '${or_dash(save.agent)} · ${or_dash(save.session)}'465	}466	if save.restores != '' {467		tags << 'back to ${save.restores}'468	}469	if save.replayed_from != '' {470		tags << 'replayed'471	}472	if tags.len > 0 {473		line += '   ${fmt.dim(tags.join('  '))}'474	}475	if selected {476		return fmt.reverse(line)477	}478	return line479}480481fn meta_line(save history.Save) string {482	return fmt.dim('         ${fmt.human_bytes(save.size_bytes)}  ·  agent ${or_dash(save.agent)}  ·  session ${or_dash(save.session)}')483}484485fn status_bar(screen Screen) string {486	mut hints := 'j/k move · / search · tab mode · c copy · enter detail · q quit'487	if screen.status != '' {488		hints = screen.status489	}490	left := 'view: ${tab_name(screen.tab)} · ${hints}'491	right := '${screen.items.len} shown'492	mut gap := screen.cols - fmt.visible_width(left) - fmt.visible_width(right) - 3493	if gap < 1 {494		gap = 1495	}496	return fmt.dim('  ${left}${' '.repeat(gap)}${right}')497}498499fn save_mark(save history.Save, screen Screen) string {500	if save.is_checkpoint {501		return fmt.amber('◐')502	}503	if (screen.flags[save.id] or {504		events.Flag{505			save_id: save.id506		}507	}).flagged {508		return fmt.red('⚑')509	}510	if save.replayed_from != '' {511		return fmt.dim('↪')512	}513	return ' '514}515516fn detail_frame(screen Screen, target history.Save, deltas []snapshot.Delta, asking_reason bool, typed string) string {517	flag := screen.flags[target.id] or {518		events.Flag{519			save_id: target.id520		}521	}522	mut out := strings.Builder{}523	out.write_string('\x1b[H\x1b[2J')524	for line in detail_lines(target, deltas, flag) {525		out.write_string('${line}\r\n')526	}527	out.write_string('\r\n')528	if asking_reason {529		out.write_string('  ${fmt.amber('flag reason')}: ${typed}_')530		return out.str()531	}532	action := if flag.flagged { 'clear flag' } else { 'flag' }533	out.write_string(fmt.dim('  l load · f ${action} · esc back'))534	return out.str()535}536537fn trim_last(text string) string {538	if text.len == 0 {539		return text540	}541	return text[..text.len - 1]542}543544// ask_confirm is the strict prompt inside the view: only a lowercase y.545fn ask_confirm(what string) bool {546	paint('\x1b[H\x1b[2J  ${what} — continue? [y/N] ')547	return read_key().text == 'y'548}549550// copy_id puts the ID on the clipboard through OSC 52, which works over ssh and551// in any terminal without a helper program.552fn copy_id(save_id string) {553	paint('\x1b]52;c;${base64.encode(save_id.bytes())}\x07')554}555556fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {557	if save.parent == '' {558		return snapshot.Tree{}559	}560	return snapshot.Tree{561		files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }562	}563}564565fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {566	return snapshot.Tree{567		files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }568	}569}570571fn on_line_set(store &storage.Store) map[string]bool {572	mut ids := map[string]bool{}573	for save in history.line(store) or { []history.Save{} } {574		ids[save.id] = true575	}576	return ids577}578579fn enter_screen() {580	paint('\x1b[?1049h\x1b[?25l')581	raw_mode(true)582}583584fn leave_screen() {585	raw_mode(false)586	paint('\x1b[?25h\x1b[?1049l')587}588589fn paint(text string) {590	os.fd_write(1, text)591}592593fn tab_at(index int) Tab {594	return match index {595		0 { Tab.all }596		1 { Tab.saves }597		else { Tab.checkpoints }598	}599}600601fn tab_name(tab Tab) string {602	return match tab {603		.all { 'all' }604		.saves { 'saves' }605		else { 'checkpoints' }606	}607}608609fn next_tab(tab Tab) Tab {610	return match tab {611		.all { Tab.saves }612		.saves { Tab.checkpoints }613		else { Tab.all }614	}615}616617fn or_dash(value string) string {618	return if value == '' { '—' } else { value }619}