12345678910111213module 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}43444546pub struct TabCounts {47pub mut:48 all int49 saves int50 checkpoints int51}525354pub 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}76777879struct Session {80mut:81 screen Screen82 mode Mode83 target history.Save84 typed string85 pending Action86 detail []string87 88 stale bool89 90 detail_for string91}9293949596pub 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 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 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}140141142fn 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}151152153154155fn list_key(mut session Session, key Key) bool {156 count := session.screen.items.len157 match key.kind {158 .tab {159 session.screen.tab = next_tab(session.screen.tab)160 session.screen.selected = 0161 }162 .down {163 session.screen.selected = move_selection(session.screen.selected, 1, count)164 }165 .up {166 session.screen.selected = move_selection(session.screen.selected, -1, count)167 }168 .enter {169 if count > 0 {170 session.target = session.screen.items[session.screen.selected]171 session.mode = .detail172 }173 }174 175 .escape {176 if session.screen.query == '' {177 return true178 }179 session.screen.query = ''180 session.screen.selected = 0181 }182 .rune {183 match key.text {184 'q' {185 return true186 }187 'j' {188 session.screen.selected = move_selection(session.screen.selected, 1, count)189 }190 'k' {191 session.screen.selected = move_selection(session.screen.selected, -1,192 count)193 }194 195 'g' {196 session.screen.selected = move_selection(count, -1, count)197 }198 'G' {199 session.screen.selected = 0200 }201 '/' {202 session.mode = .search203 }204 'c' {205 if count > 0 {206 copy_selected(mut session.screen,207 session.screen.items[session.screen.selected].id)208 }209 }210 else {}211 }212 }213 else {}214 }215 return false216}217218219220fn search_key(mut session Session, key Key) bool {221 match key.kind {222 .rune {223 session.screen.query += key.text224 session.screen.selected = 0225 }226 .backspace {227 session.screen.query = trim_last(session.screen.query)228 session.screen.selected = 0229 }230 .escape {231 session.screen.query = ''232 session.screen.selected = 0233 session.mode = .list234 }235 .enter {236 session.mode = .list237 }238 else {}239 }240 return false241}242243244245fn detail_key(mut session Session, key Key) bool {246 match key.kind {247 .escape {248 session.mode = .list249 }250 .rune {251 match key.text {252 'q' {253 return true254 }255 'l' {256 session.pending = Action{257 kind: .load258 save_id: session.target.id259 }260 session.mode = .confirm261 }262 'f' {263 if is_flagged(session.screen, session.target.id) {264 session.pending = Action{265 kind: .clear_flag266 save_id: session.target.id267 }268 session.mode = .confirm269 } else {270 session.typed = ''271 session.mode = .reason272 }273 }274 'c' {275 copy_selected(mut session.screen, session.target.id)276 }277 else {}278 }279 }280 else {}281 }282 return false283}284285286287fn reason_key(mut session Session, key Key) bool {288 match key.kind {289 .rune {290 session.typed += key.text291 }292 .backspace {293 session.typed = trim_last(session.typed)294 }295 .escape {296 session.typed = ''297 session.mode = .detail298 }299 .enter {300 if session.typed != '' {301 session.pending = Action{302 kind: .flag303 save_id: session.target.id304 reason: session.typed305 }306 session.typed = ''307 session.mode = .confirm308 }309 }310 else {}311 }312 return false313}314315fn confirm_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {316 if key.kind == .rune && key.text in ['y', 'Y'] {317 session.screen.status = act(store, session.pending)318 session.stale = true319 }320 session.pending = Action{}321 session.mode = .list322 return false323}324325326327328pub fn filter_saves(items []history.Save, tab Tab, on_line map[string]bool, query string) []history.Save {329 mut out := []history.Save{}330 needle := query.to_lower()331 for save in items {332 live := save.id in on_line333 if tab == .saves && (!live || save.is_checkpoint) {334 continue335 }336 if tab == .checkpoints && (!live || !save.is_checkpoint) {337 continue338 }339 if needle != '' && !searchable(save).contains(needle) {340 continue341 }342 out << save343 }344 return out345}346347348fn searchable(save history.Save) string {349 return '${save.id} ${save.message} ${save.agent} ${save.session}'.to_lower()350}351352pub fn count_tabs(items []history.Save, on_line map[string]bool) TabCounts {353 return TabCounts{354 all: filter_saves(items, .all, on_line, '').len355 saves: filter_saves(items, .saves, on_line, '').len356 checkpoints: filter_saves(items, .checkpoints, on_line, '').len357 }358}359360361pub fn move_selection(index int, delta int, length int) int {362 if length <= 0 {363 return 0364 }365 moved := index + delta366 if moved < 0 {367 return 0368 }369 if moved >= length {370 return length - 1371 }372 return moved373}374375376377pub fn render(lines []string) string {378 mut out := strings.new_builder(4096)379 out.write_string('\x1b[H')380 for i, line in lines {381 out.write_string('\x1b[2K${line}')382 if i < lines.len - 1 {383 out.write_string('\r\n')384 }385 }386 return out.str()387}388389390391fn frame_lines(session Session) []string {392 screen := session.screen393 rows := if screen.rows > 4 { screen.rows } else { 4 }394 cols := if screen.cols > 10 { screen.cols } else { 10 }395 body := rows - 3396 mut lines := [tab_bar(screen, session.mode), '']397 if session.mode in [.detail, .reason, .confirm] {398 lines << session.detail#[..body]399 } else {400 lines << list_body(screen, body, cols)401 }402 for lines.len < rows - 1 {403 lines << ''404 }405 lines << status_bar(session, cols)406 return lines.map(fmt.fit(it, cols))407}408409410411412413fn list_body(screen Screen, body int, cols int) []string {414 if screen.items.len == 0 {415 return [' ${fmt.muted('no results')}']416 }417 slots := if body / 4 < 7 { body / 4 } else { 7 }418 if slots == 0 {419 return [entry_header(screen.items[screen.selected], true, screen)]420 }421 separator := fmt.muted('─'.repeat(cols))422 start := window_start(screen.selected, slots, screen.items.len)423 mut lines := []string{}424 for slot in 0 .. slots {425 height := body / slots + if slot < body % slots { 1 } else { 0 }426 spare := if height > 4 { height - 4 } else { 0 }427 for _ in 0 .. spare / 2 {428 lines << ''429 }430 index := start + slot431 if index < screen.items.len {432 entry := screen.items[index]433 selected := index == screen.selected434 lines << entry_header(entry, selected, screen)435 lines << entry_message(entry, screen)436 lines << if selected { entry_meta(entry) } else { '' }437 } else {438 lines << ['', '', '']439 }440 lines << separator441 for _ in 0 .. spare - spare / 2 {442 lines << ''443 }444 }445 return lines446}447448449fn window_start(selected int, slots int, count int) int {450 if count <= slots {451 return 0452 }453 mut start := if selected > slots / 2 { selected - slots / 2 } else { 0 }454 if start + slots > count {455 start = count - slots456 }457 return start458}459460461462463fn entry_header(save history.Save, selected bool, screen Screen) string {464 head := save.id == screen.head465 flagged := is_flagged(screen, save.id)466 mark := if head { fmt.blue('▸') } else { fmt.muted('·') }467 mut line := ' ${mark} '468 line += if flagged {469 fmt.red(save.id)470 } else if selected {471 fmt.gold(save.id)472 } else {473 fmt.muted(save.id)474 }475 if head {476 line += ' ${fmt.blue('HEAD')}'477 }478 mut labels := []string{}479 if selected && save.is_checkpoint {480 labels << '[checkpoint]'481 }482 if flagged {483 labels << '[flagged]'484 }485 if save.id !in screen.on_line {486 labels << '[replayed away]'487 }488 if labels.len > 0 {489 line += ' ${labels.join(' ')}'490 }491 return line + ' ${fmt.hour_of(save.created_at)} ${fmt.date_of(save.created_at)}'492}493494fn entry_message(save history.Save, screen Screen) string {495 warning := if is_flagged(screen, save.id) { fmt.red('⚠ ') } else { '' }496 return ' ${warning}${save.message}'497}498499fn entry_meta(save history.Save) string {500 agent := if save.agent == '' { 'unknown' } else { save.agent }501 session := if save.session == '' { 'no session' } else { save.session }502 return ' ${fmt.human_bytes(save.size_bytes)} · ${agent} · ${session}'503}504505fn tab_bar(screen Screen, mode Mode) string {506 names := ['all', 'saves', 'checkpoints']507 counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]508 mut bar := ''509 for i, name in names {510 if tab_at(i) == screen.tab {511 bar += fmt.gold(' [${name} (${counts[i]})] ')512 } else {513 bar += fmt.muted(' ${name} (${counts[i]}) ')514 }515 }516 bar += fmt.muted('│ ')517 if mode == .search {518 bar += '/${screen.query}_'519 } else if screen.query != '' {520 bar += '/${screen.query}'521 } else {522 bar += fmt.muted('/ search')523 }524 return bar525}526527fn status_bar(session Session, cols int) string {528 hint := match session.mode {529 .search {530 'type to filter · enter keep · esc clear'531 }532 .detail {533 flag_word := if is_flagged(session.screen, session.target.id) {534 'clear flag'535 } else {536 'flag'537 }538 'esc back · l load · f ${flag_word} · c copy · q'539 }540 .reason {541 'flag reason: ${session.typed}_ · enter confirm · esc cancel'542 }543 .confirm {544 '${action_word(session.pending.kind)} [${session.pending.save_id}]? y yes · n/esc cancel'545 }546 .list {547 '↑↓ nav · / search · tab mode · c copy · enter detail · q/esc'548 }549 }550 left := ' view: ${tab_name(session.screen.tab)} · ${hint}'551 right := if session.screen.status != '' {552 session.screen.status553 } else if session.mode == .list || session.mode == .search {554 '${session.screen.items.len} shown'555 } else {556 ''557 }558 if right == '' {559 return fmt.muted(left)560 }561 562 563 room := cols - fmt.visible_width(right) - 1564 shown := fmt.fit(left, if room > 0 { room } else { 0 })565 gap := cols - fmt.visible_width(shown) - fmt.visible_width(right)566 return fmt.muted('${shown}${' '.repeat(if gap > 0 { gap } else { 0 })}${right}')567}568569fn action_word(kind ActionKind) string {570 return match kind {571 .load { 'load' }572 .flag { 'flag' }573 .clear_flag { 'clear the flag on' }574 .none { '' }575 }576}577578579pub fn detail_lines(save history.Save, head string, deltas []snapshot.Delta, flag events.Flag) []string {580 mut title := ' ${fmt.gold(save.id)}'581 if save.id == head {582 title += ' ${fmt.blue('HEAD')}'583 }584 if save.is_checkpoint {585 title += ' [checkpoint]'586 }587 if flag.flagged {588 title += ' [flagged]'589 }590 mut lines := [591 title,592 ' ${save.message}',593 '',594 detail_row('time', '${fmt.date_of(save.created_at)} · ${fmt.clock_of(save.created_at)}'),595 detail_row('size', fmt.human_bytes(save.size_bytes)),596 detail_row('agent', if save.agent == '' { 'unknown' } else { save.agent }),597 detail_row('session', if save.session == '' { 'none' } else { save.session }),598 detail_row('parent', if save.parent == '' { 'root' } else { save.parent }),599 detail_row('checksum', save.id),600 ]601 if flag.flagged {602 lines << detail_row('flag', flag.reason)603 }604 lines << ''605 if save.parent == '' {606 lines << ' initial save — no parent to diff against'607 return lines608 }609 lines << ' ${deltas.len} files changed'610 mut totals := snapshot.Delta{}611 for delta in deltas {612 lines << ' ${change_mark(delta)} ${delta.path} ${line_stats(delta)}'613 if !delta.binary {614 totals.added += delta.added615 totals.removed += delta.removed616 }617 }618 lines << ['', ' total ${line_stats(totals)}']619 return lines620}621622fn detail_row(label string, value string) string {623 return ' ${fmt.muted(label + ' '.repeat(9 - label.len))}${value}'624}625626fn line_stats(delta snapshot.Delta) string {627 if delta.binary {628 return fmt.muted('(binary)')629 }630 return '${fmt.green('+' + delta.added.str())} ${fmt.red('-' + delta.removed.str())}'631}632633fn change_mark(delta snapshot.Delta) string {634 return match delta.kind {635 'added' { fmt.green('A') }636 'removed' { fmt.red('R') }637 else { fmt.gold('M') }638 }639}640641fn is_flagged(screen Screen, save_id string) bool {642 return (screen.flags[save_id] or { events.Flag{} }).flagged643}644645646fn diff_of(store &storage.Store, save history.Save) []snapshot.Delta {647 return snapshot.diff_trees(store, parent_tree(store, save), child_tree(store, save)) or {648 []snapshot.Delta{}649 }650}651652fn trim_last(text string) string {653 runes := text.runes()654 if runes.len == 0 {655 return text656 }657 return runes[..runes.len - 1].string()658}659660661662fn copy_selected(mut screen Screen, save_id string) {663 paint('\x1b]52;c;${base64.encode(save_id.bytes())}\a')664 screen.status = 'copied ${save_id}'665}666667fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {668 if save.parent == '' {669 return snapshot.Tree{}670 }671 return snapshot.Tree{672 files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }673 }674}675676fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {677 return snapshot.Tree{678 files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }679 }680}681682683fn on_line_set(store &storage.Store) map[string]bool {684 mut ids := map[string]bool{}685 for save in history.line(store) or { []history.Save{} } {686 ids[save.id] = true687 }688 return ids689}690691692fn enter_screen() {693 paint('\x1b[?1049h\x1b[?25l')694 raw_mode(true)695}696697fn leave_screen() {698 raw_mode(false)699 paint('\x1b[?25h\x1b[?1049l')700}701702fn paint(text string) {703 os.fd_write(1, text)704}705706fn tab_at(index int) Tab {707 return match index {708 0 { Tab.all }709 1 { Tab.saves }710 else { Tab.checkpoints }711 }712}713714fn tab_name(tab Tab) string {715 return match tab {716 .all { 'all' }717 .saves { 'saves' }718 else { 'checkpoints' }719 }720}721722fn next_tab(tab Tab) Tab {723 return match tab {724 .all { Tab.saves }725 .saves { Tab.checkpoints }726 else { Tab.all }727 }728}