view.v

14.8 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}8586// run shows the view until the user leaves it. `act` performs a chosen action87// and returns the message for the status bar, so the view itself never changes88// history.89pub fn run(store &storage.Store, start Tab, act fn (store &storage.Store, action Action) string) ! {90	enter_screen()91	defer {92		leave_screen()93	}94	mut every := []history.Save{}95	mut session := Session{96		screen: Screen{97			tab: start98		}99		stale:  true100	}101	for {102		if session.stale {103			// A load appends a save and a flag changes a mark: read both again.104			every = history.all_saves(store)!105			session.screen.on_line = on_line_set(store)106			session.screen.flags = events.latest_flags(store)!107			session.screen.counts = count_tabs(every, session.screen.on_line)108			session.stale = false109		}110		session.screen.rows, session.screen.cols = terminal_size()111		session.screen.items = filter_saves(every, session.screen.tab, session.screen.on_line,112			session.screen.query)113		if session.screen.selected >= session.screen.items.len {114			session.screen.selected = move_selection(session.screen.selected, 0,115				session.screen.items.len)116		}117		paint(frame_for(store, session))118		key := read_key()119		if key.kind in [.interrupt, .eof] {120			return121		}122		leaving := match session.mode {123			.list { list_key(mut session, key) }124			.search { search_key(mut session, key) }125			.reason { reason_key(mut session, store, key, act) }126			.detail { detail_key(mut session, store, key, act) }127		}128129		if leaving {130			return131		}132	}133}134135fn frame_for(store &storage.Store, session Session) string {136	match session.mode {137		.detail, .reason {138			delta := snapshot.diff_trees(store, parent_tree(store, session.target), child_tree(store,139				session.target)) or { []snapshot.Delta{} }140			return detail_frame(session.screen, session.target, delta, session.mode == .reason,141				session.typed)142		}143		else {144			return render(session.screen)145		}146	}147}148149// list_key handles one key in the list and reports whether to leave the view.150fn list_key(mut session Session, key Key) bool {151	match key.kind {152		.tab {153			session.screen.tab = next_tab(session.screen.tab)154			session.screen.selected = 0155		}156		.down {157			session.screen.selected = move_selection(session.screen.selected, 1,158				session.screen.items.len)159		}160		.up {161			session.screen.selected = move_selection(session.screen.selected, -1,162				session.screen.items.len)163		}164		.enter {165			if session.screen.items.len > 0 {166				session.target = session.screen.items[session.screen.selected]167				session.mode = .detail168			}169		}170		.rune {171			match key.text {172				'q' {173					return true174				}175				'j' {176					session.screen.selected = move_selection(session.screen.selected, 1,177						session.screen.items.len)178				}179				'k' {180					session.screen.selected = move_selection(session.screen.selected, -1,181						session.screen.items.len)182				}183				'g' {184					session.screen.selected = 0185				}186				'G' {187					session.screen.selected = move_selection(session.screen.items.len, -1,188						session.screen.items.len)189				}190				'/' {191					session.mode = .search192					session.screen.query = ''193				}194				'c' {195					if session.screen.items.len > 0 {196						id := session.screen.items[session.screen.selected].id197						copy_id(id)198						session.screen.status = 'copied ${id}'199					}200				}201				else {}202			}203		}204		else {}205	}206207	return false208}209210fn search_key(mut session Session, key Key) bool {211	match key.kind {212		.rune {213			session.screen.query += key.text214		}215		.backspace {216			session.screen.query = trim_last(session.screen.query)217		}218		.escape {219			session.screen.query = ''220			session.mode = .list221		}222		.enter {223			session.mode = .list224		}225		else {}226	}227228	return false229}230231fn reason_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {232	match key.kind {233		.rune {234			session.typed += key.text235		}236		.backspace {237			session.typed = trim_last(session.typed)238		}239		.escape {240			session.typed = ''241			session.mode = .list242		}243		.enter {244			if session.typed != '' {245				session.stale = true246				session.screen.status = act(store, Action{247					kind:    .flag248					save_id: session.target.id249					reason:  session.typed250				})251			}252			session.typed = ''253			session.mode = .list254		}255		else {}256	}257258	return false259}260261fn detail_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {262	match key.kind {263		.escape {264			session.mode = .list265		}266		.rune {267			match key.text {268				'l' {269					if ask_confirm('load [${session.target.id}]') {270						session.stale = true271						session.screen.status = act(store, Action{272							kind:    .load273							save_id: session.target.id274						})275					}276					session.mode = .list277				}278				'f' {279					state := session.screen.flags[session.target.id] or {280						events.Flag{281							save_id: session.target.id282						}283					}284					if state.flagged {285						session.stale = true286						session.screen.status = act(store, Action{287							kind:    .clear_flag288							save_id: session.target.id289						})290						session.mode = .list291					} else {292						session.typed = ''293						session.mode = .reason294					}295				}296				else {}297			}298		}299		else {}300	}301302	return false303}304305// filter_saves applies a tab and the search text. `on_line` marks the saves306// still on the line: a replayed-away save exists in history but belongs to307// neither the saves nor the checkpoints list.308pub fn filter_saves(items []history.Save, tab Tab, on_line map[string]bool, query string) []history.Save {309	mut out := []history.Save{}310	needle := query.to_lower()311	for save in items {312		live := save.id in on_line313		if tab == .saves && !live {314			continue315		}316		if tab == .checkpoints && (!live || !save.is_checkpoint) {317			continue318		}319		if needle != '' && !searchable(save).contains(needle) {320			continue321		}322		out << save323	}324	return out325}326327fn searchable(save history.Save) string {328	return '${save.id} ${save.message} ${save.agent} ${save.session}'.to_lower()329}330331pub fn count_tabs(items []history.Save, on_line map[string]bool) TabCounts {332	return TabCounts{333		all:         filter_saves(items, .all, on_line, '').len334		saves:       filter_saves(items, .saves, on_line, '').len335		checkpoints: filter_saves(items, .checkpoints, on_line, '').len336	}337}338339// move_selection clamps a move to the ends of the list.340pub fn move_selection(index int, delta int, length int) int {341	if length <= 0 {342		return 0343	}344	moved := index + delta345	if moved < 0 {346		return 0347	}348	if moved >= length {349		return length - 1350	}351	return moved352}353354// detail_lines describes one save: what it was, and what it changed.355pub fn detail_lines(save history.Save, deltas []snapshot.Delta, flag events.Flag) []string {356	mut lines := [357		'  ${fmt.bold('[' + save.id + ']')}  ${save.message}',358		'',359		'  time      ${fmt.stamp(save.created_at)}',360		'  size      ${fmt.human_bytes(save.size_bytes)}',361		'  agent     ${or_dash(save.agent)}',362		'  session   ${or_dash(save.session)}',363		'  parent    ${or_dash(save.parent)}',364		'  checksum  ${save.id}',365	]366	if flag.flagged {367		lines << '  flag      ${or_dash(flag.reason)}'368	}369	lines << ''370	if deltas.len == 0 {371		lines << '  ${fmt.dim('nothing changed against the parent')}'372		return lines373	}374	lines << '  ${fmt.plural(deltas.len, 'file')} changed against the parent:'375	for delta in deltas {376		lines << '  ${change_mark(delta)} ${delta.path}${line_stats(delta)}'377	}378	return lines379}380381fn line_stats(delta snapshot.Delta) string {382	if delta.binary {383		return '  ${fmt.dim('(binary)')}'384	}385	return '  ${fmt.green('+' + delta.added.str())} ${fmt.red('-' + delta.removed.str())}'386}387388fn change_mark(delta snapshot.Delta) string {389	return match delta.kind {390		'added' { fmt.green('+') }391		'removed' { fmt.red('-') }392		else { fmt.amber('~') }393	}394}395396// render is a whole frame of the list: tabs, entries, status bar.397pub fn render(screen Screen) string {398	mut out := strings.Builder{}399	out.write_string('\x1b[H')400	for line in frame_lines(screen) {401		out.write_string('\x1b[2K${line}\r\n')402	}403	return out.str()404}405406pub fn frame_lines(screen Screen) []string {407	mut lines := [tab_bar(screen)]408	mut body := []string{}409	for i, save in screen.items {410		body << entry_line(save, i == screen.selected, screen)411		if i == screen.selected {412			body << meta_line(save)413		}414		body << fmt.dim('  ${'─'.repeat(separator_width(screen.cols))}')415	}416	if body.len == 0 {417		body << fmt.dim('  no results')418	}419	room := body_room(screen.rows)420	start := if screen.selected >= room { screen.selected - room + 1 } else { 0 }421	for i := start; i < body.len && lines.len < room + 1; i++ {422		lines << body[i]423	}424	for lines.len < room + 1 {425		lines << ''426	}427	lines << status_bar(screen)428	return lines429}430431fn body_room(rows int) int {432	// The tab bar and the status bar take one row each; two more rows keep the433	// list off the very bottom of the screen.434	if rows <= 4 {435		return 1436	}437	return rows - 3438}439440fn separator_width(cols int) int {441	if cols <= 6 {442		return 1443	}444	return cols - 4445}446447fn tab_bar(screen Screen) string {448	names := ['all', 'saves', 'checkpoints']449	counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]450	mut parts := []string{}451	for i, name in names {452		label := '${name} (${counts[i]})'453		if tab_at(i) == screen.tab {454			parts << fmt.bold('[${label}]')455		} else {456			parts << fmt.dim(label)457		}458	}459	mut bar := '  ${parts.join('  ')}    '460	if screen.query != '' {461		bar += fmt.bold('/${screen.query}_')462	} else {463		bar += fmt.dim('/ search')464	}465	return bar466}467468fn entry_line(save history.Save, selected bool, screen Screen) string {469	mut line := '  ${if selected { fmt.amber('▸') } else { ' ' }} '470	line += '${save_mark(save, screen)} ${fmt.dim('[' + save.id + ']')} '471	line += '${fmt.date_of(save.created_at)} ${fmt.clock_of(save.created_at)}  ${save.message}'472	mut tags := []string{}473	if save.agent != '' || save.session != '' {474		tags << '${or_dash(save.agent)} · ${or_dash(save.session)}'475	}476	if save.restores != '' {477		tags << 'back to ${save.restores}'478	}479	if save.id !in screen.on_line {480		tags << 'replayed away'481	}482	if tags.len > 0 {483		line += '   ${fmt.dim(tags.join('  '))}'484	}485	if selected {486		return fmt.reverse(line)487	}488	return line489}490491fn meta_line(save history.Save) string {492	return fmt.dim('         ${fmt.human_bytes(save.size_bytes)}  ·  agent ${or_dash(save.agent)}  ·  session ${or_dash(save.session)}')493}494495fn status_bar(screen Screen) string {496	mut hints := 'j/k move · / search · tab mode · c copy · enter detail · q quit'497	if screen.status != '' {498		hints = screen.status499	}500	left := 'view: ${tab_name(screen.tab)} · ${hints}'501	right := '${screen.items.len} shown'502	mut gap := screen.cols - fmt.visible_width(left) - fmt.visible_width(right) - 3503	if gap < 1 {504		gap = 1505	}506	return fmt.dim('  ${left}${' '.repeat(gap)}${right}')507}508509fn save_mark(save history.Save, screen Screen) string {510	// Replayed away: still in history, no longer on the line.511	if save.id !in screen.on_line {512		return fmt.dim('↪')513	}514	if save.is_checkpoint {515		return fmt.amber('◐')516	}517	if (screen.flags[save.id] or {518		events.Flag{519			save_id: save.id520		}521	}).flagged {522		return fmt.red('⚑')523	}524	return ' '525}526527fn detail_frame(screen Screen, target history.Save, deltas []snapshot.Delta, asking_reason bool, typed string) string {528	flag := screen.flags[target.id] or {529		events.Flag{530			save_id: target.id531		}532	}533	mut out := strings.Builder{}534	out.write_string('\x1b[H\x1b[2J')535	for line in detail_lines(target, deltas, flag) {536		out.write_string('${line}\r\n')537	}538	out.write_string('\r\n')539	if asking_reason {540		out.write_string('  ${fmt.amber('flag reason')}: ${typed}_')541		return out.str()542	}543	action := if flag.flagged { 'clear flag' } else { 'flag' }544	out.write_string(fmt.dim('  l load · f ${action} · esc back'))545	return out.str()546}547548fn trim_last(text string) string {549	if text.len == 0 {550		return text551	}552	return text[..text.len - 1]553}554555// ask_confirm is the strict prompt inside the view: only a lowercase y.556fn ask_confirm(what string) bool {557	paint('\x1b[H\x1b[2J  ${what} — continue? [y/N] ')558	return read_key().text == 'y'559}560561// copy_id puts the ID on the clipboard through OSC 52, which works over ssh and562// in any terminal without a helper program.563fn copy_id(save_id string) {564	paint('\x1b]52;c;${base64.encode(save_id.bytes())}\x07')565}566567fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {568	if save.parent == '' {569		return snapshot.Tree{}570	}571	return snapshot.Tree{572		files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }573	}574}575576fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {577	return snapshot.Tree{578		files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }579	}580}581582fn on_line_set(store &storage.Store) map[string]bool {583	mut ids := map[string]bool{}584	for save in history.line(store) or { []history.Save{} } {585		ids[save.id] = true586	}587	return ids588}589590fn enter_screen() {591	paint('\x1b[?1049h\x1b[?25l')592	raw_mode(true)593}594595fn leave_screen() {596	raw_mode(false)597	paint('\x1b[?25h\x1b[?1049l')598}599600fn paint(text string) {601	os.fd_write(1, text)602}603604fn tab_at(index int) Tab {605	return match index {606		0 { Tab.all }607		1 { Tab.saves }608		else { Tab.checkpoints }609	}610}611612fn tab_name(tab Tab) string {613	return match tab {614		.all { 'all' }615		.saves { 'saves' }616		else { 'checkpoints' }617	}618}619620fn next_tab(tab Tab) Tab {621	return match tab {622		.all { Tab.saves }623		.saves { Tab.checkpoints }624		else { Tab.all }625	}626}627628fn or_dash(value string) string {629	return if value == '' { '—' } else { value }630}