feat(project): add project trust and restricted execution mode (#517)

Projects now open with a user-owned trust decision. The prompt offers Preview in Restricted Mode, Don't Open and Trust Project, with an optional trusted parent folder; preview is the default focus so Enter never grants trust.

Restricted projects keep browsing, editing, saving, search and highlighting. Project-controlled execution is denied by default in the backend: run, debug, build, tools, language servers, plugins and Git commands that could honour repository configuration. Trusting a project enables those paths without reopening it, and decisions can be revoked in Settings.

Terminals stay available when the user opens one, without project shell settings, environment or virtualenv activation. A shell prompt that runs git in the folder can still honour the repository's core.fsmonitor.

Fixes #462
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-31 20:58:45 +08:00
1 джерело cf51b57924
коміт 2c9a6470ff
103 files changed
+1996 -168

No files matched your search

+1
Переглянути файл
@@ -125,6 +125,7 @@ describe("workspace task project", () => {
const project = {
root: "/workspace",
name: "Workspace",
trusted: true,
isGit: false,
workspace: { version: 1, name: "Workspace", projects: ["/project-a", "/project-b"] },
} as ProjectInfo;
+1 -1
Переглянути файл
@@ -44,7 +44,7 @@ export function activate(): () => void {
const disposers = [
activateSettingsPages(),
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) sync(s.project?.root);
if (s.project?.root !== prev.project?.root || s.project?.trusted !== prev.project?.trusted) sync(s.project?.root);
}),
registerToolWindow({
id: TOOL_WINDOW,
+6 -1
Переглянути файл
@@ -165,7 +165,12 @@ export async function connect(): Promise<void> {
};
let tasks: AgentTask[], providers: AgentProvider[];
try {
[tasks, providers] = await Promise.all([call(commands.agentSubscribe(channel)), call(commands.agentProviders())]);
[tasks, providers] = await Promise.all([
call(commands.agentSubscribe(channel)),
useProject.getState().project?.trusted === false
? Promise.resolve([] as AgentProvider[])
: call(commands.agentProviders()),
]);
} catch (e) {
if (gen === generation) useAgents.setState({ loaded: true, error: e instanceof Error ? e.message : String(e) });
return;
+4 -2
Переглянути файл
@@ -10,7 +10,7 @@ const windowTakeLaunch = vi.fn();
const lightEditOpen = vi.fn(async (_path: string) => ({ status: "ok", data: useProject.getState().settings ?? {} }));
const fsReadDir = vi.fn(async (_path: string) => ({ status: "ok", data: [] }));
const openProject = vi.fn(async (path: string) => {
const project = { root: path, name: path, isGit: false, settings: {} };
const project = { root: path, name: path, trusted: true, isGit: false, settings: {} };
useProject.setState({ project, lightEdit: false } as never);
return project;
});
@@ -61,7 +61,7 @@ provideEditorService({
});
const ok = (data: unknown) => ({ status: "ok", data });
const projectState = (root: string) => ({ project: { root, name: root, isGit: false, settings: {} } });
const projectState = (root: string) => ({ project: { root, name: root, trusted: true, isGit: false, settings: {} } });
beforeEach(() => {
for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch]) f.mockReset();
@@ -246,6 +246,7 @@ describe("Attach", () => {
const before = {
root: "/workspace",
name: "A",
trusted: true,
isGit: false,
settings: {},
workspace: { version: 1, name: "A", projects: ["/p/a"] },
@@ -269,6 +270,7 @@ it("removes the first workspace member while retaining the workspace state root"
const project = {
root: "/workspace",
name: "A",
trusted: true,
isGit: false,
settings: {},
workspace: { version: 1, name: "A", projects: ["/a", "/b"] },
+2 -1
Переглянути файл
@@ -9,7 +9,7 @@
*/
import { executeAction, type Action } from "@/platform/actions";
import { t } from "@/platform/i18n";
import { call, commands, isTauri } from "@/platform/ipc";
import { call, commands, isCancelled, isTauri } from "@/platform/ipc";
import {
basename,
confirmLeaveProject,
@@ -39,6 +39,7 @@ function desktopOnly(): boolean {
}
function openFailed(path: string, e: unknown) {
if (isCancelled(e)) return;
notifications().notify({
level: "error",
title: t("app.window.openFailed", { name: basename(path) }),
+1 -1
Переглянути файл
@@ -154,7 +154,7 @@ export function activate(): () => void {
if (s.model !== prev.model || s.root !== prev.root) apply(s.root ? s.model : null, registered);
}),
useProject.subscribe((s, prev) => {
if (s.project?.root === prev.project?.root) return;
if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return;
resetBuild();
if (s.project) void loadModel();
}),
+1 -1
Переглянути файл
@@ -158,7 +158,7 @@ export async function loadModel(): Promise<void> {
current: {},
});
}
if (!root || !isTauri()) return;
if (!root || useProject.getState().project?.trusted === false || !isTauri()) return;
useBuild.setState({ loading: true });
try {
const [model, configs] = await Promise.all([
+2 -2
Переглянути файл
@@ -27,7 +27,7 @@ describe("server status", () => {
afterEach(() => dispose());
it("keeps a status that arrived after the snapshot was requested", async () => {
useProject.setState({ project: { name: "p", root: "/p", isGit: false, settings: {} } as never });
useProject.setState({ project: { name: "p", root: "/p", trusted: true, isGit: false, settings: {} } as never });
dispose = installServerEvents();
const id = 1 as ServerId;
const info: ServerInfo = { id, name: "jdtls", languages: [], root: "/p", status: "starting", pid: null };
@@ -47,7 +47,7 @@ describe("server status", () => {
});
it("drops a server the backend removed, even if an older snapshot still lists it", async () => {
useProject.setState({ project: { name: "p", root: "/p", isGit: false, settings: {} } as never });
useProject.setState({ project: { name: "p", root: "/p", trusted: true, isGit: false, settings: {} } as never });
dispose = installServerEvents();
const info = (id: number, name: string): ServerInfo => ({
id: id as ServerId,
+3 -1
Переглянути файл
@@ -5,6 +5,7 @@
* which applies them to every session (adapter defaults for filters without a choice).
*/
import { create } from "zustand";
import { useProject } from "@/platform/project";
import { call, commands, isTauri, type ExceptionFilter } from "@/platform/ipc";
interface ExceptionState {
@@ -128,7 +129,8 @@ export function mergeKnown(
}
async function push() {
if (!isTauri()) return;
if (!isTauri() || !useExceptionBreakpoints.getState().root || useProject.getState().project?.trusted === false)
return;
try {
await call(commands.debugSetExceptionBreakpoints(useExceptionBreakpoints.getState().choices));
} catch (e) {
+2 -1
Переглянути файл
@@ -5,6 +5,7 @@
* them. Entries remember the source line they were created on for the gutter icon.
*/
import { create } from "zustand";
import { useProject } from "@/platform/project";
import { call, commands, isTauri, type DataBreakpoint, type FunctionBreakpoint } from "@/platform/ipc";
import { newBreakpointId, type BreakpointStatus } from "./model";
import { useBreakpoints } from "./store";
@@ -121,7 +122,7 @@ function scheduleSync() {
}
export async function syncSpecial() {
if (!isTauri()) return;
if (!isTauri() || !useSpecialBreakpoints.getState().root || useProject.getState().project?.trusted === false) return;
const { methods, fields } = useSpecialBreakpoints.getState();
const muted = useBreakpoints.getState().muted;
try {
+2 -1
Переглянути файл
@@ -4,6 +4,7 @@
* The backend keeps them across sessions and returns verification states.
*/
import { create } from "zustand";
import { useProject } from "@/platform/project";
import { call, commands, isTauri } from "@/platform/ipc";
import {
makeBreakpoint,
@@ -132,7 +133,7 @@ export function syncFile(path: string): Promise<void> {
}
async function sendFile(path: string) {
if (!isTauri()) return;
if (!isTauri() || useProject.getState().project?.trusted === false) return;
const { byFile, muted } = useBreakpoints.getState();
const list = byFile[path] ?? [];
sent.set(
+1 -1
Переглянути файл
@@ -81,7 +81,7 @@ export function activate(): () => void {
}),
useProject.subscribe((s, prev) => {
const root = s.project?.root ?? null;
if (root !== (prev.project?.root ?? null)) {
if (root !== (prev.project?.root ?? null) || s.project?.trusted !== prev.project?.trusted) {
resetSessions();
loadBreakpoints(root);
loadExceptionBreakpoints(root);
+3
Переглянути файл
@@ -4,6 +4,7 @@
* a disposer. The editor provides the editor service; app must install its launch
* barrier before welcome can restore a remembered project.
*/
import { activate as projectTrust } from "./project-trust";
import { activate as editor } from "@/editor/core";
import { activate as editorLsp } from "@/editor/lsp";
import { activate as welcome } from "./welcome";
@@ -46,6 +47,7 @@ import { activate as remoteDev } from "./remote-dev";
type Activate = () => void | (() => void);
const features: Array<[string, Activate]> = [
["project-trust", projectTrust],
["editor", editor],
["editor-lsp", editorLsp],
["app", app],
@@ -107,6 +109,7 @@ export { SettingsWindow, settingsWindowParent } from "./settings";
*/
export function activateSettingsWindowFeatures(parent: string): () => void {
const pages: Array<[string, Activate]> = [
["project-trust", projectTrust],
["locale", appLocale],
["settings", () => activateSettingsWindow(parent)],
["templates", templatesPages],
+3 -1
Переглянути файл
@@ -90,6 +90,7 @@ const sdkKey = (s: ReturnType<typeof useProject.getState>) => {
const l = s.settings?.languages;
return JSON.stringify([
s.project?.root ?? null,
s.project?.trusted,
l?.python?.interpreter,
l?.java?.jdk,
l?.node?.interpreter,
@@ -116,7 +117,8 @@ export function activate(): () => void {
};
const refresh = () => {
const id = ++seq;
if (!useProject.getState().project || !isTauri()) return clear();
if (!useProject.getState().project || useProject.getState().project?.trusted === false || !isTauri())
return clear();
call(commands.sdkProjectLibraries())
.then((groups) => {
if (id !== seq) return;
+1 -1
Переглянути файл
@@ -70,7 +70,7 @@ export function activate(): () => void {
startPluginBridge(),
// Dev plugins (`<project>/.openidea/plugins`) depend on the open project.
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) void refreshPlugins();
if (s.project?.root !== prev.project?.root || s.project?.trusted !== prev.project?.trusted) void refreshPlugins();
}),
];
void refreshPlugins();
+4 -3
Переглянути файл
@@ -53,7 +53,7 @@ let disposers: Array<() => void> = [];
beforeEach(() => {
useProjectTree.setState({ root: ROOT });
useProject.setState({
project: { name: "p", root: ROOT, isGit: true, settings: {} } as never,
project: { name: "p", root: ROOT, trusted: true, isGit: true, settings: {} } as never,
settings: {} as never,
});
disposers = [
@@ -179,7 +179,7 @@ describe("project view context menu", () => {
it("offers Compare With… in a non-git project too", () => {
useProject.setState({
project: { name: "p", root: ROOT, isGit: false, settings: {} } as never,
project: { name: "p", root: ROOT, trusted: true, isGit: false, settings: {} } as never,
settings: {} as never,
});
const l = labels(contextMenuItems([node(`${ROOT}/a.py`)]));
@@ -192,7 +192,7 @@ describe("project view context menu", () => {
disposers.forEach((d) => d());
disposers = [];
useProject.setState({
project: { name: "p", root: ROOT, isGit: true, settings: {} } as never,
project: { name: "p", root: ROOT, trusted: true, isGit: true, settings: {} } as never,
settings: {} as never,
});
const l = labels(contextMenuItems([node(`${ROOT}/a.py`)]));
@@ -208,6 +208,7 @@ it("offers workspace removal for every member root", () => {
root: "/workspace",
name: "A",
settings: {},
trusted: true,
isGit: false,
workspace: { version: 1, name: "A", projects: ["/a", "/b"] },
},
+1 -1
Переглянути файл
@@ -20,7 +20,7 @@ let disposers: Array<() => void> = [];
beforeEach(() => {
useProjectTree.setState({ root: ROOT });
useProject.setState({
project: { name: "p", root: ROOT, isGit: false, settings: {} } as never,
project: { name: "p", root: ROOT, trusted: true, isGit: false, settings: {} } as never,
settings: {} as never,
});
disposers = [
+1 -1
Переглянути файл
@@ -69,7 +69,7 @@ describe("creating packages", () => {
setProjectFsForTests(fs, "/p");
initProjectTree("/p", "p");
useProject.setState({
project: { name: "p", root: "/p", isGit: false, settings: {} } as never,
project: { name: "p", root: "/p", trusted: true, isGit: false, settings: {} } as never,
settings: {} as never,
});
useProjectRoots.setState({ root: "/p", detected: MAVEN });
+218
Переглянути файл
@@ -0,0 +1,218 @@
import { useEffect, useState } from "react";
import { create } from "zustand";
import { executeAction, registerActions } from "@/platform/actions";
import { call, commands, onIdeEvent, type TrustDecisions } from "@/platform/ipc";
import { dirname, useProject } from "@/platform/project";
import { initializeRemoteTrustMigration } from "@/platform/remote";
import { provideProjectTrustPrompt } from "@/platform/projectTrust";
import { registerOverlay, registerSettingsPage, registerWorkbenchBanner } from "@/workbench/contributions";
import { Button, Checkbox, Dialog, T } from "@/ui";
import styles from "./trust.module.css";
const usePrompt = create<{ path: string | null; folder: boolean; finish: ((open: boolean) => void) | null }>(() => ({
path: null,
folder: false,
finish: null,
}));
/** One line; a long path loses its middle, so the final folder stays visible. */
function PathLine({ path }: { path: string }) {
const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
return (
<span className={styles.pathLine} title={path}>
<span className={styles.pathHead}>{path.slice(0, Math.max(cut, 0))}</span>
<span className={styles.pathTail}>{path.slice(Math.max(cut, 0))}</span>
</span>
);
}
function TrustDialog() {
const { path, folder } = usePrompt();
return (
<Dialog
open={!!path}
icon="app"
width={560}
title="Trust and Open Project?"
onClose={() => void executeAction("project.dontOpen")}
buttons={[
{
label: "Preview in Restricted Mode",
autoFocus: true,
onClick: () => void executeAction("project.previewRestricted"),
},
{ label: "Don't Open", onClick: () => void executeAction("project.dontOpen") },
{ label: "Trust Project", role: "default", onClick: () => void executeAction("project.trust") },
]}
footerStart={
<Checkbox
checked={folder}
onChange={(folder) => usePrompt.setState({ folder })}
className={styles.folderOption}
label={
<span className={styles.folderLabel}>
<span className={styles.folderPrefix}>
<T>Trust projects in</T>
</span>{" "}
<PathLine path={path ? dirname(path) : ""} />
</span>
}
/>
}
>
<div className={styles.content}>
<PathLine path={path ?? ""} />
<p>
<T>
If you don't trust the source, preview the project in Restricted Mode, where project commands and tools
can't run.
</T>
</p>
<p>
<T>Loading, running or building a project may execute potentially malicious code from its build scripts.</T>
</p>
</div>
</Dialog>
);
}
function RestrictedBanner() {
const restricted = useProject((s) => s.project?.trusted === false);
return restricted ? (
<div className={styles.banner} role="status">
<span>
<T>Restricted Mode: project commands and tools are disabled.</T>
</span>
<Button onClick={() => void executeAction("project.trust")}>
<T>Trust Project</T>
</Button>
</div>
) : null;
}
function TrustSettings() {
const [decisions, setDecisions] = useState<TrustDecisions | null>(null);
const [error, setError] = useState("");
const refresh = () =>
call(commands.projectTrustList())
.then(setDecisions)
.catch((e: unknown) => setError(String(e)));
useEffect(() => {
void refresh();
}, []);
const revoke = async (action: string, path: string) => {
try {
await executeAction(action, path);
await refresh();
} catch (e) {
setError(String(e));
}
};
return (
<div className={styles.content}>
<p>
<T>Trust decisions are saved in your user data and take effect immediately.</T>
</p>
{error && <p role="alert">{error}</p>}
<h3>
<T>Trusted projects</T>
</h3>
{decisions?.projects.map((path) => (
<div className={styles.row} key={path}>
<span className={styles.path}>{path}</span>
<Button onClick={() => void revoke("project.revokeTrust", path)}>
<T>Revoke Trust</T>
</Button>
</div>
))}
<h3>
<T>Trusted locations</T>
</h3>
{decisions?.locations.map((path) => (
<div className={styles.row} key={path}>
<span className={styles.path}>{path}</span>
<Button onClick={() => void revoke("project.removeTrustedLocation", path)}>
<T>Remove</T>
</Button>
</div>
))}
</div>
);
}
async function decide(trusted: boolean) {
const pending = usePrompt.getState();
const path = pending.path ?? useProject.getState().project?.root;
if (!path) return;
await call(commands.projectTrustSet(path, trusted, trusted && pending.folder));
const project = await commands.projectCurrent();
if (project) useProject.setState({ project, settings: project.settings });
pending.finish?.(true);
}
export function activate(): () => void {
initializeRemoteTrustMigration();
const disposers = [
provideProjectTrustPrompt(async (path) => {
const status = await call(commands.projectTrustStatus(path));
if (status.decided) return true;
usePrompt.getState().finish?.(false);
return new Promise<boolean>((resolve) =>
usePrompt.setState({
path: status.root,
folder: false,
finish: (open) => {
usePrompt.setState({ path: null, finish: null, folder: false });
resolve(open);
},
}),
);
}),
registerOverlay({ id: "project.trustDialog", component: TrustDialog }),
registerWorkbenchBanner({ id: "project.restrictedMode", component: RestrictedBanner }),
registerSettingsPage({ id: "projectTrust", title: "Project Trust", group: "Security", component: TrustSettings }),
registerActions([
{ id: "project.trust", title: "Trust Project", category: "Project", run: () => decide(true) },
{
id: "project.previewRestricted",
title: "Preview in Restricted Mode",
category: "Project",
hidden: true,
run: () => decide(false),
},
{
id: "project.dontOpen",
title: "Don't Open",
category: "Project",
hidden: true,
run: () => usePrompt.getState().finish?.(false),
},
{
id: "project.revokeTrust",
title: "Revoke Trust",
category: "Project",
run: async ({ arg }) => {
const path = typeof arg === "string" ? arg : useProject.getState().project?.root;
if (path) await call(commands.projectTrustSet(path, false, false));
},
},
{
id: "project.removeTrustedLocation",
title: "Remove Trusted Location",
category: "Project",
hidden: true,
run: async ({ arg }) => {
if (typeof arg === "string") await call(commands.projectTrustRemoveLocation(arg));
},
},
]),
onIdeEvent("projectTrustChanged", (e) => {
const project = useProject.getState().project;
if (project?.root === e.root) useProject.setState({ project: { ...project, trusted: e.trusted } });
}),
];
return () => {
usePrompt.getState().finish?.(false);
disposers.reverse().forEach((dispose) => dispose());
};
}
+57
Переглянути файл
@@ -0,0 +1,57 @@
.content {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.content p {
margin: 0;
}
.pathLine {
display: flex;
min-width: 0;
white-space: nowrap;
}
.content > .pathLine {
color: var(--fg-secondary);
}
.pathHead {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.pathTail {
flex: none;
max-width: 70%;
overflow: hidden;
text-overflow: ellipsis;
}
.folderOption {
max-width: 100%;
}
.folderLabel {
display: flex;
gap: var(--space-2);
min-width: 0;
}
.folderPrefix {
flex: none;
}
.banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-2) var(--space-4);
background: var(--bg-elev);
color: var(--fg);
border-bottom: 1px solid var(--border);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.path {
overflow-wrap: anywhere;
}
+83
Переглянути файл
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { confirmProjectTrust } from "@/platform/projectTrust";
import { executeAction } from "@/platform/actions";
import { emitLocalIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { OverlayHost } from "@/workbench/OverlayHost";
import { useContributions } from "@/workbench/contributions";
import { activate } from "./index";
const status = vi.fn();
const decide = vi.fn();
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
projectTrustStatus: (path: string) => status(path),
projectTrustSet: (...args: unknown[]) => decide(...args),
projectCurrent: async () => null,
},
};
});
let dispose: () => void;
beforeEach(() => {
status
.mockReset()
.mockImplementation(async (root: string) => ({ status: "ok", data: { root, trusted: false, decided: false } }));
decide.mockReset().mockResolvedValue({ status: "ok", data: null });
useProject.setState({ project: null });
dispose = activate();
});
afterEach(() => dispose());
it("offers three choices, cancels without a trust decision, and supports restricted preview", async () => {
render(<OverlayHost />);
const cancelled = confirmProjectTrust("/new").catch(() => "cancelled");
await screen.findByRole("dialog");
fireEvent.click(screen.getByRole("button", { name: "Don't Open" }));
expect(await cancelled).toBe("cancelled");
expect(decide).not.toHaveBeenCalled();
const preview = confirmProjectTrust("/new");
await screen.findByRole("dialog");
fireEvent.click(screen.getByRole("button", { name: "Preview in Restricted Mode" }));
await act(async () => preview);
expect(decide).toHaveBeenCalledWith("/new", false, false);
});
it("saves the parent-folder choice and reuses a persisted decision", async () => {
render(<OverlayHost />);
const opening = confirmProjectTrust("/projects/new");
await screen.findByRole("dialog");
fireEvent.click(screen.getByRole("checkbox", { name: /Trust projects in/ }));
fireEvent.click(screen.getByRole("button", { name: "Trust Project" }));
await act(async () => opening);
expect(decide).toHaveBeenCalledWith("/projects/new", true, true);
status.mockResolvedValue({ status: "ok", data: { trusted: true, decided: true } });
await act(async () => confirmProjectTrust("/projects/other"));
expect(screen.queryByRole("dialog")).toBeNull();
});
it("updates the running project's banner after a backend trust transition", async () => {
useProject.setState({ project: { root: "/p", name: "P", isGit: false, trusted: false, settings: {} } });
const Banner = useContributions.getState().workbenchBanners.get("project.restrictedMode")!.component;
render(<Banner />);
expect(screen.getByRole("status")).toHaveTextContent("Restricted Mode");
await act(async () => executeAction("project.trust"));
expect(decide).toHaveBeenCalledWith("/p", true, false);
act(() => emitLocalIdeEvent({ type: "projectTrustChanged", root: "/p", trusted: true }));
expect(screen.queryByRole("status")).toBeNull();
});
it("shows and trusts the canonical folder when opening through a symlink", async () => {
status.mockResolvedValue({ status: "ok", data: { root: "/real/projects/new", trusted: false, decided: false } });
render(<OverlayHost />);
const opening = confirmProjectTrust("/aliases/new");
await screen.findByRole("dialog");
fireEvent.click(screen.getByRole("checkbox", { name: /Trust projects in \/real\/projects/ }));
fireEvent.click(screen.getByRole("button", { name: "Trust Project" }));
await act(async () => opening);
expect(decide).toHaveBeenCalledWith("/real/projects/new", true, true);
});
+2 -2
Переглянути файл
@@ -2,7 +2,7 @@
import { useState } from "react";
import { executeAction } from "@/platform/actions";
import { useTx } from "@/platform/i18n";
import { call, commands, type RemoteEntry } from "@/platform/ipc";
import { call, commands, isCancelled, type RemoteEntry } from "@/platform/ipc";
import { useRemoteProgress } from "@/platform/remote";
import { sshLabel } from "@/platform/remoteSettings";
import { useWorkflowSettings } from "@/platform/workflowSettings";
@@ -57,7 +57,7 @@ function ConnectDialog() {
if (await openRemoteProject(ssh, target)) closeConnectDialog();
else setBusy(false);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
if (!isCancelled(e)) setError(e instanceof Error ? e.message : String(e));
setBusy(false);
}
};
+21 -1
Переглянути файл
@@ -1,6 +1,14 @@
import { describe, expect, it } from "vitest";
import type { RemoteEntry } from "@/platform/ipc";
import { forgetRemote, parseRemoteLocation, recentRemotes, rememberRemote, remoteLocation } from "@/platform/remote";
import {
initializeRemoteTrustMigration,
takeLegacyRemoteTrust,
forgetRemote,
parseRemoteLocation,
recentRemotes,
rememberRemote,
remoteLocation,
} from "@/platform/remote";
import { browsableFolders, listedDirectory, remoteParent } from "./model";
const entry = (path: string, isDir = true): RemoteEntry => ({
@@ -57,3 +65,15 @@ describe("remote project locations", () => {
expect(recentRemotes().map((r) => r.path)).toEqual(["/q"]);
});
});
it("migrates only the original remote recents once, scoped to the saved host", () => {
localStorage.clear();
rememberRemote({ sshConfig: "a", path: "/old", host: "me@old", name: "old", openedAtMs: 1 });
initializeRemoteTrustMigration();
rememberRemote({ sshConfig: "a", path: "/new", host: "me@old", name: "new", openedAtMs: 2 });
initializeRemoteTrustMigration();
expect(takeLegacyRemoteTrust("a", "/new", "me@old")).toBe(false);
expect(takeLegacyRemoteTrust("a", "/old", "me@other")).toBe(false);
expect(takeLegacyRemoteTrust("a", "/old", "me@old")).toBe(true);
expect(takeLegacyRemoteTrust("a", "/old", "me@old")).toBe(false);
});
+3 -3
Переглянути файл
@@ -42,7 +42,7 @@ const labels = (items: Item[]): string[] =>
describe("run context menu group", () => {
beforeEach(() => {
useProject.setState({ project: { name: "app", root: ROOT, isGit: false, settings: {} } as never });
useProject.setState({ project: { name: "app", root: ROOT, trusted: true, isGit: false, settings: {} } as never });
useRun.setState({ configs: [npmTest], loaded: true });
});
afterEach(() => {
@@ -127,7 +127,7 @@ describe("menuGroupItems", () => {
describe("debug entry", () => {
beforeEach(() => {
useProject.setState({ project: { name: "app", root: ROOT, isGit: false, settings: {} } as never });
useProject.setState({ project: { name: "app", root: ROOT, trusted: true, isGit: false, settings: {} } as never });
useRun.setState({ configs: [], loaded: true });
});
afterEach(() => useProject.setState({ project: null }));
@@ -150,7 +150,7 @@ describe("run gutter popup", () => {
);
beforeEach(() => {
useProject.setState({ project: { name: "app", root: ROOT, isGit: false, settings: {} } as never });
useProject.setState({ project: { name: "app", root: ROOT, trusted: true, isGit: false, settings: {} } as never });
useRun.setState({ configs: [npmTest], loaded: true });
});
afterEach(() => {
+1 -1
Переглянути файл
@@ -516,7 +516,7 @@ export function activate(): () => void {
);
}),
useProject.subscribe((s, prev) => {
if (s.project?.root === prev.project?.root) return;
if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return;
resetRuns();
useStackTraces.setState({ tabs: [], dialog: null });
if (s.project) void loadConfigs();
+1
Переглянути файл
@@ -164,6 +164,7 @@ function writeSelected(root: string, id: string | null) {
}
export async function loadConfigs(): Promise<void> {
if (useProject.getState().project?.trusted === false) return;
const root = useProject.getState().project?.root;
if (!root) {
useRun.setState({ configs: [], loaded: false, selectedId: null });
+1 -1
Переглянути файл
@@ -49,7 +49,7 @@ let dispose: () => void;
beforeEach(() => {
localStorage.clear();
useProject.setState({
project: { root: "/services-debug", name: "app", isGit: false, settings: validateSettings({}) },
project: { root: "/services-debug", name: "app", isGit: false, settings: validateSettings({}) } as never,
});
useRun.setState({ configs: [config], sessions: [], loaded: true });
useDebug.setState({ sessions: {}, console: {}, active: null });
+1 -1
Переглянути файл
@@ -23,7 +23,7 @@ const config = (id: string, kind: string): RunConfig => ({
debugAdapter: null,
});
function project(root: string) {
useProject.setState({ project: { root, name: "app", isGit: false, settings: validateSettings({}) } });
useProject.setState({ project: { root, name: "app", trusted: true, isGit: false, settings: validateSettings({}) } });
}
let dispose: Array<() => void>;
const dockerRoots = [{ key: "docker:one", label: "Docker", icon: "file-docker" }];
+3 -2
Переглянути файл
@@ -67,13 +67,14 @@ async function hasPythonFiles(): Promise<boolean> {
function PythonStatus() {
const root = useProject((s) => s.project?.root);
const trusted = useProject((s) => s.project?.trusted !== false);
const configured = useProject((s) => s.settings?.languages?.python?.interpreter ?? "");
const status = usePopup((s) => s.status);
const open = usePopup((s) => s.open);
const anchor = useRef<HTMLButtonElement>(null);
useEffect(() => {
reset();
if (!root || !isTauri()) return;
if (!root || !trusted || !isTauri()) return;
let live = true;
let sequence = 0;
let detected: Promise<Sdk[]> | undefined;
@@ -121,7 +122,7 @@ function PythonStatus() {
off.forEach((dispose) => dispose());
reset();
};
}, [root, configured]);
}, [root, configured, trusted]);
// The widget only renders once its project's status is in the store, so the popup action
// sees the same state as the button without waiting for an effect to publish a flag.
if (!popupShown() || !status) return null;
+2 -1
Переглянути файл
@@ -14,6 +14,7 @@ import {
Kbd,
SearchField,
Spinner,
isPopupOpen,
listNavigate,
showContextMenu,
T,
@@ -263,7 +264,7 @@ export function ProjectsPage() {
aria-label="Search projects"
aria-controls={`${baseId}-list`}
aria-activedescendant={items.length ? optionId(current) : undefined}
autoFocus
autoFocus={!isPopupOpen()}
data-autofocus
onKeyDown={onKeyDown}
className={styles.search}
+2 -2
Переглянути файл
@@ -68,9 +68,9 @@ describe("session restore", () => {
it("remembers the open project and forgets it on explicit close", () => {
const stop = trackSession();
useProject.setState({ project: { root: "/w/a", name: "a", isGit: false, settings: {} } });
useProject.setState({ project: { root: "/w/a", name: "a", trusted: true, isGit: false, settings: {} } });
expect(lastSessionProject()).toBe("/w/a");
useProject.setState({ project: { root: "/w/b", name: "b", isGit: false, settings: {} } });
useProject.setState({ project: { root: "/w/b", name: "b", trusted: true, isGit: false, settings: {} } });
expect(lastSessionProject()).toBe("/w/b");
useProject.setState({ project: null });
expect(lastSessionProject()).toBeNull();
+3 -1
Переглянути файл
@@ -1,7 +1,7 @@
/** Recent projects: loading, filtering, opening and removal. */
import { create } from "zustand";
import { executeAction, getAction } from "@/platform/actions";
import { commands, IpcError, isTauri, type RecentProject } from "@/platform/ipc";
import { commands, IpcError, isCancelled, isTauri, type RecentProject } from "@/platform/ipc";
import { openProject, useProject } from "@/platform/project";
import { connectRemote, forgetRemote, parseRemoteLocation, recentRemotes, remoteLocation } from "@/platform/remote";
import { tx } from "@/platform/i18n";
@@ -113,6 +113,7 @@ export async function openRecent(project: Pick<RecentProject, "path" | "name">):
await openProject(project.path);
return true;
} catch (e) {
if (isCancelled(e)) return false;
const message = e instanceof Error ? e.message : String(e);
const missing = e instanceof IpcError ? e.kind === "notFound" : /not found|no such/i.test(message);
notifications().notify({
@@ -138,6 +139,7 @@ async function openRemote(
await connectRemote(remote.sshConfig, remote.path);
});
} catch (e) {
if (isCancelled(e)) return false;
notifications().notify({
level: "error",
title: tx`Cannot open ${project.name}`,
+2 -1
Переглянути файл
@@ -5,7 +5,7 @@
*/
import { getCurrentWindow } from "@tauri-apps/api/window";
import { create } from "zustand";
import { commands, isTauri, type Settings } from "@/platform/ipc";
import { commands, isCancelled, isTauri, type Settings } from "@/platform/ipc";
import { openProject, startupLaunchHandled, useProject } from "@/platform/project";
import { connectRemote, parseRemoteLocation, projectLocation } from "@/platform/remote";
import { notifications } from "@/workbench/contributions";
@@ -123,6 +123,7 @@ async function reopen(path: string) {
} catch (e) {
write(SESSION_KEY, null);
windowProject(null);
if (isCancelled(e)) return;
notifications().notify({
level: "warning",
title: `Cannot reopen ${name}`,