дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(remote): SSH configurations, SFTP deployment, run targets and remote development (#91)
Цей коміт міститься в:
1 джерело
8eb4834a48
коміт
025e0019d2
196 files changed
+17920
-3729
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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…";
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача