diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 8ab29e669..a1ffe89a4 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -74,6 +74,21 @@ impl TrustStore { Ok(()) } + pub fn add_location(&self, path: &Path) -> IdeResult<()> { + let root = dunce::canonicalize(path)?; + if !root.is_dir() { + return Err(IdeError::invalid("trusted location must be an existing directory")); + } + let mut state = self.state.lock(); + let mut next = state.clone()?; + if !next.locations.contains(&root) { + next.locations.push(root); + persist(&self.file, &next)?; + *state = Ok(next); + } + Ok(()) + } + pub fn remove_location(&self, path: &Path) -> IdeResult<()> { let mut state = self.state.lock(); let mut next = state.clone()?; diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index b50d5abce..74766decd 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -349,6 +349,29 @@ fn trusted_locations_use_canonical_directory_boundaries() { assert_eq!(trust.status(&alias).unwrap().root.as_ref(), dunce::canonicalize(&other).unwrap()); } +#[test] +fn add_location_trusts_every_project_under_it() { + let tmp = tempfile::tempdir().unwrap(); + let location = tmp.path().join("projects"); + let inside = location.join("nested/deep/app"); + let outside = tmp.path().join("projects-other/app"); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let file = tmp.path().join("not-a-dir"); + std::fs::write(&file, "x").unwrap(); + let trust = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data")); + trust.add_location(&location).unwrap(); + assert!(trust.trusted(&inside).unwrap()); + assert!(!trust.trusted(&outside).unwrap()); + assert_eq!(trust.decisions().unwrap().locations.len(), 1); + trust.add_location(&location).unwrap(); + assert_eq!(trust.decisions().unwrap().locations.len(), 1, "re-adding is a no-op"); + assert!(trust.add_location(&file).is_err(), "a file is not a location"); + assert!(trust.add_location(&tmp.path().join("missing")).is_err(), "a missing path is not a location"); + let reloaded = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data")); + assert!(reloaded.trusted(&inside).unwrap()); +} + #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn restricted_git_views_ignore_project_executables_and_mutations_cannot_run_hooks() { diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index dcd00e7cf..a817273da 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -314,6 +314,13 @@ pub async fn project_trust_set( Ok(()) } +#[tauri::command] +#[specta::specta] +pub async fn project_trust_add_location(state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> { + state.trust.add_location(&path)?; + state.host.refresh_project_trust().await +} + #[tauri::command] #[specta::specta] pub async fn project_trust_remove_location(state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ccf96577e..39b86e93b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -75,6 +75,7 @@ pub fn builder() -> tauri_specta::Builder { app::project_trust_status, app::project_trust_list, app::project_trust_set, + app::project_trust_add_location, app::project_trust_remove_location, workspace::workspace_create, workspace::workspace_attach, diff --git a/src/bindings.ts b/src/bindings.ts index 373914328..59d4b663e 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -28,6 +28,7 @@ export const commands = { projectTrustStatus: (path: string) => typedError(__TAURI_INVOKE("project_trust_status", { path })), projectTrustList: () => typedError(__TAURI_INVOKE("project_trust_list")), projectTrustSet: (path: string, trusted: boolean, folder: boolean) => typedError(__TAURI_INVOKE("project_trust_set", { path, trusted, folder })), + projectTrustAddLocation: (path: string) => typedError(__TAURI_INVOKE("project_trust_add_location", { path })), projectTrustRemoveLocation: (path: string) => typedError(__TAURI_INVOKE("project_trust_remove_location", { path })), workspaceCreate: (path: string, name: string, projects: string[]) => typedError(__TAURI_INVOKE("workspace_create", { path, name, projects })), workspaceAttach: (path: string) => typedError(__TAURI_INVOKE("workspace_attach", { path })), diff --git a/src/features/project-trust/index.tsx b/src/features/project-trust/index.tsx index eac48b77a..d7d1f8773 100644 --- a/src/features/project-trust/index.tsx +++ b/src/features/project-trust/index.tsx @@ -115,6 +115,18 @@ function TrustSettings() { setError(String(e)); } }; + const addLocation = async () => { + try { + const { open } = await import("@tauri-apps/plugin-dialog"); + const picked = await open({ directory: true, multiple: false, title: "Add Trusted Location" }); + const path = Array.isArray(picked) ? picked[0] : picked; + if (!path) return; + await call(commands.projectTrustAddLocation(path)); + await refresh(); + } catch (e) { + setError(String(e)); + } + }; return (

@@ -132,9 +144,17 @@ function TrustSettings() {

))} -

