diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/actions/ActionDispatcher.kt b/shared/src/commonMain/kotlin/com/bricks/mp/actions/ActionDispatcher.kt index 30c572c..70a9066 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/actions/ActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/actions/ActionDispatcher.kt @@ -83,6 +83,78 @@ class ActionDispatcher( } } + /** + * Load a widget and return it via callbacks — does NOT call onWidgetLoaded. + * Used by RenderUrlWidget for in-place loading of subwidgets. + * Reuses the same HTTP client and handles 403/3xx redirects. + */ + suspend fun loadWidgetInPlace( + url: String, + onSuccess: (BricksWidget) -> Unit, + onError: (String) -> Unit + ) { + val fullUrl = resolveUrl(url) + try { + val widget = http.fetchUi(fullUrl, authToken = context.authToken) + onSuccess(widget) + DevLogStore.log( + level = DevLogLevel.INFO, + message = "In-place load OK: $fullUrl", + source = DevLogSource.ACTION + ) + } catch (e: BricksHttpException) { + handleHttpErrorInPlace(e, fullUrl, onSuccess, onError) + } catch (e: Exception) { + onError("Failed to load: ${e.message}") + DevLogStore.log( + level = DevLogLevel.ERROR, + message = "In-place load failed: $fullUrl", + details = e.message, + source = DevLogSource.ACTION, + stackTrace = e.stackTraceToString() + ) + } + } + + private suspend fun handleHttpErrorInPlace( + error: BricksHttpException, + requestUrl: String, + onSuccess: (BricksWidget) -> Unit, + onError: (String) -> Unit, + redirectDepth: Int = 0 + ) { + when (error.statusCode) { + 403 -> { + try { + val loginWidget = http.fetchUi(resolveUrl(loginUiPath), authToken = context.authToken) + onDialog?.invoke(loginWidget, true) + } catch (loginError: Exception) { + onError("Forbidden: ${error.displayBody()}") + } + } + 401 -> onError("Unauthorized: ${error.displayBody()}") + in 300..399 -> { + val location = error.location + if (location.isNullOrBlank()) { + onError("HTTP ${error.statusCode} without Location") + return + } + if (redirectDepth >= MAX_REDIRECTS) { + onError("Too many redirects: $location") + return + } + val nextUrl = resolveRedirectUrl(requestUrl, location) + DevLogStore.log( + level = DevLogLevel.INFO, + message = "Redirect ${error.statusCode}: $requestUrl -> $nextUrl", + source = DevLogSource.ACTION + ) + loadWidgetInPlace(nextUrl, onSuccess, onError) + } + else -> onError("HTTP ${error.statusCode}: ${error.displayBody()}") + } + } + private fun handleUrlWidget(bind: BricksBind) { val url = bind.options["url"]?.let { if (it is JsonPrimitive) it.contentOrNull else null diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksRenderer.kt b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksRenderer.kt index 1dcaba1..2283ae8 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksRenderer.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksRenderer.kt @@ -517,8 +517,34 @@ private fun parseJsonObject(obj: kotlinx.serialization.json.JsonObject): BricksW @Composable private fun RenderUrlWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) { val url = WidgetOptions.getString(widget.options, "url", "") + + // Loaded content kept in local state — renders in-place, NOT replacing the root widget. + // Using actionDispatcher.dispatch() would call onWidgetLoaded which replaces the entire + // page (context.setCurrentWidget). When multiple urlwidgets exist as siblings (e.g. index.ui + // with top.ui + center.ui + bottom.ui), the last one to finish loading would overwrite + // everything else. By maintaining local state each urlwidget replaces only itself. + var loadedWidget by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + + if (loadedWidget != null) { + RenderWidget(loadedWidget!!, actionDispatcher) + return + } + + if (error != null) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Failed to load: $url", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + error?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + } + return + } + Column( - modifier = Modifier.fillMaxSize().padding(16.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { @@ -527,16 +553,41 @@ private fun RenderUrlWidget(widget: BricksWidget, actionDispatcher: ActionDispat Text("Loading: $url", style = MaterialTheme.typography.bodySmall) } - // 自动加载 url + // Load content in-place via ActionDispatcher's internal HTTP client. + // We use a callback approach so the ActionDispatcher can reuse its HTTP client + // and handle 403/3xx, but loadWidget must NOT call onWidgetLoaded for urlwidgets + // that are part of a subwidgets array. LaunchedEffect(url) { - if (url.isNotEmpty() && actionDispatcher != null) { - val bind = BricksBind( - event = "auto", - actiontype = "urlwidget", - target = "self", - options = mapOf("url" to kotlinx.serialization.json.JsonPrimitive(url)) - ) - actionDispatcher.dispatch(bind) + if (url.isEmpty()) return@LaunchedEffect + try { + // Try to resolve the URL — if actionDispatcher is available, use its resolveUrl + // which handles relative paths via context.entireUrl(). Otherwise use the URL as-is. + val resolvedUrl = if (actionDispatcher != null) { + // Use reflection-free approach: dispatch a special bind and intercept via callback + // Since we can't access http directly, we need a different approach. + // For now, if url is already absolute (starts with http), use it directly. + if (url.startsWith("http://") || url.startsWith("https://")) url + else url // Relative URLs need the dispatcher's http client — see note below + } else { + url + } + + // We need access to BricksHttp to fetch the widget. + // Since RenderUrlWidget only receives ActionDispatcher, we dispatch through it + // but with a flag to NOT replace the root widget. + // The cleanest fix: add an onWidgetLoadedInPlace callback to ActionDispatcher. + // For this fix, we handle it by dispatching and letting the dispatcher know + // this is an in-place load. + + if (actionDispatcher != null) { + actionDispatcher.loadWidgetInPlace( + url = resolvedUrl, + onSuccess = { w -> loadedWidget = w }, + onError = { msg -> error = msg } + ) + } + } catch (e: Exception) { + error = e.message } } }