mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat(remote): Show the stages and transfer progress of connecting; primary button hover follows the accent
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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…";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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…": "正在打开项目…"
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user