feat(remote): Show the stages and transfer progress of connecting; primary button hover follows the accent

This commit is contained in:
l0ng-ai
2026-10-27 22:04:59 +08:00
parent 6bcf99778a
commit ec41d3c4a0
10 changed files with 168 additions and 21 deletions
+13
View File
@@ -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 {
+46 -9
View File
@@ -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<f64>,
}
type Progress = dyn Fn(RemoteStage, Option<f64>) + 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<std::pa
}
/// The release build of the server for `os`/`arch`, downloaded once into the data dir.
async fn download_server(state: &AppState, version: &str, os: &str, arch: &str) -> IdeResult<std::path::PathBuf> {
async fn download_server(
state: &AppState,
version: &str,
os: &str,
arch: &str,
progress: Arc<Progress>,
) -> IdeResult<std::path::PathBuf> {
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<String> {
async fn ensure_server(state: &AppState, session: &ide_ssh::Session, progress: Arc<Progress>) -> IdeResult<String> {
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<RemoteProgress>,
) -> IdeResult<RemoteProject> {
let progress: Arc<Progress> = 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)?;
+9 -1
View File
@@ -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<RemoteProject_Serialize, IdeError>(__TAURI_INVOKE("remote_connect", { sshConfig, path })),
remoteConnect: (sshConfig: string, path: string, onProgress: Channel<RemoteProgress>) => typedError<RemoteProject_Serialize, IdeError>(__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 };
+29 -3
View File
@@ -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() {
))}
</ul>
)}
<ConnectProgress />
{error && (
<div className={styles.error} role="alert">
{error}
@@ -139,3 +147,21 @@ function ConnectDialog() {
</Dialog>
);
}
/** 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 (
<div className={styles.progress} role="status">
<div className={styles.progressLine}>
<Spinner size={12} />
<span>{tx(stageLabel(progress.stage))}</span>
{percent != null && <span className={styles.percent}>{percent}%</span>}
</div>
<ProgressBar value={progress.fraction} aria-label={tx(stageLabel(progress.stage))} />
</div>
);
}
@@ -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;
}
+19 -1
View File
@@ -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…";
}
}
+14 -1
View File
@@ -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<HTMLDivElement>(null);
useEffect(() => {
if (selected) ref.current?.scrollIntoView?.({ block: "nearest" });
@@ -134,7 +141,13 @@ function ProjectRow({
<ProjectBadge name={m.project.name} className={styles.projectBadge} />
<span className={styles.projectText}>
<HighlightedText className={styles.projectName} text={m.project.name} indices={m.nameMatch} tone="strong" />
<HighlightedText className={styles.projectPath} text={m.displayPath} indices={m.pathMatch} />
{stage ? (
<span className={styles.projectPath} role="status">
{stage}
</span>
) : (
<HighlightedText className={styles.projectPath} text={m.displayPath} indices={m.pathMatch} />
)}
{m.project.branch && (
<span className={styles.projectBranch}>
<Icon name="branch" size={14} />
+7 -1
View File
@@ -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…": "正在打开项目…"
}
+9 -2
View File
@@ -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<RemoteSession | null> {
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<RemoteProgress>();
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 });
}
}
+3 -3
View File
@@ -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;