//! The history view: the saves and checkpoints list, full screen. //! //! On a terminal `koh log` opens this instead of printing; `koh saves` and //! `koh checkpoints` open it on their tab. 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. //! //! Everything a frame needs is in `Screen`, and `render` turns one into the //! text of a frame. That split keeps the interesting part testable without a //! terminal: the tests here never run the event loop. 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 one frame's worth of state. pub struct Screen { pub mut: tab Tab items []history.Save counts TabCounts selected int query string status string flags map[string]events.Flag on_line map[string]bool rows int cols int } enum Mode { list search reason detail } // 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 } // run shows the view until the user leaves it. `act` performs a chosen 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() } every := history.all_saves(store)! on_line := on_line_set(store) mut session := Session{ screen: Screen{ tab: start on_line: on_line flags: events.latest_flags(store)! counts: count_tabs(every, on_line) } } for { session.screen.rows, session.screen.cols = terminal_size() session.screen.items = filter_saves(every, session.screen.tab, session.screen.on_line, session.screen.query) if session.screen.selected >= session.screen.items.len { session.screen.selected = move_selection(session.screen.selected, 0, session.screen.items.len) } paint(frame_for(store, session)) key := read_key() if key.kind in [.interrupt, .eof] { return } leaving := match session.mode { .list { list_key(mut session, key) } .search { search_key(mut session, key) } .reason { reason_key(mut session, store, key, act) } .detail { detail_key(mut session, store, key, act) } } if leaving { return } } } fn frame_for(store &storage.Store, session Session) string { match session.mode { .detail, .reason { delta := snapshot.diff_trees(store, parent_tree(store, session.target), child_tree(store, session.target)) or { []snapshot.Delta{} } return detail_frame(session.screen, session.target, delta, session.mode == .reason, session.typed) } else { return render(session.screen) } } } // list_key handles one key in the list and reports whether to leave the view. fn list_key(mut session Session, key Key) bool { 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, session.screen.items.len) } .up { session.screen.selected = move_selection(session.screen.selected, -1, session.screen.items.len) } .enter { if session.screen.items.len > 0 { session.target = session.screen.items[session.screen.selected] session.mode = .detail } } .rune { match key.text { 'q' { return true } 'j' { session.screen.selected = move_selection(session.screen.selected, 1, session.screen.items.len) } 'k' { session.screen.selected = move_selection(session.screen.selected, -1, session.screen.items.len) } 'g' { session.screen.selected = 0 } 'G' { session.screen.selected = move_selection(session.screen.items.len, -1, session.screen.items.len) } '/' { session.mode = .search session.screen.query = '' } 'c' { if session.screen.items.len > 0 { id := session.screen.items[session.screen.selected].id copy_id(id) session.screen.status = 'copied ${id}' } } else {} } } else {} } return false } fn search_key(mut session Session, key Key) bool { match key.kind { .rune { session.screen.query += key.text } .backspace { session.screen.query = trim_last(session.screen.query) } .escape { session.screen.query = '' session.mode = .list } .enter { session.mode = .list } else {} } return false } fn reason_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool { match key.kind { .rune { session.typed += key.text } .backspace { session.typed = trim_last(session.typed) } .escape { session.typed = '' session.mode = .list } .enter { if session.typed != '' { session.screen.status = act(store, Action{ kind: .flag save_id: session.target.id reason: session.typed }) } session.typed = '' session.mode = .list } else {} } return false } fn detail_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool { match key.kind { .escape { session.mode = .list } .rune { match key.text { 'l' { if ask_confirm('load [${session.target.id}]') { session.screen.status = act(store, Action{ kind: .load save_id: session.target.id }) } session.mode = .list } 'f' { state := session.screen.flags[session.target.id] or { events.Flag{ save_id: session.target.id } } if state.flagged { session.screen.status = act(store, Action{ kind: .clear_flag save_id: session.target.id }) session.mode = .list } else { session.typed = '' session.mode = .reason } } else {} } } else {} } 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 && save.replayed_from == '' if tab == .saves && !live { continue } if tab == .checkpoints && (!live || !save.is_checkpoint) { continue } if needle != '' && !searchable(save).contains(needle) { continue } out << save } return out } 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 } // detail_lines describes one save: what it was, and what it changed. pub fn detail_lines(save history.Save, deltas []snapshot.Delta, flag events.Flag) []string { mut lines := [ ' ${fmt.bold('[' + save.id + ']')} ${save.message}', '', ' time ${fmt.stamp(save.created_at)}', ' size ${fmt.human_bytes(save.size_bytes)}', ' agent ${or_dash(save.agent)}', ' session ${or_dash(save.session)}', ' parent ${or_dash(save.parent)}', ' checksum ${save.id}', ] if flag.flagged { lines << ' flag ${or_dash(flag.reason)}' } lines << '' if deltas.len == 0 { lines << ' ${fmt.dim('nothing changed against the parent')}' return lines } lines << ' ${fmt.plural(deltas.len, 'file')} changed against the parent:' for delta in deltas { lines << ' ${change_mark(delta)} ${delta.path}${line_stats(delta)}' } return lines } fn line_stats(delta snapshot.Delta) string { if delta.binary { return ' ${fmt.dim('(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('+') } 'removed' { fmt.red('-') } else { fmt.amber('~') } } } // render is a whole frame of the list: tabs, entries, status bar. pub fn render(screen Screen) string { mut out := strings.Builder{} out.write_string('\x1b[H') for line in frame_lines(screen) { out.write_string('\x1b[2K${line}\r\n') } return out.str() } pub fn frame_lines(screen Screen) []string { mut lines := [tab_bar(screen)] mut body := []string{} for i, save in screen.items { body << entry_line(save, i == screen.selected, screen) if i == screen.selected { body << meta_line(save) } body << fmt.dim(' ${'─'.repeat(separator_width(screen.cols))}') } if body.len == 0 { body << fmt.dim(' no results') } room := body_room(screen.rows) start := if screen.selected >= room { screen.selected - room + 1 } else { 0 } for i := start; i < body.len && lines.len < room + 1; i++ { lines << body[i] } for lines.len < room + 1 { lines << '' } lines << status_bar(screen) return lines } fn body_room(rows int) int { // The tab bar and the status bar take one row each; two more rows keep the // list off the very bottom of the screen. if rows <= 4 { return 1 } return rows - 3 } fn separator_width(cols int) int { if cols <= 6 { return 1 } return cols - 4 } fn tab_bar(screen Screen) string { names := ['all', 'saves', 'checkpoints'] counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints] mut parts := []string{} for i, name in names { label := '${name} (${counts[i]})' if tab_at(i) == screen.tab { parts << fmt.bold('[${label}]') } else { parts << fmt.dim(label) } } mut bar := ' ${parts.join(' ')} ' if screen.query != '' { bar += fmt.bold('/${screen.query}_') } else { bar += fmt.dim('/ search') } return bar } fn entry_line(save history.Save, selected bool, screen Screen) string { mut line := ' ${if selected { fmt.amber('▸') } else { ' ' }} ' line += '${save_mark(save, screen)} ${fmt.dim('[' + save.id + ']')} ' line += '${fmt.date_of(save.created_at)} ${fmt.clock_of(save.created_at)} ${save.message}' mut tags := []string{} if save.agent != '' || save.session != '' { tags << '${or_dash(save.agent)} · ${or_dash(save.session)}' } if save.restores != '' { tags << 'back to ${save.restores}' } if save.replayed_from != '' { tags << 'replayed' } if tags.len > 0 { line += ' ${fmt.dim(tags.join(' '))}' } if selected { return fmt.reverse(line) } return line } fn meta_line(save history.Save) string { return fmt.dim(' ${fmt.human_bytes(save.size_bytes)} · agent ${or_dash(save.agent)} · session ${or_dash(save.session)}') } fn status_bar(screen Screen) string { mut hints := 'j/k move · / search · tab mode · c copy · enter detail · q quit' if screen.status != '' { hints = screen.status } left := 'view: ${tab_name(screen.tab)} · ${hints}' right := '${screen.items.len} shown' mut gap := screen.cols - fmt.visible_width(left) - fmt.visible_width(right) - 3 if gap < 1 { gap = 1 } return fmt.dim(' ${left}${' '.repeat(gap)}${right}') } fn save_mark(save history.Save, screen Screen) string { if save.is_checkpoint { return fmt.amber('◐') } if (screen.flags[save.id] or { events.Flag{ save_id: save.id } }).flagged { return fmt.red('⚑') } if save.replayed_from != '' { return fmt.dim('↪') } return ' ' } fn detail_frame(screen Screen, target history.Save, deltas []snapshot.Delta, asking_reason bool, typed string) string { flag := screen.flags[target.id] or { events.Flag{ save_id: target.id } } mut out := strings.Builder{} out.write_string('\x1b[H\x1b[2J') for line in detail_lines(target, deltas, flag) { out.write_string('${line}\r\n') } out.write_string('\r\n') if asking_reason { out.write_string(' ${fmt.amber('flag reason')}: ${typed}_') return out.str() } action := if flag.flagged { 'clear flag' } else { 'flag' } out.write_string(fmt.dim(' l load · f ${action} · esc back')) return out.str() } fn trim_last(text string) string { if text.len == 0 { return text } return text[..text.len - 1] } // ask_confirm is the strict prompt inside the view: only a lowercase y. fn ask_confirm(what string) bool { paint('\x1b[H\x1b[2J ${what} — continue? [y/N] ') return read_key().text == 'y' } // copy_id puts the ID on the clipboard through OSC 52, which works over ssh and // in any terminal without a helper program. fn copy_id(save_id string) { paint('\x1b]52;c;${base64.encode(save_id.bytes())}\x07') } 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{} } } } 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 } 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 } } } fn or_dash(value string) string { return if value == '' { '—' } else { value } }