752b815dd4f5

rebuild the history view on Koh 2's layout: evenly spaced entries that never wrap, HEAD marked and every other save a dot, the selected ID gold and the rest greyed, search only after /, q quits while searching, and flag or load only after the strict confirm (3.0.65)

time
2026-09-26 13:09 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
history view
parent
a98160f0b963
5 files VERSION+1 −1src/fmt/fmt.v+48 −0src/view/view.v+340 −268src/view/view_test.v+192 −74v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.641+3.0.65

view file

src/fmt/fmt.v+48 −0
@@ -50,6 +50,20 @@5050 	return paint(text, '38;5;245')5151 }5252 53+// The history view keeps Koh 2's softer palette: gold for the selected save,54+// muted grey for everything around it, blue for HEAD.55+pub fn gold(text string) string {56+	return paint(text, '38;5;179')57+}58+59+pub fn muted(text string) string {60+	return paint(text, '38;5;241')61+}62+63+pub fn blue(text string) string {64+	return paint(text, '38;5;110')65+}66+5367 // strip_ansi removes the colour codes, for measuring how wide a line really is.5468 pub fn strip_ansi(text string) string {5569 	mut out := ''@@ -74,12 +88,46 @@7488 	return strip_ansi(text).runes().len7589 }7690 91+// fit cuts a line to `width` visible columns and keeps its colour codes, so a92+// long line can never wrap and scroll a full-screen frame.93+pub fn fit(text string, width int) string {94+	if visible_width(text) <= width {95+		return text96+	}97+	mut out := []rune{}98+	mut shown := 099+	mut skipping := false100+	for c in text.runes() {101+		if c == `\x1b` {102+			skipping = true103+		}104+		if skipping {105+			out << c106+			if (c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) {107+				skipping = false108+			}109+			continue110+		}111+		if shown == width {112+			break113+		}114+		out << c115+		shown++116+	}117+	cut := out.string()118+	return if cut.contains('\x1b') { cut + '\x1b[0m' } else { cut }119+}120+77121 pub fn stamp(unix_seconds i64) string {78122 	return time.unix(unix_seconds).local().format()79123 }80124 81125 pub fn date_of(unix_seconds i64) string {82126 	return time.unix(unix_seconds).local().ymmdd()127+}128+129+pub fn hour_of(unix_seconds i64) string {130+	return time.unix(unix_seconds).local().hhmm()83131 }84132 85133 pub fn clock_of(unix_seconds i64) string {

view file

