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 .rune {172 match key.text {173 'q' {174 return true175 }176 'j' {177 session.screen.selected = move_selection(session.screen.selected, 1, count)178 }179 'k' {180 session.screen.selected = move_selection(session.screen.selected, -1,181 count)182 }183 184 'g' {185 session.screen.selected = move_selection(count, -1, count)186 }187 'G' {188 session.screen.selected = 0189 }190 '/' {191 session.mode = .search192 }193 'c' {194 if count > 0 {195 copy_selected(mut session.screen,196 session.screen.items[session.screen.selected].id)197 }198 }199 else {}200 }201 }202 else {}203 }204 return false205}206207208209fn search_key(mut session Session, key Key) bool {210 match key.kind {211 .rune {212 if key.text == 'q' {213 return true214 }215 session.screen.query += key.text216 session.screen.selected = 0217 }218 .backspace {219 session.screen.query = trim_last(session.screen.query)220 session.screen.selected = 0221 }222 .escape {223 session.screen.query = ''224 session.screen.selected = 0225 session.mode = .list226 }227 .enter {228 session.mode = .list229 }230 else {}231 }232 return false233}234235fn 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 }272 return false273}274275276277fn reason_key(mut session Session, key Key) bool {278 match key.kind {279 .rune {280 session.typed += key.text281 }282 .backspace {283 session.typed = trim_last(session.typed)284 }285 .escape {286 session.typed = ''287 session.mode = .detail288 }289 .enter {290 if session.typed != '' {291 session.pending = Action{292 kind: .flag293 save_id: session.target.id294 reason: session.typed295 }296 session.typed = ''297 session.mode = .confirm298 }299 }300 else {}301 }302 return false303}304305306fn 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 = true310 }311 session.pending = Action{}312 session.mode = .list313 return false314}315316317318319pub fn filter_saves(items []history.Save, tab Tab, on_line map[string]bool, query string) []history.Save {320 mut out := []history.Save{}321 needle := query.to_lower()322 for save in items {323 live := save.id in on_line324 if tab == .saves && !live {325 continue326 }327 if tab == .checkpoints && (!live || !save.is_checkpoint) {328 continue329 }330 if needle != '' && !searchable(save).contains(needle) {331 continue332 }333 out << save334 }335 return out336}337338fn searchable(save history.Save) string {339 return '${save.id} ${save.message} ${save.agent} ${save.session}'.to_lower()340}341342pub fn count_tabs(items []history.Save, on_line map[string]bool) TabCounts {343 return TabCounts{344 all: filter_saves(items, .all, on_line, '').len345 saves: filter_saves(items, .saves, on_line, '').len346 checkpoints: filter_saves(items, .checkpoints, on_line, '').len347 }348}349350351pub fn move_selection(index int, delta int, length int) int {352 if length <= 0 {353 return 0354 }355 moved := index + delta356 if moved < 0 {357 return 0358 }359 if moved >= length {360 return length - 1361 }362 return moved363}364365366367pub 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 }375 }376 return out.str()377}378379380381fn 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)391 }392 for lines.len < rows - 1 {393 lines << ''394 }395 lines << status_bar(session, cols)396 return lines.map(fmt.fit(it, cols))397}398399400401402fn list_body(screen Screen, body int, cols int) []string {403 if screen.items.len == 0 {404 return [' ${fmt.muted('no results')}']405 }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 lines435}436437438fn window_start(selected int, slots int, count int) int {439 if count <= slots {440 return 0441 }442 mut start := if selected > slots / 2 { selected - slots / 2 } else { 0 }443 if start + slots > count {444 start = count - slots445 }446 return start447}448449450451452fn 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)463 }464 if head {465 line += ' ${fmt.blue('HEAD')}'466 }467 mut labels := []string{}468 if selected && save.is_checkpoint {469 labels << '[checkpoint]'470 }471 if flagged {472 labels << '[flagged]'473 }474 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 line483}484485fn 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) }}'488}489490fn 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}'494}495496fn tab_bar(screen Screen, mode Mode) string {497 names := ['all', 'saves', 'checkpoints']498 counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]499 mut bar := ''500 for i, name in names {501 if tab_at(i) == screen.tab {502 bar += fmt.gold(' [${name} (${counts[i]})] ')503 } else {504 bar += fmt.muted(' ${name} (${counts[i]}) ')505 }506 }507 bar += fmt.muted('│ ')508 if mode == .search {509 bar += '/${screen.query}_'510 } else if screen.query != '' {511 bar += '/${screen.query}'512 } else {513 bar += fmt.muted('/ search')514 }515 return bar516}517518fn 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 }540 }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 ''548 }549 if right == '' {550 return fmt.muted(left)551 }552 553 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}')558}559560fn action_word(kind ActionKind) string {561 return match kind {562 .load { 'load' }563 .flag { 'flag' }564 .clear_flag { 'clear the flag on' }565 .none { '' }566 }567}568569570pub 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')}'574 }575 if save.is_checkpoint {576 title += ' [checkpoint]'577 }578 if flag.flagged {579 title += ' [flagged]'580 }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 lines605}606607fn detail_row(label string, value string) string {608 return ' ${fmt.muted(label + ' '.repeat(9 - label.len))}${value}'609}610611fn line_stats(delta snapshot.Delta) string {612 if delta.binary {613 return fmt.muted('(binary)')614 }615 return '${fmt.green('+' + delta.added.str())} ${fmt.red('-' + delta.removed.str())}'616}617618fn 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') }623 }624}625626fn is_flagged(screen Screen, save_id string) bool {627 return (screen.flags[save_id] or { events.Flag{} }).flagged628}629630fn 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{}633 }634}635636fn trim_last(text string) string {637 runes := text.runes()638 if runes.len == 0 {639 return text640 }641 return runes[..runes.len - 1].string()642}643644645646fn copy_selected(mut screen Screen, save_id string) {647 paint('\x1b]52;c;${base64.encode(save_id.bytes())}\a')648 screen.status = 'copied ${save_id}'649}650651fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {652 if save.parent == '' {653 return snapshot.Tree{}654 }655 return snapshot.Tree{656 files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }657 }658}659660fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {661 return snapshot.Tree{662 files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }663 }664}665666fn on_line_set(store &storage.Store) map[string]bool {667 mut ids := map[string]bool{}668 for save in history.line(store) or { []history.Save{} } {669 ids[save.id] = true670 }671 return ids672}673674fn enter_screen() {675 paint('\x1b[?1049h\x1b[?25l')676 raw_mode(true)677}678679fn leave_screen() {680 raw_mode(false)681 paint('\x1b[?25h\x1b[?1049l')682}683684fn paint(text string) {685 os.fd_write(1, text)686}687688fn tab_at(index int) Tab {689 return match index {690 0 { Tab.all }691 1 { Tab.saves }692 else { Tab.checkpoints }693 }694}695696fn tab_name(tab Tab) string {697 return match tab {698 .all { 'all' }699 .saves { 'saves' }700 else { 'checkpoints' }701 }702}703704fn next_tab(tab Tab) Tab {705 return match tab {706 .all { Tab.saves }707 .saves { Tab.checkpoints }708 else { Tab.all }709 }710}