Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 24 additions & 15 deletions mobile-gpui/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion mobile-gpui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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."*"]
Expand Down
22 changes: 17 additions & 5 deletions mobile-gpui/scripts/android-smoke.sh
Original file line number Diff line number Diff line change
@@ -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 <app.apk> [output-dir]
Expand All @@ -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=$!
Expand Down Expand Up @@ -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*}
Expand Down
3 changes: 3 additions & 0 deletions mobile-gpui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}),
)?;
Expand Down
130 changes: 109 additions & 21 deletions mobile-gpui/src/ui/home.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -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,
}
Expand Down Expand Up @@ -154,18 +157,15 @@ impl HomeScreen {
)
}

fn hosts(&self, cx: &mut Context<Self>) -> impl IntoElement {
// The saved-host rows, or `None` when there are none.
fn host_rows(&self, cx: &mut Context<Self>) -> Option<Div> {
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))
Expand All @@ -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<Self>) -> 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(
Expand All @@ -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<Self>) -> 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<Self>) -> 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<Self>) -> 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)
}
}
Loading
Loading