view.v

18.1 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 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}151152fn list_key(mut session Session, key Key) bool {153	count := session.screen.items.len154	match key.kind {155		.tab {156			session.screen.tab = next_tab(session.screen.tab)157			session.screen.selected = 0158		}159		.down {160			session.screen.selected = move_selection(session.screen.selected, 1, count)161		}162		.up {163			session.screen.selected = move_selection(session.screen.selected, -1, count)164		}165		.enter {166			if count > 0 {167				session.target = session.screen.items[session.screen.selected]168				session.mode = .detail169			}170		}171		.rune {172			match key.text {173				'q' {174					return true175				}176				'j' {177					session.screen.selected = move_selection(session.screen.selected, 1, count)178				}179				'k' {180					session.screen.selected = move_selection(session.screen.selected, -1,181						count)182				}183				// The list is newest first: g is the oldest save, G the newest.184				'g' {185					session.screen.selected = move_selection(count, -1, count)186				}187				'G' {188					session.screen.selected = 0189				}190				'/' {191					session.mode = .search192				}193				'c' {194					if count > 0 {195						copy_selected(mut session.screen,196							session.screen.items[session.screen.selected].id)197					}198				}199				else {}200			}201		}202		else {}203	}204	return false205}206207// search_key edits the query. q still quits, as in Koh 2: a view you cannot208// leave is worse than a search that cannot contain q.209fn search_key(mut session Session, key Key) bool {210	match key.kind {211		.rune {212			if key.text == 'q' {213				return true214			}215			session.screen.query += key.text216			session.screen.selected = 0217		}218		.backspace {219			session.screen.query = trim_last(session.screen.query)220			session.screen.selected = 0221		}222		.escape {223			session.screen.query = ''224			session.screen.selected = 0225			session.mode = .list226		}227		.enter {228			session.mode = .list229		}230		else {}231	}232	return false233}234235fn detail_key(mut session Session, key Key) bool {236	match key.kind {237		.escape {238			session.mode = .list239		}240		.rune {241			match key.text {242				'q' {243					return true244				}245				'l' {246					session.pending = Action{247						kind:    .load248						save_id: session.target.id249					}250					session.mode = .confirm251				}252				'f' {253					if is_flagged(session.screen, session.target.id) {254						session.pending = Action{255							kind:    .clear_flag256							save_id: session.target.id257						}258						session.mode = .confirm259					} else {260						session.typed = ''261						session.mode = .reason262					}263				}264				'c' {265					copy_selected(mut session.screen, session.target.id)266				}267				else {}268			}269		}270		else {}271	}272	return false273}274275// reason_key collects a flag's reason. Every key is text here, q included:276// a reason may well say "quick hack".277fn reason_key(mut session Session, key Key) bool {278	match key.kind {279		.rune {280			session.typed += key.text281		}282		.backspace {283			session.typed = trim_last(session.typed)284		}285		.escape {286			session.typed = ''287			session.mode = .detail288		}289		.enter {290			if session.typed != '' {291				session.pending = Action{292					kind:    .flag293					save_id: session.target.id294					reason:  session.typed295				}296				session.typed = ''297				session.mode = .confirm298			}299		}300		else {}301	}302	return false303}304305// confirm_key is the strict confirm: only a lowercase y goes ahead.306fn confirm_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {307	if key.kind == .rune && key.text == 'y' {308		session.screen.status = act(store, session.pending)309		session.stale = true310	}311	session.pending = Action{}312	session.mode = .list313	return false314}315316// filter_saves applies a tab and the search text. `on_line` marks the saves317// still on the line: a replayed-away save exists in history but belongs to318// neither the saves nor the checkpoints list.319pub fn filter_saves(items []history.Save, tab Tab, on_line map[string]bool, query string) []history.Save {320	mut out := []history.Save{}321	needle := query.to_lower()322	for save in items {323		live := save.id in on_line324		if tab == .saves && !live {325			continue326		}327		if tab == .checkpoints && (!live || !save.is_checkpoint) {328			continue329		}330		if needle != '' && !searchable(save).contains(needle) {331			continue332		}333		out << save334	}335	return out336}337338fn searchable(save history.Save) string {339	return '${save.id} ${save.message} ${save.agent} ${save.session}'.to_lower()340}341342pub fn count_tabs(items []history.Save, on_line map[string]bool) TabCounts {343	return TabCounts{344		all:         filter_saves(items, .all, on_line, '').len345		saves:       filter_saves(items, .saves, on_line, '').len346		checkpoints: filter_saves(items, .checkpoints, on_line, '').len347	}348}349350// move_selection clamps a move to the ends of the list.351pub fn move_selection(index int, delta int, length int) int {352	if length <= 0 {353		return 0354	}355	moved := index + delta356	if moved < 0 {357		return 0358	}359	if moved >= length {360		return length - 1361	}362	return moved363}364365// render writes a frame over the last one: each row is cleared before it is366// drawn, and the last row has no newline, so the terminal never scrolls.367pub fn render(lines []string) string {368	mut out := strings.new_builder(4096)369	out.write_string('\x1b[H')370	for i, line in lines {371		out.write_string('\x1b[2K${line}')372		if i < lines.len - 1 {373			out.write_string('\r\n')374		}375	}376	return out.str()377}378379// frame_lines is one whole frame, exactly as tall as the screen and no line380// wider than it: the tab bar, a gap, the body, the status bar.381fn frame_lines(session Session) []string {382	screen := session.screen383	rows := if screen.rows > 4 { screen.rows } else { 4 }384	cols := if screen.cols > 10 { screen.cols } else { 10 }385	body := rows - 3386	mut lines := [tab_bar(screen, session.mode), '']387	if session.mode in [.detail, .reason, .confirm] {388		lines << session.detail#[..body]389	} else {390		lines << list_body(screen, body, cols)391	}392	for lines.len < rows - 1 {393		lines << ''394	}395	lines << status_bar(session, cols)396	return lines.map(fmt.fit(it, cols))397}398399// list_body lays the entries out in up to six equal blocks, each a header, a400// message, the selected entry's details and a separator, with the spare rows401// split above and below so the list spreads over the whole screen.402fn list_body(screen Screen, body int, cols int) []string {403	if screen.items.len == 0 {404		return ['  ${fmt.muted('no results')}']405	}406	slots := if body / 4 < 6 { body / 4 } else { 6 }407	if slots == 0 {408		return [entry_header(screen.items[screen.selected], true, screen)]409	}410	separator := fmt.muted('─'.repeat(cols))411	start := window_start(screen.selected, slots, screen.items.len)412	mut lines := []string{}413	for slot in 0 .. slots {414		height := body / slots + if slot < body % slots { 1 } else { 0 }415		spare := if height > 4 { height - 4 } else { 0 }416		for _ in 0 .. spare / 2 {417			lines << ''418		}419		index := start + slot420		if index < screen.items.len {421			entry := screen.items[index]422			selected := index == screen.selected423			lines << entry_header(entry, selected, screen)424			lines << entry_message(entry, selected, screen)425			lines << if selected { entry_meta(entry) } else { '' }426		} else {427			lines << ['', '', '']428		}429		lines << separator430		for _ in 0 .. spare - spare / 2 {431			lines << ''432		}433	}434	return lines435}436437// window_start keeps the selected entry near the middle of the visible blocks.438fn window_start(selected int, slots int, count int) int {439	if count <= slots {440		return 0441	}442	mut start := if selected > slots / 2 { selected - slots / 2 } else { 0 }443	if start + slots > count {444		start = count - slots445	}446	return start447}448449// entry_header is the mark, ID, time and date. HEAD has its own mark; every450// other save is a dot. The selected save's ID is gold and everything else is451// greyed out around it.452fn entry_header(save history.Save, selected bool, screen Screen) string {453	head := save.id == screen.head454	flagged := is_flagged(screen, save.id)455	mark := if head { fmt.blue('▸') } else { fmt.muted('·') }456	mut line := '  ${mark} '457	line += if flagged {458		fmt.red(save.id)459	} else if selected {460		fmt.gold(save.id)461	} else {462		fmt.muted(save.id)463	}464	if head {465		line += '  ${fmt.blue('HEAD')}'466	}467	mut labels := []string{}468	if selected && save.is_checkpoint {469		labels << '[checkpoint]'470	}471	if flagged {472		labels << '[flagged]'473	}474	if save.id !in screen.on_line {475		labels << '[replayed away]'476	}477	if labels.len > 0 {478		line += '  ${fmt.muted(labels.join(' '))}'479	}480	stamp := '${fmt.hour_of(save.created_at)}  ${fmt.date_of(save.created_at)}'481	line += '  ${if selected { stamp } else { fmt.muted(stamp) }}'482	return line483}484485fn entry_message(save history.Save, selected bool, screen Screen) string {486	warning := if is_flagged(screen, save.id) { fmt.red('⚠ ') } else { '' }487	return '    ${warning}${if selected { save.message } else { fmt.muted(save.message) }}'488}489490fn entry_meta(save history.Save) string {491	agent := if save.agent == '' { 'unknown' } else { save.agent }492	session := if save.session == '' { 'no session' } else { save.session }493	return '    ${fmt.human_bytes(save.size_bytes)} · ${agent} · ${session}'494}495496fn tab_bar(screen Screen, mode Mode) string {497	names := ['all', 'saves', 'checkpoints']498	counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]499	mut bar := ''500	for i, name in names {501		if tab_at(i) == screen.tab {502			bar += fmt.gold(' [${name} (${counts[i]})] ')503		} else {504			bar += fmt.muted(' ${name} (${counts[i]}) ')505		}506	}507	bar += fmt.muted('│ ')508	if mode == .search {509		bar += '/${screen.query}_'510	} else if screen.query != '' {511		bar += '/${screen.query}'512	} else {513		bar += fmt.muted('/ search')514	}515	return bar516}517518fn status_bar(session Session, cols int) string {519	hint := match session.mode {520		.search {521			'type to filter · enter keep · esc clear'522		}523		.detail {524			flag_word := if is_flagged(session.screen, session.target.id) {525				'clear flag'526			} else {527				'flag'528			}529			'esc back · l load · f ${flag_word} · c copy · q'530		}531		.reason {532			'flag reason: ${session.typed}_ · enter confirm · esc cancel'533		}534		.confirm {535			'${action_word(session.pending.kind)} [${session.pending.save_id}]? y yes · n/esc cancel'536		}537		.list {538			'↑↓ nav · / search · tab mode · c copy · enter detail · q'539		}540	}541	left := ' view: ${tab_name(session.screen.tab)} · ${hint}'542	right := if session.screen.status != '' {543		session.screen.status544	} else if session.mode == .list || session.mode == .search {545		'${session.screen.items.len} shown'546	} else {547		''548	}549	if right == '' {550		return fmt.muted(left)551	}552	// The hints give way to the count or the result of an action, which is553	// the one thing worth reading right after a key.554	room := cols - fmt.visible_width(right) - 1555	shown := fmt.fit(left, if room > 0 { room } else { 0 })556	gap := cols - fmt.visible_width(shown) - fmt.visible_width(right)557	return fmt.muted('${shown}${' '.repeat(if gap > 0 { gap } else { 0 })}${right}')558}559560fn action_word(kind ActionKind) string {561	return match kind {562		.load { 'load' }563		.flag { 'flag' }564		.clear_flag { 'clear the flag on' }565		.none { '' }566	}567}568569// detail_lines describes one save: what it was, and what it changed.570pub fn detail_lines(save history.Save, head string, deltas []snapshot.Delta, flag events.Flag) []string {571	mut title := '  ${fmt.gold(save.id)}'572	if save.id == head {573		title += '  ${fmt.blue('HEAD')}'574	}575	if save.is_checkpoint {576		title += '  [checkpoint]'577	}578	if flag.flagged {579		title += '  [flagged]'580	}581	mut lines := [582		title,583		'  ${save.message}',584		'',585		detail_row('time', '${fmt.date_of(save.created_at)} · ${fmt.clock_of(save.created_at)}'),586		detail_row('size', fmt.human_bytes(save.size_bytes)),587		detail_row('agent', if save.agent == '' { 'unknown' } else { save.agent }),588		detail_row('session', if save.session == '' { 'none' } else { save.session }),589		detail_row('parent', if save.parent == '' { 'root' } else { save.parent }),590		detail_row('checksum', save.id),591	]592	if flag.flagged {593		lines << detail_row('flag', flag.reason)594	}595	lines << ''596	if save.parent == '' {597		lines << '  initial save — no parent to diff against'598		return lines599	}600	lines << '  ${deltas.len} files changed'601	for delta in deltas {602		lines << '  ${change_mark(delta)} ${delta.path}  ${line_stats(delta)}'603	}604	return lines605}606607fn detail_row(label string, value string) string {608	return '  ${fmt.muted(label + ' '.repeat(9 - label.len))}${value}'609}610611fn line_stats(delta snapshot.Delta) string {612	if delta.binary {613		return fmt.muted('(binary)')614	}615	return '${fmt.green('+' + delta.added.str())}  ${fmt.red('-' + delta.removed.str())}'616}617618fn change_mark(delta snapshot.Delta) string {619	return match delta.kind {620		'added' { fmt.green('A') }621		'removed' { fmt.red('R') }622		else { fmt.gold('M') }623	}624}625626fn is_flagged(screen Screen, save_id string) bool {627	return (screen.flags[save_id] or { events.Flag{} }).flagged628}629630fn diff_of(store &storage.Store, save history.Save) []snapshot.Delta {631	return snapshot.diff_trees(store, parent_tree(store, save), child_tree(store, save)) or {632		[]snapshot.Delta{}633	}634}635636fn trim_last(text string) string {637	runes := text.runes()638	if runes.len == 0 {639		return text640	}641	return runes[..runes.len - 1].string()642}643644// copy_selected puts the ID on the clipboard through OSC 52, which works over645// ssh and in any terminal without a helper program.646fn copy_selected(mut screen Screen, save_id string) {647	paint('\x1b]52;c;${base64.encode(save_id.bytes())}\a')648	screen.status = 'copied ${save_id}'649}650651fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {652	if save.parent == '' {653		return snapshot.Tree{}654	}655	return snapshot.Tree{656		files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }657	}658}659660fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {661	return snapshot.Tree{662		files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }663	}664}665666fn on_line_set(store &storage.Store) map[string]bool {667	mut ids := map[string]bool{}668	for save in history.line(store) or { []history.Save{} } {669		ids[save.id] = true670	}671	return ids672}673674fn enter_screen() {675	paint('\x1b[?1049h\x1b[?25l')676	raw_mode(true)677}678679fn leave_screen() {680	raw_mode(false)681	paint('\x1b[?25h\x1b[?1049l')682}683684fn paint(text string) {685	os.fd_write(1, text)686}687688fn tab_at(index int) Tab {689	return match index {690		0 { Tab.all }691		1 { Tab.saves }692		else { Tab.checkpoints }693	}694}695696fn tab_name(tab Tab) string {697	return match tab {698		.all { 'all' }699		.saves { 'saves' }700		else { 'checkpoints' }701	}702}703704fn next_tab(tab Tab) Tab {705	return match tab {706		.all { Tab.saves }707		.saves { Tab.checkpoints }708		else { Tab.all }709	}710}