Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 58 additions & 7 deletions apps/web/e2e/browser-lock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,18 +327,13 @@ test("lock broadcast in one tab locks another open tab", async ({
await context.close();
});

test("shell loading state is text-first, not the large six-dot grip", async ({
page,
}) => {
test("projects loading state is the legacy amber grip", async ({ page }) => {
await mockApi(page, { userDelayMs: 1_000 });
await seedBrowserState(page, Date.now());

await page.goto(`${APP}/app/project`, { waitUntil: "domcontentloaded" });

await expect(
page.getByRole("status").filter({ hasText: "Loading" }),
).toBeVisible();
await expect(page.locator('svg[aria-label="Loading"]')).toHaveCount(0);
await expect(page.locator('svg[aria-label="Loading"]')).toBeVisible();
});

test("unlock after lock cleanup does not leave project shell stuck loading", async ({
Expand Down Expand Up @@ -446,3 +441,59 @@ test("unsaved edits survive tab switches and project switches go through the gua
await expect(page).toHaveURL(/project-two$/);
await expect(guard).toHaveCount(0);
});

test("saving does not flag the project as updated elsewhere", async ({
page,
}) => {
const passphrase = "correct horse battery staple";
const fixtures = await createUnlockedFixtures(page, passphrase);
await mockApi(page, { user: fixtures.user, projects: [fixtures.project] });
// The backend echoes every save to all subscribers, the saver included,
// and the echo can arrive before the PATCH response.
let echo: string | null = null;
let echoed = () => {};
const echoDelivered = new Promise<void>((resolve) => (echoed = resolve));
await page.route("**/projects/*/events", async (route) => {
if (!echo) return route.fulfill({ status: 204, body: "" });
await route.fulfill({
headers: { "content-type": "text/event-stream" },
body: `data: ${JSON.stringify({ newEncryptedSecrets: echo })}\n\n`,
});
echoed();
});
await page.route("**/projects/project-eval", async (route) => {
if (route.request().method() !== "PATCH") return route.fallback();
echo = route.request().postDataJSON().encryptedSecrets;
await echoDelivered;
await new Promise((resolve) => setTimeout(resolve, 300));
await route.fulfill({ json: {} });
});
await seedAuthOnly(page);

await page.goto(`${APP}/app/project/project-eval`, {
waitUntil: "domcontentloaded",
});
await page.getByRole("textbox", { name: "Passphrase" }).fill(passphrase);
await page.getByRole("button", { name: "Unlock" }).click();

const editor = page.locator(".monaco-editor .view-lines");
await editor.click();
await page.keyboard.press("ControlOrMeta+End");
await page.keyboard.press("Enter");
await page.keyboard.type('SAVED="1"');
await page.getByRole("button", { name: "Save", exact: true }).click();

await expect(
page.getByRole("button", { name: "Saved", exact: true }),
).toBeVisible();
await expect(
page.getByText("This project was updated elsewhere"),
).toHaveCount(0);

await editor.click();
await page.keyboard.press("ControlOrMeta+End");
await page.keyboard.type("2");
await expect(
page.getByRole("button", { name: "Save", exact: true }),
).toBeEnabled();
});
4 changes: 2 additions & 2 deletions apps/web/src/lib/projects/ui/DiffEditor.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import loader from "@monaco-editor/loader";
import { initMonaco } from "$lib/secrets/monaco/init";
import type * as Monaco from "monaco-editor";

type RevealOn = "always" | "hover" | "never";
Expand Down Expand Up @@ -192,7 +192,7 @@
let cancelled = false;

void (async () => {
const monaco = await loader.init();
const monaco = await initMonaco();
if (cancelled || !host) return;

registerDiffLanguage(monaco);
Expand Down
37 changes: 31 additions & 6 deletions apps/web/src/lib/projects/ui/ProjectPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@
import DiffEditor from "./DiffEditor.svelte";
import GripLoader from "$lib/shared/ui/GripLoader.svelte";
import HistoryIcon from "$lib/shared/ui/HistoryIcon.svelte";
import ShellLoader from "$lib/shared/ui/ShellLoader.svelte";
import ButtonLoader from "$lib/shared/ui/ButtonLoader.svelte";
import SlidersIcon from "$lib/shared/ui/SlidersIcon.svelte";
import YearHeatmap from "./YearHeatmap.svelte";
Expand Down Expand Up @@ -142,6 +141,8 @@
let activeProject = $state<Project | null>(null);
let displayedProjectId = $state("");
let pendingProjectId = $state<string | null>(null);
let editorSwitching = $state(false);
let editorLoading = $state(true);
let loading = $state(true);
let loadError = $state<string | null>(null);
let searchQuery = $state("");
Expand Down Expand Up @@ -345,6 +346,14 @@
);
const isAdmin = $derived(currentRole === "admin");
const isSwitching = $derived(pendingProjectId !== null);
// One grip across the shell and editor loads, so it never remounts mid-boot.
const showLoader = $derived(
loading && !activeProject
? true
: loadError && !activeProject
? false
: activeTab === "editor" && editorLoading,
);
const DangerIcon = $derived(isAdmin ? IconTrash : IconUserMinus);
const revealOnSetting = $derived(
normalizeProjectSettings(activeProject?.settings).revealOn,
Expand Down Expand Up @@ -2316,7 +2325,10 @@
>
{#each uniqueProjects as project, index (project.id)}
{@const isDisplayed = project.id === displayedProjectId}
{@const isPending = project.id === pendingProjectId}
{@const isPending =
project.id ===
(pendingProjectId ??
(editorSwitching ? displayedProjectId : null))}
{@const isShadow = (
project as Project & { isDndShadowItem?: boolean }
).isDndShadowItem}
Expand Down Expand Up @@ -2833,12 +2845,21 @@
</div>

<div
class={`min-h-0 flex-1 overflow-hidden transition-opacity duration-300 ease-in-out ${
isSwitching ? "pointer-events-none opacity-60" : "opacity-100"
class={`relative min-h-0 flex-1 overflow-hidden transition-opacity duration-300 ease-in-out ${
isSwitching || editorSwitching
? "pointer-events-none opacity-60"
: "opacity-100"
} ${isSearching ? "hidden" : ""}`}
>
{#if showLoader}
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center"
>
<GripLoader color="#DDA15E" class="h-12 w-12" />
</div>
{/if}
{#if loading && !activeProject}
<ShellLoader label="Loading project" class="h-full" />
<!-- The grip above covers the shell load. -->
{:else if loadError && !activeProject}
<section
class="flex h-full items-center justify-center bg-background p-6"
Expand Down Expand Up @@ -2869,8 +2890,12 @@
{:else}
<!-- Kept mounted on every tab (only hidden) so unsaved editor edits survive tab switches. -->
<div class={activeTab === "editor" ? "h-full" : "hidden"}>
<!-- Loads the pending project right away so the swap lands together with the shell. -->
<SecretsEditorPage
projectId={displayedProjectId}
projectId={pendingProjectId ?? displayedProjectId}
busy={isSwitching}
bind:switching={editorSwitching}
bind:loading={editorLoading}
projectName={activeProject?.name ?? ""}
changedBy={changedByLabel}
active={activeTab === "editor"}
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/lib/projects/ui/ProjectsPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { SymmetricCrypto } from "$lib/auth/symmetric-crypto";
import { UserApi } from "$lib/auth/user.api";
import { ProjectsApi, type Project } from "$lib/projects/projects.api";
import ShellLoader from "$lib/shared/ui/ShellLoader.svelte";
import GripLoader from "$lib/shared/ui/GripLoader.svelte";
import {
accountLoadErrorMessage,
auth,
Expand Down Expand Up @@ -258,5 +258,7 @@
</div>
</div>
{:else}
<ShellLoader class="h-screen w-screen" />
<div class="flex h-screen w-screen items-center justify-center">
<GripLoader color="#DDA15E" class="h-12 w-12" />
</div>
{/if}
9 changes: 7 additions & 2 deletions apps/web/src/lib/secrets/monaco/SecretsFileEditor.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount } from "svelte";
import loader from "@monaco-editor/loader";
import type { ProjectRevealOn } from "$lib/auth/domain/project-settings";
import { registerDotenvLanguage } from "./dotenv-language";
import { initMonaco } from "./init";
import { injectMaskingCSS } from "./masking-css";
import { createSecretMasking } from "./secretMasking";

Expand All @@ -15,6 +15,7 @@
padding = { top: 16, bottom: 80 },
lineNumbersMinChars = undefined,
readOnly = false,
onReady,
}: {
value: string;
onChange: (v: string) => void;
Expand All @@ -24,6 +25,8 @@
padding?: { top: number; bottom: number };
lineNumbersMinChars?: number;
readOnly?: boolean;
/** Fires once Monaco is mounted and styled. */
onReady?: () => void;
} = $props();

let host = $state<HTMLDivElement | null>(null);
Expand All @@ -37,7 +40,7 @@
let cancelled = false;

void (async () => {
const monaco = await loader.init();
const monaco = await initMonaco();
if (cancelled || !host) {
return;
}
Expand Down Expand Up @@ -78,6 +81,7 @@

m.handleEditorMount(editorInstance, monaco);
m.syncExternalValue(value, revealOn);
onReady?.();
})();

return () => {
Expand All @@ -100,6 +104,7 @@
fontSize,
padding,
lineNumbersMinChars: lineNumbersMinChars ?? 5,
readOnly,
});
});

Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/lib/secrets/monaco/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import loader from "@monaco-editor/loader";

/**
* Monaco's AMD bundle appends its stylesheet without waiting for it,
* so an editor created right after `loader.init()` paints unstyled for a frame.
*/
export async function initMonaco() {
const monaco = await loader.init();
const css = document.querySelector<HTMLLinkElement>(
'link[href$="/editor/editor.main.css"]',
);
if (css && !css.sheet) {
await new Promise((done) => {
css.addEventListener("load", done, { once: true });
css.addEventListener("error", done, { once: true });
});
}
return monaco;
}
Loading
Loading