дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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
Цей коміт міститься в:
1 джерело
cf51b57924
коміт
2c9a6470ff
103 files changed
+1996
-168
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" }];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in new issue
Заблокувати користувача