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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { createKeyboardLayoutRouter } from "./keyboardLayout";
import { createMenuRouter } from "./menu";
import { createMigrationRouter } from "./migration";
import { createNotificationsRouter } from "./notifications";
import { createPageContentRouter } from "./page-content";
import { createPermissionsRouter } from "./permissions";
import { createPluginsRouter } from "./plugins";
import { createPortsRouter } from "./ports";
Expand Down Expand Up @@ -45,6 +46,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
changes: createChangesRouter(),
filesystem: createFilesystemRouter(),
notifications: createNotificationsRouter(getWindow),
pageContent: createPageContentRouter(),
permissions: createPermissionsRouter(),
plugins: createPluginsRouter(),
ports: createPortsRouter(),
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/lib/trpc/routers/page-content/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createPageContentRouter } from "./page-content";
17 changes: 17 additions & 0 deletions apps/desktop/src/lib/trpc/routers/page-content/page-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { registerPageContent, releasePageContent } from "main/lib/pageContent";
import { z } from "zod";
import { publicProcedure, router } from "../..";

export const createPageContentRouter = () => {
return router({
register: publicProcedure
.input(z.object({ html: z.string() }))
.mutation(({ input }) => registerPageContent(input.html)),

release: publicProcedure
.input(z.object({ token: z.string() }))
.mutation(({ input }) => {
releasePageContent(input.token);
}),
});
};
13 changes: 13 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { loadWebviewBrowserExtension } from "./lib/extensions";
import { getHostServiceCoordinator } from "./lib/host-service-coordinator";
import { localDb } from "./lib/local-db";
import { requestLocalNetworkAccess } from "./lib/local-network-permission";
import { PAGE_SCHEME, pageProtocolHandler } from "./lib/pageContent";
import {
initTanstackDbPersistence,
shutdownTanstackDbPersistence,
Expand Down Expand Up @@ -339,6 +340,13 @@ protocol.registerSchemesAsPrivileged([
supportFetchAPI: true,
},
},
{
scheme: PAGE_SCHEME,
privileges: {
standard: true,
secure: true,
},
},
]);

