From 31d8a3f2710707ca18e0511c51d674bbda08c091 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 13 Oct 2026 15:31:18 +0200 Subject: [PATCH] =?UTF-8?q?Revert=20"feat(web):=20ship=20the=20v4=20design?= =?UTF-8?q?=20(secrets=20table,=20history,=20settings,=20them=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit c66f6bd798e035a47de6df1c7c1272f7959cd28e. --- apps/web/e2e/browser-lock.spec.ts | 26 +- apps/web/e2e/visual-parity.spec.ts | 56 + apps/web/package.json | 3 +- apps/web/src/app.css | 61 - apps/web/src/app.html | 34 - .../lib/account/ui/AccountSettingsPage.svelte | 340 -- apps/web/src/lib/account/ui/AppPreview.svelte | 249 - .../account/ui/ChangePassphraseDialog.svelte | 154 - .../src/lib/account/ui/PassphraseField.svelte | 58 - apps/web/src/lib/api/integrations.api.ts | 46 +- .../src/lib/appearance/appearance.svelte.ts | 78 - apps/web/src/lib/appearance/greys.css | 222 - apps/web/src/lib/appearance/greys.ts | 29 - apps/web/src/lib/appearance/theme.css | 86 - apps/web/src/lib/auth/change-passphrase.ts | 24 - apps/web/src/lib/auth/private-key.ts | 63 - .../src/lib/auth/ui/CliAuthorizePage.svelte | 13 +- .../ui/DeviceFlowApproverDialog.svelte | 189 +- apps/web/src/lib/history/diff.ts | 184 - apps/web/src/lib/history/format.ts | 54 - apps/web/src/lib/history/history.svelte.ts | 149 - .../src/lib/history/ui/ActivityHeatmap.svelte | 96 - .../web/src/lib/history/ui/HistoryView.svelte | 520 -- .../integrations/ui/GithubSyncToast.svelte | 33 + .../ui/PersonalInvitationsInbox.svelte | 127 + .../lib/keys/ui/UnlockBrowserDialog.svelte | 248 +- apps/web/src/lib/paths.ts | 22 - apps/web/src/lib/projects/create-project.ts | 78 - apps/web/src/lib/projects/project-key.ts | 40 - apps/web/src/lib/projects/project-tab.ts | 7 - .../web/src/lib/projects/ui/DiffEditor.svelte | 335 +- .../src/lib/projects/ui/ProjectPage.svelte | 4863 ++++++++++++++++- .../src/lib/projects/ui/ProjectTopbar.svelte | 49 - .../projects/ui/ProjectUnsavedNavGuard.svelte | 155 +- .../src/lib/projects/ui/ProjectsPage.svelte | 306 +- .../src/lib/projects/ui/RevealOnPicker.svelte | 128 +- .../src/lib/projects/ui/YearHeatmap.svelte | 216 + .../ui/settings/AddPeopleDialog.svelte | 354 -- .../projects/ui/settings/ConfirmDialog.svelte | 81 - .../settings/ConnectRepositoryDialog.svelte | 286 - .../projects/ui/settings/DangerSection.svelte | 71 - .../ui/settings/GeneralSection.svelte | 160 - .../projects/ui/settings/GithubSection.svelte | 196 - .../ui/settings/MembersSection.svelte | 330 -- .../ui/settings/ProjectSettingsView.svelte | 38 - apps/web/src/lib/secrets/env-doc.ts | 412 -- .../secrets/monaco/SecretsFileEditor.svelte | 68 +- apps/web/src/lib/secrets/monaco/assembler.ts | 89 + .../src/lib/secrets/monaco/dotenv-language.ts | 21 +- .../web/src/lib/secrets/monaco/masking-css.ts | 66 +- apps/web/src/lib/secrets/monaco/monaco.ts | 10 - apps/web/src/lib/secrets/monaco/parser.ts | 446 +- .../src/lib/secrets/monaco/secretMasking.ts | 1828 ++++--- apps/web/src/lib/secrets/monaco/theme.ts | 57 - .../secrets-editor-nav-guard.svelte.ts | 5 - .../secrets/ui/MobileSavePushButtons.svelte | 116 + apps/web/src/lib/secrets/ui/SecretForm.svelte | 132 - .../lib/secrets/ui/SecretsEditorPage.svelte | 781 +++ .../src/lib/secrets/ui/SecretsTable.svelte | 701 --- .../web/src/lib/secrets/ui/SecretsView.svelte | 738 --- .../web/src/lib/shared/ui/BracketsIcon.svelte | 14 + apps/web/src/lib/shared/ui/BulbIcon.svelte | 24 + .../web/src/lib/shared/ui/ButtonLoader.svelte | 26 + apps/web/src/lib/shared/ui/FtuxPopover.svelte | 142 +- apps/web/src/lib/shared/ui/GripLoader.svelte | 71 + apps/web/src/lib/shared/ui/HistoryIcon.svelte | 14 + apps/web/src/lib/shared/ui/ShellLoader.svelte | 30 + apps/web/src/lib/shared/ui/SlidersIcon.svelte | 14 + apps/web/src/lib/shell/shell.svelte.ts | 103 - apps/web/src/lib/shell/ui/AccountMenu.svelte | 130 - apps/web/src/lib/shell/ui/AppShell.svelte | 79 - apps/web/src/lib/shell/ui/AppSidebar.svelte | 414 -- .../lib/shell/ui/CreateProjectDialog.svelte | 109 - .../src/lib/shell/ui/InvitationsDialog.svelte | 112 - .../web/src/lib/shell/ui/SearchPalette.svelte | 256 - apps/web/src/lib/stores/key-state.svelte.ts | 7 - apps/web/src/lib/stores/key.svelte.ts | 42 +- apps/web/src/lib/ui/Avatar.svelte | 67 - apps/web/src/lib/ui/Button.svelte | 52 - apps/web/src/lib/ui/Dialog.svelte | 90 - apps/web/src/lib/ui/IconButton.svelte | 35 - apps/web/src/lib/ui/Kbd.svelte | 14 - apps/web/src/lib/ui/SearchField.svelte | 89 - apps/web/src/lib/ui/Segmented.svelte | 51 - apps/web/src/lib/ui/SettingsItem.svelte | 40 - apps/web/src/lib/ui/SettingsLayout.svelte | 132 - apps/web/src/lib/ui/Well.svelte | 74 - apps/web/src/lib/utils.ts | 8 +- apps/web/src/params/projectTab.ts | 5 - apps/web/src/routes/+layout.svelte | 7 +- .../web/src/routes/app/(shell)/+layout.svelte | 7 - .../routes/app/(shell)/settings/+page.svelte | 9 - .../integrations/github/+page.svelte | 13 +- .../app/{(shell) => }/project/+page.svelte | 0 .../[projectId]}/+page.svelte | 2 +- .../[projectId]}/+page.ts | 2 - .../entities/github-integration.entity.ts | 4 - .../entities/github-integration.interface.ts | 4 - .../entities/github-integration.serializer.ts | 2 - .../write/github-integration-write.service.ts | 11 - .../project/core/dto/secrets-pushed.body.ts | 14 - .../project/core/project-core.controller.ts | 11 - .../src/project/core/project-core.module.ts | 2 - .../external-connections/integrations.spec.ts | 145 - backend/test/projects/writes.spec.ts | 1 + backend/test/utils/bootstrap.ts | 5 +- packages/backend-sdk/openapi.json | 25 - packages/backend-sdk/src/openapi.generated.ts | 11 +- pnpm-lock.yaml | 19 +- 109 files changed, 9178 insertions(+), 9687 deletions(-) delete mode 100644 apps/web/src/lib/account/ui/AccountSettingsPage.svelte delete mode 100644 apps/web/src/lib/account/ui/AppPreview.svelte delete mode 100644 apps/web/src/lib/account/ui/ChangePassphraseDialog.svelte delete mode 100644 apps/web/src/lib/account/ui/PassphraseField.svelte delete mode 100644 apps/web/src/lib/appearance/appearance.svelte.ts delete mode 100644 apps/web/src/lib/appearance/greys.css delete mode 100644 apps/web/src/lib/appearance/greys.ts delete mode 100644 apps/web/src/lib/appearance/theme.css delete mode 100644 apps/web/src/lib/auth/change-passphrase.ts delete mode 100644 apps/web/src/lib/auth/private-key.ts delete mode 100644 apps/web/src/lib/history/diff.ts delete mode 100644 apps/web/src/lib/history/format.ts delete mode 100644 apps/web/src/lib/history/history.svelte.ts delete mode 100644 apps/web/src/lib/history/ui/ActivityHeatmap.svelte delete mode 100644 apps/web/src/lib/history/ui/HistoryView.svelte create mode 100644 apps/web/src/lib/integrations/ui/GithubSyncToast.svelte create mode 100644 apps/web/src/lib/invitations/ui/PersonalInvitationsInbox.svelte delete mode 100644 apps/web/src/lib/paths.ts delete mode 100644 apps/web/src/lib/projects/create-project.ts delete mode 100644 apps/web/src/lib/projects/project-key.ts delete mode 100644 apps/web/src/lib/projects/project-tab.ts delete mode 100644 apps/web/src/lib/projects/ui/ProjectTopbar.svelte create mode 100644 apps/web/src/lib/projects/ui/YearHeatmap.svelte delete mode 100644 apps/web/src/lib/projects/ui/settings/AddPeopleDialog.svelte delete mode 100644 apps/web/src/lib/projects/ui/settings/ConfirmDialog.svelte delete mode 100644 apps/web/src/lib/projects/ui/settings/ConnectRepositoryDialog.svelte delete mode 100644 apps/web/src/lib/projects/ui/settings/DangerSection.svelte delete mode 100644 apps/web/src/lib/projects/ui/settings/GeneralSection.svelte delete mode 100644 apps/web/src/lib/projects/ui/settings/GithubSection.svelte delete mode 100644 apps/web/src/lib/projects/ui/settings/MembersSection.svelte delete mode 100644 apps/web/src/lib/projects/ui/settings/ProjectSettingsView.svelte delete mode 100644 apps/web/src/lib/secrets/env-doc.ts create mode 100644 apps/web/src/lib/secrets/monaco/assembler.ts delete mode 100644 apps/web/src/lib/secrets/monaco/monaco.ts delete mode 100644 apps/web/src/lib/secrets/monaco/theme.ts create mode 100644 apps/web/src/lib/secrets/ui/MobileSavePushButtons.svelte delete mode 100644 apps/web/src/lib/secrets/ui/SecretForm.svelte create mode 100644 apps/web/src/lib/secrets/ui/SecretsEditorPage.svelte delete mode 100644 apps/web/src/lib/secrets/ui/SecretsTable.svelte delete mode 100644 apps/web/src/lib/secrets/ui/SecretsView.svelte create mode 100644 apps/web/src/lib/shared/ui/BracketsIcon.svelte create mode 100644 apps/web/src/lib/shared/ui/BulbIcon.svelte create mode 100644 apps/web/src/lib/shared/ui/ButtonLoader.svelte create mode 100644 apps/web/src/lib/shared/ui/GripLoader.svelte create mode 100644 apps/web/src/lib/shared/ui/HistoryIcon.svelte create mode 100644 apps/web/src/lib/shared/ui/ShellLoader.svelte create mode 100644 apps/web/src/lib/shared/ui/SlidersIcon.svelte delete mode 100644 apps/web/src/lib/shell/shell.svelte.ts delete mode 100644 apps/web/src/lib/shell/ui/AccountMenu.svelte delete mode 100644 apps/web/src/lib/shell/ui/AppShell.svelte delete mode 100644 apps/web/src/lib/shell/ui/AppSidebar.svelte delete mode 100644 apps/web/src/lib/shell/ui/CreateProjectDialog.svelte delete mode 100644 apps/web/src/lib/shell/ui/InvitationsDialog.svelte delete mode 100644 apps/web/src/lib/shell/ui/SearchPalette.svelte delete mode 100644 apps/web/src/lib/ui/Avatar.svelte delete mode 100644 apps/web/src/lib/ui/Button.svelte delete mode 100644 apps/web/src/lib/ui/Dialog.svelte delete mode 100644 apps/web/src/lib/ui/IconButton.svelte delete mode 100644 apps/web/src/lib/ui/Kbd.svelte delete mode 100644 apps/web/src/lib/ui/SearchField.svelte delete mode 100644 apps/web/src/lib/ui/Segmented.svelte delete mode 100644 apps/web/src/lib/ui/SettingsItem.svelte delete mode 100644 apps/web/src/lib/ui/SettingsLayout.svelte delete mode 100644 apps/web/src/lib/ui/Well.svelte delete mode 100644 apps/web/src/params/projectTab.ts delete mode 100644 apps/web/src/routes/app/(shell)/+layout.svelte delete mode 100644 apps/web/src/routes/app/(shell)/settings/+page.svelte rename apps/web/src/routes/app/{(shell) => }/project/+page.svelte (100%) rename apps/web/src/routes/app/{(shell)/project/[projectId]/[[tab=projectTab]] => project/[projectId]}/+page.svelte (67%) rename apps/web/src/routes/app/{(shell)/project/[projectId]/[[tab=projectTab]] => project/[projectId]}/+page.ts (56%) delete mode 100644 backend/src/project/core/dto/secrets-pushed.body.ts diff --git a/apps/web/e2e/browser-lock.spec.ts b/apps/web/e2e/browser-lock.spec.ts index 80621173..3106fe9d 100644 --- a/apps/web/e2e/browser-lock.spec.ts +++ b/apps/web/e2e/browser-lock.spec.ts @@ -327,7 +327,7 @@ test("lock broadcast in one tab locks another open tab", async ({ await context.close(); }); -test("shell loading state is a quiet skeleton, not the large six-dot grip", async ({ +test("shell loading state is text-first, not the large six-dot grip", async ({ page, }) => { await mockApi(page, { userDelayMs: 1_000 }); @@ -336,7 +336,7 @@ test("shell loading state is a quiet skeleton, not the large six-dot grip", asyn await page.goto(`${APP}/app/project`, { waitUntil: "domcontentloaded" }); await expect( - page.getByRole("status", { name: "Loading projects" }), + page.getByRole("status").filter({ hasText: "Loading" }), ).toBeVisible(); await expect(page.locator('svg[aria-label="Loading"]')).toHaveCount(0); }); @@ -417,22 +417,26 @@ test("unsaved edits survive tab switches and project switches go through the gua await page.getByRole("textbox", { name: "Passphrase" }).fill(passphrase); await page.getByRole("button", { name: "Unlock" }).click(); - // Raw .env view: the editor lives in Monaco there (the table is the default view). - await page.getByRole("radio", { name: ".env" }).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('UNSAVED="1"'); - await expect(page.getByRole("button", { name: /^Save/ })).toBeEnabled(); - - const tabs = page.getByRole("navigation", { name: "Project" }); - await tabs.getByRole("link", { name: "History" }).click(); - await expect(page).toHaveURL(/project-eval\/history$/); - await tabs.getByRole("link", { name: "Secrets" }).click(); + await expect( + page.getByRole("button", { name: "Save", exact: true }), + ).toBeEnabled(); + + await page + .getByRole("button", { name: "History", exact: true }) + .first() + .click(); + await page + .getByRole("button", { name: "Editor", exact: true }) + .first() + .click(); await expect(editor).toContainText("UNSAVED"); - await page.getByRole("link", { name: /Second Project/ }).click(); + await page.locator("aside nav a", { hasText: "Second Project" }).click(); const guard = page.getByRole("dialog", { name: "Save your changes?" }); await expect(guard).toBeVisible(); await expect(page).toHaveURL(/project-eval$/); diff --git a/apps/web/e2e/visual-parity.spec.ts b/apps/web/e2e/visual-parity.spec.ts index 95c30380..07b1b1e0 100644 --- a/apps/web/e2e/visual-parity.spec.ts +++ b/apps/web/e2e/visual-parity.spec.ts @@ -14,6 +14,9 @@ import { PNG } from "pngjs"; const REACT = process.env.REACT_URL ?? "http://127.0.0.1:5173"; const SVELTE = process.env.SVELTE_URL ?? "http://127.0.0.1:9090"; +const PROJECT_ID = process.env.PARITY_PROJECT_ID ?? "69e4efc7ed94cab2c9b88c9b"; +const REACT_STORAGE_STATE = process.env.PARITY_REACT_STORAGE_STATE; +const SVELTE_STORAGE_STATE = process.env.PARITY_SVELTE_STORAGE_STATE; test.beforeAll(async ({ request }, testInfo) => { const check = async (url: string) => { @@ -94,3 +97,56 @@ for (const route of Object.keys(THRESHOLDS)) { ).toBeLessThanOrEqual(threshold); }); } + +test("authenticated history tab visual parity", async ({ + browser, +}, testInfo) => { + if (!REACT_STORAGE_STATE || !SVELTE_STORAGE_STATE) { + testInfo.skip( + true, + "History parity needs authenticated storage states. Provide PARITY_REACT_STORAGE_STATE and PARITY_SVELTE_STORAGE_STATE.", + ); + } + + const route = `/app/project/${PROJECT_ID}`; + const reactContext = await browser.newContext({ + storageState: REACT_STORAGE_STATE, + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 1, + colorScheme: "dark", + }); + const svelteContext = await browser.newContext({ + storageState: SVELTE_STORAGE_STATE, + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 1, + colorScheme: "dark", + }); + + const reactPage = await reactContext.newPage(); + await reactPage.goto(`${REACT}${route}`, { waitUntil: "load" }); + await reactPage.getByRole("button", { name: "History" }).click(); + await expect( + reactPage.getByPlaceholder("Search edits — author, email, diff content…"), + ).toBeVisible(); + await reactPage.waitForTimeout(1200); + + const sveltePage = await svelteContext.newPage(); + await sveltePage.goto(`${SVELTE}${route}`, { waitUntil: "load" }); + await sveltePage.getByRole("button", { name: "History" }).click(); + await expect( + sveltePage.getByPlaceholder("Search edits — author, email, diff content…"), + ).toBeVisible(); + await sveltePage.waitForTimeout(1200); + + const reactShot = await reactPage.locator("main").screenshot(); + const svelteShot = await sveltePage.locator("main").screenshot(); + + await reactContext.close(); + await svelteContext.close(); + + const ratio = mismatchRatio(reactShot, svelteShot); + expect( + ratio, + `History tab pixel mismatch ${(ratio * 100).toFixed(2)}% for ${route}.`, + ).toBeLessThanOrEqual(0.03); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 935cad9b..3a017997 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,8 +20,7 @@ }, "dependencies": { "@fontsource-variable/funnel-display": "^5.2.8", - "@fontsource-variable/geist": "^5.3.0", - "@fontsource-variable/geist-mono": "^5.3.0", + "@monaco-editor/loader": "^1.5.0", "@packages/backend-sdk": "workspace:*", "@tabler/icons-svelte": "^3.34.1", "clsx": "^2.1.1", diff --git a/apps/web/src/app.css b/apps/web/src/app.css index ff5b698a..13433a40 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -1,13 +1,9 @@ @import "@fontsource-variable/funnel-display"; -@import "@fontsource-variable/geist"; -@import "@fontsource-variable/geist-mono"; @import "tailwindcss"; @plugin "daisyui" { logs: false; } @import "tw-animate-css"; -@import "./lib/appearance/greys.css"; -@import "./lib/appearance/theme.css"; @custom-variant dark (&:is(.dark *)); @@ -54,54 +50,6 @@ --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); --color-editor: #1e1e1e; - /* App design system (lib/appearance/theme.css). */ - --font-ui: "Geist Variable", system-ui, sans-serif; - --font-code: "Geist Mono Variable", ui-monospace, monospace; - --color-root: var(--root); - --color-root-hover: var(--root-hover); - --color-root-sel: var(--root-sel); - --color-s50: var(--s50); - --color-s50-hover: var(--s50-hover); - --color-s50-sel: var(--s50-sel); - --color-s50-border: var(--s50-border); - --color-s100: var(--s100); - --color-s100-hover: var(--s100-hover); - --color-s100-sel: var(--s100-sel); - --color-s100-border: var(--s100-border); - --color-s150: var(--s150); - --color-s150-hover: var(--s150-hover); - --color-s150-border: var(--s150-border); - --color-elev: var(--elev); - --color-elev-hover: var(--elev-hover); - --color-elev-border: var(--elev-border); - --color-field: var(--field); - --color-field-border: var(--field-border); - --color-fg: var(--fg); - --color-fg2: var(--fg2); - --color-fg3: var(--fg3); - --color-ph: var(--ph); - --color-faint: var(--faint); - --color-btn: var(--btn); - --color-btn-hover: var(--btn-hover); - --color-btn-border: var(--btn-border); - --color-btn-primary: var(--btn-primary); - --color-btn-primary-hover: var(--btn-primary-hover); - --color-btn-primary-fg: var(--btn-primary-fg); - --color-success: var(--success); - --color-warning: var(--warning); - --color-danger: var(--danger); - --color-danger-bg: var(--danger-bg); - --color-danger-bg-hover: var(--danger-bg-hover); - --radius-panel: 24px; - --radius-well: 18px; - --radius-card: 12px; - --radius-row: 6px; - --shadow-raise: var(--raise-shadow); - --shadow-panel: inset 0 0 0 0.5px var(--s50-border); - --shadow-card: inset 0 0 0 0.5px var(--s100-border), var(--raise-shadow); - --shadow-btn: inset 0 0 0 0.5px var(--btn-border), var(--raise-shadow); - --shadow-field: inset 0 0 0 0.5px var(--field-border); - --shadow-elev: 0 0 0 0.5px var(--elev-border), var(--elev-shadow); --animate-marquee: marquee var(--duration) infinite linear; --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite; @keyframes marquee { @@ -254,15 +202,6 @@ body { min-height: 100%; } -/* Monospace for keys and values. Ligatures off so "==" or "->" never render as one glyph. */ -@utility mono { - font-family: var(--font-code); - font-variant-ligatures: none; - font-feature-settings: - "liga" 0, - "calt" 0; -} - body { margin: 0; } diff --git a/apps/web/src/app.html b/apps/web/src/app.html index b97a44a7..44290495 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -9,40 +9,6 @@ /> - diff --git a/apps/web/src/lib/account/ui/AccountSettingsPage.svelte b/apps/web/src/lib/account/ui/AccountSettingsPage.svelte deleted file mode 100644 index e3d0fa79..00000000 --- a/apps/web/src/lib/account/ui/AccountSettingsPage.svelte +++ /dev/null @@ -1,340 +0,0 @@ - - - - -{#snippet copyButton(text: string, label: string)} - void copy(text)}> - {#if copied === text} - - {:else} - - {/if} - -{/snippet} - -{#snippet swatch(name: GreyPreset)} - {@const on = appearance.greys === name} - {@const [dark, mid, light] = GREY_SWATCHES[name]} - -{/snippet} - -
- {#if user?.displayName} - {user.displayName} - / - {/if} - Settings -
- - - - - {#if renaming} -
- { - if (event.key === "Escape") { - event.preventDefault(); - renaming = false; - } - }} - /> -
- {:else} - {user?.displayName} - {/if} - {#snippet actions()} - {#if renaming} - - - {:else} - - {/if} - {/snippet} -
- - {#if user} - {PROVIDERS[user.authMethod]} - {#if user.email} - {user.email} - {/if} - {/if} - {#snippet actions()} - - {/snippet} - -
- - - - {themeNote} - {#snippet actions()} - appearance.theme, setTheme} - /> - {/snippet} - - -
- {#each GREY_PRESETS as name (name)} - {@render swatch(name)} - {/each} -
-
- - - -
- - - - Cryptly never sees it and cannot reset it. - {#snippet actions()} - - {/snippet} - - - - - - {device} - {keyAuth.hasMasterKey ? "Unlocked" : "Locked"} - {#snippet actions()} - {#if keyAuth.hasMasterKey} - - {/if} - {/snippet} - - - {INSTALL} - {#snippet actions()} - {@render copyButton(INSTALL, "Copy install command")} - {/snippet} - - - {LOGIN} - {#snippet actions()} - {@render copyButton(LOGIN, "Copy login command")} - {/snippet} - - -
- - diff --git a/apps/web/src/lib/account/ui/AppPreview.svelte b/apps/web/src/lib/account/ui/AppPreview.svelte deleted file mode 100644 index e724e888..00000000 --- a/apps/web/src/lib/account/ui/AppPreview.svelte +++ /dev/null @@ -1,249 +0,0 @@ - - - - - diff --git a/apps/web/src/lib/account/ui/ChangePassphraseDialog.svelte b/apps/web/src/lib/account/ui/ChangePassphraseDialog.svelte deleted file mode 100644 index bfdd3e6e..00000000 --- a/apps/web/src/lib/account/ui/ChangePassphraseDialog.svelte +++ /dev/null @@ -1,154 +0,0 @@ - - - -
-

- Other browsers and cryptly login will ask - for the new passphrase next time. This browser stays unlocked. -

- - - (error = null)} - /> - {#if error === "incorrect"} -

Incorrect passphrase.

- {/if} - (error = null)} - /> - (error = null)} - /> - {#if error === "mismatch"} -

Passphrases don't match.

- {:else if error === "same"} -

- The new passphrase is the same as the current one. -

- {:else if error === "failed"} -

- Couldn't change the passphrase. Try again. -

- {/if} -

- - If you forget it, the account has to be reset. -

-
- - -
- -
diff --git a/apps/web/src/lib/account/ui/PassphraseField.svelte b/apps/web/src/lib/account/ui/PassphraseField.svelte deleted file mode 100644 index fc85683a..00000000 --- a/apps/web/src/lib/account/ui/PassphraseField.svelte +++ /dev/null @@ -1,58 +0,0 @@ - - -
- -
- - (shown = !shown)} - > - {#if shown} - - {:else} - - {/if} - -
-
diff --git a/apps/web/src/lib/api/integrations.api.ts b/apps/web/src/lib/api/integrations.api.ts index 430869db..611b163c 100644 --- a/apps/web/src/lib/api/integrations.api.ts +++ b/apps/web/src/lib/api/integrations.api.ts @@ -129,17 +129,11 @@ export class IntegrationsApi { } } - /** Records the push (analytics + `lastPushedAt` on these integrations for the sync status). */ - static acknowledgeSecretsPushed( - jwtToken: string, - projectId: string, - integrationIds: string[], - ) { + static acknowledgeSecretsPushed(jwtToken: string, projectId: string) { return unwrap( backend.POST("/projects/{projectId}/analytics/secrets-pushed", { params: { path: { projectId } }, headers: bearer(jwtToken), - body: { integrationIds }, }), "Failed to acknowledge secrets push", ); @@ -150,36 +144,8 @@ export class IntegrationsApi { integrations: Integration[], content: string, ): Promise { - const targets = integrations.filter( - (integration) => - integration.repositoryData?.owner && integration.repositoryData.name, - ); + if (publicEnv.githubLocalMock) return; - // The local GitHub mock never sends secrets anywhere; it still records the push below so - // the sync status works locally. - if (!publicEnv.githubLocalMock) { - await this.pushToRepositories(jwtToken, targets, content); - } - - const projectId = integrations[0]?.projectId; - if (projectId) { - try { - await this.acknowledgeSecretsPushed( - jwtToken, - projectId, - targets.map((integration) => integration.id), - ); - } catch { - // the push itself succeeded; a missing record only leaves the sync status stale - } - } - } - - private static async pushToRepositories( - jwtToken: string, - integrations: Integration[], - content: string, - ): Promise { const secrets = parseDotenv(content); await Promise.all( integrations.map(async (integration) => { @@ -209,6 +175,14 @@ export class IntegrationsApi { ); }), ); + const pid = integrations[0]?.projectId; + if (pid) { + try { + await this.acknowledgeSecretsPushed(jwtToken, pid); + } catch { + // analytics must not fail the push flow + } + } } } diff --git a/apps/web/src/lib/appearance/appearance.svelte.ts b/apps/web/src/lib/appearance/appearance.svelte.ts deleted file mode 100644 index 1df5cbd4..00000000 --- a/apps/web/src/lib/appearance/appearance.svelte.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { browser } from "$app/environment"; -import { GREY_PRESETS, type GreyPreset } from "./greys"; - -export type ThemeChoice = "system" | "light" | "dark"; - -/** Must match the key read by the pre-paint script in app.html. */ -const STORAGE_KEY = "cryptly.appearance"; -const DEFAULT_GREYS: GreyPreset = "Stone"; - -function read(): { theme: ThemeChoice; greys: GreyPreset } { - let saved: { theme?: unknown; greys?: unknown } = {}; - if (browser) { - try { - saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") ?? {}; - } catch { - saved = {}; - } - } - const theme = - saved.theme === "light" || saved.theme === "dark" ? saved.theme : "system"; - const greys = GREY_PRESETS.includes(saved.greys as GreyPreset) - ? (saved.greys as GreyPreset) - : DEFAULT_GREYS; - return { theme, greys }; -} - -const systemLight = browser - ? matchMedia("(prefers-color-scheme: light)") - : null; - -export const appearance = $state({ - ...read(), - /** The theme in effect after resolving "system". */ - resolved: "dark" as "light" | "dark", -}); - -function apply() { - if (!browser) return; - appearance.resolved = - appearance.theme === "system" - ? systemLight?.matches - ? "light" - : "dark" - : appearance.theme; - document.documentElement.dataset.theme = appearance.resolved; - document.documentElement.dataset.greys = appearance.greys; -} - -function save() { - if (!browser) return; - localStorage.setItem( - STORAGE_KEY, - JSON.stringify({ theme: appearance.theme, greys: appearance.greys }), - ); -} - -export function setTheme(theme: ThemeChoice) { - appearance.theme = theme; - save(); - apply(); -} - -export function setGreys(greys: GreyPreset) { - appearance.greys = greys; - save(); - apply(); -} - -if (browser) { - apply(); - systemLight?.addEventListener("change", apply); - // Keep other tabs in sync. - window.addEventListener("storage", (event) => { - if (event.key !== STORAGE_KEY) return; - Object.assign(appearance, read()); - apply(); - }); -} diff --git a/apps/web/src/lib/appearance/greys.css b/apps/web/src/lib/appearance/greys.css deleted file mode 100644 index 4b11fbab..00000000 --- a/apps/web/src/lib/appearance/greys.css +++ /dev/null @@ -1,222 +0,0 @@ -/* Grey ramps for the app UI. One preset is active per html[data-greys]; themes map semantic tokens onto these steps. */ -html[data-greys="Neutral"] { - --g50: #fbfbfb; - --g100: #f5f5f5; - --g150: #f0f0f0; - --g200: #eaeaea; - --g250: #dedede; - --g300: #d8d8d8; - --g350: #cdcdcd; - --g400: #b6b6b6; - --g450: #979797; - --g500: #828282; - --g550: #717171; - --g600: #666666; - --g650: #4f4f4f; - --g700: #404040; - --g750: #373737; - --g800: #2f2f2f; - --g850: #292929; - --g900: #1f1f1f; - --g925: #1a1a1a; - --g950: #141414; -} -:root, -html[data-greys="Stone"] { - --g50: #fcfbfb; - --g100: #f7f5f4; - --g150: #f2efee; - --g200: #ece9e7; - --g250: #e1dddb; - --g300: #dbd7d4; - --g350: #d1ccc9; - --g400: #b9b5b2; - --g450: #9b9693; - --g500: #86817e; - --g550: #75706d; - --g600: #6a6562; - --g650: #534f4c; - --g700: #44403d; - --g750: #393633; - --g800: #322f2d; - --g850: #2b2826; - --g900: #211f1d; - --g925: #1c1a18; - --g950: #161413; -} -html[data-greys="Paper"] { - --g50: #fcfbfa; - --g100: #f6f5f3; - --g150: #f1f0ed; - --g200: #eceae6; - --g250: #e0ded9; - --g300: #dad7d2; - --g350: #d0cdc7; - --g400: #b8b5af; - --g450: #9a9790; - --g500: #85827c; - --g550: #74716b; - --g600: #696660; - --g650: #524f4a; - --g700: #43403b; - --g750: #393632; - --g800: #312f2b; - --g850: #2b2925; - --g900: #211f1c; - --g925: #1b1a17; - --g950: #151412; -} -html[data-greys="Sand"] { - --g50: #fcfbfa; - --g100: #f7f5f2; - --g150: #f3efeb; - --g200: #eee9e4; - --g250: #e3ddd7; - --g300: #ddd7cf; - --g350: #d3ccc4; - --g400: #bcb4ac; - --g450: #9d968d; - --g500: #898178; - --g550: #777067; - --g600: #6c655d; - --g650: #554e47; - --g700: #463f38; - --g750: #3b362f; - --g800: #342f29; - --g850: #2d2823; - --g900: #231e1a; - --g925: #1d1916; - --g950: #171411; -} -html[data-greys="Clay"] { - --g50: #fdfbfa; - --g100: #f8f5f3; - --g150: #f4eeed; - --g200: #efe8e6; - --g250: #e4dcd9; - --g300: #dfd6d2; - --g350: #d5cbc7; - --g400: #beb3b0; - --g450: #9f9491; - --g500: #8a807c; - --g550: #796e6b; - --g600: #6e6460; - --g650: #564d4a; - --g700: #473e3b; - --g750: #3c3532; - --g800: #352e2b; - --g850: #2e2725; - --g900: #231e1c; - --g925: #1e1917; - --g950: #171312; -} -html[data-greys="Rose"] { - --g50: #fcfbfb; - --g100: #f7f4f5; - --g150: #f3eeef; - --g200: #eee8e9; - --g250: #e3dcdd; - --g300: #ddd6d6; - --g350: #d3cbcc; - --g400: #bcb3b4; - --g450: #9d9495; - --g500: #898081; - --g550: #776e6f; - --g600: #6c6465; - --g650: #554d4e; - --g700: #463e3f; - --g750: #3b3535; - --g800: #342e2e; - --g850: #2d2828; - --g900: #231e1e; - --g925: #1d1919; - --g950: #171314; -} -html[data-greys="Plum"] { - --g50: #fcfbfc; - --g100: #f7f5f7; - --g150: #f2eff2; - --g200: #ece9ed; - --g250: #e1dce1; - --g300: #dbd6db; - --g350: #d1cbd1; - --g400: #bab3ba; - --g450: #9b959b; - --g500: #868086; - --g550: #756f75; - --g600: #6a646a; - --g650: #534d53; - --g700: #443f44; - --g750: #3a353a; - --g800: #322e32; - --g850: #2b282c; - --g900: #211e21; - --g925: #1c191c; - --g950: #161416; -} -html[data-greys="Sage"] { - --g50: #fbfcfb; - --g100: #f4f6f4; - --g150: #eef1ee; - --g200: #e8ebe8; - --g250: #dbdfdb; - --g300: #d4d9d4; - --g350: #c9cfc9; - --g400: #b2b8b2; - --g450: #939993; - --g500: #7e847e; - --g550: #6d736d; - --g600: #626862; - --g650: #4c514c; - --g700: #3d423d; - --g750: #343834; - --g800: #2d312d; - --g850: #272a27; - --g900: #1d201d; - --g925: #181b18; - --g950: #131513; -} -html[data-greys="Slate"] { - --g50: #fafbfd; - --g100: #f4f6f8; - --g150: #edf0f3; - --g200: #e7ebef; - --g250: #dadfe4; - --g300: #d3d8de; - --g350: #c8ced4; - --g400: #b0b7bd; - --g450: #91989f; - --g500: #7d838a; - --g550: #6b7278; - --g600: #61676d; - --g650: #4b5056; - --g700: #3c4147; - --g750: #32373c; - --g800: #2c3035; - --g850: #262a2e; - --g900: #1c2023; - --g925: #181a1e; - --g950: #121517; -} -html[data-greys="Ink"] { - --g50: #fafbfe; - --g100: #f3f5fa; - --g150: #ecf0f7; - --g200: #e6eaf3; - --g250: #d9dee9; - --g300: #d2d8e5; - --g350: #c6cddb; - --g400: #afb6c5; - --g450: #9097a6; - --g500: #7b8291; - --g550: #6a717f; - --g600: #5f6674; - --g650: #49505d; - --g700: #3b414d; - --g750: #313742; - --g800: #2b303a; - --g850: #252932; - --g900: #1c1f27; - --g925: #171a21; - --g950: #12141a; -} diff --git a/apps/web/src/lib/appearance/greys.ts b/apps/web/src/lib/appearance/greys.ts deleted file mode 100644 index 1d0b3eae..00000000 --- a/apps/web/src/lib/appearance/greys.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** Grey presets, in display order. Each maps to an html[data-greys] block in greys.css. */ -export const GREY_PRESETS = [ - "Neutral", - "Stone", - "Paper", - "Sand", - "Clay", - "Rose", - "Plum", - "Sage", - "Slate", - "Ink", -] as const; - -export type GreyPreset = (typeof GREY_PRESETS)[number]; - -/** Swatch colors for the Appearance picker: dark, mid and light steps of each ramp. */ -export const GREY_SWATCHES: Record = { - Neutral: ["#141414", "#828282", "#f0f0f0"], - Stone: ["#161413", "#86817e", "#f2efee"], - Paper: ["#151412", "#85827c", "#f1f0ed"], - Sand: ["#171411", "#898178", "#f3efeb"], - Clay: ["#171312", "#8a807c", "#f4eeed"], - Rose: ["#171314", "#898081", "#f3eeef"], - Plum: ["#161416", "#868086", "#f2eff2"], - Sage: ["#131513", "#7e847e", "#eef1ee"], - Slate: ["#121517", "#7d838a", "#edf0f3"], - Ink: ["#12141a", "#7b8291", "#ecf0f7"], -}; diff --git a/apps/web/src/lib/appearance/theme.css b/apps/web/src/lib/appearance/theme.css deleted file mode 100644 index 2564c7f3..00000000 --- a/apps/web/src/lib/appearance/theme.css +++ /dev/null @@ -1,86 +0,0 @@ -/* - * App design tokens. One grey ramp (greys.css) feeds both themes; each theme maps the same - * semantic tokens onto different steps. The pre-paint script in app.html sets data-theme and - * data-greys before first paint. Surfaces nest root → s50 (panel) → root (well) → s100 (card). - */ -html[data-theme="dark"] { - --scheme: dark; - --root: var(--g950); - --root-hover: var(--g900); - --root-sel: var(--g850); - --s50: var(--g925); - --s50-hover: var(--g900); - --s50-sel: var(--g850); - --s50-border: var(--g850); - --s100: var(--g850); - --s100-hover: var(--g800); - --s100-sel: var(--g750); - --s100-border: var(--g800); - --s150: var(--g800); - --s150-hover: var(--g750); - --s150-border: var(--g700); - --elev: var(--g800); - --elev-hover: var(--g750); - --elev-border: var(--g700); - --field: var(--g950); - --field-border: var(--g800); - --fg: var(--g100); - --fg2: var(--g400); - --fg3: var(--g500); - --ph: var(--g550); - --faint: var(--g650); - --btn: var(--g800); - --btn-hover: var(--g750); - --btn-border: var(--g700); - --btn-primary: var(--g50); - --btn-primary-hover: var(--g200); - --btn-primary-fg: var(--g950); - --success: #4ade80; - --warning: #fbbf24; - --danger: #f87171; - --danger-bg: #3b1d1c; - --danger-bg-hover: #4a2321; - --raise-shadow: 0 1px 2px #0000004d; - --elev-shadow: 0 12px 32px #00000066; -} - -html[data-theme="light"] { - --scheme: light; - --root: var(--g200); - --root-hover: var(--g250); - --root-sel: var(--g300); - --s50: var(--g150); - --s50-hover: var(--g200); - --s50-sel: var(--g250); - --s50-border: var(--g250); - --s100: var(--g50); - --s100-hover: var(--g150); - --s100-sel: var(--g200); - --s100-border: var(--g250); - --s150: var(--g50); - --s150-hover: var(--g100); - --s150-border: var(--g250); - --elev: var(--g50); - --elev-hover: var(--g150); - --elev-border: var(--g250); - --field: var(--g100); - --field-border: var(--g250); - --fg: var(--g900); - --fg2: var(--g700); - --fg3: var(--g600); - --ph: var(--g500); - --faint: var(--g400); - --btn: var(--g50); - --btn-hover: var(--g150); - --btn-border: var(--g250); - --btn-primary: var(--g950); - --btn-primary-hover: var(--g800); - --btn-primary-fg: var(--g50); - --success: #16a34a; - --warning: #c27803; - --danger: #d82d2a; - --danger-bg: #fde8e7; - --danger-bg-hover: #fbd5d3; - --raise-shadow: 0 1px 2px #0000000d; - --elev-shadow: 0 12px 32px #0000001f; -} diff --git a/apps/web/src/lib/auth/change-passphrase.ts b/apps/web/src/lib/auth/change-passphrase.ts deleted file mode 100644 index c6e167c0..00000000 --- a/apps/web/src/lib/auth/change-passphrase.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { broadcastPassphraseChanged } from "$lib/stores/key-state.svelte"; -import { decryptPrivateKey, encryptPrivateKey } from "./private-key"; -import { UserApi } from "./user.api"; - -export { IncorrectPassphraseError } from "./private-key"; - -/** - * Changes the account passphrase by re-encrypting the private key in the browser. The key pair - * does not change, so project keys, this unlocked browser and logged-in CLIs keep working. - * Only the re-encrypted key is sent; the passphrases and the decrypted key never leave this - * function. Nothing is written when `current` is wrong (IncorrectPassphraseError). - */ -export async function changePassphrase( - jwtToken: string, - current: string, - next: string, -): Promise { - const privateKeyEncrypted = await encryptPrivateKey( - await decryptPrivateKey(current), - next, - ); - await UserApi.updateMe(jwtToken, { privateKeyEncrypted }); - broadcastPassphraseChanged(); -} diff --git a/apps/web/src/lib/auth/private-key.ts b/apps/web/src/lib/auth/private-key.ts deleted file mode 100644 index 65f98ec5..00000000 --- a/apps/web/src/lib/auth/private-key.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { auth, setUserData } from "$lib/stores/auth.svelte"; -import { SymmetricCrypto } from "./symmetric-crypto"; -import { UserApi } from "./user.api"; - -/** - * The account private key (PKCS#8, base64) is stored on the server encrypted with AES-GCM keyed - * by SHA-256 of the passphrase. Every path that opens or writes it goes through this module, so - * setup, unlock, device approval, CLI login and passphrase change share one format. - */ - -export class IncorrectPassphraseError extends Error { - constructor() { - super("Incorrect passphrase"); - this.name = "IncorrectPassphraseError"; - } -} - -/** - * The server's current encrypted private key. A copy loaded earlier may predate a passphrase - * change (in another tab or browser), and would still open with the old passphrase. Falls back - * to the loaded copy when the API is unreachable. - */ -async function currentPrivateKeyEncrypted(): Promise { - const jwt = auth.jwtToken; - if (jwt) { - try { - const me = await UserApi.getMe(jwt); - if (auth.jwtToken === jwt) setUserData(me); - return me.privateKeyEncrypted; - } catch { - // Offline or API error: use the copy we have. - } - } - return auth.userData?.privateKeyEncrypted; -} - -/** Opens the account private key with `passphrase`. Throws IncorrectPassphraseError. */ -export async function decryptPrivateKey(passphrase: string): Promise { - const encrypted = await currentPrivateKeyEncrypted(); - if (!encrypted) throw new Error("This account has no passphrase yet"); - const key = await SymmetricCrypto.deriveBase64KeyFromPassphrase(passphrase); - try { - return await SymmetricCrypto.decrypt(encrypted, key); - } catch { - throw new IncorrectPassphraseError(); - } -} - -/** - * Encrypts the private key with `passphrase`, then opens the result again before it can leave - * the browser: a blob the passphrase can't open would lock the account out for good. - */ -export async function encryptPrivateKey( - privateKey: string, - passphrase: string, -): Promise { - const key = await SymmetricCrypto.deriveBase64KeyFromPassphrase(passphrase); - const encrypted = await SymmetricCrypto.encrypt(privateKey, key); - if ((await SymmetricCrypto.decrypt(encrypted, key)) !== privateKey) { - throw new Error("Could not verify the encrypted private key"); - } - return encrypted; -} diff --git a/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte b/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte index 692c1fc5..e26b1645 100644 --- a/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte +++ b/apps/web/src/lib/auth/ui/CliAuthorizePage.svelte @@ -18,7 +18,6 @@ type CliSessionInfo, } from "$lib/auth/cli-flow.api"; import { randomBytes, u8ToBase64 } from "$lib/auth/crypto.utils"; - import { decryptPrivateKey } from "$lib/auth/private-key"; import { SymmetricCrypto } from "$lib/auth/symmetric-crypto"; import { auth, loadUserData } from "$lib/stores/auth.svelte"; @@ -135,8 +134,16 @@ let ephemeralKey: string | null = null; try { - // Throws "Incorrect passphrase"; checked against the server's current key. - rawPrivateKey = await decryptPrivateKey(passphrase); + const base64Key = + await SymmetricCrypto.deriveBase64KeyFromPassphrase(passphrase); + try { + rawPrivateKey = await SymmetricCrypto.decrypt( + auth.userData.privateKeyEncrypted, + base64Key, + ); + } catch { + throw new Error("Incorrect passphrase"); + } ephemeralKey = u8ToBase64(randomBytes(32)); const wrappedKey = await AsymmetricCrypto.encrypt( diff --git a/apps/web/src/lib/device-flow/ui/DeviceFlowApproverDialog.svelte b/apps/web/src/lib/device-flow/ui/DeviceFlowApproverDialog.svelte index ed14923a..e7da834b 100644 --- a/apps/web/src/lib/device-flow/ui/DeviceFlowApproverDialog.svelte +++ b/apps/web/src/lib/device-flow/ui/DeviceFlowApproverDialog.svelte @@ -1,15 +1,13 @@ - - {#if typeof msg?.pin === "string"} +{#if msg?.type === "request"} +
- {/if} +
+
+ +
+
+

+ Device sign in request +

+

+ Another device is trying to sign in +

+
+
-
-
- - { - if (passphraseError) passphraseError = false; - }} - onkeydown={(e) => { - if (e.key === "Enter" && passphrase && !processing) - void handleApprove(); - }} - /> - { - showPassphrase = !showPassphrase; - }} + {#if typeof msg.pin === "string"} +
+

+ {msg.pin} +

+

+ Verify this PIN matches on your other device +

+
+ {/if} + +
- {#if showPassphrase} - - {:else} - +

+ Enter your passphrase to share it with the requesting device. It is + encrypted under that device's one-time key before it leaves this + browser. +

+
+ +
+ +
+ { + if (e.key === "Enter" && passphrase && !processing) + void handleApprove(); + }} + /> + +
+ {#if passphraseError} +
+ Incorrect passphrase +
{/if} - -
- {#if passphraseError} -

Incorrect passphrase

- {/if} -
+
-
- - +
+ + +
+
-
+{/if} diff --git a/apps/web/src/lib/history/diff.ts b/apps/web/src/lib/history/diff.ts deleted file mode 100644 index 19d265a8..00000000 --- a/apps/web/src/lib/history/diff.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { diffArrays } from "diff"; -import type { ProjectRevealOn } from "$lib/auth/domain/project-settings"; -import { oneLine, parseEnv as parseEnvDoc, peek } from "$lib/secrets/env-doc"; - -/** One secret of a decrypted .env document, with the group it sits in on the Secrets page. */ -export interface EnvSecret { - key: string; - value: string; - group: string; - /** Index of the group block, so two "Untitled" groups stay apart. */ - block: number; -} - -export interface SecretChange { - kind: "added" | "changed" | "removed"; - key: string; - before?: string; - after?: string; -} - -export interface SecretDiff { - /** Changed secrets grouped like the Secrets page, in the newer document's group order. */ - groups: { name: string; changes: SecretChange[] }[]; - added: number; - changed: number; - removed: number; - unchanged: number; - unchangedGroups: number; -} - -/** Secrets with their groups, parsed exactly like the Secrets page (`env-doc.ts`). */ -export function parseEnv(text: string): EnvSecret[] { - return parseEnvDoc(text).groups.flatMap((g) => - g.secrets.map((s) => ({ - key: s.key, - value: s.value, - group: g.name ?? "Untitled", - block: g.index, - })), - ); -} - -/** - * Per-secret diff by key. Moving a secret to another group or reordering it is not a change; - * a key defined twice counts with its last value, like dotenv. - */ -export function diffSecrets( - before: EnvSecret[], - after: EnvSecret[], -): SecretDiff { - const old = new Map(before.map((s) => [s.key, s])); - const now = new Map(after.map((s) => [s.key, s])); - const order = new Map(); - for (const s of [...now.values(), ...old.values()]) - if (!order.has(s.group)) order.set(s.group, order.size); - - const byGroup = new Map(); - const push = (group: string, change: SecretChange) => - byGroup.set(group, [...(byGroup.get(group) ?? []), change]); - let added = 0; - let changed = 0; - let removed = 0; - let unchanged = 0; - const unchangedBlocks = new Set(); - - for (const s of now.values()) { - const prev = old.get(s.key); - if (!prev) { - added++; - push(s.group, { kind: "added", key: s.key, after: s.value }); - } else if (prev.value !== s.value) { - changed++; - push(s.group, { - kind: "changed", - key: s.key, - before: prev.value, - after: s.value, - }); - } else { - unchanged++; - unchangedBlocks.add(s.block); - } - } - for (const s of old.values()) { - if (now.has(s.key)) continue; - removed++; - push(s.group, { kind: "removed", key: s.key, before: s.value }); - } - - const groups = [...byGroup] - .map(([name, changes]) => ({ name, changes })) - .sort((a, b) => order.get(a.name)! - order.get(b.name)!); - return { - groups, - added, - changed, - removed, - unchanged, - unchangedGroups: unchangedBlocks.size, - }; -} - -/** - * A value as the project's Values setting shows it, as [head, mask, tail]: Peek keeps up to 4 - * characters at each end around a fixed mask, Hidden is a fixed mask, Visible is the value. - * Never reveals the length unless the value is Visible. - */ -export function shownValue( - value: string, - revealOn: ProjectRevealOn, -): [string, string, string] { - if (revealOn === "always") return [oneLine(value), "", ""]; - const p = revealOn === "hover" ? peek(value) : null; - return p ? [p.head, "••••••", p.tail] : ["", "••••••••", ""]; -} - -/** One line of the document; a secret, multiline or not, is one line showing its value as set. */ -interface DiffLine { - /** Exact source text, what the diff compares. */ - real: string; - shown: string; -} - -function diffLines(text: string, revealOn: ProjectRevealOn): DiffLine[] { - const doc = parseEnvDoc(text); - const secretAt = new Map(doc.secrets.map((s) => [s.start, s])); - const out: DiffLine[] = []; - for (let i = 0; i < doc.lines.length; i++) { - const line = doc.lines[i]!; - const s = secretAt.get(i); - if (!s) { - out.push({ real: line, shown: line }); - continue; - } - const real = doc.lines.slice(s.start, s.end + 1).join("\n"); - out.push({ - real, - shown: - revealOn === "always" - ? real - : line.slice(0, line.indexOf("=") + 1) + - shownValue(s.value, revealOn).join(""), - }); - i = s.end; - } - return out; -} - -/** - * Line diff for the ".env diff" view, `+`/`-`/` ` prefixed with 4 lines of context and "⋯" for - * the gaps. Lines are compared by their real text but show values per the Values setting, so a - * masked line never depends on the value's length and a change hidden by the mask still shows. - */ -export function envDiff( - before: string | null, - after: string, - revealOn: ProjectRevealOn, -): string { - const next = diffLines(after, revealOn); - const rows = - before === null - ? next.map((l) => ({ mark: "+", text: l.shown })) - : diffArrays(diffLines(before, revealOn), next, { - comparator: (a, b) => a.real === b.real, - }).flatMap((c) => - c.value.map((l) => ({ - mark: c.added ? "+" : c.removed ? "-" : " ", - text: l.shown, - })), - ); - const changed = rows.map((r) => r.mark !== " "); - const out: string[] = []; - let gap = false; - rows.forEach((row, i) => { - if (!changed.slice(Math.max(0, i - 4), i + 5).some(Boolean)) { - gap = out.length > 0; - return; - } - if (gap) out.push(" ⋯"); - gap = false; - for (const line of row.text.split("\n")) out.push(row.mark + line); - }); - return out.join("\n"); -} diff --git a/apps/web/src/lib/history/format.ts b/apps/web/src/lib/history/format.ts deleted file mode 100644 index 0c5c8a76..00000000 --- a/apps/web/src/lib/history/format.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** Local calendar day, `YYYY-MM-DD`. */ -export function dayKey(value: string | Date): string { - const d = new Date(value); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; -} - -function fromKey(key: string) { - const [y, m, d] = key.split("-").map(Number); - return new Date(y!, m! - 1, d!); -} - -/** "Oct 8", with the year when it isn't this year. */ -export function shortDay(key: string): string { - const date = fromKey(key); - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: - date.getFullYear() === new Date().getFullYear() ? undefined : "numeric", - }); -} - -/** "Today", "Yesterday", else `shortDay`. */ -export function dayLabel(key: string, now: number): string { - if (key === dayKey(new Date(now))) return "Today"; - const yesterday = new Date(now); - yesterday.setDate(yesterday.getDate() - 1); - return key === dayKey(yesterday) ? "Yesterday" : shortDay(key); -} - -/** "now", "12m", "2h" today; "18:42" before. */ -export function shortTime(value: string, now: number): string { - if (dayKey(value) !== dayKey(new Date(now))) return clock(value); - const minutes = Math.floor((now - Date.parse(value)) / 60_000); - if (minutes < 1) return "now"; - return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`; -} - -/** "Oct 12, 2026 · 11:01". */ -export function fullDate(value: string): string { - const date = new Date(value).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); - return `${date} · ${clock(value)}`; -} - -function clock(value: string) { - return new Date(value).toLocaleTimeString("en-GB", { - hour: "2-digit", - minute: "2-digit", - }); -} diff --git a/apps/web/src/lib/history/history.svelte.ts b/apps/web/src/lib/history/history.svelte.ts deleted file mode 100644 index 20f2a3eb..00000000 --- a/apps/web/src/lib/history/history.svelte.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { browser } from "$app/environment"; -import { SymmetricCrypto } from "$lib/auth/symmetric-crypto"; -import { getUnlockedProjectKey } from "$lib/projects/project-key"; -import { ProjectsApi, type Project } from "$lib/projects/projects.api"; -import { auth } from "$lib/stores/auth.svelte"; -import { keyAuth } from "$lib/stores/key.svelte"; -import { diffSecrets, parseEnv, type SecretDiff } from "./diff"; - -export interface HistoryVersion { - id: string; - createdAt: string; - updatedAt: string; - /** Missing on the version Cryptly created together with the project. */ - author?: { - id: string; - displayName: string; - avatarUrl?: string; - email?: string; - }; - /** Decrypted .env text of this version. In memory only, never sent anywhere. */ - content: string; - /** Decrypted text of the version before it; null for the oldest kept version. */ - previous: string | null; - /** Per-secret changes against `previous` (everything added for the oldest version). */ - diff: SecretDiff; -} - -class HistoryStore { - projectId = $state(null); - /** Newest first. Raw: replaced as a whole, never mutated in place. */ - versions = $state.raw([]); - loading = $state(false); - /** The browser is locked, so nothing can be decrypted. */ - locked = $state(false); - message = $state(null); -} - -/** - * Decrypted version history of the open project, shared by the History tab, the GitHub sync - * status in Settings, and the Secrets tab (which reloads it after a save). Decryption happens - * in the browser; the plaintext is dropped as soon as the browser locks. - */ -export const history = new HistoryStore(); - -let seq = 0; - -export async function loadHistory(project: Project): Promise { - const mySeq = ++seq; - const revision = keyAuth.revision; - const current = () => - mySeq === seq && revision === keyAuth.revision && keyAuth.hasMasterKey; - - if (history.projectId !== project.id) { - history.projectId = project.id; - history.versions = []; - } - if (!keyAuth.hasMasterKey) { - dropDecrypted(); - return; - } - history.loading = true; - history.locked = false; - history.message = null; - - try { - const jwt = auth.jwtToken; - const userId = auth.userData?.id; - if (!jwt || !userId) throw new Error("Signed out"); - if (!project.encryptedSecretsKeys?.[userId]) { - history.versions = []; - history.message = "You don't have a key for this project."; - return; - } - const encrypted = await ProjectsApi.getProjectVersions(jwt, project.id); - if (!current()) return; - const key = await getUnlockedProjectKey(project); - if (!current()) return; - if (!key) { - dropDecrypted(); - return; - } - const contents = await Promise.all( - encrypted.map((v) => - SymmetricCrypto.decryptWithKey(v.encryptedSecrets, key).catch(() => ""), - ), - ); - if (!current()) return; - - // Oldest first, so each version diffs against the one before it. - const chronological = encrypted - .map((v, i) => ({ ...v, content: contents[i]! })) - .reverse(); - const parsed = chronological.map((v) => parseEnv(v.content)); - history.versions = chronological - .map( - (v, i): HistoryVersion => ({ - id: v.id, - createdAt: v.createdAt, - updatedAt: v.updatedAt, - author: - i === 0 && isCreation(v.createdAt, project) ? undefined : v.author, - content: v.content, - previous: chronological[i - 1]?.content ?? null, - diff: diffSecrets(parsed[i - 1] ?? [], parsed[i]!), - }), - ) - .reverse(); - } catch { - if (!current()) return; - history.versions = []; - history.message = "Check your connection and try again."; - } finally { - if (mySeq === seq) history.loading = false; - } -} - -export function clearHistory() { - seq++; - history.projectId = null; - history.versions = []; - history.loading = false; - history.locked = false; - history.message = null; -} - -function dropDecrypted() { - seq++; - history.versions = []; - history.loading = false; - history.locked = true; - history.message = null; -} - -/** - * The oldest version is the project's first one (shown as created by Cryptly) unless older - * versions were pruned (the backend keeps the last 100), which shows as a later timestamp. - */ -function isCreation(createdAt: string, project: Project) { - return Date.parse(createdAt) - Date.parse(project.createdAt) < 60_000; -} - -if (browser) { - // Plaintext must not outlive the unlocked browser, whichever tab is open. - $effect.root(() => { - $effect(() => { - if (!keyAuth.hasMasterKey || !auth.jwtToken) dropDecrypted(); - }); - }); -} diff --git a/apps/web/src/lib/history/ui/ActivityHeatmap.svelte b/apps/web/src/lib/history/ui/ActivityHeatmap.svelte deleted file mode 100644 index 8c2c7de6..00000000 --- a/apps/web/src/lib/history/ui/ActivityHeatmap.svelte +++ /dev/null @@ -1,96 +0,0 @@ - - - - {#snippet trail()} - {#if day} - - {/if} - {/snippet} - {#each cells as cell (cell.key)} - {#if cell.count > 0} - - {:else} - (hovered = cell.future ? null : cell)} - onpointerleave={() => (hovered = null)} - > - {/if} - {/each} - diff --git a/apps/web/src/lib/history/ui/HistoryView.svelte b/apps/web/src/lib/history/ui/HistoryView.svelte deleted file mode 100644 index 7804a598..00000000 --- a/apps/web/src/lib/history/ui/HistoryView.svelte +++ /dev/null @@ -1,520 +0,0 @@ - - - - -{#snippet who(v: HistoryVersion)} - {#if v.author} - - {v.author.displayName} - {#if v.author.id === me} - you - {/if} - {:else} - - Cryptly - {/if} -{/snippet} - -{#snippet counts(diff: SecretDiff)} - - {#if diff.added}+{diff.added}{/if} - {#if diff.changed}~{diff.changed}{/if} - {#if diff.removed}−{diff.removed}{/if} - {#if !diff.added && !diff.changed && !diff.removed}±0{/if} - -{/snippet} - -{#snippet secretValue(value: string)} - {@const [head, mask, tail] = shownValue(value, revealOn)} - {head}{mask}{tail} -{/snippet} - -{#snippet message(title: string, text?: string, retry = false)} -
-
-

{title}

- {#if text}

{text}

{/if} - {#if retry} - - {/if} -
-
-{/snippet} - -{#snippet filters(className: string)} - -
- -{/snippet} - - -
{@render filters("")}
-
-
- {@render filters("w-auto flex-1")} -
- -{#if locked} - {@render message( - "Unlock to view history", - "Versions are encrypted. Unlock this browser to decrypt them here.", - )} -{:else if history.message && history.projectId === project.id} - {@render message("Couldn't load the history", history.message, true)} -{:else if loading} - {@render message("Loading history…")} -{:else if versions.length === 0} - {@render message("No versions yet", "Save secrets to start the history.")} -{:else} -
-
- {#each days as group (group.key)} - - {#each group.versions as v (v.id)} - {@const on = v.id === selected?.id} - - {/each} - - {:else} -
-

No versions match.

- -
- {/each} - -
- -
- { - day = key; - syncUrl(); - }} - /> -
-
- - {#if selected} -
-
- - {@render who(selected)} - - {fullDate(selected.createdAt)} - {@render counts(selected.diff)} -
- localStorage.setItem(DIFF_MODE_KEY, mode)} - /> - {#if !readOnly && selected.id !== versions[0]?.id} - - {/if} -
- - {#if envText !== null} - - - - {:else} -
- {#each selected.diff.groups as group (group.name)} - - {#each group.changes as change (change.key)} - {@const tone = TONE[change.kind]} -
- {tone.mark} - {change.key} - - - {#if change.kind === "added"} - {@render secretValue(change.after ?? "")} - {:else if change.kind === "removed"} - {@render secretValue(change.before ?? "")} - {:else if revealOn === "never"} - value changed - {:else} - {@render secretValue(change.before ?? "")} - → - {@render secretValue(change.after ?? "")} - {/if} - -
- {/each} -
- {:else} -

- No secrets changed. The .env diff shows comment and order - changes. -

- {/each} - {#if selected.diff.unchanged} -

- {plural(selected.diff.unchanged, "unchanged secret")} in {plural( - selected.diff.unchangedGroups, - "group", - )} -

- {/if} -
- {/if} -
- {/if} -
-{/if} - - -
- - -
-
diff --git a/apps/web/src/lib/integrations/ui/GithubSyncToast.svelte b/apps/web/src/lib/integrations/ui/GithubSyncToast.svelte new file mode 100644 index 00000000..29f548b4 --- /dev/null +++ b/apps/web/src/lib/integrations/ui/GithubSyncToast.svelte @@ -0,0 +1,33 @@ + + +
+
+ {#if ok} + + {:else} + + {/if} +
+ {ok ? "Synced with" : "Failed to sync with"} + + + GitHub + +
+ +
+
diff --git a/apps/web/src/lib/invitations/ui/PersonalInvitationsInbox.svelte b/apps/web/src/lib/invitations/ui/PersonalInvitationsInbox.svelte new file mode 100644 index 00000000..dc03d141 --- /dev/null +++ b/apps/web/src/lib/invitations/ui/PersonalInvitationsInbox.svelte @@ -0,0 +1,127 @@ + + +{#if shouldShow && invitations.length > 0} +
+
+

Project invitations

+

+ You have pending direct invitations. +

+
+
+ {#each invitations as invitation (invitation.id)} +
+
+
+

+ {invitation.projectName} +

+

+ Invited by {invitation.author.displayName || + invitation.author.email || + "a teammate"} as {invitation.role} +

+
+
+
+ + +
+
+ {/each} +
+
+{/if} diff --git a/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte b/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte index de9eda20..35a6281f 100644 --- a/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte +++ b/apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte @@ -1,6 +1,14 @@ - -
{ - event.preventDefault(); - void handleUnlock(); - }} +{#if open} +
+
+ + +{/if} diff --git a/apps/web/src/lib/paths.ts b/apps/web/src/lib/paths.ts deleted file mode 100644 index 19d068a7..00000000 --- a/apps/web/src/lib/paths.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ProjectTab } from "$lib/projects/project-tab"; - -/** - * In-app paths for project tabs and settings sections. Wrap them in `resolve()` from - * `$app/paths` at the call site (`href={resolve(projectPath(id, "history"))}`); that's what - * adds the base path and what the svelte/no-navigation-without-resolve lint looks for. - */ -export function projectPath( - projectId: string, - tab: ProjectTab = "secrets", - section?: string, -): `/app/project/${string}` { - const path = `/app/project/${encodeURIComponent(projectId)}${tab === "secrets" ? "" : `/${tab}`}`; - return `${path}${section ? `#${section}` : ""}` as `/app/project/${string}`; -} - -/** Account settings, optionally at a section (`#devices`). */ -export function settingsPath(section?: string): "/app/settings" { - return ( - section ? `/app/settings#${section}` : "/app/settings" - ) as "/app/settings"; -} diff --git a/apps/web/src/lib/projects/create-project.ts b/apps/web/src/lib/projects/create-project.ts deleted file mode 100644 index c24c6f40..00000000 --- a/apps/web/src/lib/projects/create-project.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { goto } from "$app/navigation"; -import { resolve } from "$app/paths"; -import { toast } from "svelte-sonner"; -import { track } from "$lib/analytics/logdash"; -import { IntegrationsApi } from "$lib/api/integrations.api"; -import { AsymmetricCrypto } from "$lib/auth/asymmetric-crypto"; -import type { ProjectSettings } from "$lib/auth/domain/project-settings"; -import { SymmetricCrypto } from "$lib/auth/symmetric-crypto"; -import { UserApi } from "$lib/auth/user.api"; -import { projectPath } from "$lib/paths"; -import { ProjectsApi, type Project } from "$lib/projects/projects.api"; -import { upsertProject } from "$lib/shell/shell.svelte"; -import { auth, setUserData } from "$lib/stores/auth.svelte"; - -const STARTER_CONTENT = `# App\nAPI_KEY="your-value-here"\nDATABASE_URL="postgres://..."`; - -/** - * Creates a project end to end encrypted: a fresh AES project key encrypts the starter content - * in the browser, and the key is wrapped with the user's public key, so the server only ever - * stores ciphertext. Optionally connects a GitHub repository, remembers the settings as the - * user's defaults, adds the project to the sidebar and opens it. - */ -export async function createEncryptedProject({ - name, - settings, - repo, - replaceState = false, -}: { - name: string; - settings: ProjectSettings; - repo?: { id: number; installationEntityId: string }; - replaceState?: boolean; -}): Promise { - const jwt = auth.jwtToken; - const user = auth.userData; - if (!jwt || !user?.publicKey) { - throw new Error("Finish passphrase setup first"); - } - - const projectKey = await SymmetricCrypto.generateProjectKey(); - const encryptedSecrets = await SymmetricCrypto.encrypt( - STARTER_CONTENT, - projectKey, - ); - const wrappedKey = await AsymmetricCrypto.encrypt(projectKey, user.publicKey); - - const created = await ProjectsApi.createProject(jwt, { - name, - encryptedSecrets, - encryptedSecretsKeys: { [user.id]: wrappedKey }, - settings, - }); - track("project_created", { github_repo: Boolean(repo) }); - - if (repo) { - try { - await IntegrationsApi.createIntegration(jwt, { - projectId: created.id, - repositoryId: repo.id, - installationEntityId: repo.installationEntityId, - }); - } catch { - toast.error("Project created, but connecting the repository failed"); - } - } - - if (user.projectCreationDefaults?.revealOn !== settings.revealOn) { - UserApi.updateMe(jwt, { projectCreationDefaults: settings }) - .then((updated) => { - if (auth.jwtToken === jwt) setUserData(updated); - }) - .catch(() => undefined); - } - - upsertProject(created); - await goto(resolve(projectPath(created.id)), { replaceState }); - return created; -} diff --git a/apps/web/src/lib/projects/project-key.ts b/apps/web/src/lib/projects/project-key.ts deleted file mode 100644 index 6abbbe32..00000000 --- a/apps/web/src/lib/projects/project-key.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { AsymmetricCrypto } from "$lib/auth/asymmetric-crypto"; -import { keystore } from "$lib/auth/keystore"; -import { SymmetricCrypto } from "$lib/auth/symmetric-crypto"; -import { auth } from "$lib/stores/auth.svelte"; -import { keyAuth } from "$lib/stores/key.svelte"; - -/** - * The project's AES key, unwrapped in the browser with the user's private key and cached in - * the keystore. Null while the browser is locked, when the user has no wrapped key for the - * project, or when the lock state changed mid-way (the caller must drop any decrypted state). - */ -export async function getUnlockedProjectKey(project: { - id: string; - encryptedSecretsKeys?: Record; -}): Promise { - const expectedRevision = keyAuth.revision; - const stillUnlocked = () => - expectedRevision === keyAuth.revision && keyAuth.hasMasterKey; - if (!keyAuth.hasMasterKey) return null; - - let projectKey = await keystore.getProjectKey(project.id); - if (!stillUnlocked()) return null; - if (projectKey) return projectKey; - - const userId = auth.userData?.id; - const encryptedKey = userId - ? project.encryptedSecretsKeys?.[userId] - : undefined; - const masterKey = await keystore.getMasterKey(); - if (!stillUnlocked() || !masterKey || !encryptedKey) return null; - - const projectKeyB64 = await AsymmetricCrypto.decryptWithKey( - encryptedKey, - masterKey, - ); - projectKey = await SymmetricCrypto.importAesKey(projectKeyB64); - if (!stillUnlocked()) return null; - await keystore.setProjectKey(project.id, projectKey); - return projectKey; -} diff --git a/apps/web/src/lib/projects/project-tab.ts b/apps/web/src/lib/projects/project-tab.ts deleted file mode 100644 index 575e17bd..00000000 --- a/apps/web/src/lib/projects/project-tab.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type ProjectTab = "secrets" | "history" | "settings"; - -export const PROJECT_TABS: readonly { id: ProjectTab; label: string }[] = [ - { id: "secrets", label: "Secrets" }, - { id: "history", label: "History" }, - { id: "settings", label: "Settings" }, -]; diff --git a/apps/web/src/lib/projects/ui/DiffEditor.svelte b/apps/web/src/lib/projects/ui/DiffEditor.svelte index a44ba589..089454f4 100644 --- a/apps/web/src/lib/projects/ui/DiffEditor.svelte +++ b/apps/web/src/lib/projects/ui/DiffEditor.svelte @@ -1,81 +1,213 @@ -
- - +
diff --git a/apps/web/src/lib/projects/ui/ProjectPage.svelte b/apps/web/src/lib/projects/ui/ProjectPage.svelte index d56c7e23..09ab2e18 100644 --- a/apps/web/src/lib/projects/ui/ProjectPage.svelte +++ b/apps/web/src/lib/projects/ui/ProjectPage.svelte @@ -1,75 +1,4832 @@ - - {project ? `${project.name} · Cryptly` : "Cryptly"} - + $effect(() => { + if ( + !historyLocked || + !keyAuth.hasMasterKey || + !auth.jwtToken || + !activeProject + ) + return; + void loadHistory(auth.jwtToken, activeProject); + }); + + $effect(() => { + if (keyAuth.hasMasterKey) return; + clearDecryptedProjectState(); + }); + + $effect(() => { + if (!keyAuth.hasMasterKey || !auth.jwtToken) return; + const revision = keyAuth.revision; + if (revision === lastUnlockReloadRevision) return; + lastUnlockReloadRevision = revision; + if (loading || !activeProject || projects === null) { + lastLoadKey = ""; + void loadShell(projectId); + } + }); + + $effect(() => { + if (!showCreateDialog) return; + createRevealOn = normalizeProjectSettings( + auth.userData?.projectCreationDefaults ?? DEFAULT_PROJECT_SETTINGS, + ).revealOn; + }); + + $effect(() => { + persistActiveTab(activeTab); + void tick().then(updateTabUnderline); + }); + + $effect(() => { + if (activeTab === "integrations") ftuxUserOpenedIntegrations(); + }); + + function updateMobileTabFades() { + if (!mobileTabsEl) return; + const { scrollLeft, scrollWidth, clientWidth } = mobileTabsEl; + // 1px tolerance for sub-pixel rounding + mobileTabsCanScrollLeft = scrollLeft > 1; + mobileTabsCanScrollRight = scrollLeft + clientWidth < scrollWidth - 1; + } + + $effect(() => { + const el = mobileTabsEl; + if (!el) return; + updateMobileTabFades(); + const observer = new ResizeObserver(updateMobileTabFades); + observer.observe(el); + return () => observer.disconnect(); + }); + + $effect(() => { + // Keep the active tab's underline in view on narrow screens. + mobileTabsEl + ?.querySelector(`[data-tab-id="${activeTab}"]`) + ?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "nearest", + }); + }); + + const changedByLabel = $derived.by(() => { + const author = historyVersions[0]?.author; + if (!author || !activeProject) return null; + const who = author.id === auth.userData?.id ? "you" : author.displayName; + return `Changed by ${who} ${getRelativeTime(activeProject.updatedAt)}`; + }); + + const menuItemClass = + "relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent focus-visible:bg-accent"; + + function closeUserMenu() { + showUserMenu = false; + cancelDisplayNameEdit(); + } + + function cancelDisplayNameEdit() { + displayNameInput = auth.userData?.displayName ?? ""; + editingDisplayName = false; + } + + async function saveDisplayName() { + const jwt = auth.jwtToken; + const trimmed = displayNameInput.trim(); + if (!jwt || !trimmed || savingDisplayName) return; + savingDisplayName = true; + try { + await UserApi.updateMe(jwt, { displayName: trimmed }); + await loadUserData(); + editingDisplayName = false; + } catch { + toast.error("Failed to update display name"); + } finally { + savingDisplayName = false; + } + } + + async function createEncryptedProject( + jwt: string, + user: { id: string; publicKey: string }, + name: string, + settings: ProjectSettingsType, + repo: RepoWithInstallation | null, + ): Promise { + const projectKey = await SymmetricCrypto.generateProjectKey(); + const content = `# Define your secrets below. Example:\nAPI_KEY="your-value-here"\nDATABASE_URL="postgres://..."`; + const contentEncrypted = await SymmetricCrypto.encrypt(content, projectKey); + const projectKeyEncrypted = await AsymmetricCrypto.encrypt( + projectKey, + user.publicKey, + ); + + const created = await ProjectsApi.createProject(jwt, { + name, + encryptedSecrets: contentEncrypted, + encryptedSecretsKeys: { [user.id]: projectKeyEncrypted }, + settings, + }); + + if (repo) { + await IntegrationsApi.createIntegration(jwt, { + projectId: created.id, + repositoryId: repo.id, + installationEntityId: repo.installationEntityId, + }); + } + track("project_created", { github_repo: Boolean(repo) }); + return created; + } + + async function acceptSuggestion(repo: RepoWithInstallation) { + const jwt = auth.jwtToken; + const user = auth.userData; + if (!jwt || acceptingRepoId !== null) return; + if (!user?.publicKey) { + toast.error("Finish passphrase setup first"); + return; + } + acceptingRepoId = repo.id; + try { + const settings = normalizeProjectSettings( + user.projectCreationDefaults ?? DEFAULT_PROJECT_SETTINGS, + ); + const created = await createEncryptedProject( + jwt, + { id: user.id, publicKey: user.publicKey }, + repo.name, + settings, + repo, + ); + projects = await ProjectsApi.getProjects(jwt); + switchProject(created.id); + } catch { + toast.error("Could not create project"); + } finally { + acceptingRepoId = null; + } + } + + async function createProject() { + const jwt = auth.jwtToken; + const user = auth.userData; + const trimmed = newProjectName.trim(); + if (!jwt || !trimmed || creatingProject) return; + if (!user?.publicKey) { + toast.error("Finish passphrase setup first"); + return; + } + + creatingProject = true; + try { + const settings = normalizeProjectSettings({ revealOn: createRevealOn }); + const created = await createEncryptedProject( + jwt, + { id: user.id, publicKey: user.publicKey }, + trimmed, + settings, + null, + ); + + await UserApi.updateMe(jwt, { projectCreationDefaults: settings }); + newProjectName = ""; + showCreateDialog = false; + await goto( + resolve("/app/project/[projectId]", { projectId: created.id }), + { replaceState: true }, + ); + lastLoadKey = ""; + await loadShell(created.id); + } catch { + toast.error("Could not create project"); + } finally { + creatingProject = false; + } + } + + async function saveSettings() { + const jwt = auth.jwtToken; + if (!jwt || !activeProject || savingSettings) return; + savingSettings = true; + try { + const updated = await ProjectsApi.updateProject(jwt, activeProject.id, { + settings: settingsDraft, + }); + activeProject = updated; + projects = + projects?.map((project) => + project.id === updated.id ? updated : project, + ) ?? null; + } catch { + toast.error("Failed to save settings"); + } finally { + savingSettings = false; + } + } + + async function renameProject() { + const jwt = auth.jwtToken; + const trimmed = renameProjectName.trim(); + if ( + !jwt || + !activeProject || + !trimmed || + trimmed === activeProject.name || + renamingProject + ) + return; + renamingProject = true; + try { + const updated = await ProjectsApi.updateProject(jwt, activeProject.id, { + name: trimmed, + }); + activeProject = updated; + projects = + projects?.map((project) => + project.id === updated.id ? updated : project, + ) ?? null; + showRenameForm = false; + } catch { + toast.error("Failed to rename project"); + } finally { + renamingProject = false; + } + } + + async function saveRevealSettings() { + await saveSettings(); + revealEditing = false; + } + + async function connectRepository(repo: RepoWithInstallation) { + const jwt = auth.jwtToken; + if (!jwt || !activeProject || connectingIntegration) return; + connectingIntegration = true; + connectingIntegrationRepoId = repo.id; + try { + const integration = await IntegrationsApi.createIntegration(jwt, { + projectId: activeProject.id, + repositoryId: repo.id, + installationEntityId: repo.installationEntityId, + }); + integrations = [ + ...integrations.filter((item) => item.id !== integration.id), + integration, + ]; + closeIntegrationDialog(); + await loadIntegrations(jwt, activeProject.id); + } catch { + toast.error("Failed to connect repository"); + } finally { + connectingIntegration = false; + connectingIntegrationRepoId = null; + } + } + + async function disconnectRepository(integration: Integration) { + const jwt = auth.jwtToken; + if (!jwt || disconnectingIntegrationId) return; + disconnectingIntegrationId = integration.id; + try { + await IntegrationsApi.deleteIntegration(jwt, integration.id); + integrations = integrations.filter((item) => item.id !== integration.id); + if (activeProject) { + void loadIntegrations(jwt, activeProject.id); + } + } catch { + toast.error("Failed to disconnect repository"); + } finally { + disconnectingIntegrationId = null; + } + } + + async function installGithubApp() { + if (!activeProject || installingGithubApp) return; + const jwt = auth.jwtToken; + installingGithubApp = true; + try { + if (publicEnv.githubLocalMock && jwt) { + const { githubInstallationId } = + await IntegrationsApi.bootstrapLocalGithubMock(jwt); + const state = createGitHubAppInstallState(activeProject.id); + if (!state) throw new Error("Failed to start GitHub installation"); + window.location.href = `${publicEnv.appUrl.replace(/\/$/, "")}/app/callbacks/integrations/github?installation_id=${githubInstallationId}&state=${encodeURIComponent(state)}`; + return; + } + const state = createGitHubAppInstallState(activeProject.id); + if (!state) throw new Error("Failed to start GitHub installation"); + window.location.href = `https://github.com/apps/cryptly-dev/installations/new?state=${encodeURIComponent(state)}`; + } catch { + installingGithubApp = false; + toast.error("Failed to start GitHub installation"); + } + } + + function chooseInstallation(installationId: string) { + selectedInstallationEntityId = installationId; + integrationRepoSearch = ""; + } + + async function loadSuggestedUsers() { + const jwt = auth.jwtToken; + if (!jwt || !activeProject || suggestedUsersLoading) return; + suggestedUsersLoading = true; + try { + suggestedUsers = await ProjectsApi.getSuggestedUsers( + jwt, + activeProject.id, + ); + } catch { + suggestedUsers = []; + toast.error("Failed to load suggested users"); + } finally { + suggestedUsersLoading = false; + } + } - + async function createInviteLink(role: "read" | "write" | "admin") { + const jwt = auth.jwtToken; + const project = activeProject; + if (!jwt || !project || creatingInvitation) return; -{#if !project} -

- Loading project… -

-{:else} - -
- + creatingInvitation = true; + try { + const projectKey = await getUnlockedProjectKey(project); + if (!projectKey) { + toast.error("Unlock this browser before creating invitations"); + return; + } + + const keyPair = await AsymmetricCrypto.generateKeyPair(); + const passphrase = invitePassphrase || generateInviteCode(); + const passphraseKey = + await SymmetricCrypto.deriveBase64KeyFromPassphrase(passphrase); + const encryptedPrivateKey = await SymmetricCrypto.encrypt( + keyPair.privateKey, + passphraseKey, + ); + const exportedProjectKey = await SymmetricCrypto.exportAesKey(projectKey); + const encryptedProjectKey = await AsymmetricCrypto.encrypt( + exportedProjectKey, + keyPair.publicKey, + ); + const invitation = await InvitationsApi.createInvitation(jwt, { + projectId: project.id, + temporaryPublicKey: keyPair.publicKey, + temporaryPrivateKey: encryptedPrivateKey, + temporarySecretsKey: encryptedProjectKey, + role, + }); + track("invitation_created", { kind: "link", role }); + + invitePassphrase = passphrase; + lastCreatedInvitation = invitation; + addPeopleStep = "done"; + void loadPendingInvitations(jwt, project); + } catch { + toast.error("Failed to create invitation"); + } finally { + creatingInvitation = false; + } + } + + async function createSuggestedInvitation(role: "read" | "write" | "admin") { + const jwt = auth.jwtToken; + const project = activeProject; + const user = selectedSuggestedUser; + if (!jwt || !project || !user?.publicKey || creatingInvitation) return; + + creatingInvitation = true; + try { + const projectKey = await getUnlockedProjectKey(project); + if (!projectKey) { + toast.error("Unlock this browser before creating invitations"); + return; + } + const exportedProjectKey = await SymmetricCrypto.exportAesKey(projectKey); + const encryptedProjectKey = await AsymmetricCrypto.encrypt( + exportedProjectKey, + user.publicKey, + ); + await ProjectsApi.addEncryptedSecretsKey( + jwt, + project.id, + user.id, + encryptedProjectKey, + ); + await InvitationsApi.createPersonalInvitation(jwt, project.id, { + invitedUserId: user.id, + role, + }); + track("invitation_created", { kind: "direct", role }); + addPeopleStep = "done"; + void loadPendingInvitations(jwt, project); + } catch { + toast.error("Failed to send invitation"); + } finally { + creatingInvitation = false; + } + } + + async function updateSelectedMemberRole(role: "read" | "write" | "admin") { + const jwt = auth.jwtToken; + if (!jwt || !activeProject || !selectedMember || updatingMember) return; + updatingMember = true; + try { + await ProjectsApi.updateMemberRole(jwt, { + projectId: activeProject.id, + memberId: selectedMember.id, + role, + }); + activeProject = { + ...activeProject, + members: activeProject.members.map((member) => + member.id === selectedMember.id ? { ...member, role } : member, + ), + }; + projects = + projects?.map((project) => + project.id === activeProject?.id ? activeProject : project, + ) ?? null; + } catch { + toast.error("Failed to update member role"); + } finally { + updatingMember = false; + } + } + + async function removeSelectedMember() { + const jwt = auth.jwtToken; + if (!jwt || !activeProject || !selectedMember || updatingMember) return; + updatingMember = true; + try { + await ProjectsApi.removeMember(jwt, { + projectId: activeProject.id, + memberId: selectedMember.id, + }); + activeProject = { + ...activeProject, + members: activeProject.members.filter( + (member) => member.id !== selectedMember.id, + ), + }; + projects = + projects?.map((project) => + project.id === activeProject?.id ? activeProject : project, + ) ?? null; + selectedMemberId = null; + } catch { + toast.error("Failed to remove member"); + } finally { + updatingMember = false; + } + } + + async function deleteActiveProject() { + const jwt = auth.jwtToken; + if (!jwt || !activeProject || leavingOrDeleting) return; + leavingOrDeleting = true; + try { + await ProjectsApi.deleteProject(jwt, activeProject.id); + const remaining = + projects?.filter((project) => project.id !== activeProject?.id) ?? []; + projects = remaining; + showDeleteConfirm = false; + const nextProject = remaining[0]; + await goto( + nextProject + ? resolve("/app/project/[projectId]", { projectId: nextProject.id }) + : resolve("/app/project"), + { replaceState: true }, + ); + if (nextProject) { + lastLoadKey = ""; + await loadShell(nextProject.id); + } + } catch { + toast.error("Failed to delete project"); + } finally { + leavingOrDeleting = false; + } + } + + async function leaveActiveProject() { + const jwt = auth.jwtToken; + const userId = auth.userData?.id; + if (!jwt || !activeProject || !userId || leavingOrDeleting) return; + leavingOrDeleting = true; + try { + const leavingProjectId = activeProject.id; + await ProjectsApi.removeMember(jwt, { + projectId: leavingProjectId, + memberId: userId, + }); + const remaining = + projects?.filter((project) => project.id !== leavingProjectId) ?? []; + projects = remaining; + showDeleteConfirm = false; + const nextProject = remaining[0]; + await goto( + nextProject + ? resolve("/app/project/[projectId]", { projectId: nextProject.id }) + : resolve("/app/project"), + { replaceState: true }, + ); + if (nextProject) { + lastLoadKey = ""; + await loadShell(nextProject.id); + } + } catch { + toast.error("Failed to leave project"); + } finally { + leavingOrDeleting = false; + } + } + + async function copyInviteField(field: "link" | "code", value: string) { + if (!value) return; + await navigator.clipboard.writeText(value); + inviteCopiedField = field; + setTimeout(() => { + if (inviteCopiedField === field) inviteCopiedField = null; + }, 2000); + } + + function displayMemberName(member: Project["members"][number]) { + return member.id === auth.userData?.id + ? "You" + : member.displayName || member.email || member.id; + } + + function displayInitial(member: Project["members"][number]) { + return (member.displayName || member.email || "U").charAt(0).toUpperCase(); + } + + function switchProject(targetProjectId: string) { + // Navigate first when there are unsaved edits so the unsaved-changes guard can stop the + // switch before the editor swaps to the next project. + if (secretsEditorNavGuard.isDirty) { + void goto( + resolve("/app/project/[projectId]", { projectId: targetProjectId }), + ); + return; + } + void loadShell(targetProjectId, { navigateAfterLoad: true }); + } + + function onMobileProjectChange(event: Event) { + const select = event.currentTarget as HTMLSelectElement; + const value = select.value; + select.value = displayedProjectId; + if (value === "__add_project__") { + openCreateDialog(); + return; + } + if (value && value !== displayedProjectId) switchProject(value); + } + + function onProjectLinkClick(event: MouseEvent, targetProjectId: string) { + if ( + targetProjectId === displayedProjectId || + targetProjectId === pendingProjectId + ) { + event.preventDefault(); + return; + } + if ( + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + event.button !== 0 + ) + return; + event.preventDefault(); + switchProject(targetProjectId); + } + + +{#snippet connectionIcon(className: string)} + + + + +{/snippet} + +{#snippet wizardStepper(current: number, total: number)} +
+ {#each Array.from({ length: total }, (_, index) => index + 1) as step (step)} + + {/each}
- {#if tab === "history"} - - {:else if tab === "settings"} - +{/snippet} + +{#snippet hoverTip(text: string, extra = "")} + + {text} + +{/snippet} + +{#snippet userMenuContent(mobile: boolean)} + {#if mobile} +
+

+ {auth.userData?.displayName || auth.userData?.email || "User"} +

+ {#if auth.userData?.email} +

+ {auth.userData.email} +

+ {/if} +
+
+ {/if} + {#if editingDisplayName} +
+ +
+ + { + if (event.key === "Enter") void saveDisplayName(); + if (event.key === "Escape") cancelDisplayNameEdit(); + }} + /> + + +
+
+ {:else} + {/if} +
+ +{/snippet} + + { + if (event.key !== "Escape") return; + if (showUserMenu) closeUserMenu(); + else if (showCreateDialog) closeCreateDialog(); + else if (showIntegrationDialog) closeIntegrationDialog(); + else if (showAddPeopleDialog) closeAddPeopleDialog(); + else if (selectedMember) closeMemberDialog(); + }} +/> + +
+ + + +
+ {#if isSearching} +
+
+ +
+ + + +
+ {#if !searchableProjectsLoading} + + {searchResults.length} result{searchResults.length !== 1 + ? "s" + : ""} + + {/if} +
+
+ +
+ {#if searchableProjectsLoading} +
+
+

Searching...

+
+ {:else if searchResults.length === 0} +
+ +

No projects found

+

+ Try a different search term +

+
+ {:else} +
+ {#each searchResults as result (result.id)} + {@const mobileSnippet = contentSnippet( + result.decryptedContent, + searchQuery, + 20, + 40, + )} + {@const desktopSnippet = contentSnippet( + result.decryptedContent, + searchQuery, + 30, + 50, + )} + openSearchResult(event, result.id)} + class="group flex cursor-pointer items-start gap-3 rounded-lg border border-border/50 p-3 transition-colors active:bg-neutral-800 md:p-4 md:hover:bg-neutral-800" + > +
+ +
+
+

+ {result.name} +

+ {#if mobileSnippet} +

+ {mobileSnippet} +

+ + {/if} +
+
+ {/each} +
+ {/if} +
+ {/if} +
+
+ Cryptly +
+ + + +
+ +
+ + {#if showUserMenu} + + + {/if} +
+
+
+
+
+ {#each mobileTabs as tab (tab.id)} + {@const Icon = tab.icon} + + {/each} +
+ + +
+ {#if activeProject} +
+ +
+ {/if} +
+
+ + + +
+ {#if loading && !activeProject} + + {:else if loadError && !activeProject} +
+
+

+ We couldn't finish loading +

+

{loadError}

+
+ + +
+
+
+ {:else} + +
+ { + const jwt = auth.jwtToken; + if (jwt && activeProject) void loadHistory(jwt, activeProject); + }} + onConnectIntegrations={() => setActiveTab("integrations")} + /> +
+ {#if activeTab === "history"} +
+ {#if historyLoading} +
+ Loading history... +
+ {:else if historyLocked} +
+
+

+ Unlock your safe to view history +

+

{historyLoadMessage}

+

+ Your account session is still active. Enter your Cryptly + passphrase in the unlock dialog to continue. +

+
+
+ {:else if historyLoadMessage} +
+
+

{historyLoadMessage}

+ +
+
+ {:else if historyVersions.length === 0} +
+
+

No history available yet.

+

Make changes to see version history.

+
+
+ {:else if mobileQuery.current} +
+
+ {#each historyVersions as version (version.id)} + {@const isSelected = selectedHistory?.id === version.id} + {@const volume = version.additions + version.deletions} + {@const addRatio = version.additions / Math.max(volume, 1)} + {@const barWidthPct = + (volume / + Math.max( + ...historyVersions.map( + (v) => v.additions + v.deletions, + ), + 1, + )) * + 100} + + {/each} +
+
+ {#if selectedHistory} +
+ {#key selectedHistory.id} + + {/key} +
+ {:else} +
+
+ Select a version to see changes +
+
+ {/if} +
+
+ {:else} +
+
+
+ {#if historyMode} + {@const activeSuggestion = historySearchSuggestions.find( + (item) => item.mode === historyMode, + )} + + {:else} + + {/if} + { + historySuggestionIndex = 0; + if (historyQuery.trim().length === 0) { + historyMode = null; + historySuggestionsOpen = false; + } else if (!historyMode) { + historySuggestionsOpen = true; + } + }} + onfocus={() => { + if (historyQuery.trim() && !historyMode) + historySuggestionsOpen = true; + }} + /> +
+ {#if historyQuery || historyMode} + + {:else} + / + {/if} +
+
+ {#if historyQuery.trim() && historySuggestionsOpen && !historyMode} +
+
+ What do you mean by "{historyQuery.trim()}"? +
+
+ {#each historySearchSuggestions as suggestion, index (suggestion.mode)} + {@const SuggestionIcon = suggestion.icon} + {@const isHighlighted = + index === historySuggestionIndex} + + {/each} +
+
+
+ + ↑ + ↓ + navigate + + + ↵ + apply + + + esc + dismiss + +
+
+
+ {/if} +
+ +
+
+ {#if selectedHistoryDay} + + {/if} + {#each [{ key: "all", label: "All" }, { key: "24h", label: "-24h" }, { key: "7d", label: "-7d" }, { key: "30d", label: "-30d" }] as range (range.key)} + + {/each} +
+ +
+
+ {#each visibleHistoryAuthors as author (author.id)} + + {/each} +
+ {#if overflowHistoryAuthors.length > 0} +
+ + {#if historyAuthorsOverflowOpen} + +
+
+ {#each overflowHistoryAuthors as author (author.id)} + + {/each} +
+
+ {/if} +
+ {/if} +
+
+ +
+ {#if filteredHistory.length === 0} +
+ No results +
+ {:else} + {#each filteredHistory as version (version.id)} + {@const isSelected = selectedHistory?.id === version.id} + {@const volume = version.additions + version.deletions} + {@const addRatio = + version.additions / Math.max(volume, 1)} + {@const barWidthPct = (volume / maxHistoryVolume) * 100} + + {/each} + {/if} +
+ +
+ (selectedHistoryDay = key)} + /> +
+ +
+
+ + ↑ + ↓ + · + j + k + navigate + + + / + search + +
+ {filteredHistory.length}/{historyVersions.length} +
+
+ +
+ {#if selectedHistory} +
+ +
+
+ {selectedHistory.author?.displayName || + "System"} + {#if selectedHistory.author?.id === auth.userData?.id} + · you + {/if} + edited + + {getCompactRelativeTime(selectedHistory.createdAt)} + +
+
+ {formatFullDate(selectedHistory.createdAt)} +
+
+
+ + + {selectedHistory.additions} + + + + {selectedHistory.deletions} + +
+
+
+ {#key selectedHistory.id} + + {/key} +
+ {:else} +
+ Pick an entry to view its changes +
+ {/if} +
+ {/if} +
+ {:else if activeTab === "members"} +
+
+
+
+

Members

+

+ Manage who has access to {activeProject?.name} +

+
+ {#if isAdmin} + + {/if} +
+
+
+ +

Members

+
+
+ {#each activeProject?.members ?? [] as member (member.id)} + {@const meta = + roleMeta[member.role as keyof typeof roleMeta] ?? + roleMeta.read} + {@const RoleIcon = meta.icon} + + {/each} +
+
+ {#if isAdmin && activeInvites.length > 0} +
+
+ +

Active invites

+
+
+ {#each activeInvites as invite (`${invite.type}-${invite.data.id}`)} +
+
+ {#if invite.type === "personal"} + {#if invite.data.invitedUser.avatarUrl} + {invite.data.invitedUser.displayName} + {:else} + {(invite.data.invitedUser.displayName || "U") + .charAt(0) + .toUpperCase()} + {/if} + {:else} + + {/if} +
+
+
+ {invite.type === "personal" + ? invite.data.invitedUser.displayName + : "Invite link"} +
+
+ {#if invite.type === "personal"} + {invite.data.role} · {getRelativeTime( + invite.data.createdAt, + )} + {:else} + ID: {invite.data.id.slice(-8)} + {/if} +
+
+
+ {#if invite.type === "link"} + {@const inviteId = invite.data.id} + + {/if} + +
+
+ {/each} +
+
+ {/if} +
+
+ {:else if activeTab === "integrations"} +
+
+
+

Sync externally

+

+ Integrate your {activeProject?.name} secrets with Github Actions. +

+
+
+ {#if integrations.length > 0} +
+ {#each integrations as integration (integration.id)} + {@const repoHref = + integration.repositoryData?.owner && + integration.repositoryData?.name + ? `https://github.com/${integration.repositoryData.owner}/${integration.repositoryData.name}` + : null} +
+
+ {#if integration.repositoryData?.avatarUrl} + {integration.repositoryData.owner} + {:else} + + {/if} +
+
+
+ {integration.repositoryData?.name ?? "Unknown"} +
+
+ {integration.repositoryData?.owner ?? ""} +
+
+
+ {#if repoHref} + + + Open + + {/if} + {#if isAdmin} + + {:else} + + {@render connectionIcon("size-4")} + + {/if} +
+
+ {/each} +
+ {/if} + {#if isAdmin && !integrationsLoading} + + {:else if !integrationsLoading && integrations.length === 0} +
+ No repositories connected yet +
+ {/if} +
+ {#if !integrationsLoading && integrations.length === 0 && suggestedIntegrations.length > 0} +
+
+ + Suggested +
+

+ We found these repositories on your GitHub account based on + your Cryptly project name. +

+
+ {#each suggestedIntegrations as repo (repo.id)} + + {/each} +
+
+ {/if} + {#if integrations.length > 0} +
+ +

+ Hit + + Push + + in the + + + Editor + + to sync your secrets with GitHub Actions. +

+
+ {/if} +
+
+ {:else if activeTab === "settings"} +
+
+
+

Settings

+

+ Manage settings for {activeProject?.name} +

+
+ +
+
+ +

Rename project

+
+ {#if !isAdmin} +
+
+ Only Admins can rename + projects. +
+
+ {:else if showRenameForm} +
+ + { + if (event.key === "Enter") { + event.preventDefault(); + void renameProject(); + } + }} + /> +
+ + +
+
+ {:else} +
+
+
+
+ {activeProject?.name} +
+
+ +
+
+ {/if} +
+ +
+
+ +

Reveal on

+
+ {#if !isAdmin} +
+
+ Only Admins can change + project reveal settings. +
+
+ {:else if revealEditing} +
+ +
+ + +
+
+ {:else} + {@const revealMeta = + REVEAL_ON_OPTIONS.find( + (option) => option.key === revealOnSetting, + ) ?? REVEAL_ON_OPTIONS[1]} + {@const RevealIcon = revealMeta.icon} +
+
+ +
+
+ {revealMeta.label} +
+
+ {revealMeta.description} +
+
+ +
+
+ {/if} +
+ +
+
+ +

Danger zone

+
+ {#if showDeleteConfirm} +
+
+ {isAdmin + ? "Are you sure you want to delete this project?" + : "Are you sure you want to leave this project?"} +
+
+ {isAdmin + ? "This action cannot be undone. This will permanently delete the project and remove all members' access." + : "You will lose access to this project and its secrets. You'll need a new invitation to rejoin."} +
+
+ + +
+
+ {:else} +
+
+
+
+ {isAdmin ? "Delete project" : "Leave project"} +
+
+ {isAdmin + ? "Permanently delete this project for all members" + : "Remove yourself from this project"} +
+
+ +
+
+ {/if} +
+
+
+ {/if} + {/if} +
+
+
+ +{#if selectedMember} + + +{/if} + +{#if showAddPeopleDialog} + + +{/if} + +{#if showIntegrationDialog} + + +{/if} + +{#if showCreateDialog} + +
{ + event.preventDefault(); + void createProject(); + }} + > + {#if !creatingProject} + + {/if} + +
+

Add a new project

+

+ Name your project and choose how secrets should reveal in the editor. + We'll remember this for next time. +

+
+ +
+ + + +
+ +
+ Reveal on + +
+ +
+ +
+
{/if} + + diff --git a/apps/web/src/lib/projects/ui/ProjectTopbar.svelte b/apps/web/src/lib/projects/ui/ProjectTopbar.svelte deleted file mode 100644 index 8d2f6158..00000000 --- a/apps/web/src/lib/projects/ui/ProjectTopbar.svelte +++ /dev/null @@ -1,49 +0,0 @@ - - -
-
- Projects - / - {projectName} -
- - {@render children?.()} -
diff --git a/apps/web/src/lib/projects/ui/ProjectUnsavedNavGuard.svelte b/apps/web/src/lib/projects/ui/ProjectUnsavedNavGuard.svelte index 78cbbd2e..c1cb296e 100644 --- a/apps/web/src/lib/projects/ui/ProjectUnsavedNavGuard.svelte +++ b/apps/web/src/lib/projects/ui/ProjectUnsavedNavGuard.svelte @@ -2,24 +2,23 @@ import { browser } from "$app/environment"; import { beforeNavigate, goto } from "$app/navigation"; import { tick } from "svelte"; + import { fade } from "svelte/transition"; + import { Command } from "lucide-svelte"; import { secretsEditorNavGuard } from "$lib/secrets/secrets-editor-nav-guard.svelte"; - import Button from "$lib/ui/Button.svelte"; - import Dialog from "$lib/ui/Dialog.svelte"; - import Kbd from "$lib/ui/Kbd.svelte"; let dialogOpen = $state(false); let saving = $state(false); let saveFailed = $state(false); let pendingDestination = $state(null); let snapshot = $state({ projectName: "", externallyUpdated: false }); + let firstButton: HTMLButtonElement | undefined = $state(); + + const kbdClass = + "pointer-events-none inline-flex h-6 w-fit min-w-5 select-none items-center justify-center gap-1 rounded-sm border-0 bg-primary-foreground/20 px-1.5 font-sans text-xs font-medium text-primary-foreground"; const canSave = $derived( !saving && !secretsEditorNavGuard.readOnly && !snapshot.externallyUpdated, ); - const canPush = $derived( - secretsEditorNavGuard.hasIntegrations && - Boolean(secretsEditorNavGuard.push), - ); function shouldBlockNavigation(fromUrl: URL, toUrl: URL): boolean { if (!secretsEditorNavGuard.isDirty) return false; @@ -55,6 +54,7 @@ }; saveFailed = false; dialogOpen = true; + void tick().then(() => firstButton?.focus()); }); $effect(() => { @@ -68,12 +68,15 @@ $effect(() => { if (!dialogOpen) return; - // Capture phase so the editor's own ⌘S handler doesn't also fire. Esc is the dialog's. + // Capture phase so the editor's own ⌘S handler doesn't also fire. const handler = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key === "s") { event.preventDefault(); event.stopImmediatePropagation(); void handleSave(); + } else if (event.key === "Escape" && !saving) { + event.preventDefault(); + closeDialog(); } }; document.addEventListener("keydown", handler, true); @@ -95,7 +98,7 @@ if (dest) void goto(dest); } - async function handleSave(push = false) { + async function handleSave() { if (!canSave || !secretsEditorNavGuard.save) return; saving = true; saveFailed = false; @@ -105,7 +108,6 @@ saveFailed = true; return; } - if (push) await secretsEditorNavGuard.push?.(); await proceed(); } finally { saving = false; @@ -118,55 +120,92 @@ } - -

- {#if snapshot.projectName} - You have unsaved edits to {snapshot.projectName}. Save now, discard them, or keep editing. - {:else} - You have unsaved edits. Save now, discard them, or keep editing. - {/if} -

+{#if dialogOpen} + +
+{/if} diff --git a/apps/web/src/lib/projects/ui/ProjectsPage.svelte b/apps/web/src/lib/projects/ui/ProjectsPage.svelte index 03f7f5f5..34d3451b 100644 --- a/apps/web/src/lib/projects/ui/ProjectsPage.svelte +++ b/apps/web/src/lib/projects/ui/ProjectsPage.svelte @@ -1,100 +1,262 @@ - - Projects · Cryptly - - -{#if empty} -
-
-
-

Name your first project

-

- Secrets you store in it are encrypted in your browser, before they +{#if loadError} +

+

+ {loadError} +

+ +
+{:else if profileError} +
+

+ {profileError} +

+ +
+{:else if !profileLoading && !projectsLoading && projects && projects.length === 0} +
+
+
+
+ New project +
+

+ Name your first project. +

+

+ Secrets you store in it are encrypted in your browser — before they leave your machine.

- {#if !keysReady} -

- Set up your passphrase first. - + Set up your passphrase and keys to create a project. + Continue setup

{/if} -
- - - -
- + {#if createError} +

{createError}

+ {/if} +
+
+ + +
+
+
+{:else} + {/if} diff --git a/apps/web/src/lib/projects/ui/RevealOnPicker.svelte b/apps/web/src/lib/projects/ui/RevealOnPicker.svelte index 289b3bcc..10d03d8e 100644 --- a/apps/web/src/lib/projects/ui/RevealOnPicker.svelte +++ b/apps/web/src/lib/projects/ui/RevealOnPicker.svelte @@ -1,54 +1,122 @@ - +
+
+ {#each REVEAL_ON_OPTIONS as option, index (option.key)} + {@const Icon = option.icon} + {@const isActive = index === activeIndex} + + {/each} +
+ +
+
+
+
+ {#each REVEAL_ON_OPTIONS as option, index (option.key)} + + {/each} +
+
+ +
+ {active.label} + {active.description} +
+
diff --git a/apps/web/src/lib/projects/ui/YearHeatmap.svelte b/apps/web/src/lib/projects/ui/YearHeatmap.svelte new file mode 100644 index 00000000..a265e89f --- /dev/null +++ b/apps/web/src/lib/projects/ui/YearHeatmap.svelte @@ -0,0 +1,216 @@ + + +
+
+ {#each heatmapData.monthLabels as month (month.weekIdx)} + + {month.label} + + {/each} +
+ +
+
+ Mon + Wed + Fri +
+ + +
+ +
+ Less + {#each [0, 1, 3, 6, 9] as level (level)} + + {/each} + More +
+ + {#if hover} +
+
+ {formatTooltip(hover.day.date, hover.day.count)} +
+
+ {/if} +
diff --git a/apps/web/src/lib/projects/ui/settings/AddPeopleDialog.svelte b/apps/web/src/lib/projects/ui/settings/AddPeopleDialog.svelte deleted file mode 100644 index 7d646f97..00000000 --- a/apps/web/src/lib/projects/ui/settings/AddPeopleDialog.svelte +++ /dev/null @@ -1,354 +0,0 @@ - - -{#snippet option( - Icon: typeof Link, - label: string, - hint: string, - onclick: () => void, -)} - -{/snippet} - -{#snippet field(id: "link" | "code", label: string, value: string)} - -{/snippet} - - - {#if step === "type"} -
- {@render option( - Link, - "Invite link", - "A single-use link plus a code.", - () => { - kind = "link"; - step = "role"; - }, - )} - {@render option( - UserPlus, - "Suggested person", - "Someone you already work with in Cryptly.", - () => void choosePerson(), - )} -
- {:else if step === "person"} -
- {#if people === null} -

Loading…

- {:else} - {#each people as user (user.id)} - - {:else} -

- No suggestions yet. People appear here once you share another - project with them. -

- {/each} - {/if} -
- {:else if step === "role"} -
- {#each ROLES as role (role.value)} - - {/each} -
- {:else if kind === "link"} -
- {@render field("link", "Invite link", inviteUrl)} - {@render field("code", "Invitation code", inviteCode)} -

- You won't see the code again after closing this dialog. Send the link - and the code through different channels. -

-
- {/if} - - {#if step === "person" || step === "role"} -
- -
- {:else if step === "done"} -
- -
- {/if} -
diff --git a/apps/web/src/lib/projects/ui/settings/ConfirmDialog.svelte b/apps/web/src/lib/projects/ui/settings/ConfirmDialog.svelte deleted file mode 100644 index 35c55afe..00000000 --- a/apps/web/src/lib/projects/ui/settings/ConfirmDialog.svelte +++ /dev/null @@ -1,81 +0,0 @@ - - - (typed = "")} -> -
- {#if confirmText} - - {/if} -
- - -
-
-
diff --git a/apps/web/src/lib/projects/ui/settings/ConnectRepositoryDialog.svelte b/apps/web/src/lib/projects/ui/settings/ConnectRepositoryDialog.svelte deleted file mode 100644 index 458cdcdc..00000000 --- a/apps/web/src/lib/projects/ui/settings/ConnectRepositoryDialog.svelte +++ /dev/null @@ -1,286 +0,0 @@ - - -{#snippet repoRow(repo: Repo)} - -{/snippet} - - - {#if installations === null} -

Loading repositories…

- {:else if installation} - -
- {#each installationRepos as repo (repo.id)} - {@render repoRow(repo)} - {:else} -

- {query.trim() - ? "No repositories match." - : "Every repository here is already connected."} -

- {/each} -
-
- {#if installations.length > 1} - - {/if} - -
- {:else if installations.length === 0} -
-

- Install the Cryptly GitHub App on the account or organization that owns - the repository. -

- -
- {:else} - {#if suggested.length > 0} -
-

Suggested

-
- {#each suggested as repo (repo.id)} - {@render repoRow(repo)} - {/each} -
-
- {/if} -
-

Installations

-
- {#each installations as i (i.id)} - - {/each} - -
-
- {/if} -
diff --git a/apps/web/src/lib/projects/ui/settings/DangerSection.svelte b/apps/web/src/lib/projects/ui/settings/DangerSection.svelte deleted file mode 100644 index 611d6edd..00000000 --- a/apps/web/src/lib/projects/ui/settings/DangerSection.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - -{#snippet deleteAction()} - -{/snippet} - - - - {isAdmin - ? "Removes the project and every version, for all members." - : "Remove yourself from this project."} - - - -{#if isAdmin} - -{:else} - -{/if} diff --git a/apps/web/src/lib/projects/ui/settings/GeneralSection.svelte b/apps/web/src/lib/projects/ui/settings/GeneralSection.svelte deleted file mode 100644 index a002597f..00000000 --- a/apps/web/src/lib/projects/ui/settings/GeneralSection.svelte +++ /dev/null @@ -1,160 +0,0 @@ - - -{#snippet nameActions()} - {#if renaming} - - - {:else} - - {/if} -{/snippet} - -{#snippet revealActions()} - void saveRevealOn(next)} - /> -{/snippet} - -{#snippet idActions()} - void copyId()}> - {#if copiedId} - - {:else} - - {/if} - -{/snippet} - - - - {#if renaming} - input.select()} - aria-label="Project name" - autocomplete="off" - spellcheck="false" - data-1p-ignore - disabled={savingName} - class="-my-1 -ml-2 h-[30px] min-w-0 flex-1 rounded-lg bg-field px-2 shadow-field outline-none focus:shadow-[inset_0_0_0_1px_var(--fg3)]" - onkeydown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void saveName(); - } else if (event.key === "Escape") { - event.preventDefault(); - renaming = false; - } - }} - /> - {:else} - {project.name} - {/if} - - - {reveal.label} - {reveal.description} - - - {project.id} - - diff --git a/apps/web/src/lib/projects/ui/settings/GithubSection.svelte b/apps/web/src/lib/projects/ui/settings/GithubSection.svelte deleted file mode 100644 index 8df9084d..00000000 --- a/apps/web/src/lib/projects/ui/settings/GithubSection.svelte +++ /dev/null @@ -1,196 +0,0 @@ - - - - -{#snippet connect()} - -{/snippet} - - - {#each integrations ?? [] as integration (integration.id)} - {@const url = repoUrl(integration)} - {@const status = syncStatus(integration)} - {@const StatusIcon = status.icon} - - {#if url} - - {repoName(integration)} - - {:else} - {repoName(integration)} - {/if} - {#snippet actions()} - {#if isAdmin} - { - removing = integration; - removeOpen = true; - }} - > - - - {/if} - - {status.text} - - {/snippet} - - {:else} -
- {integrations === null ? "Loading…" : "No repositories connected"} -
- {/each} -
- -{#if isAdmin} - void loadIntegrations()} - /> -{/if} - - diff --git a/apps/web/src/lib/projects/ui/settings/MembersSection.svelte b/apps/web/src/lib/projects/ui/settings/MembersSection.svelte deleted file mode 100644 index 0d6139fa..00000000 --- a/apps/web/src/lib/projects/ui/settings/MembersSection.svelte +++ /dev/null @@ -1,330 +0,0 @@ - - - - -{#snippet roleText(role: ProjectRole)} - {roleLabel(role)} -{/snippet} - -{#snippet addPeople()} - -{/snippet} - - - {#each members as member (member.id)} - {@const isMe = member.id === me} - {@const name = memberName(member)} - - - {name} - {#if member.email && member.email !== name} - {member.email} - {/if} - {#snippet actions()} - {#if isAdmin && !isMe} - { - removing = member; - removeOpen = true; - }} - > - - - - {:else} - {@render roleText(member.role)} - {/if} - {/snippet} - - {/each} - {#each invites as invite (invite.data.id)} - - - {invite.type === "link" - ? "Invite link" - : invite.data.invitedUser.displayName} - {shortDate(invite.data.createdAt)} - {#snippet actions()} - {#if invite.type === "link"} - {@const inviteId = invite.data.id} - void copyInviteLink(inviteId)} - > - {#if copiedInviteId === inviteId} - - {:else} - - {/if} - - {/if} - void revoke(invite)} - > - - - {@render roleText(invite.data.role)} - {/snippet} - - {/each} - - -{#if isAdmin} - void loadInvites()} - /> -{/if} - - diff --git a/apps/web/src/lib/projects/ui/settings/ProjectSettingsView.svelte b/apps/web/src/lib/projects/ui/settings/ProjectSettingsView.svelte deleted file mode 100644 index e2f0077f..00000000 --- a/apps/web/src/lib/projects/ui/settings/ProjectSettingsView.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - diff --git a/apps/web/src/lib/secrets/env-doc.ts b/apps/web/src/lib/secrets/env-doc.ts deleted file mode 100644 index d251868f..00000000 --- a/apps/web/src/lib/secrets/env-doc.ts +++ /dev/null @@ -1,412 +0,0 @@ -/** - * The secrets document is plain `.env` text: the single source of truth that gets encrypted - * and saved. The table view reads groups and secrets from it and edits it with line surgery: - * each operation replaces exactly the lines it touches, so everything else round-trips byte - * for byte (line endings included). - * - * Groups: blank lines split them; a `#` comment as a group's first line names it. - */ - -const SECRET_LINE = /^\s*(export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=(.*)$/; -export const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/; - -export type Quote = '"' | "'" | ""; - -export interface EnvSecret { - /** Key plus occurrence ("API_KEY", then "API_KEY#2"), stable while other lines change. */ - id: string; - key: string; - /** Decoded value: quotes stripped, escapes in double quotes resolved. */ - value: string; - quote: Quote; - exported: boolean; - /** First and last line index (inclusive); multiline quoted values span several lines. */ - start: number; - end: number; - /** Where the value starts in the first line (right after `=`). */ - valueCol: number; - /** The value exactly as written (quotes and escapes included), lines joined with `\n`. */ - raw: string; - /** What follows the value on its last line, e.g. ` # default`. */ - tail: string; -} - -export interface EnvGroup { - index: number; - /** From the group's leading `#` comment; null when it has none ("Untitled"). */ - name: string | null; - /** Line index of that comment. */ - header: number | null; - start: number; - end: number; - secrets: EnvSecret[]; -} - -export interface EnvDoc { - lines: string[]; - eol: string; - groups: EnvGroup[]; - secrets: EnvSecret[]; -} - -export function parseEnv(text: string): EnvDoc { - const lines = text.split(/\r?\n/); - const eol = text.includes("\r\n") ? "\r\n" : "\n"; - const groups: EnvGroup[] = []; - const secrets: EnvSecret[] = []; - const seen = new Map(); - let group: EnvGroup | null = null; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (line.trim() === "") { - group = null; - continue; - } - if (!group) { - group = { - index: groups.length, - name: null, - header: null, - start: i, - end: i, - secrets: [], - }; - groups.push(group); - } - group.end = i; - - if (line.trimStart().startsWith("#")) { - // A commented-out secret (`# OLD_KEY=...`) is a note, never a group name: its value - // would show in plain text as the title. - const body = line.replace(/^\s*#\s*/, ""); - if (group.start === i && !SECRET_LINE.test(body)) { - group.header = i; - group.name = groupName(line); - } - continue; - } - - const match = SECRET_LINE.exec(line); - if (!match) continue; - const [, exported, key, rest] = match; - const read = readValue(lines, i, rest); - const n = (seen.get(key) ?? 0) + 1; - seen.set(key, n); - const secret: EnvSecret = { - id: n === 1 ? key : `${key}#${n}`, - key, - exported: Boolean(exported), - start: i, - valueCol: line.length - rest.length, - ...read, - }; - const end = read.end; - group.secrets.push(secret); - secrets.push(secret); - group.end = end; - i = end; - } - - return { lines, eol, groups, secrets }; -} - -/** `# --- Payments ---` → "Payments"; null when nothing is left. */ -function groupName(line: string): string | null { - const name = line.replace(/^\s*#[\s#=-]*/, "").replace(/[\s#=-]*$/, ""); - return name || null; -} - -/** - * Same rules as dotenv. The GitHub push (`monaco/parser.ts`) and the .env view masking use it too: - * a quoted value runs to its first closing quote, across lines if needed, and that quote must - * end its line (a `# comment` may follow). Otherwise the quote is just a character of a - * one-line unquoted value, so an unclosed quote never swallows the lines below it. - */ -function readValue( - lines: string[], - start: number, - rest: string, -): Pick { - const q = rest[0]; - if (q === '"' || q === "'") { - let text = rest; - let end = start; - let pos = 1; - for (;;) { - for (; pos < text.length; pos++) { - if (q === '"' && text[pos] === "\\") { - pos++; - continue; - } - if (text[pos] === q) { - if (!/^\s*(#.*)?$/.test(text.slice(pos + 1))) break; - const body = text.slice(1, pos); - return { - value: q === '"' ? unescape(body) : body, - quote: q, - end, - raw: text.slice(0, pos + 1), - tail: text.slice(pos + 1), - }; - } - } - if (pos < text.length || end + 1 >= lines.length) break; - end++; - text += "\n" + lines[end]; - } - } - const tail = /\s+#.*$/.exec(rest)?.[0] ?? ""; - const raw = rest.slice(0, rest.length - tail.length); - return { value: raw.trim(), quote: "", end: start, raw, tail }; -} - -function unescape(body: string): string { - return body.replace(/\\([\\"nr])/g, (_, c: string) => - c === "n" ? "\n" : c === "r" ? "\r" : c, - ); -} - -/** - * Writes a value the way dotenv reads it back, never adding backslash escapes (other tools - * would keep them): unquoted when nothing needs quoting, else `prefer` when it can hold the - * value, else single quotes, else double quotes. - */ -export function formatValue(value: string, prefer: Quote = ""): string { - const single = !value.includes("'"); - // Inside double quotes \n \r \" \\ are escapes for us and the GitHub push. - const double = !value.includes('"') && !/\\[nr"\\]/.test(value); - if (prefer === "" && !/[\s#"'`]/.test(value)) return value; - if (prefer === '"' && double) return `"${value}"`; - if (single) return `'${value}'`; - if (double) return `"${value}"`; - // Both quote characters inside: the one case that needs escaping. - return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; -} - -function secretLine(key: string, value: string) { - return `${key}=${formatValue(value)}`; -} - -function splice( - doc: EnvDoc, - start: number, - remove: number, - insert: string[] = [], -): string { - const lines = doc.lines.slice(); - lines.splice(start, remove, ...insert.flatMap((l) => l.split("\n"))); - return lines.join(doc.eol); -} - -/** Adds a blank line and `block` after the last non-blank line: a new group at the end. */ -function appendBlock(doc: EnvDoc, block: string[]): string { - let last = doc.lines.length - 1; - while (last >= 0 && doc.lines[last].trim() === "") last--; - if (last < 0) return block.join(doc.eol); - return splice(doc, last + 1, 0, ["", ...block]); -} - -/** - * Changes one secret in place: a rename replaces only the key, a new value keeps the quote - * style when it can, and `export`, spacing and a trailing `# comment` always stay. - */ -export function setSecret( - text: string, - id: string, - key: string, - value: string, -): string { - const doc = parseEnv(text); - const s = doc.secrets.find((x) => x.id === id); - if (!s || (s.key === key && s.value === value)) return text; - const prefix = doc.lines[s.start] - .slice(0, s.valueCol) - .replace( - /^(\s*(?:export\s+)?)[A-Za-z_][A-Za-z0-9_.-]*/, - (_, lead) => lead + key, - ); - const lead = s.quote ? "" : (/^\s*/.exec(s.raw)?.[0] ?? ""); - const raw = value === s.value ? s.raw : lead + formatValue(value, s.quote); - return splice(doc, s.start, s.end - s.start + 1, [prefix + raw + s.tail]); -} - -/** Appends a secret to group `index`, or starts a new group at the end if there is none. */ -export function addSecret( - text: string, - index: number, - key: string, - value: string, -): string { - const doc = parseEnv(text); - const g = doc.groups[index]; - const line = secretLine(key, value); - return g ? splice(doc, g.end + 1, 0, [line]) : appendBlock(doc, [line]); -} - -export function removeSecrets(text: string, ids: Iterable): string { - const doc = parseEnv(text); - const remove = new Set(ids); - const lines = doc.lines.slice(); - for (const s of doc.secrets.filter((x) => remove.has(x.id)).reverse()) - lines.splice(s.start, s.end - s.start + 1); - return lines.join(doc.eol); -} - -export function addGroup(text: string, name: string): string { - return appendBlock(parseEnv(text), [`# ${name}`]); -} - -/** Rewrites the group's naming comment (keeping its dashes), or adds one. */ -export function renameGroup(text: string, index: number, name: string): string { - const doc = parseEnv(text); - const g = doc.groups[index]; - if (!g || g.name === name) return text; - if (g.header === null) return splice(doc, g.start, 0, [`# ${name}`]); - const m = /^(\s*#[\s#=-]*)(.*?)([\s#=-]*)$/.exec(doc.lines[g.header]); - const line = m?.[2] ? m[1] + name + m[3] : `# ${name}`; - return splice(doc, g.header, 1, [line]); -} - -/** Removes a whole group block plus one neighbouring blank line. */ -export function removeGroup(text: string, index: number): string { - const doc = parseEnv(text); - const g = doc.groups[index]; - if (!g) return text; - let { start, end } = g; - if (start > 0 && doc.lines[start - 1].trim() === "") start--; - else if (end + 1 < doc.lines.length && doc.lines[end + 1].trim() === "") - end++; - return splice(doc, start, end - start + 1); -} - -export type Anchor = - | { kind: "after" | "before"; id: string } - | { kind: "top"; group: number }; - -/** - * Where a secret from `base` (the saved version) belongs in `doc`: after its nearest earlier - * neighbour that still exists, before its nearest later one, or at the top of its group - * (matched by name). Places crossed-out deleted rows and restores them. - */ -export function anchorOf( - doc: EnvDoc, - base: EnvDoc, - secret: EnvSecret, -): Anchor | null { - const group = base.groups.find((g) => g.secrets.includes(secret)); - if (!group) return null; - // Stay in the group of the same name when there is one. - const named = group.name - ? doc.groups.find((g) => g.name === group.name) - : undefined; - const ids = new Set((named ?? doc).secrets.map((s) => s.id)); - const at = group.secrets.indexOf(secret); - for (let i = at - 1; i >= 0; i--) - if (ids.has(group.secrets[i].id)) - return { kind: "after", id: group.secrets[i].id }; - for (let i = at + 1; i < group.secrets.length; i++) - if (ids.has(group.secrets[i].id)) - return { kind: "before", id: group.secrets[i].id }; - return named ? { kind: "top", group: named.index } : null; -} - -/** Puts a secret deleted since `baseline` back where it was, with its original lines. */ -export function restoreSecret( - text: string, - baseline: string, - id: string, -): string { - const doc = parseEnv(text); - const base = parseEnv(baseline); - const s = base.secrets.find((x) => x.id === id); - if (!s || doc.secrets.some((x) => x.id === id)) return text; - const lines = base.lines.slice(s.start, s.end + 1); - const anchor = anchorOf(doc, base, s); - if (!anchor) return appendBlock(doc, lines); - if (anchor.kind === "top") { - const g = doc.groups[anchor.group]; - return splice(doc, g.header === null ? g.start : g.header + 1, 0, lines); - } - const ref = doc.secrets.find((x) => x.id === anchor.id)!; - return splice( - doc, - anchor.kind === "after" ? ref.end + 1 : ref.start, - 0, - lines, - ); -} - -/** - * Merges pasted `.env` text: existing keys are updated in place, new ones are appended to - * `target`. When the paste has named groups, new keys go to the group with the same name - * (created at the end if missing), so pasting a whole file keeps its structure. - */ -export function pasteEnv( - text: string, - pasted: string, - target: number, -): { text: string; updated: number; added: number } { - const src = parseEnv(pasted); - let out = text; - let updated = 0; - let added = 0; - for (const pg of src.groups) { - let dest: number | null = pg.name ? null : target; - for (const s of pg.secrets) { - // The last copy of a duplicated key is the one dotenv uses: update that one. - const existing = parseEnv(out).secrets.findLast((x) => x.key === s.key); - if (existing) { - if (existing.value === s.value) continue; - out = setSecret(out, existing.id, existing.key, s.value); - updated++; - continue; - } - if (dest === null) { - const doc = parseEnv(out); - const named = doc.groups.find((g) => g.name === pg.name); - if (!named) out = appendBlock(doc, [src.lines[pg.header!]]); - dest = named ? named.index : parseEnv(out).groups.length - 1; - } - // New secrets keep the pasted lines exactly as typed. - const lines = src.lines.slice(s.start, s.end + 1); - const doc = parseEnv(out); - const g = doc.groups[dest]; - out = g ? splice(doc, g.end + 1, 0, lines) : appendBlock(doc, lines); - if (!g) dest = parseEnv(out).groups.length - 1; - added++; - } - } - return { text: out, updated, added }; -} - -/** The exact lines of the given secrets, as `.env` text for the clipboard. */ -export function secretsText(doc: EnvDoc, ids: Iterable): string { - const want = new Set(ids); - return doc.secrets - .filter((s) => want.has(s.id)) - .map((s) => doc.lines.slice(s.start, s.end + 1).join("\n")) - .join("\n"); -} - -export function groupText(doc: EnvDoc, index: number): string { - const g = doc.groups[index]; - return g ? doc.lines.slice(g.start, g.end + 1).join("\n") : ""; -} - -/** - * Up to 4 characters at each end, so `sk_live…` and `sk_test…` tell apart, and a fixed-width - * mask that never reveals the length. Values under 8 characters stay fully masked. - */ -export function peek(value: string): { head: string; tail: string } | null { - const n = Math.min(4, Math.floor(value.length / 8)); - if (!n) return null; - return { - head: oneLine(value.slice(0, n)), - tail: oneLine(value.slice(-n)), - }; -} - -export function oneLine(value: string): string { - return value.replace(/\r?\n/g, "↵"); -} diff --git a/apps/web/src/lib/secrets/monaco/SecretsFileEditor.svelte b/apps/web/src/lib/secrets/monaco/SecretsFileEditor.svelte index 8b90f584..9fd63359 100644 --- a/apps/web/src/lib/secrets/monaco/SecretsFileEditor.svelte +++ b/apps/web/src/lib/secrets/monaco/SecretsFileEditor.svelte @@ -1,31 +1,32 @@ -
+
diff --git a/apps/web/src/lib/secrets/monaco/assembler.ts b/apps/web/src/lib/secrets/monaco/assembler.ts new file mode 100644 index 00000000..6a541a31 --- /dev/null +++ b/apps/web/src/lib/secrets/monaco/assembler.ts @@ -0,0 +1,89 @@ +import type * as Monaco from "monaco-editor"; +import { lineColToOffset, type MonacoSelectionLike } from "./parser"; + +/** + * Walk the tracking decorations sorted by start offset, splice in real values + * for collapsed ranges, leave model text in place for ranges whose decoration + * id is in `expandedIds` (the user is currently looking at / editing the real + * value). O(L + R). + */ +export function assembleRealText( + model: Monaco.editor.ITextModel | null, + decorationToReal: Map, + expandedIds: Set, + monaco: typeof Monaco, +): string { + if (!model || decorationToReal.size === 0) { + return model?.getValue() ?? ""; + } + + const fullText = model.getValue(); + const lines = fullText.split("\n"); + const lineLengths = lines.map((l: string) => l.length); + + type Entry = { id: string; start: number; end: number }; + const entries: Entry[] = []; + + for (const id of decorationToReal.keys()) { + if (expandedIds.has(id)) continue; + const range = model.getDecorationRange(id); + if (!range) continue; + if ( + range.startLineNumber === range.endLineNumber && + range.startColumn === range.endColumn + ) { + continue; + } + const start = lineColToOffset( + lineLengths, + range.startLineNumber, + range.startColumn, + ); + const end = lineColToOffset( + lineLengths, + range.endLineNumber, + range.endColumn, + ); + entries.push({ id, start, end }); + } + + entries.sort((a, b) => a.start - b.start); + + const out: string[] = []; + let cursor = 0; + for (const e of entries) { + if (e.start < cursor) continue; + out.push(fullText.slice(cursor, e.start)); + out.push(decorationToReal.get(e.id) ?? fullText.slice(e.start, e.end)); + cursor = e.end; + } + out.push(fullText.slice(cursor)); + + return out.join(""); + void monaco; +} + +/** + * Given an assembled real-text buffer and a Monaco selection (which is in + * model coordinates), return the slice of the real buffer that corresponds + * to that selection. Used by the clipboard intercept. + * + * IMPORTANT: this assumes lengths-of-lines in the assembled text equal the + * model line lengths for collapsed ranges (true: bullets are same width) + * AND for expanded ranges (true: model already holds the real text). So + * model line/col coordinates map 1:1 to real-text offsets. + */ +export function sliceRealByMonacoRange( + realText: string, + sel: MonacoSelectionLike, +): string { + const lines = realText.split("\n"); + const lineLengths = lines.map((l) => l.length); + const start = lineColToOffset( + lineLengths, + sel.startLineNumber, + sel.startColumn, + ); + const end = lineColToOffset(lineLengths, sel.endLineNumber, sel.endColumn); + return realText.slice(start, end); +} diff --git a/apps/web/src/lib/secrets/monaco/dotenv-language.ts b/apps/web/src/lib/secrets/monaco/dotenv-language.ts index fe97bacf..5b9ef363 100644 --- a/apps/web/src/lib/secrets/monaco/dotenv-language.ts +++ b/apps/web/src/lib/secrets/monaco/dotenv-language.ts @@ -16,7 +16,7 @@ export function registerDotenvLanguage(monaco: typeof Monaco): void { tokenizer: { root: [ [/#.*$/, "comment"], - [/^\s*(?:export\s+)?[a-zA-Z_][a-zA-Z0-9_.-]*(?=\s*=)/, "variable"], + [/^[a-zA-Z_][a-zA-Z0-9_.]*(?=\s*=)/, "variable"], [/=/, "delimiter"], [/"(?:[^"\\]|\\.)*"/, "string"], [/'(?:[^'\\]|\\.)*'/, "string"], @@ -24,4 +24,23 @@ export function registerDotenvLanguage(monaco: typeof Monaco): void { ], }, }); + + monaco.editor.defineTheme("dotenvTheme", { + base: "vs-dark", + inherit: true, + rules: [ + { token: "variable", foreground: "DDA15E" }, + { token: "delimiter", foreground: "CCCCCC" }, + { token: "identifier", foreground: "CCCCCC" }, + { token: "string", foreground: "CCCCCC" }, + { token: "string.escape", foreground: "D7BA7D" }, + { token: "comment", foreground: "545C66", fontStyle: "italic" }, + ], + colors: { + "editor.foreground": "#CCCCCC", + "editor.background": "#000000", + "editor.lineHighlightBackground": "#0a0a0a", + focusBorder: "#00000000", + }, + }); } diff --git a/apps/web/src/lib/secrets/monaco/masking-css.ts b/apps/web/src/lib/secrets/monaco/masking-css.ts index 73bde441..39f050d5 100644 --- a/apps/web/src/lib/secrets/monaco/masking-css.ts +++ b/apps/web/src/lib/secrets/monaco/masking-css.ts @@ -1,27 +1,16 @@ -// Design tokens (lib/appearance/theme.css), so both themes and every grey preset just work. const MASKING_CSS = ` - /* The fixed mask injected where a hidden value goes. */ - /* !important beats the token colour class Monaco puts on the same span. */ - .secret-mask { - color: var(--faint) !important; + .secret-masked { + color: transparent !important; + background-image: radial-gradient(circle, #6e7681 1.5px, transparent 1.5px); + background-size: 8.4px 100%; + background-repeat: repeat-x; + background-position: 2px center; + border-radius: 3px; cursor: pointer; } - /* Peek: n plain characters, 6 faint bullets, n plain characters. */ - .secret-mask-1, .secret-mask-2, .secret-mask-3, .secret-mask-4 { - color: transparent !important; - -webkit-background-clip: text; - background-clip: text; - background-image: linear-gradient( - to right, - var(--fg2) var(--n), - var(--faint) var(--n) calc(var(--n) + 6ch), - var(--fg2) calc(var(--n) + 6ch) - ); + .secret-masked::selection { + background: rgba(100, 148, 237, 0.3); } - .secret-mask-1 { --n: 1ch; } - .secret-mask-2 { --n: 2ch; } - .secret-mask-3 { --n: 3ch; } - .secret-mask-4 { --n: 4ch; } .secret-group-zone-hover .view-line, .secret-group-zone-hover .view-lines, .secret-group-zone-hover .view-overlays, @@ -30,11 +19,11 @@ const MASKING_CSS = ` cursor: pointer !important; } .secret-copied { - animation: secret-copied-flash 1.5s steps(1, end) forwards; + animation: secret-copied-flash 1.5s ease-out forwards; } @keyframes secret-copied-flash { - 0% { - background-color: color-mix(in oklab, var(--success) 18%, transparent); + 0%, 20% { + background-color: rgba(34, 197, 94, 0.15); } 100% { background-color: transparent; @@ -44,19 +33,21 @@ const MASKING_CSS = ` position: absolute; top: 0; left: 0; - padding: 3px 8px; - background: var(--elev); - color: var(--fg2); - font: 500 12px/18px "Geist Variable", system-ui, sans-serif; + padding: 4px 8px; + background: #1c2128; + color: #adbac7; + font-size: 12px; border-radius: 6px; - box-shadow: 0 0 0 0.5px var(--elev-border), var(--elev-shadow); + border: 1px solid #373e47; pointer-events: none; white-space: nowrap; z-index: 100; will-change: transform; } .secret-tooltip.copied { - color: var(--success); + background: #14271c; + border-color: rgba(34, 197, 94, 0.55); + color: #b6f0c2; } .secret-group-zone { display: flex; @@ -68,23 +59,26 @@ const MASKING_CSS = ` } .secret-group-pill { display: inline-block; - color: var(--fg3); - font: 500 11px/1.2 "Geist Variable", system-ui, sans-serif; + color: #6e7681; + font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; + font-size: 10.5px; + line-height: 1.2; text-decoration: underline; text-underline-offset: 2px; - text-decoration-color: var(--faint); + text-decoration-color: #4d5562; cursor: pointer; pointer-events: auto; user-select: none; + transition: color 120ms ease, text-decoration-color 120ms ease; } .secret-group-pill:hover, .secret-group-pill.hover { - color: var(--fg); - text-decoration-color: var(--fg3); + color: #adbac7; + text-decoration-color: #adbac7; } .secret-group-pill.copied { - color: var(--success); - text-decoration-color: var(--success); + color: #b6f0c2; + text-decoration-color: #b6f0c2; } `; diff --git a/apps/web/src/lib/secrets/monaco/monaco.ts b/apps/web/src/lib/secrets/monaco/monaco.ts deleted file mode 100644 index 082deccf..00000000 --- a/apps/web/src/lib/secrets/monaco/monaco.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Monaco is bundled with the app, never fetched from a CDN at runtime: it runs -// in the page that holds decrypted secrets and the unwrapped private key. -// Import this module lazily (`await import(...)`) so it stays out of the main chunk. -import "monaco-editor/esm/vs/editor/edcore.main.js"; -import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; -import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; - -self.MonacoEnvironment = { getWorker: () => new EditorWorker() }; - -export default monaco; diff --git a/apps/web/src/lib/secrets/monaco/parser.ts b/apps/web/src/lib/secrets/monaco/parser.ts index 4425ccd1..2466f748 100644 --- a/apps/web/src/lib/secrets/monaco/parser.ts +++ b/apps/web/src/lib/secrets/monaco/parser.ts @@ -1,6 +1,7 @@ -import { parseEnv } from "$lib/secrets/env-doc"; +import type * as Monaco from "monaco-editor"; + +const BULLET = "\u2022"; -/** 1-based line/column coordinates of a value, quotes included. */ export interface ValueRange { startLine: number; startCol: number; @@ -14,34 +15,266 @@ export interface ParsedSecret { closed: boolean; } -/** Keys the GitHub push picks up. */ -const PUSHED_KEY = /^[A-Z_][A-Z0-9_]*$/; +export interface MonacoSelectionLike { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; +} + +export interface MaskEntry { + range: ValueRange; + original: string; + key: string; +} + +export interface MaskResult { + masked: string; + entries: MaskEntry[]; +} + +export interface ValueGroup { + startLine: number; + endLine: number; + ranges: ValueRange[]; +} + +export function getValueRanges( + model: Monaco.editor.ITextModel, +): ParsedSecret[] { + const lineCount = model.getLineCount(); + const ranges: ParsedSecret[] = []; + + let inQuote: string | null = null; + let current: { startLine: number; startCol: number; key: string } | null = + null; + + for (let line = 1; line <= lineCount; line++) { + const text = model.getLineContent(line); + + if (inQuote) { + for (let i = 0; i < text.length; i++) { + if (inQuote === '"' && text[i] === "\\") { + i++; + continue; + } + if (text[i] === inQuote) { + const { startLine, startCol, key } = current!; + ranges.push({ + range: { + startLine, + startCol, + endLine: line, + endCol: i + 2, + }, + key, + closed: true, + }); + inQuote = null; + current = null; + break; + } + } + continue; + } + + const trimmed = text.trim(); + if (trimmed === "" || trimmed.startsWith("#")) continue; + + const eqIdx = text.indexOf("="); + if (eqIdx === -1) continue; + + const key = text.substring(0, eqIdx).trim(); + if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) continue; + + const afterEq = text.substring(eqIdx + 1); + if (afterEq.length === 0) continue; + + const valueStartCol = eqIdx + 2; + const firstChar = afterEq[0]; + + if (firstChar === '"' || firstChar === "'") { + let closed = false; + for (let i = 1; i < afterEq.length; i++) { + if (firstChar === '"' && afterEq[i] === "\\") { + i++; + continue; + } + if (afterEq[i] === firstChar) { + ranges.push({ + range: { + startLine: line, + startCol: valueStartCol, + endLine: line, + endCol: eqIdx + 1 + i + 2, + }, + key, + closed: true, + }); + closed = true; + break; + } + } + if (!closed) { + inQuote = firstChar; + current = { startLine: line, startCol: valueStartCol, key }; + } + } else { + ranges.push({ + range: { + startLine: line, + startCol: valueStartCol, + endLine: line, + endCol: text.length + 1, + }, + key, + closed: true, + }); + } + } + + if (inQuote && current) { + const lastLine = lineCount; + const { startLine, startCol, key } = current; + ranges.push({ + range: { + startLine, + startCol, + endLine: lastLine, + endCol: model.getLineContent(lastLine).length + 1, + }, + key, + closed: false, + }); + } + + return ranges; +} /** - * Value ranges for the GitHub push and the ⌘K search, read by the same parser as the table - * and the .env view (`env-doc.ts`), so they all agree on where a value starts and ends (an - * unclosed quote, for one, is a one-line value). `KEY=` with nothing after it has no range. + * String-based clone of `getValueRanges` that does NOT need a Monaco model. + * Required so we can pre-mask the initial `value` synchronously, before + * Monaco mounts, ensuring plaintext never appears in the DOM. + * + * MUST stay in lockstep with `getValueRanges`. There is a dev-mode runtime + * assertion (see `assertParserParity`) that compares the two. */ -export function parseValueRangesFromString( - text: string, - keyPattern = PUSHED_KEY, -): ParsedSecret[] { - const doc = parseEnv(text); - return doc.secrets - .filter((s) => s.raw !== "" && keyPattern.test(s.key)) - .map((s) => ({ - key: s.key, - closed: true, +export function parseValueRangesFromString(text: string): ParsedSecret[] { + const lines = text.split("\n"); + const ranges: ParsedSecret[] = []; + + let inQuote: string | null = null; + let current: { startLine: number; startCol: number; key: string } | null = + null; + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lineIdx + 1; + const lineText = lines[lineIdx]; + + if (inQuote) { + for (let i = 0; i < lineText.length; i++) { + if (inQuote === '"' && lineText[i] === "\\") { + i++; + continue; + } + if (lineText[i] === inQuote) { + const { startLine, startCol, key } = current!; + ranges.push({ + range: { + startLine, + startCol, + endLine: line, + endCol: i + 2, + }, + key, + closed: true, + }); + inQuote = null; + current = null; + break; + } + } + continue; + } + + const trimmed = lineText.trim(); + if (trimmed === "" || trimmed.startsWith("#")) continue; + + const eqIdx = lineText.indexOf("="); + if (eqIdx === -1) continue; + + const key = lineText.substring(0, eqIdx).trim(); + if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) continue; + + const afterEq = lineText.substring(eqIdx + 1); + if (afterEq.length === 0) continue; + + const valueStartCol = eqIdx + 2; + const firstChar = afterEq[0]; + + if (firstChar === '"' || firstChar === "'") { + let closed = false; + for (let i = 1; i < afterEq.length; i++) { + if (firstChar === '"' && afterEq[i] === "\\") { + i++; + continue; + } + if (afterEq[i] === firstChar) { + ranges.push({ + range: { + startLine: line, + startCol: valueStartCol, + endLine: line, + endCol: eqIdx + 1 + i + 2, + }, + key, + closed: true, + }); + closed = true; + break; + } + } + if (!closed) { + inQuote = firstChar; + current = { startLine: line, startCol: valueStartCol, key }; + } + } else { + ranges.push({ + range: { + startLine: line, + startCol: valueStartCol, + endLine: line, + endCol: lineText.length + 1, + }, + key, + closed: true, + }); + } + } + + if (inQuote && current) { + const lastLine = lines.length; + const { startLine, startCol, key } = current; + ranges.push({ range: { - startLine: s.start + 1, - startCol: s.valueCol + 1, - endLine: s.end + 1, - endCol: doc.lines[s.end].length - s.tail.length + 1, + startLine, + startCol, + endLine: lastLine, + endCol: lines[lastLine - 1].length + 1, }, - })); + key, + closed: false, + }); + } + + return ranges; } -/** 1-based (line, col) to an offset in the `\n`-joined text with these line lengths. */ +/** + * Convert (line, col) 1-based positions into a flat string offset against + * a `\n`-joined text buffer. Mirrors how Monaco offsets work for the model + * we use (we ignore CRLF — Monaco normalizes on paste). + */ export function lineColToOffset( lineLengths: number[], line: number, @@ -53,3 +286,170 @@ export function lineColToOffset( } return offset + (col - 1); } + +export function bulletsForSubstring(s: string): string { + return s.replace(/[^\n]/g, BULLET); +} + +/** + * Parse `text`, then replace each value range with same-width bullet + * placeholders (preserving newlines inside multiline quoted values). + * Returns the masked string AND the per-range originals so callers can + * stash them keyed by decoration id after the editor mounts. + */ +export function maskText(text: string): MaskResult { + const parsed = parseValueRangesFromString(text); + if (parsed.length === 0) { + return { masked: text, entries: [] }; + } + + const lines = text.split("\n"); + const lineLengths = lines.map((l) => l.length); + + const sorted = [...parsed].sort((a, b) => { + if (a.range.startLine !== b.range.startLine) + return a.range.startLine - b.range.startLine; + return a.range.startCol - b.range.startCol; + }); + + const out: string[] = []; + const entries: MaskEntry[] = []; + let cursor = 0; + + for (const p of sorted) { + const r = p.range; + const start = lineColToOffset(lineLengths, r.startLine, r.startCol); + const end = lineColToOffset(lineLengths, r.endLine, r.endCol); + if (start < cursor) continue; + out.push(text.slice(cursor, start)); + const original = text.slice(start, end); + out.push(bulletsForSubstring(original)); + entries.push({ range: r, original, key: p.key }); + cursor = end; + } + out.push(text.slice(cursor)); + + return { masked: out.join(""), entries }; +} + +/** + * Dev-only sanity check: confirm `parseValueRangesFromString` agrees with + * `getValueRanges` for the given model. Catches drift between the two + * implementations early. No-op in production. + */ +export function assertParserParity(model: Monaco.editor.ITextModel): void { + if (!import.meta.env?.DEV) return; + try { + const fromModel = getValueRanges(model); + const fromString = parseValueRangesFromString(model.getValue()); + if (fromModel.length !== fromString.length) { + console.warn("[BaseFileEditor] parser parity mismatch: lengths differ", { + fromModel, + fromString, + }); + return; + } + for (let i = 0; i < fromModel.length; i++) { + if ( + !rangesEqual(fromModel[i].range, fromString[i].range) || + fromModel[i].closed !== fromString[i].closed + ) { + console.warn("[BaseFileEditor] parser parity mismatch at index", i, { + fromModel: fromModel[i], + fromString: fromString[i], + }); + return; + } + } + } catch (err) { + console.warn("[BaseFileEditor] parser parity check failed", err); + } +} + +export function posInRange( + pos: { lineNumber: number; column: number }, + r: ValueRange, +): boolean { + if (pos.lineNumber < r.startLine || pos.lineNumber > r.endLine) return false; + if (pos.lineNumber === r.startLine && pos.column < r.startCol) return false; + if (pos.lineNumber === r.endLine && pos.column >= r.endCol) return false; + return true; +} + +export function selectionTouchesRange( + sel: MonacoSelectionLike, + r: ValueRange, +): boolean { + const selStartBeforeOrAtRangeEnd = + sel.startLineNumber < r.endLine || + (sel.startLineNumber === r.endLine && sel.startColumn <= r.endCol); + const selEndAfterOrAtRangeStart = + sel.endLineNumber > r.startLine || + (sel.endLineNumber === r.startLine && sel.endColumn >= r.startCol); + return selStartBeforeOrAtRangeEnd && selEndAfterOrAtRangeStart; +} + +export function rangesEqual(a: ValueRange, b: ValueRange): boolean { + return ( + a.startLine === b.startLine && + a.startCol === b.startCol && + a.endLine === b.endLine && + a.endCol === b.endCol + ); +} + +/** Group = max run of non-blank lines containing ≥1 KEY=VALUE pair, bounded by blank lines or file edges. */ +export function getValueGroups( + model: Monaco.editor.ITextModel, + parsed: ParsedSecret[], +): ValueGroup[] { + const lineCount = model.getLineCount(); + const ranges = parsed.map((p) => p.range); + const linesWithValues = new Set(); + for (const r of ranges) { + for (let l = r.startLine; l <= r.endLine; l++) linesWithValues.add(l); + } + + const groups: ValueGroup[] = []; + let blockStart: number | null = null; + let blockEnd = 0; + let blockHasValue = false; + + const flush = () => { + if (blockStart !== null && blockHasValue) { + const start = blockStart; + const end = blockEnd; + groups.push({ + startLine: start, + endLine: end, + ranges: ranges.filter((r) => r.startLine >= start && r.endLine <= end), + }); + } + blockStart = null; + blockHasValue = false; + }; + + for (let line = 1; line <= lineCount; line++) { + const text = model.getLineContent(line); + if (text.trim() === "") { + flush(); + continue; + } + if (blockStart === null) blockStart = line; + blockEnd = line; + if (linesWithValues.has(line)) blockHasValue = true; + } + flush(); + + return groups; +} + +export function stripQuotes(text: string): string { + if (text.length < 2) return text; + const first = text[0]; + const last = text[text.length - 1]; + if ((first === '"' || first === "'") && first === last) { + return text.slice(1, -1); + } + return text; +} diff --git a/apps/web/src/lib/secrets/monaco/secretMasking.ts b/apps/web/src/lib/secrets/monaco/secretMasking.ts index ad287712..c965a0dc 100644 --- a/apps/web/src/lib/secrets/monaco/secretMasking.ts +++ b/apps/web/src/lib/secrets/monaco/secretMasking.ts @@ -1,732 +1,1280 @@ import type * as Monaco from "monaco-editor"; -import { toast } from "svelte-sonner"; import type { ProjectRevealOn } from "$lib/auth/domain/project-settings"; -import { parseEnv, peek, type EnvDoc } from "$lib/secrets/env-doc"; +import { assembleRealText, sliceRealByMonacoRange } from "./assembler"; +import { + assertParserParity, + bulletsForSubstring, + getValueGroups, + getValueRanges, + maskText, + posInRange, + selectionTouchesRange, + stripQuotes, + type MaskEntry, + type MaskResult, + type ParsedSecret, + type ValueGroup, + type ValueRange, +} from "./parser"; + +const TOOLTIP_DEFAULT_TEXT = "Click to copy"; /** - * Masking for the .env editor. A masked value is not in the Monaco model at all: its line ends - * right after `KEY=` and a zero-width tracking decoration ("anchor") marks where the value - * goes, with a fixed mask injected there (the peek, or 8 bullets in Hidden mode). The page - * therefore never holds a masked value, its length or its line count. Revealing (caret or - * hover in Peek mode, or typing a new value) puts the text back into the model. - * - * The editor stands for `real`, the plain text: the model with every hidden value put back. - * Undo/redo work on `real` snapshots, because the model is rewritten as values hide and show. + * Monaco's runtime TextModel exposes `changeDecorations`, but the public + * `ITextModel` typings omit it. */ - -type Editor = Monaco.editor.IStandaloneCodeEditor; -type Model = Monaco.editor.ITextModel; -/** The runtime model has `changeDecorations`; the public typings omit it. */ -type DecoratableModel = Model & { +type TextModelWithChangeDecorations = Monaco.editor.ITextModel & { changeDecorations( - fn: (accessor: { - changeDecoration(id: string, range: Monaco.IRange): void; + callback: (accessor: { + changeDecoration(id: string, newRange: Monaco.IRange): void; }) => void, ): void; }; -const TOOLTIP = "Click to copy"; -const HIDDEN_HINT = - "Values are hidden in this project. Edit them in the table view."; - -interface Span { - startLineNumber: number; - startColumn: number; - endLineNumber: number; - endColumn: number; -} - -/** A value and whatever follows it on its last line (`"…" # note`), in 1-based coordinates. */ -function valueSpan(doc: EnvDoc, index: number): Span | null { - const s = doc.secrets[index]; - if (!`${s.raw}${s.tail}`.trim()) return null; - return { - startLineNumber: s.start + 1, - startColumn: s.valueCol + 1, - endLineNumber: s.end + 1, - endColumn: doc.lines[s.end].length + 1, - }; -} - -/** `text` with every value removed, plus where each one was and what it was. */ -function hideAll(text: string) { - const doc = parseEnv(text); - const lines: string[] = []; - const slots: { line: number; column: number; text: string }[] = []; - const at = new Map(doc.secrets.map((s, i) => [s.start, i])); - for (let i = 0; i < doc.lines.length; i++) { - const index = at.get(i); - const span = index === undefined ? null : valueSpan(doc, index); - if (index === undefined || !span) { - lines.push(doc.lines[i]); - continue; - } - const s = doc.secrets[index]; - const first = doc.lines[i]; - lines.push(first.slice(0, s.valueCol)); - slots.push({ - line: lines.length, - column: s.valueCol + 1, - text: [ - first.slice(s.valueCol), - ...doc.lines.slice(i + 1, s.end + 1), - ].join(doc.eol), - }); - i = s.end; - } - return { masked: lines.join(doc.eol), slots, eol: doc.eol }; -} - -/** The value as the app uses it (quotes stripped, escapes resolved). */ -function decode(valueText: string): string { - return parseEnv(`K=${valueText}`).secrets[0]?.value ?? ""; +interface UseSecretMaskingArgs { + value: string; + onChange: (value: string) => void; + revealOn: ProjectRevealOn; } -function touches(sel: Monaco.Selection, r: Span): boolean { - const before = - sel.startLineNumber < r.endLineNumber || - (sel.startLineNumber === r.endLineNumber && sel.startColumn <= r.endColumn); - const after = - sel.endLineNumber > r.startLineNumber || - (sel.endLineNumber === r.startLineNumber && sel.endColumn >= r.startColumn); - return before && after; +interface SecretMaskingResult { + maskedInitialValue: string; + handleEditorMount: ( + editor: Monaco.editor.IStandaloneCodeEditor, + monaco: typeof Monaco, + ) => void; + syncExternalValue: (value: string, revealOn: ProjectRevealOn) => void; + setOnChange: (onChange: (value: string) => void) => void; + dispose: () => void; } export function createSecretMasking({ value, onChange, revealOn, -}: { - value: string; - onChange: (value: string) => void; - revealOn: ProjectRevealOn; -}) { - const plain = revealOn === "always"; - // Peek mode shows a value while the caret is in it or the pointer is on its line. - const reveals = revealOn === "hover"; - - let editor: Editor | null = null; - let monaco: typeof Monaco | null = null; - let emit = onChange; - let real = value; - let lastEmitted = value; - - const tracked = new Set(); - /** Text of each hidden value, by anchor decoration. */ - const hidden = new Map(); - /** Values currently in the model (revealed or being typed). */ - const shown = new Set(); - /** Values typed in this session: shown while the caret is in them, in every mode. */ - const fresh = new Set(); - let hoverId: string | null = null; - let internal = 0; - let masks: Monaco.editor.IEditorDecorationsCollection | null = null; - let flashes: Monaco.editor.IEditorDecorationsCollection | null = null; - let tooltip: HTMLDivElement | null = null; - let tooltipTimer: number | null = null; - let zoneIds: string[] = []; - const zones = new Map string; pill: HTMLElement }>(); - /** Anchor offsets before the next user edit, to tell deleted values apart. */ - let anchorsBefore = new Map(); - let past: string[] = []; - let future: string[] = []; - let lastEditAt = 0; - const disposers: (() => void)[] = []; - - const maskedInitialValue = plain ? value : hideAll(value).masked; - - const model = () => editor?.getModel() ?? null; - const rangeOf = (id: string) => model()?.getDecorationRange(id) ?? null; - const isEmpty = (r: Span) => - r.startLineNumber === r.endLineNumber && r.startColumn === r.endColumn; - - function quietly(fn: () => void) { - internal++; - try { - fn(); - } finally { - internal--; - } - } +}: UseSecretMaskingArgs): SecretMaskingResult { + const editorRef = { + current: null as Monaco.editor.IStandaloneCodeEditor | null, + }; + const monacoRef = { current: null as typeof Monaco | null }; + const decorationsRef = { + current: null as Monaco.editor.IEditorDecorationsCollection | null, + }; + const flashDecorationsRef = { + current: null as Monaco.editor.IEditorDecorationsCollection | null, + }; + const tooltipRef = { current: null as HTMLDivElement | null }; + const hoveredDecIdRef = { current: null as string | null }; + const groupZoneIdsRef = { current: [] as string[] }; + const groupZoneMapRef = { + current: new Map(), + }; - function setRange(id: string, range: Span) { - (model() as DecoratableModel | null)?.changeDecorations((a) => - a.changeDecoration(id, range), - ); - } + const hoverRevealedDecIdRef = { current: null as string | null }; + const caretRevealedDecIdsRef = { current: new Set() }; - function track(range: Span): string { - const [id] = editor!.deltaDecorations( - [], - [ - { - range, - options: { - stickiness: - monaco!.editor.TrackedRangeStickiness - .AlwaysGrowsWhenTypingAtEdges, - }, - }, - ], - ); - tracked.add(id); - return id; - } + const trackingDecorationIdsRef = { current: new Set() }; + const decorationToRealRef = { current: new Map() }; - function untrack(id: string) { - editor?.deltaDecorations([id], []); - tracked.delete(id); - hidden.delete(id); - shown.delete(id); - fresh.delete(id); - if (hoverId === id) hoverId = null; - } + // Pre-edit offset snapshot per tracking decoration. Refreshed after every + // internal edit (ingest, expand/collapse, mask-after-typing, drift + // reconcile, initial mount) so that the NEXT user-driven keystroke's + // change events can be mapped onto the right slot inside each stored real + // value. + const decorationPreEditOffsetsRef = { + current: new Map(), + }; - /** The plain text: the model with every hidden value put back. */ - function assemble(from?: Span): string { - const m = model(); - if (!m) return real; - const start = from - ? m.getOffsetAt({ - lineNumber: from.startLineNumber, - column: from.startColumn, - }) - : 0; - const end = from - ? m.getOffsetAt({ - lineNumber: from.endLineNumber, - column: from.endColumn, - }) - : m.getValueLength(); - let text = m.getValue().slice(start, end); - const inside: { offset: number; text: string }[] = []; - for (const [id, t] of hidden) { - const r = rangeOf(id); - const offset = r ? m.getOffsetAt(r.getStartPosition()) - start : -1; - if (offset >= 0 && offset <= end - start) - inside.push({ offset, text: t }); - } - // Back to front, so earlier offsets stay valid. - for (const x of inside.sort((a, b) => b.offset - a.offset)) - text = text.slice(0, x.offset) + x.text + text.slice(x.offset); - return text; - } + // Re-entrancy counter. depth > 0 means "this content change is ours". + const internalEditDepthRef = { current: 0 }; - /** Puts a hidden value back into the model. */ - function show(id: string) { - const m = model(); - const r = rangeOf(id); - const text = hidden.get(id); - if (!m || !r || text === undefined) return; - const pos = r.getStartPosition(); - quietly(() => - m.applyEdits([ - { - range: monaco!.Range.fromPositions(pos), - text, - forceMoveMarkers: false, - }, - ]), - ); - const end = m.getPositionAt(m.getOffsetAt(pos) + text.length); - setRange(id, monaco!.Range.fromPositions(pos, end)); - hidden.delete(id); - shown.add(id); - } + const lastParentValueRef = { current: value }; - /** Takes a value out of the model again, re-reading where it starts and ends. */ - function hide(id: string) { - const m = model(); - const r = rangeOf(id); - if (!m || !r) return untrack(id); - const doc = parseEnv(m.getValue()); - const index = doc.secrets.findIndex( - (s) => s.start + 1 === r.startLineNumber, - ); - const span = index < 0 ? null : valueSpan(doc, index); - // No longer a value (e.g. its `=` was deleted): the text stays as typed. - if (!span) return untrack(id); - const text = m.getValueInRange(span); - quietly(() => m.applyEdits([{ range: span, text: "" }])); - setRange( - id, - monaco!.Range.fromPositions({ - lineNumber: span.startLineNumber, - column: span.startColumn, - }), - ); - hidden.set(id, text); - shown.delete(id); - fresh.delete(id); - } + const pendingInitialMaskRef = { current: null as MaskResult | null }; - function refreshMasks() { - if (!editor || !monaco) return; + const onChangeRef = { current: onChange }; + const lastEmittedValueRef = { current: value }; + + const revealOnRef = { current: revealOn }; + // Pre-mount masking: synchronously mask `value` BEFORE Monaco mounts, + // so plaintext never enters the DOM, not even for one frame. + const maskedInitialValue = (() => { + if (revealOn === "always") { + pendingInitialMaskRef.current = null; + lastParentValueRef.current = value; + return value; + } + const result = maskText(value); + pendingInitialMaskRef.current = result; + lastParentValueRef.current = value; + return result.masked; + })(); + + const tooltipResetTimerRef = { current: null as number | null }; + + const getExpandedIds = (): Set => { + const ids = new Set(caretRevealedDecIdsRef.current); + if (hoverRevealedDecIdRef.current) ids.add(hoverRevealedDecIdRef.current); + return ids; + }; + + const refreshMaskDecorations = () => { + const editor = editorRef.current; + const monacoInstance = monacoRef.current; + const model = editor?.getModel(); + if (!editor || !monacoInstance || !model) return; + + const expanded = getExpandedIds(); const decorations: Monaco.editor.IModelDeltaDecoration[] = []; - for (const [id, text] of hidden) { - const r = rangeOf(id); - if (!r) continue; - const p = reveals ? peek(decode(text)) : null; + + for (const decId of trackingDecorationIdsRef.current) { + if (expanded.has(decId)) continue; + const range = model.getDecorationRange(decId); + if (!range) continue; + if ( + range.startLineNumber === range.endLineNumber && + range.startColumn === range.endColumn + ) { + continue; + } decorations.push({ - range: monaco.Range.fromPositions(r.getStartPosition()), - options: { - showIfCollapsed: true, - before: { - content: p ? `${p.head}••••••${p.tail}` : "••••••••", - inlineClassName: `secret-mask secret-mask-${p?.head.length ?? 0}`, - cursorStops: monaco.editor.InjectedTextCursorStops.Left, - }, - }, + range, + options: { inlineClassName: "secret-masked" }, }); } - if (!masks) masks = editor.createDecorationsCollection(decorations); - else masks.set(decorations); - } - /** Shows exactly the values the caret is in (and the hovered one), hides the rest. */ - function syncShown() { - if (!editor) return; - const selections = editor.getSelections() ?? []; - const want = new Set(); - for (const id of tracked) { - if (!reveals && !fresh.has(id)) continue; - const r = rangeOf(id); - if (r && selections.some((sel) => touches(sel, r))) want.add(id); - } - if (reveals && hoverId && tracked.has(hoverId)) want.add(hoverId); - for (const id of [...shown]) if (!want.has(id)) hide(id); - for (const id of want) if (!shown.has(id)) show(id); - refreshMasks(); - snapshotAnchors(); - } + if (!decorationsRef.current) { + decorationsRef.current = editor.createDecorationsCollection(decorations); + } else { + decorationsRef.current.set(decorations); + } + }; - function snapshotAnchors() { - const m = model(); - anchorsBefore = new Map(); - if (!m) return; - for (const id of hidden.keys()) { - const r = rangeOf(id); - if (!r) continue; - anchorsBefore.set(id, { - offset: m.getOffsetAt(r.getStartPosition()), - lineStart: m.getOffsetAt({ lineNumber: r.startLineNumber, column: 1 }), + const withInternalEdit = (fn: () => T): T => { + internalEditDepthRef.current++; + try { + return fn(); + } finally { + internalEditDepthRef.current--; + } + }; + + const snapshotDecorationOffsets = () => { + const model = editorRef.current?.getModel(); + const snap = decorationPreEditOffsetsRef.current; + snap.clear(); + if (!model) return; + for (const decId of trackingDecorationIdsRef.current) { + const range = model.getDecorationRange(decId); + if (!range) continue; + const start = model.getOffsetAt({ + lineNumber: range.startLineNumber, + column: range.startColumn, + }); + const end = model.getOffsetAt({ + lineNumber: range.endLineNumber, + column: range.endColumn, }); + snap.set(decId, { start, length: end - start }); } - } + }; - /** Shows `text` from scratch: everything hidden, nothing shown. */ - function render(text: string) { - const m = model(); - if (!editor || !monaco || !m) return; - const view = editor.saveViewState(); - const { masked, slots, eol } = hideAll(text); - for (const id of [...tracked]) untrack(id); - quietly(() => { - m.setValue(masked); - m.setEOL( - eol === "\r\n" - ? monaco!.editor.EndOfLineSequence.CRLF - : monaco!.editor.EndOfLineSequence.LF, - ); - }); - for (const slot of slots) { - const id = track( - monaco.Range.fromPositions({ - lineNumber: slot.line, - column: slot.column, - }), - ); - hidden.set(id, slot.text); + const applyTypingChangesToReal = ( + model: Monaco.editor.ITextModel, + changes: Array<{ + rangeOffset: number; + rangeLength: number; + text: string; + }>, + ) => { + const editor = editorRef.current; + if (!editor || !model || changes.length === 0) return; + const snap = decorationPreEditOffsetsRef.current; + if (snap.size === 0) return; + const expanded = getExpandedIds(); + + const touched = new Map< + string, + Array<{ offsetWithin: number; rangeLength: number; text: string }> + >(); + for (const [decId, { start: decStart, length: decLen }] of snap) { + if (expanded.has(decId)) continue; + const decEnd = decStart + decLen; + const relevant: Array<{ + offsetWithin: number; + rangeLength: number; + text: string; + }> = []; + for (const ch of changes) { + const chStart = ch.rangeOffset; + const chEnd = ch.rangeOffset + ch.rangeLength; + if (chStart >= decStart && chEnd <= decEnd) { + relevant.push({ + offsetWithin: chStart - decStart, + rangeLength: ch.rangeLength, + text: ch.text, + }); + } + } + if (relevant.length > 0) touched.set(decId, relevant); } - real = text; - if (view) editor.restoreViewState(view); - syncShown(); - refreshZones(); - } - /** After a user edit: drop deleted values, pick up typed ones, keep the rest in place. */ - function reconcile( - changes: readonly Monaco.editor.IModelContentChange[], - ): boolean { - const m = model(); - if (!editor || !monaco || !m) return true; - - // A value goes away with its line: the edit covered its key and its anchor. - for (const [id, a] of anchorsBefore) { - if (!hidden.has(id)) continue; - const deleted = changes.some( - (c) => - c.rangeLength > 0 && - c.rangeOffset <= a.lineStart && - c.rangeOffset + c.rangeLength >= a.offset, - ); - if (deleted) untrack(id); + if (touched.size === 0) return; + + for (const [decId, decChanges] of touched) { + const oldReal = decorationToRealRef.current.get(decId) ?? ""; + decChanges.sort((a, b) => b.offsetWithin - a.offsetWithin); + let newReal = oldReal; + for (const ch of decChanges) { + newReal = + newReal.slice(0, ch.offsetWithin) + + ch.text + + newReal.slice(ch.offsetWithin + ch.rangeLength); + } + decorationToRealRef.current.set(decId, newReal); } - // A hidden value is valid only at the very end of a `KEY=` line. - let doc = parseEnv(m.getValue()); - for (const id of [...hidden.keys()]) { - const r = rangeOf(id); - if (!r) { - untrack(id); - continue; + withInternalEdit(() => { + const maskEdits: Array<{ range: Monaco.IRange; text: string }> = []; + for (const decId of touched.keys()) { + const postRange = model.getDecorationRange(decId); + if (!postRange) continue; + const real = decorationToRealRef.current.get(decId) ?? ""; + const expectedBullets = bulletsForSubstring(real); + const currentText = model.getValueInRange(postRange); + if (currentText === expectedBullets) continue; + maskEdits.push({ range: postRange, text: expectedBullets }); } - const s = doc.secrets.find((x) => x.start + 1 === r.startLineNumber); - const ok = - s && - s.valueCol + 1 === r.startColumn && - r.startColumn === m.getLineMaxColumn(r.startLineNumber); - if (ok) { - if (!isEmpty(r)) - setRange(id, monaco.Range.fromPositions(r.getStartPosition())); - continue; + if (maskEdits.length > 0) { + editor.executeEdits("mask-after-typing", maskEdits); } - // Text was typed or joined onto a hidden value. Peek mode reveals it in place; Hidden - // mode never reveals, so the edit is undone. - if (!reveals) return false; - show(id); - doc = parseEnv(m.getValue()); - } - - // Shown values follow their line; typed values start being tracked. - const claimed = new Set(); - for (const id of [...shown]) { - const r = rangeOf(id); - const index = r - ? doc.secrets.findIndex((s) => s.start + 1 === r.startLineNumber) - : -1; - const span = index < 0 ? null : valueSpan(doc, index); - if (!span || claimed.has(index)) { - untrack(id); - continue; + }); + }; + + const expandDecorationInModel = (decId: string) => { + const editor = editorRef.current; + const monacoInstance = monacoRef.current; + const model = editor?.getModel(); + if (!editor || !monacoInstance || !model) return; + const real = decorationToRealRef.current.get(decId); + if (real == null) return; + const range = model.getDecorationRange(decId); + if (!range) return; + const current = model.getValueInRange(range); + if (current === real) return; + if (current.length !== real.length) return; + withInternalEdit(() => { + editor.executeEdits("mask-expand", [ + { + range, + text: real, + }, + ]); + }); + snapshotDecorationOffsets(); + }; + + const collapseDecorationInModel = (decId: string) => { + const editor = editorRef.current; + const monacoInstance = monacoRef.current; + const model = editor?.getModel(); + if (!editor || !monacoInstance || !model) return; + const range = model.getDecorationRange(decId); + if (!range) return; + const current = model.getValueInRange(range); + if (current.length === 0) return; + decorationToRealRef.current.set(decId, current); + const bullets = bulletsForSubstring(current); + if (current === bullets) return; + withInternalEdit(() => { + editor.executeEdits("mask-collapse", [ + { + range, + text: bullets, + }, + ]); + }); + snapshotDecorationOffsets(); + }; + + const hideTooltip = () => { + if (tooltipResetTimerRef.current !== null) { + window.clearTimeout(tooltipResetTimerRef.current); + tooltipResetTimerRef.current = null; + } + const tip = tooltipRef.current; + if (tip && tip.style.display !== "none") { + tip.style.display = "none"; + } + hoveredDecIdRef.current = null; + const revealedId = hoverRevealedDecIdRef.current; + if (revealedId) { + hoverRevealedDecIdRef.current = null; + if (!caretRevealedDecIdsRef.current.has(revealedId)) { + collapseDecorationInModel(revealedId); } - claimed.add(index); - setRange(id, span); + refreshMaskDecorations(); } - const anchored = new Set( - [...hidden.keys()].map((id) => rangeOf(id)?.startLineNumber), - ); - doc.secrets.forEach((s, index) => { - const span = valueSpan(doc, index); - if (!span || claimed.has(index) || anchored.has(s.start + 1)) return; - const id = track(span); - shown.add(id); - fresh.add(id); + }; + + const showTooltipAtDecoration = (decId: string) => { + const editor = editorRef.current; + const model = editor?.getModel(); + if (!editor || !model) return; + + const range = model.getDecorationRange(decId); + if (!range) return; + + const domNode = editor.getDomNode(); + if (!domNode) return; + + const visiblePos = editor.getScrolledVisiblePosition({ + lineNumber: range.startLineNumber, + column: range.startColumn, }); + if (!visiblePos) return; - syncShown(); - refreshZones(); - return true; - } + let tip = tooltipRef.current; + if (!tip) { + tip = document.createElement("div"); + tip.className = "secret-tooltip"; + tip.textContent = TOOLTIP_DEFAULT_TEXT; + domNode.appendChild(tip); + tooltipRef.current = tip; + } else { + if (tooltipResetTimerRef.current !== null) { + window.clearTimeout(tooltipResetTimerRef.current); + tooltipResetTimerRef.current = null; + } + if (tip.classList.contains("copied")) { + tip.classList.remove("copied"); + } + if (tip.textContent !== TOOLTIP_DEFAULT_TEXT) { + tip.textContent = TOOLTIP_DEFAULT_TEXT; + } + } - function commit(next: string) { - if (next === real) return; - const now = Date.now(); - if (now - lastEditAt > 600 || !past.length) past.push(real); - if (past.length > 200) past.shift(); - future = []; - lastEditAt = now; - real = next; - lastEmitted = next; - emit(next); - } + if (tip.style.display === "none") tip.style.display = ""; + tip.style.transform = `translate3d(${visiblePos.left}px, ${visiblePos.top}px, 0) translateY(-100%) translateY(-6px)`; + }; - function travel(from: string[], to: string[]) { - const next = from.pop(); - if (next === undefined) return; - to.push(real); - render(next); - lastEmitted = next; - lastEditAt = 0; - emit(next); - } + const flashTooltipCopied = () => { + const tip = tooltipRef.current; + if (!tip) return; + if (tooltipResetTimerRef.current !== null) { + window.clearTimeout(tooltipResetTimerRef.current); + } + tip.classList.add("copied"); + tip.textContent = "Copied!"; + tooltipResetTimerRef.current = window.setTimeout(() => { + tooltipResetTimerRef.current = null; + const t = tooltipRef.current; + if (t) { + t.classList.remove("copied"); + t.textContent = TOOLTIP_DEFAULT_TEXT; + } + }, 1200); + }; - function hideTooltip() { - if (tooltip) tooltip.style.display = "none"; - } + const flashCopied = (rangesToFlash: Array<{ range: Monaco.IRange }>) => { + const editor = editorRef.current; + const monacoInstance = monacoRef.current; + if (!editor || !monacoInstance || rangesToFlash.length === 0) return; - function showTooltip(id: string, text = TOOLTIP) { - const r = rangeOf(id); - const dom = editor?.getDomNode(); - if (!editor || !r || !dom) return; - const pos = editor.getScrolledVisiblePosition(r.getStartPosition()); - if (!pos) return; - if (!tooltip) { - tooltip = document.createElement("div"); - tooltip.className = "secret-tooltip"; - dom.appendChild(tooltip); - } - tooltip.textContent = text; - tooltip.classList.toggle("copied", text !== TOOLTIP); - tooltip.style.display = ""; - tooltip.style.transform = `translate3d(${pos.left}px, ${pos.top}px, 0) translateY(-100%) translateY(-6px)`; - } + // `secret-copied` is ONLY the green-flash animation (no bullet overlay). + // We intentionally don't stack `secret-masked` here: the underlying + // mask decoration already paints bullets for collapsed values, and + // for group-copy flashes this range covers whole lines including + // keys — masking those would briefly turn key names into dots. + const decorations = rangesToFlash.map((entry) => ({ + range: entry.range, + options: { + inlineClassName: "secret-copied", + }, + })); - function idOnLine(line: number): string | null { - for (const id of tracked) { - const r = rangeOf(id); - if (r && line >= r.startLineNumber && line <= r.endLineNumber) return id; + if (!flashDecorationsRef.current) { + flashDecorationsRef.current = + editor.createDecorationsCollection(decorations); + } else { + flashDecorationsRef.current.set(decorations); } - return null; - } - function flash(range: Span) { - if (!editor) return; - const d = [{ range, options: { inlineClassName: "secret-copied" } }]; - if (!flashes) flashes = editor.createDecorationsCollection(d); - else flashes.set(d); - window.setTimeout(() => flashes?.clear(), 1500); - } + window.setTimeout(() => { + if (flashDecorationsRef.current) { + flashDecorationsRef.current.clear(); + } + }, 1500); + }; - function copyValue(id: string) { - const r = rangeOf(id); - const m = model(); - if (!r || !m) return; - const text = hidden.get(id) ?? m.getValueInRange(r); - void navigator.clipboard?.writeText(decode(text)).catch(() => {}); - if (!isEmpty(r)) flash(r); - showTooltip(id, "Copied!"); - if (tooltipTimer !== null) window.clearTimeout(tooltipTimer); - tooltipTimer = window.setTimeout(() => { - tooltipTimer = null; - if (hoverId === id || idOnLine(r.startLineNumber) === id) showTooltip(id); - }, 1200); - } + const flashCopiedDecorations = (decIds: string[]) => { + const editor = editorRef.current; + const model = editor?.getModel(); + if (!model) return; + const items: Array<{ range: Monaco.IRange }> = []; + for (const id of decIds) { + const r = model.getDecorationRange(id); + if (r) items.push({ range: r }); + } + flashCopied(items); + }; + + const copyGroup = (group: ValueGroup, pill: HTMLElement) => { + const editor = editorRef.current; + const monacoInstance = monacoRef.current; + if (!editor || !monacoInstance) return; + const model = editor.getModel(); + if (!model) return; - /** "Copy whole group" above every group with two or more values. */ - function refreshZones() { - const m = model(); - if (!editor || !m) return; - const groups = parseEnv(m.getValue()).groups.filter( - (g) => g.secrets.length >= 2, + const realFile = assembleRealText( + model, + decorationToRealRef.current, + getExpandedIds(), + monacoInstance, ); + const realLines = realFile.split("\n"); + const text = realLines.slice(group.startLine - 1, group.endLine).join("\n"); + + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).catch(() => {}); + } + + const original = pill.textContent; + pill.classList.add("copied"); + pill.textContent = "Copied!"; + window.setTimeout(() => { + if (pill.isConnected) { + pill.classList.remove("copied"); + pill.textContent = original; + } + }, 1200); + + flashCopied([ + { + range: new monacoInstance.Range( + group.startLine, + 1, + group.endLine, + model.getLineMaxColumn(group.endLine), + ), + }, + ]); + }; + + const applyGroupZones = (groups: ValueGroup[]) => { + const editor = editorRef.current; + if (!editor) return; + + const groupsToShow = groups.filter((g) => g.ranges.length >= 2); + editor.changeViewZones((accessor) => { - for (const id of zoneIds) accessor.removeZone(id); - zoneIds = []; - zones.clear(); - for (const g of groups) { - const node = document.createElement("div"); - node.className = "secret-group-zone"; + for (const id of groupZoneIdsRef.current) { + accessor.removeZone(id); + } + groupZoneIdsRef.current = []; + groupZoneMapRef.current.clear(); + + for (const group of groupsToShow) { + const zoneNode = document.createElement("div"); + zoneNode.className = "secret-group-zone"; + const pill = document.createElement("span"); pill.className = "secret-group-pill"; pill.textContent = "Copy whole group"; - node.appendChild(pill); + + zoneNode.appendChild(pill); + const id = accessor.addZone({ - afterLineNumber: g.start, + afterLineNumber: group.startLine - 1, heightInPx: 22, - domNode: node, + domNode: zoneNode, suppressMouseDown: true, }); - const span: Span = { - startLineNumber: g.start + 1, - startColumn: 1, - endLineNumber: g.end + 1, - endColumn: m.getLineMaxColumn(g.end + 1), + groupZoneIdsRef.current.push(id); + groupZoneMapRef.current.set(id, { group, pill }); + } + }); + }; + + const clearGroupZones = () => { + const editor = editorRef.current; + if (!editor) return; + editor.changeViewZones((accessor) => { + for (const id of groupZoneIdsRef.current) { + accessor.removeZone(id); + } + groupZoneIdsRef.current = []; + groupZoneMapRef.current.clear(); + }); + }; + + const recomputeInFlightRef = { current: false }; + + const recomputeCaretReveals = () => { + // tight: never reveal via caret; yolo: hook not wired. + if (revealOnRef.current !== "hover") return; + if (recomputeInFlightRef.current) return; + const editor = editorRef.current; + const model = editor?.getModel(); + if (!editor || !model) return; + + recomputeInFlightRef.current = true; + try { + const selections = editor.getSelections() ?? []; + const next = new Set(); + + for (const decId of trackingDecorationIdsRef.current) { + const range = model.getDecorationRange(decId); + if (!range) continue; + const rValueRange: ValueRange = { + startLine: range.startLineNumber, + startCol: range.startColumn, + endLine: range.endLineNumber, + endCol: range.endColumn, }; - zoneIds.push(id); - zones.set(id, { - pill, - text: () => { - flash(span); - return assemble(span); + for (const sel of selections) { + if (selectionTouchesRange(sel, rValueRange)) { + next.add(decId); + break; + } + } + } + + const prev = caretRevealedDecIdsRef.current; + let unchanged = false; + if (prev.size === next.size) { + unchanged = true; + for (const id of prev) { + if (!next.has(id)) { + unchanged = false; + break; + } + } + } + + if (!unchanged) { + const hoverId = hoverRevealedDecIdRef.current; + for (const id of prev) { + if (!next.has(id) && id !== hoverId) { + collapseDecorationInModel(id); + } + } + for (const id of next) { + if (!prev.has(id)) { + expandDecorationInModel(id); + } + } + + caretRevealedDecIdsRef.current = next; + refreshMaskDecorations(); + } + } finally { + recomputeInFlightRef.current = false; + } + }; + + const findOverlappingTrackingDecoration = ( + model: Monaco.editor.ITextModel, + parsedRange: ValueRange, + ): string | null => { + for (const decId of trackingDecorationIdsRef.current) { + const r = model.getDecorationRange(decId); + if (!r) continue; + if ( + r.startLineNumber === r.endLineNumber && + r.startColumn === r.endColumn + ) { + continue; + } + if ( + r.startLineNumber > parsedRange.endLine || + r.endLineNumber < parsedRange.startLine + ) { + continue; + } + return decId; + } + return null; + }; + + const applySecretDecorations = () => { + const editor = editorRef.current; + const monacoInstance = monacoRef.current; + const model = editor?.getModel(); + if (!editor || !monacoInstance || !model) return; + + assertParserParity(model); + + const parsed = getValueRanges(model); + const expanded = getExpandedIds(); + + const toIngest: ParsedSecret[] = []; + for (const pr of parsed) { + const existing = findOverlappingTrackingDecoration(model, pr.range); + if (existing == null) toIngest.push(pr); + } + + if (toIngest.length > 0) { + const ingestPlan: Array<{ + range: ValueRange; + original: string; + key: string; + }> = []; + for (const pr of toIngest) { + const r = pr.range; + const monacoRange = new monacoInstance.Range( + r.startLine, + r.startCol, + r.endLine, + r.endCol, + ); + const original = model.getValueInRange(monacoRange); + ingestPlan.push({ range: r, original, key: pr.key }); + } + + withInternalEdit(() => { + const edits = ingestPlan.map(({ range, original }) => ({ + range: new monacoInstance.Range( + range.startLine, + range.startCol, + range.endLine, + range.endCol, + ), + text: bulletsForSubstring(original), + })); + editor.executeEdits("mask-ingest", edits); + + const newDecs = ingestPlan.map(({ range }) => ({ + range: new monacoInstance.Range( + range.startLine, + range.startCol, + range.endLine, + range.endCol, + ), + options: { + stickiness: + monacoInstance.editor.TrackedRangeStickiness + ?.AlwaysGrowsWhenTypingAtEdges ?? 0, }, - }); + })); + const newIds = editor.deltaDecorations([], newDecs); + for (let i = 0; i < newIds.length; i++) { + const id = newIds[i]; + trackingDecorationIdsRef.current.add(id); + decorationToRealRef.current.set(id, ingestPlan[i].original); + } + }); + } + + const driftEdits: Array<{ + range: Monaco.Range; + text: string; + }> = []; + const driftUpdates: Array<{ + decId: string; + real: string; + range: Monaco.Range; + }> = []; + for (const pr of parsed) { + if (!pr.closed) continue; + const decId = findOverlappingTrackingDecoration(model, pr.range); + if (decId == null) continue; + if (expanded.has(decId)) continue; + const parsedRange = new monacoInstance.Range( + pr.range.startLine, + pr.range.startCol, + pr.range.endLine, + pr.range.endCol, + ); + const curr = model.getValueInRange(parsedRange); + if (curr.length === 0) continue; + let pure = true; + for (let i = 0; i < curr.length; i++) { + const c = curr.charCodeAt(i); + if (c !== 0x2022 && c !== 0x0a) { + pure = false; + break; + } } - }); - } + if (pure) continue; + const storedReal = decorationToRealRef.current.get(decId); + const newReal = + storedReal != null && storedReal.length === curr.length + ? storedReal + : curr; + driftUpdates.push({ decId, real: newReal, range: parsedRange }); + driftEdits.push({ + range: parsedRange, + text: bulletsForSubstring(newReal), + }); + } + if (driftEdits.length > 0) { + withInternalEdit(() => { + editor.executeEdits("mask-drift-reconcile", driftEdits); + (model as TextModelWithChangeDecorations).changeDecorations( + (accessor) => { + for (const { decId, range } of driftUpdates) { + accessor.changeDecoration(decId, range); + } + }, + ); + }); + for (const { decId, real } of driftUpdates) { + decorationToRealRef.current.set(decId, real); + } + } - function copyZone(id: string) { - const zone = zones.get(id); - if (!zone) return; - void navigator.clipboard?.writeText(zone.text()).catch(() => {}); - zone.pill.classList.add("copied"); - zone.pill.textContent = "Copied!"; - window.setTimeout(() => { - zone.pill.classList.remove("copied"); - zone.pill.textContent = "Copy whole group"; - }, 1200); - } + // Range sync: snap every tracked decoration's range back to the + // parser's canonical range for the value it covers. `AlwaysGrows` + // stickiness will happily extend a decoration across a newline (e.g. + // user hits Enter at the end of an unquoted value) — without this + // step the decoration stays inflated and caret-reveal keeps thinking + // the caret is still inside the value (because the inflated `endCol` + // reaches wherever the caret went), so it never collapses back to + // bullets. + // + // Expanded decorations are synced too: we only touch the range, not + // the model text. For a collapsed decoration we also trim the stored + // real to match the new length (typical excess is a trailing `\n`); + // for an expanded decoration the model holds plaintext, so we take + // the parsed text directly as the new real when lengths differ. + const rangeSyncUpdates: Array<{ + decId: string; + newRange: Monaco.Range; + newReal: string; + }> = []; + for (const pr of parsed) { + if (!pr.closed) continue; + const decId = findOverlappingTrackingDecoration(model, pr.range); + if (decId == null) continue; + const decRange = model.getDecorationRange(decId); + if (!decRange) continue; + if ( + decRange.startLineNumber === pr.range.startLine && + decRange.startColumn === pr.range.startCol && + decRange.endLineNumber === pr.range.endLine && + decRange.endColumn === pr.range.endCol + ) { + continue; + } + const pRange = new monacoInstance.Range( + pr.range.startLine, + pr.range.startCol, + pr.range.endLine, + pr.range.endCol, + ); + const parsedText = model.getValueInRange(pRange); + const storedReal = decorationToRealRef.current.get(decId) ?? ""; + const isExpanded = expanded.has(decId); + let newReal: string; + if (isExpanded) { + // Model holds the live value — trust it. + newReal = parsedText; + } else if (storedReal.length === parsedText.length) { + newReal = storedReal; + } else if (storedReal.length > parsedText.length) { + // Trim trailing overflow (typical case: an inserted `\n`). + newReal = storedReal.slice(0, parsedText.length); + } else { + // Stored is shorter than parsed: fall back to model text. + newReal = parsedText; + } + rangeSyncUpdates.push({ decId, newRange: pRange, newReal }); + } + if (rangeSyncUpdates.length > 0) { + (model as TextModelWithChangeDecorations).changeDecorations( + (accessor) => { + for (const { decId, newRange } of rangeSyncUpdates) { + accessor.changeDecoration(decId, newRange); + } + }, + ); + for (const { decId, newReal } of rangeSyncUpdates) { + decorationToRealRef.current.set(decId, newReal); + } + } + + const decHasParsedOverlap = (r: { + startLineNumber: number; + endLineNumber: number; + }): boolean => { + for (const pr of parsed) { + if (r.startLineNumber > pr.range.endLine) continue; + if (r.endLineNumber < pr.range.startLine) continue; + return true; + } + return false; + }; + + const toDrop: string[] = []; + for (const decId of trackingDecorationIdsRef.current) { + if (expanded.has(decId)) continue; + const r = model.getDecorationRange(decId); + if (!r) { + toDrop.push(decId); + continue; + } + const empty = + r.startLineNumber === r.endLineNumber && r.startColumn === r.endColumn; + if (empty || !decHasParsedOverlap(r)) { + toDrop.push(decId); + } + } + + if (toDrop.length > 0) { + editor.deltaDecorations(toDrop, []); + for (const id of toDrop) { + trackingDecorationIdsRef.current.delete(id); + decorationToRealRef.current.delete(id); + } + } + + refreshMaskDecorations(); + recomputeCaretReveals(); + + const groups = getValueGroups(model, parsed); + applyGroupZones(groups); + + snapshotDecorationOffsets(); + }; + + const revealDecoration = (decId: string) => { + // tight / yolo never hover-reveal. + if (revealOnRef.current !== "hover") return; + if (hoverRevealedDecIdRef.current === decId) return; + const prev = hoverRevealedDecIdRef.current; + if (prev && !caretRevealedDecIdsRef.current.has(prev)) { + collapseDecorationInModel(prev); + } + hoverRevealedDecIdRef.current = decId; + if (!caretRevealedDecIdsRef.current.has(decId)) { + expandDecorationInModel(decId); + } + refreshMaskDecorations(); + }; + + const findDecorationAtPosition = (pos: { + lineNumber: number; + column: number; + }): string | null => { + const editor = editorRef.current; + const model = editor?.getModel(); + if (!model) return null; + for (const decId of trackingDecorationIdsRef.current) { + const r = model.getDecorationRange(decId); + if (!r) continue; + const valueRange: ValueRange = { + startLine: r.startLineNumber, + startCol: r.startColumn, + endLine: r.endLineNumber, + endCol: r.endColumn, + }; + if (posInRange(pos, valueRange)) return decId; + } + return null; + }; - function handleEditorMount(e: Editor, m: typeof Monaco) { - editor = e; - monaco = m; - if (plain) { - const sub = e.onDidChangeModelContent(() => { - if (internal) return; - const next = e.getValue(); - lastEmitted = next; - real = next; - emit(next); + const findDecorationOnLine = (line: number): string | null => { + const editor = editorRef.current; + const model = editor?.getModel(); + if (!model) return null; + for (const decId of trackingDecorationIdsRef.current) { + const r = model.getDecorationRange(decId); + if (!r) continue; + if (line >= r.startLineNumber && line <= r.endLineNumber) return decId; + } + return null; + }; + + const buildRealText = (): string => { + const editor = editorRef.current; + const monacoInstance = monacoRef.current; + const model = editor?.getModel(); + if (!model || !monacoInstance) return ""; + return assembleRealText( + model, + decorationToRealRef.current, + getExpandedIds(), + monacoInstance, + ); + }; + + const handleEditorChange = () => { + if (internalEditDepthRef.current > 0) return; + let out = buildRealText(); + out = out.replace(/\n*$/, "") + "\n"; + lastEmittedValueRef.current = out; + onChangeRef.current(out); + }; + + const handleYoloChange = () => { + const editor = editorRef.current; + const model = editor?.getModel(); + if (!model) return; + let out = model.getValue(); + out = out.replace(/\n*$/, "") + "\n"; + lastEmittedValueRef.current = out; + onChangeRef.current(out); + }; + + const handleEditorMount = ( + editor: Monaco.editor.IStandaloneCodeEditor, + monaco: typeof Monaco, + ) => { + editorRef.current = editor; + monacoRef.current = monaco; + + if (revealOn === "always") { + // yolo: plain text editor. No masking, no decorations, no listeners + // beyond forwarding content changes to the parent. + editor.onDidChangeModelContent(() => { + handleYoloChange(); }); - disposers.push(() => sub.dispose()); return; } - render(value); - e.updateOptions({ emptySelectionClipboard: false, dragAndDrop: false }); + const initial = pendingInitialMaskRef.current; + pendingInitialMaskRef.current = null; + if (initial && initial.entries.length > 0) { + const decs = initial.entries.map((entry: MaskEntry) => ({ + range: new monaco.Range( + entry.range.startLine, + entry.range.startCol, + entry.range.endLine, + entry.range.endCol, + ), + options: { + stickiness: + monaco.editor.TrackedRangeStickiness + ?.AlwaysGrowsWhenTypingAtEdges ?? 0, + }, + })); + const ids = editor.deltaDecorations([], decs); + for (let i = 0; i < ids.length; i++) { + trackingDecorationIdsRef.current.add(ids[i]); + decorationToRealRef.current.set(ids[i], initial.entries[i].original); + } + editor.getModel()?.pushStackElement(); + } - // The model is rewritten as values hide and show, so undo works on plain-text snapshots. - const { KeyMod, KeyCode } = m; - e.addCommand(KeyMod.CtrlCmd | KeyCode.KeyZ, () => travel(past, future)); - e.addCommand(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ, () => - travel(future, past), - ); - e.addCommand(KeyMod.CtrlCmd | KeyCode.KeyY, () => travel(future, past)); - - const zoneOf = (t: Monaco.editor.IMouseTarget) => - t.type === m.editor.MouseTargetType.CONTENT_VIEW_ZONE - ? t.detail.viewZoneId - : undefined; - - const subs = [ - e.onDidChangeModelContent((event) => { - if (internal) return; - if (reconcile(event.changes)) { - commit(assemble()); - return; + const model = editor.getModel(); + if (model) { + const groups = getValueGroups(model, getValueRanges(model)); + applyGroupZones(groups); + } + refreshMaskDecorations(); + + snapshotDecorationOffsets(); + + editor.onDidChangeModelContent( + (e: { + changes?: Array<{ + rangeOffset: number; + rangeLength: number; + text: string; + }>; + }) => { + if (internalEditDepthRef.current > 0) return; + const m = editor.getModel(); + if (m && e?.changes) { + applyTypingChangesToReal(m, e.changes); } - // Hidden mode: the edit touched a hidden value. Put the text back as it was. - const before = real; - queueMicrotask(() => render(before)); - toast(HIDDEN_HINT, { id: "secrets-hidden-edit" }); - }), - e.onDidChangeCursorSelection(() => { - if (!internal) syncShown(); - }), - e.onMouseMove((event) => { - const dom = e.getDomNode(); - const zone = zoneOf(event.target); - if (zone) { - dom?.classList.toggle("secret-group-zone-hover", zones.has(zone)); - hideTooltip(); - return; + applySecretDecorations(); + handleEditorChange(); + }, + ); + + const VIEW_ZONE_TARGET_TYPE = + monaco.editor.MouseTargetType?.CONTENT_VIEW_ZONE ?? 8; + const CONTENT_TEXT_TARGET_TYPE = + monaco.editor.MouseTargetType?.CONTENT_TEXT ?? 6; + + let mouseDownInfo: { + pos: { lineNumber: number; column: number }; + time: number; + } | null = null; + let mouseDownZoneId: string | null = null; + + editor.onMouseDown((e) => { + if (e.target?.type === VIEW_ZONE_TARGET_TYPE) { + mouseDownInfo = null; + mouseDownZoneId = e.target.detail?.viewZoneId ?? null; + return; + } + + mouseDownZoneId = null; + const pos = e.target?.position; + if (!pos) { + mouseDownInfo = null; + return; + } + mouseDownInfo = { pos, time: Date.now() }; + }); + + editor.onMouseUp((e) => { + if (mouseDownZoneId !== null) { + const downZoneId = mouseDownZoneId; + mouseDownZoneId = null; + if ( + e.target?.type === VIEW_ZONE_TARGET_TYPE && + e.target.detail?.viewZoneId === downZoneId + ) { + const entry = groupZoneMapRef.current.get(downZoneId); + if (entry) copyGroup(entry.group, entry.pill); } - dom?.classList.remove("secret-group-zone-hover"); - const line = event.target.position?.lineNumber; - const id = line ? idOnLine(line) : null; - if (!id) { - hideTooltip(); - if (hoverId) { - hoverId = null; - syncShown(); + return; + } + + if (!mouseDownInfo) return; + const downInfo = mouseDownInfo; + mouseDownInfo = null; + + const pos = e.target?.position; + if (!pos) return; + + const samePos = + pos.lineNumber === downInfo.pos.lineNumber && + pos.column === downInfo.pos.column; + const fast = Date.now() - downInfo.time < 300; + + if (!samePos || !fast) return; + + const decId = findDecorationAtPosition(pos); + if (!decId) return; + + const real = decorationToRealRef.current.get(decId); + if (real == null) return; + const toCopy = stripQuotes(real); + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(toCopy).catch(() => {}); + } + flashCopiedDecorations([decId]); + flashTooltipCopied(); + }); + + const editorDom = editor.getDomNode(); + let hoveredZoneId: string | null = null; + editor.onMouseMove((e) => { + const targetType = e.target?.type; + + if (targetType === VIEW_ZONE_TARGET_TYPE) { + const zoneId: string | undefined = e.target.detail?.viewZoneId; + if (zoneId && groupZoneMapRef.current.has(zoneId)) { + if (zoneId !== hoveredZoneId) { + if (hoveredZoneId) { + groupZoneMapRef.current + .get(hoveredZoneId) + ?.pill.classList.remove("hover"); + } + hoveredZoneId = zoneId; + groupZoneMapRef.current.get(zoneId)?.pill.classList.add("hover"); + editorDom?.classList.add("secret-group-zone-hover"); } + hideTooltip(); return; } - showTooltip(id); - // Multi-line values reveal on click only: showing them would move the lines under - // the pointer. - const r = rangeOf(id); - const oneLine = - r?.startLineNumber === r?.endLineNumber && - !hidden.get(id)?.includes("\n"); - const next = reveals && oneLine ? id : null; - if (hoverId !== next) { - hoverId = next; - syncShown(); - } - }), - e.onMouseLeave(() => { + } + + if (hoveredZoneId) { + groupZoneMapRef.current + .get(hoveredZoneId) + ?.pill.classList.remove("hover"); + hoveredZoneId = null; + editorDom?.classList.remove("secret-group-zone-hover"); + } + + if (targetType !== CONTENT_TEXT_TARGET_TYPE) { + hideTooltip(); + return; + } + + const pos = e.target?.position; + if (!pos) { + hideTooltip(); + return; + } + + const decId = findDecorationOnLine(pos.lineNumber); + if (!decId) { + hideTooltip(); + return; + } + + if (hoveredDecIdRef.current === decId) return; + + hoveredDecIdRef.current = decId; + // `tight` keeps the tooltip ("Click to copy") but never reveals. + revealDecoration(decId); + showTooltipAtDecoration(decId); + }); + + editor.onDidChangeCursorSelection(() => { + recomputeCaretReveals(); + }); + + editor.onDidScrollChange(() => { + const id = hoveredDecIdRef.current; + if (id) { + showTooltipAtDecoration(id); + } else if ( + tooltipRef.current && + tooltipRef.current.style.display !== "none" + ) { + tooltipRef.current.style.display = "none"; + } + }); + + const domNode = editor.getDomNode(); + if (domNode) { + domNode.addEventListener("mouseleave", () => { hideTooltip(); - e.getDomNode()?.classList.remove("secret-group-zone-hover"); - if (hoverId) { - hoverId = null; - syncShown(); + domNode.classList.remove("secret-group-zone-hover"); + }); + } + + const handleCopy = (e: ClipboardEvent) => { + const sel = editor.getSelection(); + if (!sel || sel.isEmpty()) return; + const realFile = buildRealText(); + const realSliced = sliceRealByMonacoRange(realFile, sel); + if (e.clipboardData) { + e.clipboardData.setData("text/plain", realSliced); + e.preventDefault(); + } + }; + + const handleCut = (e: ClipboardEvent) => { + const sel = editor.getSelection(); + const m = editor.getModel(); + if (!sel || sel.isEmpty() || !m) return; + const realFile = buildRealText(); + const realSliced = sliceRealByMonacoRange(realFile, sel); + if (e.clipboardData) { + e.clipboardData.setData("text/plain", realSliced); + e.preventDefault(); + } + withInternalEdit(() => { + editor.executeEdits("clipboard-cut", [ + { range: sel, text: "", forceMoveMarkers: true }, + ]); + }); + applySecretDecorations(); + handleEditorChange(); + }; + + if (editorDom) { + editorDom.addEventListener("copy", handleCopy as EventListener); + editorDom.addEventListener("cut", handleCut as EventListener); + } + }; + + function syncExternalValue(value: string, revealOn: ProjectRevealOn) { + revealOnRef.current = revealOn; + if (value === lastParentValueRef.current) return; + if (value === lastEmittedValueRef.current) { + lastParentValueRef.current = value; + return; + } + lastParentValueRef.current = value; + lastEmittedValueRef.current = value; + + const editor = editorRef.current; + const monacoInstance = monacoRef.current; + + if (revealOn === "always") { + if (!editor) return; + const model = editor.getModel(); + if (!model) return; + if (model.getValue() === value) return; + withInternalEdit(() => { + const fullRange = model.getFullModelRange(); + editor.executeEdits("external-value-reset", [ + { range: fullRange, text: value, forceMoveMarkers: true }, + ]); + if (model.getValue() !== value) { + model.setValue(value); } - }), - ]; - - let down: { - line: number; - column: number; - at: number; - zone?: string; - } | null = null; - subs.push( - e.onMouseDown((event) => { - const zone = zoneOf(event.target); - const pos = event.target.position; - down = zone - ? { line: 0, column: 0, at: Date.now(), zone } - : pos - ? { line: pos.lineNumber, column: pos.column, at: Date.now() } - : null; - }), - e.onMouseUp((event) => { - const start = down; - down = null; - if (!start || Date.now() - start.at > 300) return; - if (start.zone) { - if (zoneOf(event.target) === start.zone) copyZone(start.zone); - return; + model.pushStackElement(); + }); + return; + } + + if (!editor || !monacoInstance) { + const result = maskText(value); + pendingInitialMaskRef.current = result; + return; + } + + const model = editor.getModel(); + if (!model) return; + + hoverRevealedDecIdRef.current = null; + caretRevealedDecIdsRef.current = new Set(); + + const oldIds = Array.from(trackingDecorationIdsRef.current); + if (oldIds.length > 0) { + editor.deltaDecorations(oldIds, []); + } + trackingDecorationIdsRef.current.clear(); + decorationToRealRef.current.clear(); + + const result = maskText(value); + + withInternalEdit(() => { + const fullRange = model.getFullModelRange(); + editor.executeEdits("external-value-reset", [ + { range: fullRange, text: result.masked, forceMoveMarkers: true }, + ]); + if (model.getValue() !== result.masked) { + model.setValue(result.masked); + } + + if (result.entries.length > 0) { + const decs = result.entries.map((entry: MaskEntry) => ({ + range: new monacoInstance.Range( + entry.range.startLine, + entry.range.startCol, + entry.range.endLine, + entry.range.endCol, + ), + options: { + stickiness: + monacoInstance.editor.TrackedRangeStickiness + ?.AlwaysGrowsWhenTypingAtEdges ?? 0, + }, + })); + const ids = editor.deltaDecorations([], decs); + for (let i = 0; i < ids.length; i++) { + trackingDecorationIdsRef.current.add(ids[i]); + decorationToRealRef.current.set(ids[i], result.entries[i].original); } - const pos = event.target.position; - if ( - !pos || - pos.lineNumber !== start.line || - pos.column !== start.column - ) - return; - const id = idOnLine(pos.lineNumber); - const r = id ? rangeOf(id) : null; - // Clicking the value (not its key) copies it. - if ( - id && - r && - (pos.lineNumber > r.startLineNumber || pos.column >= r.startColumn) - ) - copyValue(id); - }), - ); - disposers.push(() => subs.forEach((s) => s.dispose())); - - // Hidden values are not in the model, so copy and cut put them back in. Capture phase, - // before Monaco's own handler on its textarea writes the model text. - const dom = e.getDomNode(); - const onCopy = (event: ClipboardEvent, cut: boolean) => { - const sel = e.getSelection(); - if (!sel || sel.isEmpty() || !event.clipboardData) return; - event.clipboardData.setData("text/plain", assemble(sel)); - event.preventDefault(); - event.stopPropagation(); - if (cut && !e.getOption(m.editor.EditorOption.readOnly)) - e.executeEdits("cut", [{ range: sel, text: "" }]); - }; - const copy = (event: ClipboardEvent) => onCopy(event, false); - const cut = (event: ClipboardEvent) => onCopy(event, true); - dom?.addEventListener("copy", copy, true); - dom?.addEventListener("cut", cut, true); - disposers.push(() => { - dom?.removeEventListener("copy", copy, true); - dom?.removeEventListener("cut", cut, true); + } + + model.pushStackElement(); }); + + refreshMaskDecorations(); + const groups = getValueGroups(model, getValueRanges(model)); + applyGroupZones(groups); } - function syncExternalValue(next: string) { - if (next === lastEmitted || next === real) return; - lastEmitted = next; - if (plain) { - const m = model(); - real = next; - if (m && m.getValue() !== next) quietly(() => m.setValue(next)); - return; + function setOnChange(onChange: (value: string) => void) { + onChangeRef.current = onChange; + } + + function dispose() { + if (tooltipRef.current) { + tooltipRef.current.remove(); + tooltipRef.current = null; } - past = []; - future = []; - render(next); + clearGroupZones(); } return { maskedInitialValue, handleEditorMount, syncExternalValue, - setOnChange(next: (value: string) => void) { - emit = next; - }, - dispose() { - disposers.forEach((d) => d()); - tooltip?.remove(); - tooltip = null; - if (tooltipTimer !== null) window.clearTimeout(tooltipTimer); - }, + setOnChange, + dispose, }; } diff --git a/apps/web/src/lib/secrets/monaco/theme.ts b/apps/web/src/lib/secrets/monaco/theme.ts deleted file mode 100644 index 401b357d..00000000 --- a/apps/web/src/lib/secrets/monaco/theme.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type * as Monaco from "monaco-editor"; - -/** A design token (lib/appearance/theme.css) as the hex colour Monaco expects. */ -function token(name: string): string { - const raw = getComputedStyle(document.documentElement) - .getPropertyValue(`--${name}`) - .trim(); - if (/^#[0-9a-f]{6}$/i.test(raw)) return raw; - // Anything else (short hex, rgb(), oklch()) goes through canvas, which normalises to hex. - const ctx = document.createElement("canvas").getContext("2d"); - if (!ctx) return "#808080"; - ctx.fillStyle = raw; - return ctx.fillStyle; -} - -/** - * Defines and applies the editor theme from the current tokens. Monaco has one global theme, - * so call this on mount and again whenever the app theme or grey preset changes. - */ -export function applyEditorTheme(monaco: typeof Monaco, dark: boolean) { - const name = dark ? "cryptly-dark" : "cryptly-light"; - const fg = (n: string) => token(n).slice(1); - const clear = "#00000000"; - monaco.editor.defineTheme(name, { - base: dark ? "vs-dark" : "vs", - inherit: true, - rules: [ - { token: "", foreground: fg("fg2") }, - { token: "variable", foreground: fg("fg") }, - { token: "delimiter", foreground: fg("faint") }, - { token: "string", foreground: fg("fg2") }, - { token: "comment", foreground: fg("fg3") }, - ], - colors: { - "editor.background": token("s100"), - "editor.foreground": token("fg2"), - "editorGutter.background": token("s100"), - "editorLineNumber.foreground": token("faint"), - "editorLineNumber.activeForeground": token("fg3"), - "editorCursor.foreground": token("fg"), - "editor.selectionBackground": token("s100-sel"), - "editor.inactiveSelectionBackground": token("s100-hover"), - "editor.lineHighlightBackground": clear, - "editor.lineHighlightBorder": clear, - "editorWidget.background": token("elev"), - "editorWidget.border": token("elev-border"), - "input.background": token("field"), - "input.border": token("field-border"), - focusBorder: clear, - "scrollbar.shadow": clear, - "scrollbarSlider.background": `${token("fg3")}33`, - "scrollbarSlider.hoverBackground": `${token("fg3")}55`, - "scrollbarSlider.activeBackground": `${token("fg3")}77`, - }, - }); - monaco.editor.setTheme(name); -} diff --git a/apps/web/src/lib/secrets/secrets-editor-nav-guard.svelte.ts b/apps/web/src/lib/secrets/secrets-editor-nav-guard.svelte.ts index 948aaddb..2938dbce 100644 --- a/apps/web/src/lib/secrets/secrets-editor-nav-guard.svelte.ts +++ b/apps/web/src/lib/secrets/secrets-editor-nav-guard.svelte.ts @@ -13,9 +13,4 @@ export const secretsEditorNavGuard = $state({ save: null as null | (() => Promise), discard: null as null | (() => void), push: null as null | (() => Promise), - /** - * Replaces the working document (History's Restore). The saved version stays the baseline, - * so the result shows as unsaved changes and ⌘Z undoes it. Null until secrets are decrypted. - */ - replaceDoc: null as null | ((content: string) => void), }); diff --git a/apps/web/src/lib/secrets/ui/MobileSavePushButtons.svelte b/apps/web/src/lib/secrets/ui/MobileSavePushButtons.svelte new file mode 100644 index 00000000..72f7259b --- /dev/null +++ b/apps/web/src/lib/secrets/ui/MobileSavePushButtons.svelte @@ -0,0 +1,116 @@ + + +
+
+ + +
+ {#if menuOpen} + + + {/if} +
diff --git a/apps/web/src/lib/secrets/ui/SecretForm.svelte b/apps/web/src/lib/secrets/ui/SecretForm.svelte deleted file mode 100644 index 48a30bf8..00000000 --- a/apps/web/src/lib/secrets/ui/SecretForm.svelte +++ /dev/null @@ -1,132 +0,0 @@ - - -
- onkeydown(event, "key")} - onpaste={onKeyPaste} - /> - - -
- {#if error} - {error} - {:else if duplicate} - {name} already exists - {/if} - - -
-
diff --git a/apps/web/src/lib/secrets/ui/SecretsEditorPage.svelte b/apps/web/src/lib/secrets/ui/SecretsEditorPage.svelte new file mode 100644 index 00000000..0c88c7ca --- /dev/null +++ b/apps/web/src/lib/secrets/ui/SecretsEditorPage.svelte @@ -0,0 +1,781 @@ + + + + +{#if loadPhase === "loading"} +
+ Loading project… +
+{:else if loadPhase === "error" || loadPhase === "forbidden"} +
+ {loadMessage ?? "Something went wrong."} +
+{:else if loadPhase === "locked"} + +{:else if loadPhase === "ready"} +
+ {#if isExternallyUpdated} +
+ This project was updated elsewhere. Refresh the project before saving or + pushing. +
+ {/if} + {#key revealOn} + + {/key} + {#if changedBy} +
+ + {changedBy} + +
+ {/if} + {#if active && ftux.step === "editor"} + +
Store API keys, tokens, and sensitive data.
+
Everything is end-to-end encrypted.
+
+ {/if} + +
+{/if} diff --git a/apps/web/src/lib/secrets/ui/SecretsTable.svelte b/apps/web/src/lib/secrets/ui/SecretsTable.svelte deleted file mode 100644 index 83c17b0e..00000000 --- a/apps/web/src/lib/secrets/ui/SecretsTable.svelte +++ /dev/null @@ -1,701 +0,0 @@ - - - - - - - -{#snippet value(s: EnvSecret, mode: "peek" | "line" | "wrap")} - {#if mode === "line"} - {oneLine(s.value)} - {:else if mode === "wrap"} - {s.value} - {:else} - {@const p = revealOn === "never" ? null : peek(s.value)} - {#if p}{p.head}••••••{p.tail}{:else}••••••••{/if} - {/if} -{/snippet} - -{#snippet gone(list: EnvSecret[] | undefined)} - {#each list ?? [] as s (s.id)} -
- {s.key} - {@render value(s, revealOn === "always" ? "line" : "peek")} - {#if !readOnly} - - {/if} -
- {/each} -{/snippet} - -{#snippet row(s: EnvSecret)} - {@const sel = selected.has(s.id)} - {@const tone = status(s)} - {@const mode = - revealed === s.id ? "wrap" : revealOn === "always" ? "line" : "peek"} - - {#if s.id === lastSelected} - - {/if} -{/snippet} - -{#snippet group(g: EnvGroup, rows: EnvSecret[], first: boolean)} -
(hovered = g.index < 0 ? null : g.index)} - onpointerleave={() => (hovered = null)} - > - - {#snippet head()} - {#if editing?.kind === "rename" && editing.group === g.index} - - nameKeydown(event, () => - commitRename(g.index, event.currentTarget), - )} - onblur={(event) => commitRename(g.index, event.currentTarget)} - /> - {:else if readOnly || g.index < 0} -

- {g.name ?? "Untitled"} -

- {:else} -

- -

- {/if} - {/snippet} - {#snippet trail()} - {#if g.secrets.length} - - {:else if !readOnly && g.index >= 0} - onedit(removeGroup(doc, g.index))} - > - - - {/if} - {/snippet} - {@render gone(deleted[`top:${g.index}`])} - {#each rows as s (s.id)} - {@render gone(deleted[`before:${s.id}`])} - {#if editing?.kind === "edit" && editing.id === s.id} - finish(s.id)} - onsubmit={(key, value) => { - onedit(setSecret(doc, s.id, key, value)); - void finish(key); - }} - /> - {:else} - {@render row(s)} - {/if} - {@render gone(deleted[`after:${s.id}`])} - {/each} - {#if editing?.kind === "add" && editing.group === g.index} - finish()} - onsubmit={(key, value) => { - onedit(addSecret(doc, g.index, key, value)); - void finish(key); - }} - /> - {:else if !readOnly} - - {:else if !g.secrets.length} -

No secrets yet

- {/if} -
-
-{/snippet} - -
-
- {#each columns as column, c (c)} -
- {#each column as i (visible[i].g.index)} - {@render group(visible[i].g, visible[i].rows, i === 0)} - {/each} - {#if c === lastColumn && !readOnly && !query && parsed.groups.length} - {#if editing?.kind === "group"} - - {#snippet head()} - - nameKeydown(event, () => commitGroup(event.currentTarget))} - onblur={(event) => commitGroup(event.currentTarget)} - /> - {/snippet} - - {:else} - - {/if} - {/if} -
- {/each} -
-
diff --git a/apps/web/src/lib/secrets/ui/SecretsView.svelte b/apps/web/src/lib/secrets/ui/SecretsView.svelte deleted file mode 100644 index 27760eab..00000000 --- a/apps/web/src/lib/secrets/ui/SecretsView.svelte +++ /dev/null @@ -1,738 +0,0 @@ - - - - -{#snippet controls()} - {#if view === "table"} - - {/if} -
- {#if readOnly} - Read only - {:else if saving} - Saving… - {:else if isDirty} - - - - {:else} - Saved - {/if} - {#if integrations.length && !readOnly} - - - - {/if} - - {#if !readOnly} - - {/if} - localStorage.setItem(VIEW_KEY, next)} - /> -
-{/snippet} - -
- - {#if phase === "ready"} -
{@render controls()}
- {/if} -
- - {#if phase === "loading"} -

Decrypting…

- {:else if phase === "error" || phase === "forbidden"} -
- -

{message ?? "Something went wrong."}

-
-
- {:else if phase === "ready"} - - {#if externallyUpdated} - - {/if} - {#if view === "table"} - - {:else} -
- -
- {#if active} - {#key revealOn} - (doc = next)} - {revealOn} - {readOnly} - /> - {/key} - {/if} -
-
- {/if} - - {#if active && ftux.step === "editor"} - - Store API keys, tokens and other secrets here. Everything is end-to-end - encrypted. - - {:else if active && ftux.step === "save"} - - Press Save or ⌘S. Your secrets are encrypted on - this device before they leave it. - - {:else if active && ftux.step === "integrations"} - - Connect GitHub in Settings to push these secrets to your repositories. - - {/if} - {/if} -
- - { - pasteText = ""; - pasteError = null; - }} -> - - - {#if parsed.groups.length > 1} - - {/if} - {#if pasteError} -

{pasteError}

- {/if} -
- - -
-
- - -
    - {#each repos as repo, i (i)} -
  • - {repo} -
  • - {/each} -
-
- - -
-
diff --git a/apps/web/src/lib/shared/ui/BracketsIcon.svelte b/apps/web/src/lib/shared/ui/BracketsIcon.svelte new file mode 100644 index 00000000..32032380 --- /dev/null +++ b/apps/web/src/lib/shared/ui/BracketsIcon.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/apps/web/src/lib/shared/ui/BulbIcon.svelte b/apps/web/src/lib/shared/ui/BulbIcon.svelte new file mode 100644 index 00000000..ab9361de --- /dev/null +++ b/apps/web/src/lib/shared/ui/BulbIcon.svelte @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/apps/web/src/lib/shared/ui/ButtonLoader.svelte b/apps/web/src/lib/shared/ui/ButtonLoader.svelte new file mode 100644 index 00000000..fd29ba6e --- /dev/null +++ b/apps/web/src/lib/shared/ui/ButtonLoader.svelte @@ -0,0 +1,26 @@ + + + + diff --git a/apps/web/src/lib/shared/ui/FtuxPopover.svelte b/apps/web/src/lib/shared/ui/FtuxPopover.svelte index 65b74936..7c8e45aa 100644 --- a/apps/web/src/lib/shared/ui/FtuxPopover.svelte +++ b/apps/web/src/lib/shared/ui/FtuxPopover.svelte @@ -9,88 +9,98 @@ nextFTUXStep, previousFTUXStep, } from "$lib/stores/ftux.svelte"; - import Button from "$lib/ui/Button.svelte"; - import IconButton from "$lib/ui/IconButton.svelte"; + import { cn } from "$lib/utils"; - /** One tour step: a card under the first visible element matching `anchor`, pointing at it. */ let { - anchor, + arrow, + arrowStyle = "", nextLabel = "Next", + class: className = "", children, }: { - /** CSS selector of the element the card points at. */ - anchor: string; + /** Side of the card the arrow sits on (points at the anchor). */ + arrow: "top" | "bottom"; + arrowStyle?: string; nextLabel?: string; + class?: string; children: Snippet; } = $props(); - const WIDTH = 300; const step = $derived(ftuxStepNumber()); - let pos = $state<{ top: number; left: number; arrow: number } | null>(null); - - // Follows the anchor every frame: it moves when the topbar or the grid re-flows. - $effect(() => { - const selector = anchor; - let frame = 0; - const place = () => { - const el = [...document.querySelectorAll(selector)].find( - (e) => e.offsetParent !== null, - ); - const r = el?.getBoundingClientRect(); - if (!r) pos = null; - else { - const center = r.left + Math.min(r.width, WIDTH) / 2; - const left = Math.round( - Math.min(Math.max(8, center - WIDTH / 2), innerWidth - WIDTH - 8), - ); - const top = Math.round(Math.min(r.bottom + 10, innerHeight - 200)); - if (pos?.top !== top || pos.left !== left) - pos = { top, left, arrow: Math.round(center - left) }; - } - frame = requestAnimationFrame(place); - }; - place(); - return () => cancelAnimationFrame(frame); - }); -{#if pos} -