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 seven equal blocks, each a header,397// a message, the selected entry's details and a separator, with the spare rows398// split above and below so the list fills the screen. Koh 2 had six; seven is399// the 15% tighter spacing June asked for, without leaving an empty band.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 < 7 { body / 4 } else { 7 }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		height := body / slots + if slot < body % slots { 1 } else { 0 }413		spare := if height > 4 { height - 4 } else { 0 }414		for _ in 0 .. spare / 2 {415			lines << ''416		}417		index := start + slot418		if index < screen.items.len {419			entry := screen.items[index]420			selected := index == screen.selected421			lines << entry_header(entry, selected, screen)422			lines << entry_message(entry, screen)423			lines << if selected { entry_meta(entry) } else { '' }424		} else {425			lines << ['', '', '']426		}427		lines << separator428		for _ in 0 .. spare - spare / 2 {429			lines << ''430		}431	}432	return lines433}434435// window_start keeps the selected entry near the middle of the visible blocks.436fn window_start(selected int, slots int, count int) int {437	if count <= slots {438		return 0439	}440	mut start := if selected > slots / 2 { selected - slots / 2 } else { 0 }441	if start + slots > count {442		start = count - slots443	}444	return start445}446447// entry_header is the mark, ID, time and date. HEAD has its own mark; every448// other save is a dot. As in Koh 2, only the ID tells the selected save apart:449// gold when selected, grey otherwise, while time, date and message stay plain.450fn entry_header(save history.Save, selected bool, screen Screen) string {451	head := save.id == screen.head452	flagged := is_flagged(screen, save.id)453	mark := if head { fmt.blue('▸') } else { fmt.muted('·') }454	mut line := '  ${mark} '455	line += if flagged {456		fmt.red(save.id)457	} else if selected {458		fmt.gold(save.id)459	} else {460		fmt.muted(save.id)461	}462	if head {463		line += '  ${fmt.blue('HEAD')}'464	}465	mut labels := []string{}466	if selected && save.is_checkpoint {467		labels << '[checkpoint]'468	}469	if flagged {470		labels << '[flagged]'471	}472	if save.id !in screen.on_line {473		labels << '[replayed away]'474	}475	if labels.len > 0 {476		line += '  ${labels.join(' ')}'477	}478	return line + '  ${fmt.hour_of(save.created_at)}  ${fmt.date_of(save.created_at)}'479}480481fn entry_message(save history.Save, screen Screen) string {482	warning := if is_flagged(screen, save.id) { fmt.red('⚠ ') } else { '' }483	return '    ${warning}${save.message}'484}485486fn entry_meta(save history.Save) string {487	agent := if save.agent == '' { 'unknown' } else { save.agent }488	session := if save.session == '' { 'no session' } else { save.session }489	return '    ${fmt.human_bytes(save.size_bytes)} · ${agent} · ${session}'490}491492fn tab_bar(screen Screen, mode Mode) string {493	names := ['all', 'saves', 'checkpoints']494	counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]495	mut bar := ''496	for i, name in names {497		if tab_at(i) == screen.tab {498			bar += fmt.gold(' [${name} (${counts[i]})] ')499		} else {500			bar += fmt.muted(' ${name} (${counts[i]}) ')501		}502	}503	bar += fmt.muted('│ ')504	if mode == .search {505		bar += '/${screen.query}_'506	} else if screen.query != '' {507		bar += '/${screen.query}'508	} else {509		bar += fmt.muted('/ search')510	}511	return bar512}513514fn status_bar(session Session, cols int) string {515	hint := match session.mode {516		.search {517			'type to filter · enter keep · esc clear'518		}519		.detail {520			flag_word := if is_flagged(session.screen, session.target.id) {521				'clear flag'522			} else {523				'flag'524			}525			'esc back · l load · f ${flag_word} · c copy · q'526		}527		.reason {528			'flag reason: ${session.typed}_ · enter confirm · esc cancel'529		}530		.confirm {531			'${action_word(session.pending.kind)} [${session.pending.save_id}]? y yes · n/esc cancel'532		}533		.list {534			'↑↓ nav · / search · tab mode · c copy · enter detail · q'535		}536	}537	left := ' view: ${tab_name(session.screen.tab)} · ${hint}'538	right := if session.screen.status != '' {539		session.screen.status540	} else if session.mode == .list || session.mode == .search {541		'${session.screen.items.len} shown'542	} else {543		''544	}545	if right == '' {546		return fmt.muted(left)547	}548	// The hints give way to the count or the result of an action, which is549	// the one thing worth reading right after a key.550	room := cols - fmt.visible_width(right) - 1551	shown := fmt.fit(left, if room > 0 { room } else { 0 })552	gap := cols - fmt.visible_width(shown) - fmt.visible_width(right)553	return fmt.muted('${shown}${' '.repeat(if gap > 0 { gap } else { 0 })}${right}')554}555556fn action_word(kind ActionKind) string {557	return match kind {558		.load { 'load' }559		.flag { 'flag' }560		.clear_flag { 'clear the flag on' }561		.none { '' }562	}563}564565// detail_lines describes one save: what it was, and what it changed.566pub fn detail_lines(save history.Save, head string, deltas []snapshot.Delta, flag events.Flag) []string {567	mut title := '  ${fmt.gold(save.id)}'568	if save.id == head {569		title += '  ${fmt.blue('HEAD')}'570	}571	if save.is_checkpoint {572		title += '  [checkpoint]'573	}574	if flag.flagged {575		title += '  [flagged]'576	}577	mut lines := [578		title,579		'  ${save.message}',580		'',581		detail_row('time', '${fmt.date_of(save.created_at)} · ${fmt.clock_of(save.created_at)}'),582		detail_row('size', fmt.human_bytes(save.size_bytes)),583		detail_row('agent', if save.agent == '' { 'unknown' } else { save.agent }),584		detail_row('session', if save.session == '' { 'none' } else { save.session }),585		detail_row('parent', if save.parent == '' { 'root' } else { save.parent }),586		detail_row('checksum', save.id),587	]588	if flag.flagged {589		lines << detail_row('flag', flag.reason)590	}591	lines << ''592	if save.parent == '' {593		lines << '  initial save — no parent to diff against'594		return lines595	}596	lines << '  ${deltas.len} files changed'597	for delta in deltas {598		lines << '  ${change_mark(delta)} ${delta.path}  ${line_stats(delta)}'599	}600	return lines601}602603fn detail_row(label string, value string) string {604	return '  ${fmt.muted(label + ' '.repeat(9 - label.len))}${value}'605}606607fn line_stats(delta snapshot.Delta) string {608	if delta.binary {609		return fmt.muted('(binary)')610	}611	return '${fmt.green('+' + delta.added.str())}  ${fmt.red('-' + delta.removed.str())}'612}613614fn change_mark(delta snapshot.Delta) string {615	return match delta.kind {616		'added' { fmt.green('A') }617		'removed' { fmt.red('R') }618		else { fmt.gold('M') }619	}620}621622fn is_flagged(screen Screen, save_id string) bool {623	return (screen.flags[save_id] or { events.Flag{} }).flagged624}625626fn diff_of(store &storage.Store, save history.Save) []snapshot.Delta {627	return snapshot.diff_trees(store, parent_tree(store, save), child_tree(store, save)) or {628		[]snapshot.Delta{}629	}630}631632fn trim_last(text string) string {633	runes := text.runes()634	if runes.len == 0 {635		return text636	}637	return runes[..runes.len - 1].string()638}639640// copy_selected puts the ID on the clipboard through OSC 52, which works over641// ssh and in any terminal without a helper program.642fn copy_selected(mut screen Screen, save_id string) {643	paint('\x1b]52;c;${base64.encode(save_id.bytes())}\a')644	screen.status = 'copied ${save_id}'645}646647fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {648	if save.parent == '' {649		return snapshot.Tree{}650	}651	return snapshot.Tree{652		files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }653	}654}655656fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {657	return snapshot.Tree{658		files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }659	}660}661662fn on_line_set(store &storage.Store) map[string]bool {663	mut ids := map[string]bool{}664	for save in history.line(store) or { []history.Save{} } {665		ids[save.id] = true666	}667	return ids668}669670fn enter_screen() {671	paint('\x1b[?1049h\x1b[?25l')672	raw_mode(true)673}674675fn leave_screen() {676	raw_mode(false)677	paint('\x1b[?25h\x1b[?1049l')678}679680fn paint(text string) {681	os.fd_write(1, text)682}683684fn tab_at(index int) Tab {685	return match index {686		0 { Tab.all }687		1 { Tab.saves }688		else { Tab.checkpoints }689	}690}691692fn tab_name(tab Tab) string {693	return match tab {694		.all { 'all' }695		.saves { 'saves' }696		else { 'checkpoints' }697	}698}699700fn next_tab(tab Tab) Tab {701	return match tab {702		.all { Tab.saves }703		.saves { Tab.checkpoints }704		else { Tab.all }705	}706}