From 0222c84acea256d8d8eadaf08dc6e907c370423d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:29:03 +0800 Subject: [PATCH] feat(remote-dev): Remote Development in the project popup, opened in a new or this window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/window.rs | 13 +++++ src-tauri/src/remote_dev.rs | 10 ++++ src-tauri/src/windows.rs | 46 +++++++++++++--- src/bindings.ts | 15 ++++++ src/features/app/windows.ts | 46 ++++++---------- src/features/remote-dev/ConnectDialog.tsx | 15 +++--- src/features/remote-dev/index.ts | 2 +- src/features/welcome/ProjectPopup.tsx | 1 + src/features/welcome/RecentList.tsx | 22 +++++--- src/features/welcome/WelcomeView.tsx | 9 +++- src/features/welcome/recent.ts | 8 +-- src/platform/locales/text/zh.json | 4 +- src/platform/remote.ts | 14 +++-- src/workbench/projectWindow.ts | 66 +++++++++++++++++++++++ 15 files changed, 210 insertions(+), 62 deletions(-) create mode 100644 src/workbench/projectWindow.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e60f2d4a..6f03b04a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -296,6 +296,7 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 20ecd5d1..64b6c593 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -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 { + windows::open_remote_window(&app, &ssh_config, &path, new_window) +} + /// Opens an empty (welcome) window; returns its label. #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index c0966dfc..876b15e6 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -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 { + 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(); diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 0c1cc1dd..deb97acd 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -38,12 +38,22 @@ const SETTINGS_MIN_SIZE: (f64, f64) = (640.0, 420.0); pub struct Launch { /// Project folder to open. pub project: Option, + /// Remote project to open (Remote Development). + pub remote: Option, /// Settings windows: the page to show. pub settings_page: Option, /// 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>, @@ -184,7 +194,7 @@ pub fn find_project_window(app: &AppHandle, root: &Path) -> Option bool { - app.try_state::().is_some_and(|s| s.project(label).is_ok()) + app.try_state::().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 { /// Creates a new project window; `project` is opened by its frontend on load. pub fn create_window(app: &AppHandle, project: Option) -> IdeResult { + 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, launch: Launch) -> IdeResult { 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) -> IdeResult().set_launch(&label, Launch { project: project.map(Into::into), ..Default::default() }); + app.state::().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 { + tracing::info!("open_remote_window {ssh_config}:{path} new_window={new_window}"); + let windows = app.webview_windows(); + let open = app.try_state::().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::(); 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()) diff --git a/src/bindings.ts b/src/bindings.ts index 6dd7ce9b..d0677fb1 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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(__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(__TAURI_INVOKE("window_open_remote", { sshConfig, path, newWindow })), /** Opens an empty (welcome) window; returns its label. */ windowNew: () => typedError(__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 } | diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index 1849cbbc..3a65b3c1 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -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 { 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 { 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[] { diff --git a/src/features/remote-dev/ConnectDialog.tsx b/src/features/remote-dev/ConnectDialog.tsx index 772430b5..50a81317 100644 --- a/src/features/remote-dev/ConnectDialog.tsx +++ b/src/features/remote-dev/ConnectDialog.tsx @@ -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 ( void connect() }, + { label: connecting ? "Connecting…" : "Open", role: "default", disabled: !ready, onClick: () => void connect() }, { label: "Cancel", role: "cancel" }, ]} > @@ -83,7 +86,7 @@ function ConnectDialog() { >

{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.", )}

diff --git a/src/features/remote-dev/index.ts b/src/features/remote-dev/index.ts index 2cdad563..1ba6bea2 100644 --- a/src/features/remote-dev/index.ts +++ b/src/features/remote-dev/index.ts @@ -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"], diff --git a/src/features/welcome/ProjectPopup.tsx b/src/features/welcome/ProjectPopup.tsx index 1caa4cd4..96bf1723 100644 --- a/src/features/welcome/ProjectPopup.tsx +++ b/src/features/welcome/ProjectPopup.tsx @@ -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[] { diff --git a/src/features/welcome/RecentList.tsx b/src/features/welcome/RecentList.tsx index c0dd2d45..fac32294 100644 --- a/src/features/welcome/RecentList.tsx +++ b/src/features/welcome/RecentList.tsx @@ -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 { diff --git a/src/features/welcome/WelcomeView.tsx b/src/features/welcome/WelcomeView.tsx index 4ac6ecfb..5599bf7b 100644 --- a/src/features/welcome/WelcomeView.tsx +++ b/src/features/welcome/WelcomeView.tsx @@ -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("projects"); @@ -150,6 +154,7 @@ export function WelcomeView() { }, []); if (reopening) return ; + if (connecting && !connectDialog && !listOpening) return ; return (
diff --git a/src/features/welcome/recent.ts b/src/features/welcome/recent.ts index 16be3263..bed647f1 100644 --- a/src/features/welcome/recent.ts +++ b/src/features/welcome/recent.ts @@ -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, remote: { sshConfig: string; path: string }, ): Promise { - 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", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 2813f6ca..4c6a1678 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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…": "正在连接主机…", diff --git a/src/platform/remote.ts b/src/platform/remote.ts index 95f47982..838dbea5 100644 --- a/src/platform/remote.ts +++ b/src/platform/remote.ts @@ -96,15 +96,21 @@ export async function restoreRemote(): Promise { 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(); 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 }); } } diff --git a/src/workbench/projectWindow.ts b/src/workbench/projectWindow.ts new file mode 100644 index 00000000..d36cd283 --- /dev/null +++ b/src/workbench/projectWindow.ts @@ -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 = () => connectRemote(sshConfig, path), +): Promise { + 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; +}