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

This commit is contained in:
l0ng-ai committed 2026-10-27 22:04:59 +08:00
1 parent 6bcf99778a
commit ec41d3c4a0
10 files changed
+168 -21

No files matched your search

+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;