feat(settings): HTTP Proxy settings for the IDE's outgoing connections (#194)

* feat(settings): HTTP Proxy settings for the IDE's outgoing connections

Add Appearance & Behavior › System Settings › HTTP Proxy with three modes:
No proxy, Auto-detect proxy settings (the default; environment proxy as
before) and Manual proxy configuration (HTTP or SOCKS, host and port, a
comma-separated "No proxy for" list with `*` wildcards, optional proxy
authentication). "Check connection…" GETs a URL with the settings as edited
and reports the status or the error.

- ide-core: `ProxySettings` plus routing (no-proxy globs, loopback always
  direct, credentials percent-encoded), the current proxy for the process,
  curl arguments and child-process variables.
- ide-http: `client::build` / `client::shared` is the one HTTP client
  constructor; the HTTP Client requests and the update check go through it.
- ide-tools: curl downloads (tools, JDKs, debug adapters, agent registry,
  remote server) follow the setting.
- A manual proxy reaches child processes (language servers, debug adapters,
  terminals, git) as HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY.
- The proxy password is kept in the owner-only saved passwords file
  (`proxy-password.json` in the app data directory), not in settings.toml.

Co-Authored-By: Claude <[email protected]>

* fix(host): Allow the Unix-only post-start test helpers on Windows

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
Цей коміт міститься в:
l0ng-aiтаClaude authored and GitHub committed 2026-10-29 10:43:30 +08:00
1 джерело c8e3fe9d2e
коміт 9d4e778c7b
28 files changed
+1343 -65

No files matched your search

