view.v

18.3 KB · raw

1//! The history view: the saves and checkpoints list, full screen.2//!3//! On a terminal `koh view` opens this instead of printing; `--checkpoints`4//! opens it on the checkpoints 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		// Esc goes back one step: a kept search filter first, then the view.172		.escape {173			if session.screen.query == '' {174				return true175			}176			session.screen.query = ''177			session.screen.selected = 0178		}179		.rune {180			match key.text {181				'q' {182					return true183				}184				'j' {185					session.screen.selected = move_selection(session.screen.selected, 1, count)186				}187				'k' {188					session.screen.selected = move_selection(session.screen.selected, -1,189						count)190				}191				// The list is newest first: g is the oldest save, G the newest.192				'g' {193					session.screen.selected = move_selection(count, -1, count)194				}195				'G' {196					session.screen.selected = 0197				}198				'/' {199					session.mode = .search200				}201				'c' {202					if count > 0 {203						copy_selected(mut session.screen,204							session.screen.items[session.screen.selected].id)205					}206				}207				else {}208			}209		}210		else {}211	}212	return false213}214215// search_key edits the query. Every key is text here, q included, so a216// search can begin with q; Esc is the way out.217fn search_key(mut session Session, key Key) bool {218	match key.kind {219		.rune {220			session.screen.query += key.text221			session.screen.selected = 0222		}223		.backspace {224			session.screen.query = trim_last(session.screen.query)225			session.screen.selected = 0226		}227		.escape {228			session.screen.query = ''229			session.screen.selected = 0230			session.mode = .list231		}232		.enter {233			session.mode = .list234		}235		else {}236	}237	return false238}239240fn detail_key(mut session Session, key Key) bool {241	match key.kind {242		.escape {243			session.mode = .list244		}245		.rune {246			match key.text {247				'q' {248					return true249				}250				'l' {251					session.pending = Action{252						kind:    .load253						save_id: session.target.id254					}255					session.mode = .confirm256				}257				'f' {258					if is_flagged(session.screen, session.target.id) {259						session.pending = Action{260							kind:    .clear_flag261							save_id: session.target.id262						}263						session.mode = .confirm264					} else {265						session.typed = ''266						session.mode = .reason267					}268				}269				'c' {270					copy_selected(mut session.screen, session.target.id)271				}272				else {}273			}274		}275		else {}276	}277	return false278}279280// reason_key collects a flag's reason. Every key is text here, q included:281// a reason may well say "quick hack".282fn reason_key(mut session Session, key Key) bool {283	match key.kind {284		.rune {285			session.typed += key.text286		}287		.backspace {288			session.typed = trim_last(session.typed)289		}290		.escape {291			session.typed = ''292			session.mode = .detail293		}294		.enter {295			if session.typed != '' {296				session.pending = Action{297					kind:    .flag298					save_id: session.target.id299					reason:  session.typed300				}301				session.typed = ''302				session.mode = .confirm303			}304		}305		else {}306	}307	return false308}309310// confirm_key is the strict confirm: only a lowercase y goes ahead.311fn confirm_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {312	if key.kind == .rune && key.text == 'y' {313		session.screen.status = act(store, session.pending)314		session.stale = true315	}316	session.pending = Action{}317	session.mode = .list318	return false319}320321// filter_saves applies a tab and the search text. `on_line` marks the saves322// still on the line: a replayed-away save exists in history but belongs to323// neither the saves nor the checkpoints list.324pub fn filter_saves(items []history.Save, tab Tab, on_line map[string]bool, query string) []history.Save {325	mut out := []history.Save{}326	needle := query.to_lower()327	for save in items {328		live := save.id in on_line329		if tab == .saves && (!live || save.is_checkpoint) {330			continue331		}332		if tab == .checkpoints && (!live || !save.is_checkpoint) {333			continue334		}335		if needle != '' && !searchable(save).contains(needle) {336			continue337		}338		out << save339	}340	return out341}342343fn searchable(save history.Save) string {344	return '${save.id} ${save.message} ${save.agent} ${save.session}'.to_lower()345}346347pub fn count_tabs(items []history.Save, on_line map[string]bool) TabCounts {348	return TabCounts{349		all:         filter_saves(items, .all, on_line, '').len350		saves:       filter_saves(items, .saves, on_line, '').len351		checkpoints: filter_saves(items, .checkpoints, on_line, '').len352	}353}354355// move_selection clamps a move to the ends of the list.356pub fn move_selection(index int, delta int, length int) int {357	if length <= 0 {358		return 0359	}360	moved := index + delta361	if moved < 0 {362		return 0363	}364	if moved >= length {365		return length - 1366	}367	return moved368}369370// render writes a frame over the last one: each row is cleared before it is371// drawn, and the last row has no newline, so the terminal never scrolls.372pub fn render(lines []string) string {373	mut out := strings.new_builder(4096)374	out.write_string('\x1b[H')375	for i, line in lines {376		out.write_string('\x1b[2K${line}')377		if i < lines.len - 1 {378			out.write_string('\r\n')379		}380	}381	return out.str()382}383384// frame_lines is one whole frame, exactly as tall as the screen and no line385// wider than it: the tab bar, a gap, the body, the status bar.386fn frame_lines(session Session) []string {387	screen := session.screen388	rows := if screen.rows > 4 { screen.rows } else { 4 }389	cols := if screen.cols > 10 { screen.cols } else { 10 }390	body := rows - 3391	mut lines := [tab_bar(screen, session.mode), '']392	if session.mode in [.detail, .reason, .confirm] {393		lines << session.detail#[..body]394	} else {395		lines << list_body(screen, body, cols)396	}397	for lines.len < rows - 1 {398		lines << ''399	}400	lines << status_bar(session, cols)401	return lines.map(fmt.fit(it, cols))402}403404// list_body lays the entries out in up to seven equal blocks, each a header,405// a message, the selected entry's details and a separator, with the spare rows406// split above and below so the list fills the screen. Koh 2 had six; seven is407// the 15% tighter spacing June asked for, without leaving an empty band.408fn list_body(screen Screen, body int, cols int) []string {409	if screen.items.len == 0 {410		return ['  ${fmt.muted('no results')}']411	}412	slots := if body / 4 < 7 { body / 4 } else { 7 }413	if slots == 0 {414		return [entry_header(screen.items[screen.selected], true, screen)]415	}416	separator := fmt.muted('─'.repeat(cols))417	start := window_start(screen.selected, slots, screen.items.len)418	mut lines := []string{}419	for slot in 0 .. slots {420		height := body / slots + if slot < body % slots { 1 } else { 0 }421		spare := if height > 4 { height - 4 } else { 0 }422		for _ in 0 .. spare / 2 {423			lines << ''424		}425		index := start + slot426		if index < screen.items.len {427			entry := screen.items[index]428			selected := index == screen.selected429			lines << entry_header(entry, selected, screen)430			lines << entry_message(entry, screen)431			lines << if selected { entry_meta(entry) } else { '' }432		} else {433			lines << ['', '', '']434		}435		lines << separator436		for _ in 0 .. spare - spare / 2 {437			lines << ''438		}439	}440	return lines441}442443// window_start keeps the selected entry near the middle of the visible blocks.444fn window_start(selected int, slots int, count int) int {445	if count <= slots {446		return 0447	}448	mut start := if selected > slots / 2 { selected - slots / 2 } else { 0 }449	if start + slots > count {450		start = count - slots451	}452	return start453}454455// entry_header is the mark, ID, time and date. HEAD has its own mark; every456// other save is a dot. As in Koh 2, only the ID tells the selected save apart:457// gold when selected, grey otherwise, while time, date and message stay plain.458fn entry_header(save history.Save, selected bool, screen Screen) string {459	head := save.id == screen.head460	flagged := is_flagged(screen, save.id)461	mark := if head { fmt.blue('▸') } else { fmt.muted('·') }462	mut line := '  ${mark} '463	line += if flagged {464		fmt.red(save.id)465	} else if selected {466		fmt.gold(save.id)467	} else {468		fmt.muted(save.id)469	}470	if head {471		line += '  ${fmt.blue('HEAD')}'472	}473	mut labels := []string{}474	if selected && save.is_checkpoint {475		labels << '[checkpoint]'476	}477	if flagged {478		labels << '[flagged]'479	}480	if save.id !in screen.on_line {481		labels << '[replayed away]'482	}483	if labels.len > 0 {484		line += '  ${labels.join(' ')}'485	}486	return line + '  ${fmt.hour_of(save.created_at)}  ${fmt.date_of(save.created_at)}'487}488489fn entry_message(save history.Save, screen Screen) string {490	warning := if is_flagged(screen, save.id) { fmt.red('⚠ ') } else { '' }491	return '    ${warning}${save.message}'492}493494fn entry_meta(save history.Save) string {495	agent := if save.agent == '' { 'unknown' } else { save.agent }496	session := if save.session == '' { 'no session' } else { save.session }497	return '    ${fmt.human_bytes(save.size_bytes)} · ${agent} · ${session}'498}499500fn tab_bar(screen Screen, mode Mode) string {501	names := ['all', 'saves', 'checkpoints']502	counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]503	mut bar := ''504	for i, name in names {505		if tab_at(i) == screen.tab {506			bar += fmt.gold(' [${name} (${counts[i]})] ')507		} else {508			bar += fmt.muted(' ${name} (${counts[i]}) ')509		}510	}511	bar += fmt.muted('│ ')512	if mode == .search {513		bar += '/${screen.query}_'514	} else if screen.query != '' {515		bar += '/${screen.query}'516	} else {517		bar += fmt.muted('/ search')518	}519	return bar520}521522fn status_bar(session Session, cols int) string {523	hint := match session.mode {524		.search {525			'type to filter · enter keep · esc clear'526		}527		.detail {528			flag_word := if is_flagged(session.screen, session.target.id) {529				'clear flag'530			} else {531				'flag'532			}533			'esc back · l load · f ${flag_word} · c copy · q'534		}535		.reason {536			'flag reason: ${session.typed}_ · enter confirm · esc cancel'537		}538		.confirm {539			'${action_word(session.pending.kind)} [${session.pending.save_id}]? y yes · n/esc cancel'540		}541		.list {542			'↑↓ nav · / search · tab mode · c copy · enter detail · q/esc'543		}544	}545	left := ' view: ${tab_name(session.screen.tab)} · ${hint}'546	right := if session.screen.status != '' {547		session.screen.status548	} else if session.mode == .list || session.mode == .search {549		'${session.screen.items.len} shown'550	} else {551		''552	}553	if right == '' {554		return fmt.muted(left)555	}556	// The hints give way to the count or the result of an action, which is557	// the one thing worth reading right after a key.558	room := cols - fmt.visible_width(right) - 1559	shown := fmt.fit(left, if room > 0 { room } else { 0 })560	gap := cols - fmt.visible_width(shown) - fmt.visible_width(right)561	return fmt.muted('${shown}${' '.repeat(if gap > 0 { gap } else { 0 })}${right}')562}563564fn action_word(kind ActionKind) string {565	return match kind {566		.load { 'load' }567		.flag { 'flag' }568		.clear_flag { 'clear the flag on' }569		.none { '' }570	}571}572573// detail_lines describes one save: what it was, and what it changed.574pub fn detail_lines(save history.Save, head string, deltas []snapshot.Delta, flag events.Flag) []string {575	mut title := '  ${fmt.gold(save.id)}'576	if save.id == head {577		title += '  ${fmt.blue('HEAD')}'578	}579	if save.is_checkpoint {580		title += '  [checkpoint]'581	}582	if flag.flagged {583		title += '  [flagged]'584	}585	mut lines := [586		title,587		'  ${save.message}',588		'',589		detail_row('time', '${fmt.date_of(save.created_at)} · ${fmt.clock_of(save.created_at)}'),590		detail_row('size', fmt.human_bytes(save.size_bytes)),591		detail_row('agent', if save.agent == '' { 'unknown' } else { save.agent }),592		detail_row('session', if save.session == '' { 'none' } else { save.session }),593		detail_row('parent', if save.parent == '' { 'root' } else { save.parent }),594		detail_row('checksum', save.id),595	]596	if flag.flagged {597		lines << detail_row('flag', flag.reason)598	}599	lines << ''600	if save.parent == '' {601		lines << '  initial save — no parent to diff against'602		return lines603	}604	lines << '  ${deltas.len} files changed'605	for delta in deltas {606		lines << '  ${change_mark(delta)} ${delta.path}  ${line_stats(delta)}'607	}608	return lines609}610611fn detail_row(label string, value string) string {612	return '  ${fmt.muted(label + ' '.repeat(9 - label.len))}${value}'613}614615fn line_stats(delta snapshot.Delta) string {616	if delta.binary {617		return fmt.muted('(binary)')618	}619	return '${fmt.green('+' + delta.added.str())}  ${fmt.red('-' + delta.removed.str())}'620}621622fn change_mark(delta snapshot.Delta) string {623	return match delta.kind {624		'added' { fmt.green('A') }625		'removed' { fmt.red('R') }626		else { fmt.gold('M') }627	}628}629630fn is_flagged(screen Screen, save_id string) bool {631	return (screen.flags[save_id] or { events.Flag{} }).flagged632}633634fn diff_of(store &storage.Store, save history.Save) []snapshot.Delta {635	return snapshot.diff_trees(store, parent_tree(store, save), child_tree(store, save)) or {636		[]snapshot.Delta{}637	}638}639640fn trim_last(text string) string {641	runes := text.runes()642	if runes.len == 0 {643		return text644	}645	return runes[..runes.len - 1].string()646}647648// copy_selected puts the ID on the clipboard through OSC 52, which works over649// ssh and in any terminal without a helper program.650fn copy_selected(mut screen Screen, save_id string) {651	paint('\x1b]52;c;${base64.encode(save_id.bytes())}\a')652	screen.status = 'copied ${save_id}'653}654655fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {656	if save.parent == '' {657		return snapshot.Tree{}658	}659	return snapshot.Tree{660		files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }661	}662}663664fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {665	return snapshot.Tree{666		files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }667	}668}669670fn on_line_set(store &storage.Store) map[string]bool {671	mut ids := map[string]bool{}672	for save in history.line(store) or { []history.Save{} } {673		ids[save.id] = true674	}675	return ids676}677678fn enter_screen() {679	paint('\x1b[?1049h\x1b[?25l')680	raw_mode(true)681}682683fn leave_screen() {684	raw_mode(false)685	paint('\x1b[?25h\x1b[?1049l')686}687688fn paint(text string) {689	os.fd_write(1, text)690}691692fn tab_at(index int) Tab {693	return match index {694		0 { Tab.all }695		1 { Tab.saves }696		else { Tab.checkpoints }697	}698}699700fn tab_name(tab Tab) string {701	return match tab {702		.all { 'all' }703		.saves { 'saves' }704		else { 'checkpoints' }705	}706}707708fn next_tab(tab Tab) Tab {709	return match tab {710		.all { Tab.saves }711		.saves { Tab.checkpoints }712		else { Tab.all }713	}714}