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

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

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

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

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

No files matched your search

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