Landing page with terminal demo, ISL embed, and read-only mode

- Add landing page for logged-out users: animated terminal showing
  grove init → sl commit → sl push workflow, ISL iframe embed,
  and feature grid (Sapling, Mononoke, Canopy, self-hosted)
- Add read-only mode to ISL: server blocks runOperation/abort,
  client hides action buttons and shows "Read-only" badge
- Add Caddy route for isl.grove.host proxying to ISL server
- Logged-in users see the existing repo list as before
Anton Kaminsky22d ago4bb999b8f19aparent 0d9d723
10 files changed+358-10
addons/isl/src/TopBar.css
@@ -27,3 +27,15 @@
2727 align-items: center;
2828 flex-grow: 1;
2929}
30
31.read-only-badge {
32 font-size: 11px;
33 font-weight: 600;
34 text-transform: uppercase;
35 letter-spacing: 0.05em;
36 color: var(--foreground);
37 opacity: 0.6;
38 padding: 2px 8px;
39 border: 1px solid currentColor;
40 border-radius: 4px;
41}
3042
addons/isl/src/TopBar.tsx
@@ -26,7 +26,7 @@
2626import {DebugToolsButton} from './debug/DebugToolsButton';
2727import {T} from './i18n';
2828import {maybeRemoveForgottenOperation, useClearAllOptimisticState} from './operationsState';
29import {haveCommitsLoadedYet, haveRemotePath, isFetchingCommits, watchmanStatus} from './serverAPIState';
29import {applicationinfo, haveCommitsLoadedYet, haveRemotePath, isFetchingCommits, watchmanStatus} from './serverAPIState';
3030
3131import {Internal} from './Internal';
3232import './TopBar.css';
@@ -34,6 +34,8 @@
3434export function TopBar() {
3535 const loaded = useAtomValue(haveCommitsLoadedYet);
3636 const canPush = useAtomValue(haveRemotePath);
37 const appInfo = useAtomValue(applicationinfo);
38 const readOnly = appInfo?.readOnly ?? false;
3739
3840 if (!loaded) {
3941 return null;
@@ -41,21 +43,22 @@
4143 return (
4244 <div className="top-bar">
4345 <span className="button-group">
44 {canPush && <PullButton />}
46 {readOnly && <span className="read-only-badge"><T>Read-only</T></span>}
47 {!readOnly && canPush && <PullButton />}
4548 <CwdSelector />
46 <DownloadCommitsTooltipButton />
47 <ShelvedChangesMenu />
48 <BulkActionsMenu />
49 <BookmarksManagerMenu />
50 {Internal.FullRepoBranchButton && <Internal.FullRepoBranchButton />}
49 {!readOnly && <DownloadCommitsTooltipButton />}
50 {!readOnly && <ShelvedChangesMenu />}
51 {!readOnly && <BulkActionsMenu />}
52 {!readOnly && <BookmarksManagerMenu />}
53 {!readOnly && Internal.FullRepoBranchButton && <Internal.FullRepoBranchButton />}
5154 <FetchingDataIndicator />
5255 </span>
5356 <span className="button-group">
5457 <FlexSpacer />
55 <DebugToolsButton />
58 {!readOnly && <DebugToolsButton />}
5659 <WatchmanStatusIndicator />
5760 <FocusModeToggle />
58 <BugButton />
61 {!readOnly && <BugButton />}
5962 <SettingsGearButton />
6063 <RefreshButton />
6164 </span>
6265
addons/isl/src/operationsState.ts
@@ -15,6 +15,7 @@
1515import serverAPI from './ClientToServerAPI';
1616import {atomFamilyWeak, readAtom, writeAtom} from './jotaiUtils';
1717import {atomResetOnCwdChange} from './repositoryData';
18import {applicationinfo} from './serverAPIState';
1819import {Timer} from './timer';
1920import {registerCleanup, registerDisposable, short} from './utils';
2021
@@ -484,6 +485,9 @@
484485 */
485486export function useRunOperation() {
486487 return useCallback(async (operation: Operation, throwOnError?: boolean): Promise<void> => {
488 if (readAtom(applicationinfo)?.readOnly) {
489 return;
490 }
487491 const result = await runOperationImpl(operation);
488492 if (result != null && throwOnError) {
489493 throw result;
490494
addons/isl/src/types.ts
@@ -256,6 +256,7 @@
256256 platformName: string;
257257 version: string;
258258 logFilePath: string;
259 readOnly?: boolean;
259260};
260261
261262/**
262263
addons/isl-server/proxy/server.ts
@@ -31,6 +31,7 @@
3131 command: string;
3232 slVersion: string;
3333 foreground: boolean;
34 readOnly?: boolean;
3435};
3536
3637export type StartServerResult =
@@ -53,6 +54,7 @@
5354 command,
5455 slVersion,
5556 foreground,
57 readOnly,
5658}: StartServerArgs): Promise<StartServerResult> {
5759 const originalProcessCwd = process.cwd();
5860 const serverRoot = path.isAbsolute(ossSmartlogDir)
@@ -229,6 +231,7 @@
229231
230232 appMode: {mode: 'isl'},
231233 platform: platformImpl,
234 readOnly,
232235 });
233236 socket.on('close', () => {
234237 dispose();
235238
addons/isl-server/src/ServerToClientAPI.ts
@@ -319,6 +319,7 @@
319319 platformName: this.platform.platformName,
320320 version: this.connection.version,
321321 logFilePath: this.connection.logFileLocation ?? '(no log file, logging to stdout)',
322 readOnly: this.connection.readOnly ?? false,
322323 },
323324 });
324325 break;
@@ -701,6 +702,17 @@
701702 break;
702703 }
703704 case 'runOperation': {
705 if (this.connection.readOnly) {
706 const {operation} = data;
707 this.postMessage({
708 type: 'operationProgress',
709 kind: 'exit',
710 exitCode: 1,
711 id: operation.id,
712 timestamp: Date.now() / 1000,
713 });
714 break;
715 }
704716 const {operation} = data;
705717 repo.runOrQueueOperation(ctx, operation, progress => {
706718 this.postMessage({type: 'operationProgress', ...progress});
@@ -711,6 +723,9 @@
711723 break;
712724 }
713725 case 'abortRunningOperation': {
726 if (this.connection.readOnly) {
727 break;
728 }
714729 const {operationId} = data;
715730 repo.abortRunningOperation(operationId);
716731 this.handleMaybeForgotOperation(operationId, repo);
717732
addons/isl-server/src/index.ts
@@ -51,6 +51,9 @@
5151 platform?: ServerPlatform;
5252 appMode: AppMode;
5353
54 /** When true, block all write operations (commit, amend, rebase, etc.) */
55 readOnly?: boolean;
56
5457 /**
5558 * A deferred promise that resolves when the client signals it's ready
5659 */
5760
hub/Caddyfile
@@ -85,6 +85,21 @@
8585 }
8686}
8787
88isl.{$DOMAIN} {
89 handle /ws {
90 reverse_proxy isl-server:3011
91 }
92
93 handle {
94 reverse_proxy isl-server:3011
95 }
96
97 header {
98 X-Content-Type-Options nosniff
99 X-Frame-Options SAMEORIGIN
100 }
101}
102
88103{$DOMAIN} {
89104 handle /v2/* {
90105 reverse_proxy registry:5000
91106
web/app/landing.tsx
@@ -0,0 +1,287 @@
1"use client";
2
3import { useEffect, useRef, useState } from "react";
4import { GroveLogo } from "@/app/components/grove-logo";
5
6const TERMINAL_LINES = [
7 { prompt: true, text: "grove init --owner letterpress-labs", delay: 40 },
8 { prompt: false, text: "┌ grove init grove-cli", delay: 0 },
9 { prompt: false, text: "◇ Created letterpress-labs/grove-cli", delay: 800 },
10 { prompt: false, text: "◇ Sapling repository initialized", delay: 400 },
11 { prompt: false, text: "◇ Remote configured", delay: 300 },
12 { prompt: false, text: "└ Initialized letterpress-labs/grove-cli", delay: 200 },
13 { prompt: false, text: "", delay: 400 },
14 { prompt: true, text: 'sl commit -m "add CLI help system and read-only ISL mode"', delay: 35 },
15 { prompt: false, text: "", delay: 600 },
16 { prompt: true, text: "sl push --to main", delay: 40 },
17 { prompt: false, text: "pushing rev c21911da to bookmark main", delay: 400 },
18 { prompt: false, text: "edenapi: uploaded 9 files", delay: 300 },
19 { prompt: false, text: "edenapi: uploaded 1 commit", delay: 200 },
20 { prompt: false, text: 'updated remote bookmark main to c21911da', delay: 300 },
21];
22
23function TerminalDemo() {
24 const [lines, setLines] = useState<{ text: string; isPrompt: boolean }[]>([]);
25 const [currentTyping, setCurrentTyping] = useState("");
26 const [isTyping, setIsTyping] = useState(false);
27 const [showCursor, setShowCursor] = useState(true);
28 const termRef = useRef<HTMLDivElement>(null);
29
30 useEffect(() => {
31 const blink = setInterval(() => setShowCursor((c) => !c), 530);
32 return () => clearInterval(blink);
33 }, []);
34
35 useEffect(() => {
36 let cancelled = false;
37
38 async function run() {
39 await sleep(800);
40 for (const line of TERMINAL_LINES) {
41 if (cancelled) return;
42 if (line.prompt) {
43 setIsTyping(true);
44 for (let i = 0; i <= line.text.length; i++) {
45 if (cancelled) return;
46 setCurrentTyping(line.text.slice(0, i));
47 await sleep(line.delay + Math.random() * 20);
48 }
49 setIsTyping(false);
50 setLines((prev) => [...prev, { text: line.text, isPrompt: true }]);
51 setCurrentTyping("");
52 await sleep(200);
53 } else {
54 await sleep(line.delay);
55 if (line.text) {
56 setLines((prev) => [...prev, { text: line.text, isPrompt: false }]);
57 } else {
58 setLines((prev) => [...prev, { text: "", isPrompt: false }]);
59 }
60 }
61 }
62 }
63 run();
64 return () => { cancelled = true; };
65 }, []);
66
67 useEffect(() => {
68 if (termRef.current) {
69 termRef.current.scrollTop = termRef.current.scrollHeight;
70 }
71 }, [lines, currentTyping]);
72
73 const cursor = showCursor ? "█" : " ";
74
75 return (
76 <div
77 ref={termRef}
78 style={{
79 backgroundColor: "#1a1918",
80 border: "1px solid #302e2b",
81 padding: "20px",
82 fontFamily: "'JetBrains Mono', Menlo, monospace",
83 fontSize: "13px",
84 lineHeight: 1.7,
85 color: "#c4bfb8",
86 overflow: "hidden",
87 maxHeight: "380px",
88 }}
89 >
90 {lines.map((line, i) => (
91 <div key={i} style={{ minHeight: "1.7em" }}>
92 {line.isPrompt && <span style={{ color: "#7aab9c" }}>❯ </span>}
93 {line.isPrompt ? (
94 <span style={{ color: "#e8e4df" }}>{line.text}</span>
95 ) : (
96 <span style={{ color: "#9a948c" }}>{line.text}</span>
97 )}
98 </div>
99 ))}
100 {(isTyping || lines.length === 0) && (
101 <div>
102 <span style={{ color: "#7aab9c" }}>❯ </span>
103 <span style={{ color: "#e8e4df" }}>{currentTyping}</span>
104 <span style={{ color: "#7aab9c" }}>{cursor}</span>
105 </div>
106 )}
107 </div>
108 );
109}
110
111function sleep(ms: number) {
112 return new Promise((r) => setTimeout(r, ms));
113}
114
115export function LandingPage() {
116 const [islDomain, setIslDomain] = useState("");
117
118 useEffect(() => {
119 const host = window.location.hostname;
120 // Derive isl subdomain: grove.host → isl.grove.host, localhost → ""
121 if (host !== "localhost" && !host.match(/^\d/)) {
122 setIslDomain(`https://isl.${host}`);
123 }
124 }, []);
125
126 return (
127 <div style={{ maxWidth: "800px", margin: "0 auto", padding: "60px 24px 80px" }}>
128 {/* Hero */}
129 <div style={{ textAlign: "center", marginBottom: "64px" }}>
130 <div style={{ display: "inline-block", marginBottom: "24px" }}>
131 <GroveLogo size={64} />
132 </div>
133 <h1
134 style={{
135 fontSize: "2rem",
136 fontWeight: 400,
137 color: "var(--text-primary)",
138 marginBottom: "12px",
139 }}
140 >
141 Source control, self-hosted
142 </h1>
143 <p
144 style={{
145 fontSize: "1rem",
146 color: "var(--text-muted)",
147 maxWidth: "480px",
148 margin: "0 auto 32px",
149 lineHeight: 1.6,
150 }}
151 >
152 Grove is a complete code hosting platform built on Sapling SCM and Mononoke.
153 Stacked diffs, interactive smartlog, CI pipelines — on your own infrastructure.
154 </p>
155 <a
156 href="/login"
157 style={{
158 display: "inline-block",
159 padding: "10px 24px",
160 backgroundColor: "var(--accent)",
161 color: "var(--accent-text)",
162 fontSize: "0.875rem",
163 textDecoration: "none",
164 }}
165 >
166 Get started
167 </a>
168 </div>
169
170 {/* Terminal */}
171 <section style={{ marginBottom: "64px" }}>
172 <div style={{ marginBottom: "16px" }}>
173 <h2
174 style={{
175 fontSize: "0.75rem",
176 fontWeight: 600,
177 textTransform: "uppercase",
178 letterSpacing: "0.1em",
179 color: "var(--text-faint)",
180 marginBottom: "4px",
181 }}
182 >
183 Init, commit, push
184 </h2>
185 <p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
186 From zero to hosted repository in seconds. Sapling&apos;s clean CLI, backed by Mononoke.
187 </p>
188 </div>
189 <TerminalDemo />
190 </section>
191
192 {/* ISL */}
193 {islDomain && (
194 <section style={{ marginBottom: "64px" }}>
195 <div style={{ marginBottom: "16px" }}>
196 <h2
197 style={{
198 fontSize: "0.75rem",
199 fontWeight: 600,
200 textTransform: "uppercase",
201 letterSpacing: "0.1em",
202 color: "var(--text-faint)",
203 marginBottom: "4px",
204 }}
205 >
206 Interactive Smartlog
207 </h2>
208 <p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
209 Visualize your commit graph, browse diffs, and manage stacked changes — all in the browser.
210 This is a live, read-only view of Grove&apos;s own repository.
211 </p>
212 </div>
213 <div
214 style={{
215 border: "1px solid var(--border-subtle)",
216 overflow: "hidden",
217 height: "500px",
218 }}
219 >
220 <iframe
221 src={islDomain}
222 style={{
223 width: "100%",
224 height: "100%",
225 border: "none",
226 }}
227 title="Interactive Smartlog"
228 />
229 </div>
230 </section>
231 )}
232
233 {/* Features */}
234 <section>
235 <div
236 style={{
237 display: "grid",
238 gridTemplateColumns: "1fr 1fr",
239 gap: "1px",
240 backgroundColor: "var(--border-subtle)",
241 border: "1px solid var(--border-subtle)",
242 }}
243 >
244 {[
245 {
246 title: "Sapling SCM",
247 desc: "Stacked diffs, amend-based workflow, interactive rebase — built for how engineers actually work.",
248 },
249 {
250 title: "Mononoke",
251 desc: "Meta's scalable source control server. Handles monorepos with millions of files.",
252 },
253 {
254 title: "Canopy CI",
255 desc: "Pipelines defined in YAML, triggered on push. Build, test, and deploy from your own runners.",
256 },
257 {
258 title: "Self-hosted",
259 desc: "Your code, your servers. Deploy on any Linux machine with a single command.",
260 },
261 ].map((f) => (
262 <div
263 key={f.title}
264 style={{
265 backgroundColor: "var(--bg-card)",
266 padding: "24px",
267 }}
268 >
269 <h3
270 style={{
271 fontSize: "0.875rem",
272 color: "var(--text-primary)",
273 marginBottom: "6px",
274 }}
275 >
276 {f.title}
277 </h3>
278 <p style={{ fontSize: "0.8rem", color: "var(--text-muted)", lineHeight: 1.6 }}>
279 {f.desc}
280 </p>
281 </div>
282 ))}
283 </div>
284 </section>
285 </div>
286 );
287}
0288
web/app/page.tsx
@@ -7,9 +7,14 @@
77import { useAuth } from "@/lib/auth";
88import { Skeleton } from "@/app/components/skeleton";
99import { timeAgo } from "@/lib/utils";
10import { LandingPage } from "./landing";
1011
1112export default function HomePage() {
12 const { user } = useAuth();
13 const { user, loading } = useAuth();
14
15 if (!loading && !user) {
16 return <LandingPage />;
17 }
1318 const [repoList, setRepoList] = useState<Repo[]>([]);
1419 const [loaded, setLoaded] = useState(false);
1520 const [query, setQuery] = useState("");
1621