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}8384858687pub fn run(store &storage.Store, start Tab, act fn (store &storage.Store, action Action) string) ! {88 enter_screen()89 defer {90 leave_screen()91 }92 every := history.all_saves(store)!93 on_line := on_line_set(store)94 mut session := Session{95 screen: Screen{96 tab: start97 on_line: on_line98 flags: events.latest_flags(store)!99 counts: count_tabs(every, on_line)100 }101 }102 for {103 session.screen.rows, session.screen.cols = terminal_size()104 session.screen.items = filter_saves(every, session.screen.tab, session.screen.on_line,105 session.screen.query)106 if session.screen.selected >= session.screen.items.len {107 session.screen.selected = move_selection(session.screen.selected, 0,108 session.screen.items.len)109 }110 paint(frame_for(store, session))111 key := read_key()112 if key.kind in [.interrupt, .eof] {113 return114 }115 leaving := match session.mode {116 .list { list_key(mut session, key) }117 .search { search_key(mut session, key) }118 .reason { reason_key(mut session, store, key, act) }119 .detail { detail_key(mut session, store, key, act) }120 }121122 if leaving {123 return124 }125 }126}127128fn frame_for(store &storage.Store, session Session) string {129 match session.mode {130 .detail, .reason {131 delta := snapshot.diff_trees(store, parent_tree(store, session.target), child_tree(store,132 session.target)) or { []snapshot.Delta{} }133 return detail_frame(session.screen, session.target, delta, session.mode == .reason,134 session.typed)135 }136 else {137 return render(session.screen)138 }139 }140}141142143fn list_key(mut session Session, key Key) bool {144 match key.kind {145 .tab {146 session.screen.tab = next_tab(session.screen.tab)147 session.screen.selected = 0148 }149 .down {150 session.screen.selected = move_selection(session.screen.selected, 1,151 session.screen.items.len)152 }153 .up {154 session.screen.selected = move_selection(session.screen.selected, -1,155 session.screen.items.len)156 }157 .enter {158 if session.screen.items.len > 0 {159 session.target = session.screen.items[session.screen.selected]160 session.mode = .detail161 }162 }163 .rune {164 match key.text {165 'q' {166 return true167 }168 'j' {169 session.screen.selected = move_selection(session.screen.selected, 1,170 session.screen.items.len)171 }172 'k' {173 session.screen.selected = move_selection(session.screen.selected, -1,174 session.screen.items.len)175 }176 'g' {177 session.screen.selected = 0178 }179 'G' {180 session.screen.selected = move_selection(session.screen.items.len, -1,181 session.screen.items.len)182 }183 '/' {184 session.mode = .search185 session.screen.query = ''186 }187 'c' {188 if session.screen.items.len > 0 {189 id := session.screen.items[session.screen.selected].id190 copy_id(id)191 session.screen.status = 'copied ${id}'192 }193 }194 else {}195 }196 }197 else {}198 }199200 return false201}202203fn search_key(mut session Session, key Key) bool {204 match key.kind {205 .rune {206 session.screen.query += key.text207 }208 .backspace {209 session.screen.query = trim_last(session.screen.query)210 }211 .escape {212 session.screen.query = ''213 session.mode = .list214 }215 .enter {216 session.mode = .list217 }218 else {}219 }220221 return false222}223224fn reason_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {225 match key.kind {226 .rune {227 session.typed += key.text228 }229 .backspace {230 session.typed = trim_last(session.typed)231 }232 .escape {233 session.typed = ''234 session.mode = .list235 }236 .enter {237 if session.typed != '' {238 session.screen.status = act(store, Action{239 kind: .flag240 save_id: session.target.id241 reason: session.typed242 })243 }244 session.typed = ''245 session.mode = .list246 }247 else {}248 }249250 return false251}252253fn detail_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {254 match key.kind {255 .escape {256 session.mode = .list257 }258 .rune {259 match key.text {260 'l' {261 if ask_confirm('load [${session.target.id}]') {262 session.screen.status = act(store, Action{263 kind: .load264 save_id: session.target.id265 })266 }267 session.mode = .list268 }269 'f' {270 state := session.screen.flags[session.target.id] or {271 events.Flag{272 save_id: session.target.id273 }274 }275 if state.flagged {276 session.screen.status = act(store, Action{277 kind: .clear_flag278 save_id: session.target.id279 })280 session.mode = .list281 } else {282 session.typed = ''283 session.mode = .reason284 }285 }286 else {}287 }288 }289 else {}290 }291292 return false293}294295296297298pub fn filter_saves(items []history.Save, tab Tab, on_line map[string]bool, query string) []history.Save {299 mut out := []history.Save{}300 needle := query.to_lower()301 for save in items {302 live := save.id in on_line && save.replayed_from == ''303 if tab == .saves && !live {304 continue305 }306 if tab == .checkpoints && (!live || !save.is_checkpoint) {307 continue308 }309 if needle != '' && !searchable(save).contains(needle) {310 continue311 }312 out << save313 }314 return out315}316317fn searchable(save history.Save) string {318 return '${save.id} ${save.message} ${save.agent} ${save.session}'.to_lower()319}320321pub fn count_tabs(items []history.Save, on_line map[string]bool) TabCounts {322 return TabCounts{323 all: filter_saves(items, .all, on_line, '').len324 saves: filter_saves(items, .saves, on_line, '').len325 checkpoints: filter_saves(items, .checkpoints, on_line, '').len326 }327}328329330pub fn move_selection(index int, delta int, length int) int {331 if length <= 0 {332 return 0333 }334 moved := index + delta335 if moved < 0 {336 return 0337 }338 if moved >= length {339 return length - 1340 }341 return moved342}343344345pub fn detail_lines(save history.Save, deltas []snapshot.Delta, flag events.Flag) []string {346 mut lines := [347 ' ${fmt.bold('[' + save.id + ']')} ${save.message}',348 '',349 ' time ${fmt.stamp(save.created_at)}',350 ' size ${fmt.human_bytes(save.size_bytes)}',351 ' agent ${or_dash(save.agent)}',352 ' session ${or_dash(save.session)}',353 ' parent ${or_dash(save.parent)}',354 ' checksum ${save.id}',355 ]356 if flag.flagged {357 lines << ' flag ${or_dash(flag.reason)}'358 }359 lines << ''360 if deltas.len == 0 {361 lines << ' ${fmt.dim('nothing changed against the parent')}'362 return lines363 }364 lines << ' ${fmt.plural(deltas.len, 'file')} changed against the parent:'365 for delta in deltas {366 lines << ' ${change_mark(delta)} ${delta.path}${line_stats(delta)}'367 }368 return lines369}370371fn line_stats(delta snapshot.Delta) string {372 if delta.binary {373 return ' ${fmt.dim('(binary)')}'374 }375 return ' ${fmt.green('+' + delta.added.str())} ${fmt.red('-' + delta.removed.str())}'376}377378fn change_mark(delta snapshot.Delta) string {379 return match delta.kind {380 'added' { fmt.green('+') }381 'removed' { fmt.red('-') }382 else { fmt.amber('~') }383 }384}385386387pub fn render(screen Screen) string {388 mut out := strings.Builder{}389 out.write_string('\x1b[H')390 for line in frame_lines(screen) {391 out.write_string('\x1b[2K${line}\r\n')392 }393 return out.str()394}395396pub fn frame_lines(screen Screen) []string {397 mut lines := [tab_bar(screen)]398 mut body := []string{}399 for i, save in screen.items {400 body << entry_line(save, i == screen.selected, screen)401 if i == screen.selected {402 body << meta_line(save)403 }404 body << fmt.dim(' ${'─'.repeat(separator_width(screen.cols))}')405 }406 if body.len == 0 {407 body << fmt.dim(' no results')408 }409 room := body_room(screen.rows)410 start := if screen.selected >= room { screen.selected - room + 1 } else { 0 }411 for i := start; i < body.len && lines.len < room + 1; i++ {412 lines << body[i]413 }414 for lines.len < room + 1 {415 lines << ''416 }417 lines << status_bar(screen)418 return lines419}420421fn body_room(rows int) int {422 423 424 if rows <= 4 {425 return 1426 }427 return rows - 3428}429430fn separator_width(cols int) int {431 if cols <= 6 {432 return 1433 }434 return cols - 4435}436437fn tab_bar(screen Screen) string {438 names := ['all', 'saves', 'checkpoints']439 counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]440 mut parts := []string{}441 for i, name in names {442 label := '${name} (${counts[i]})'443 if tab_at(i) == screen.tab {444 parts << fmt.bold('[${label}]')445 } else {446 parts << fmt.dim(label)447 }448 }449 mut bar := ' ${parts.join(' ')} '450 if screen.query != '' {451 bar += fmt.bold('/${screen.query}_')452 } else {453 bar += fmt.dim('/ search')454 }455 return bar456}457458fn entry_line(save history.Save, selected bool, screen Screen) string {459 mut line := ' ${if selected { fmt.amber('▸') } else { ' ' }} '460 line += '${save_mark(save, screen)} ${fmt.dim('[' + save.id + ']')} '461 line += '${fmt.date_of(save.created_at)} ${fmt.clock_of(save.created_at)} ${save.message}'462 mut tags := []string{}463 if save.agent != '' || save.session != '' {464 tags << '${or_dash(save.agent)} · ${or_dash(save.session)}'465 }466 if save.restores != '' {467 tags << 'back to ${save.restores}'468 }469 if save.replayed_from != '' {470 tags << 'replayed'471 }472 if tags.len > 0 {473 line += ' ${fmt.dim(tags.join(' '))}'474 }475 if selected {476 return fmt.reverse(line)477 }478 return line479}480481fn meta_line(save history.Save) string {482 return fmt.dim(' ${fmt.human_bytes(save.size_bytes)} · agent ${or_dash(save.agent)} · session ${or_dash(save.session)}')483}484485fn status_bar(screen Screen) string {486 mut hints := 'j/k move · / search · tab mode · c copy · enter detail · q quit'487 if screen.status != '' {488 hints = screen.status489 }490 left := 'view: ${tab_name(screen.tab)} · ${hints}'491 right := '${screen.items.len} shown'492 mut gap := screen.cols - fmt.visible_width(left) - fmt.visible_width(right) - 3493 if gap < 1 {494 gap = 1495 }496 return fmt.dim(' ${left}${' '.repeat(gap)}${right}')497}498499fn save_mark(save history.Save, screen Screen) string {500 if save.is_checkpoint {501 return fmt.amber('◐')502 }503 if (screen.flags[save.id] or {504 events.Flag{505 save_id: save.id506 }507 }).flagged {508 return fmt.red('⚑')509 }510 if save.replayed_from != '' {511 return fmt.dim('↪')512 }513 return ' '514}515516fn detail_frame(screen Screen, target history.Save, deltas []snapshot.Delta, asking_reason bool, typed string) string {517 flag := screen.flags[target.id] or {518 events.Flag{519 save_id: target.id520 }521 }522 mut out := strings.Builder{}523 out.write_string('\x1b[H\x1b[2J')524 for line in detail_lines(target, deltas, flag) {525 out.write_string('${line}\r\n')526 }527 out.write_string('\r\n')528 if asking_reason {529 out.write_string(' ${fmt.amber('flag reason')}: ${typed}_')530 return out.str()531 }532 action := if flag.flagged { 'clear flag' } else { 'flag' }533 out.write_string(fmt.dim(' l load · f ${action} · esc back'))534 return out.str()535}536537fn trim_last(text string) string {538 if text.len == 0 {539 return text540 }541 return text[..text.len - 1]542}543544545fn ask_confirm(what string) bool {546 paint('\x1b[H\x1b[2J ${what} — continue? [y/N] ')547 return read_key().text == 'y'548}549550551552fn copy_id(save_id string) {553 paint('\x1b]52;c;${base64.encode(save_id.bytes())}\x07')554}555556fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {557 if save.parent == '' {558 return snapshot.Tree{}559 }560 return snapshot.Tree{561 files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }562 }563}564565fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {566 return snapshot.Tree{567 files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }568 }569}570571fn on_line_set(store &storage.Store) map[string]bool {572 mut ids := map[string]bool{}573 for save in history.line(store) or { []history.Save{} } {574 ids[save.id] = true575 }576 return ids577}578579fn enter_screen() {580 paint('\x1b[?1049h\x1b[?25l')581 raw_mode(true)582}583584fn leave_screen() {585 raw_mode(false)586 paint('\x1b[?25h\x1b[?1049l')587}588589fn paint(text string) {590 os.fd_write(1, text)591}592593fn tab_at(index int) Tab {594 return match index {595 0 { Tab.all }596 1 { Tab.saves }597 else { Tab.checkpoints }598 }599}600601fn tab_name(tab Tab) string {602 return match tab {603 .all { 'all' }604 .saves { 'saves' }605 else { 'checkpoints' }606 }607}608609fn next_tab(tab Tab) Tab {610 return match tab {611 .all { Tab.saves }612 .saves { Tab.checkpoints }613 else { Tab.all }614 }615}616617fn or_dash(value string) string {618 return if value == '' { '—' } else { value }619}