Skip to content

Commit 33ebb52

Browse files
feat: support serving a gallery of notebooks (#8056)
## 📝 Summary Enables running multiple marimo notebook files as a gallery of apps in the below style: ```shell marimo run folder marimo run folder another_folder marimo run file_a.py file_b.py folder ``` When pointed to a local clone of https://github.com/marimo-team/learn this gives us: <img width="1047" height="983" alt="Screenshot 2026-01-29 at 19 24 02" src="https://github.com/user-attachments/assets/bb6b949c-52f8-4e34-b675-8628e22e0dc4" /> Closes #3257 ## 🔍 Description of Changes I based this work on #4961, borrowing the idea of reusing the existing home page infrastructure used in `marimo edit` mode and adding `gallery` as a new frontend view in `marimo run` mode. - Gallery item links are encoded through `/?file=<encoded>` to avoid introducing new routing / mounting logic - Navigation is restricted so that we don't accept arbitrary `/?file=<encoded>` values - extended `marimo._server.file_router.ListOfFilesAppFileRouter` to behave like an allowlist router - Preserved existing `marimo run app.py -- --arg value` behavior working while enabling `marimo run file_a.py file_b.py folder` and still allowing notebook args to be explicitly separated - Added a root directory heuristic for nicer URLs and less path leakage (via `marimo._cli.cli.py._resolve_root_dir`) - e.g. if I run `uv run marimo run /Users/petergy/Projects/opensource/marimo-team/learn`. the `learn` repo is recognized as gallery root, so opening `/Users/petergy/Projects/opensource/marimo-team/learn/functional_programming/05_functors.py` will be available under `http://localhost:2720/?file=functional_programming%2F05_functors.py` - Gallery entries have auto-generated `title` and `subtitle` for now (notebook dir and file name piped through `titleCase`) ## TODO before undrafting this - define `--sandbox` semantics - per-notebook or per-gallery venv? - should we create sandbox eagerly (we create all the venvs at cmd execution time) or lazily (create venv only on notebook visit)? ## 📋 Checklist - [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md). - [x] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable). - [x] Tests have been added for the changes made. - [x] Documentation has been updated where applicable, including docstrings for API changes. - [x] Pull request title is a good summary of the changes - it will be used in the [release notes](https://github.com/marimo-team/marimo/releases). --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent fc88208 commit 33ebb52

File tree

16 files changed

+854
-55
lines changed

16 files changed

+854
-55
lines changed
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/* Copyright 2026 Marimo. All rights reserved. */
2+
3+
import { SearchIcon } from "lucide-react";
4+
import type React from "react";
5+
import { Suspense, useMemo, useState } from "react";
6+
import { ErrorBoundary } from "@/components/editor/boundary/ErrorBoundary";
7+
import { Spinner } from "@/components/icons/spinner";
8+
import { Card, CardContent } from "@/components/ui/card";
9+
import { Input } from "@/components/ui/input";
10+
import { getSessionId } from "@/core/kernel/session";
11+
import { useRequestClient } from "@/core/network/requests";
12+
import { useAsyncData } from "@/hooks/useAsyncData";
13+
import { Banner } from "@/plugins/impl/common/error-banner";
14+
import { prettyError } from "@/utils/errors";
15+
import { PathBuilder, Paths } from "@/utils/paths";
16+
import { asURL } from "@/utils/url";
17+
18+
const capitalize = (word: string): string => {
19+
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
20+
};
21+
22+
const titleCase = (path: string): string => {
23+
const delimiter = PathBuilder.guessDeliminator(path).deliminator;
24+
return path
25+
.replace(/\.[^./]+$/, "")
26+
.split(delimiter)
27+
.filter(Boolean)
28+
.map((part) => part.split(/[_-]/).map(capitalize).join(" "))
29+
.join(" > ");
30+
};
31+
32+
const tabTarget = (path: string): string => {
33+
return `${getSessionId()}-${encodeURIComponent(path)}`;
34+
};
35+
36+
const SEARCH_THRESHOLD = 10;
37+
38+
const GalleryPage: React.FC = () => {
39+
const { getWorkspaceFiles } = useRequestClient();
40+
const [searchQuery, setSearchQuery] = useState("");
41+
const response = useAsyncData(
42+
() => getWorkspaceFiles({ includeMarkdown: false }),
43+
[],
44+
);
45+
const workspace = response.data;
46+
const files = workspace?.files ?? [];
47+
const root = workspace?.root ?? "";
48+
49+
const formattedFiles = useMemo(() => {
50+
return files
51+
.filter((file) => !file.isDirectory)
52+
.map((file) => {
53+
const relativePath =
54+
root && Paths.isAbsolute(file.path) && file.path.startsWith(root)
55+
? Paths.rest(file.path, root)
56+
: file.path;
57+
const title = titleCase(Paths.basename(relativePath));
58+
const subtitle = titleCase(Paths.dirname(relativePath));
59+
return {
60+
...file,
61+
relativePath,
62+
title,
63+
subtitle,
64+
};
65+
})
66+
.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
67+
}, [files, root]);
68+
69+
const filteredFiles = useMemo(() => {
70+
if (!searchQuery) {
71+
return formattedFiles;
72+
}
73+
const query = searchQuery.toLowerCase();
74+
return formattedFiles.filter((file) =>
75+
file.title.toLowerCase().includes(query),
76+
);
77+
}, [formattedFiles, searchQuery]);
78+
79+
if (response.isPending) {
80+
return <Spinner centered={true} size="xlarge" className="mt-6" />;
81+
}
82+
83+
if (response.error) {
84+
return (
85+
<Banner kind="danger" className="rounded p-4">
86+
{prettyError(response.error)}
87+
</Banner>
88+
);
89+
}
90+
91+
if (!workspace) {
92+
return <Spinner centered={true} size="xlarge" className="mt-6" />;
93+
}
94+
95+
return (
96+
<Suspense>
97+
<div className="flex flex-col gap-6 max-w-6xl container pt-5 pb-20 z-10">
98+
<img src="logo.png" alt="marimo logo" className="w-48 mb-2" />
99+
<ErrorBoundary>
100+
<div className="flex flex-col gap-2">
101+
{workspace.hasMore && (
102+
<Banner kind="warn" className="rounded p-4">
103+
Showing first {workspace.fileCount} files. Your workspace has
104+
more files.
105+
</Banner>
106+
)}
107+
{formattedFiles.length > SEARCH_THRESHOLD && (
108+
<Input
109+
id="search"
110+
value={searchQuery}
111+
icon={<SearchIcon className="h-4 w-4" />}
112+
onChange={(event) => setSearchQuery(event.target.value)}
113+
placeholder="Search"
114+
rootClassName="mb-3"
115+
className="mb-0 border-border"
116+
/>
117+
)}
118+
{filteredFiles.length === 0 ? (
119+
<Banner kind="warn" className="rounded p-4">
120+
No marimo apps found.
121+
</Banner>
122+
) : (
123+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
124+
{filteredFiles.map((file) => (
125+
<a
126+
key={file.path}
127+
href={asURL(
128+
`?file=${encodeURIComponent(file.relativePath)}`,
129+
).toString()}
130+
target={tabTarget(file.path)}
131+
className="no-underline"
132+
>
133+
<Card className="h-full hover:bg-accent/20 transition-colors">
134+
<CardContent className="p-6">
135+
<div className="flex flex-col gap-1">
136+
{file.subtitle && (
137+
<div className="text-sm font-semibold text-muted-foreground">
138+
{file.subtitle}
139+
</div>
140+
)}
141+
<div className="text-lg font-medium">
142+
{file.title}
143+
</div>
144+
</div>
145+
</CardContent>
146+
</Card>
147+
</a>
148+
))}
149+
</div>
150+
)}
151+
</div>
152+
</ErrorBoundary>
153+
</div>
154+
</Suspense>
155+
);
156+
};
157+
158+
export default GalleryPage;

‎frontend/src/core/MarimoApp.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,15 @@ const LazyRunPage = reactLazyWithPreload(
3434
const LazyEditPage = reactLazyWithPreload(
3535
() => import("@/components/pages/edit-page"),
3636
);
37+
const LazyGalleryPage = reactLazyWithPreload(
38+
() => import("@/components/pages/gallery-page"),
39+
);
3740

3841
export function preloadPage(mode: string) {
3942
if (mode === "home") {
4043
LazyHomePage.preload();
44+
} else if (mode === "gallery") {
45+
LazyGalleryPage.preload();
4146
} else if (mode === "read") {
4247
LazyRunPage.preload();
4348
} else {
@@ -58,6 +63,9 @@ export const MarimoApp: React.FC = memo(() => {
5863
if (initialMode === "home") {
5964
return <LazyHomePage.Component />;
6065
}
66+
if (initialMode === "gallery") {
67+
return <LazyGalleryPage.Component />;
68+
}
6169
if (initialMode === "read") {
6270
return <LazyRunPage.Component appConfig={appConfig} />;
6371
}

‎frontend/src/core/mode.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import { store } from "./state/jotai";
1313
* - `edit`: A user is editing the notebook. Can switch to present mode.
1414
* - `present`: A user is presenting the notebook, it looks like read mode but with some editing features. Cannot switch to present mode.
1515
* - `home`: A user is in the home page.
16+
* - `gallery`: A user is in the gallery page.
1617
*/
17-
export type AppMode = "read" | "edit" | "present" | "home";
18+
export type AppMode = "read" | "edit" | "present" | "home" | "gallery";
1819

1920
export function getInitialAppMode(): Exclude<AppMode, "present"> {
2021
const initialMode = store.get(initialModeAtom);
@@ -28,8 +29,8 @@ export function getInitialAppMode(): Exclude<AppMode, "present"> {
2829

2930
export function toggleAppMode(mode: AppMode): AppMode {
3031
// Can't switch to present mode.
31-
if (mode === "read") {
32-
return "read";
32+
if (mode === "read" || mode === "home" || mode === "gallery") {
33+
return mode;
3334
}
3435

3536
return mode === "edit" ? "present" : "edit";

‎frontend/src/core/run-app.tsx‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,38 @@ export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
5656
return <CellsRenderer appConfig={appConfig} mode="read" />;
5757
};
5858

59+
const galleryHref = (() => {
60+
if (typeof window === "undefined") {
61+
return null;
62+
}
63+
const url = new URL(window.location.href);
64+
if (!url.searchParams.has("file")) {
65+
return null;
66+
}
67+
url.searchParams.delete("file");
68+
const search = url.searchParams.toString();
69+
return search ? `${url.pathname}?${search}` : url.pathname;
70+
})();
71+
5972
return (
6073
<AppContainer
6174
connection={connection}
6275
isRunning={isRunning}
6376
width={appConfig.width}
6477
>
65-
<AppHeader connection={connection} className={"sm:pt-8"} />
78+
<AppHeader connection={connection} className={"sm:pt-8"}>
79+
{galleryHref && (
80+
<div className="flex items-center px-6 pt-4">
81+
<a
82+
href={galleryHref}
83+
aria-label="Back to gallery"
84+
className="inline-flex items-center"
85+
>
86+
<img src="logo.png" alt="marimo logo" className="h-6 w-auto" />
87+
</a>
88+
</div>
89+
)}
90+
</AppHeader>
6691
{renderCells()}
6792
</AppContainer>
6893
);

‎frontend/src/mount.tsx‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,16 @@ const mountOptionsSchema = z.object({
153153
.nullish()
154154
.transform((val) => val ?? "unknown"),
155155
/**
156-
* 'edit' or 'read'/'run' or 'home'
156+
* 'edit' or 'read'/'run' or 'home' or 'gallery'
157157
*/
158-
mode: z.enum(["edit", "read", "home", "run"]).transform((val): AppMode => {
159-
if (val === "run") {
160-
return "read";
161-
}
162-
return val;
163-
}),
158+
mode: z
159+
.enum(["edit", "read", "home", "run", "gallery"])
160+
.transform((val): AppMode => {
161+
if (val === "run") {
162+
return "read";
163+
}
164+
return val;
165+
}),
164166
/**
165167
* marimo config
166168
*/

0 commit comments

Comments
 (0)