- Trusted locations -

+
+

+ Trusted locations +

+ +
+

+ Every project inside a trusted location is trusted without asking. +

{decisions?.locations.map((path) => (
{path} diff --git a/src/features/project-trust/trust.module.css b/src/features/project-trust/trust.module.css index 5e419c0d2..88fef2ba5 100644 --- a/src/features/project-trust/trust.module.css +++ b/src/features/project-trust/trust.module.css @@ -52,6 +52,18 @@ justify-content: space-between; gap: var(--space-3); } +.sectionHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} +.sectionHead h3 { + margin: 0; +} +.hint { + color: var(--fg-secondary); +} .path { overflow-wrap: anywhere; } diff --git a/src/features/project-trust/trust.test.tsx b/src/features/project-trust/trust.test.tsx index 703cda7c2..75756d2f6 100644 --- a/src/features/project-trust/trust.test.tsx +++ b/src/features/project-trust/trust.test.tsx @@ -6,7 +6,7 @@ import { emitLocalIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { invokeLocal } from "@/platform/tauriCore"; import { OverlayHost } from "@/workbench/OverlayHost"; -import { useContributions } from "@/workbench/contributions"; +import { useContributions, useSettingsPages } from "@/workbench/contributions"; import { activate } from "./index"; const status = vi.fn(); @@ -15,6 +15,10 @@ vi.mock("@/platform/tauriCore", async (original) => ({ ...(await original()), invokeLocal: vi.fn(), })); +const addLocation = vi.fn(); +const listDecisions = vi.fn(); +const pickFolder = vi.fn(); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: pickFolder })); vi.mock("@/platform/ipc", async (original) => { const real = await original(); return { @@ -23,6 +27,8 @@ vi.mock("@/platform/ipc", async (original) => { ...real.commands, projectTrustStatus: (path: string) => status(path), projectTrustSet: (...args: unknown[]) => decide(...args), + projectTrustList: () => listDecisions(), + projectTrustAddLocation: (...args: unknown[]) => addLocation(...args), projectCurrent: async () => null, }, }; @@ -34,6 +40,9 @@ beforeEach(() => { .mockReset() .mockImplementation(async (root: string) => ({ status: "ok", data: { root, trusted: false, decided: false } })); decide.mockReset().mockResolvedValue({ status: "ok", data: null }); + addLocation.mockReset().mockResolvedValue({ status: "ok", data: null }); + listDecisions.mockReset().mockResolvedValue({ status: "ok", data: { projects: [], locations: [], restricted: [] } }); + pickFolder.mockReset().mockResolvedValue(null); useProject.setState({ project: null }); dispose = activate(); }); @@ -88,6 +97,26 @@ it("shows and trusts the canonical folder when opening through a symlink", async expect(decide).toHaveBeenCalledWith("/real/projects/new", true, true); }); +it("adds a picked folder as a trusted location from settings", async () => { + const Page = useSettingsPages.getState().pages.get("projectTrust")!.component!; + pickFolder.mockResolvedValue("/projects"); + addLocation.mockImplementation(async () => { + listDecisions.mockResolvedValue({ + status: "ok", + data: { projects: [], locations: ["/projects"], restricted: [] }, + }); + return { status: "ok", data: null }; + }); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Add folder…" })); + await screen.findByText("/projects"); + expect(addLocation).toHaveBeenCalledWith("/projects"); + pickFolder.mockResolvedValue(null); + fireEvent.click(screen.getByRole("button", { name: "Add folder…" })); + await act(async () => {}); + expect(addLocation).toHaveBeenCalledTimes(1); +}); + it.each(["Trust Project", "Preview in Restricted Mode"])( "keeps a local opening's trust decision on the client: %s", async (button) => { diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index b9e5bffe2..b81359cd4 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -232,6 +232,8 @@ "Activate virtualenv": "激活 virtualenv", "Add": "添加", "Add Column": "添加列", + "Add Trusted Location": "添加受信任的位置", + "Add folder…": "添加文件夹…", "Add Field Watchpoint": "添加字段监视点", "Add File to Git": "将文件添加到 Git", "Add GitHub Pattern": "添加 GitHub 模式", @@ -3155,6 +3157,7 @@ "(empty line)": "(空行)", "Name cannot be empty": "名称不能为空", "Every scope needs a name": "每个范围都需要名称", + "Every project inside a trusted location is trusted without asking.": "受信任的位置内的每个项目都会被信任,不再询问。", "Scopes": "范围", "Add Scope": "添加范围", "Remove Scope": "移除范围",