const gotTheLock = app.requestSingleInstanceLock();
Expand Down Expand Up @@ -376,6 +384,11 @@ if (!gotTheLock) {
.fromPartition("persist:superset")
.protocol.handle("superset-icon", iconProtocolHandler);

protocol.handle(PAGE_SCHEME, pageProtocolHandler);
session
.fromPartition("persist:superset")
.protocol.handle(PAGE_SCHEME, pageProtocolHandler);

// Serve system fonts (e.g. SF Mono on macOS) via custom protocol
// so the renderer can use @font-face with font-src 'self' CSP
if (process.platform === "darwin") {
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/lib/pageContent/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {
PAGE_SCHEME,
pageProtocolHandler,
registerPageContent,
releasePageContent,
} from "./pageContent";
51 changes: 51 additions & 0 deletions apps/desktop/src/main/lib/pageContent/pageContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { randomUUID } from "node:crypto";

export const PAGE_SCHEME = "superset-page";

const contents = new Map<string, string>();

export function registerPageContent(html: string): {
token: string;
url: string;
} {
const token = randomUUID();
contents.set(token, html);
return { token, url: `${PAGE_SCHEME}://${token}/` };
}

export function releasePageContent(token: string): void {
contents.delete(token);
}

export function readPageContent(token: string): string | undefined {
return contents.get(token);
}

const PAGE_CSP = [
"default-src 'none'",
"script-src 'unsafe-inline'",
"style-src 'unsafe-inline'",
"img-src data: blob: https:",
"media-src data: blob: https:",
"font-src data: https:",
"form-action 'none'",
].join("; ");

export function pageProtocolHandler(request: Request): Response {
const url = new URL(request.url);
if (url.pathname !== "/") {
return new Response("Not found", { status: 404 });
}
const html = readPageContent(url.hostname);
if (html === undefined) {
return new Response("Not found", { status: 404 });
}
return new Response(html, {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": PAGE_CSP,
"Cache-Control": "no-store",
},
});
}
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@
- default-src 'self': Only allow resources from same origin
- script-src 'self' 'wasm-unsafe-eval' https://*.posthog.com: Allow scripts from same origin + WebAssembly (for xterm ImageAddon) + PostHog
- style-src 'self' 'unsafe-inline': Allow styles from same origin + inline (needed for CSS-in-JS)
- connect-src 'self' data: blob: ws: wss: http://127.0.0.1:* %RELAY_URL% https://relay-backup.superset.sh https://relay2.superset.sh https://superset-relay2.avi-6ac.workers.dev %NEXT_PUBLIC_API_URL% https://*.posthog.com https://*.sentry.io sentry-ipc: https://*.preview.bl.run: Allow WebSocket + API + PostHog + Sentry + data URIs (file attachment upload via data URL) + blob URIs + local host-service (127.0.0.1) + relay + relay override target (for staging/failover via PostHog flag) + cloud workspace sandboxes, which a browser reaches directly at the provider's preview domain (this entry goes away once that traffic moves behind the relay)
- connect-src 'self' data: blob: ws: wss: http://127.0.0.1:* %RELAY_URL% https://relay-backup.superset.sh https://relay2.superset.sh https://superset-relay2.avi-6ac.workers.dev %NEXT_PUBLIC_API_URL% https://*.posthog.com https://*.sentry.io sentry-ipc: https://*.preview.bl.run https://*.public.blob.vercel-storage.com: Allow WebSocket + API + PostHog + Sentry + data URIs (file attachment upload via data URL) + blob URIs + local host-service (127.0.0.1) + relay + relay override target (for staging/failover via PostHog flag) + cloud workspace sandboxes, which a browser reaches directly at the provider's preview domain (this entry goes away once that traffic moves behind the relay) + published page content, which the page pane fetches straight from blob storage (this entry goes away when pages move to a user-content origin we serve ourselves)
- img-src 'self' data: blob: https: http:: Allow images from any source (needed for favicons, browser pane webview content, and file attachment previews)
- media-src 'self' data: blob:: Allow <video>/<audio> from blob + data URIs (file preview); without this, media falls back to default-src 'self' and blob video won't play
- font-src 'self': Allow fonts from same origin
- frame-src https: http: data: blob:: Allow webview browser pane to load any URL
- frame-src https: http: data: blob: superset-page:: Allow webview browser pane to load any URL + the page pane's frame, which main serves under the page's own CSP (srcdoc would inherit this policy and block the page's scripts)
- child-src 'self' blob:: Allow workers from same origin + blob workers
-->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://*.posthog.com; style-src 'self' 'unsafe-inline'; connect-src 'self' data: blob: ws: wss: http://127.0.0.1:* %RELAY_URL% https://relay-backup.superset.sh https://relay2.superset.sh https://superset-relay2.avi-6ac.workers.dev %NEXT_PUBLIC_API_URL% https://*.posthog.com https://*.sentry.io sentry-ipc: https://*.preview.bl.run; img-src 'self' data: blob: https: http:; media-src 'self' data: blob:; font-src 'self'; frame-src https: http: data: blob:; child-src 'self' blob:;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://*.posthog.com; style-src 'self' 'unsafe-inline'; connect-src 'self' data: blob: ws: wss: http://127.0.0.1:* %RELAY_URL% https://relay-backup.superset.sh https://relay2.superset.sh https://superset-relay2.avi-6ac.workers.dev %NEXT_PUBLIC_API_URL% https://*.posthog.com https://*.sentry.io sentry-ipc: https://*.preview.bl.run https://*.public.blob.vercel-storage.com; img-src 'self' data: blob: https: http:; media-src 'self' data: blob:; font-src 'self'; frame-src https: http: data: blob: superset-page:; child-src 'self' blob:;" />
</head>

<body>
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/lib/cloud-trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export const CLOUD_TRPC_ROUTER_ROOTS = [
"host",
"integration",
"organization",
"page",
"pageComment",
"support",
"task",
"team",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { FEATURE_FLAGS } from "@superset/shared/constants";
import { workspaceTrpc } from "@superset/workspace-client";
import { eq } from "@tanstack/db";
import { useLiveQuery } from "@tanstack/react-db";
import { useFeatureFlagEnabled } from "posthog-js/react";
import { useEffect, useRef, useState } from "react";
import { LuFile, LuGitCompareArrows } from "react-icons/lu";
import { LuFile, LuFileText, LuGitCompareArrows } from "react-icons/lu";
import { getChangesetFileKey } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset";
import { useWorkspaceGitStatus } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/WorkspaceGitStatusProvider";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import { useSettings } from "renderer/stores/settings";
import type { CommentPaneData, DiffFocusSide } from "../../types";
import type { CommentPaneData, DiffFocusSide, PagePaneData } from "../../types";
import { FilesTab } from "./components/FilesTab";
import { PagesTab } from "./components/PagesTab";
import { PRActionHeader } from "./components/PRActionHeader";
import { SidebarHeader } from "./components/SidebarHeader";
import { useChangesTab } from "./hooks/useChangesTab";
Expand All @@ -20,9 +23,14 @@ import type { SidebarTabDefinition } from "./types";
// exist in v2 yet. The PR status group (link + merge dropdown for an open PR)
// always renders so users can see PR state and merge once a PR exists.

type SidebarTabId = "changes" | "files" | "review";
type SidebarTabId = "changes" | "files" | "review" | "pages";

const VALID_TAB_IDS: readonly SidebarTabId[] = ["changes", "files", "review"];
const VALID_TAB_IDS: readonly SidebarTabId[] = [
"changes",
"files",
"review",
"pages",
];

function isSidebarTabId(tab: string): tab is SidebarTabId {
return (VALID_TAB_IDS as readonly string[]).includes(tab);
Expand All @@ -43,6 +51,7 @@ interface WorkspaceSidebarProps {
changeKey?: string,
) => void;
onOpenComment?: (comment: CommentPaneData) => void;
onOpenPage?: (page: PagePaneData) => void;
onSearch?: () => void;
selectedFilePath?: string;
pendingReveal?: PendingReveal | null;
Expand All @@ -53,6 +62,7 @@ export function WorkspaceSidebar({
onSelectFile,
onSelectDiffFile,
onOpenComment,
onOpenPage,
onSearch,
selectedFilePath,
pendingReveal,
Expand Down Expand Up @@ -166,8 +176,32 @@ export function WorkspaceSidebar({
),
};

const tabs: SidebarTabDefinition[] = [filesTab, changesTab, reviewTab];
const activeTabDef = tabs.find((t) => t.id === activeTab);
const isPagesEnabled = useFeatureFlagEnabled(FEATURE_FLAGS.PAGES) ?? false;
const pagesTab: SidebarTabDefinition = {
id: "pages",
label: "Pages",
icon: LuFileText,
content: (
<PagesTab
workspaceId={workspaceId}
onOpenPage={(page) =>
onOpenPage?.({
pageId: page.id,
slug: page.slug,
title: page.title,
})
}
/>
),
};

const tabs: SidebarTabDefinition[] = [
filesTab,
changesTab,
reviewTab,
...(isPagesEnabled ? [pagesTab] : []),
];
const activeTabDef = tabs.find((t) => t.id === activeTab) ?? tabs[0];

return (
<div
Expand All @@ -181,7 +215,7 @@ export function WorkspaceSidebar({
/>
<SidebarHeader
tabs={tabs}
activeTab={activeTab}
activeTab={activeTabDef?.id ?? activeTab}
onTabChange={setActiveTab}
compact={compact}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Spinner } from "@superset/ui/spinner";
import { cn } from "@superset/ui/utils";
import { formatDistanceToNowStrict } from "date-fns";
import { Building2, FileText, Lock } from "lucide-react";
import { cloudTrpc } from "renderer/lib/cloud-trpc";

interface PagesTabProps {
workspaceId: string;
onOpenPage: (page: { id: string; slug: string; title: string }) => void;
activePageId?: string;
}

const GROUPS = [
{ visibility: "org", label: "Team", icon: Building2 },
{ visibility: "just_me", label: "Just me", icon: Lock },
] as const;

export function PagesTab({
workspaceId,
onOpenPage,
activePageId,
}: PagesTabProps) {
const pages = cloudTrpc.page.list.useQuery({ workspaceId });

if (pages.isPending) {
return (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="size-4" />
</div>
);
}

if (pages.error) {
return (
<p className="px-3 py-4 text-muted-foreground text-xs">
{pages.error.message}
</p>
);
}

if (pages.data.length === 0) {
return (
<p className="px-3 py-4 text-muted-foreground text-xs">
Nothing published from this workspace yet.
</p>
);
}

return (
<div className="flex h-full flex-col overflow-y-auto">
{GROUPS.map(({ visibility, label, icon: Icon }) => {
const group = pages.data.filter(
(page) => page.visibility === visibility,
);
if (group.length === 0) return null;

return (
<div key={visibility} className="flex shrink-0 flex-col">
<div className="flex h-7 shrink-0 items-center gap-1.5 px-3 text-[10px] text-muted-foreground/70 uppercase tracking-wide">
<Icon className="size-3 shrink-0" />
<span className="font-medium">{label}</span>
<span className="tabular-nums">{group.length}</span>
</div>
{group.map((page) => (
<button
key={page.id}
type="button"
onClick={() =>
onOpenPage({
id: page.id,
slug: page.slug,
title: page.title,
})
}
className={cn(
"flex h-7 w-full shrink-0 items-center gap-1.5 px-3 text-left text-xs transition-colors",
page.id === activePageId
? "bg-accent text-accent-foreground"
: "hover:bg-accent/50",
)}
>
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">
{page.title}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{formatDistanceToNowStrict(page.updatedAt, {
addSuffix: true,
})}
</span>
</button>
))}
</div>
);
})}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PagesTab } from "./PagesTab";
Loading
Loading