feat(remote): SSH configurations, SFTP deployment, run targets and remote development (#91)

Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 23:12:19 +08:00
1 джерело 8eb4834a48
коміт 025e0019d2
196 files changed
+17920 -3729

No files matched your search

+167
Переглянути файл
@@ -0,0 +1,167 @@
/** Picks an SSH configuration and a folder on that host, then opens it in this 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 { sshLabel } from "@/platform/remoteSettings";
import { useWorkflowSettings } from "@/platform/workflowSettings";
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() {
const open = useConnectDialog((s) => s.open);
return open ? <ConnectDialog /> : null;
}
const SSH_SETTINGS_PAGE = "tools.sshConfigurations";
function ConnectDialog() {
const tx = useTx();
const configs = useWorkflowSettings((s) => s.ssh.configs);
const [ssh, setSsh] = useState(configs[0]?.id ?? "");
const [path, setPath] = useState("~");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [browse, setBrowse] = useState<{ dir: string; folders: RemoteEntry[] } | null>(null);
const list = async (dir: string) => {
setError(null);
setBusy(true);
try {
const entries = await call(commands.remoteList(ssh, dir));
const shown = listedDirectory(dir, entries);
setBrowse({ dir: shown, folders: browsableFolders(entries) });
setPath(shown);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const connect = async () => {
setError(null);
setBusy(true);
try {
await connectRemote(ssh, path.trim() || "~");
closeConnectDialog();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setBusy(false);
}
};
const options = configs.map((c) => ({ value: c.id, label: c.name.trim() || sshLabel(c), detail: sshLabel(c) }));
const ready = !!ssh && !busy && !!path.trim();
return (
<Dialog
open
title={tx("Remote Development")}
onClose={closeConnectDialog}
width={560}
buttons={[
{ label: busy ? "Connecting…" : "Open", role: "default", disabled: !ready, onClick: () => void connect() },
{ label: "Cancel", role: "cancel" },
]}
>
<form
className={styles.form}
onSubmit={(e) => {
e.preventDefault();
if (ready) void connect();
}}
>
<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.",
)}
</p>
<label className={styles.label}>{tx("SSH configuration:")}</label>
<div className={styles.row}>
<Select
className={styles.grow}
value={ssh || null}
options={options}
placeholder={tx("No SSH configurations")}
onChange={(id) => {
setSsh(id);
setBrowse(null);
}}
aria-label={tx("SSH configuration")}
/>
<Button onClick={() => void executeAction("file.settings", SSH_SETTINGS_PAGE)}>{tx("Configure…")}</Button>
</div>
<label className={styles.label} htmlFor="remote-dev-path">
{tx("Project directory:")}
</label>
<div className={styles.row}>
<Input
id="remote-dev-path"
className={styles.grow}
data-autofocus
value={path}
spellCheck={false}
onChange={(e) => setPath(e.target.value)}
/>
<Button disabled={!ssh || busy} onClick={() => void list(path.trim() || "~")}>
{tx("Browse")}
</Button>
</div>
{browse && (
<ul className={styles.folders} aria-label={tx("Remote folders")}>
{browse.dir !== "/" && (
<li>
<button type="button" className={styles.folder} onClick={() => void list(remoteParent(browse.dir))}>
<Icon name="folder" />
..
</button>
</li>
)}
{browse.folders.map((f) => (
<li key={f.path}>
<button type="button" className={styles.folder} onClick={() => void list(f.path)}>
<Icon name="folder" />
{f.name}
</button>
</li>
))}
</ul>
)}
<ConnectProgress />
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
</form>
</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>
);
}
+74
Переглянути файл
@@ -0,0 +1,74 @@
.form {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
}
.hint {
margin: 0 0 6px;
color: var(--fg-muted);
line-height: 1.5;
}
.label {
color: var(--fg);
margin-top: 4px;
}
.row {
display: flex;
gap: 8px;
align-items: center;
}
.grow {
flex: 1;
min-width: 0;
}
.folders {
list-style: none;
margin: 4px 0 0;
padding: 4px 0;
max-height: 220px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 4px;
}
.folder {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 3px 8px;
border: 0;
background: none;
color: var(--fg);
font: inherit;
text-align: left;
cursor: default;
}
.folder:hover {
background: var(--bg-hover);
}
.error {
color: var(--danger);
white-space: pre-wrap;
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;
}
+39
Переглянути файл
@@ -0,0 +1,39 @@
/**
* Remote development: a window whose project lives on an SSH host. The backend
* (`openidea-server`) runs there and this window routes its commands to it
* (platform/remote.ts).
*/
import { registerActions } from "@/platform/actions";
import { disconnectRemote } from "@/platform/remote";
import { registerOverlay } from "@/workbench/contributions";
import { ConnectHost } from "./ConnectDialog";
import { closeConnectDialog, openConnectDialog } from "./model";
export function activate(): () => void {
const disposers = [
registerOverlay({ id: "remote.connect", component: ConnectHost }),
registerActions([
{
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.",
category: "File",
icon: "folder",
keywords: ["ssh", "remote", "server", "gateway", "connect", "host"],
run: () => openConnectDialog(),
},
{
id: "remote.disconnect",
title: "Disconnect from Remote Host",
category: "File",
keywords: ["ssh", "remote", "close"],
when: "projectOpen",
run: () => disconnectRemote(),
},
]),
];
return () => {
disposers.reverse().forEach((d) => d());
closeConnectDialog();
};
}
+53
Переглянути файл
@@ -0,0 +1,53 @@
/** File › Remote Development…: state of the connect dialog and the folder browser helpers. */
import { create } from "zustand";
import type { RemoteEntry, RemoteStage } from "@/platform/ipc";
export const useConnectDialog = create<{ open: boolean }>(() => ({ open: false }));
export function openConnectDialog() {
useConnectDialog.setState({ open: true });
}
export function closeConnectDialog() {
useConnectDialog.setState({ open: false });
}
/** The parent of absolute POSIX path `path` (`/` stays `/`). */
export function remoteParent(path: string): string {
const trimmed = path.replace(/\/+$/, "");
const i = trimmed.lastIndexOf("/");
return i <= 0 ? "/" : trimmed.slice(0, i);
}
/** The folders of a listing, sorted by name (hidden ones last). */
export function browsableFolders(entries: readonly RemoteEntry[]): RemoteEntry[] {
const hidden = (e: RemoteEntry) => e.name.startsWith(".");
return entries
.filter((e) => e.isDir)
.sort((a, b) => Number(hidden(a)) - Number(hidden(b)) || a.name.localeCompare(b.name));
}
/** The directory a listing of `requested` stands for (`~` resolves on the server). */
export function listedDirectory(requested: string, entries: readonly RemoteEntry[]): string {
if (requested.startsWith("/")) return requested.replace(/(.)\/+$/, "$1");
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…";
}
}
+59
Переглянути файл
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import type { RemoteEntry } from "@/platform/ipc";
import { forgetRemote, parseRemoteLocation, recentRemotes, rememberRemote, remoteLocation } from "@/platform/remote";
import { browsableFolders, listedDirectory, remoteParent } from "./model";
const entry = (path: string, isDir = true): RemoteEntry => ({
name: path.slice(path.lastIndexOf("/") + 1),
path,
isDir,
isSymlink: false,
size: 0,
mtime: null,
permissions: "rwxr-xr-x",
});
describe("remote folder browser", () => {
it("goes up one level and stops at the root", () => {
expect(remoteParent("/home/dev/app")).toBe("/home/dev");
expect(remoteParent("/home/dev/")).toBe("/home");
expect(remoteParent("/home")).toBe("/");
expect(remoteParent("/")).toBe("/");
});
it("lists folders only, hidden ones last", () => {
const names = browsableFolders([
entry("/h/.cache"),
entry("/h/zeta"),
entry("/h/notes.txt", false),
entry("/h/alpha"),
]).map((e) => e.name);
expect(names).toEqual(["alpha", "zeta", ".cache"]);
});
it("resolves `~` from the listing", () => {
expect(listedDirectory("~", [entry("/home/dev/app")])).toBe("/home/dev");
expect(listedDirectory("~", [])).toBe("~");
expect(listedDirectory("/srv/", [])).toBe("/srv");
expect(listedDirectory("/", [])).toBe("/");
});
});
describe("remote project locations", () => {
it("round-trip the configuration id and the path", () => {
const loc = remoteLocation("mac mini/1", "/Users/me/repo");
expect(loc).toBe("ssh://mac%20mini%2F1/Users/me/repo");
expect(parseRemoteLocation(loc)).toEqual({ sshConfig: "mac mini/1", path: "/Users/me/repo" });
expect(parseRemoteLocation("/Users/me/repo")).toBeNull();
});
it("remembers recent remote projects most recent first, once each", () => {
localStorage.clear();
rememberRemote({ sshConfig: "a", path: "/p", name: "p", host: "me@a", openedAtMs: 1 });
rememberRemote({ sshConfig: "b", path: "/q", name: "q", host: "me@b", openedAtMs: 2 });
rememberRemote({ sshConfig: "a", path: "/p", name: "p", host: "me@a", openedAtMs: 3 });
expect(recentRemotes().map((r) => r.path)).toEqual(["/p", "/q"]);
forgetRemote("a", "/p");
expect(recentRemotes().map((r) => r.path)).toEqual(["/q"]);
});
});