Skip to content

Commit 2c7faba

Browse files
authored
AppKernel and Google Drive Libraries (#119)
## Summary This PR advances the Google Drive import/copy workflow by introducing an AppKernel-based JavaScript notebook runner path (browser-local execution via `JSKernel`), a user-facing Google Drive cookbook notebook, and a `Save As` flow that correctly rebinds the current notebook to a newly created Drive-backed mirror. ## Motivation We want a better story for Google Drive manipulation from in-app scripting (App Console and executable notebook cells), including a clear path for users to: - copy the current notebook to Google Drive - continue editing the copied notebook with normal autosave behavior - learn and validate Drive operations using executable documentation notebooks The existing architecture mirrors Drive files into IndexedDB (`local://file/...`) and treats the local mirror URI as the active notebook identity. That means a correct `Save As` implementation cannot simply change the remote target in place; it must create a new local mirrored notebook identity and switch the UI to it. ## What Changed ### 1. AppKernel notebook execution (JavaScript-only, browser-local) - Added an AppKernel runner option for notebook cells using browser-side `JSKernel` execution. - AppKernel captures `stdout/stderr` and writes notebook `CellOutput[]` so outputs render in the notebook UI. - Added CUJ coverage and browser artifacts to verify rendered outputs (not just notebook model state). - Removed reliance on the legacy `WebContainer` path for this work. ### 2. App Console / AppKernel helper surface improvements - Exposed AppConsole-style helper namespaces in AppKernel JS cells (`runme`, `drive`, `googleClientManager`, `oidc`, `app`, etc.). - Improved `JSKernel` logging/error visibility and BigInt-safe output formatting for `console.log(...)`. - Added structured logging (`appLogger`) for AppKernel execution and Drive helper operations. ### 3. Google Drive `Save As` for mirrored notebooks - Added `drive.saveAsCurrentNotebook(folderIdOrUri, fileName)` helper. - Implementation creates a new Drive file, uploads the current notebook JSON, creates a new local mirrored record, and switches the current UI notebook to the new local URI. - Added `app.openNotebook(localUri)` helper and app-state wiring to switch the active notebook from runtime helpers. This matches the current architecture where the active document is the local mirror (`local://file/...`) and `CurrentDocContext` derives the URL `?doc=` from the mirror's `remoteUri`. ### 4. User-facing executable documentation notebook - Added a JSON Runme notebook under `docs/` (user-facing docs location) with executable AppKernel JS cells demonstrating Google Drive operations. - Includes a “How to use this notebook” / `Save As` cell so users can create and edit their own Drive-backed copy. - SaveAs filename timestamp is optional and disabled by default. ### 5. Design documentation - Added `docs-dev/design/appkernel.md` for the AppKernel runner approach - Added `docs-dev/design/saveas.md` describing the mirrored-notebook `Save As` / rename design and why URI rekeying is not the right v1 implementation ## Testing ### Automated - Targeted Vitest coverage for: - `driveTransfer` save-as helper behavior - `NotebookData` AppKernel execution and helper availability - `JSKernel` output formatting / logging behavior - `Actions` output rendering behavior - `runme run build test` passes ### Browser CUJ - Added/updated AppKernel JavaScript notebook CUJ that verifies: - AppKernel cell execution - rendered notebook output visibility (UI-level assertions) - rerun output replacement semantics - Video artifact generated during local validation (not committed) ## Notes / Follow-ups - Explorer tree refresh after `Save As` may not immediately show the new file under a mounted mirrored folder until refresh/resync; the helper creates the local mirrored file record and switches tabs, but does not yet insert into the mirrored folder children list. - `app.runners.*` parity in AppKernel cells remains a follow-up. Fix #115 --------- Signed-off-by: Jeremy lewi <jeremy@lewi.us>
1 parent 02e723b commit 2c7faba

30 files changed

+3376
-323
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ Run the backend-unavailable toast check (backend must be stopped, frontend runni
7171
cd app && bash test/browser/test-backend-toast.sh
7272
```
7373

74+
Developer docs index:
75+
76+
- [`docs-dev/index.md`](docs-dev/index.md)
77+
7478
## Linting
7579

7680
Lint all packages:

app/AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Documentation and comments
44

5+
- User-facing documentation belongs in the repo-root `docs/` directory (not `docs-dev/`).
6+
- Prefer user-facing documentation in notebook form so examples are executable. For Runme notebooks, prefer JSON notebook files when possible.
57
- Functions and classes should have comments explaining what they do
68
- Comments should capture important design decisions
79
- Comments should explain how state is being managed via contexts and other react features

app/src/components/Actions/Actions.test.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
33
import { render, screen, act, fireEvent } from "@testing-library/react";
44
import { clone, create } from "@bufbuild/protobuf";
55
import React from "react";
6+
import { APPKERNEL_RUNNER_NAME, APPKERNEL_RUNNER_LABEL } from "../../lib/runtime/appKernel";
67

78
import { parser_pb, RunmeMetadataKey } from "../../runme/client";
89
import type { CellData } from "../../lib/notebookData";
@@ -104,7 +105,22 @@ class StubCellData {
104105
}
105106

106107
getStreams() {
107-
return null;
108+
if (!this.getRunID()) {
109+
return null;
110+
}
111+
const sub = () => ({ unsubscribe: () => {} });
112+
return {
113+
stdout: { subscribe: sub },
114+
stderr: { subscribe: sub },
115+
pid: { subscribe: sub },
116+
exitCode: { subscribe: sub },
117+
mimeType: { subscribe: sub },
118+
errors: { subscribe: sub },
119+
sendExecuteRequest: () => {},
120+
setCallback: () => {},
121+
close: () => {},
122+
connect: () => ({ subscribe: () => ({ unsubscribe: () => {} }) }),
123+
} as any;
108124
}
109125
addBefore() {}
110126
addAfter() {}
@@ -215,4 +231,28 @@ describe("Action component", () => {
215231
expect(updatedCell.kind).toBe(parser_pb.CellKind.MARKUP);
216232
expect(updatedCell.languageId).toBe("markdown");
217233
});
234+
235+
it("includes AppKernel in the runner selector", () => {
236+
const cell = create(parser_pb.CellSchema, {
237+
refId: "cell-runner-select",
238+
kind: parser_pb.CellKind.CODE,
239+
languageId: "javascript",
240+
outputs: [],
241+
metadata: {},
242+
value: 'console.log("hi")',
243+
});
244+
const stub = new StubCellData(cell);
245+
246+
render(<Action cellData={stub as unknown as CellData} isFirst={false} />);
247+
248+
const runnerSelect = document.getElementById("runner-select-cell-runner-select") as
249+
| HTMLSelectElement
250+
| null;
251+
expect(runnerSelect).toBeTruthy();
252+
253+
const appKernelOption = Array.from(runnerSelect!.options).find(
254+
(option) => option.value === APPKERNEL_RUNNER_NAME,
255+
);
256+
expect(appKernelOption?.textContent).toBe(APPKERNEL_RUNNER_LABEL);
257+
});
218258
});

app/src/components/Actions/Actions.tsx

Lines changed: 38 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import { CellData } from "../../lib/notebookData";
2222
import { useNotebookContext } from "../../contexts/NotebookContext";
2323
import { useOutput } from "../../contexts/OutputContext";
2424
import CellConsole, { fontSettings } from "./CellConsole";
25-
import WebContainerConsole from "./WebContainer";
2625
import Editor from "./Editor";
2726
import MarkdownCell from "./MarkdownCell";
2827
import { IOPUB_INCOMPLETE_METADATA_KEY } from "../../lib/ipykernel";
@@ -36,6 +35,10 @@ import {
3635
import { useCurrentDoc } from "../../contexts/CurrentDocContext";
3736
import { useRunners } from "../../contexts/RunnersContext";
3837
import { DEFAULT_RUNNER_PLACEHOLDER } from "../../lib/runtime/runnersManager";
38+
import {
39+
APPKERNEL_RUNNER_LABEL,
40+
APPKERNEL_RUNNER_NAME,
41+
} from "../../lib/runtime/appKernel";
3942
import React from "react";
4043

4144
type TabPanelProps = React.HTMLAttributes<HTMLDivElement> & {
@@ -108,14 +111,14 @@ const LANGUAGE_OPTIONS = [
108111
{ label: "JS", value: "javascript" },
109112
] as const;
110113

111-
type SupportedLanguage = "bash" | "javascript" | "markdown" | "python";
114+
type SupportedLanguage =
115+
| "bash"
116+
| "javascript"
117+
| "markdown"
118+
| "python";
112119

113120
const outputTextDecoder = new TextDecoder();
114-
const OUTPUT_SKIP_MIMES = new Set<string>([
115-
MimeType.StatefulRunmeTerminal,
116-
MimeType.VSCodeNotebookStdOut,
117-
MimeType.VSCodeNotebookStdErr,
118-
]);
121+
const ALWAYS_SKIP_MIMES = new Set<string>([MimeType.StatefulRunmeTerminal]);
119122

120123
function normalizeLanguageId(
121124
kind: parser_pb.CellKind,
@@ -237,10 +240,24 @@ function ActionOutputItemView({
237240
}
238241

239242
function ActionOutputItems({ outputs }: { outputs: parser_pb.CellOutput[] }) {
243+
const hasTerminalOutput = outputs.some((output) =>
244+
(output.items ?? []).some((item) => item?.mime === MimeType.StatefulRunmeTerminal),
245+
);
246+
240247
const displayableItems = outputs.flatMap((output, outputIndex) =>
241248
(output.items ?? [])
242249
.map((item, itemIndex) => {
243-
if (!item || OUTPUT_SKIP_MIMES.has(item.mime || "")) {
250+
if (!item) {
251+
return null;
252+
}
253+
const mime = item.mime || "";
254+
if (ALWAYS_SKIP_MIMES.has(mime)) {
255+
return null;
256+
}
257+
if (
258+
hasTerminalOutput &&
259+
(mime === MimeType.VSCodeNotebookStdOut || mime === MimeType.VSCodeNotebookStdErr)
260+
) {
244261
return null;
245262
}
246263
if (!(item.data instanceof Uint8Array)) {
@@ -423,56 +440,11 @@ export function Action({ cellData, isFirst }: { cellData: CellData; isFirst: boo
423440
}
424441

425442
const renderedOutputs = useMemo(() => {
426-
const languageId = cell?.languageId?.toLowerCase();
427-
const isObservable = languageId === "observable" || languageId === "d3";
428-
const isJavaScript =
429-
languageId === "javascript" ||
430-
languageId === "typescript" ||
431-
languageId === "js" ||
432-
languageId === "ts";
433-
const isPython = languageId === "python" || languageId === "py";
434-
435-
if (!isPython && (isObservable || isJavaScript)) {
436-
return (
437-
<WebContainerConsole
438-
key={`webcontainer-${cell.refId}`}
439-
cell={cell}
440-
onPid={setPid}
441-
onExitCode={handleExitCode}
442-
/>
443-
);
444-
}
445-
446-
// For non-JS/Observable cells, prefer renderer-backed outputs. If none
447-
// exist (fresh cell), fall back to the terminal console so the user sees
448-
// an output area immediately.
449-
// const rendered = cellData.snapshot?.outputs
450-
// .flatMap((o) =>
451-
// (o.items ?? []).map((oi) => {
452-
// const renderer = getRenderer(oi.mime);
453-
// if (!renderer) {
454-
// return null;
455-
// }
456-
// const Component = renderer.component;
457-
// return (
458-
// <Component
459-
// key={`${oi.mime}-${cell.refId}`}
460-
// cell={cell}
461-
// cellData={cellData}
462-
// onPid={setPid}
463-
// onExitCode={handleExitCode}
464-
// {...renderer.props}
465-
// />
466-
// );
467-
// }),
468-
// )
469-
// .filter(Boolean);
470-
471-
// if (rendered && rendered.length > 0) {
472-
// return rendered;
473-
// }
474-
475-
if (!runID && (cell?.outputs?.length ?? 0) === 0) {
443+
const hasTerminalOutput = (cell?.outputs ?? []).some((output) =>
444+
(output.items ?? []).some((item) => item.mime === MimeType.StatefulRunmeTerminal),
445+
);
446+
const hasActiveStream = Boolean(cellData.getStreams());
447+
if (!hasTerminalOutput && !hasActiveStream) {
476448
return null;
477449
}
478450

@@ -696,7 +668,11 @@ export function Action({ cellData, isFirst }: { cellData: CellData; isFirst: boo
696668
onChange={(event) => {
697669
const nextName = event.target.value;
698670
const names = new Set(listRunners().map((r) => r.name));
699-
if (!names.has(nextName) && nextName !== DEFAULT_RUNNER_PLACEHOLDER) {
671+
if (
672+
!names.has(nextName) &&
673+
nextName !== DEFAULT_RUNNER_PLACEHOLDER &&
674+
nextName !== APPKERNEL_RUNNER_NAME
675+
) {
700676
return;
701677
}
702678
cellData.setRunner(nextName);
@@ -706,6 +682,9 @@ export function Action({ cellData, isFirst }: { cellData: CellData; isFirst: boo
706682
<option value="<default>">
707683
{defaultRunnerName ? `${defaultRunnerName}` : "default"}
708684
</option>
685+
<option value={APPKERNEL_RUNNER_NAME}>
686+
{APPKERNEL_RUNNER_LABEL}
687+
</option>
709688
{listRunners().map((runner) => (
710689
<option key={runner.name} value={runner.name}>
711690
{runner.name}
@@ -942,26 +921,6 @@ export default function Actions() {
942921
onPid: (pid: number | null) => void;
943922
onExitCode: (exitCode: number | null) => void;
944923
}) => {
945-
const languageId = cell.languageId?.toLowerCase();
946-
const isJavaScript =
947-
languageId === "javascript" ||
948-
languageId === "typescript" ||
949-
languageId === "js" ||
950-
languageId === "ts";
951-
const isObservable = languageId === "observable" || languageId === "d3";
952-
const isPython = languageId === "python" || languageId === "py";
953-
954-
if (!isPython && (isObservable || isJavaScript)) {
955-
return (
956-
<WebContainerConsole
957-
key={`webcontainer-${cell.refId}`}
958-
cell={cell}
959-
onPid={onPid}
960-
onExitCode={onExitCode}
961-
/>
962-
);
963-
}
964-
965924
return (
966925
// TODO(jlewi): Why do we pass cell which is parser_pb.Cell? Rather than CellData?
967926
<CellConsole

0 commit comments

Comments
 (0)