7ec030db3e06
match the guided steal screens to the addendum: the repo named, relative times, rows cut to the terminal, the kept and taken files listed, and d and ? under test (3.0.106)
- time
- 2026-09-28 05:09 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- fix a2 review findings
- parent
- 29a1ceda1bfd
↓ koh-7ec030db.tar.gzatlas at this save
files changed6 files
VERSION+1 −1src/cli/cmd_steal.v+51 −11src/cli/cmd_steal_test.v+11 −0src/exchange/steal.v+2 −2tests/cli_test.v+22 −5v.mod+1 −1src/cli/cmd_steal.v+51 −11
@@ -16,6 +16,7 @@1616 import src.storage1717 import src.exchange1818 import src.view19+import time1920 2021 pub fn cmd_steal(raw []string) int {2122 args := parse('steal', raw, Options{@@ -123,7 +124,9 @@123124 } else if !guided {124125 note("replayed ${report.replayed} of this machine's saves onto the remote head")125126 }126- if report.kept_mine > 0 {127+ if guided {128+ note_choices(report)129+ } else if report.kept_mine > 0 {127130 note('kept mine for ${fmt.plural(report.kept_mine, 'file')}')128131 }129132 if guided {@@ -151,12 +154,30 @@151154 // note_kept_versions names, for each file answered "theirs", the save that still holds this152155 // machine's version.153156 fn note_kept_versions(report exchange.StealReport) {157+ // `<path>:<save>`: a save ID has no colon, a path may.154158 for kept in report.kept_versions {155- parts := kept.split_nth(':', 2)156- note('your ${parts[0]} is in ${parts[1]} · koh peek ${parts[1]}')159+ path := kept.all_before_last(':')160+ save := kept.all_after_last(':')161+ note('your ${path} is in ${save} · koh peek ${save}')157162 }158163 }159164 165+// note_choices is the done screen's line of answers: `kept mine: a · took theirs: b`.166+fn note_choices(report exchange.StealReport) {167+ theirs := report.kept_versions.map(it.all_before_last(':'))168+ mine := report.clashes.filter(it !in theirs)169+ mut parts := []string{}170+ if mine.len > 0 {171+ parts << 'kept mine: ${mine.join(' · ')}'172+ }173+ if theirs.len > 0 {174+ parts << 'took theirs: ${theirs.join(' · ')}'175+ }176+ if parts.len > 0 {177+ note(parts.join(' · '))178+ }179+}180+160181 fn ready_to_steal() bool {161182 store := open_store() or { return true }162183 head := history.head(store)@@ -169,8 +190,9 @@169190 return true170191 }171192 paths := unsaved_paths(store, head) or { []string{} }193+ name := os.base(store.root)172194 store.close()173- println(' ${fmt.gold(fmt.glyph_ok + ' steal')}')195+ println(' ${fmt.gold('${fmt.glyph_ok} steal ${name}')}')174196 println('')175197 println(' you have ${fmt.plural(paths.len, 'unsaved file')}')176198 if paths.len > 0 {@@ -189,7 +211,11 @@189211 's' {190212 print(' message: ')191213 message := os.get_line().trim_space()192- return message != '' && cmd_save([message], false) == 0214+ if message == '' {215+ println(' no message — nothing was saved')216+ return false217+ }218+ return cmd_save([message], false) == 0193219 }194220 else { return false }195221 }@@ -222,10 +248,12 @@222248 _, columns := view.terminal_size()223249 width := if columns > 0 && columns < 80 { columns } else { 80 }224250 println('')225- println(' ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base}')251+ println(' ${fmt.gold('you')} and ${fmt.blue(preview.repo)} both moved since ${preview.base.id} · ${ago(preview.base.created_at)}')226252 println('')227- println(' ${fmt.gold('you')} ${fmt.plural(preview.local.len, 'save')} ${messages(preview.local.map(it.message))}')228- println(' ${fmt.blue('remote')} ${fmt.plural(preview.remote.len, 'save')} ${messages(preview.remote.map(it.message))}')253+ println(overview_row(' ${fmt.gold('you')} ${fmt.plural(preview.local.len, 'save')} ${messages(preview.local.map(it.message))}',254+ width))255+ println(overview_row(' ${fmt.blue('remote')} ${fmt.plural(preview.remote.len, 'save')} ${messages(preview.remote.map(it.message))}',256+ width))229257 println('')230258 println(' ${fmt.plural(preview.changed, 'file')} changed')231259 println(' ${fmt.green('✓')} ${preview.automatic} combine by themselves')@@ -246,9 +274,9 @@246274 clash := preview.clashes[at]247275 println('')248276 println(' ── ${at + 1} of ${preview.clashes.len} · ${clash.path} ──')249- println(fmt.fit(' ${fmt.gold('you')} ${clash.mine_stat} ${clash.mine_save.message} · ${fmt.stamp(clash.mine_save.created_at)} · ${clash.mine_save.machine}',277+ println(fmt.fit(' ${fmt.gold('you')} ${clash.mine_stat} ${clash.mine_save.message} · ${ago(clash.mine_save.created_at)} · ${clash.mine_save.machine}',250278 width))251- println(fmt.fit(' ${fmt.blue('remote')} ${clash.theirs_stat} ${clash.theirs_save.message} · ${fmt.stamp(clash.theirs_save.created_at)} · ${clash.theirs_save.machine}',279+ println(fmt.fit(' ${fmt.blue('remote')} ${clash.theirs_stat} ${clash.theirs_save.message} · ${ago(clash.theirs_save.created_at)} · ${clash.theirs_save.machine}',252280 width))253281 println('')254282 println(' [m] keep mine [t] take theirs [d] see both changes')@@ -290,7 +318,19 @@290318 }291319 292320 fn messages(items []string) string {293- return fmt.fit(items.map('"${it}"').join(' · '), 52)321+ return items.map('"${it}"').join(' · ')322+}323+324+// overview_row cuts a row of save messages to the terminal, ending in `· …` when some were left out.325+fn overview_row(text string, width int) string {326+ if fmt.visible_width(text) <= width {327+ return text328+ }329+ return fmt.fit(text, width - 4) + ' · …'330+}331+332+fn ago(unix_seconds i64) string {333+ return time.unix(unix_seconds).relative()294334 }295335 296336 fn show_clash(clash exchange.Clash) {src/cli/cmd_steal_test.v+11 −0
@@ -0,0 +1,11 @@1+module cli2+3+fn test_an_overview_row_that_fits_is_left_alone() {4+ assert overview_row(' you 1 save "tighten parser"', 80) == ' you 1 save "tighten parser"'5+}6+7+fn test_an_overview_row_too_wide_for_the_terminal_ends_in_an_ellipsis() {8+ row := overview_row(' you 3 saves "${'x'.repeat(100)}"', 80)9+ assert row.ends_with(' · …')10+ assert row.runes().len == 8011+}src/exchange/steal.v+2 −2
@@ -42,7 +42,7 @@4242 pub struct StealPreview {4343 pub:4444 repo string45- base string45+ base history.Save4646 local []history.Save4747 remote []SaveRecord4848 changed int@@ -470,7 +470,7 @@470470 }471471 return StealPreview{472472 repo: name473- base: fetched.base473+ base: history.by_id(store, fetched.base) or { history.Save{} }474474 local: local475475 remote: fetched.saves476476 changed: changed.lentests/cli_test.v+22 −5
@@ -80,10 +80,12 @@8080 script += ' {${arg}}'8181 }8282 script += '\n'83+ // Each answer waits for its prompt: typed ahead, it would be flushed when the pager takes the84+ // terminal raw. `close` ends a run that is still asking, so `wait` can never hang the suite.8385 for answer in answers {84- script += 'after 250\nsend -- "${answer}\\r"\n'86+ script += 'expect -re {› \$|message: \$|\\[y/N\\] \$|q quit}\nsend -- "${answer}\\r"\n'8587 }86- script += 'expect eof\nset status [wait]\nexit [lindex $status 3]\n'88+ script += 'expect eof\ncatch close\nset status [wait]\nexit [lindex $status 3]\n'8789 os.write_file(script_path, script) or { panic(err) }8890 result := os.execute('cd ${q(repo)} && expect -f ${q(script_path)}')8991 return Result{@@ -1067,9 +1069,15 @@10671069 write(b, 'dirty.txt', 'mine\n')10681070 before := run_home(b, home, 'status', '--json').out10691071 quit := run_pty_home(b, home, ['q'], 'steal')1072+ assert quit.out.contains('steal proj')10701073 assert quit.out.contains('you have 1 unsaved file')10711074 assert quit.out.contains('dirty.txt')10721075 assert run_home(b, home, 'status', '--json').out == before1076+ // A save needs a message; an empty one stops, says so, and saves nothing.1077+ unsaid := run_pty_home(b, home, ['s', ''], 'steal')1078+ assert unsaid.code == 11079+ assert unsaid.out.contains('no message — nothing was saved')1080+ assert run_home(b, home, 'status', '--json').out == before10731081 checkpoint := run_pty_home(b, home, ['c'], 'steal')10741082 assert checkpoint.code == 0, checkpoint.err10751083 assert run_home(b, home, 'view', '--checkpoints').out.contains('before steal')@@ -1091,8 +1099,15 @@10911099 write(b, 'local-only.txt', 'mine\n')10921100 assert run_home(b, home, 'save', 'local change').code == 010931101 local_head := run_home(b, home, 'status', '--json').out.all_after('"head_id":"')[..12]1094- cancel := run_pty_home(b, home, ['m', 'b', 't', 't', 'n'], 'steal')1095- assert cancel.out.contains('both moved since')1102+ // `d` opens both changes in the pager, which Enter leaves; `?` explains the two sides.1103+ cancel := run_pty_home(b, home, ['d', '', '?', 'm', 'b', 't', 't', 'n'], 'steal')1104+ assert cancel.out.contains('your changes')1105+ assert cancel.out.contains('remote changes')1106+ assert cancel.out.contains('Nothing is lost either way')1107+ // The shared save's age, relative: seconds old here, so `now` unless the machine is slow.1108+ since := cancel.out.split_into_lines().filter(it.contains('both moved since'))1109+ assert since.len == 11110+ assert since[0].trim_space().ends_with(' · now') || since[0].trim_space().ends_with(' ago'), since[0]10961111 assert cancel.out.contains('5 files changed')10971112 assert cancel.out.contains('2 combine by themselves')10981113 assert cancel.out.contains('1 changed the same way')@@ -1103,7 +1118,9 @@11031118 11041119 done := run_pty_home(b, home, ['y'], 'steal', '--theirs')11051120 assert done.out.contains('combined · offered to proj')1106- assert done.out.contains('to load') || done.out.contains('koh load')1121+ assert done.out.contains('took theirs: clash.txt · other.txt')1122+ assert !done.out.contains('kept mine')1123+ assert done.out.contains('to undo all of it: koh load ${local_head}')11071124 assert done.out.contains('your clash.txt is in ${local_head}')11081125 assert !done.out.contains('\x1b')11091126 for line in done.out.split_into_lines() {v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.0.105'3+ version: '3.0.106'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }