diff --git a/web-admin/src/routes/[organization]/[project]/-/edit/+layout.svelte b/web-admin/src/routes/[organization]/[project]/-/edit/+layout.svelte
index cc67834718b4..8a6a5d7c521a 100644
--- a/web-admin/src/routes/[organization]/[project]/-/edit/+layout.svelte
+++ b/web-admin/src/routes/[organization]/[project]/-/edit/+layout.svelte
@@ -30,6 +30,7 @@
import { InfoIcon } from "lucide-svelte";
import { overlay } from "@rilldata/web-common/layout/overlay-store";
import BlockingOverlayContainer from "@rilldata/web-common/layout/BlockingOverlayContainer.svelte";
+ import { fileArtifacts } from "@rilldata/web-common/features/entity-management/file-artifacts.ts";
$: organization = $page.params.organization;
$: project = $page.params.project;
@@ -83,8 +84,12 @@
// keeps the UI in loading state while the backend transitions STOPPED → PENDING → RUNNING.
let starting = false;
+ // Wait for `primaryProjectQuery` too: the cloud readonly notice is gated on
+ // `hasPrimaryDeployment`, and it must be registered before `` renders
+ // any file editor (getReadonlyNotice reads the notice non-reactively).
$: isLoading =
$projectQuery.isPending ||
+ $primaryProjectQuery.isPending ||
starting ||
deploymentStatus === V1DeploymentStatus.DEPLOYMENT_STATUS_PENDING;
@@ -119,7 +124,18 @@
});
};
- setCloudReadonlyNotice(envEditDisabled);
+ // Only surface the env notice once the project has a primary deployment.
+ // Fail closed: env becomes editable only once we've positively confirmed the
+ // project has no primary deployment. A failed or otherwise inconclusive lookup
+ // keeps the notice set, so a published project never exposes editable `.env`
+ // files while the deployment state is unknown.
+ // `isLoading` blocks `` until `primaryProjectQuery` resolves, so this
+ // has run before any file editor reads the notice.
+ $: if (!$primaryProjectQuery.isPending) {
+ const envEditable = $primaryProjectQuery.isSuccess && !hasPrimaryDeployment;
+ setCloudReadonlyNotice(envEditable ? undefined : envEditDisabled);
+ fileArtifacts.recheckReadonlyStatus();
+ }
onDestroy(() => {
$editorRoutePrefix = "";
@@ -161,7 +177,7 @@
bind:starting
/>
{:else if isReady && deployment?.id && instanceId && runtimeHost && jwt}
- {#key `${runtimeHost}::${instanceId}`}
+ {#key `${runtimeHost}::${instanceId}::${hasPrimaryDeployment}`}
{#if !inProjectWelcomePage}
- {#if hasPrimaryDeployment}
- Manage environment variables in
-
- Settings →
-
- {:else}
- You can manage environment variables from settings page
- after the project has been published.
- {/if}
+ Manage environment variables in
+
+ Settings →
+
{/snippet}
diff --git a/web-common/src/features/entity-management/actions/protected-files.spec.ts b/web-common/src/features/entity-management/actions/protected-files.spec.ts
index c4ea75d5a767..3101748f435e 100644
--- a/web-common/src/features/entity-management/actions/protected-files.spec.ts
+++ b/web-common/src/features/entity-management/actions/protected-files.spec.ts
@@ -1,6 +1,16 @@
import { beforeAll, afterAll, describe, expect, it } from "vitest";
+import type { Snippet } from "svelte";
import { setRuntimeEditEnvironment } from "../edit-environment.ts";
-import { isPinned, isProtectedDirectory, isManaged } from "./protected-files";
+import {
+ isPinned,
+ isProtectedDirectory,
+ isManaged,
+ setCloudReadonlyNotice,
+} from "./protected-files";
+
+// A published project registers a readonly notice; the content is irrelevant to
+// these predicates, so a placeholder snippet stands in for it.
+const notice = (() => {}) as unknown as Snippet;
describe("isPinned", () => {
it("matches /rill.yaml exactly", () => {
@@ -35,10 +45,13 @@ describe("isReadonly on local", () => {
describe("isReadonly on cloud", () => {
beforeAll(() => {
setRuntimeEditEnvironment("cloud");
+ // Simulate a published project, where env editing is managed from settings.
+ setCloudReadonlyNotice(notice);
});
afterAll(() => {
setRuntimeEditEnvironment("local");
+ setCloudReadonlyNotice(undefined);
});
it("locks /.env at the project root", () => {
@@ -70,6 +83,25 @@ describe("isReadonly on cloud", () => {
});
});
+describe("isReadonly on cloud without a readonly notice", () => {
+ beforeAll(() => {
+ setRuntimeEditEnvironment("cloud");
+ // No notice registered: the project has no primary deployment (unpublished),
+ // or the deployment lookup has not yet resolved.
+ setCloudReadonlyNotice(undefined);
+ });
+
+ afterAll(() => {
+ setRuntimeEditEnvironment("local");
+ });
+
+ it("does not lock .env files", () => {
+ expect(isManaged("/.env")).toBe(false);
+ expect(isManaged("/foo/.env")).toBe(false);
+ expect(isManaged("/.dev.env")).toBe(false);
+ });
+});
+
describe("isProtectedDirectory", () => {
it("matches /tmp exactly", () => {
expect(isProtectedDirectory("/tmp")).toBe(true);
diff --git a/web-common/src/features/entity-management/actions/protected-files.ts b/web-common/src/features/entity-management/actions/protected-files.ts
index 598b64122a26..e6cead250362 100644
--- a/web-common/src/features/entity-management/actions/protected-files.ts
+++ b/web-common/src/features/entity-management/actions/protected-files.ts
@@ -40,7 +40,7 @@ export function setCloudReadonlyNotice(notice: Snippet | undefined) {
export function isManaged(path: string): boolean {
if (isCloudRuntimeEditEnvironment()) {
- return CLOUD_READONLY.some((m) => m(path));
+ return CLOUD_READONLY.some((m) => m(path)) && !!cloudReadonlyNotice;
}
return false;
}
diff --git a/web-common/src/features/entity-management/file-artifact.ts b/web-common/src/features/entity-management/file-artifact.ts
index fba5e3c001d7..d4f6f0222c89 100644
--- a/web-common/src/features/entity-management/file-artifact.ts
+++ b/web-common/src/features/entity-management/file-artifact.ts
@@ -91,10 +91,10 @@ export class FileArtifact {
readonly autoSave: Writable;
// Path is locked: file can't be renamed or deleted, and other files can't
// be renamed onto this path.
- readonly pinned: boolean;
+ pinned: boolean;
// Content is managed outside of editors.
// Currently **/.*.env files are managed from project settings page on cloud editor
- readonly managed: boolean;
+ managed: boolean;
readonly snapshot: Writable<{
scroll?: ReturnType;
selection?: EditorSelection;
@@ -412,6 +412,11 @@ export class FileArtifact {
);
}
+ recheckReadonlyStatus() {
+ this.pinned = isPinned(this.path);
+ this.managed = isManaged(this.path);
+ }
+
private updateResourceNameIfChanged(resource: V1Resource) {
const isSubResource = !!resource.component?.spec?.definedInCanvas;
if (isSubResource) return;
diff --git a/web-common/src/features/entity-management/file-artifacts.ts b/web-common/src/features/entity-management/file-artifacts.ts
index 7488bcdec863..7c1f97670bc2 100644
--- a/web-common/src/features/entity-management/file-artifacts.ts
+++ b/web-common/src/features/entity-management/file-artifacts.ts
@@ -187,6 +187,10 @@ export class FileArtifacts {
const fileParseErrors = fileArtifact.fetchParserErrors(queryClient);
return fileParseErrors[0]?.message ?? null;
}
+
+ recheckReadonlyStatus() {
+ this.artifacts.forEach((artifact) => artifact.recheckReadonlyStatus());
+ }
}
export const fileArtifacts = new FileArtifacts();