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 85 deltas []snapshot.Delta86 deltas_for string87}8889909192pub fn run(store &storage.Store, start Tab, act fn (store &storage.Store, action Action) string) ! {93 enter_screen()94 defer {95 leave_screen()96 }97 mut every := []history.Save{}98 mut session := Session{99 screen: Screen{100 tab: start101 }102 stale: true103 }104 for {105 if session.stale {106 107 every = history.all_saves(store)!108 session.screen.on_line = on_line_set(store)109 session.screen.flags = events.latest_flags(store)!110 session.screen.counts = count_tabs(every, session.screen.on_line)111 session.stale = false112 }113 session.screen.rows, session.screen.cols = terminal_size()114 session.screen.items = filter_saves(every, session.screen.tab, session.screen.on_line,115 session.screen.query)116 if session.screen.selected >= session.screen.items.len {117 session.screen.selected = move_selection(session.screen.selected, 0,118 session.screen.items.len)119 }120 paint(frame_for(store, mut session))121 key := read_key()122 if key.kind in [.interrupt, .eof] {123 return124 }125 leaving := match session.mode {126 .list { list_key(mut session, key) }127 .search { search_key(mut session, key) }128 .reason { reason_key(mut session, store, key, act) }129 .detail { detail_key(mut session, store, key, act) }130 }131132 if leaving {133 return134 }135 }136}137138fn frame_for(store &storage.Store, mut session Session) string {139 match session.mode {140 .detail, .reason {141 142 143 if session.deltas_for != session.target.id {144 session.deltas = snapshot.diff_trees(store, parent_tree(store, session.target), child_tree(store,145 session.target)) or { []snapshot.Delta{} }146 session.deltas_for = session.target.id147 }148 return detail_frame(session.screen, session.target, session.deltas,149 session.mode == .reason, session.typed)150 }151 else {152 return render(session.screen)153 }154 }155}156157158fn list_key(mut session Session, key Key) bool {159 match key.kind {160 .tab {161 session.screen.tab = next_tab(session.screen.tab)162 session.screen.selected = 0163 }164 .down {165 session.screen.selected = move_selection(session.screen.selected, 1,166 session.screen.items.len)167 }168 .up {169 session.screen.selected = move_selection(session.screen.selected, -1,170 session.screen.items.len)171 }172 .enter {173 if session.screen.items.len > 0 {174 session.target = session.screen.items[session.screen.selected]175 session.mode = .detail176 }177 }178 .rune {179 match key.text {180 'q' {181 return true182 }183 'j' {184 session.screen.selected = move_selection(session.screen.selected, 1,185 session.screen.items.len)186 }187 'k' {188 session.screen.selected = move_selection(session.screen.selected, -1,189 session.screen.items.len)190 }191 'g' {192 session.screen.selected = 0193 }194 'G' {195 session.screen.selected = move_selection(session.screen.items.len, -1,196 session.screen.items.len)197 }198 '/' {199 session.mode = .search200 session.screen.query = ''201 }202 'c' {203 if session.screen.items.len > 0 {204 id := session.screen.items[session.screen.selected].id205 copy_id(id)206 session.screen.status = 'copied ${id}'207 }208 }209 else {}210 }211 }212 else {}213 }214215 return false216}217218fn search_key(mut session Session, key Key) bool {219 match key.kind {220 .rune {221 session.screen.query += key.text222 }223 .backspace {224 session.screen.query = trim_last(session.screen.query)225 }226 .escape {227 session.screen.query = ''228 session.mode = .list229 }230 .enter {231 session.mode = .list232 }233 else {}234 }235236 return false237}238239fn reason_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {240 match key.kind {241 .rune {242 session.typed += key.text243 }244 .backspace {245 session.typed = trim_last(session.typed)246 }247 .escape {248 session.typed = ''249 session.mode = .list250 }251 .enter {252 if session.typed != '' {253 session.stale = true254 session.screen.status = act(store, Action{255 kind: .flag256 save_id: session.target.id257 reason: session.typed258 })259 }260 session.typed = ''261 session.mode = .list262 }263 else {}264 }265266 return false267}268269fn detail_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {270 match key.kind {271 .escape {272 session.mode = .list273 }274 .rune {275 match key.text {276 'l' {277 if ask_confirm('load [${session.target.id}]') {278 session.stale = true279 session.screen.status = act(store, Action{280 kind: .load281 save_id: session.target.id282 })283 }284 session.mode = .list285 }286 'f' {287 state := session.screen.flags[session.target.id] or {288 events.Flag{289 save_id: session.target.id290 }291 }292 if state.flagged {293 session.stale = true294 session.screen.status = act(store, Action{295 kind: .clear_flag296 save_id: session.target.id297 })298 session.mode = .list299 } else {300 session.typed = ''301 session.mode = .reason302 }303 }304 else {}305 }306 }307 else {}308 }309310 return false311}312313314315316pub fn filter_saves(items []history.Save, tab Tab, on_line map[string]bool, query string) []history.Save {317 mut out := []history.Save{}318 needle := query.to_lower()319 for save in items {320 live := save.id in on_line321 if tab == .saves && !live {322 continue323 }324 if tab == .checkpoints && (!live || !save.is_checkpoint) {325 continue326 }327 if needle != '' && !searchable(save).contains(needle) {328 continue329 }330 out << save331 }332 return out333}334335fn searchable(save history.Save) string {336 return '${save.id} ${save.message} ${save.agent} ${save.session}'.to_lower()337}338339pub fn count_tabs(items []history.Save, on_line map[string]bool) TabCounts {340 return TabCounts{341 all: filter_saves(items, .all, on_line, '').len342 saves: filter_saves(items, .saves, on_line, '').len343 checkpoints: filter_saves(items, .checkpoints, on_line, '').len344 }345}346347348pub fn move_selection(index int, delta int, length int) int {349 if length <= 0 {350 return 0351 }352 moved := index + delta353 if moved < 0 {354 return 0355 }356 if moved >= length {357 return length - 1358 }359 return moved360}361362363pub fn detail_lines(save history.Save, deltas []snapshot.Delta, flag events.Flag) []string {364 mut lines := [365 ' ${fmt.bold('[' + save.id + ']')} ${save.message}',366 '',367 ' time ${fmt.stamp(save.created_at)}',368 ' size ${fmt.human_bytes(save.size_bytes)}',369 ' agent ${or_dash(save.agent)}',370 ' session ${or_dash(save.session)}',371 ' parent ${or_dash(save.parent)}',372 ' checksum ${save.id}',373 ]374 if flag.flagged {375 lines << ' flag ${or_dash(flag.reason)}'376 }377 lines << ''378 if deltas.len == 0 {379 lines << ' ${fmt.dim('nothing changed against the parent')}'380 return lines381 }382 lines << ' ${fmt.plural(deltas.len, 'file')} changed against the parent:'383 for delta in deltas {384 lines << ' ${change_mark(delta)} ${delta.path}${line_stats(delta)}'385 }386 return lines387}388389fn line_stats(delta snapshot.Delta) string {390 if delta.binary {391 return ' ${fmt.dim('(binary)')}'392 }393 return ' ${fmt.green('+' + delta.added.str())} ${fmt.red('-' + delta.removed.str())}'394}395396fn change_mark(delta snapshot.Delta) string {397 return match delta.kind {398 'added' { fmt.green('+') }399 'removed' { fmt.red('-') }400 else { fmt.amber('~') }401 }402}403404405pub fn render(screen Screen) string {406 mut out := strings.Builder{}407 out.write_string('\x1b[H')408 for line in frame_lines(screen) {409 out.write_string('\x1b[2K${line}\r\n')410 }411 return out.str()412}413414pub fn frame_lines(screen Screen) []string {415 mut lines := [tab_bar(screen)]416 mut body := []string{}417 for i, save in screen.items {418 body << entry_line(save, i == screen.selected, screen)419 if i == screen.selected {420 body << meta_line(save)421 }422 body << fmt.dim(' ${'─'.repeat(separator_width(screen.cols))}')423 }424 if body.len == 0 {425 body << fmt.dim(' no results')426 }427 room := body_room(screen.rows)428 start := if screen.selected >= room { screen.selected - room + 1 } else { 0 }429 for i := start; i < body.len && lines.len < room + 1; i++ {430 lines << body[i]431 }432 for lines.len < room + 1 {433 lines << ''434 }435 lines << status_bar(screen)436 return lines437}438439fn body_room(rows int) int {440 441 442 if rows <= 4 {443 return 1444 }445 return rows - 3446}447448fn separator_width(cols int) int {449 if cols <= 6 {450 return 1451 }452 return cols - 4453}454455fn tab_bar(screen Screen) string {456 names := ['all', 'saves', 'checkpoints']457 counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]458 mut parts := []string{}459 for i, name in names {460 label := '${name} (${counts[i]})'461 if tab_at(i) == screen.tab {462 parts << fmt.bold('[${label}]')463 } else {464 parts << fmt.dim(label)465 }466 }467 mut bar := ' ${parts.join(' ')} '468 if screen.query != '' {469 bar += fmt.bold('/${screen.query}_')470 } else {471 bar += fmt.dim('/ search')472 }473 return bar474}475476fn entry_line(save history.Save, selected bool, screen Screen) string {477 mut line := ' ${if selected { fmt.amber('▸') } else { ' ' }} '478 line += '${save_mark(save, screen)} ${fmt.dim('[' + save.id + ']')} '479 line += '${fmt.date_of(save.created_at)} ${fmt.clock_of(save.created_at)} ${save.message}'480 mut tags := []string{}481 if save.agent != '' || save.session != '' {482 tags << '${or_dash(save.agent)} · ${or_dash(save.session)}'483 }484 if save.restores != '' {485 tags << 'back to ${save.restores}'486 }487 if save.id !in screen.on_line {488 tags << 'replayed away'489 }490 if tags.len > 0 {491 line += ' ${fmt.dim(tags.join(' '))}'492 }493 if selected {494 return fmt.reverse(line)495 }496 return line497}498499fn meta_line(save history.Save) string {500 return fmt.dim(' ${fmt.human_bytes(save.size_bytes)} · agent ${or_dash(save.agent)} · session ${or_dash(save.session)}')501}502503fn status_bar(screen Screen) string {504 mut hints := 'j/k move · / search · tab mode · c copy · enter detail · q quit'505 if screen.status != '' {506 hints = screen.status507 }508 left := 'view: ${tab_name(screen.tab)} · ${hints}'509 right := '${screen.items.len} shown'510 mut gap := screen.cols - fmt.visible_width(left) - fmt.visible_width(right) - 3511 if gap < 1 {512 gap = 1513 }514 return fmt.dim(' ${left}${' '.repeat(gap)}${right}')515}516517fn save_mark(save history.Save, screen Screen) string {518 519 if save.id !in screen.on_line {520 return fmt.dim('↪')521 }522 if save.is_checkpoint {523 return fmt.amber('◐')524 }525 if (screen.flags[save.id] or {526 events.Flag{527 save_id: save.id528 }529 }).flagged {530 return fmt.red('⚑')531 }532 return ' '533}534535fn detail_frame(screen Screen, target history.Save, deltas []snapshot.Delta, asking_reason bool, typed string) string {536 flag := screen.flags[target.id] or {537 events.Flag{538 save_id: target.id539 }540 }541 mut out := strings.Builder{}542 out.write_string('\x1b[H\x1b[2J')543 for line in detail_lines(target, deltas, flag) {544 out.write_string('${line}\r\n')545 }546 out.write_string('\r\n')547 if asking_reason {548 out.write_string(' ${fmt.amber('flag reason')}: ${typed}_')549 return out.str()550 }551 action := if flag.flagged { 'clear flag' } else { 'flag' }552 out.write_string(fmt.dim(' l load · f ${action} · esc back'))553 return out.str()554}555556fn trim_last(text string) string {557 if text.len == 0 {558 return text559 }560 return text[..text.len - 1]561}562563564fn ask_confirm(what string) bool {565 paint('\x1b[H\x1b[2J ${what} — continue? [y/N] ')566 return read_key().text == 'y'567}568569570571fn copy_id(save_id string) {572 paint('\x1b]52;c;${base64.encode(save_id.bytes())}\x07')573}574575fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {576 if save.parent == '' {577 return snapshot.Tree{}578 }579 return snapshot.Tree{580 files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }581 }582}583584fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {585 return snapshot.Tree{586 files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }587 }588}589590fn on_line_set(store &storage.Store) map[string]bool {591 mut ids := map[string]bool{}592 for save in history.line(store) or { []history.Save{} } {593 ids[save.id] = true594 }595 return ids596}597598fn enter_screen() {599 paint('\x1b[?1049h\x1b[?25l')600 raw_mode(true)601}602603fn leave_screen() {604 raw_mode(false)605 paint('\x1b[?25h\x1b[?1049l')606}607608fn paint(text string) {609 os.fd_write(1, text)610}611612fn tab_at(index int) Tab {613 return match index {614 0 { Tab.all }615 1 { Tab.saves }616 else { Tab.checkpoints }617 }618}619620fn tab_name(tab Tab) string {621 return match tab {622 .all { 'all' }623 .saves { 'saves' }624 else { 'checkpoints' }625 }626}627628fn next_tab(tab Tab) Tab {629 return match tab {630 .all { Tab.saves }631 .saves { Tab.checkpoints }632 else { Tab.all }633 }634}635636fn or_dash(value string) string {637 return if value == '' { '—' } else { value }638}