git.iko.soy/opencode-android
on master help sign in
Keep the server URL field editable; normalize only on connect

normalizeUrl ran on every keystroke via ServerUrlChanged and was bound straight back into the text field, so the field fought the user: typing "http://" produced "http://http:" and trimEnd('/') ate the slashes, making the URL impossible to input. Store the field verbatim on ServerUrlChanged and normalize once in Connect (mirrored in the Rust core). Tests updated accordingly.

commit537d5042dc5ab9d8ba4ad1a5f6de08621afd32aa authoriko <mail@iko.soy> dateWed 8 Jul 2026, 20:30 (3 months ago) parent3e94016
3 files changed+57−21
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+5−1 app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.kt+34−11 shared/src/app.rs+18−9
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+5−1
@@ -51,11 +51,15 @@ class OpencodeCore(
5151 // ── Event entry point ──────────────────────────────────────────────────────
5252 fun dispatch(event: Event) {
5353 when (event) {
54− is Event.ServerUrlChanged -> { serverUrl = normalizeUrl(event.url); emit() }
54+ // Store the field verbatim while typing; normalizing here would fight
55+ // the user's edits (e.g. eat the "://" as they type it). The URL is
56+ // normalized once, on Connect.
57+ is Event.ServerUrlChanged -> { serverUrl = event.url; emit() }
5558 is Event.UsernameChanged -> { username = event.value; emit() }
5659 is Event.PasswordChanged -> { password = event.value; emit() }
5760
5861 Event.Connect -> {
62+ serverUrl = normalizeUrl(serverUrl)
5963 if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
6064 loading = true; error = null; authRequired = false; emit()
6165 scope.launch { probeHealth() }
app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.kt+34−11
@@ -3,39 +3,62 @@ package soy.iko.opencode.core
33import kotlinx.coroutines.CoroutineScope
44import kotlinx.coroutines.Dispatchers
55import kotlinx.coroutines.test.runTest
6+import okhttp3.OkHttpClient
7+import okhttp3.Interceptor
68import org.junit.Assert.assertEquals
79import org.junit.Assert.assertTrue
810import org.junit.Test
11+import java.io.IOException
912
1013/**
11− * Regression coverage for the "app crashes after submitting a URL" bug: a
12− * scheme-less server URL (e.g. "192.168.1.10:4096") used to reach OkHttp's URL
13− * parser and throw, crashing the connect coroutine. It must now (a) be
14− * normalized to an http:// URL by the core, and (b) never crash the HTTP client
15− * even if something un-parseable slips through.
14+ * Covers two related connect-screen bugs:
15+ * - the URL field must stay editable — normalizing per keystroke made it
16+ * "impossible to input" (it ate the "://" as you typed);
17+ * - a scheme-less URL must still be normalized to http:// on connect, and the
18+ * HTTP client must never crash on a malformed URL.
1619 */
1720class ConnectUrlTest {
1821 private fun core() = OpencodeCore(CoroutineScope(Dispatchers.Unconfined))
1922
23+ /** A client that never touches the network, so Connect's probe can't hang. */
24+ private fun offlineCore(): OpencodeCore {
25+ val offline = OkHttpClient.Builder()
26+ .addInterceptor(Interceptor { throw IOException("offline in test") })
27+ .build()
28+ return OpencodeCore(CoroutineScope(Dispatchers.Unconfined), HttpClient(offline))
29+ }
30+
2031 @Test
21− fun `scheme-less url defaults to http`() {
32+ fun `typing preserves raw input verbatim`() {
2233 val core = core()
34+ // Mid-scheme text the user is still typing must not be rewritten.
35+ core.dispatch(Event.ServerUrlChanged("http:/"))
36+ assertEquals("http:/", core.view.value.serverUrl)
37+ }
38+
39+ @Test
40+ fun `connect normalizes a scheme-less url to http`() {
41+ val core = offlineCore()
2342 core.dispatch(Event.ServerUrlChanged("192.168.1.10:4096"))
24− assertEquals("http://192.168.1.10:4096", core.view.value.serverUrl)
43+ assertEquals("192.168.1.10:4096", core.view.value.serverUrl) // raw while typing
44+ core.dispatch(Event.Connect)
45+ assertEquals("http://192.168.1.10:4096", core.view.value.serverUrl) // normalized on connect
2546 }
2647
2748 @Test
28− fun `url with scheme is preserved and trimmed`() {
29− val core = core()
49+ fun `connect trims and keeps an explicit scheme`() {
50+ val core = offlineCore()
3051 core.dispatch(Event.ServerUrlChanged(" https://host:4096/ "))
52+ core.dispatch(Event.Connect)
3153 assertEquals("https://host:4096", core.view.value.serverUrl)
3254 }
3355
3456 @Test
35− fun `blank url normalizes to empty`() {
57+ fun `connect on a blank url reports an error`() {
3658 val core = core()
3759 core.dispatch(Event.ServerUrlChanged(" "))
38− assertEquals("", core.view.value.serverUrl)
60+ core.dispatch(Event.Connect)
61+ assertEquals("Enter a server URL", core.view.value.error)
3962 }
4063
4164 @Test
shared/src/app.rs+18−9
@@ -294,8 +294,11 @@ impl App for OpencodeApp {
294294 // ── Lifecycle / connect ─────────────────────────────────────────
295295 Event::Start => render(),
296296
297+ // Store the field verbatim while typing; normalizing here would fight
298+ // the user's edits (e.g. eat the "://" as they type it). The URL is
299+ // normalized once, on Connect.
297300 Event::ServerUrlChanged(url) => {
298− model.server_url = normalize_url(&url);
301+ model.server_url = url;
299302 render()
300303 }
301304 Event::UsernameChanged(u) => {
@@ -308,6 +311,7 @@ impl App for OpencodeApp {
308311 }
309312
310313 Event::Connect => {
314+ model.server_url = normalize_url(&model.server_url);
311315 if model.server_url.is_empty() {
312316 model.error = Some("Enter a server URL".to_string());
313317 return render();
@@ -803,28 +807,33 @@ mod tests {
803807 }
804808
805809 #[test]
806− fn server_url_is_normalized() {
810+ fn server_url_is_kept_raw_while_typing() {
807811 let app = app();
808812 let mut model = Model::default();
809− let _ = app.update(Event::ServerUrlChanged(" http://host:4096/ ".into()), &mut model);
810− assert_eq!(app.view(&model).server_url, "http://host:4096");
813+ // Mid-scheme input must survive verbatim — normalizing per keystroke
814+ // would eat the "://" the user is typing and make the field unusable.
815+ let _ = app.update(Event::ServerUrlChanged("http:/".into()), &mut model);
816+ assert_eq!(app.view(&model).server_url, "http:/");
811817 }
812818
813819 #[test]
814− fn server_url_without_scheme_defaults_to_http() {
820+ fn connect_normalizes_scheme_less_url() {
815821 let app = app();
816822 let mut model = Model::default();
817− // A scheme-less host (what a user often types) must gain http://, or the
818− // shell's URL parser throws and the connect attempt crashes.
823+ // A scheme-less host (what a user often types) must gain http:// on
824+ // connect, or the shell's URL parser throws and the attempt crashes.
819825 let _ = app.update(Event::ServerUrlChanged("192.168.1.10:4096".into()), &mut model);
826+ assert_eq!(app.view(&model).server_url, "192.168.1.10:4096");
827+ let _ = app.update(Event::Connect, &mut model);
820828 assert_eq!(app.view(&model).server_url, "http://192.168.1.10:4096");
821829 }
822830
823831 #[test]
824− fn server_url_keeps_https_scheme() {
832+ fn connect_trims_and_keeps_explicit_scheme() {
825833 let app = app();
826834 let mut model = Model::default();
827− let _ = app.update(Event::ServerUrlChanged("https://host:4096".into()), &mut model);
835+ let _ = app.update(Event::ServerUrlChanged(" https://host:4096/ ".into()), &mut model);
836+ let _ = app.update(Event::Connect, &mut model);
828837 assert_eq!(app.view(&model).server_url, "https://host:4096");
829838 }
830839