//! The history view: the saves and checkpoints list, full screen. //! //! On a terminal `koh view` opens this instead of printing; `--checkpoints` //! opens it on the checkpoints tab. The look and the keys are Koh 2's //! (`src/commands/history.zig`): a tab bar, evenly spaced entries between //! separators, and a status bar with the hints for the current mode. //! //! The view only reads — history, flags and diffs — and hands any decision back //! to the caller as an `Action`, so the command layer stays the only thing that //! changes anything. `frame_lines` turns a `Session` into the rows of one frame, //! which keeps the interesting part testable without a terminal. module view import encoding.base64 import os import strings import src.events import src.fmt import src.history import src.snapshot import src.storage pub enum Tab { all saves checkpoints } pub enum ActionKind { none load flag clear_flag } pub struct Action { pub mut: kind ActionKind save_id string reason string } // TabCounts is what the tab bar shows: how many saves are in each list before // the search narrows anything. pub struct TabCounts { pub mut: all int saves int checkpoints int } // Screen is what the list shows. pub struct Screen { pub mut: tab Tab items []history.Save counts TabCounts selected int query string status string head string flags map[string]events.Flag on_line map[string]bool rows int cols int } enum Mode { list search detail reason confirm } // Session is everything the event loop changes: what is on screen, which mode // it is in, and what the current action is about. struct Session { mut: screen Screen mode Mode target history.Save typed string pending Action detail []string // set when an action changed history, so the lists are read again stale bool // the detail is worked out once per save rather than per key detail_for string } // run shows the view until the user leaves it. `act` performs a confirmed // action and returns the message for the status bar, so the view itself never // changes history. pub fn run(store &storage.Store, start Tab, act fn (store &storage.Store, action Action) string) ! { enter_screen() defer { leave_screen() } mut every := []history.Save{} mut session := Session{ screen: Screen{ tab: start } stale: true } for { if session.stale { // A load appends a save and a flag changes a mark: read both again. every = history.all_saves(store)! session.screen.head = history.head_id(store) session.screen.on_line = on_line_set(store) session.screen.flags = events.latest_flags(store)! session.screen.counts = count_tabs(every, session.screen.on_line) session.detail_for = '' session.stale = false } session.screen.rows, session.screen.cols = terminal_size() session.screen.items = filter_saves(every, session.screen.tab, session.screen.on_line, session.screen.query) session.screen.selected = move_selection(session.screen.selected, 0, session.screen.items.len) if session.mode in [.detail, .reason, .confirm] && session.detail_for != session.target.id { session.detail = detail_lines(session.target, session.screen.head, diff_of(store, session.target), session.screen.flags[session.target.id] or { events.Flag{} }) session.detail_for = session.target.id } paint(render(frame_lines(session))) // A status message is shown for one frame, as in Koh 2. session.screen.status = '' key := read_key() if key.kind in [.interrupt, .eof] { return } if handle_key(mut session, store, key, act) { return } } } // handle_key applies one key and reports whether to leave the view. fn handle_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool { return match session.mode { .list { list_key(mut session, key) } .search { search_key(mut session, key) } .detail { detail_key(mut session, key) } .reason { reason_key(mut session, key) } .confirm { confirm_key(mut session, store, key, act) } } } // list_key: Tab switches tabs; up/down and j/k move; g is the oldest save, G the newest (the list is newest // first); Enter opens the detail; / searches; c copies the ID; q leaves, and Esc drops a kept filter before // it leaves. Only selection, tab, query and mode change here. fn list_key(mut session Session, key Key) bool { count := session.screen.items.len match key.kind { .tab { session.screen.tab = next_tab(session.screen.tab) session.screen.selected = 0 } .down { session.screen.selected = move_selection(session.screen.selected, 1, count) } .up { session.screen.selected = move_selection(session.screen.selected, -1, count) } .enter { if count > 0 { session.target = session.screen.items[session.screen.selected] session.mode = .detail } } // Esc goes back one step: a kept search filter first, then the view. .escape { if session.screen.query == '' { return true } session.screen.query = '' session.screen.selected = 0 } .rune { match key.text { 'q' { return true } 'j' { session.screen.selected = move_selection(session.screen.selected, 1, count) } 'k' { session.screen.selected = move_selection(session.screen.selected, -1, count) } // The list is newest first: g is the oldest save, G the newest. 'g' { session.screen.selected = move_selection(count, -1, count) } 'G' { session.screen.selected = 0 } '/' { session.mode = .search } 'c' { if count > 0 { copy_selected(mut session.screen, session.screen.items[session.screen.selected].id) } } else {} } } else {} } return false } // search_key edits the query. Every key is text here, q included, so a // search can begin with q; Esc is the way out. fn search_key(mut session Session, key Key) bool { match key.kind { .rune { session.screen.query += key.text session.screen.selected = 0 } .backspace { session.screen.query = trim_last(session.screen.query) session.screen.selected = 0 } .escape { session.screen.query = '' session.screen.selected = 0 session.mode = .list } .enter { session.mode = .list } else {} } return false } // detail_key: l loads this save, and f flags it, or clears an existing flag. Both go on to confirm_key, which // is the only place an action runs; an unflagged save asks for a reason first. c copies the ID, Esc goes back. fn detail_key(mut session Session, key Key) bool { match key.kind { .escape { session.mode = .list } .rune { match key.text { 'q' { return true } 'l' { session.pending = Action{ kind: .load save_id: session.target.id } session.mode = .confirm } 'f' { if is_flagged(session.screen, session.target.id) { session.pending = Action{ kind: .clear_flag save_id: session.target.id } session.mode = .confirm } else { session.typed = '' session.mode = .reason } } 'c' { copy_selected(mut session.screen, session.target.id) } else {} } } else {} } return false } // reason_key collects a flag's reason. Every key is text here, q included: // a reason may well say "quick hack". fn reason_key(mut session Session, key Key) bool { match key.kind { .rune { session.typed += key.text } .backspace { session.typed = trim_last(session.typed) } .escape { session.typed = '' session.mode = .detail } .enter { if session.typed != '' { session.pending = Action{ kind: .flag save_id: session.target.id reason: session.typed } session.typed = '' session.mode = .confirm } } else {} } return false } fn confirm_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool { if key.kind == .rune && key.text in ['y', 'Y'] { session.screen.status = act(store, session.pending) session.stale = true } session.pending = Action{} session.mode = .list return false } // filter_saves applies a tab and the search text. `on_line` marks the saves // still on the line: a replayed-away save exists in history but belongs to // neither the saves nor the checkpoints list. pub fn filter_saves(items []history.Save, tab Tab, on_line map[string]bool, query string) []history.Save { mut out := []history.Save{} needle := query.to_lower() for save in items { live := save.id in on_line if tab == .saves && (!live || save.is_checkpoint) { continue } if tab == .checkpoints && (!live || !save.is_checkpoint) { continue } if needle != '' && !searchable(save).contains(needle) { continue } out << save } return out } // searchable is what a search matches: ID, message, agent and session, without regard to case. fn searchable(save history.Save) string { return '${save.id} ${save.message} ${save.agent} ${save.session}'.to_lower() } pub fn count_tabs(items []history.Save, on_line map[string]bool) TabCounts { return TabCounts{ all: filter_saves(items, .all, on_line, '').len saves: filter_saves(items, .saves, on_line, '').len checkpoints: filter_saves(items, .checkpoints, on_line, '').len } } // move_selection clamps a move to the ends of the list. pub fn move_selection(index int, delta int, length int) int { if length <= 0 { return 0 } moved := index + delta if moved < 0 { return 0 } if moved >= length { return length - 1 } return moved } // render writes a frame over the last one: each row is cleared before it is // drawn, and the last row has no newline, so the terminal never scrolls. pub fn render(lines []string) string { mut out := strings.new_builder(4096) out.write_string('\x1b[H') for i, line in lines { out.write_string('\x1b[2K${line}') if i < lines.len - 1 { out.write_string('\r\n') } } return out.str() } // frame_lines is one whole frame, exactly as tall as the screen and no line // wider than it: the tab bar, a gap, the body, the status bar. fn frame_lines(session Session) []string { screen := session.screen rows := if screen.rows > 4 { screen.rows } else { 4 } cols := if screen.cols > 10 { screen.cols } else { 10 } body := rows - 3 mut lines := [tab_bar(screen, session.mode), ''] if session.mode in [.detail, .reason, .confirm] { lines << session.detail#[..body] } else { lines << list_body(screen, body, cols) } for lines.len < rows - 1 { lines << '' } lines << status_bar(session, cols) return lines.map(fmt.fit(it, cols)) } // list_body lays the entries out in up to seven equal blocks, each a header, // a message, the selected entry's details and a separator, with the spare rows // split above and below so the list fills the screen. Koh 2 had six; seven is // the 15% tighter spacing June asked for, without leaving an empty band. fn list_body(screen Screen, body int, cols int) []string { if screen.items.len == 0 { return [' ${fmt.muted('no results')}'] } slots := if body / 4 < 7 { body / 4 } else { 7 } if slots == 0 { return [entry_header(screen.items[screen.selected], true, screen)] } separator := fmt.muted('─'.repeat(cols)) start := window_start(screen.selected, slots, screen.items.len) mut lines := []string{} for slot in 0 .. slots { height := body / slots + if slot < body % slots { 1 } else { 0 } spare := if height > 4 { height - 4 } else { 0 } for _ in 0 .. spare / 2 { lines << '' } index := start + slot if index < screen.items.len { entry := screen.items[index] selected := index == screen.selected lines << entry_header(entry, selected, screen) lines << entry_message(entry, screen) lines << if selected { entry_meta(entry) } else { '' } } else { lines << ['', '', ''] } lines << separator for _ in 0 .. spare - spare / 2 { lines << '' } } return lines } // window_start keeps the selected entry near the middle of the visible blocks. fn window_start(selected int, slots int, count int) int { if count <= slots { return 0 } mut start := if selected > slots / 2 { selected - slots / 2 } else { 0 } if start + slots > count { start = count - slots } return start } // entry_header is the mark, ID, time and date. HEAD has its own mark; every // other save is a dot. As in Koh 2, only the ID tells the selected save apart: // gold when selected, grey otherwise, while time, date and message stay plain. fn entry_header(save history.Save, selected bool, screen Screen) string { head := save.id == screen.head flagged := is_flagged(screen, save.id) mark := if head { fmt.blue('▸') } else { fmt.muted('·') } mut line := ' ${mark} ' line += if flagged { fmt.red(save.id) } else if selected { fmt.gold(save.id) } else { fmt.muted(save.id) } if head { line += ' ${fmt.blue('HEAD')}' } mut labels := []string{} if selected && save.is_checkpoint { labels << '[checkpoint]' } if flagged { labels << '[flagged]' } if save.id !in screen.on_line { labels << '[replayed away]' } if labels.len > 0 { line += ' ${labels.join(' ')}' } return line + ' ${fmt.hour_of(save.created_at)} ${fmt.date_of(save.created_at)}' } fn entry_message(save history.Save, screen Screen) string { warning := if is_flagged(screen, save.id) { fmt.red('⚠ ') } else { '' } return ' ${warning}${save.message}' } fn entry_meta(save history.Save) string { agent := if save.agent == '' { 'unknown' } else { save.agent } session := if save.session == '' { 'no session' } else { save.session } return ' ${fmt.human_bytes(save.size_bytes)} · ${agent} · ${session}' } fn tab_bar(screen Screen, mode Mode) string { names := ['all', 'saves', 'checkpoints'] counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints] mut bar := '' for i, name in names { if tab_at(i) == screen.tab { bar += fmt.gold(' [${name} (${counts[i]})] ') } else { bar += fmt.muted(' ${name} (${counts[i]}) ') } } bar += fmt.muted('│ ') if mode == .search { bar += '/${screen.query}_' } else if screen.query != '' { bar += '/${screen.query}' } else { bar += fmt.muted('/ search') } return bar } fn status_bar(session Session, cols int) string { hint := match session.mode { .search { 'type to filter · enter keep · esc clear' } .detail { flag_word := if is_flagged(session.screen, session.target.id) { 'clear flag' } else { 'flag' } 'esc back · l load · f ${flag_word} · c copy · q' } .reason { 'flag reason: ${session.typed}_ · enter confirm · esc cancel' } .confirm { '${action_word(session.pending.kind)} [${session.pending.save_id}]? y yes · n/esc cancel' } .list { '↑↓ nav · / search · tab mode · c copy · enter detail · q/esc' } } left := ' view: ${tab_name(session.screen.tab)} · ${hint}' right := if session.screen.status != '' { session.screen.status } else if session.mode == .list || session.mode == .search { '${session.screen.items.len} shown' } else { '' } if right == '' { return fmt.muted(left) } // The hints give way to the count or the result of an action, which is // the one thing worth reading right after a key. room := cols - fmt.visible_width(right) - 1 shown := fmt.fit(left, if room > 0 { room } else { 0 }) gap := cols - fmt.visible_width(shown) - fmt.visible_width(right) return fmt.muted('${shown}${' '.repeat(if gap > 0 { gap } else { 0 })}${right}') } fn action_word(kind ActionKind) string { return match kind { .load { 'load' } .flag { 'flag' } .clear_flag { 'clear the flag on' } .none { '' } } } // detail_lines describes one save: what it was, and what it changed. pub fn detail_lines(save history.Save, head string, deltas []snapshot.Delta, flag events.Flag) []string { mut title := ' ${fmt.gold(save.id)}' if save.id == head { title += ' ${fmt.blue('HEAD')}' } if save.is_checkpoint { title += ' [checkpoint]' } if flag.flagged { title += ' [flagged]' } mut lines := [ title, ' ${save.message}', '', detail_row('time', '${fmt.date_of(save.created_at)} · ${fmt.clock_of(save.created_at)}'), detail_row('size', fmt.human_bytes(save.size_bytes)), detail_row('agent', if save.agent == '' { 'unknown' } else { save.agent }), detail_row('session', if save.session == '' { 'none' } else { save.session }), detail_row('parent', if save.parent == '' { 'root' } else { save.parent }), detail_row('checksum', save.id), ] if flag.flagged { lines << detail_row('flag', flag.reason) } lines << '' if save.parent == '' { lines << ' initial save — no parent to diff against' return lines } lines << ' ${deltas.len} files changed' mut totals := snapshot.Delta{} for delta in deltas { lines << ' ${change_mark(delta)} ${delta.path} ${line_stats(delta)}' if !delta.binary { totals.added += delta.added totals.removed += delta.removed } } lines << ['', ' total ${line_stats(totals)}'] return lines } fn detail_row(label string, value string) string { return ' ${fmt.muted(label + ' '.repeat(9 - label.len))}${value}' } fn line_stats(delta snapshot.Delta) string { if delta.binary { return fmt.muted('(binary)') } return '${fmt.green('+' + delta.added.str())} ${fmt.red('-' + delta.removed.str())}' } fn change_mark(delta snapshot.Delta) string { return match delta.kind { 'added' { fmt.green('A') } 'removed' { fmt.red('R') } else { fmt.gold('M') } } } fn is_flagged(screen Screen, save_id string) bool { return (screen.flags[save_id] or { events.Flag{} }).flagged } // diff_of compares a save with its parent. A failed read shows an empty diff instead of closing the screen. fn diff_of(store &storage.Store, save history.Save) []snapshot.Delta { return snapshot.diff_trees(store, parent_tree(store, save), child_tree(store, save)) or { []snapshot.Delta{} } } fn trim_last(text string) string { runes := text.runes() if runes.len == 0 { return text } return runes[..runes.len - 1].string() } // copy_selected puts the ID on the clipboard through OSC 52, which works over // ssh and in any terminal without a helper program. fn copy_selected(mut screen Screen, save_id string) { paint('\x1b]52;c;${base64.encode(save_id.bytes())}\a') screen.status = 'copied ${save_id}' } fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree { if save.parent == '' { return snapshot.Tree{} } return snapshot.Tree{ files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} } } } fn child_tree(store &storage.Store, save history.Save) snapshot.Tree { return snapshot.Tree{ files: history.files_of(store, save.id) or { []snapshot.FileEntry{} } } } // on_line_set marks the saves on the current line; the all tab also lists those replayed off it. fn on_line_set(store &storage.Store) map[string]bool { mut ids := map[string]bool{} for save in history.line(store) or { []history.Save{} } { ids[save.id] = true } return ids } // enter_screen and leave_screen are a pair: run defers leave_screen so every exit restores the terminal. fn enter_screen() { paint('\x1b[?1049h\x1b[?25l') raw_mode(true) } fn leave_screen() { raw_mode(false) paint('\x1b[?25h\x1b[?1049l') } fn paint(text string) { os.fd_write(1, text) } fn tab_at(index int) Tab { return match index { 0 { Tab.all } 1 { Tab.saves } else { Tab.checkpoints } } } fn tab_name(tab Tab) string { return match tab { .all { 'all' } .saves { 'saves' } else { 'checkpoints' } } } fn next_tab(tab Tab) Tab { return match tab { .all { Tab.saves } .saves { Tab.checkpoints } else { Tab.all } } }