дзеркало
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
+20
-1
@@ -11,6 +11,10 @@ export const commands = {
|
||||
recentProjects: () => __TAURI_INVOKE<RecentProject[]>("recent_projects"),
|
||||
removeRecentProject: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remove_recent_project", { path })),
|
||||
openProject: (path: string) => typedError<ProjectInfo_Serialize, IdeError>(__TAURI_INVOKE("open_project", { path })),
|
||||
projectTrustStatus: (path: string) => typedError<TrustStatus, IdeError>(__TAURI_INVOKE("project_trust_status", { path })),
|
||||
projectTrustList: () => typedError<TrustDecisions, IdeError>(__TAURI_INVOKE("project_trust_list")),
|
||||
projectTrustSet: (path: string, trusted: boolean, folder: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("project_trust_set", { path, trusted, folder })),
|
||||
projectTrustRemoveLocation: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("project_trust_remove_location", { path })),
|
||||
workspaceCreate: (path: string, name: string, projects: string[]) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("workspace_create", { path, name, projects })),
|
||||
workspaceAttach: (path: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("workspace_attach", { path })),
|
||||
workspaceUpdate: (name: string, projects: string[]) => typedError<ProjectInfo_Serialize, IdeError>(__TAURI_INVOKE("workspace_update", { name, projects })),
|
||||
@@ -28,6 +32,7 @@ export const commands = {
|
||||
lightEditOpen: (path: string) => typedError<Settings_Serialize, IdeError>(__TAURI_INVOKE("light_edit_open", { path })),
|
||||
/** The project open in this window (a settings window: its parent's), if any. */
|
||||
projectCurrent: () => __TAURI_INVOKE<{
|
||||
trusted: boolean,
|
||||
root: IpcPath,
|
||||
name: string,
|
||||
isGit: boolean,
|
||||
@@ -3463,7 +3468,7 @@ export type IdeError = {
|
||||
|
||||
export type IdeEvent =
|
||||
/** Project window membership or selection changed. */
|
||||
{ type: "windowGroupsChanged" } |
|
||||
{ type: "windowGroupsChanged" } | { type: "projectTrustChanged"; root: IpcPath; trusted: boolean } |
|
||||
/** Files or directories changed on disk (after debouncing, ignore rules applied). */
|
||||
{ type: "fsChanged"; created: IpcPath[]; modified: IpcPath[]; removed: IpcPath[] } |
|
||||
/** Excluded or ignored folders whose contents changed; they stay out of `FsChanged`. */
|
||||
@@ -4770,6 +4775,7 @@ export type ProjectFileSet =
|
||||
export type ProjectInfo = ProjectInfo_Serialize | ProjectInfo_Deserialize;
|
||||
|
||||
export type ProjectInfo_Deserialize = {
|
||||
trusted: boolean,
|
||||
root: IpcPath,
|
||||
name: string,
|
||||
isGit: boolean,
|
||||
@@ -4778,6 +4784,7 @@ export type ProjectInfo_Deserialize = {
|
||||
};
|
||||
|
||||
export type ProjectInfo_Serialize = {
|
||||
trusted: boolean,
|
||||
root: IpcPath,
|
||||
name: string,
|
||||
isGit: boolean,
|
||||
@@ -6111,6 +6118,18 @@ export type TransferReport = {
|
||||
errors: TransferError[],
|
||||
};
|
||||
|
||||
export type TrustDecisions = {
|
||||
projects: string[],
|
||||
locations: string[],
|
||||
restricted: string[],
|
||||
};
|
||||
|
||||
export type TrustStatus = {
|
||||
root: IpcPath,
|
||||
trusted: boolean,
|
||||
decided: boolean,
|
||||
};
|
||||
|
||||
export type TypeScriptSettings = {
|
||||
/**
|
||||
* `bundled` (the server's own TypeScript), `workspace` (`node_modules/typescript`),
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("EditorController", () => {
|
||||
it("carries the plain project's editor tabs into its automatic workspace", async () => {
|
||||
const settings = {};
|
||||
useProject.setState({
|
||||
project: { root: "/attach-a", name: "A", isGit: false, settings },
|
||||
project: { root: "/attach-a", name: "A", trusted: true, isGit: false, settings },
|
||||
settings,
|
||||
lightEdit: false,
|
||||
});
|
||||
@@ -185,6 +185,7 @@ describe("EditorController", () => {
|
||||
project: {
|
||||
root: "/attach-workspace",
|
||||
name: "A",
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
settings,
|
||||
workspace: { version: 1, name: "A", projects: ["/attach-a", "/attach-b"] },
|
||||
@@ -230,7 +231,7 @@ describe("EditorController", () => {
|
||||
|
||||
it("builds IDEA's editor tab menu", async () => {
|
||||
useProject.setState({
|
||||
project: { name: "p", root: "/p", isGit: true, settings: {} } as never,
|
||||
project: { name: "p", root: "/p", trusted: true, isGit: true, settings: {} } as never,
|
||||
settings: {} as never,
|
||||
});
|
||||
const ids = [
|
||||
|
||||
@@ -82,6 +82,7 @@ it("shows the member and its directories in workspace breadcrumbs", async () =>
|
||||
root: "/workspace",
|
||||
name: "Workspace",
|
||||
settings: {},
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
workspace: { version: 1, name: "Workspace", projects: ["/a", "/b"] },
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@ function deps(files: string[], roots = ROOTS) {
|
||||
|
||||
beforeEach(() => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
{
|
||||
"Project opening cancelled": "已取消打开项目",
|
||||
"Project is in Restricted Mode": "项目处于受限模式",
|
||||
"Project is in Restricted Mode. Trust the project to enable execution.": "项目处于受限模式。信任项目后可启用执行功能。",
|
||||
"Trust and Open Project?": "信任并打开项目?",
|
||||
"Preview in Restricted Mode": "以受限模式预览",
|
||||
"Don't Open": "不打开",
|
||||
"If you don't trust the source, preview the project in Restricted Mode, where project commands and tools can't run.": "如果不信任来源,请以受限模式预览项目,此模式下不会运行项目命令和工具。",
|
||||
"Loading, running or building a project may execute potentially malicious code from its build scripts.": "加载、运行或构建项目可能会执行其构建脚本中的恶意代码。",
|
||||
"Trust projects in": "信任此文件夹中的项目:",
|
||||
"Restricted Mode: project commands and tools are disabled.": "受限模式:项目命令和工具已禁用。",
|
||||
"Trust Project": "信任项目",
|
||||
"Trust decisions are saved in your user data and take effect immediately.": "信任决定保存在您的用户数据中,并立即生效。",
|
||||
"Trusted projects": "受信任的项目",
|
||||
"Trusted locations": "受信任的位置",
|
||||
"Revoke Trust": "撤销信任",
|
||||
"Project Trust": "项目信任",
|
||||
"Security": "安全",
|
||||
"Remove Trusted Location": "移除受信任的位置",
|
||||
"Cannot determine the module for main class '{value}'": "无法确定主类 '{value}' 所属的模块",
|
||||
"Attach": "附加",
|
||||
"Remove from Workspace": "从工作区移除",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** The project open in this window. */
|
||||
import { confirmProjectTrust } from "./projectTrust";
|
||||
import { create } from "zustand";
|
||||
import { call, commands, type ProjectInfo, type Settings } from "./ipc";
|
||||
import { isVirtualUri, virtualContainer, virtualName, virtualParent } from "./virtualDocs";
|
||||
@@ -48,6 +49,7 @@ export function startupLaunchHandled(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function openProject(path: string): Promise<ProjectInfo> {
|
||||
await confirmProjectTrust(path);
|
||||
useProject.setState({ opening: true });
|
||||
try {
|
||||
const project = await call(commands.openProject(path));
|
||||
|
||||
@@ -86,7 +86,7 @@ describe("effective roots", () => {
|
||||
describe("project roots store", () => {
|
||||
beforeEach(() => {
|
||||
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: { projectStructure: { excluded: ["out"] } } as never,
|
||||
});
|
||||
useProjectRoots.setState({ root: "/p", detected: DETECTED });
|
||||
@@ -122,6 +122,7 @@ it("lists workspace members as scopes and admits their files without admitting w
|
||||
root: "/workspace",
|
||||
name: "Workspace",
|
||||
settings: {},
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
workspace: { version: 1, name: "Workspace", projects: ["/z", "/a"] },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IpcError } from "./ipc";
|
||||
|
||||
let prompt: ((path: string) => Promise<boolean>) | null = null;
|
||||
|
||||
export function provideProjectTrustPrompt(value: (path: string) => Promise<boolean>): () => void {
|
||||
prompt = value;
|
||||
return () => {
|
||||
if (prompt === value) prompt = null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function confirmProjectTrust(path: string): Promise<void> {
|
||||
if (prompt && !(await prompt(path))) {
|
||||
throw new IpcError({ kind: "cancelled", message: "Project opening cancelled" });
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { confirmProjectTrust } from "./projectTrust";
|
||||
/**
|
||||
* Remote development: in a window whose project lives on another machine, the
|
||||
* commands that machine's `openidea-server` answers are routed to it (through
|
||||
@@ -138,6 +139,19 @@ export async function connectRemote(sshConfig: string, path: string) {
|
||||
try {
|
||||
const r = await call(commands.remoteConnect(sshConfig, path, onProgress));
|
||||
enableRemote({ sshConfig: r.sshConfig, root: r.root, host: r.host, commands: r.commands });
|
||||
if (takeLegacyRemoteTrust(r.sshConfig, path, r.host)) {
|
||||
const status = await call(commands.projectTrustStatus(r.root));
|
||||
if (!status.decided) await call(commands.projectTrustSet(r.root, true, false));
|
||||
}
|
||||
try {
|
||||
await confirmProjectTrust(r.root);
|
||||
} catch (error) {
|
||||
await commands.closeProject();
|
||||
disableRemote();
|
||||
throw error;
|
||||
}
|
||||
const current = await commands.projectCurrent();
|
||||
if (current) r.project = current;
|
||||
r.project.settings = await clientSettings(r.project.settings);
|
||||
useProject.setState({ project: r.project, settings: r.project.settings });
|
||||
rememberRemote({
|
||||
@@ -197,6 +211,32 @@ export interface RecentRemote {
|
||||
|
||||
const RECENT_KEY = "openidea.remote.recent";
|
||||
const RECENT_MAX = 20;
|
||||
const TRUST_MIGRATION_KEY = "openidea.remote.trustMigration";
|
||||
|
||||
/** Snapshot existing user-owned recents before any new connection is remembered. */
|
||||
export function initializeRemoteTrustMigration() {
|
||||
try {
|
||||
if (localStorage.getItem(TRUST_MIGRATION_KEY) === null) {
|
||||
localStorage.setItem(TRUST_MIGRATION_KEY, JSON.stringify(recentRemotes()));
|
||||
}
|
||||
} catch {
|
||||
/* Unavailable storage never grants trust. */
|
||||
}
|
||||
}
|
||||
|
||||
export function takeLegacyRemoteTrust(sshConfig: string, path: string, host: string): boolean {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(TRUST_MIGRATION_KEY) ?? "[]");
|
||||
if (!Array.isArray(value)) return false;
|
||||
const matches = (entry: RecentRemote) =>
|
||||
entry?.sshConfig === sshConfig && entry.path === path && entry.host === host;
|
||||
const found = value.some(matches);
|
||||
localStorage.setItem(TRUST_MIGRATION_KEY, JSON.stringify(value.filter((entry) => !matches(entry))));
|
||||
return found;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function recentRemotes(): RecentRemote[] {
|
||||
try {
|
||||
|
||||
@@ -80,7 +80,9 @@ describe("remote client settings", () => {
|
||||
commands: [...served],
|
||||
project: { root: "/remote", name: "remote", settings: remote },
|
||||
}
|
||||
: local,
|
||||
: cmd === "remote_invoke"
|
||||
? "null"
|
||||
: local,
|
||||
);
|
||||
await connectRemote("test", "/remote");
|
||||
expect(useProject.getState().settings).toMatchObject({ keymap: "my-map", editor: { tabSize: 2, fontSize: 19 } });
|
||||
|
||||
@@ -15,6 +15,9 @@ html {
|
||||
--fw-code-bold: 700;
|
||||
}
|
||||
:root {
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
/* surfaces */
|
||||
--bg-app: #191a1c;
|
||||
--bg-elev: #212326;
|
||||
|
||||
@@ -231,6 +231,11 @@
|
||||
.messageIcon {
|
||||
flex: none;
|
||||
}
|
||||
.messageAppIcon {
|
||||
flex: none;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
.messageShape {
|
||||
fill: var(--message-icon-color);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Icon } from "./icons";
|
||||
import { FOCUSABLE, onWindowDeactivate, openLayer, trapTab } from "./popupState";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { selectableText } from "./selectableText";
|
||||
import appIcon from "../../src-tauri/icons/128x128.png";
|
||||
import styles from "./Dialog.module.css";
|
||||
import { isImeKey } from "@/platform/ime";
|
||||
|
||||
@@ -135,7 +136,7 @@ export interface DialogProps {
|
||||
* A Messages alert (IDEA `MessageDialogBuilder`): no title bar, this icon on the
|
||||
* left, the title in bold above the content, `footerStart` under the text.
|
||||
*/
|
||||
icon?: MessageIconKind;
|
||||
icon?: MessageIconKind | "app";
|
||||
}
|
||||
|
||||
export type MessageIconKind = "question" | "info" | "warning" | "error";
|
||||
@@ -398,7 +399,11 @@ function DialogInner({
|
||||
)}
|
||||
{icon ? (
|
||||
<div className={styles.message} data-dialog-body="" onPointerDown={drag}>
|
||||
<MessageIcon kind={icon} />
|
||||
{icon === "app" ? (
|
||||
<img className={styles.messageAppIcon} src={appIcon} alt="" draggable={false} />
|
||||
) : (
|
||||
<MessageIcon kind={icon} />
|
||||
)}
|
||||
<div className={styles.messageText}>
|
||||
{title != null && <div className={clsx(styles.messageTitle, selectableText)}>{title}</div>}
|
||||
<div className={clsx(styles.messageBody, selectableText)}>
|
||||
|
||||
@@ -328,7 +328,11 @@ describe("workbench shell", () => {
|
||||
useProject.setState({ project: null });
|
||||
const { unmount } = render(<Workbench />);
|
||||
expect(screen.getByRole("button", { name: /Open/ })).toBeInTheDocument();
|
||||
act(() => useProject.setState({ project: { root: "/shell", name: "shell-demo", isGit: false, settings: {} } }));
|
||||
act(() =>
|
||||
useProject.setState({
|
||||
project: { root: "/shell", name: "shell-demo", trusted: true, isGit: false, settings: {} },
|
||||
}),
|
||||
);
|
||||
loadToolWindowLayout("/shell");
|
||||
expect(await screen.findByText("shell-demo")).toBeInTheDocument();
|
||||
expect(screen.getByText("project view")).toBeInTheDocument();
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useApplyTheme } from "./theme";
|
||||
import { ToolWindowLayout } from "./toolwindows/ToolWindows";
|
||||
import { Welcome } from "./Welcome";
|
||||
import { WindowResizeHandles } from "./WindowResizeHandles";
|
||||
import { useContributions } from "./contributions";
|
||||
import styles from "./Workbench.module.css";
|
||||
|
||||
/** LightEdit: editor tabs only; no tool windows, toolbar widgets or project status items. */
|
||||
@@ -38,6 +39,7 @@ function LightEditFrame() {
|
||||
|
||||
export function Workbench() {
|
||||
useApplyTheme();
|
||||
const banners = useContributions((s) => s.workbenchBanners);
|
||||
const project = useProject((s) => s.project);
|
||||
const lightEdit = useProject((s) => s.lightEdit);
|
||||
const root = project?.root ?? null;
|
||||
@@ -54,6 +56,9 @@ export function Workbench() {
|
||||
{/* Distraction Free Mode keeps the bar for the traffic lights and window dragging only. */}
|
||||
<TitleBar bare={distractionFree} />
|
||||
{!distractionFree && <WindowGroupTabs />}
|
||||
{[...banners.values()].map(({ id, component: Banner }) => (
|
||||
<Banner key={id} />
|
||||
))}
|
||||
<ToolWindowLayout key={root} editor={<EditorArea />} />
|
||||
{!distractionFree && <StatusBar />}
|
||||
</>
|
||||
|
||||
@@ -264,7 +264,13 @@ export interface EditorInput {
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface WorkbenchBannerContribution {
|
||||
id: string;
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
interface Contributions {
|
||||
workbenchBanners: Map<string, WorkbenchBannerContribution>;
|
||||
toolWindows: Map<string, ToolWindowContribution>;
|
||||
statusItems: Map<string, StatusItemContribution>;
|
||||
overlays: Map<string, OverlayContribution>;
|
||||
@@ -274,6 +280,7 @@ interface Contributions {
|
||||
}
|
||||
|
||||
export const useContributions = create<Contributions>(() => ({
|
||||
workbenchBanners: new Map(),
|
||||
toolWindows: new Map(),
|
||||
statusItems: new Map(),
|
||||
overlays: new Map(),
|
||||
@@ -301,6 +308,7 @@ function register<K extends keyof Contributions>(
|
||||
});
|
||||
}
|
||||
|
||||
export const registerWorkbenchBanner = (c: WorkbenchBannerContribution) => register("workbenchBanners", c.id, c);
|
||||
export const registerToolWindow = (c: ToolWindowContribution) => register("toolWindows", c.id, c);
|
||||
export const registerStatusItem = (c: StatusItemContribution) => register("statusItems", c.id, c);
|
||||
export const registerOverlay = (c: OverlayContribution) => register("overlays", c.id, c);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useProject } from "@/platform/project";
|
||||
export function registerDevSamples(): () => void {
|
||||
if (!useProject.getState().project) {
|
||||
useProject.setState({
|
||||
project: { root: "/dev/acme-api", name: "acme-api", isGit: true, settings: {} as never },
|
||||
project: { root: "/dev/acme-api", name: "acme-api", trusted: true, isGit: true, settings: {} as never },
|
||||
});
|
||||
}
|
||||
return () => {};
|
||||
|
||||
@@ -201,7 +201,11 @@ describe("distraction free shell", () => {
|
||||
disposers.push(
|
||||
registerStatusItem({ id: "t.status", align: "left", priority: 0, component: () => <span>status-x</span> }),
|
||||
);
|
||||
act(() => useProject.setState({ project: { root: "/work/df", name: "df-demo", isGit: false, settings: {} } }));
|
||||
act(() =>
|
||||
useProject.setState({
|
||||
project: { root: "/work/df", name: "df-demo", trusted: true, isGit: false, settings: {} },
|
||||
}),
|
||||
);
|
||||
const { unmount } = render(<Workbench />);
|
||||
expect(await screen.findByText("df-demo")).toBeInTheDocument();
|
||||
expect(screen.getByText("status-x")).toBeInTheDocument();
|
||||
|
||||
@@ -18,7 +18,7 @@ vi.mock("@/platform/ipc", () => ({
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useProject.setState({
|
||||
project: { root: "/a", name: "A", settings: {}, isGit: false },
|
||||
project: { root: "/a", name: "A", settings: {}, trusted: true, isGit: false },
|
||||
settings: {},
|
||||
lightEdit: false,
|
||||
});
|
||||
|
||||
Reference in new issue
Заблокувати користувача