+5
Переглянути файл
@@ -18,6 +18,7 @@ import {
import { EDITOR_EXTRA_DEFAULTS, validateEditorExtras, type EditorExtras } from "@/platform/editorSettings";
import { DEFAULT_WORKFLOW, validateWorkflow, type WorkflowSettings } from "@/platform/workflowSettings";
import { DEFAULT_INSPECTIONS, validateInspections, type InspectionSettingsValid } from "@/platform/inspections";
import { DEFAULT_PROXY, validateProxy, type ProxySettingsValid } from "@/platform/proxySettings";
import {
DEFAULT_APPEARANCE_EXTRAS,
DEFAULT_GENERAL_EXTRAS,
@@ -77,6 +78,8 @@ export interface ValidSettings extends WorkflowSettings {
keymapOverrides: Record<string, string[]>;
customKeymaps: Record<string, CustomKeymapSettings>;
notifications: NotificationsValid;
/** System Settings › HTTP Proxy (global). */
proxy: ProxySettingsValid;
languages: LanguagesValid;
projectStructure: ProjectStructureValid;
inspections: InspectionSettingsValid;
@@ -143,6 +146,7 @@ export const DEFAULT_SETTINGS: ValidSettings = {
keymapOverrides: {},
customKeymaps: {},
notifications: DEFAULT_NOTIFICATIONS,
proxy: DEFAULT_PROXY,
languages: DEFAULT_LANGUAGES,
projectStructure: DEFAULT_PROJECT_STRUCTURE,
inspections: DEFAULT_INSPECTIONS,
@@ -245,6 +249,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
keymapOverrides: overrides,
customKeymaps,
notifications: validateNotifications(root.notifications),
proxy: validateProxy(root.proxy),
languages: validateLanguages(root.languages),
projectStructure: validateProjectStructure(root.projectStructure),
inspections: validateInspections(root.inspections),
+88
Переглянути файл
@@ -0,0 +1,88 @@
.page {
display: flex;
flex-direction: column;
gap: 4px;
}
.modes {
display: flex;
flex-direction: column;
}
.radio {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
}
.radio input {
margin: 0;
accent-color: var(--accent);
}
.disabled {
color: var(--fg-subtle);
}
.hint {
padding-left: 20px;
color: var(--fg-subtle);
}
/* Manual configuration, indented under its radio button. */
.manual {
display: flex;
flex-direction: column;
padding-left: 20px;
}
.kinds {
display: flex;
align-items: center;
gap: 16px;
}
.hostRow {
display: flex;
align-items: flex-start;
gap: 24px;
}
.host {
width: 220px;
}
.port {
width: 64px;
}
.wide {
width: 360px;
}
.toggle {
display: flex;
align-items: center;
min-height: 28px;
}
.auth {
display: flex;
flex-direction: column;
padding-left: 20px;
}
.error {
color: var(--danger);
}
.ok {
color: var(--success, var(--fg-muted));
}
.check {
margin-top: 12px;
}
.popup {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 14px;
max-width: 480px;
}
.urlRow {
display: flex;
align-items: center;
gap: 8px;
}
.result {
min-height: 1lh;
overflow-wrap: anywhere;
}
+3 -1
Переглянути файл
@@ -1,6 +1,6 @@
/**
* Appearance & Behavior pages, laid out like IntelliJ IDEA / PyCharm:
* Appearance, System Settings (+ Language and Region, Date Formats) and
* Appearance, System Settings (+ Language and Region, Date Formats, HTTP Proxy) and
* Notifications. Every option is wired (see `appearanceRuntime`, workbench
* theme / tool windows / notifications, app title / windows, `platform/dateFormat`).
*/
@@ -26,6 +26,7 @@ import {
} from "../model/behaviorSchema";
import { ACCENT_PRESETS, LIMITS, type ThemeId } from "../model/schema";
import type { PageSpec } from "./spec";
import { HTTP_PROXY_PAGE } from "./httpProxy";
import styles from "./Behavior.module.css";
import { tx, useT } from "@/platform/i18n";
@@ -673,6 +674,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
},
],
},
HTTP_PROXY_PAGE,
{
id: "notifications",
title: "Notifications",
+297
Переглянути файл
@@ -0,0 +1,297 @@
/**
* Appearance & Behavior › System Settings › HTTP Proxy: how the IDE's own
* connections (HTTP Client, tool / JDK / agent downloads, update checks) reach
* the network. A manual proxy also reaches child processes as `HTTP_PROXY`.
*/
import { useId, useRef, useState } from "react";
import clsx from "clsx";
import { call, commands, isTauri } from "@/platform/ipc";
import { tx, useTx } from "@/platform/i18n";
import {
PROXY_PORT_LIMITS,
proxyProblem,
withProxyKind,
type ProxyKindValid,
type ProxyModeValid,
type ProxySettingsValid,
} from "@/platform/proxySettings";
import { Button, Checkbox, Input, Popup, T } from "@/ui";
import { NumberInput, Row, SearchLabel } from "../components/Form";
import { usePageContext, useSetting } from "../components/context";
import { stage } from "../model/staging";
import type { PageSpec } from "./spec";
import styles from "./HttpProxy.module.css";
const MODES: Array<{ value: ProxyModeValid; label: string; hint?: string }> = [
{ value: "none", label: "No proxy" },
{
value: "auto",
label: "Auto-detect proxy settings",
hint: "Uses the system proxy: the HTTP_PROXY, HTTPS_PROXY and NO_PROXY environment variables.",
},
{ value: "manual", label: "Manual proxy configuration" },
];
const KINDS: Array<{ value: ProxyKindValid; label: string }> = [
{ value: "http", label: "HTTP" },
{ value: "socks", label: "SOCKS" },
];
/** The staged proxy settings and a setter per field (each written as its own key). */
function useProxy(): [ProxySettingsValid, (patch: Partial<ProxySettingsValid>) => void] {
const [proxy] = useSetting<ProxySettingsValid>("proxy", "global");
const { pageId } = usePageContext();
const update = (patch: Partial<ProxySettingsValid>) => {
for (const [field, value] of Object.entries(patch)) stage(pageId, "global", `proxy.${field}`, value);
};
return [proxy, update];
}
function Radio({
name,
checked,
onSelect,
label,
disabled,
}: {
name: string;
checked: boolean;
onSelect: () => void;
label: string;
disabled?: boolean;
}) {
return (
<label className={clsx(styles.radio, disabled && styles.disabled)}>
<input type="radio" name={name} checked={checked} disabled={disabled} onChange={onSelect} />
<SearchLabel text={label} />
</label>
);
}
function ProxyOptions() {
const [proxy, update] = useProxy();
const name = useId();
const kindName = useId();
const ids = {
host: useId(),
port: useId(),
noProxy: useId(),
login: useId(),
password: useId(),
};
const manual = proxy.mode === "manual";
const auth = manual && proxy.authenticate;
const problem = proxyProblem(proxy);
return (
<div className={styles.page}>
<div className={styles.modes} role="radiogroup" aria-label={tx("Proxy")}>
{MODES.map((m) => (
<div key={m.value}>
<Radio
name={name}
checked={proxy.mode === m.value}
onSelect={() => update({ mode: m.value })}
label={m.label}
/>
{m.hint && (
<div className={styles.hint}>
<T>{m.hint}</T>
</div>
)}
</div>
))}
</div>
<div className={styles.manual}>
<div className={styles.kinds} role="radiogroup" aria-label={tx("Proxy type")}>
{KINDS.map((k) => (
<Radio
key={k.value}
name={kindName}
checked={proxy.kind === k.value}
disabled={!manual}
onSelect={() => update(withProxyKind(proxy, k.value))}
label={k.label}
/>
))}
</div>
<div className={styles.hostRow}>
<Row label="Host name" htmlFor={ids.host}>
<Input
id={ids.host}
className={styles.host}
disabled={!manual}
value={proxy.host}
onChange={(e) => update({ host: e.target.value })}
/>
</Row>
<Row label="Port number" htmlFor={ids.port}>
{manual ? (
<NumberInput
id={ids.port}
value={proxy.port}
onChange={(port) => update({ port })}
{...PROXY_PORT_LIMITS}
integer
aria-label="Port number"
/>
) : (
<Input id={ids.port} className={styles.port} value={String(proxy.port)} disabled readOnly />
)}
</Row>
</div>
<Row
label="No proxy for"
htmlFor={ids.noProxy}
hint="Comma-separated host names; * matches any characters, e.g. *.example.com, 192.168.*"
>
<Input
id={ids.noProxy}
className={styles.wide}
disabled={!manual}
value={proxy.noProxy}
onChange={(e) => update({ noProxy: e.target.value })}
/>
</Row>
<div className={styles.toggle}>
<Checkbox
checked={proxy.authenticate}
disabled={!manual}
onChange={(authenticate) => update({ authenticate })}
label={<SearchLabel text="Proxy authentication" />}
/>
</div>
<div className={styles.auth}>
<Row label="Login" htmlFor={ids.login}>
<Input
id={ids.login}
className={styles.host}
disabled={!auth}
value={proxy.login}
onChange={(e) => update({ login: e.target.value })}
/>
</Row>
<Row label="Password" htmlFor={ids.password}>
<Input
id={ids.password}
type="password"
className={styles.host}
disabled={!auth}
value={proxy.password}
onChange={(e) => update({ password: e.target.value })}
/>
</Row>
</div>
{problem && (
<div className={styles.error} role="alert">
<T>{problem}</T>
</div>
)}
</div>
<CheckConnection proxy={proxy} />
</div>
);
}
type CheckState = { status: "idle" | "running" } | { status: "ok" | "failed"; text: string };
/** Check connection…: asks for a URL and GETs it with the settings as edited. */
function CheckConnection({ proxy }: { proxy: ProxySettingsValid }) {
const t = useTx();
const anchor = useRef<HTMLButtonElement>(null);
const urlId = useId();
const [open, setOpen] = useState(false);
const [url, setUrl] = useState("http://");
const [state, setState] = useState<CheckState>({ status: "idle" });
const check = async () => {
setState({ status: "running" });
try {
const status = await call(commands.proxyCheckConnection(proxy, url));
setState({ status: "ok", text: t`Connection successful: ${status}` });
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
setState({
status: "failed",
text: t`Problem with connection: ${message}`,
});
}
};
const running = state.status === "running";
return (
<div className={styles.check}>
<Button
ref={anchor}
size="small"
disabled={!isTauri() || !!proxyProblem(proxy)}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<T>Check connection…</T>
</Button>
<Popup
open={open}
onClose={() => setOpen(false)}
anchor={anchor}
placement="bottom-start"
offset={4}
aria-label="Check connection"
>
<form
className={styles.popup}
onSubmit={(e) => {
e.preventDefault();
if (!running && url.trim()) void check();
}}
>
<label htmlFor={urlId}>
<T>Enter any URL to check connection to:</T>
</label>
<span className={styles.urlRow}>
<Input id={urlId} autoFocus className={styles.wide} value={url} onChange={(e) => setUrl(e.target.value)} />
<Button size="small" type="submit" disabled={running || !url.trim()}>
<T>Check</T>
</Button>
</span>
<span
role="status"
className={clsx(
styles.result,
state.status === "ok" && styles.ok,
state.status === "failed" && styles.error,
)}
>
{state.status === "ok" || state.status === "failed" ? state.text : running ? t("Connecting…") : ""}
</span>
</form>
</Popup>
</div>
);
}
export const HTTP_PROXY_PAGE: PageSpec = {
id: "general.httpProxy",
title: "HTTP Proxy",
order: 2,
keywords: ["proxy", "network", "socks", "http_proxy", "no_proxy", "connection"],
sections: [
{
options: [
{
kind: "custom",
label: "Manual proxy configuration",
keywords: [
"No proxy",
"Auto-detect proxy settings",
"Host name",
"Port number",
"No proxy for",
"Proxy authentication",
"Login",
"Password",
"Check connection…",
],
component: ProxyOptions,
},
],
},
],
};