src/view/view.v+340 −268
@@ -1,13 +1,14 @@11 //! The history view: the saves and checkpoints list, full screen.22 //!33 //! On a terminal `koh log` opens this instead of printing; `koh saves` and4-//! `koh checkpoints` open it on their tab. The view only reads — history, flags5-//! and diffs — and hands any decision back to the caller as an `Action`, so the6-//! command layer stays the only thing that changes anything.4+//! `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.77 //!8-//! Everything a frame needs is in `Screen`, and `render` turns one into the9-//! text of a frame. That split keeps the interesting part testable without a10-//! terminal: the tests here never run the event loop.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.1112 1213 module view1314 @@ -49,7 +50,7 @@4950 	checkpoints int5051 }5152 52-// Screen is one frame's worth of state.53+// Screen is what the list shows.5354 pub struct Screen {5455 pub mut:5556 	tab      Tab@@ -58,6 +59,7 @@5859 	selected int5960 	query    string6061 	status   string62+	head     string6163 	flags    map[string]events.Flag6264 	on_line  map[string]bool6365 	rows     int@@ -67,28 +69,30 @@6769 enum Mode {6870 	list6971 	search70-	reason7172 	detail73+	reason74+	confirm7275 }7376 7477 // Session is everything the event loop changes: what is on screen, which mode7578 // it is in, and what the current action is about.7679 struct Session {7780 mut:78-	screen Screen79-	mode   Mode80-	target history.Save81-	typed  string81+	screen  Screen82+	mode    Mode83+	target  history.Save84+	typed   string85+	pending Action86+	detail  []string8287 	// set when an action changed history, so the lists are read again8388 	stale bool84-	// the detail's diff, worked out once per save rather than per key85-	deltas     []snapshot.Delta86-	deltas_for string89+	// the detail is worked out once per save rather than per key90+	detail_for string8791 }8892 89-// run shows the view until the user leaves it. `act` performs a chosen action90-// and returns the message for the status bar, so the view itself never changes91-// history.93+// 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.9296 pub fn run(store &storage.Store, start Tab, act fn (store &storage.Store, action Action) string) ! {9397 	enter_screen()9498 	defer {@@ -105,72 +109,61 @@105109 		if session.stale {106110 			// A load appends a save and a flag changes a mark: read both again.107111 			every = history.all_saves(store)!112+			session.screen.head = history.head_id(store)108113 			session.screen.on_line = on_line_set(store)109114 			session.screen.flags = events.latest_flags(store)!110115 			session.screen.counts = count_tabs(every, session.screen.on_line)116+			session.detail_for = ''111117 			session.stale = false112118 		}113119 		session.screen.rows, session.screen.cols = terminal_size()114120 		session.screen.items = filter_saves(every, session.screen.tab, session.screen.on_line,115121 			session.screen.query)116-		if session.screen.selected >= session.screen.items.len {117-			session.screen.selected = move_selection(session.screen.selected, 0,118-				session.screen.items.len)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.id119127 		}120-		paint(frame_for(store, mut session))128+		paint(render(frame_lines(session)))129+		// A status message is shown for one frame, as in Koh 2.130+		session.screen.status = ''121131 		key := read_key()122132 		if key.kind in [.interrupt, .eof] {123133 			return124134 		}125-		leaving := match session.mode {126-			.list { list_key(mut session, key) }127-			.search { search_key(mut session, key) }128-			.reason { reason_key(mut session, store, key, act) }129-			.detail { detail_key(mut session, store, key, act) }130-		}131-132-		if leaving {135+		if handle_key(mut session, store, key, act) {133136 			return134137 		}135138 	}136139 }137140 138-fn frame_for(store &storage.Store, mut session Session) string {139-	match session.mode {140-		.detail, .reason {141-			// Typing a flag reason repaints on every key; diffing a large save142-			// each time would stall the typing.143-			if session.deltas_for != session.target.id {144-				session.deltas = snapshot.diff_trees(store, parent_tree(store, session.target), child_tree(store,145-					session.target)) or { []snapshot.Delta{} }146-				session.deltas_for = session.target.id147-			}148-			return detail_frame(session.screen, session.target, session.deltas,149-				session.mode == .reason, session.typed)150-		}151-		else {152-			return render(session.screen)153-		}141+// handle_key applies one key and reports whether to leave the view.142+fn 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) }154149 	}155150 }156151 157-// list_key handles one key in the list and reports whether to leave the view.158152 fn list_key(mut session Session, key Key) bool {153+	count := session.screen.items.len159154 	match key.kind {160155 		.tab {161156 			session.screen.tab = next_tab(session.screen.tab)162157 			session.screen.selected = 0163158 		}164159 		.down {165-			session.screen.selected = move_selection(session.screen.selected, 1,166-				session.screen.items.len)160+			session.screen.selected = move_selection(session.screen.selected, 1, count)167161 		}168162 		.up {169-			session.screen.selected = move_selection(session.screen.selected, -1,170-				session.screen.items.len)163+			session.screen.selected = move_selection(session.screen.selected, -1, count)171164 		}172165 		.enter {173-			if session.screen.items.len > 0 {166+			if count > 0 {174167 				session.target = session.screen.items[session.screen.selected]175168 				session.mode = .detail176169 			}@@ -181,29 +174,26 @@181174 					return true182175 				}183176 				'j' {184-					session.screen.selected = move_selection(session.screen.selected, 1,185-						session.screen.items.len)177+					session.screen.selected = move_selection(session.screen.selected, 1, count)186178 				}187179 				'k' {188180 					session.screen.selected = move_selection(session.screen.selected, -1,189-						session.screen.items.len)181+						count)190182 				}183+				// The list is newest first: g is the oldest save, G the newest.191184 				'g' {192-					session.screen.selected = 0185+					session.screen.selected = move_selection(count, -1, count)193186 				}194187 				'G' {195-					session.screen.selected = move_selection(session.screen.items.len, -1,196-						session.screen.items.len)188+					session.screen.selected = 0197189 				}198190 				'/' {199191 					session.mode = .search200-					session.screen.query = ''201192 				}202193 				'c' {203-					if session.screen.items.len > 0 {204-						id := session.screen.items[session.screen.selected].id205-						copy_id(id)206-						session.screen.status = 'copied ${id}'194+					if count > 0 {195+						copy_selected(mut session.screen,196+							session.screen.items[session.screen.selected].id)207197 					}208198 				}209199 				else {}@@ -211,20 +201,27 @@211201 		}212202 		else {}213203 	}214-215204 	return false216205 }217206 207+// 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.218209 fn search_key(mut session Session, key Key) bool {219210 	match key.kind {220211 		.rune {212+			if key.text == 'q' {213+				return true214+			}221215 			session.screen.query += key.text216+			session.screen.selected = 0222217 		}223218 		.backspace {224219 			session.screen.query = trim_last(session.screen.query)220+			session.screen.selected = 0225221 		}226222 		.escape {227223 			session.screen.query = ''224+			session.screen.selected = 0228225 			session.mode = .list229226 		}230227 		.enter {@@ -232,11 +229,52 @@232229 		}233230 		else {}234231 	}232+	return false233+}235234 235+fn 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+	}236272 	return false237273 }238274 239-fn reason_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {275+// reason_key collects a flag's reason. Every key is text here, q included:276+// a reason may well say "quick hack".277+fn reason_key(mut session Session, key Key) bool {240278 	match key.kind {241279 		.rune {242280 			session.typed += key.text@@ -246,67 +284,32 @@246284 		}247285 		.escape {248286 			session.typed = ''249-			session.mode = .list287+			session.mode = .detail250288 		}251289 		.enter {252290 			if session.typed != '' {253-				session.stale = true254-				session.screen.status = act(store, Action{291+				session.pending = Action{255292 					kind:    .flag256293 					save_id: session.target.id257294 					reason:  session.typed258-				})295+				}296+				session.typed = ''297+				session.mode = .confirm259298 			}260-			session.typed = ''261-			session.mode = .list262299 		}263300 		else {}264301 	}265-266302 	return false267303 }268304 269-fn detail_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {270-	match key.kind {271-		.escape {272-			session.mode = .list273-		}274-		.rune {275-			match key.text {276-				'l' {277-					if ask_confirm('load [${session.target.id}]') {278-						session.stale = true279-						session.screen.status = act(store, Action{280-							kind:    .load281-							save_id: session.target.id282-						})283-					}284-					session.mode = .list285-				}286-				'f' {287-					state := session.screen.flags[session.target.id] or {288-						events.Flag{289-							save_id: session.target.id290-						}291-					}292-					if state.flagged {293-						session.stale = true294-						session.screen.status = act(store, Action{295-							kind:    .clear_flag296-							save_id: session.target.id297-						})298-						session.mode = .list299-					} else {300-						session.typed = ''301-						session.mode = .reason302-					}303-				}304-				else {}305-			}306-		}307-		else {}305+// confirm_key is the strict confirm: only a lowercase y goes ahead.306+fn 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 = true308310 	}309-311+	session.pending = Action{}312+	session.mode = .list310313 	return false311314 }312315 @@ -359,217 +362,290 @@359362 	return moved360363 }361364 362-// detail_lines describes one save: what it was, and what it changed.363-pub fn detail_lines(save history.Save, deltas []snapshot.Delta, flag events.Flag) []string {364-	mut lines := [365-		'  ${fmt.bold('[' + save.id + ']')}  ${save.message}',366-		'',367-		'  time      ${fmt.stamp(save.created_at)}',368-		'  size      ${fmt.human_bytes(save.size_bytes)}',369-		'  agent     ${or_dash(save.agent)}',370-		'  session   ${or_dash(save.session)}',371-		'  parent    ${or_dash(save.parent)}',372-		'  checksum  ${save.id}',373-	]374-	if flag.flagged {375-		lines << '  flag      ${or_dash(flag.reason)}'365+// 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.367+pub 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+		}376375 	}377-	lines << ''378-	if deltas.len == 0 {379-		lines << '  ${fmt.dim('nothing changed against the parent')}'380-		return lines381-	}382-	lines << '  ${fmt.plural(deltas.len, 'file')} changed against the parent:'383-	for delta in deltas {384-		lines << '  ${change_mark(delta)} ${delta.path}${line_stats(delta)}'385-	}386-	return lines376+	return out.str()387377 }388378 389-fn line_stats(delta snapshot.Delta) string {390-	if delta.binary {391-		return '  ${fmt.dim('(binary)')}'379+// 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.381+fn 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)392391 	}393-	return '  ${fmt.green('+' + delta.added.str())} ${fmt.red('-' + delta.removed.str())}'392+	for lines.len < rows - 1 {393+		lines << ''394+	}395+	lines << status_bar(session, cols)396+	return lines.map(fmt.fit(it, cols))394397 }395398 396-fn change_mark(delta snapshot.Delta) string {397-	return match delta.kind {398-		'added' { fmt.green('+') }399-		'removed' { fmt.red('-') }400-		else { fmt.amber('~') }399+// 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.402+fn list_body(screen Screen, body int, cols int) []string {403+	if screen.items.len == 0 {404+		return ['  ${fmt.muted('no results')}']401405 	}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 lines402435 }403436 404-// render is a whole frame of the list: tabs, entries, status bar.405-pub fn render(screen Screen) string {406-	mut out := strings.Builder{}407-	out.write_string('\x1b[H')408-	for line in frame_lines(screen) {409-		out.write_string('\x1b[2K${line}\r\n')437+// window_start keeps the selected entry near the middle of the visible blocks.438+fn window_start(selected int, slots int, count int) int {439+	if count <= slots {440+		return 0410441 	}411-	return out.str()442+	mut start := if selected > slots / 2 { selected - slots / 2 } else { 0 }443+	if start + slots > count {444+		start = count - slots445+	}446+	return start412447 }413448 414-pub fn frame_lines(screen Screen) []string {415-	mut lines := [tab_bar(screen)]416-	mut body := []string{}417-	for i, save in screen.items {418-		body << entry_line(save, i == screen.selected, screen)419-		if i == screen.selected {420-			body << meta_line(save)421-		}422-		body << fmt.dim('  ${'─'.repeat(separator_width(screen.cols))}')449+// 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.452+fn 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)423463 	}424-	if body.len == 0 {425-		body << fmt.dim('  no results')464+	if head {465+		line += '  ${fmt.blue('HEAD')}'426466 	}427-	room := body_room(screen.rows)428-	start := if screen.selected >= room { screen.selected - room + 1 } else { 0 }429-	for i := start; i < body.len && lines.len < room + 1; i++ {430-		lines << body[i]467+	mut labels := []string{}468+	if selected && save.is_checkpoint {469+		labels << '[checkpoint]'431470 	}432-	for lines.len < room + 1 {433-		lines << ''471+	if flagged {472+		labels << '[flagged]'434473 	}435-	lines << status_bar(screen)436-	return lines474+	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 line437483 }438484 439-fn body_room(rows int) int {440-	// The tab bar and the status bar take one row each; two more rows keep the441-	// list off the very bottom of the screen.442-	if rows <= 4 {443-		return 1444-	}445-	return rows - 3485+fn 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) }}'446488 }447489 448-fn separator_width(cols int) int {449-	if cols <= 6 {450-		return 1451-	}452-	return cols - 4490+fn 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}'453494 }454495 455-fn tab_bar(screen Screen) string {496+fn tab_bar(screen Screen, mode Mode) string {456497 	names := ['all', 'saves', 'checkpoints']457498 	counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]458-	mut parts := []string{}499+	mut bar := ''459500 	for i, name in names {460-		label := '${name} (${counts[i]})'461501 		if tab_at(i) == screen.tab {462-			parts << fmt.bold('[${label}]')502+			bar += fmt.gold(' [${name} (${counts[i]})] ')463503 		} else {464-			parts << fmt.dim(label)504+			bar += fmt.muted(' ${name} (${counts[i]}) ')465505 		}466506 	}467-	mut bar := '  ${parts.join('  ')}    '468-	if screen.query != '' {469-		bar += fmt.bold('/${screen.query}_')507+	bar += fmt.muted('│ ')508+	if mode == .search {509+		bar += '/${screen.query}_'510+	} else if screen.query != '' {511+		bar += '/${screen.query}'470512 	} else {471-		bar += fmt.dim('/ search')513+		bar += fmt.muted('/ search')472514 	}473515 	return bar474516 }475517 476-fn entry_line(save history.Save, selected bool, screen Screen) string {477-	mut line := '  ${if selected { fmt.amber('▸') } else { ' ' }} '478-	line += '${save_mark(save, screen)} ${fmt.dim('[' + save.id + ']')} '479-	line += '${fmt.date_of(save.created_at)} ${fmt.clock_of(save.created_at)}  ${save.message}'480-	mut tags := []string{}481-	if save.agent != '' || save.session != '' {482-		tags << '${or_dash(save.agent)} · ${or_dash(save.session)}'518+fn 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+		}483540 	}484-	if save.restores != '' {485-		tags << 'back to ${save.restores}'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+		''486548 	}487-	if save.id !in screen.on_line {488-		tags << 'replayed away'549+	if right == '' {550+		return fmt.muted(left)489551 	}490-	if tags.len > 0 {491-		line += '   ${fmt.dim(tags.join('  '))}'492-	}493-	if selected {494-		return fmt.reverse(line)495-	}496-	return line552+	// 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}')497558 }498559 499-fn meta_line(save history.Save) string {500-	return fmt.dim('         ${fmt.human_bytes(save.size_bytes)}  ·  agent ${or_dash(save.agent)}  ·  session ${or_dash(save.session)}')501-}502-503-fn status_bar(screen Screen) string {504-	mut hints := 'j/k move · / search · tab mode · c copy · enter detail · q quit'505-	if screen.status != '' {506-		hints = screen.status560+fn action_word(kind ActionKind) string {561+	return match kind {562+		.load { 'load' }563+		.flag { 'flag' }564+		.clear_flag { 'clear the flag on' }565+		.none { '' }507566 	}508-	left := 'view: ${tab_name(screen.tab)} · ${hints}'509-	right := '${screen.items.len} shown'510-	mut gap := screen.cols - fmt.visible_width(left) - fmt.visible_width(right) - 3511-	if gap < 1 {512-		gap = 1513-	}514-	return fmt.dim('  ${left}${' '.repeat(gap)}${right}')515567 }516568 517-fn save_mark(save history.Save, screen Screen) string {518-	// Replayed away: still in history, no longer on the line.519-	if save.id !in screen.on_line {520-		return fmt.dim('↪')569+// detail_lines describes one save: what it was, and what it changed.570+pub 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')}'521574 	}522575 	if save.is_checkpoint {523-		return fmt.amber('◐')576+		title += '  [checkpoint]'524577 	}525-	if (screen.flags[save.id] or {526-		events.Flag{527-			save_id: save.id528-		}529-	}).flagged {530-		return fmt.red('⚑')578+	if flag.flagged {579+		title += '  [flagged]'531580 	}532-	return ' '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 lines533605 }534606 535-fn detail_frame(screen Screen, target history.Save, deltas []snapshot.Delta, asking_reason bool, typed string) string {536-	flag := screen.flags[target.id] or {537-		events.Flag{538-			save_id: target.id539-		}607+fn detail_row(label string, value string) string {608+	return '  ${fmt.muted(label + ' '.repeat(9 - label.len))}${value}'609+}610+611+fn line_stats(delta snapshot.Delta) string {612+	if delta.binary {613+		return fmt.muted('(binary)')540614 	}541-	mut out := strings.Builder{}542-	out.write_string('\x1b[H\x1b[2J')543-	for line in detail_lines(target, deltas, flag) {544-		out.write_string('${line}\r\n')615+	return '${fmt.green('+' + delta.added.str())}  ${fmt.red('-' + delta.removed.str())}'616+}617+618+fn 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') }545623 	}546-	out.write_string('\r\n')547-	if asking_reason {548-		out.write_string('  ${fmt.amber('flag reason')}: ${typed}_')549-		return out.str()624+}625+626+fn is_flagged(screen Screen, save_id string) bool {627+	return (screen.flags[save_id] or { events.Flag{} }).flagged628+}629+630+fn 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{}550633 	}551-	action := if flag.flagged { 'clear flag' } else { 'flag' }552-	out.write_string(fmt.dim('  l load · f ${action} · esc back'))553-	return out.str()554634 }555635 556636 fn trim_last(text string) string {557-	if text.len == 0 {637+	runes := text.runes()638+	if runes.len == 0 {558639 		return text559640 	}560-	return text[..text.len - 1]641+	return runes[..runes.len - 1].string()561642 }562643 563-// ask_confirm is the strict prompt inside the view: only a lowercase y.564-fn ask_confirm(what string) bool {565-	paint('\x1b[H\x1b[2J  ${what} — continue? [y/N] ')566-	return read_key().text == 'y'567-}568-569-// copy_id puts the ID on the clipboard through OSC 52, which works over ssh and570-// in any terminal without a helper program.571-fn copy_id(save_id string) {644+// copy_selected puts the ID on the clipboard through OSC 52, which works over645+// ssh and in any terminal without a helper program.646+fn copy_selected(mut screen Screen, save_id string) {572647 	paint('\x1b]52;c;${base64.encode(save_id.bytes())}\a')648+	screen.status = 'copied ${save_id}'573649 }574650 575651 fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {@@ -631,8 +707,4 @@631707 		.saves { Tab.checkpoints }632708 		else { Tab.all }633709 	}634-}635-636-fn or_dash(value string) string {637-	return if value == '' { '—' } else { value }638710 }

view file

src/view/view_test.v+192 −74
@@ -4,6 +4,7 @@44 import src.fmt55 import src.history66 import src.snapshot7+import src.storage78 89 fn save(id string, message string, checkpoint bool) history.Save {910 	return history.Save{@@ -23,6 +24,27 @@2324 	return out2425 }2526 27+fn plain(lines []string) []string {28+	return lines.map(fmt.strip_ansi(it))29+}30+31+// never_act stands in for the command layer where no action may happen yet.32+fn never_act(_ &storage.Store, action Action) string {33+	panic('an action ran without the strict confirm: ${action.kind}')34+}35+36+fn list_session(items []history.Save) Session {37+	return Session{38+		screen: Screen{39+			tab:     .all40+			items:   items41+			on_line: line_map(items.map(it.id))42+			rows:    3043+			cols:    8044+		}45+	}46+}47+2648 fn test_tabs_filter_by_what_is_on_the_line() {2749 	// A combine replays the local save `bbb` as `ddd`: the copy is on the line,2850 	// the original is not.@@ -39,19 +61,6 @@3961 	assert filter_saves(items, .checkpoints, on_the_line, '').map(it.id) == ['ccc']4062 }4163 42-fn test_only_the_replayed_away_original_is_marked() {43-	on_the_line := line_map(['ddd'])44-	mut copy := save('ddd', 'my work, replayed', false)45-	copy.replayed_from = 'bbb'46-	screen := Screen{47-		on_line: on_the_line48-	}49-	assert fmt.strip_ansi(save_mark(save('bbb', 'my work', false), screen)) == '↪'50-	assert fmt.strip_ansi(save_mark(copy, screen)) == ' '51-	assert entry_line(save('bbb', 'my work', false), false, screen).contains('replayed away')52-	assert !entry_line(copy, false, screen).contains('replayed away')53-}54-5564 fn test_search_is_case_insensitive_across_the_fields() {5665 	on_the_line := line_map(['aaa', 'bbb', 'ccc'])5766 	mut with_agent := save('bbb', 'fix chunk boundary', false)@@ -73,12 +82,154 @@7382 	assert move_selection(0, -1, 3) == 07483 	assert move_selection(0, 0, 0) == 07584 	assert move_selection(5, 1, 0) == 076-	// G asks for a move past the end and lands on the last entry.7785 	assert move_selection(3, -1, 3) == 27886 }7987 88+// A long message used to wrap, which scrolled the terminal and pushed the tab89+// bar off the top: every line of a frame must fit, and the frame must fill90+// the screen exactly.91+fn test_frame_fills_the_screen_and_never_wraps() {92+	mut items := []history.Save{}93+	for i in 0 .. 20 {94+		items << save('id${i:09}', 'a very long message '.repeat(12), i % 3 == 0)95+	}96+	for tab in [Tab.all, .saves, .checkpoints] {97+		mut session := list_session(items)98+		session.screen.tab = tab99+		session.screen.cols = 60100+		session.screen.selected = 7101+		lines := frame_lines(session)102+		assert lines.len == 30103+		for line in lines {104+			assert fmt.visible_width(line) <= 60105+		}106+		assert plain(lines)[0].contains('checkpoints')107+	}108+	frame := render(frame_lines(list_session(items)))109+	assert frame.starts_with('\x1b[H')110+	assert frame.count('\x1b[2K') == 30111+	// No newline after the last row, or the terminal scrolls one line.112+	assert !frame.ends_with('\n')113+}114+115+fn test_head_has_its_own_mark_and_every_other_save_a_dot() {116+	items := [save('aaa', 'newest', false), save('bbb', 'a checkpoint', true),117+		save('ccc', 'older', false)]118+	mut session := list_session(items)119+	session.screen.head = 'aaa'120+	session.screen.selected = 2121+	text := plain(frame_lines(session)).join('\n')122+	assert text.contains('▸ aaa  HEAD')123+	assert text.contains('· bbb')124+	assert text.contains('· ccc')125+	assert !text.contains('◐')126+}127+128+fn test_the_selected_save_adds_size_agent_and_session() {129+	mut first := save('aaa', 'first', false)130+	first.agent = 'claude-opus-5-5'131+	first.session = 'history view'132+	mut session := list_session([first, save('bbb', 'second', false)])133+	text := plain(frame_lines(session)).join('\n')134+	assert text.contains('1.0 KiB · claude-opus-5-5 · history view')135+	session.screen.selected = 1136+	unselected := plain(frame_lines(session)).join('\n')137+	assert !unselected.contains('claude-opus-5-5')138+	assert unselected.contains('unknown · no session')139+}140+141+fn test_flagged_and_replayed_away_saves_are_marked() {142+	mut session := list_session([save('aaa', 'risky', false), save('bbb', 'my work', false)])143+	session.screen.flags['aaa'] = events.Flag{144+		save_id: 'aaa'145+		flagged: true146+	}147+	session.screen.on_line = line_map(['aaa'])148+	text := plain(frame_lines(session)).join('\n')149+	assert text.contains('⚠ risky')150+	assert text.contains('[flagged]')151+	assert text.contains('bbb  [replayed away]')152+}153+154+fn test_typing_searches_only_after_a_slash() {155+	mut session := list_session([save('aaa', 'first', false)])156+	assert !handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'x' }, never_act)157+	assert session.mode == .list158+	assert session.screen.query == ''159+	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: '/' }, never_act)160+	assert session.mode == .search161+	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'x' }, never_act)162+	assert session.screen.query == 'x'163+}164+165+fn test_q_quits_from_the_list_and_while_searching() {166+	mut session := list_session([save('aaa', 'first', false)])167+	assert handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'q' }, never_act)168+	session.mode = .search169+	assert handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'q' }, never_act)170+}171+172+fn test_the_search_bar_shows_whether_it_is_being_typed() {173+	mut session := list_session([save('aaa', 'first', false)])174+	for key in ['/', 'f', 'i'] {175+		handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: key }, never_act)176+	}177+	typing := plain(frame_lines(session))178+	assert typing[0].contains('/fi_')179+	assert typing.last().contains('type to filter · enter keep · esc clear')180+	handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)181+	kept := plain(frame_lines(session))182+	assert kept[0].contains('/fi') && !kept[0].contains('/fi_')183+	assert kept.last().contains('/ search · tab mode')184+	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: '/' }, never_act)185+	handle_key(mut session, unsafe { nil }, Key{ kind: .escape }, never_act)186+	assert session.screen.query == ''187+}188+189+fn test_g_goes_to_the_oldest_and_shift_g_to_the_newest() {190+	mut session := list_session([save('aaa', 'new', false), save('bbb', 'mid', false),191+		save('ccc', 'old', false)])192+	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'g' }, never_act)193+	assert session.screen.selected == 2194+	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'G' }, never_act)195+	assert session.screen.selected == 0196+}197+198+fn test_tab_cycles_the_tabs_and_starts_at_the_top() {199+	mut session := list_session([save('aaa', 'new', false), save('bbb', 'old', false)])200+	session.screen.selected = 1201+	handle_key(mut session, unsafe { nil }, Key{ kind: .tab }, never_act)202+	assert session.screen.tab == .saves203+	assert session.screen.selected == 0204+	handle_key(mut session, unsafe { nil }, Key{ kind: .tab }, never_act)205+	handle_key(mut session, unsafe { nil }, Key{ kind: .tab }, never_act)206+	assert session.screen.tab == .all207+}208+209+fn test_flagging_asks_the_strict_confirm_before_anything_happens() {210+	mut session := list_session([save('aaa', 'risky', false)])211+	handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)212+	assert session.mode == .detail213+	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'f' }, never_act)214+	assert session.mode == .reason215+	// q is text here: a reason may say "quick hack".216+	for key in ['q', 'u', 'i', 'c', 'k'] {217+		handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: key }, never_act)218+	}219+	handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)220+	assert session.mode == .confirm221+	assert session.pending.kind == .flag222+	assert session.pending.reason == 'quick'223+	assert plain(frame_lines(session)).last().contains('flag [aaa]? y yes · n/esc cancel')224+	// Only a lowercase y goes ahead; anything else cancels.225+	handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'Y' }, never_act)226+	assert session.mode == .list227+	assert session.pending.kind == .none228+}229+80230 fn test_detail_lists_files_with_stats_and_notes_binaries() {81-	target := save('aaa', 'change two files', false)231+	mut target := save('aaa', 'change two files', false)232+	target.parent = 'ppp'82233 	flag := events.Flag{83234 		save_id: 'aaa'84235 		flagged: true@@ -97,71 +248,38 @@97248 			binary: true98249 		},99250 	]100-	text := detail_lines(target, deltas, flag).join('\n')101-	assert text.contains('checksum  aaa')102-	assert text.contains('flag      sketchy')103-	assert text.contains('src/main.v')104-	assert text.contains('+4')105-	assert text.contains('-2')106-	assert text.contains('logo.png')107-	assert text.contains('(binary)')108-	assert text.contains('2 files changed against the parent')251+	text := plain(detail_lines(target, '', deltas, flag)).join('\n')252+	assert text.contains('checksum aaa')253+	assert text.contains('flag     sketchy')254+	assert text.contains('M src/main.v  +4  -2')255+	assert text.contains('A logo.png  (binary)')256+	assert text.contains('2 files changed')109257 }110258 111-fn test_detail_says_so_when_nothing_changed() {112-	target := save('aaa', 'first save', false)113-	text := detail_lines(target, [], events.Flag{259+fn test_detail_of_the_first_save_says_there_is_no_parent() {260+	text := plain(detail_lines(save('aaa', 'first save', false), '', [], events.Flag{114261 		save_id: 'aaa'115-	}).join('\n')116-	assert text.contains('nothing changed against the parent')262+	})).join('\n')263+	assert text.contains('initial save — no parent to diff against')117264 }118265 119-fn test_frame_shows_counts_the_selected_entry_and_the_status_bar() {120-	on_the_line := line_map(['aaa', 'ccc'])121-	items := [save('aaa', 'first', false), save('ccc', 'before storage', true)]122-	screen := Screen{123-		tab:      .all124-		items:    items125-		selected: 1126-		on_line:  on_the_line127-		counts:   count_tabs(items, on_the_line)128-		rows:     24129-		cols:     100130-		status:   '3 shown'266+fn test_detail_fits_the_frame_too() {267+	mut session := list_session([save('aaa', 'x'.repeat(300), false)])268+	session.mode = .detail269+	session.target = session.screen.items[0]270+	session.detail = detail_lines(session.target, '', [], events.Flag{})271+	lines := frame_lines(session)272+	assert lines.len == 30273+	for line in lines {274+		assert fmt.visible_width(line) <= 80131275 	}132-	text := frame_lines(screen).join('\n')133-	assert text.contains('all (2)')134-	assert text.contains('checkpoints (1)')135-	assert text.contains('before storage')136-	assert text.contains('aaa')137-	// The selected entry carries its extra line.138-	assert text.contains('agent')139-	assert text.contains('◐')140-	assert text.contains('3 shown')141276 }142277 143-fn test_frame_says_no_results_when_the_search_matches_nothing() {144-	screen := Screen{145-		tab:    .all146-		rows:   24147-		cols:   80148-		query:  'nothing matches this'149-		counts: TabCounts{}150-	}151-	text := frame_lines(screen).join('\n')152-	assert text.contains('no results')153-	assert text.contains('/nothing matches this')154-}155-156-fn test_render_clears_each_line_before_drawing_it() {157-	screen := Screen{158-		tab:  .saves159-		rows: 6160-		cols: 60161-	}162-	frame := render(screen)163-	assert frame.starts_with('\x1b[H')164-	// One line-clear per row drawn, so a shorter frame cannot leave the tail of165-	// a longer one behind.166-	assert frame.count('\x1b[2K') == frame_lines(screen).len278+fn test_an_action_result_shows_even_on_a_narrow_screen() {279+	mut session := list_session([save('aaa', 'first', false)])280+	session.screen.cols = 50281+	session.screen.status = 'flagged [70c78decf279]'282+	last := plain(frame_lines(session)).last()283+	assert last.ends_with('flagged [70c78decf279]')284+	assert fmt.visible_width(last) <= 50167285 }

view file

v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 	name: 'koh3'3-	version: '3.0.64'3+	version: '3.0.65'44 	description: 'One SQLite file per repo, one line of history.'55 	license: 'MIT'66 }

view file