git.iko.soy/opencode-android
on master help sign in
Keep the server URL field editable; normalize only on connect
537d504iko
app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+5−1
@@ -51,11 +51,15 @@ class OpencodeCore(
51 // ── Event entry point ──────────────────────────────────────────────────────
51 // ── Event entry point ──────────────────────────────────────────────────────
52 fun dispatch(event: Event) {
52 fun dispatch(event: Event) {
53 when (event) {
53 when (event) {
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.
54− is Event.ServerUrlChanged -> { serverUrl = normalizeUrl(event.url); emit() }
57+ is Event.ServerUrlChanged -> { serverUrl = event.url; emit() }
55 is Event.UsernameChanged -> { username = event.value; emit() }
58 is Event.UsernameChanged -> { username = event.value; emit() }
56 is Event.PasswordChanged -> { password = event.value; emit() }
59 is Event.PasswordChanged -> { password = event.value; emit() }
57
60
58 Event.Connect -> {
61 Event.Connect -> {
62+ serverUrl = normalizeUrl(serverUrl)
59 if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
63 if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
60 loading = true; error = null; authRequired = false; emit()
64 loading = true; error = null; authRequired = false; emit()
61 scope.launch { probeHealth() }
65 scope.launch { probeHealth() }
app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.kt+34−11
@@ -3,39 +3,62 @@ package soy.iko.opencode.core
3import kotlinx.coroutines.CoroutineScope
3import kotlinx.coroutines.CoroutineScope
4import kotlinx.coroutines.Dispatchers
4import kotlinx.coroutines.Dispatchers
5import kotlinx.coroutines.test.runTest
5import kotlinx.coroutines.test.runTest
6+import okhttp3.OkHttpClient
7+import okhttp3.Interceptor
6import org.junit.Assert.assertEquals
8import org.junit.Assert.assertEquals
7import org.junit.Assert.assertTrue
9import org.junit.Assert.assertTrue
8import org.junit.Test
10import org.junit.Test
11+import java.io.IOException
9
12
10/**
13/**
11− * Regression coverage for the "app crashes after submitting a URL" bug: a
14+ * Covers two related connect-screen bugs:
12− * scheme-less server URL (e.g. "192.168.1.10:4096") used to reach OkHttp's URL
15+ * - the URL field must stay editable — normalizing per keystroke made it
13− * parser and throw, crashing the connect coroutine. It must now (a) be
16+ * "impossible to input" (it ate the "://" as you typed);
14− * normalized to an http:// URL by the core, and (b) never crash the HTTP client
17+ * - a scheme-less URL must still be normalized to http:// on connect, and the
15− * even if something un-parseable slips through.
18+ * HTTP client must never crash on a malformed URL.
16 */
19 */
17class ConnectUrlTest {
20class ConnectUrlTest {
18 private fun core() = OpencodeCore(CoroutineScope(Dispatchers.Unconfined))
21 private fun core() = OpencodeCore(CoroutineScope(Dispatchers.Unconfined))
19
22
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+
20 @Test
31 @Test
21− fun `scheme-less url defaults to http`() {
32+ fun `typing preserves raw input verbatim`() {
22 val core = core()
33 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()
23 core.dispatch(Event.ServerUrlChanged("192.168.1.10:4096"))
42 core.dispatch(Event.ServerUrlChanged("192.168.1.10:4096"))
43+ assertEquals("192.168.1.10:4096", core.view.value.serverUrl) // raw while typing
44+ core.dispatch(Event.Connect)
24− assertEquals("http://192.168.1.10:4096", core.view.value.serverUrl)
45+ assertEquals("http://192.168.1.10:4096", core.view.value.serverUrl) // normalized on connect
25 }
46 }
26
47
27 @Test
48 @Test
28− fun `url with scheme is preserved and trimmed`() {
49+ fun `connect trims and keeps an explicit scheme`() {
29− val core = core()
50+ val core = offlineCore()
30 core.dispatch(Event.ServerUrlChanged(" https://host:4096/ "))
51 core.dispatch(Event.ServerUrlChanged(" https://host:4096/ "))
52+ core.dispatch(Event.Connect)
31 assertEquals("https://host:4096", core.view.value.serverUrl)
53 assertEquals("https://host:4096", core.view.value.serverUrl)
32 }
54 }
33
55
34 @Test
56 @Test
35− fun `blank url normalizes to empty`() {
57+ fun `connect on a blank url reports an error`() {
36 val core = core()
58 val core = core()
37 core.dispatch(Event.ServerUrlChanged(" "))
59 core.dispatch(Event.ServerUrlChanged(" "))
60+ core.dispatch(Event.Connect)
38− assertEquals("", core.view.value.serverUrl)
61+ assertEquals("Enter a server URL", core.view.value.error)
39 }
62 }
40
63
41 @Test
64 @Test
shared/src/app.rs+18−9
@@ -294,8 +294,11 @@ impl App for OpencodeApp {
294 // ── Lifecycle / connect ─────────────────────────────────────────
294 // ── Lifecycle / connect ─────────────────────────────────────────
295 Event::Start => render(),
295 Event::Start => render(),
296
296
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.
297 Event::ServerUrlChanged(url) => {
300 Event::ServerUrlChanged(url) => {
298− model.server_url = normalize_url(&url);
301+ model.server_url = url;
299 render()
302 render()
300 }
303 }
301 Event::UsernameChanged(u) => {
304 Event::UsernameChanged(u) => {
@@ -308,6 +311,7 @@ impl App for OpencodeApp {
308 }
311 }
309
312
310 Event::Connect => {
313 Event::Connect => {
314+ model.server_url = normalize_url(&model.server_url);
311 if model.server_url.is_empty() {
315 if model.server_url.is_empty() {
312 model.error = Some("Enter a server URL".to_string());
316 model.error = Some("Enter a server URL".to_string());
313 return render();
317 return render();
@@ -803,28 +807,33 @@ mod tests {
803 }
807 }
804
808
805 #[test]
809 #[test]
806− fn server_url_is_normalized() {
810+ fn server_url_is_kept_raw_while_typing() {
807 let app = app();
811 let app = app();
808 let mut model = Model::default();
812 let mut model = Model::default();
813+ // Mid-scheme input must survive verbatim — normalizing per keystroke
814+ // would eat the "://" the user is typing and make the field unusable.
809− let _ = app.update(Event::ServerUrlChanged(" http://host:4096/ ".into()), &mut model);
815+ let _ = app.update(Event::ServerUrlChanged("http:/".into()), &mut model);
810− assert_eq!(app.view(&model).server_url, "http://host:4096");
816+ assert_eq!(app.view(&model).server_url, "http:/");
811 }
817 }
812
818
813 #[test]
819 #[test]
814− fn server_url_without_scheme_defaults_to_http() {
820+ fn connect_normalizes_scheme_less_url() {
815 let app = app();
821 let app = app();
816 let mut model = Model::default();
822 let mut model = Model::default();
817− // A scheme-less host (what a user often types) must gain http://, or the
823+ // A scheme-less host (what a user often types) must gain http:// on
818− // shell's URL parser throws and the connect attempt crashes.
824+ // connect, or the shell's URL parser throws and the attempt crashes.
819 let _ = app.update(Event::ServerUrlChanged("192.168.1.10:4096".into()), &mut model);
825 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);
820 assert_eq!(app.view(&model).server_url, "http://192.168.1.10:4096");
828 assert_eq!(app.view(&model).server_url, "http://192.168.1.10:4096");
821 }
829 }
822
830
823 #[test]
831 #[test]
824− fn server_url_keeps_https_scheme() {
832+ fn connect_trims_and_keeps_explicit_scheme() {
825 let app = app();
833 let app = app();
826 let mut model = Model::default();
834 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);
828 assert_eq!(app.view(&model).server_url, "https://host:4096");
837 assert_eq!(app.view(&model).server_url, "https://host:4096");
829 }
838 }
830
839