diff --git a/mobile-gpui/Cargo.lock b/mobile-gpui/Cargo.lock index 923689ef0..c437af494 100644 --- a/mobile-gpui/Cargo.lock +++ b/mobile-gpui/Cargo.lock @@ -805,7 +805,7 @@ dependencies = [ "bitflags 2.13.2", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -4424,7 +4424,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.21", - "windows 0.58.0", + "windows 0.62.2", ] [[package]] @@ -4450,8 +4450,7 @@ dependencies = [ [[package]] name = "gpui-base" version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf698677b253118459f552fe15513858c669d79a25837a94e480db2ae330050" +source = "git+https://github.com/Ziinc/gpui-kit?branch=main#ccfe83085c331b641ed9e29f4a0e428a14cb6bfe" dependencies = [ "aho-corasick", "anyhow", @@ -4491,8 +4490,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc8b7def8e74b0d822d3e96f6b28fb5a2c20eb7a83d30d22fa59ca8088d1159b" +source = "git+https://github.com/Ziinc/gpui-kit?branch=main#ccfe83085c331b641ed9e29f4a0e428a14cb6bfe" dependencies = [ "anyhow", "chrono", @@ -4525,9 +4523,12 @@ dependencies = [ "serde", "serde_json", "serde_repr", + "similar", "smallvec", "smol", "tracing", + "unicode-segmentation", + "unicode-width", "uuid", "windows 0.58.0", ] @@ -4535,8 +4536,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2968cc7f4a2bd8f51b09cfe4f1e77409951b48a0e2d48f372b010e69c3d7fb49" +source = "git+https://github.com/Ziinc/gpui-kit?branch=main#ccfe83085c331b641ed9e29f4a0e428a14cb6bfe" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -4547,13 +4547,13 @@ dependencies = [ [[package]] name = "gpui-kit" version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be18e0417095ebfea77ac25962961ff4e41a120ea6cb8f05a7505b682478b83e" +source = "git+https://github.com/Ziinc/gpui-kit?branch=main#ccfe83085c331b641ed9e29f4a0e428a14cb6bfe" dependencies = [ "gpui-base", "gpui-component", "gpui-kit-assets", "gpui-pre", + "gpui-pre-macros", "gpui-pre-platform", "gpui-pre-web", ] @@ -4561,8 +4561,7 @@ dependencies = [ [[package]] name = "gpui-kit-assets" version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6c481ca987f1e6f10b1aed4d47f1b301cd1dc6f3016fac93c305f2a4f307c0" +source = "git+https://github.com/Ziinc/gpui-kit?branch=main#ccfe83085c331b641ed9e29f4a0e428a14cb6bfe" dependencies = [ "anyhow", "gpui-pre", @@ -5512,7 +5511,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.62.2", ] [[package]] @@ -8530,7 +8529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.119", @@ -10247,6 +10246,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +dependencies = [ + "bstr", + "unicode-segmentation", +] + [[package]] name = "simplecss" version = "0.2.2" @@ -11175,7 +11184,7 @@ dependencies = [ [[package]] name = "tauri-plugin-gpui" version = "0.1.0" -source = "git+https://github.com/ziinc/tauri-gpui?branch=ccr-abfcd600-eoaphz#0ed7515ee2b5884ec40a9ebaccaaf27060254cb6" +source = "git+https://github.com/ziinc/tauri-gpui?branch=main#cbabb700a77097fdfb5f987438a6359b8157ae49" dependencies = [ "anyhow", "arboard", diff --git a/mobile-gpui/Cargo.toml b/mobile-gpui/Cargo.toml index fe01f832e..92bfb9d2d 100644 --- a/mobile-gpui/Cargo.toml +++ b/mobile-gpui/Cargo.toml @@ -20,7 +20,7 @@ tauri-build = { version = "2", features = [] } [dependencies] treq = { path = "../src-tauri", features = ["embed"] } tauri = { version = "2.12", features = ["unstable"] } -tauri-plugin-gpui = { git = "https://github.com/ziinc/tauri-gpui", branch = "ccr-abfcd600-eoaphz", features = ["mobile"] } +tauri-plugin-gpui = { git = "https://github.com/ziinc/tauri-gpui", branch = "main", features = ["mobile"] } gpui-kit = "0.7.1" tauri-plugin-deep-link = "2.4.7" tauri-plugin-opener = "2.5.2" @@ -50,6 +50,14 @@ android_logger = "0.15" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] env_logger = "0.11" +# gpui-kit fork with a native touch selection opt-out and +# `set_selected_text_range`, so tauri-plugin-gpui's native Android toolbar and +# drag handles replace gpui-kit's own. Drop once upstream has both. +[patch.crates-io] +gpui-kit = { git = "https://github.com/Ziinc/gpui-kit", branch = "main" } +gpui-component = { git = "https://github.com/Ziinc/gpui-kit", branch = "main" } +gpui-base = { git = "https://github.com/Ziinc/gpui-kit", branch = "main" } + [profile.dev] opt-level = 1 [profile.dev.package."*"] diff --git a/mobile-gpui/scripts/android-smoke.sh b/mobile-gpui/scripts/android-smoke.sh index 564e8f51d..8877b2b07 100755 --- a/mobile-gpui/scripts/android-smoke.sh +++ b/mobile-gpui/scripts/android-smoke.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Installs the treq-gpui debug APK on a running emulator, opens the home and -# add-host screens, and checks the app renders without crashing. Screenshots +# Installs the treq-gpui debug APK on a running emulator, opens the signed-out +# landing page, swipes it, and checks the app renders without crashing. Screenshots # and logcat go to the output directory. # # mobile-gpui/scripts/android-smoke.sh [output-dir] @@ -20,8 +20,19 @@ dismiss_dialogs() { adb shell am broadcast -a android.intent.action.CLOSE_SYSTEM timeout 300 "$ADB_BIN" wait-for-device adb shell settings put global hide_error_dialogs 1 || true adb shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS > /dev/null || true -adb install -r -g "$APK" adb logcat -c +adb install -r -g "$APK" +# Let the install settle before the first launch. The system's post-install +# handling (PACKAGE_ADDED broadcast, `onPackageAdded` in the launcher) relaunches +# an activity that is already running, and tauri-plugin-gpui does not yet +# re-attach to a recreated activity, so the screen would go blank. Both waits +# are best-effort: `wait-for-broadcast-idle` needs API 30+, the logcat wait is +# capped at 20 s. +adb shell am wait-for-broadcast-idle || sleep 5 +for _ in $(seq 20); do + if adb logcat -d | grep "onPackageAdded: $PKG" > /dev/null; then break; fi + sleep 1 +done # Keep a live capture so the logs survive an emulator crash. "$ADB_BIN" logcat > "$OUT/logcat-stream.txt" 2>&1 & LOGCAT_PID=$! @@ -54,8 +65,9 @@ fi sleep 6 shot 01-home -# "Add SSH host" sits below the account and remote cards; scroll and tap it -# by its approximate position, then come back with the back button. +# The signed-out landing page fits on one screen (no saved hosts on a fresh +# install), so the swipe only checks that scrolling it is harmless. Tapping +# "Sign in" would leave the app for the browser, so it is not exercised. dismiss_dialogs size=$(adb shell wm size | awk '/Physical/ {print $3}' | tr -d '\r') width=${size%x*} diff --git a/mobile-gpui/src/lib.rs b/mobile-gpui/src/lib.rs index c601d0d49..ed0b1fa24 100644 --- a/mobile-gpui/src/lib.rs +++ b/mobile-gpui/src/lib.rs @@ -70,6 +70,9 @@ pub fn run() { .assets(gpui_kit::assets::Assets) .on_launch(|cx| { gpui_kit::init(cx); + // tauri-plugin-gpui draws Android's own selection toolbar and handles. + #[cfg(target_os = "android")] + gpui_kit::component::input::set_native_touch_selection(true, cx); ui::theme::init(cx); }), )?; diff --git a/mobile-gpui/src/ui/home.rs b/mobile-gpui/src/ui/home.rs index 6ead5a408..9d13e3631 100644 --- a/mobile-gpui/src/ui/home.rs +++ b/mobile-gpui/src/ui/home.rs @@ -1,4 +1,5 @@ -//! Home: account, managed instance and saved SSH hosts. +//! Home: a landing page when signed out, otherwise account, managed +//! instance and saved SSH hosts. use gpui_kit::{component::Disableable as _, prelude::FluentBuilder as _}; use gpui_kit::{ @@ -15,6 +16,8 @@ use gpui_kit::{ use super::{model, screens, widgets}; use crate::{backend::ConnectionKind, managed, store}; +const TAGLINE: &str = "Isolates each agent and rebases stacked PRs when the base moves"; + pub struct HomeScreen { scroll: ScrollHandle, } @@ -154,18 +157,15 @@ impl HomeScreen { ) } - fn hosts(&self, cx: &mut Context) -> impl IntoElement { + // The saved-host rows, or `None` when there are none. + fn host_rows(&self, cx: &mut Context) -> Option
{ let connecting = model(cx).read(cx).connecting.is_some(); let hosts = store::user_managed_endpoints(); - let empty = hosts.is_empty(); - let list = widgets::card(cx) - .when(empty, |card| { - card.child(widgets::centered_message( - "No SSH hosts saved on this device.", - cx, - )) - }) - .children(hosts.into_iter().enumerate().map(|(i, host)| { + if hosts.is_empty() { + return None; + } + Some( + widgets::card(cx).children(hosts.into_iter().enumerate().map(|(i, host)| { let detail = format!("{}@{}:{}", host.username, host.hostname, host.port); let id = host.id.clone(); let remove = Button::new(("remove-host", i)) @@ -187,7 +187,17 @@ impl HomeScreen { .when(!connecting, |row| { row.on_click(move |_, _, cx| super::connect_user_managed(host.clone(), cx)) }) - })); + })), + ) + } + + fn hosts(&self, cx: &mut Context) -> impl IntoElement { + let list = self.host_rows(cx).unwrap_or_else(|| { + widgets::card(cx).child(widgets::centered_message( + "No SSH hosts saved on this device.", + cx, + )) + }); widgets::section("SSH hosts on this device", cx) .child(list) .child( @@ -198,24 +208,102 @@ impl HomeScreen { .on_click(|_, _, cx| screens::add_host::open(cx)), ) } + + /// The signed-out start screen: brand centered, sign-in within thumb reach. + fn landing(&self, cx: &mut Context) -> impl IntoElement { + let model = model(cx).read(cx); + let busy = model.auth_busy; + let error = model.auth_error.clone(); + let connecting = model.connecting; + let connect_error = model.connect_error.clone(); + // Saved hosts stay reachable below the buttons. + let hosts = self.host_rows(cx); + v_flex() + .min_h_full() + .p_6() + .gap_6() + .child( + v_flex() + .flex_1() + .items_center() + .justify_center() + .gap_3() + .child(widgets::logo(96.)) + .child(widgets::wordmark(40., cx)) + .child(widgets::muted(TAGLINE, cx).max_w(px(300.)).text_center()), + ) + .child( + v_flex() + .gap_3() + .child( + Button::new("sign-in") + .primary() + .large() + .w_full() + .h(px(widgets::CTA_H)) + .label("Sign in") + .loading(busy) + .on_click(|_, _, cx| super::sign_in(cx)), + ) + .children(error.map(|e| widgets::error_text(e, cx).text_center())) + .child( + Button::new("add-host") + .outline() + .large() + .w_full() + .h(px(widgets::CTA_H)) + .label("Use your own SSH host") + .on_click(|_, _, cx| screens::add_host::open(cx)), + ) + .when_some(connecting, |c, step| { + c.child( + h_flex() + .gap_2() + .justify_center() + .items_center() + .child(Spinner::new().small()) + .child(widgets::muted(format!("{step}…"), cx)), + ) + }) + .children(connect_error.map(|e| widgets::error_text(e, cx).text_center())) + .children(hosts.map(|rows| widgets::section("SSH hosts on this device", cx).child(rows))), + ) + } + + /// Compact brand header above the signed-in home. + fn brand_header(&self, cx: &mut Context) -> impl IntoElement { + h_flex() + .gap_2() + .items_center() + .child(widgets::logo(28.)) + .child(widgets::wordmark(20., cx)) + } } impl Render for HomeScreen { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let account = self.account(cx); - let connection = self.connection(cx); - let hosts = self.hosts(cx); + let model = model(cx).read(cx); + let landing = super::is_landing(model.user.as_deref(), model.connection.is_some()); + let content = if landing { + self.landing(cx).into_any_element() + } else { + let header = self.brand_header(cx); + let account = self.account(cx); + let connection = self.connection(cx); + let hosts = self.hosts(cx); + widgets::page() + .child(header) + .child(account) + .child(connection) + .child(hosts) + .into_any_element() + }; div() .id("home") .size_full() .overflow_y_scroll() .track_scroll(&self.scroll) .bg(cx.theme().background) - .child( - widgets::page() - .child(account) - .child(connection) - .child(hosts), - ) + .child(content) } } diff --git a/mobile-gpui/src/ui/mod.rs b/mobile-gpui/src/ui/mod.rs index 377ca3239..bf256506f 100644 --- a/mobile-gpui/src/ui/mod.rs +++ b/mobile-gpui/src/ui/mod.rs @@ -28,6 +28,12 @@ use crate::{ pub use load::Load; +/// The signed-out, unconnected home is a landing page. Once signed in or +/// connected (even to a saved SSH host) the regular home takes over. +fn is_landing(user: Option<&str>, connected: bool) -> bool { + user.is_none() && !connected +} + /// App-wide state the screens observe. #[derive(Default)] pub struct AppModel { @@ -249,6 +255,8 @@ impl Render for Root { let page = self.stack.last().expect("the stack keeps its root page"); let can_go_back = self.stack.len() > 1; let model = self.model.read(cx); + // The landing page brings its own branding, so it drops the title bar. + let landing = !can_go_back && is_landing(model.user.as_deref(), model.connection.is_some()); let blocked = model.cutoff.clone(); let managed = model .connection @@ -264,34 +272,36 @@ impl Render for Root { .pl(visible.origin.x) .pr(viewport.width - visible.right()) .pb(viewport.height - visible.bottom()) - .child( - h_flex() - .h(px(56.)) - .px_2() - .gap_1() - .items_center() - .border_b_1() - .border_color(theme.border) - .when(can_go_back, |bar| { - bar.child( - Button::new("back") - .ghost() - .large() - .icon(IconName::ArrowLeft) - .on_click(|_, _, cx| nav::pop(cx)), - ) - }) - .child( - div() - .flex_1() - .min_w_0() - .px_2() - .truncate() - .text_lg() - .font_weight(FontWeight::SEMIBOLD) - .child(page.title.clone()), - ), - ) + .when(!landing, |root| { + root.child( + h_flex() + .h(px(56.)) + .px_2() + .gap_1() + .items_center() + .border_b_1() + .border_color(theme.border) + .when(can_go_back, |bar| { + bar.child( + Button::new("back") + .ghost() + .large() + .icon(IconName::ArrowLeft) + .on_click(|_, _, cx| nav::pop(cx)), + ) + }) + .child( + div() + .flex_1() + .min_w_0() + .px_2() + .truncate() + .text_lg() + .font_weight(FontWeight::SEMIBOLD) + .child(page.title.clone()), + ), + ) + }) .child(div().flex_1().min_h_0().child(page.view.clone())) .when_some(blocked, |root, reason| { root.child(cutoff_overlay(reason, managed, cx)) @@ -645,3 +655,16 @@ impl Root { })); } } + +#[cfg(test)] +mod tests { + use super::is_landing; + + #[test] + fn landing_only_when_signed_out_and_unconnected() { + assert!(is_landing(None, false)); + assert!(!is_landing(Some("a@b.c"), false)); + assert!(!is_landing(None, true)); + assert!(!is_landing(Some("a@b.c"), true)); + } +} diff --git a/mobile-gpui/src/ui/theme.rs b/mobile-gpui/src/ui/theme.rs index e839b899a..2649ecc61 100644 --- a/mobile-gpui/src/ui/theme.rs +++ b/mobile-gpui/src/ui/theme.rs @@ -4,6 +4,9 @@ use std::borrow::Cow; use gpui_kit::{component::Theme, px, App}; +/// The Treq logo blue (sampled from `assets/logo.svg`'s PNG render), as 0xRRGGBB. +pub const BRAND_BLUE: u32 = 0x4B97E3; + // Static Inter weights. Android's only UI font is a single variable Roboto, // which the text system treats as one regular face, so bold text would not // render. Bundling a static family gives real weights on every platform. diff --git a/mobile-gpui/src/ui/widgets.rs b/mobile-gpui/src/ui/widgets.rs index 255edb75b..7e3264912 100644 --- a/mobile-gpui/src/ui/widgets.rs +++ b/mobile-gpui/src/ui/widgets.rs @@ -1,13 +1,43 @@ //! Small layout pieces shared by the screens, sized for touch. +use std::sync::{Arc, LazyLock}; + use gpui_kit::{ component::{h_flex, v_flex, ActiveTheme, IconName}, *, }; +use super::theme::BRAND_BLUE; + +// The blue "T" mark, shared with the website and desktop app. +const LOGO_PNG: &[u8] = include_bytes!("../../../assets/logo.imageset/logo@3x.png"); + +// Decoded once: GPUI caches the texture by the image's id. +static LOGO: LazyLock> = + LazyLock::new(|| Arc::new(Image::from_bytes(ImageFormat::Png, LOGO_PNG.to_vec()))); + +/// The Treq mark, `size` logical pixels square. +pub fn logo(size: f32) -> Img { + img(LOGO.clone()).size(px(size)) +} + +/// The lowercase "treq" wordmark in bold brand-blue monospace. +pub fn wordmark(size: f32, cx: &App) -> Div { + div() + .font_family(cx.theme().mono_font_family.clone()) + .font_weight(FontWeight::BOLD) + .text_size(px(size)) + .line_height(relative(1.)) + .text_color(rgb(BRAND_BLUE)) + .child("treq") +} + /// Minimum height of a tappable row. pub const ROW_H: f32 = 56.; +/// Height of a full-width call-to-action button (gpui-kit's large is 32px). +pub const CTA_H: f32 = 52.; + /// A titled group of content. pub fn section(title: impl Into, cx: &App) -> Div { v_flex().gap_2().child(