feat(remote-dev): Remote Development in the project popup, opened in a new or this window

- The title bar project popup lists Remote Development… next to Open… and Clone Repository….
- Opening a remote project follows "Open project in" (new window / this window / ask) when this window already has a project, and confirms leaving the current one first.
- A remote project already open in another window focuses that window.
- A new window connects to the host itself and shows the connection progress on its welcome screen.
- Recent remote projects get Open in New Window.
This commit is contained in:
l0ng-ai
2026-10-28 11:29:13 +08:00
parent 45c7f43c02
commit 0222c84ace
15 changed files with 210 additions and 62 deletions
+1
View File
@@ -296,6 +296,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
sdk::sdk_python_packages,
sdk::sdk_python_pip,
window::window_open_project,
window::window_open_remote,
window::window_new,
window::window_open_settings,
window::window_cycle,
+13
View File
@@ -17,6 +17,19 @@ pub async fn window_open_project(app: AppHandle, path: PathBuf, new_window: bool
windows::open_project_window(&app, &path, new_window)
}
/// [`window_open_project`] for folder `path` on the host of SSH configuration
/// `ssh_config` (Remote Development); a new window connects itself.
#[tauri::command]
#[specta::specta]
pub async fn window_open_remote(
app: AppHandle,
ssh_config: String,
path: String,
new_window: bool,
) -> IdeResult<OpenOutcome> {
windows::open_remote_window(&app, &ssh_config, &path, new_window)
}
/// Opens an empty (welcome) window; returns its label.
#[tauri::command]
#[specta::specta]
+10
View File
@@ -47,6 +47,16 @@ impl Connections {
self.0.lock().get(window).cloned()
}
/// The window connected to folder `root` on the host of SSH configuration `ssh_config`.
pub fn window_of(&self, ssh_config: &str, root: &str) -> Option<String> {
let root = root.trim_end_matches('/');
self.0
.lock()
.iter()
.find(|(_, c)| c.ssh_config == ssh_config && c.root.trim_end_matches('/') == root)
.map(|(label, _)| label.clone())
}
pub fn disconnect(&self, window: &str) {
if let Some(c) = self.0.lock().remove(window) {
c.peer.kill();
+40 -6
View File
@@ -38,12 +38,22 @@ const SETTINGS_MIN_SIZE: (f64, f64) = (640.0, 420.0);
pub struct Launch {
/// Project folder to open.
pub project: Option<ide_core::IpcPath>,
/// Remote project to open (Remote Development).
pub remote: Option<RemoteLaunch>,
/// Settings windows: the page to show.
pub settings_page: Option<String>,
/// Settings windows: edit the global layer only ("Settings for New Projects").
pub new_projects: bool,
}
/// A project folder on the host of an SSH configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RemoteLaunch {
pub ssh_config: String,
pub path: String,
}
#[derive(Default)]
pub struct WindowsState {
launches: Mutex<HashMap<String, Launch>>,
@@ -184,7 +194,7 @@ pub fn find_project_window(app: &AppHandle, root: &Path) -> Option<WebviewWindow
}
fn has_project(app: &AppHandle, label: &str) -> bool {
app.try_state::<AppState>().is_some_and(|s| s.project(label).is_ok())
app.try_state::<AppState>().is_some_and(|s| s.project(label).is_ok() || s.remote_dev.get(label).is_some())
}
/// The focused window, else the last focused, else any.
@@ -272,12 +282,17 @@ pub fn create_main_window(app: &AppHandle) -> IdeResult<WebviewWindow> {
/// Creates a new project window; `project` is opened by its frontend on load.
pub fn create_window(app: &AppHandle, project: Option<PathBuf>) -> IdeResult<WebviewWindow> {
let title = project.as_deref().and_then(Path::file_name).map(|n| n.to_string_lossy().into_owned());
create_window_for(app, title, Launch { project: project.map(Into::into), ..Default::default() })
}
fn create_window_for(app: &AppHandle, title: Option<String>, launch: Launch) -> IdeResult<WebviewWindow> {
let windows = app.webview_windows();
let label = next_label(windows.keys().map(String::as_str));
let mut config = window_config(app)?;
config.label = label.clone();
if let Some(name) = project.as_deref().and_then(Path::file_name) {
config.title = name.to_string_lossy().into_owned();
if let Some(title) = title {
config.title = title;
}
// Cascade from the window the user is looking at; window-state overrides
// this with the remembered geometry when it has one for the label.
@@ -289,7 +304,7 @@ pub fn create_window(app: &AppHandle, project: Option<PathBuf>) -> IdeResult<Web
config.y = Some(pos.y + CASCADE);
config.center = false;
}
app.state::<WindowsState>().set_launch(&label, Launch { project: project.map(Into::into), ..Default::default() });
app.state::<WindowsState>().set_launch(&label, launch);
let window = build_project_window(app, config)?;
focus(&window);
Ok(window)
@@ -332,6 +347,25 @@ pub fn open_project_window(app: &AppHandle, path: &Path, new_window: bool) -> Id
Ok(OpenOutcome::Opened { label: window.label().to_owned() })
}
/// [`open_project_window`] for folder `path` on the host of SSH configuration
/// `ssh_config` (Remote Development).
pub fn open_remote_window(app: &AppHandle, ssh_config: &str, path: &str, new_window: bool) -> IdeResult<OpenOutcome> {
tracing::info!("open_remote_window {ssh_config}:{path} new_window={new_window}");
let windows = app.webview_windows();
let open = app.try_state::<AppState>().and_then(|s| s.remote_dev.window_of(ssh_config, path));
if let Some(w) = open.and_then(|label| windows.get(&label)) {
focus(w);
return Ok(OpenOutcome::Focused { label: w.label().to_owned() });
}
if !new_window {
return Ok(OpenOutcome::Current);
}
let title = path.trim_end_matches('/').rsplit('/').next().filter(|n| !n.is_empty() && *n != "~").map(str::to_owned);
let remote = RemoteLaunch { ssh_config: ssh_config.to_owned(), path: path.to_owned() };
let window = create_window_for(app, title, Launch { remote: Some(remote), ..Default::default() })?;
Ok(OpenOutcome::Opened { label: window.label().to_owned() })
}
/// Opens `path` from the command line / a second instance: focuses the window
/// that has it, else reuses a window without a project, else makes a new one.
pub fn open_path(app: &AppHandle, path: &Path) {
@@ -402,7 +436,7 @@ pub fn open_settings_window(
let windows = app.state::<WindowsState>();
if let Some(existing) = app.get_webview_window(&label) {
if page.is_some() || new_projects {
windows.set_launch(&label, Launch { project: None, settings_page: page, new_projects });
windows.set_launch(&label, Launch { settings_page: page, new_projects, ..Default::default() });
let _ = app.emit_to(label.as_str(), LAUNCH_EVENT, ());
}
focus(&existing);
@@ -425,7 +459,7 @@ pub fn open_settings_window(
if let Some(color) = background.as_deref().and_then(hex_color) {
config.background_color = Some(color);
}
windows.set_launch(&label, Launch { project: None, settings_page: page, new_projects });
windows.set_launch(&label, Launch { settings_page: page, new_projects, ..Default::default() });
let window = WebviewWindowBuilder::from_config(app, &config)
.and_then(|b| b.parent(&parent))
.and_then(|b| b.build())
+15
View File
@@ -578,6 +578,11 @@ export const commands = {
* itself (`current`). A project already open in some window is focused instead.
*/
windowOpenProject: (path: string, newWindow: boolean) => typedError<OpenOutcome, IdeError>(__TAURI_INVOKE("window_open_project", { path, newWindow })),
/**
* [`window_open_project`] for folder `path` on the host of SSH configuration
* `ssh_config` (Remote Development); a new window connects itself.
*/
windowOpenRemote: (sshConfig: string, path: string, newWindow: boolean) => typedError<OpenOutcome, IdeError>(__TAURI_INVOKE("window_open_remote", { sshConfig, path, newWindow })),
/** Opens an empty (welcome) window; returns its label. */
windowNew: () => typedError<string, IdeError>(__TAURI_INVOKE("window_new")),
/**
@@ -593,6 +598,8 @@ export const commands = {
windowTakeLaunch: () => __TAURI_INVOKE<{
/** Project folder to open. */
project: IpcPath | null,
/** Remote project to open (Remote Development). */
remote: RemoteLaunch | null,
/** Settings windows: the page to show. */
settingsPage: string | null,
/** Settings windows: edit the global layer only ("Settings for New Projects"). */
@@ -3510,6 +3517,8 @@ export type LanguagesSettings = {
export type Launch = {
/** Project folder to open. */
project: IpcPath | null,
/** Remote project to open (Remote Development). */
remote: RemoteLaunch | null,
/** Settings windows: the page to show. */
settingsPage: string | null,
/** Settings windows: edit the global layer only ("Settings for New Projects"). */
@@ -4461,6 +4470,12 @@ export type RemoteEntry = {
permissions: string,
};
/** A project folder on the host of an SSH configuration. */
export type RemoteLaunch = {
sshConfig: string,
path: string,
};
export type RemoteOp = { op: "fetch" } | { op: "pull"; rebase: boolean } | { op: "push"; force_with_lease: boolean; set_upstream: boolean;
/** Push dialog › Push tags. */
tags?: PushTags | null } |
+15 -31
View File
@@ -7,9 +7,10 @@ import type { Action } from "@/platform/actions";
import { t } from "@/platform/i18n";
import { call, commands, isTauri } from "@/platform/ipc";
import { basename, confirmLeaveProject, openProject, provideStartupLaunch, useProject } from "@/platform/project";
import { connectRemote } from "@/platform/remote";
import { holdSplash } from "@/platform/splash";
import { notifications } from "@/workbench/contributions";
import { ask } from "@/workbench/messages";
import { chooseProjectWindow } from "@/workbench/projectWindow";
export const LAUNCH_EVENT = "openidea://launch";
@@ -58,42 +59,14 @@ export async function openProjectInWindow(path: string, newWindow: boolean): Pro
}
}
/** System Settings › Project › "Open project in": `new` | `same` | `ask`. */
export function openPolicy(): "ask" | "new" | "same" {
const v = useProject.getState().settings?.general?.openProjectIn;
return v === "new" || v === "same" ? v : "ask";
}
/** Opens `path` following "Open project in" when this window already has a project. */
export async function openProjectWithPolicy(path: string): Promise<void> {
if (desktopOnly()) return;
const current = useProject.getState().project;
if (!current) return openProjectInWindow(path, false);
if (current.root === path) return;
let mode = openPolicy();
if (mode === "ask") {
const r = await ask({
title: t("app.window.askTitle"),
body: t("app.window.askBody", { name: basename(path) }),
buttons: [
{ id: "new", label: t("app.window.askNew") },
{ id: "same", label: t("app.window.askThis"), primary: true },
{ id: "cancel", label: t("common.cancel") },
],
checkbox: t("app.window.askRemember"),
});
if (r.button !== "same" && r.button !== "new") return;
mode = r.button;
if (r.checked) {
try {
useProject.setState({
settings: await call(commands.settingsSet("global", "general.openProjectIn", JSON.stringify(mode))),
});
} catch {
/* the choice still applies to this open */
}
}
}
const mode = await chooseProjectWindow(basename(path));
if (!mode) return;
if (mode === "new") return openProjectInWindow(path, true);
try {
// A window that already has the project is focused instead.
@@ -121,6 +94,17 @@ export async function consumeLaunch(): Promise<void> {
if (!isTauri()) return;
const launch = await commands.windowTakeLaunch().catch(() => null);
if (launch?.project) await openHere(launch.project);
else if (launch?.remote) {
const { sshConfig, path } = launch.remote;
await connectRemote(sshConfig, path).catch((e) =>
notifications().notify({
level: "error",
title: t("app.window.openFailed", { name: basename(path) }),
body: errorText(e),
key: `app.open:${path}`,
}),
);
}
}
export function windowActions(): Action[] {
+9 -6
View File
@@ -1,12 +1,13 @@
/** Picks an SSH configuration and a folder on that host, then opens it in this window. */
/** Picks an SSH configuration and a folder on that host, then opens it in this or a new window. */
import { useState } from "react";
import { executeAction } from "@/platform/actions";
import { useTx } from "@/platform/i18n";
import { call, commands, type RemoteEntry } from "@/platform/ipc";
import { connectRemote, useRemoteProgress } from "@/platform/remote";
import { useRemoteProgress } from "@/platform/remote";
import { sshLabel } from "@/platform/remoteSettings";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { Button, Dialog, Icon, Input, ProgressBar, Select, Spinner } from "@/ui";
import { openRemoteProject } from "@/workbench/projectWindow";
import {
browsableFolders,
closeConnectDialog,
@@ -52,8 +53,9 @@ function ConnectDialog() {
setError(null);
setBusy(true);
try {
await connectRemote(ssh, path.trim() || "~");
closeConnectDialog();
const target = path.trim() || "~";
if (await openRemoteProject(ssh, target)) closeConnectDialog();
else setBusy(false);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setBusy(false);
@@ -62,6 +64,7 @@ function ConnectDialog() {
const options = configs.map((c) => ({ value: c.id, label: c.name.trim() || sshLabel(c), detail: sshLabel(c) }));
const ready = !!ssh && !busy && !!path.trim();
const connecting = useRemoteProgress((s) => s.progress !== null);
return (
<Dialog
@@ -70,7 +73,7 @@ function ConnectDialog() {
onClose={closeConnectDialog}
width={560}
buttons={[
{ label: busy ? "Connecting…" : "Open", role: "default", disabled: !ready, onClick: () => void connect() },
{ label: connecting ? "Connecting…" : "Open", role: "default", disabled: !ready, onClick: () => void connect() },
{ label: "Cancel", role: "cancel" },
]}
>
@@ -83,7 +86,7 @@ function ConnectDialog() {
>
<p className={styles.hint}>
{tx(
"The project runs on the remote host; this window shows it. The IDE backend is installed there on first use.",
"The project runs on the remote host; the IDE backend is installed there on first use.",
)}
</p>
<label className={styles.label}>{tx("SSH configuration:")}</label>
+1 -1
View File
@@ -16,7 +16,7 @@ export function activate(): () => void {
{
id: "remote.connect",
title: "Remote Development…",
description: "Opens a project folder on an SSH host in this window; the IDE backend runs on that host.",
description: "Opens a project folder on an SSH host; the IDE backend runs on that host.",
category: "File",
icon: "folder",
keywords: ["ssh", "remote", "server", "gateway", "connect", "host"],
+1
View File
@@ -14,6 +14,7 @@ type Entry =
const ACTIONS: { id: string; label: string; icon: IconName }[] = [
{ id: "file.openProject", label: "Open…", icon: "folder" },
{ id: "vcs.clone", label: "Clone Repository…", icon: "branch" },
{ id: "remote.connect", label: "Remote Development…", icon: "terminal" },
];
function useOpenProjects(): OpenProject[] {
+15 -7
View File
@@ -1,6 +1,6 @@
import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
import clsx from "clsx";
import type { RecentProject } from "@/platform/ipc";
import { call, commands, type RecentProject } from "@/platform/ipc";
import { parseRemoteLocation } from "@/platform/remote";
import {
HighlightedText,
@@ -39,8 +39,8 @@ export function ProjectBadge({ name, className }: { name: string; className?: st
}
export function recentMenu(project: RecentProject, onOpened?: () => void): MenuItem[] {
// A remote project opens in this window only, and has no local folder.
const local = parseRemoteLocation(project.path) === null;
// A remote project has no local folder.
const remote = parseRemoteLocation(project.path);
const items: MenuItem[] = [
{
id: "open",
@@ -48,7 +48,17 @@ export function recentMenu(project: RecentProject, onOpened?: () => void): MenuI
icon: "folder-open",
run: () => void openRecent(project).then((ok) => ok && onOpened?.()),
},
{ kind: "action", action: "file.openInNewWindow", arg: project.path, label: "Open in New Window" },
remote
? {
id: "openInNewWindow",
label: "Open in New Window",
icon: "folder-open",
run: () =>
void call(commands.windowOpenRemote(remote.sshConfig, remote.path, true))
.then(() => onOpened?.())
.catch(() => undefined),
}
: { kind: "action", action: "file.openInNewWindow", arg: project.path, label: "Open in New Window" },
{
id: "reveal",
label: "Show in Finder",
@@ -71,9 +81,7 @@ export function recentMenu(project: RecentProject, onOpened?: () => void): MenuI
run: () => void askRemoveRecent(project),
},
];
return local
? items
: items.filter((i) => !("id" in i && i.id === "reveal") && !("action" in i && i.action === "file.openInNewWindow"));
return remote ? items.filter((i) => !("id" in i && i.id === "reveal")) : items;
}
function relativeTime(ms: number | null): string | null {
+7 -2
View File
@@ -6,7 +6,7 @@ import { useT, useTx } from "@/platform/i18n";
import { isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { useRemoteProgress } from "@/platform/remote";
import { stageLabel } from "@/features/remote-dev/model";
import { stageLabel, useConnectDialog } from "@/features/remote-dev/model";
import { Icon, IconButton, Menu, ProgressBar, Spinner, type MenuItem, T } from "@/ui";
import { useSettingsPages } from "@/workbench/contributions";
import { ErrorBoundary, Slot } from "@/workbench/ErrorBoundary";
@@ -113,7 +113,7 @@ function useFolderDrop(): boolean {
return over;
}
/** The last session's project opening at startup; a remote one shows how far connecting is. */
/** A project opening at startup or in a new window; a remote one shows how far connecting is. */
function Reopening({ location }: { location: string }) {
const tx = useTx();
const progress = useRemoteProgress((s) => s.progress);
@@ -139,6 +139,10 @@ export function WelcomeView() {
const t = useT();
const version = useRecent((s) => s.version);
const reopening = useSession((s) => s.reopening);
// A remote project connecting with no dialog or list row to show it (a new window's).
const connecting = useRemoteProgress((s) => s.location);
const connectDialog = useConnectDialog((s) => s.open);
const listOpening = useRecent((s) => s.opening);
const opening = useProject((s) => s.opening);
const Plugins = useSettingsPages((s) => s.pages.get(PLUGINS_PAGE)?.component);
const [page, setPage] = useState<WelcomePage>("projects");
@@ -150,6 +154,7 @@ export function WelcomeView() {
}, []);
if (reopening) return <Reopening location={reopening} />;
if (connecting && !connectDialog && !listOpening) return <Reopening location={connecting} />;
return (
<div className={styles.welcome}>
+5 -3
View File
@@ -7,6 +7,7 @@ import { connectRemote, forgetRemote, parseRemoteLocation, recentRemotes, remote
import { tx } from "@/platform/i18n";
import { fuzzyMatch } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { openRemoteProject } from "@/workbench/projectWindow";
interface RecentState {
projects: RecentProject[];
@@ -131,10 +132,11 @@ async function openRemote(
project: Pick<RecentProject, "path" | "name">,
remote: { sshConfig: string; path: string },
): Promise<boolean> {
useRecent.setState({ opening: project.path });
try {
await connectRemote(remote.sshConfig, remote.path);
return true;
return await openRemoteProject(remote.sshConfig, remote.path, async () => {
useRecent.setState({ opening: project.path });
await connectRemote(remote.sshConfig, remote.path);
});
} catch (e) {
notifications().notify({
level: "error",
+2 -2
View File
@@ -4175,12 +4175,12 @@
"Delete remote": "删除远程",
"Delete local": "删除本地",
"Remote Development": "远程开发",
"The project runs on the remote host; this window shows it. The IDE backend is installed there on first use.": "项目在远程主机上运行,此窗口负责显示。首次使用时会在远程主机上安装 IDE 后端。",
"The project runs on the remote host; the IDE backend is installed there on first use.": "项目在远程主机上运行,首次使用时会在该主机上安装 IDE 后端。",
"SSH configuration:": "SSH 配置:",
"No SSH configurations": "没有 SSH 配置",
"Project directory:": "项目目录:",
"Remote Development…": "远程开发…",
"Opens a project folder on an SSH host in this window; the IDE backend runs on that host.": "在此窗口中打开 SSH 主机上的项目文件夹;IDE 后端在该主机上运行。",
"Opens a project folder on an SSH host; 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;否则显示选择器)。",
"Connecting to the host…": "正在连接主机…",
+10 -4
View File
@@ -96,15 +96,21 @@ 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 }));
/** Where the connection in progress is, and to which location (`null`: none). */
export const useRemoteProgress = create<{ progress: RemoteProgress | null; location: string | null }>(() => ({
progress: null,
location: 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 } });
useRemoteProgress.setState({
progress: { stage: "connecting", fraction: null },
location: remoteLocation(sshConfig, path),
});
try {
const r = await call(commands.remoteConnect(sshConfig, path, onProgress));
enableRemote({ sshConfig: r.sshConfig, root: r.root, host: r.host, commands: r.commands });
@@ -119,7 +125,7 @@ export async function connectRemote(sshConfig: string, path: string) {
return r;
} finally {
useProject.setState({ opening: false });
useRemoteProgress.setState({ progress: null });
useRemoteProgress.setState({ progress: null, location: null });
}
}
+66
View File
@@ -0,0 +1,66 @@
/** Where a project opens when this window already has one: System Settings › Project › "Open project in". */
import { t } from "@/platform/i18n";
import { call, commands } from "@/platform/ipc";
import { confirmLeaveProject, useProject } from "@/platform/project";
import { connectRemote } from "@/platform/remote";
import { ask } from "./messages";
/** "Open project in": `new` | `same` | `ask`. */
export function openPolicy(): "ask" | "new" | "same" {
const v = useProject.getState().settings?.general?.openProjectIn;
return v === "new" || v === "same" ? v : "ask";
}
/**
* The window to open project `name` in: this one when it has no project, else
* per "Open project in", asking when set to (and remembering "Don't ask again").
* `null`: cancelled.
*/
export async function chooseProjectWindow(name: string): Promise<"new" | "same" | null> {
if (!useProject.getState().project) return "same";
const mode = openPolicy();
if (mode !== "ask") return mode;
const r = await ask({
title: t("app.window.askTitle"),
body: t("app.window.askBody", { name }),
buttons: [
{ id: "new", label: t("app.window.askNew") },
{ id: "same", label: t("app.window.askThis"), primary: true },
{ id: "cancel", label: t("common.cancel") },
],
checkbox: t("app.window.askRemember"),
});
if (r.button !== "same" && r.button !== "new") return null;
if (r.checked) {
try {
useProject.setState({
settings: await call(commands.settingsSet("global", "general.openProjectIn", JSON.stringify(r.button))),
});
} catch {
/* the choice still applies to this open */
}
}
return r.button;
}
/**
* Opens folder `path` on the host of SSH configuration `sshConfig`: focuses the
* window that has it, else opens it in a new window or (`here`) this one per
* "Open project in". `false`: cancelled.
*/
export async function openRemoteProject(
sshConfig: string,
path: string,
here: () => Promise<unknown> = () => connectRemote(sshConfig, path),
): Promise<boolean> {
if ((await call(commands.windowOpenRemote(sshConfig, path, false))).kind === "focused") return true;
const mode = await chooseProjectWindow(path.replace(/\/+$/, "").split("/").pop() || path);
if (!mode) return false;
if (mode === "new") {
await call(commands.windowOpenRemote(sshConfig, path, true));
return true;
}
if (!(await confirmLeaveProject())) return false;
await here();
return true;
}