feat(trust): let users add trusted folders from settings (#1812)

* feat(trust): let users add trusted folders from settings

The trusted-locations check already matches projects by path prefix, but
the only way to populate it was the "Trust projects in…" checkbox on the
first-open dialog — a folder could never be trusted ahead of time.

Add a project_trust_add_location command that canonicalizes the picked
directory, rejects non-directories, and deduplicates, plus an "Add
folder…" button on the Project Trust settings page backed by the native
directory picker.

Fixes #1743

* fix(trust): translate the trusted-location additions into Chinese

The zh source-text catalog must cover every literal UI text; the three
new strings in the trusted-locations settings page were missing.
This commit is contained in:
arookieofc authored and GitHub committed 2026-11-06 22:08:10 +08:00
1 parent 954ad354a1
commit b54a745d6d
9 files changed
+115 -4

No files matched your search

+15
View File
@@ -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()?;
+23
View File
@@ -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() {
+7
View File
@@ -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<()> {
+1
View File
@@ -75,6 +75,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+1
View File
@@ -28,6 +28,7 @@ export const commands = {
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 })),
projectTrustAddLocation: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("project_trust_add_location", { path })),
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 })),
+23 -3
View File
@@ -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 (
<div className={styles.content}>
<p>
@@ -132,9 +144,17 @@ function TrustSettings() {
</Button>
</div>
))}
<h3>
<T>Trusted locations</T>
</h3>
<div className={styles.sectionHead}>
<h3>
<T>Trusted locations</T>
</h3>
<Button onClick={() => void addLocation()}>
<T>Add folder…</T>
</Button>
</div>
<p className={styles.hint}>
<T>Every project inside a trusted location is trusted without asking.</T>
</p>
{decisions?.locations.map((path) => (
<div className={styles.row} key={path}>
<span className={styles.path}>{path}</span>
@@ -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;
}
+30 -1
View File
@@ -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<typeof import("@/platform/tauriCore")>()),
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<typeof import("@/platform/ipc")>();
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(<Page />);
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) => {
+3
View File
@@ -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": "移除范围",