дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +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,68 @@
|
||||
/** The oldest pending askpass prompt as a modal: password / text field, or Yes / No for a host key. */
|
||||
import { useState } from "react";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { Dialog, Input } from "@/ui";
|
||||
import { answerAskpass, askpassAnswer, useAskpass, type AskpassPrompt } from "./askpass";
|
||||
import styles from "./Ssh.module.css";
|
||||
|
||||
export function AskpassHost() {
|
||||
const head = useAskpass((s) => s.queue[0]);
|
||||
return head ? <AskpassDialog key={head.id} prompt={head} /> : null;
|
||||
}
|
||||
|
||||
function AskpassDialog({ prompt: p }: { prompt: AskpassPrompt }) {
|
||||
const tx = useTx();
|
||||
const [text, setText] = useState("");
|
||||
const done = (choice: Parameters<typeof askpassAnswer>[1]) => answerAskpass(p.id, askpassAnswer(p.kind, choice));
|
||||
const cancel = () => done(null);
|
||||
|
||||
if (p.kind === "confirm")
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
icon="warning"
|
||||
title={tx("SSH")}
|
||||
onClose={cancel}
|
||||
width={480}
|
||||
buttons={[
|
||||
{ label: "Yes", role: "default", onClick: () => done({ confirm: true }) },
|
||||
{ label: "No", onClick: () => done({ confirm: false }) },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.prompt}>{p.prompt}</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={tx(p.kind === "secret" ? "SSH Password" : "SSH")}
|
||||
onClose={cancel}
|
||||
width={440}
|
||||
buttons={[
|
||||
{ label: "OK", role: "default", onClick: () => done({ text }) },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
done({ text });
|
||||
}}
|
||||
>
|
||||
<label className={styles.prompt} htmlFor="askpass-input">
|
||||
{p.prompt}
|
||||
</label>
|
||||
<Input
|
||||
id="askpass-input"
|
||||
data-autofocus
|
||||
type={p.kind === "secret" ? "password" : "text"}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
.prompt {
|
||||
color: var(--fg);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Credential prompts of `ssh` / `git` child processes (backend askpass), shown
|
||||
* one at a time; prompts arriving meanwhile wait in order.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { commands, type AskpassKind } from "@/platform/ipc";
|
||||
|
||||
export interface AskpassPrompt {
|
||||
id: number;
|
||||
prompt: string;
|
||||
kind: AskpassKind;
|
||||
}
|
||||
|
||||
export const useAskpass = create<{ queue: AskpassPrompt[] }>(() => ({ queue: [] }));
|
||||
|
||||
export function enqueueAskpass(p: AskpassPrompt) {
|
||||
useAskpass.setState((s) => (s.queue.some((q) => q.id === p.id) ? s : { queue: [...s.queue, p] }));
|
||||
}
|
||||
|
||||
/** Drops prompt `id` (answered in another window, or the process gave up). */
|
||||
export function dropAskpass(id: number) {
|
||||
useAskpass.setState((s) => (s.queue.some((q) => q.id === id) ? { queue: s.queue.filter((q) => q.id !== id) } : s));
|
||||
}
|
||||
|
||||
/** The answer sent back: the typed text, `yes` / `no` for a confirmation, null when cancelled. */
|
||||
export function askpassAnswer(
|
||||
kind: AskpassKind,
|
||||
choice: { text: string } | { confirm: boolean } | null,
|
||||
): string | null {
|
||||
if (choice === null) return null;
|
||||
if ("confirm" in choice) return kind === "confirm" ? (choice.confirm ? "yes" : "no") : null;
|
||||
return kind === "confirm" ? null : choice.text;
|
||||
}
|
||||
|
||||
export function answerAskpass(id: number, answer: string | null, send = commands.sshAskpassAnswer) {
|
||||
dropAskpass(id);
|
||||
void send(id, answer).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* SSH: credential prompts of `ssh` / `git` child processes (askpass) and
|
||||
* Tools › Start SSH Session. The configurations live in Settings › Tools ›
|
||||
* SSH Configurations.
|
||||
*/
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { onIdeEvent } from "@/platform/ipc";
|
||||
import { registerOverlay } from "@/workbench/contributions";
|
||||
import { dropAskpass, enqueueAskpass, useAskpass } from "./askpass";
|
||||
import { AskpassHost } from "./AskpassDialog";
|
||||
import { chooseSshSession, SSH_SETTINGS_PAGE, startSshSession } from "./session";
|
||||
|
||||
export function activate(): () => void {
|
||||
const disposers = [
|
||||
registerOverlay({ id: "ssh.askpass", component: AskpassHost }),
|
||||
onIdeEvent("askpass", (e) => enqueueAskpass({ id: e.id, prompt: e.prompt, kind: e.kind })),
|
||||
onIdeEvent("askpassDone", (e) => dropAskpass(e.id)),
|
||||
registerActions([
|
||||
{
|
||||
id: "ssh.startSession",
|
||||
title: "Start SSH Session…",
|
||||
description: "Opens a terminal tab connected to an SSH configuration (`arg`: its id; otherwise a chooser).",
|
||||
category: "Tools",
|
||||
icon: "terminal",
|
||||
when: "projectOpen",
|
||||
keywords: ["ssh", "remote", "terminal", "shell", "connect"],
|
||||
run: ({ arg }) => (typeof arg === "string" && arg ? startSshSession(arg) : chooseSshSession()),
|
||||
},
|
||||
{
|
||||
id: "ssh.configurations",
|
||||
title: "SSH Configurations…",
|
||||
category: "Tools",
|
||||
icon: "settings",
|
||||
keywords: ["ssh", "remote", "host"],
|
||||
run: () => void executeAction("file.settings", SSH_SETTINGS_PAGE),
|
||||
},
|
||||
]),
|
||||
];
|
||||
return () => {
|
||||
disposers.reverse().forEach((d) => d());
|
||||
useAskpass.setState({ queue: [] });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/** Tools › Start SSH Session…: pick an SSH configuration and run `ssh` for it in a terminal tab. */
|
||||
import { openPopup, type ListPopupItem } from "@/editor/lsp/popupStore";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { sshLabel, type SshConfigValid } from "@/platform/remoteSettings";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
|
||||
export const SSH_SETTINGS_PAGE = "tools.sshConfigurations";
|
||||
|
||||
/** Rows of the chooser: one per configuration, then a way to edit them. */
|
||||
export function sessionItems(
|
||||
configs: readonly SshConfigValid[],
|
||||
start: (id: string) => void,
|
||||
configure: () => void,
|
||||
): ListPopupItem[] {
|
||||
const items: ListPopupItem[] = configs.map((c) => {
|
||||
const label = sshLabel(c);
|
||||
return {
|
||||
id: c.id,
|
||||
label: c.name.trim() || label || c.id,
|
||||
detail: c.name.trim() && label !== c.name ? label : undefined,
|
||||
icon: "terminal",
|
||||
disabled: c.host.trim() ? null : tx("No host"),
|
||||
run: () => start(c.id),
|
||||
};
|
||||
});
|
||||
items.push({
|
||||
id: "<configure>",
|
||||
label: tx(configs.length ? "Edit SSH Configurations…" : "No SSH configurations. Add one…"),
|
||||
icon: "settings",
|
||||
run: configure,
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function startSshSession(id: string) {
|
||||
try {
|
||||
const cmd = await call(commands.sshTerminalCommand(id));
|
||||
await executeAction("terminal.runCommand", { program: cmd.shell, args: cmd.args, name: cmd.title });
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: tx("Cannot start SSH session"),
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
group: "tools",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function chooseSshSession() {
|
||||
const items = sessionItems(
|
||||
workflowSettings().ssh.configs,
|
||||
(id) => void startSshSession(id),
|
||||
() => void executeAction("file.settings", SSH_SETTINGS_PAGE),
|
||||
);
|
||||
openPopup({
|
||||
kind: "list",
|
||||
anchor: { x: window.innerWidth / 2 - 200, y: 96 },
|
||||
title: tx("Start SSH Session"),
|
||||
items,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SSH_CONFIG, type SshConfigValid } from "@/platform/remoteSettings";
|
||||
import { answerAskpass, askpassAnswer, dropAskpass, enqueueAskpass, useAskpass } from "./askpass";
|
||||
import { sessionItems } from "./session";
|
||||
|
||||
describe("askpass queue", () => {
|
||||
beforeEach(() => useAskpass.setState({ queue: [] }));
|
||||
|
||||
it("queues prompts in arrival order, once each", () => {
|
||||
enqueueAskpass({ id: 1, prompt: "Password:", kind: "secret" });
|
||||
enqueueAskpass({ id: 2, prompt: "Continue connecting (yes/no)?", kind: "confirm" });
|
||||
enqueueAskpass({ id: 1, prompt: "Password:", kind: "secret" });
|
||||
expect(useAskpass.getState().queue.map((p) => p.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("drops a prompt answered elsewhere", () => {
|
||||
enqueueAskpass({ id: 1, prompt: "a", kind: "text" });
|
||||
enqueueAskpass({ id: 2, prompt: "b", kind: "text" });
|
||||
dropAskpass(1);
|
||||
dropAskpass(9);
|
||||
expect(useAskpass.getState().queue.map((p) => p.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it("answers the backend and shows the next prompt", () => {
|
||||
const send = vi.fn((_id: number, _answer: string | null) => Promise.resolve());
|
||||
enqueueAskpass({ id: 1, prompt: "a", kind: "secret" });
|
||||
enqueueAskpass({ id: 2, prompt: "b", kind: "secret" });
|
||||
answerAskpass(1, "hunter2", send);
|
||||
expect(send).toHaveBeenCalledWith(1, "hunter2");
|
||||
expect(useAskpass.getState().queue.map((p) => p.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it("maps choices to answers", () => {
|
||||
expect(askpassAnswer("secret", { text: "pw" })).toBe("pw");
|
||||
expect(askpassAnswer("text", { text: "" })).toBe("");
|
||||
expect(askpassAnswer("confirm", { confirm: true })).toBe("yes");
|
||||
expect(askpassAnswer("confirm", { confirm: false })).toBe("no");
|
||||
expect(askpassAnswer("secret", null)).toBeNull();
|
||||
expect(askpassAnswer("confirm", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Start SSH Session chooser", () => {
|
||||
const config = (patch: Partial<SshConfigValid>): SshConfigValid => ({ ...DEFAULT_SSH_CONFIG, ...patch });
|
||||
|
||||
it("lists configurations, then a way to edit them", () => {
|
||||
const start = vi.fn();
|
||||
const configure = vi.fn();
|
||||
const items = sessionItems(
|
||||
[
|
||||
config({ id: "a", name: "Dev box", host: "box", user: "dev", port: 2222 }),
|
||||
config({ id: "b", host: "bastion" }),
|
||||
config({ id: "c", name: "Empty" }),
|
||||
],
|
||||
start,
|
||||
configure,
|
||||
);
|
||||
expect(items.map((i) => [i.label, i.detail])).toEqual([
|
||||
["Dev box", "dev@box:2222"],
|
||||
["bastion", undefined],
|
||||
["Empty", undefined],
|
||||
["Edit SSH Configurations…", undefined],
|
||||
]);
|
||||
expect(items[2]!.disabled).toBeTruthy();
|
||||
void items[0]!.run();
|
||||
expect(start).toHaveBeenCalledWith("a");
|
||||
void items[3]!.run();
|
||||
expect(configure).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers to add one when there are none", () => {
|
||||
const items = sessionItems([], vi.fn(), vi.fn());
|
||||
expect(items.map((i) => i.label)).toEqual(["No SSH configurations. Add one…"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача