From ec41d3c4a045a9345bfb46de10d6b7cd988fd2ce Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:04:59 +0800 Subject: [PATCH] feat(remote): Show the stages and transfer progress of connecting; primary button hover follows the accent --- crates/ide-ssh/src/session.rs | 13 +++++ src-tauri/src/remote_dev.rs | 55 ++++++++++++++++---- src/bindings.ts | 10 +++- src/features/remote-dev/ConnectDialog.tsx | 32 ++++++++++-- src/features/remote-dev/RemoteDev.module.css | 19 +++++++ src/features/remote-dev/model.ts | 20 ++++++- src/features/welcome/ProjectsPage.tsx | 15 +++++- src/platform/locales/text/zh.json | 8 ++- src/platform/remote.ts | 11 +++- src/styles/tokens.css | 6 +-- 10 files changed, 168 insertions(+), 21 deletions(-) diff --git a/crates/ide-ssh/src/session.rs b/crates/ide-ssh/src/session.rs index 9449d196..47f9cd21 100644 --- a/crates/ide-ssh/src/session.rs +++ b/crates/ide-ssh/src/session.rs @@ -237,6 +237,16 @@ impl Session { /// exec channel, gzip-compressed. Much faster than SFTP for one big file: /// SFTP writes wait for each packet's reply, the channel only for its window. pub async fn put(&self, remote: &str, data: &[u8]) -> IdeResult<()> { + self.put_with_progress(remote, data, &|_| {}).await + } + + /// [`Self::put`], reporting the fraction sent (0..1) as it goes. + pub async fn put_with_progress( + &self, + remote: &str, + data: &[u8], + progress: &(dyn Fn(f64) + Send + Sync), + ) -> IdeResult<()> { use std::io::Write as _; let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); gz.write_all(data)?; @@ -257,8 +267,11 @@ impl Session { }), ) .await?; + let mut sent = 0; for chunk in packed.chunks(256 * 1024) { process.write(chunk).await?; + sent += chunk.len(); + progress(sent as f64 / packed.len().max(1) as f64); } process.eof().await?; match process.wait().await { diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index 2df33293..c0966dfc 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -54,6 +54,27 @@ impl Connections { } } +/// Where [`remote_connect`] is (`fraction`: 0..1 of a download / upload). +#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum RemoteStage { + Connecting, + Checking, + Downloading, + Uploading, + Starting, + Opening, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RemoteProgress { + pub stage: RemoteStage, + pub fraction: Option, +} + +type Progress = dyn Fn(RemoteStage, Option) + Send + Sync; + /// A value for one streaming argument of a remote command (`data`: JSON text). #[derive(Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -134,13 +155,18 @@ fn local_server_binary(state: &AppState, os: &str, arch: &str) -> Option IdeResult { +async fn download_server( + state: &AppState, + version: &str, + os: &str, + arch: &str, + progress: Arc, +) -> IdeResult { let (data, version, os, arch) = (state.data_dir.clone(), version.to_owned(), os.to_owned(), arch.to_owned()); tokio::task::spawn_blocking(move || { - ide_tools::server::fetch(&data, &version, &os, &arch, &ide_core::CancellationToken::new(), &|e| { - if let ide_tools::InstallEvent::Line { text } = e { - tracing::info!("openidea-server download: {text}"); - } + ide_tools::server::fetch(&data, &version, &os, &arch, &ide_core::CancellationToken::new(), &|e| match e { + ide_tools::InstallEvent::Line { text } => tracing::info!("openidea-server download: {text}"), + ide_tools::InstallEvent::Progress { fraction, .. } => progress(RemoteStage::Downloading, fraction), }) }) .await @@ -163,7 +189,8 @@ fn platform(uname: &str) -> (String, String) { } /// Makes sure this version of the server is installed on the host; returns its path. -async fn ensure_server(state: &AppState, session: &ide_ssh::Session) -> IdeResult { +async fn ensure_server(state: &AppState, session: &ide_ssh::Session, progress: Arc) -> IdeResult { + progress(RemoteStage::Checking, None); let version = env!("CARGO_PKG_VERSION"); let dir = install_dir(version); let q = ide_ssh::shell::tilde_quote; @@ -179,7 +206,7 @@ async fn ensure_server(state: &AppState, session: &ide_ssh::Session) -> IdeResul let installed: Vec<&str> = lines.collect(); let local = match local_server_binary(state, &os, &arch) { Some(local) => local, - None => match download_server(state, version, &os, &arch).await { + None => match download_server(state, version, &os, &arch, progress.clone()).await { Ok(local) => local, // Offline: any build of this version already on the host will do. Err(e) => { @@ -217,7 +244,10 @@ async fn ensure_server(state: &AppState, session: &ide_ssh::Session) -> IdeResul std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() ); let started = std::time::Instant::now(); - session.put(&tmp, &tokio::fs::read(&local).await?).await?; + progress(RemoteStage::Uploading, Some(0.0)); + session + .put_with_progress(&tmp, &tokio::fs::read(&local).await?, &|f| progress(RemoteStage::Uploading, Some(f))) + .await?; tracing::info!("installed openidea-server on {} in {:?}", session.label(), started.elapsed()); let q = ide_ssh::shell::quote; // Older builds of this version go (a running one keeps its open file). @@ -263,13 +293,19 @@ pub async fn remote_connect( state: State<'_, AppState>, ssh_config: String, path: String, + on_progress: Channel, ) -> IdeResult { + let progress: Arc = Arc::new(move |stage, fraction| { + let _ = on_progress.send(RemoteProgress { stage, fraction }); + }); + progress(RemoteStage::Connecting, None); let label = AppState::owner_window(window.label()).to_owned(); state.remote_dev.disconnect(&label); state.host.close_project(&label); let config = state.ssh.config(&ssh_config)?; let session = state.ssh.session(&ssh_config).await?; - let bin = ensure_server(&state, &session).await?; + let bin = ensure_server(&state, &session, progress.clone()).await?; + progress(RemoteStage::Starting, None); let stream = session .exec_stream(&format!("{} --stdio", ide_ssh::shell::tilde_quote(&bin))) .await @@ -300,6 +336,7 @@ pub async fn remote_connect( } else { path.trim().to_owned() }; + progress(RemoteStage::Opening, None); let call = Invoke { window: label.clone(), cmd: "open_project".into(), args: serde_json::json!({ "path": root }) }; let project: ide_host::ops::project::ProjectInfo = peer.request("invoke", call, None).await.map_err(ide_host::rpc::error_from_reply)?; diff --git a/src/bindings.ts b/src/bindings.ts index cbd880d9..2114830d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -854,7 +854,7 @@ export const commands = { * File › Remote Development: opens `path` on the host of SSH configuration * `ssh_config` in this window (installs / starts the server as needed). */ - remoteConnect: (sshConfig: string, path: string) => typedError(__TAURI_INVOKE("remote_connect", { sshConfig, path })), + remoteConnect: (sshConfig: string, path: string, onProgress: Channel) => typedError(__TAURI_INVOKE("remote_connect", { sshConfig, path, onProgress })), /** The remote session of this window (a settings window: its parent's), if any. */ remoteCurrent: () => __TAURI_INVOKE<{ sshConfig: string, @@ -4412,6 +4412,11 @@ tags?: PushTags | null } | /** Pull dialog: `branches` of `remote` (none = the configured merge branch). */ { op: "pullFrom"; remote: string; branches: string[]; rebase: boolean; options: MergeOptions }; +export type RemoteProgress = { + stage: RemoteStage, + fraction: number | null, +}; + export type RemoteProject = RemoteProject_Serialize | RemoteProject_Deserialize; export type RemoteProject_Deserialize = { @@ -4445,6 +4450,9 @@ export type RemoteSession = { commands: string[], }; +/** Where [`remote_connect`] is (`fraction`: 0..1 of a download / upload). */ +export type RemoteStage = "connecting" | "checking" | "downloading" | "uploading" | "starting" | "opening"; + export type ReplaceEvent = /** A file was changed (`replacements` > 0) or had nothing left to replace. */ { type: "file"; path: IpcPath; replacements: number; done: number; total: number } | { type: "failed"; path: IpcPath; message: string; done: number; total: number } | { type: "done"; summary: ReplaceSummary }; diff --git a/src/features/remote-dev/ConnectDialog.tsx b/src/features/remote-dev/ConnectDialog.tsx index a6f912c2..772430b5 100644 --- a/src/features/remote-dev/ConnectDialog.tsx +++ b/src/features/remote-dev/ConnectDialog.tsx @@ -3,11 +3,18 @@ import { useState } from "react"; import { executeAction } from "@/platform/actions"; import { useTx } from "@/platform/i18n"; import { call, commands, type RemoteEntry } from "@/platform/ipc"; -import { connectRemote } from "@/platform/remote"; +import { connectRemote, useRemoteProgress } from "@/platform/remote"; import { sshLabel } from "@/platform/remoteSettings"; import { useWorkflowSettings } from "@/platform/workflowSettings"; -import { Button, Dialog, Icon, Input, Select } from "@/ui"; -import { browsableFolders, closeConnectDialog, listedDirectory, remoteParent, useConnectDialog } from "./model"; +import { Button, Dialog, Icon, Input, ProgressBar, Select, Spinner } from "@/ui"; +import { + browsableFolders, + closeConnectDialog, + listedDirectory, + remoteParent, + stageLabel, + useConnectDialog, +} from "./model"; import styles from "./RemoteDev.module.css"; export function ConnectHost() { @@ -130,6 +137,7 @@ function ConnectDialog() { ))} )} + {error && (
{error} @@ -139,3 +147,21 @@ function ConnectDialog() { ); } + +/** The stage of the connection in progress (and how far a download / upload is). */ +function ConnectProgress() { + const tx = useTx(); + const progress = useRemoteProgress((s) => s.progress); + if (!progress) return null; + const percent = progress.fraction == null ? null : Math.round(progress.fraction * 100); + return ( +
+
+ + {tx(stageLabel(progress.stage))} + {percent != null && {percent}%} +
+ +
+ ); +} diff --git a/src/features/remote-dev/RemoteDev.module.css b/src/features/remote-dev/RemoteDev.module.css index 9092e0df..a53e7cff 100644 --- a/src/features/remote-dev/RemoteDev.module.css +++ b/src/features/remote-dev/RemoteDev.module.css @@ -53,3 +53,22 @@ overflow-wrap: anywhere; user-select: text; } + +.progress { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; +} + +.progressLine { + display: flex; + align-items: center; + gap: 6px; + color: var(--fg-muted); +} + +.percent { + margin-left: auto; + font-variant-numeric: tabular-nums; +} diff --git a/src/features/remote-dev/model.ts b/src/features/remote-dev/model.ts index db583eab..ce366edb 100644 --- a/src/features/remote-dev/model.ts +++ b/src/features/remote-dev/model.ts @@ -1,6 +1,6 @@ /** File › Remote Development…: state of the connect dialog and the folder browser helpers. */ import { create } from "zustand"; -import type { RemoteEntry } from "@/platform/ipc"; +import type { RemoteEntry, RemoteStage } from "@/platform/ipc"; export const useConnectDialog = create<{ open: boolean }>(() => ({ open: false })); @@ -33,3 +33,21 @@ export function listedDirectory(requested: string, entries: readonly RemoteEntry const first = entries[0]; return first ? remoteParent(first.path) : requested; } + +/** What the window is waiting for at each stage of connecting. */ +export function stageLabel(stage: RemoteStage): string { + switch (stage) { + case "connecting": + return "Connecting to the host…"; + case "checking": + return "Checking the IDE backend on the host…"; + case "downloading": + return "Downloading the IDE backend…"; + case "uploading": + return "Installing the IDE backend on the host…"; + case "starting": + return "Starting the IDE backend…"; + case "opening": + return "Opening the project…"; + } +} diff --git a/src/features/welcome/ProjectsPage.tsx b/src/features/welcome/ProjectsPage.tsx index caac37f5..13c3da41 100644 --- a/src/features/welcome/ProjectsPage.tsx +++ b/src/features/welcome/ProjectsPage.tsx @@ -4,6 +4,8 @@ import clsx from "clsx"; import { executeAction, useActions } from "@/platform/actions"; import { shortcutLabel } from "@/platform/keymap"; import { useProject } from "@/platform/project"; +import { useRemoteProgress } from "@/platform/remote"; +import { stageLabel } from "@/features/remote-dev/model"; import { Button, HighlightedText, @@ -108,6 +110,11 @@ function ProjectRow({ onOpen: () => void; }) { const opening = useRecent((s) => s.opening === m.project.path); + // Opening a remote project: which stage of connecting it is at. + const progress = useRemoteProgress((s) => (opening ? s.progress : null)); + const stage = progress + ? tx(stageLabel(progress.stage)) + (progress.fraction == null ? "" : ` ${Math.round(progress.fraction * 100)}%`) + : null; const ref = useRef(null); useEffect(() => { if (selected) ref.current?.scrollIntoView?.({ block: "nearest" }); @@ -134,7 +141,13 @@ function ProjectRow({ - + {stage ? ( + + {stage} + + ) : ( + + )} {m.project.branch && ( diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 4d31ecb9..7e5ebae5 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4153,5 +4153,11 @@ "Remote Development…": "远程开发…", "Opens a project folder on an SSH host in this window; the IDE backend runs on that host.": "在此窗口中打开 SSH 主机上的项目文件夹;IDE 后端在该主机上运行。", "Disconnect from Remote Host": "断开与远程主机的连接", - "Opens a terminal tab connected to an SSH configuration (`arg`: its id; otherwise a chooser).": "打开连接到 SSH 配置的终端标签页(`arg`:配置 id;否则显示选择器)。" + "Opens a terminal tab connected to an SSH configuration (`arg`: its id; otherwise a chooser).": "打开连接到 SSH 配置的终端标签页(`arg`:配置 id;否则显示选择器)。", + "Connecting to the host…": "正在连接主机…", + "Checking the IDE backend on the host…": "正在检查主机上的 IDE 后端…", + "Downloading the IDE backend…": "正在下载 IDE 后端…", + "Installing the IDE backend on the host…": "正在主机上安装 IDE 后端…", + "Starting the IDE backend…": "正在启动 IDE 后端…", + "Opening the project…": "正在打开项目…" } diff --git a/src/platform/remote.ts b/src/platform/remote.ts index d4d13679..95f47982 100644 --- a/src/platform/remote.ts +++ b/src/platform/remote.ts @@ -7,7 +7,7 @@ import { Channel, type InvokeArgs, type InvokeOptions } from "@tauri-apps/api/core"; import { invokeLocal, setInvokeRoute, type InvokeRoute } from "./tauriCore"; import { create } from "zustand"; -import { call, commands, isTauri, type ChannelFrame, type RemoteSession } from "./ipc"; +import { call, commands, isTauri, type ChannelFrame, type RemoteProgress, type RemoteSession } from "./ipc"; import { useProject } from "./project"; interface RemoteState { @@ -96,11 +96,17 @@ export async function restoreRemote(): Promise { return session; } +/** Where the connection in progress is (`null`: none). */ +export const useRemoteProgress = create<{ progress: RemoteProgress | null }>(() => ({ progress: null })); + /** Opens `path` on the host of SSH configuration `sshConfig` in this window. */ export async function connectRemote(sshConfig: string, path: string) { useProject.setState({ opening: true }); + const onProgress = new Channel(); + onProgress.onmessage = (progress) => useRemoteProgress.setState({ progress }); + useRemoteProgress.setState({ progress: { stage: "connecting", fraction: null } }); try { - const r = await call(commands.remoteConnect(sshConfig, path)); + const r = await call(commands.remoteConnect(sshConfig, path, onProgress)); enableRemote({ sshConfig: r.sshConfig, root: r.root, host: r.host, commands: r.commands }); useProject.setState({ project: r.project, settings: r.project.settings }); rememberRemote({ @@ -113,6 +119,7 @@ export async function connectRemote(sshConfig: string, path: string) { return r; } finally { useProject.setState({ opening: false }); + useRemoteProgress.setState({ progress: null }); } } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 2f81f644..c123c345 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -285,7 +285,7 @@ html { --bg-toolbar-selected: #2e4d89; --bg-toolbar-selected-hover: #2f5eb9; --bg-button-primary: var(--accent); - --bg-button-primary-hover: #2f5eb9; + --bg-button-primary-hover: var(--accent-hover); --fg-button-primary: #ffffff; --bg-button: transparent; --border-button: #40434a; @@ -569,7 +569,7 @@ html { --bg-toolbar-pressed: #00000020; --bg-toolbar-selected: #d0dffe; --bg-toolbar-selected-hover: #bdd3ff; - --bg-button-primary-hover: #2f5eb9; + --bg-button-primary-hover: var(--accent-hover); --fg-button-primary: #ffffff; --bg-button: #ffffff; --border-button: #d1d3d9; @@ -783,7 +783,7 @@ html { --bg-toolbar-pressed: #ffffff38; --bg-toolbar-selected: #2a4b8f; --bg-toolbar-selected-hover: #3560b0; - --bg-button-primary-hover: #a7c5ff; + --bg-button-primary-hover: var(--accent-hover); --fg-button-primary: #000000; --bg-button: transparent; --border-button: #8b8e95;