123456789101112module view1314import encoding.base6415import os16import strings17import src.events18import src.fmt19import src.history20import src.snapshot21import src.storage2223pub enum Tab {24 all25 saves26 checkpoints27}2829pub enum ActionKind {30 none31 load32 flag33 clear_flag34}3536pub struct Action {37pub mut:38 kind ActionKind39 save_id string40 reason string41}42434445pub struct TabCounts {46pub mut:47 all int48 saves int49 checkpoints int50}515253pub struct Screen {54pub mut:55 tab Tab56 items []history.Save57 counts TabCounts58 selected int59 query string60 status string61 flags map[string]events.Flag62 on_line map[string]bool63 rows int64 cols int65}6667enum Mode {68 list69 search70 reason71 detail72}73747576struct Session {77mut:78 screen Screen79 mode Mode80 target history.Save81 typed string82 83 stale bool84}8586878889pub fn run(store &storage.Store, start Tab, act fn (store &storage.Store, action Action) string) ! {90 enter_screen()91 defer {92 leave_screen()93 }94 mut every := []history.Save{}95 mut session := Session{96 screen: Screen{97 tab: start98 }99 stale: true100 }101 for {102 if session.stale {103 104 every = history.all_saves(store)!105 session.screen.on_line = on_line_set(store)106 session.screen.flags = events.latest_flags(store)!107 session.screen.counts = count_tabs(every, session.screen.on_line)108 session.stale = false109 }110 session.screen.rows, session.screen.cols = terminal_size()111 session.screen.items = filter_saves(every, session.screen.tab, session.screen.on_line,112 session.screen.query)113 if session.screen.selected >= session.screen.items.len {114 session.screen.selected = move_selection(session.screen.selected, 0,115 session.screen.items.len)116 }117 paint(frame_for(store, session))118 key := read_key()119 if key.kind in [.interrupt, .eof] {120 return121 }122 leaving := match session.mode {123 .list { list_key(mut session, key) }124 .search { search_key(mut session, key) }125 .reason { reason_key(mut session, store, key, act) }126 .detail { detail_key(mut session, store, key, act) }127 }128129 if leaving {130 return131 }132 }133}134135fn frame_for(store &storage.Store, session Session) string {136 match session.mode {137 .detail, .reason {138 delta := snapshot.diff_trees(store, parent_tree(store, session.target), child_tree(store,139 session.target)) or { []snapshot.Delta{} }140 return detail_frame(session.screen, session.target, delta, session.mode == .reason,141 session.typed)142 }143 else {144 return render(session.screen)145 }146 }147}148149150fn list_key(mut session Session, key Key) bool {151 match key.kind {152 .tab {153 session.screen.tab = next_tab(session.screen.tab)154 session.screen.selected = 0155 }156 .down {157 session.screen.selected = move_selection(session.screen.selected, 1,158 session.screen.items.len)159 }160 .up {161 session.screen.selected = move_selection(session.screen.selected, -1,162 session.screen.items.len)163 }164 .enter {165 if session.screen.items.len > 0 {166 session.target = session.screen.items[session.screen.selected]167 session.mode = .detail168 }169 }170 .rune {171 match key.text {172 'q' {173 return true174 }175 'j' {176 session.screen.selected = move_selection(session.screen.selected, 1,177 session.screen.items.len)178 }179 'k' {180 session.screen.selected = move_selection(session.screen.selected, -1,181 session.screen.items.len)182 }183 'g' {184 session.screen.selected = 0185 }186 'G' {187 session.screen.selected = move_selection(session.screen.items.len, -1,188 session.screen.items.len)189 }190 '/' {191 session.mode = .search192 session.screen.query = ''193 }194 'c' {195 if session.screen.items.len > 0 {196 id := session.screen.items[session.screen.selected].id197 copy_id(id)198 session.screen.status = 'copied ${id}'199 }200 }201 else {}202 }203 }204 else {}205 }206207 return false208}209210fn search_key(mut session Session, key Key) bool {211 match key.kind {212 .rune {213 session.screen.query += key.text214 }215 .backspace {216 session.screen.query = trim_last(session.screen.query)217 }218 .escape {219 session.screen.query = ''220 session.mode = .list221 }222 .enter {223 session.mode = .list224 }225 else {}226 }227228 return false229}230231fn reason_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {232 match key.kind {233 .rune {234 session.typed += key.text235 }236 .backspace {237 session.typed = trim_last(session.typed)238 }239 .escape {240 session.typed = ''241 session.mode = .list242 }243 .enter {244 if session.typed != '' {245 session.stale = true246 session.screen.status = act(store, Action{247 kind: .flag248 save_id: session.target.id249 reason: session.typed250 })251 }252 session.typed = ''253 session.mode = .list254 }255 else {}256 }257258 return false259}260261fn detail_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {262 match key.kind {263 .escape {264 session.mode = .list265 }266 .rune {267 match key.text {268 'l' {269 if ask_confirm('load [${session.target.id}]') {270 session.stale = true271 session.screen.status = act(store, Action{272 kind: .load273 save_id: session.target.id274 })275 }276 session.mode = .list277 }278 'f' {279 state := session.screen.flags[session.target.id] or {280 events.Flag{281 save_id: session.target.id282 }283 }284 if state.flagged {285 session.stale = true286 session.screen.status = act(store, Action{287 kind: .clear_flag288 save_id: session.target.id289 })290 session.mode = .list291 } else {292 session.typed = ''293 session.mode = .reason294 }295 }296 else {}297 }298 }299 else {}300 }301302 return false303}304305306307308pub fn filter_saves(items []history.Save, tab Tab, on_line map[string]bool, query string) []history.Save {309 mut out := []history.Save{}310 needle := query.to_lower()311 for save in items {312 live := save.id in on_line313 if tab == .saves && !live {314 continue315 }316 if tab == .checkpoints && (!live || !save.is_checkpoint) {317 continue318 }319 if needle != '' && !searchable(save).contains(needle) {320 continue321 }322 out << save323 }324 return out325}326327fn searchable(save history.Save) string {328 return '${save.id} ${save.message} ${save.agent} ${save.session}'.to_lower()329}330331pub fn count_tabs(items []history.Save, on_line map[string]bool) TabCounts {332 return TabCounts{333 all: filter_saves(items, .all, on_line, '').len334 saves: filter_saves(items, .saves, on_line, '').len335 checkpoints: filter_saves(items, .checkpoints, on_line, '').len336 }337}338339340pub fn move_selection(index int, delta int, length int) int {341 if length <= 0 {342 return 0343 }344 moved := index + delta345 if moved < 0 {346 return 0347 }348 if moved >= length {349 return length - 1350 }351 return moved352}353354355pub fn detail_lines(save history.Save, deltas []snapshot.Delta, flag events.Flag) []string {356 mut lines := [357 ' ${fmt.bold('[' + save.id + ']')} ${save.message}',358 '',359 ' time ${fmt.stamp(save.created_at)}',360 ' size ${fmt.human_bytes(save.size_bytes)}',361 ' agent ${or_dash(save.agent)}',362 ' session ${or_dash(save.session)}',363 ' parent ${or_dash(save.parent)}',364 ' checksum ${save.id}',365 ]366 if flag.flagged {367 lines << ' flag ${or_dash(flag.reason)}'368 }369 lines << ''370 if deltas.len == 0 {371 lines << ' ${fmt.dim('nothing changed against the parent')}'372 return lines373 }374 lines << ' ${fmt.plural(deltas.len, 'file')} changed against the parent:'375 for delta in deltas {376 lines << ' ${change_mark(delta)} ${delta.path}${line_stats(delta)}'377 }378 return lines379}380381fn line_stats(delta snapshot.Delta) string {382 if delta.binary {383 return ' ${fmt.dim('(binary)')}'384 }385 return ' ${fmt.green('+' + delta.added.str())} ${fmt.red('-' + delta.removed.str())}'386}387388fn change_mark(delta snapshot.Delta) string {389 return match delta.kind {390 'added' { fmt.green('+') }391 'removed' { fmt.red('-') }392 else { fmt.amber('~') }393 }394}395396397pub fn render(screen Screen) string {398 mut out := strings.Builder{}399 out.write_string('\x1b[H')400 for line in frame_lines(screen) {401 out.write_string('\x1b[2K${line}\r\n')402 }403 return out.str()404}405406pub fn frame_lines(screen Screen) []string {407 mut lines := [tab_bar(screen)]408 mut body := []string{}409 for i, save in screen.items {410 body << entry_line(save, i == screen.selected, screen)411 if i == screen.selected {412 body << meta_line(save)413 }414 body << fmt.dim(' ${'─'.repeat(separator_width(screen.cols))}')415 }416 if body.len == 0 {417 body << fmt.dim(' no results')418 }419 room := body_room(screen.rows)420 start := if screen.selected >= room { screen.selected - room + 1 } else { 0 }421 for i := start; i < body.len && lines.len < room + 1; i++ {422 lines << body[i]423 }424 for lines.len < room + 1 {425 lines << ''426 }427 lines << status_bar(screen)428 return lines429}430431fn body_room(rows int) int {432 433 434 if rows <= 4 {435 return 1436 }437 return rows - 3438}439440fn separator_width(cols int) int {441 if cols <= 6 {442 return 1443 }444 return cols - 4445}446447fn tab_bar(screen Screen) string {448 names := ['all', 'saves', 'checkpoints']449 counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]450 mut parts := []string{}451 for i, name in names {452 label := '${name} (${counts[i]})'453 if tab_at(i) == screen.tab {454 parts << fmt.bold('[${label}]')455 } else {456 parts << fmt.dim(label)457 }458 }459 mut bar := ' ${parts.join(' ')} '460 if screen.query != '' {461 bar += fmt.bold('/${screen.query}_')462 } else {463 bar += fmt.dim('/ search')464 }465 return bar466}467468fn entry_line(save history.Save, selected bool, screen Screen) string {469 mut line := ' ${if selected { fmt.amber('▸') } else { ' ' }} '470 line += '${save_mark(save, screen)} ${fmt.dim('[' + save.id + ']')} '471 line += '${fmt.date_of(save.created_at)} ${fmt.clock_of(save.created_at)} ${save.message}'472 mut tags := []string{}473 if save.agent != '' || save.session != '' {474 tags << '${or_dash(save.agent)} · ${or_dash(save.session)}'475 }476 if save.restores != '' {477 tags << 'back to ${save.restores}'478 }479 if save.id !in screen.on_line {480 tags << 'replayed away'481 }482 if tags.len > 0 {483 line += ' ${fmt.dim(tags.join(' '))}'484 }485 if selected {486 return fmt.reverse(line)487 }488 return line489}490491fn meta_line(save history.Save) string {492 return fmt.dim(' ${fmt.human_bytes(save.size_bytes)} · agent ${or_dash(save.agent)} · session ${or_dash(save.session)}')493}494495fn status_bar(screen Screen) string {496 mut hints := 'j/k move · / search · tab mode · c copy · enter detail · q quit'497 if screen.status != '' {498 hints = screen.status499 }500 left := 'view: ${tab_name(screen.tab)} · ${hints}'501 right := '${screen.items.len} shown'502 mut gap := screen.cols - fmt.visible_width(left) - fmt.visible_width(right) - 3503 if gap < 1 {504 gap = 1505 }506 return fmt.dim(' ${left}${' '.repeat(gap)}${right}')507}508509fn save_mark(save history.Save, screen Screen) string {510 511 if save.id !in screen.on_line {512 return fmt.dim('↪')513 }514 if save.is_checkpoint {515 return fmt.amber('◐')516 }517 if (screen.flags[save.id] or {518 events.Flag{519 save_id: save.id520 }521 }).flagged {522 return fmt.red('⚑')523 }524 return ' '525}526527fn detail_frame(screen Screen, target history.Save, deltas []snapshot.Delta, asking_reason bool, typed string) string {528 flag := screen.flags[target.id] or {529 events.Flag{530 save_id: target.id531 }532 }533 mut out := strings.Builder{}534 out.write_string('\x1b[H\x1b[2J')535 for line in detail_lines(target, deltas, flag) {536 out.write_string('${line}\r\n')537 }538 out.write_string('\r\n')539 if asking_reason {540 out.write_string(' ${fmt.amber('flag reason')}: ${typed}_')541 return out.str()542 }543 action := if flag.flagged { 'clear flag' } else { 'flag' }544 out.write_string(fmt.dim(' l load · f ${action} · esc back'))545 return out.str()546}547548fn trim_last(text string) string {549 if text.len == 0 {550 return text551 }552 return text[..text.len - 1]553}554555556fn ask_confirm(what string) bool {557 paint('\x1b[H\x1b[2J ${what} — continue? [y/N] ')558 return read_key().text == 'y'559}560561562563fn copy_id(save_id string) {564 paint('\x1b]52;c;${base64.encode(save_id.bytes())}\x07')565}566567fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {568 if save.parent == '' {569 return snapshot.Tree{}570 }571 return snapshot.Tree{572 files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }573 }574}575576fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {577 return snapshot.Tree{578 files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }579 }580}581582fn on_line_set(store &storage.Store) map[string]bool {583 mut ids := map[string]bool{}584 for save in history.line(store) or { []history.Save{} } {585 ids[save.id] = true586 }587 return ids588}589590fn enter_screen() {591 paint('\x1b[?1049h\x1b[?25l')592 raw_mode(true)593}594595fn leave_screen() {596 raw_mode(false)597 paint('\x1b[?25h\x1b[?1049l')598}599600fn paint(text string) {601 os.fd_write(1, text)602}603604fn tab_at(index int) Tab {605 return match index {606 0 { Tab.all }607 1 { Tab.saves }608 else { Tab.checkpoints }609 }610}611612fn tab_name(tab Tab) string {613 return match tab {614 .all { 'all' }615 .saves { 'saves' }616 else { 'checkpoints' }617 }618}619620fn next_tab(tab Tab) Tab {621 return match tab {622 .all { Tab.saves }623 .saves { Tab.checkpoints }624 else { Tab.all }625 }626}627628fn or_dash(value string) string {629 return if value == '' { '—' } else { value }630}