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 so the list spreads over the whole screen.399fn list_body(screen Screen, body int, cols int) []string {400	if screen.items.len == 0 {401		return ['  ${fmt.muted('no results')}']402	}403	slots := if body / 4 < 6 { body / 4 } else { 6 }404	if slots == 0 {405		return [entry_header(screen.items[screen.selected], true, screen)]406	}407	separator := fmt.muted('─'.repeat(cols))408	start := window_start(screen.selected, slots, screen.items.len)409	mut lines := []string{}410	for slot in 0 .. slots {411		height := body / slots + if slot < body % slots { 1 } else { 0 }412		spare := if height > 4 { height - 4 } else { 0 }413		for _ in 0 .. spare / 2 {414			lines << ''415		}416		index := start + slot417		if index < screen.items.len {418			entry := screen.items[index]419			selected := index == screen.selected420			lines << entry_header(entry, selected, screen)421			lines << entry_message(entry, selected, screen)422			lines << if selected { entry_meta(entry) } else { '' }423		} else {424			lines << ['', '', '']425		}426		lines << separator427		for _ in 0 .. spare - spare / 2 {428			lines << ''429		}430	}431	return lines432}433434// window_start keeps the selected entry near the middle of the visible blocks.435fn window_start(selected int, slots int, count int) int {436	if count <= slots {437		return 0438	}439	mut start := if selected > slots / 2 { selected - slots / 2 } else { 0 }440	if start + slots > count {441		start = count - slots442	}443	return start444}445446// entry_header is the mark, ID, time and date. HEAD has its own mark; every447// other save is a dot. The selected save's ID is gold and everything else is448// greyed out around it.449fn entry_header(save history.Save, selected bool, screen Screen) string {450	head := save.id == screen.head451	flagged := is_flagged(screen, save.id)452	mark := if head { fmt.blue('▸') } else { fmt.muted('·') }453	mut line := '  ${mark} '454	line += if flagged {455		fmt.red(save.id)456	} else if selected {457		fmt.gold(save.id)458	} else {459		fmt.muted(save.id)460	}461	if head {462		line += '  ${fmt.blue('HEAD')}'463	}464	mut labels := []string{}465	if selected && save.is_checkpoint {466		labels << '[checkpoint]'467	}468	if flagged {469		labels << '[flagged]'470	}471	if save.id !in screen.on_line {472		labels << '[replayed away]'473	}474	if labels.len > 0 {475		line += '  ${fmt.muted(labels.join(' '))}'476	}477	stamp := '${fmt.hour_of(save.created_at)}  ${fmt.date_of(save.created_at)}'478	line += '  ${if selected { stamp } else { fmt.muted(stamp) }}'479	return line480}481482fn entry_message(save history.Save, selected bool, screen Screen) string {483	warning := if is_flagged(screen, save.id) { fmt.red('⚠ ') } else { '' }484	return '    ${warning}${if selected { save.message } else { fmt.muted(save.message) }}'485}486487fn entry_meta(save history.Save) string {488	agent := if save.agent == '' { 'unknown' } else { save.agent }489	session := if save.session == '' { 'no session' } else { save.session }490	return '    ${fmt.human_bytes(save.size_bytes)} · ${agent} · ${session}'491}492493fn tab_bar(screen Screen, mode Mode) string {494	names := ['all', 'saves', 'checkpoints']495	counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]496	mut bar := ''497	for i, name in names {498		if tab_at(i) == screen.tab {499			bar += fmt.gold(' [${name} (${counts[i]})] ')500		} else {501			bar += fmt.muted(' ${name} (${counts[i]}) ')502		}503	}504	bar += fmt.muted('│ ')505	if mode == .search {506		bar += '/${screen.query}_'507	} else if screen.query != '' {508		bar += '/${screen.query}'509	} else {510		bar += fmt.muted('/ search')511	}512	return bar513}514515fn status_bar(session Session, cols int) string {516	hint := match session.mode {517		.search {518			'type to filter · enter keep · esc clear'519		}520		.detail {521			flag_word := if is_flagged(session.screen, session.target.id) {522				'clear flag'523			} else {524				'flag'525			}526			'esc back · l load · f ${flag_word} · c copy · q'527		}528		.reason {529			'flag reason: ${session.typed}_ · enter confirm · esc cancel'530		}531		.confirm {532			'${action_word(session.pending.kind)} [${session.pending.save_id}]? y yes · n/esc cancel'533		}534		.list {535			'↑↓ nav · / search · tab mode · c copy · enter detail · q'536		}537	}538	left := ' view: ${tab_name(session.screen.tab)} · ${hint}'539	right := if session.screen.status != '' {540		session.screen.status541	} else if session.mode == .list || session.mode == .search {542		'${session.screen.items.len} shown'543	} else {544		''545	}546	if right == '' {547		return fmt.muted(left)548	}549	// The hints give way to the count or the result of an action, which is550	// the one thing worth reading right after a key.551	room := cols - fmt.visible_width(right) - 1552	shown := fmt.fit(left, if room > 0 { room } else { 0 })553	gap := cols - fmt.visible_width(shown) - fmt.visible_width(right)554	return fmt.muted('${shown}${' '.repeat(if gap > 0 { gap } else { 0 })}${right}')555}556557fn action_word(kind ActionKind) string {558	return match kind {559		.load { 'load' }560		.flag { 'flag' }561		.clear_flag { 'clear the flag on' }562		.none { '' }563	}564}565566// detail_lines describes one save: what it was, and what it changed.567pub fn detail_lines(save history.Save, head string, deltas []snapshot.Delta, flag events.Flag) []string {568	mut title := '  ${fmt.gold(save.id)}'569	if save.id == head {570		title += '  ${fmt.blue('HEAD')}'571	}572	if save.is_checkpoint {573		title += '  [checkpoint]'574	}575	if flag.flagged {576		title += '  [flagged]'577	}578	mut lines := [579		title,580		'  ${save.message}',581		'',582		detail_row('time', '${fmt.date_of(save.created_at)} · ${fmt.clock_of(save.created_at)}'),583		detail_row('size', fmt.human_bytes(save.size_bytes)),584		detail_row('agent', if save.agent == '' { 'unknown' } else { save.agent }),585		detail_row('session', if save.session == '' { 'none' } else { save.session }),586		detail_row('parent', if save.parent == '' { 'root' } else { save.parent }),587		detail_row('checksum', save.id),588	]589	if flag.flagged {590		lines << detail_row('flag', flag.reason)591	}592	lines << ''593	if save.parent == '' {594		lines << '  initial save — no parent to diff against'595		return lines596	}597	lines << '  ${deltas.len} files changed'598	for delta in deltas {599		lines << '  ${change_mark(delta)} ${delta.path}  ${line_stats(delta)}'600	}601	return lines602}603604fn detail_row(label string, value string) string {605	return '  ${fmt.muted(label + ' '.repeat(9 - label.len))}${value}'606}607608fn line_stats(delta snapshot.Delta) string {609	if delta.binary {610		return fmt.muted('(binary)')611	}612	return '${fmt.green('+' + delta.added.str())}  ${fmt.red('-' + delta.removed.str())}'613}614615fn change_mark(delta snapshot.Delta) string {616	return match delta.kind {617		'added' { fmt.green('A') }618		'removed' { fmt.red('R') }619		else { fmt.gold('M') }620	}621}622623fn is_flagged(screen Screen, save_id string) bool {624	return (screen.flags[save_id] or { events.Flag{} }).flagged625}626627fn diff_of(store &storage.Store, save history.Save) []snapshot.Delta {628	return snapshot.diff_trees(store, parent_tree(store, save), child_tree(store, save)) or {629		[]snapshot.Delta{}630	}631}632633fn trim_last(text string) string {634	runes := text.runes()635	if runes.len == 0 {636		return text637	}638	return runes[..runes.len - 1].string()639}640641// copy_selected puts the ID on the clipboard through OSC 52, which works over642// ssh and in any terminal without a helper program.643fn copy_selected(mut screen Screen, save_id string) {644	paint('\x1b]52;c;${base64.encode(save_id.bytes())}\a')645	screen.status = 'copied ${save_id}'646}647648fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {649	if save.parent == '' {650		return snapshot.Tree{}651	}652	return snapshot.Tree{653		files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }654	}655}656657fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {658	return snapshot.Tree{659		files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }660	}661}662663fn on_line_set(store &storage.Store) map[string]bool {664	mut ids := map[string]bool{}665	for save in history.line(store) or { []history.Save{} } {666		ids[save.id] = true667	}668	return ids669}670671fn enter_screen() {672	paint('\x1b[?1049h\x1b[?25l')673	raw_mode(true)674}675676fn leave_screen() {677	raw_mode(false)678	paint('\x1b[?25h\x1b[?1049l')679}680681fn paint(text string) {682	os.fd_write(1, text)683}684685fn tab_at(index int) Tab {686	return match index {687		0 { Tab.all }688		1 { Tab.saves }689		else { Tab.checkpoints }690	}691}692693fn tab_name(tab Tab) string {694	return match tab {695		.all { 'all' }696		.saves { 'saves' }697		else { 'checkpoints' }698	}699}700701fn next_tab(tab Tab) Tab {702	return match tab {703		.all { Tab.saves }704		.saves { Tab.checkpoints }705		else { Tab.all }706	}707}