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. Every key is text here, q included, so a208// search can begin with q; Esc is the way out.209fn search_key(mut session Session, key Key) bool {210	match key.kind {211		.rune {212			session.screen.query += key.text213			session.screen.selected = 0214		}215		.backspace {216			session.screen.query = trim_last(session.screen.query)217			session.screen.selected = 0218		}219		.escape {220			session.screen.query = ''221			session.screen.selected = 0222			session.mode = .list223		}224		.enter {225			session.mode = .list226		}227		else {}228	}229	return false230}231232fn detail_key(mut session Session, key Key) bool {233	match key.kind {234		.escape {235			session.mode = .list236		}237		.rune {238			match key.text {239				'q' {240					return true241				}242				'l' {243					session.pending = Action{244						kind:    .load245						save_id: session.target.id246					}247					session.mode = .confirm248				}249				'f' {250					if is_flagged(session.screen, session.target.id) {251						session.pending = Action{252							kind:    .clear_flag253							save_id: session.target.id254						}255						session.mode = .confirm256					} else {257						session.typed = ''258						session.mode = .reason259					}260				}261				'c' {262					copy_selected(mut session.screen, session.target.id)263				}264				else {}265			}266		}267		else {}268	}269	return false270}271272// reason_key collects a flag's reason. Every key is text here, q included:273// a reason may well say "quick hack".274fn reason_key(mut session Session, key Key) bool {275	match key.kind {276		.rune {277			session.typed += key.text278		}279		.backspace {280			session.typed = trim_last(session.typed)281		}282		.escape {283			session.typed = ''284			session.mode = .detail285		}286		.enter {287			if session.typed != '' {288				session.pending = Action{289					kind:    .flag290					save_id: session.target.id291					reason:  session.typed292				}293				session.typed = ''294				session.mode = .confirm295			}296		}297		else {}298	}299	return false300}301302// confirm_key is the strict confirm: only a lowercase y goes ahead.303fn confirm_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {304	if key.kind == .rune && key.text == 'y' {305		session.screen.status = act(store, session.pending)306		session.stale = true307	}308	session.pending = Action{}309	session.mode = .list310	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// render writes a frame over the last one: each row is cleared before it is363// drawn, and the last row has no newline, so the terminal never scrolls.364pub fn render(lines []string) string {365	mut out := strings.new_builder(4096)366	out.write_string('\x1b[H')367	for i, line in lines {368		out.write_string('\x1b[2K${line}')369		if i < lines.len - 1 {370			out.write_string('\r\n')371		}372	}373	return out.str()374}375376// frame_lines is one whole frame, exactly as tall as the screen and no line377// wider than it: the tab bar, a gap, the body, the status bar.378fn frame_lines(session Session) []string {379	screen := session.screen380	rows := if screen.rows > 4 { screen.rows } else { 4 }381	cols := if screen.cols > 10 { screen.cols } else { 10 }382	body := rows - 3383	mut lines := [tab_bar(screen, session.mode), '']384	if session.mode in [.detail, .reason, .confirm] {385		lines << session.detail#[..body]386	} else {387		lines << list_body(screen, body, cols)388	}389	for lines.len < rows - 1 {390		lines << ''391	}392	lines << status_bar(session, cols)393	return lines.map(fmt.fit(it, cols))394}395396// list_body lays the entries out in up to six equal blocks, each a header, a397// message, the selected entry's details and a separator, with the spare rows398// split above and below. Koh 2 spread the blocks over the whole body; June399// asked for them 15% tighter, which leaves the last rows empty.400fn list_body(screen Screen, body int, cols int) []string {401	if screen.items.len == 0 {402		return ['  ${fmt.muted('no results')}']403	}404	slots := if body / 4 < 6 { body / 4 } else { 6 }405	if slots == 0 {406		return [entry_header(screen.items[screen.selected], true, screen)]407	}408	separator := fmt.muted('─'.repeat(cols))409	start := window_start(screen.selected, slots, screen.items.len)410	mut lines := []string{}411	for slot in 0 .. slots {412		spread := body / slots + if slot < body % slots { 1 } else { 0 }413		tighter := (spread * 85 + 50) / 100414		height := if tighter > 4 { tighter } else { spread }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, 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. As in Koh 2, only the ID tells the selected save apart:451// gold when selected, grey otherwise, while time, date and message stay plain.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 += '  ${labels.join(' ')}'479	}480	return line + '  ${fmt.hour_of(save.created_at)}  ${fmt.date_of(save.created_at)}'481}482483fn entry_message(save history.Save, screen Screen) string {484	warning := if is_flagged(screen, save.id) { fmt.red('⚠ ') } else { '' }485	return '    ${warning}${save.message}'486}487488fn entry_meta(save history.Save) string {489	agent := if save.agent == '' { 'unknown' } else { save.agent }490	session := if save.session == '' { 'no session' } else { save.session }491	return '    ${fmt.human_bytes(save.size_bytes)} · ${agent} · ${session}'492}493494fn tab_bar(screen Screen, mode Mode) string {495	names := ['all', 'saves', 'checkpoints']496	counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]497	mut bar := ''498	for i, name in names {499		if tab_at(i) == screen.tab {500			bar += fmt.gold(' [${name} (${counts[i]})] ')501		} else {502			bar += fmt.muted(' ${name} (${counts[i]}) ')503		}504	}505	bar += fmt.muted('│ ')506	if mode == .search {507		bar += '/${screen.query}_'508	} else if screen.query != '' {509		bar += '/${screen.query}'510	} else {511		bar += fmt.muted('/ search')512	}513	return bar514}515516fn status_bar(session Session, cols int) string {517	hint := match session.mode {518		.search {519			'type to filter · enter keep · esc clear'520		}521		.detail {522			flag_word := if is_flagged(session.screen, session.target.id) {523				'clear flag'524			} else {525				'flag'526			}527			'esc back · l load · f ${flag_word} · c copy · q'528		}529		.reason {530			'flag reason: ${session.typed}_ · enter confirm · esc cancel'531		}532		.confirm {533			'${action_word(session.pending.kind)} [${session.pending.save_id}]? y yes · n/esc cancel'534		}535		.list {536			'↑↓ nav · / search · tab mode · c copy · enter detail · q'537		}538	}539	left := ' view: ${tab_name(session.screen.tab)} · ${hint}'540	right := if session.screen.status != '' {541		session.screen.status542	} else if session.mode == .list || session.mode == .search {543		'${session.screen.items.len} shown'544	} else {545		''546	}547	if right == '' {548		return fmt.muted(left)549	}550	// The hints give way to the count or the result of an action, which is551	// the one thing worth reading right after a key.552	room := cols - fmt.visible_width(right) - 1553	shown := fmt.fit(left, if room > 0 { room } else { 0 })554	gap := cols - fmt.visible_width(shown) - fmt.visible_width(right)555	return fmt.muted('${shown}${' '.repeat(if gap > 0 { gap } else { 0 })}${right}')556}557558fn action_word(kind ActionKind) string {559	return match kind {560		.load { 'load' }561		.flag { 'flag' }562		.clear_flag { 'clear the flag on' }563		.none { '' }564	}565}566567// detail_lines describes one save: what it was, and what it changed.568pub fn detail_lines(save history.Save, head string, deltas []snapshot.Delta, flag events.Flag) []string {569	mut title := '  ${fmt.gold(save.id)}'570	if save.id == head {571		title += '  ${fmt.blue('HEAD')}'572	}573	if save.is_checkpoint {574		title += '  [checkpoint]'575	}576	if flag.flagged {577		title += '  [flagged]'578	}579	mut lines := [580		title,581		'  ${save.message}',582		'',583		detail_row('time', '${fmt.date_of(save.created_at)} · ${fmt.clock_of(save.created_at)}'),584		detail_row('size', fmt.human_bytes(save.size_bytes)),585		detail_row('agent', if save.agent == '' { 'unknown' } else { save.agent }),586		detail_row('session', if save.session == '' { 'none' } else { save.session }),587		detail_row('parent', if save.parent == '' { 'root' } else { save.parent }),588		detail_row('checksum', save.id),589	]590	if flag.flagged {591		lines << detail_row('flag', flag.reason)592	}593	lines << ''594	if save.parent == '' {595		lines << '  initial save — no parent to diff against'596		return lines597	}598	lines << '  ${deltas.len} files changed'599	for delta in deltas {600		lines << '  ${change_mark(delta)} ${delta.path}  ${line_stats(delta)}'601	}602	return lines603}604605fn detail_row(label string, value string) string {606	return '  ${fmt.muted(label + ' '.repeat(9 - label.len))}${value}'607}608609fn line_stats(delta snapshot.Delta) string {610	if delta.binary {611		return fmt.muted('(binary)')612	}613	return '${fmt.green('+' + delta.added.str())}  ${fmt.red('-' + delta.removed.str())}'614}615616fn change_mark(delta snapshot.Delta) string {617	return match delta.kind {618		'added' { fmt.green('A') }619		'removed' { fmt.red('R') }620		else { fmt.gold('M') }621	}622}623624fn is_flagged(screen Screen, save_id string) bool {625	return (screen.flags[save_id] or { events.Flag{} }).flagged626}627628fn diff_of(store &storage.Store, save history.Save) []snapshot.Delta {629	return snapshot.diff_trees(store, parent_tree(store, save), child_tree(store, save)) or {630		[]snapshot.Delta{}631	}632}633634fn trim_last(text string) string {635	runes := text.runes()636	if runes.len == 0 {637		return text638	}639	return runes[..runes.len - 1].string()640}641642// copy_selected puts the ID on the clipboard through OSC 52, which works over643// ssh and in any terminal without a helper program.644fn copy_selected(mut screen Screen, save_id string) {645	paint('\x1b]52;c;${base64.encode(save_id.bytes())}\a')646	screen.status = 'copied ${save_id}'647}648649fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {650	if save.parent == '' {651		return snapshot.Tree{}652	}653	return snapshot.Tree{654		files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }655	}656}657658fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {659	return snapshot.Tree{660		files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }661	}662}663664fn on_line_set(store &storage.Store) map[string]bool {665	mut ids := map[string]bool{}666	for save in history.line(store) or { []history.Save{} } {667		ids[save.id] = true668	}669	return ids670}671672fn enter_screen() {673	paint('\x1b[?1049h\x1b[?25l')674	raw_mode(true)675}676677fn leave_screen() {678	raw_mode(false)679	paint('\x1b[?25h\x1b[?1049l')680}681682fn paint(text string) {683	os.fd_write(1, text)684}685686fn tab_at(index int) Tab {687	return match index {688		0 { Tab.all }689		1 { Tab.saves }690		else { Tab.checkpoints }691	}692}693694fn tab_name(tab Tab) string {695	return match tab {696		.all { 'all' }697		.saves { 'saves' }698		else { 'checkpoints' }699	}700}701702fn next_tab(tab Tab) Tab {703	return match tab {704		.all { Tab.saves }705		.saves { Tab.checkpoints }706		else { Tab.all }707	}708}