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}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 172 .escape {173 if session.screen.query == '' {174 return true175 }176 session.screen.query = ''177 session.screen.selected = 0178 }179 .rune {180 match key.text {181 'q' {182 return true183 }184 'j' {185 session.screen.selected = move_selection(session.screen.selected, 1, count)186 }187 'k' {188 session.screen.selected = move_selection(session.screen.selected, -1,189 count)190 }191 192 'g' {193 session.screen.selected = move_selection(count, -1, count)194 }195 'G' {196 session.screen.selected = 0197 }198 '/' {199 session.mode = .search200 }201 'c' {202 if count > 0 {203 copy_selected(mut session.screen,204 session.screen.items[session.screen.selected].id)205 }206 }207 else {}208 }209 }210 else {}211 }212 return false213}214215216217fn search_key(mut session Session, key Key) bool {218 match key.kind {219 .rune {220 session.screen.query += key.text221 session.screen.selected = 0222 }223 .backspace {224 session.screen.query = trim_last(session.screen.query)225 session.screen.selected = 0226 }227 .escape {228 session.screen.query = ''229 session.screen.selected = 0230 session.mode = .list231 }232 .enter {233 session.mode = .list234 }235 else {}236 }237 return false238}239240fn detail_key(mut session Session, key Key) bool {241 match key.kind {242 .escape {243 session.mode = .list244 }245 .rune {246 match key.text {247 'q' {248 return true249 }250 'l' {251 session.pending = Action{252 kind: .load253 save_id: session.target.id254 }255 session.mode = .confirm256 }257 'f' {258 if is_flagged(session.screen, session.target.id) {259 session.pending = Action{260 kind: .clear_flag261 save_id: session.target.id262 }263 session.mode = .confirm264 } else {265 session.typed = ''266 session.mode = .reason267 }268 }269 'c' {270 copy_selected(mut session.screen, session.target.id)271 }272 else {}273 }274 }275 else {}276 }277 return false278}279280281282fn reason_key(mut session Session, key Key) bool {283 match key.kind {284 .rune {285 session.typed += key.text286 }287 .backspace {288 session.typed = trim_last(session.typed)289 }290 .escape {291 session.typed = ''292 session.mode = .detail293 }294 .enter {295 if session.typed != '' {296 session.pending = Action{297 kind: .flag298 save_id: session.target.id299 reason: session.typed300 }301 session.typed = ''302 session.mode = .confirm303 }304 }305 else {}306 }307 return false308}309310311fn confirm_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {312 if key.kind == .rune && key.text == 'y' {313 session.screen.status = act(store, session.pending)314 session.stale = true315 }316 session.pending = Action{}317 session.mode = .list318 return false319}320321322323324pub fn filter_saves(items []history.Save, tab Tab, on_line map[string]bool, query string) []history.Save {325 mut out := []history.Save{}326 needle := query.to_lower()327 for save in items {328 live := save.id in on_line329 if tab == .saves && !live {330 continue331 }332 if tab == .checkpoints && (!live || !save.is_checkpoint) {333 continue334 }335 if needle != '' && !searchable(save).contains(needle) {336 continue337 }338 out << save339 }340 return out341}342343fn searchable(save history.Save) string {344 return '${save.id} ${save.message} ${save.agent} ${save.session}'.to_lower()345}346347pub fn count_tabs(items []history.Save, on_line map[string]bool) TabCounts {348 return TabCounts{349 all: filter_saves(items, .all, on_line, '').len350 saves: filter_saves(items, .saves, on_line, '').len351 checkpoints: filter_saves(items, .checkpoints, on_line, '').len352 }353}354355356pub fn move_selection(index int, delta int, length int) int {357 if length <= 0 {358 return 0359 }360 moved := index + delta361 if moved < 0 {362 return 0363 }364 if moved >= length {365 return length - 1366 }367 return moved368}369370371372pub fn render(lines []string) string {373 mut out := strings.new_builder(4096)374 out.write_string('\x1b[H')375 for i, line in lines {376 out.write_string('\x1b[2K${line}')377 if i < lines.len - 1 {378 out.write_string('\r\n')379 }380 }381 return out.str()382}383384385386fn frame_lines(session Session) []string {387 screen := session.screen388 rows := if screen.rows > 4 { screen.rows } else { 4 }389 cols := if screen.cols > 10 { screen.cols } else { 10 }390 body := rows - 3391 mut lines := [tab_bar(screen, session.mode), '']392 if session.mode in [.detail, .reason, .confirm] {393 lines << session.detail#[..body]394 } else {395 lines << list_body(screen, body, cols)396 }397 for lines.len < rows - 1 {398 lines << ''399 }400 lines << status_bar(session, cols)401 return lines.map(fmt.fit(it, cols))402}403404405406407408fn list_body(screen Screen, body int, cols int) []string {409 if screen.items.len == 0 {410 return [' ${fmt.muted('no results')}']411 }412 slots := if body / 4 < 7 { body / 4 } else { 7 }413 if slots == 0 {414 return [entry_header(screen.items[screen.selected], true, screen)]415 }416 separator := fmt.muted('─'.repeat(cols))417 start := window_start(screen.selected, slots, screen.items.len)418 mut lines := []string{}419 for slot in 0 .. slots {420 height := body / slots + if slot < body % slots { 1 } else { 0 }421 spare := if height > 4 { height - 4 } else { 0 }422 for _ in 0 .. spare / 2 {423 lines << ''424 }425 index := start + slot426 if index < screen.items.len {427 entry := screen.items[index]428 selected := index == screen.selected429 lines << entry_header(entry, selected, screen)430 lines << entry_message(entry, screen)431 lines << if selected { entry_meta(entry) } else { '' }432 } else {433 lines << ['', '', '']434 }435 lines << separator436 for _ in 0 .. spare - spare / 2 {437 lines << ''438 }439 }440 return lines441}442443444fn window_start(selected int, slots int, count int) int {445 if count <= slots {446 return 0447 }448 mut start := if selected > slots / 2 { selected - slots / 2 } else { 0 }449 if start + slots > count {450 start = count - slots451 }452 return start453}454455456457458fn entry_header(save history.Save, selected bool, screen Screen) string {459 head := save.id == screen.head460 flagged := is_flagged(screen, save.id)461 mark := if head { fmt.blue('▸') } else { fmt.muted('·') }462 mut line := ' ${mark} '463 line += if flagged {464 fmt.red(save.id)465 } else if selected {466 fmt.gold(save.id)467 } else {468 fmt.muted(save.id)469 }470 if head {471 line += ' ${fmt.blue('HEAD')}'472 }473 mut labels := []string{}474 if selected && save.is_checkpoint {475 labels << '[checkpoint]'476 }477 if flagged {478 labels << '[flagged]'479 }480 if save.id !in screen.on_line {481 labels << '[replayed away]'482 }483 if labels.len > 0 {484 line += ' ${labels.join(' ')}'485 }486 return line + ' ${fmt.hour_of(save.created_at)} ${fmt.date_of(save.created_at)}'487}488489fn entry_message(save history.Save, screen Screen) string {490 warning := if is_flagged(screen, save.id) { fmt.red('⚠ ') } else { '' }491 return ' ${warning}${save.message}'492}493494fn entry_meta(save history.Save) string {495 agent := if save.agent == '' { 'unknown' } else { save.agent }496 session := if save.session == '' { 'no session' } else { save.session }497 return ' ${fmt.human_bytes(save.size_bytes)} · ${agent} · ${session}'498}499500fn tab_bar(screen Screen, mode Mode) string {501 names := ['all', 'saves', 'checkpoints']502 counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]503 mut bar := ''504 for i, name in names {505 if tab_at(i) == screen.tab {506 bar += fmt.gold(' [${name} (${counts[i]})] ')507 } else {508 bar += fmt.muted(' ${name} (${counts[i]}) ')509 }510 }511 bar += fmt.muted('│ ')512 if mode == .search {513 bar += '/${screen.query}_'514 } else if screen.query != '' {515 bar += '/${screen.query}'516 } else {517 bar += fmt.muted('/ search')518 }519 return bar520}521522fn status_bar(session Session, cols int) string {523 hint := match session.mode {524 .search {525 'type to filter · enter keep · esc clear'526 }527 .detail {528 flag_word := if is_flagged(session.screen, session.target.id) {529 'clear flag'530 } else {531 'flag'532 }533 'esc back · l load · f ${flag_word} · c copy · q'534 }535 .reason {536 'flag reason: ${session.typed}_ · enter confirm · esc cancel'537 }538 .confirm {539 '${action_word(session.pending.kind)} [${session.pending.save_id}]? y yes · n/esc cancel'540 }541 .list {542 '↑↓ nav · / search · tab mode · c copy · enter detail · q/esc'543 }544 }545 left := ' view: ${tab_name(session.screen.tab)} · ${hint}'546 right := if session.screen.status != '' {547 session.screen.status548 } else if session.mode == .list || session.mode == .search {549 '${session.screen.items.len} shown'550 } else {551 ''552 }553 if right == '' {554 return fmt.muted(left)555 }556 557 558 room := cols - fmt.visible_width(right) - 1559 shown := fmt.fit(left, if room > 0 { room } else { 0 })560 gap := cols - fmt.visible_width(shown) - fmt.visible_width(right)561 return fmt.muted('${shown}${' '.repeat(if gap > 0 { gap } else { 0 })}${right}')562}563564fn action_word(kind ActionKind) string {565 return match kind {566 .load { 'load' }567 .flag { 'flag' }568 .clear_flag { 'clear the flag on' }569 .none { '' }570 }571}572573574pub fn detail_lines(save history.Save, head string, deltas []snapshot.Delta, flag events.Flag) []string {575 mut title := ' ${fmt.gold(save.id)}'576 if save.id == head {577 title += ' ${fmt.blue('HEAD')}'578 }579 if save.is_checkpoint {580 title += ' [checkpoint]'581 }582 if flag.flagged {583 title += ' [flagged]'584 }585 mut lines := [586 title,587 ' ${save.message}',588 '',589 detail_row('time', '${fmt.date_of(save.created_at)} · ${fmt.clock_of(save.created_at)}'),590 detail_row('size', fmt.human_bytes(save.size_bytes)),591 detail_row('agent', if save.agent == '' { 'unknown' } else { save.agent }),592 detail_row('session', if save.session == '' { 'none' } else { save.session }),593 detail_row('parent', if save.parent == '' { 'root' } else { save.parent }),594 detail_row('checksum', save.id),595 ]596 if flag.flagged {597 lines << detail_row('flag', flag.reason)598 }599 lines << ''600 if save.parent == '' {601 lines << ' initial save — no parent to diff against'602 return lines603 }604 lines << ' ${deltas.len} files changed'605 for delta in deltas {606 lines << ' ${change_mark(delta)} ${delta.path} ${line_stats(delta)}'607 }608 return lines609}610611fn detail_row(label string, value string) string {612 return ' ${fmt.muted(label + ' '.repeat(9 - label.len))}${value}'613}614615fn line_stats(delta snapshot.Delta) string {616 if delta.binary {617 return fmt.muted('(binary)')618 }619 return '${fmt.green('+' + delta.added.str())} ${fmt.red('-' + delta.removed.str())}'620}621622fn change_mark(delta snapshot.Delta) string {623 return match delta.kind {624 'added' { fmt.green('A') }625 'removed' { fmt.red('R') }626 else { fmt.gold('M') }627 }628}629630fn is_flagged(screen Screen, save_id string) bool {631 return (screen.flags[save_id] or { events.Flag{} }).flagged632}633634fn diff_of(store &storage.Store, save history.Save) []snapshot.Delta {635 return snapshot.diff_trees(store, parent_tree(store, save), child_tree(store, save)) or {636 []snapshot.Delta{}637 }638}639640fn trim_last(text string) string {641 runes := text.runes()642 if runes.len == 0 {643 return text644 }645 return runes[..runes.len - 1].string()646}647648649650fn copy_selected(mut screen Screen, save_id string) {651 paint('\x1b]52;c;${base64.encode(save_id.bytes())}\a')652 screen.status = 'copied ${save_id}'653}654655fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {656 if save.parent == '' {657 return snapshot.Tree{}658 }659 return snapshot.Tree{660 files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }661 }662}663664fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {665 return snapshot.Tree{666 files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }667 }668}669670fn on_line_set(store &storage.Store) map[string]bool {671 mut ids := map[string]bool{}672 for save in history.line(store) or { []history.Save{} } {673 ids[save.id] = true674 }675 return ids676}677678fn enter_screen() {679 paint('\x1b[?1049h\x1b[?25l')680 raw_mode(true)681}682683fn leave_screen() {684 raw_mode(false)685 paint('\x1b[?25h\x1b[?1049l')686}687688fn paint(text string) {689 os.fd_write(1, text)690}691692fn tab_at(index int) Tab {693 return match index {694 0 { Tab.all }695 1 { Tab.saves }696 else { Tab.checkpoints }697 }698}699700fn tab_name(tab Tab) string {701 return match tab {702 .all { 'all' }703 .saves { 'saves' }704 else { 'checkpoints' }705 }706}707708fn next_tab(tab Tab) Tab {709 return match tab {710 .all { Tab.saves }711 .saves { Tab.checkpoints }712 else { Tab.all }713 }714}