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
6 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 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.1051+3.0.106

view file

src/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) {

view file

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+}

view file

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.len

view file

tests/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() {

view file

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 }

view file