Add notebook preview support (#1725)

* Add notebook preview support

* Fix notebook reopen and browser routing

* Add editable runnable notebooks

* Restore notebook highlighting and defer source serialization

* Mark notebook editor beta

* Fix notebook Python syntax highlighting

* Improve notebook cell controls and dark editing

* Fix notebook focus and save shortcuts

* Fix restored notebook file loads

* Harden notebook perf and output rendering

* Fix notebook source toggle shutdown noise

* Harden notebook execution trust prompt
This commit is contained in:
Neil 2026-05-13 19:28:19 -07:00 committed by GitHub
parent 5e9f54808a
commit 75d5a65633
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 2314 additions and 54 deletions

View File

@ -71,6 +71,8 @@ let runtime: OrcaRuntimeService | null = null
let rateLimits: RateLimitService | null = null
let runtimeRpc: OrcaRuntimeRpcServer | null = null
let starNag: StarNagService | null = null
let watcherShutdownPromise: Promise<void> | null = null
let watcherShutdownDone = false
installUncaughtPipeErrorGuard()
// Why: propagate the Orca app version into `process.env` so PTY-env
@ -307,6 +309,24 @@ function openMainWindow(): BrowserWindow {
return window
}
function shutdownWatchersOnce(): Promise<void> {
if (watcherShutdownDone) {
return Promise.resolve()
}
if (!watcherShutdownPromise) {
// Why: @parcel/watcher tears down native async work during unsubscribe.
// Electron must wait for that cleanup before Node's environment exits.
watcherShutdownPromise = closeAllWatchers()
.catch((error) => {
console.error('[filesystem-watcher] shutdown failed:', error)
})
.then(() => {
watcherShutdownDone = true
})
}
return watcherShutdownPromise
}
// Why: Pi-style persistent spinner — cursor-agent re-emits its own
// "Cursor Agent" OSC title on every internal redraw, so a single synthesized
// "⠋ Cursor Agent" frame gets silently overwritten in the renderer within
@ -665,7 +685,7 @@ app.on('will-quit', (e) => {
// holding ports and leaving stale session state on disk.
runtime?.getAgentBrowserBridge()?.destroyAllSessions()
killAllPty()
void closeAllWatchers()
const watcherShutdown = shutdownWatchersOnce()
store?.flush()
// Why: disconnectDaemon writes final checkpoints via async getSnapshot RPCs.
@ -708,7 +728,7 @@ app.on('will-quit', (e) => {
// inside `shutdownTelemetry()` are caught by the client itself — we
// catch again here defensively so a flush failure cannot cancel the
// quit chain.
Promise.allSettled([disconnectDaemon(), rpcStopAndClear])
Promise.allSettled([disconnectDaemon(), rpcStopAndClear, watcherShutdown])
.then(() => shutdownTelemetry())
.catch(() => {
/* swallow — telemetry must never prevent app.quit() */

View File

@ -0,0 +1,86 @@
import { EventEmitter } from 'events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChildProcessWithoutNullStreams } from 'child_process'
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>()
const { spawnMock, handleMock, resolveAuthorizedPathMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
handleMock: vi.fn((channel: string, handler: (event: unknown, args: unknown) => unknown) => {
handlers.set(channel, handler)
}),
resolveAuthorizedPathMock: vi.fn()
}))
vi.mock('child_process', () => ({
spawn: spawnMock
}))
vi.mock('electron', () => ({
ipcMain: {
handle: handleMock
}
}))
vi.mock('./filesystem-auth', () => ({
resolveAuthorizedPath: resolveAuthorizedPathMock
}))
import { registerNotebookHandlers } from './notebook'
function createMockProcess(pid = 1234): ChildProcessWithoutNullStreams {
const proc = new EventEmitter() as ChildProcessWithoutNullStreams
Object.assign(proc, {
pid,
stdout: new EventEmitter(),
stderr: new EventEmitter(),
kill: vi.fn()
})
return proc
}
describe('notebook IPC', () => {
let processKillSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
handlers.clear()
vi.useFakeTimers()
vi.clearAllMocks()
resolveAuthorizedPathMock.mockResolvedValue('/repo/notebook.ipynb')
processKillSpy = vi.spyOn(process, 'kill').mockImplementation(() => true)
})
afterEach(() => {
processKillSpy.mockRestore()
vi.useRealTimers()
})
it('kills the Python process group when a cell times out', async () => {
const proc = createMockProcess(4321)
spawnMock.mockReturnValue(proc)
registerNotebookHandlers({} as never)
const handler = handlers.get('notebook:runPythonCell')
expect(handler).toBeDefined()
const resultPromise = handler?.(null, {
filePath: '/repo/notebook.ipynb',
code: 'while True: pass'
}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(60_000)
await expect(resultPromise).resolves.toMatchObject({
exitCode: null,
error: 'Python cell timed out.'
})
if (process.platform !== 'win32') {
expect(spawnMock).toHaveBeenCalledWith(
'python3',
expect.any(Array),
expect.objectContaining({ detached: true })
)
expect(processKillSpy).toHaveBeenCalledWith(-4321, 'SIGTERM')
await vi.advanceTimersByTimeAsync(2000)
expect(processKillSpy).toHaveBeenCalledWith(-4321, 'SIGKILL')
}
})
})

220
src/main/ipc/notebook.ts Normal file
View File

@ -0,0 +1,220 @@
import { spawn } from 'child_process'
import type { ChildProcessWithoutNullStreams } from 'child_process'
import { dirname } from 'path'
import { ipcMain } from 'electron'
import type { Store } from '../persistence'
import { resolveAuthorizedPath } from './filesystem-auth'
export type NotebookRunResult = {
stdout: string
stderr: string
exitCode: number | null
error?: string
}
const PYTHON_RUN_TIMEOUT_MS = 60_000
const MAX_CAPTURE_BYTES = 2 * 1024 * 1024
type BoundedCapture = {
text: string
bytes: number
truncated: boolean
}
function pythonCandidates(): { command: string; argsPrefix: string[] }[] {
const configured = process.env.ORCA_NOTEBOOK_PYTHON?.trim()
const candidates: { command: string; argsPrefix: string[] }[] = []
if (configured) {
candidates.push({ command: configured, argsPrefix: [] })
}
if (process.platform === 'win32') {
candidates.push({ command: 'py', argsPrefix: ['-3'] })
}
candidates.push({ command: 'python3', argsPrefix: [] }, { command: 'python', argsPrefix: [] })
return candidates
}
function appendBounded(capture: BoundedCapture, chunk: Buffer): void {
if (capture.truncated) {
return
}
const remainingBytes = MAX_CAPTURE_BYTES - capture.bytes
if (remainingBytes <= 0) {
capture.truncated = true
return
}
if (chunk.byteLength <= remainingBytes) {
capture.text += chunk.toString('utf8')
capture.bytes += chunk.byteLength
return
}
capture.text += `${chunk.subarray(0, remainingBytes).toString('utf8')}\n[output truncated]\n`
capture.bytes = MAX_CAPTURE_BYTES
capture.truncated = true
}
function terminateNotebookProcessTree(
child: ChildProcessWithoutNullStreams
): ReturnType<typeof setTimeout> | null {
if (!child.pid) {
child.kill()
return null
}
if (process.platform === 'win32') {
try {
// Why: a timed-out cell can spawn descendants. taskkill /T is the
// Windows equivalent of terminating the whole process group.
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
})
killer.on('error', () => child.kill())
killer.unref()
} catch {
child.kill()
}
return null
}
try {
process.kill(-child.pid, 'SIGTERM')
} catch {
child.kill()
}
const forceKillTimer = setTimeout(() => {
try {
process.kill(-child.pid!, 'SIGKILL')
} catch {
/* process group already exited */
}
}, 2000)
forceKillTimer.unref?.()
return forceKillTimer
}
function buildPythonExecutionCode(code: string, preamble: string): string {
const payload = Buffer.from(JSON.stringify({ code, preamble }), 'utf8').toString('base64')
return [
'import base64, contextlib, io, json, sys, traceback',
`payload = json.loads(base64.b64decode(${JSON.stringify(payload)}).decode("utf-8"))`,
'namespace = {"__name__": "__main__"}',
'try:',
' with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):',
' exec(payload["preamble"], namespace)',
' exec(payload["code"], namespace)',
'except Exception:',
' traceback.print_exc()',
' sys.exit(1)'
].join('\n')
}
async function runPythonCandidate(
candidate: { command: string; argsPrefix: string[] },
code: string,
preamble: string,
cwd: string
): Promise<NotebookRunResult> {
return new Promise((resolve) => {
const stdout: BoundedCapture = { text: '', bytes: 0, truncated: false }
const stderr: BoundedCapture = { text: '', bytes: 0, truncated: false }
let settled = false
let forceKillTimer: ReturnType<typeof setTimeout> | null = null
const child = spawn(
candidate.command,
[...candidate.argsPrefix, '-c', buildPythonExecutionCode(code, preamble)],
{
cwd,
detached: process.platform !== 'win32',
windowsHide: true,
env: process.env
}
)
const timeout = setTimeout(() => {
if (settled) {
return
}
settled = true
forceKillTimer = terminateNotebookProcessTree(child)
resolve({
stdout: stdout.text,
stderr: stderr.text,
exitCode: null,
error: 'Python cell timed out.'
})
}, PYTHON_RUN_TIMEOUT_MS)
child.stdout.on('data', (chunk: Buffer) => {
appendBounded(stdout, chunk)
})
child.stderr.on('data', (chunk: Buffer) => {
appendBounded(stderr, chunk)
})
child.on('error', (error) => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (forceKillTimer) {
clearTimeout(forceKillTimer)
}
resolve({ stdout: stdout.text, stderr: stderr.text, exitCode: null, error: error.message })
})
child.on('close', (exitCode) => {
if (forceKillTimer) {
clearTimeout(forceKillTimer)
}
if (settled) {
return
}
settled = true
clearTimeout(timeout)
resolve({ stdout: stdout.text, stderr: stderr.text, exitCode })
})
})
}
async function runPythonCell(
code: string,
preamble: string,
cwd: string
): Promise<NotebookRunResult> {
if (!code.trim() && !preamble.trim()) {
return { stdout: '', stderr: '', exitCode: 0 }
}
let lastError = 'Python was not found.'
for (const candidate of pythonCandidates()) {
const result = await runPythonCandidate(candidate, code, preamble, cwd)
if (!result.error?.includes('ENOENT')) {
return result
}
lastError = result.error
}
return { stdout: '', stderr: '', exitCode: null, error: lastError }
}
export function registerNotebookHandlers(store: Store): void {
ipcMain.handle(
'notebook:runPythonCell',
async (
_event,
args: { filePath: string; code: string; preamble?: string; connectionId?: string | null }
): Promise<NotebookRunResult> => {
if (args.connectionId) {
return {
stdout: '',
stderr: '',
exitCode: null,
error: 'Notebook execution is currently supported for local files only.'
}
}
const filePath = await resolveAuthorizedPath(args.filePath, store)
// Why: execute relative to the notebook file so local imports and data
// paths behave the same way users expect from a notebook opened on disk.
return runPythonCell(args.code, args.preamble ?? '', dirname(filePath))
}
)
}

View File

@ -9,6 +9,7 @@ const {
registerFeedbackHandlersMock,
registerStatsHandlersMock,
registerMemoryHandlersMock,
registerNotebookHandlersMock,
registerNotificationHandlersMock,
registerDeveloperPermissionHandlersMock,
registerComputerUsePermissionHandlersMock,
@ -44,6 +45,7 @@ const {
registerFeedbackHandlersMock: vi.fn(),
registerStatsHandlersMock: vi.fn(),
registerMemoryHandlersMock: vi.fn(),
registerNotebookHandlersMock: vi.fn(),
registerNotificationHandlersMock: vi.fn(),
registerDeveloperPermissionHandlersMock: vi.fn(),
registerComputerUsePermissionHandlersMock: vi.fn(),
@ -112,6 +114,10 @@ vi.mock('./memory', () => ({
registerMemoryHandlers: registerMemoryHandlersMock
}))
vi.mock('./notebook', () => ({
registerNotebookHandlers: registerNotebookHandlersMock
}))
vi.mock('./notifications', () => ({
registerNotificationHandlers: registerNotificationHandlersMock
}))
@ -211,6 +217,7 @@ describe('registerCoreHandlers', () => {
registerFeedbackHandlersMock.mockReset()
registerStatsHandlersMock.mockReset()
registerMemoryHandlersMock.mockReset()
registerNotebookHandlersMock.mockReset()
registerNotificationHandlersMock.mockReset()
registerDeveloperPermissionHandlersMock.mockReset()
registerComputerUsePermissionHandlersMock.mockReset()
@ -271,6 +278,7 @@ describe('registerCoreHandlers', () => {
expect(registerFeedbackHandlersMock).toHaveBeenCalled()
expect(registerStatsHandlersMock).toHaveBeenCalledWith(stats)
expect(registerMemoryHandlersMock).toHaveBeenCalledWith(store)
expect(registerNotebookHandlersMock).toHaveBeenCalledWith(store)
expect(registerNotificationHandlersMock).toHaveBeenCalledWith(store, runtime)
expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled()
expect(registerComputerUsePermissionHandlersMock).toHaveBeenCalled()

View File

@ -17,6 +17,7 @@ import { registerMemoryHandlers } from './memory'
import { registerRateLimitHandlers } from './rate-limits'
import { registerRuntimeHandlers } from './runtime'
import { registerNotificationHandlers } from './notifications'
import { registerNotebookHandlers } from './notebook'
import { registerOnboardingHandlers } from './onboarding'
import { registerDeveloperPermissionHandlers } from './developer-permissions'
import { registerComputerUsePermissionHandlers } from './computer-use-permissions'
@ -85,6 +86,7 @@ export function registerCoreHandlers(
registerStatsHandlers(stats)
registerMemoryHandlers(store)
registerNotificationHandlers(store, runtime)
registerNotebookHandlers(store)
registerOnboardingHandlers(store)
registerDeveloperPermissionHandlers()
registerComputerUsePermissionHandlers()

View File

@ -891,6 +891,14 @@ export type PreloadApi = {
onStatus: (callback: (status: UpdateStatus) => void) => () => void
onClearDismissal: (callback: () => void) => () => void
}
notebook: {
runPythonCell: (args: {
filePath: string
code: string
preamble?: string
connectionId?: string | null
}) => Promise<{ stdout: string; stderr: string; exitCode: number | null; error?: string }>
}
stats: StatsApi
memory: MemoryApi
claudeUsage: ClaudeUsageApi

View File

@ -1499,6 +1499,16 @@ const api = {
}
},
notebook: {
runPythonCell: (args: {
filePath: string
code: string
preamble?: string
connectionId?: string | null
}): Promise<{ stdout: string; stderr: string; exitCode: number | null; error?: string }> =>
ipcRenderer.invoke('notebook:runPythonCell', args)
},
fs: {
readDir: (args: {
dirPath: string

View File

@ -2,6 +2,9 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
import { getConnectionId } from '@/lib/connection-context'
import { detectLanguage } from '@/lib/language-detect'
import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links'
import {
ArrowLeft,
ArrowRight,
@ -131,6 +134,29 @@ function isChromiumErrorPage(url: string): boolean {
return url.startsWith('chrome-error://')
}
function fileUrlToAbsolutePath(url: string): string | null {
try {
const parsed = new URL(url)
if (parsed.protocol !== 'file:') {
return null
}
const hostPrefix =
parsed.hostname && parsed.hostname !== 'localhost' ? `//${parsed.hostname}` : ''
let absolutePath = `${hostPrefix}${decodeURIComponent(parsed.pathname)}`
if (/^\/[A-Za-z]:\//.test(absolutePath)) {
absolutePath = absolutePath.slice(1)
}
return absolutePath
} catch {
return null
}
}
function getNotebookPathFromBrowserUrl(url: string): string | null {
const filePath = fileUrlToAbsolutePath(url)
return filePath?.toLowerCase().endsWith('.ipynb') ? filePath : null
}
function getLoadErrorMetadata(loadError: BrowserLoadError | null): {
displayUrl: string
host: string | null
@ -1501,29 +1527,77 @@ function BrowserPagePane({
const navigateToUrl = useCallback(
(url: string): void => {
const browserModelUrl = redactKagiSessionToken(url)
setAddressBarValue(toDisplayUrl(browserModelUrl))
onSetUrlRef.current(browserTab.id, browserModelUrl)
onUpdatePageStateRef.current(browserTab.id, {
loading: true,
loadError: null,
title: getBrowserDisplayTitle(browserModelUrl, browserModelUrl)
})
setResourceNotice(null)
const navigateBrowserUrl = (targetUrl: string): void => {
const browserModelUrl = redactKagiSessionToken(targetUrl)
setAddressBarValue(toDisplayUrl(browserModelUrl))
onSetUrlRef.current(browserTab.id, browserModelUrl)
onUpdatePageStateRef.current(browserTab.id, {
loading: true,
loadError: null,
title: getBrowserDisplayTitle(browserModelUrl, browserModelUrl)
})
setResourceNotice(null)
const webview = webviewRef.current
if (!webview) {
const webview = webviewRef.current
if (!webview) {
return
}
trackNextLoadingEventRef.current = targetUrl !== ORCA_BROWSER_BLANK_URL
lastKnownWebviewUrlRef.current =
normalizeBrowserNavigationUrl(browserModelUrl) ?? browserModelUrl
webview.src = targetUrl
if (targetUrl !== ORCA_BROWSER_BLANK_URL) {
focusWebviewNow()
}
}
const notebookPath = getNotebookPathFromBrowserUrl(url)
if (notebookPath) {
void (async () => {
const store = useAppStore.getState()
const connectionId = getConnectionId(worktreeId)
if (connectionId !== null) {
navigateBrowserUrl(url)
return
}
try {
await window.api.fs.authorizeExternalPath({ targetPath: notebookPath })
const stat = await window.api.fs.stat({ filePath: notebookPath })
if (stat.isDirectory) {
navigateBrowserUrl(url)
return
}
const activeWorktree = store.allWorktrees().find((w) => w.id === worktreeId)
let relativePath = notebookPath
if (activeWorktree?.path && isPathInsideWorktree(notebookPath, activeWorktree.path)) {
relativePath =
toWorktreeRelativePath(notebookPath, activeWorktree.path) ?? notebookPath
}
// Why: file:// notebooks in the browser are otherwise rendered as raw JSON by Chromium.
store.setActiveTabType('editor')
store.openFile(
{
filePath: notebookPath,
relativePath,
worktreeId,
language: detectLanguage(notebookPath),
mode: 'edit'
},
{ preview: false, targetGroupId: store.ensureWorktreeRootGroup(worktreeId) }
)
} catch {
navigateBrowserUrl(url)
}
})()
return
}
trackNextLoadingEventRef.current = url !== ORCA_BROWSER_BLANK_URL
lastKnownWebviewUrlRef.current =
normalizeBrowserNavigationUrl(browserModelUrl) ?? browserModelUrl
webview.src = url
if (url !== ORCA_BROWSER_BLANK_URL) {
focusWebviewNow()
}
navigateBrowserUrl(url)
},
[browserTab.id, focusWebviewNow]
[browserTab.id, focusWebviewNow, worktreeId]
)
const submitAddressBar = (): void => {

View File

@ -0,0 +1,56 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import { EditorContent } from './EditorContent'
function createOpenFile(overrides: Partial<OpenFile> = {}): OpenFile {
return {
id: '/repo/notebook.ipynb',
filePath: '/repo/notebook.ipynb',
relativePath: 'notebook.ipynb',
worktreeId: 'repo::/repo',
language: 'notebook',
isDirty: false,
mode: 'edit',
...overrides
}
}
describe('EditorContent', () => {
it('surfaces file load errors before notebook content is parsed', () => {
const activeFile = createOpenFile()
const html = renderToStaticMarkup(
<EditorContent
activeFile={activeFile}
viewStateScopeId={activeFile.id}
fileContents={{
[activeFile.id]: {
content: '',
isBinary: false,
loadError: 'Access denied: path resolves outside allowed directories.'
}
}}
diffContents={{}}
editBuffers={{}}
worktreeEntries={[]}
resolvedLanguage="notebook"
isMarkdown={false}
isMermaid={false}
isCsv={false}
isNotebook
mdViewMode="rich"
isChangesMode={false}
sideBySide={false}
pendingEditorReveal={null}
handleContentChange={vi.fn()}
handleDirtyStateHint={vi.fn()}
handleSave={vi.fn()}
reloadFileContent={vi.fn()}
/>
)
expect(html).toContain('Unable to load file')
expect(html).toContain('Access denied')
expect(html).not.toContain('Unable to render notebook')
})
})

View File

@ -5,8 +5,10 @@ to reason about than scattering the switch across per-mode wrappers. Individual
renderers (MonacoEditor, DiffViewer, ChangesModeView, MarkdownPreview, etc.)
already live in their own modules. */
import React, { lazy } from 'react'
import { AlertCircle, RefreshCw } from 'lucide-react'
import { detectLanguage } from '@/lib/language-detect'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import { ChangesModeView } from './ChangesModeView'
import { ConflictBanner, ConflictPlaceholderView, ConflictReviewPanel } from './ConflictComponents'
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
@ -27,6 +29,7 @@ const ImageViewer = lazy(() => import('./ImageViewer'))
const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
const MermaidViewer = lazy(() => import('./MermaidViewer'))
const CsvViewer = lazy(() => import('./CsvViewer'))
const IpynbViewer = lazy(() => import('./IpynbViewer'))
const richMarkdownSizeEncoder = new TextEncoder()
// Why: encodeInto() with a pre-allocated buffer avoids creating a new
@ -38,6 +41,31 @@ type FileContent = {
isBinary: boolean
isImage?: boolean
mimeType?: string
loadError?: string
}
function FileLoadErrorView({
message,
onRetry
}: {
message: string
onRetry: () => void
}): React.JSX.Element {
return (
<div className="flex h-full items-center justify-center bg-editor-surface p-6 text-sm text-muted-foreground">
<div className="flex max-w-xl items-start gap-3 rounded-md border border-border bg-background p-4">
<AlertCircle className="mt-0.5 size-4 flex-shrink-0 text-destructive" />
<div className="min-w-0">
<div className="font-medium text-foreground">Unable to load file</div>
<div className="mt-1 break-words">{message}</div>
<Button type="button" variant="outline" size="sm" className="mt-3" onClick={onRetry}>
<RefreshCw className="size-3.5" />
Retry
</Button>
</div>
</div>
</div>
)
}
export function EditorContent({
@ -51,13 +79,15 @@ export function EditorContent({
isMarkdown,
isMermaid,
isCsv,
isNotebook,
mdViewMode,
isChangesMode,
sideBySide,
pendingEditorReveal,
handleContentChange,
handleDirtyStateHint,
handleSave
handleSave,
reloadFileContent
}: {
activeFile: OpenFile
viewStateScopeId: string
@ -69,6 +99,7 @@ export function EditorContent({
isMarkdown: boolean
isMermaid: boolean
isCsv: boolean
isNotebook: boolean
mdViewMode: MarkdownViewMode
isChangesMode: boolean
sideBySide: boolean
@ -81,6 +112,7 @@ export function EditorContent({
handleContentChange: (content: string) => void
handleDirtyStateHint: (dirty: boolean) => void
handleSave: (content: string) => Promise<void>
reloadFileContent: (file: OpenFile) => void
}): React.JSX.Element {
const editorViewStateKey =
viewStateScopeId === activeFile.id
@ -92,6 +124,7 @@ export function EditorContent({
viewStateScopeId === activeFile.id
? `${activeFile.id}:preview`
: `${activeFile.id}::${viewStateScopeId}:preview`
const monacoLanguage = resolvedLanguage === 'notebook' ? 'json' : resolvedLanguage
const openConflictFile = useAppStore((s) => s.openConflictFile)
const openConflictReview = useAppStore((s) => s.openConflictReview)
@ -118,7 +151,7 @@ export function EditorContent({
viewStateKey={editorViewStateKey}
relativePath={activeFile.relativePath}
content={editBuffers[activeFile.id] ?? fc.content}
language={resolvedLanguage}
language={monacoLanguage}
onContentChange={handleContentChange}
onSave={isMarkdown ? md.mdSave : handleSave}
revealLine={
@ -302,6 +335,11 @@ export function EditorContent({
</div>
)
}
if (fc.loadError) {
return (
<FileLoadErrorView message={fc.loadError} onRetry={() => reloadFileContent(activeFile)} />
)
}
if (fc.isBinary) {
return (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
@ -337,6 +375,11 @@ export function EditorContent({
</div>
)
}
if (fc.loadError) {
return (
<FileLoadErrorView message={fc.loadError} onRetry={() => reloadFileContent(activeFile)} />
)
}
if (fc.isBinary) {
if (fc.isImage) {
return (
@ -356,7 +399,7 @@ export function EditorContent({
dc={diffContents[activeFile.id]}
modifiedContent={editBuffers[activeFile.id] ?? fc.content}
activeConflictEntry={activeConflictEntry}
resolvedLanguage={resolvedLanguage}
resolvedLanguage={monacoLanguage}
sideBySide={sideBySide}
viewStateScopeId={viewStateScopeId}
diffViewStateKey={diffViewStateKey}
@ -383,6 +426,18 @@ export function EditorContent({
content={editBuffers[activeFile.id] ?? fc.content}
filePath={activeFile.filePath}
/>
) : isNotebook && mdViewMode === 'rich' ? (
<IpynbViewer
key={activeFile.id}
content={editBuffers[activeFile.id] ?? fc.content}
fileId={activeFile.id}
filePath={activeFile.filePath}
worktreeId={activeFile.worktreeId}
scrollCacheKey={`${editorViewStateKey}:notebook`}
onContentChange={handleContentChange}
onDirtyStateHint={handleDirtyStateHint}
onSave={handleSave}
/>
) : (
renderMonacoEditor(fc)
)}
@ -455,7 +510,7 @@ export function EditorContent({
modelKey={diffViewStateKey}
originalContent={dc.originalContent}
modifiedContent={modifiedDiffContent}
language={resolvedLanguage}
language={monacoLanguage}
filePath={activeFile.filePath}
relativePath={activeFile.relativePath}
sideBySide={sideBySide}

View File

@ -23,7 +23,10 @@ import {
} from '@/components/ui/dropdown-menu'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '../tab-bar/SortableTab'
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
import EditorViewToggle, { CSV_VIEW_MODE_METADATA } from './EditorViewToggle'
import EditorViewToggle, {
CSV_VIEW_MODE_METADATA,
NOTEBOOK_VIEW_MODE_METADATA
} from './EditorViewToggle'
import { EditorContent } from './EditorContent'
import { scrollTopCache, cursorPositionCache, diffViewStateCache } from '@/lib/scroll-cache'
import type { GitDiffResult } from '../../../../shared/types'
@ -65,9 +68,34 @@ type FileContent = {
isBinary: boolean
isImage?: boolean
mimeType?: string
loadError?: string
}
type DiffContent = GitDiffResult
const FILE_LOAD_RETRY_DELAYS_MS = [250, 1000, 2500]
function shouldRetryFileLoadError(message: string): boolean {
const lower = message.toLowerCase()
return (
!lower.includes('access denied') &&
!lower.includes('enoent') &&
!lower.includes('no such file') &&
!lower.includes('file too large')
)
}
function isAbsolutePathLike(value: string): boolean {
return value.startsWith('/') || value.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(value)
}
function canUseChangesModeForFile(file: OpenFile): boolean {
return (
file.mode === 'edit' &&
!file.isUntitled &&
file.relativePath !== file.filePath &&
!isAbsolutePathLike(file.relativePath)
)
}
// Why: split-pane layouts mount one EditorPanel per pane, and each panel
// attaches its own listener to `ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT`.
@ -176,8 +204,10 @@ function EditorPanelInner({
const isChangesMode =
!!activeFile &&
activeFile.mode === 'edit' &&
canUseChangesModeForFile(activeFile) &&
editorViewMode[activeFile.id] === 'changes' &&
!fileContents[activeFile.id]?.isBinary
!fileContents[activeFile.id]?.isBinary &&
!fileContents[activeFile.id]?.loadError
const [copiedPathToast, setCopiedPathToast] = useState<{ fileId: string; token: number } | null>(
null
)
@ -190,6 +220,7 @@ function EditorPanelInner({
const [pathMenuOpen, setPathMenuOpen] = useState(false)
const [pathMenuPoint, setPathMenuPoint] = useState({ x: 0, y: 0 })
const panelRef = useRef<HTMLDivElement>(null)
const fileLoadRetryAttemptsRef = useRef<Record<string, number>>({})
const deleteCacheEntriesByPrefix = useCallback(<T,>(cache: Map<string, T>, prefix: string) => {
for (const key of cache.keys()) {
@ -347,6 +378,18 @@ function EditorPanelInner({
async (filePath: string, id: string, worktreeId?: string): Promise<void> => {
try {
const connectionId = getConnectionId(worktreeId ?? null) ?? undefined
const restoredOpenFile = openFilesRef.current.find((file) => file.id === id)
if (
!connectionId &&
restoredOpenFile?.filePath === filePath &&
restoredOpenFile.relativePath === filePath
) {
// Why: external files selected through OS/browser/drop flows are
// authorized in the main process, but that grant is in-memory. On
// session restore, re-authorize only tabs that were stored with an
// absolute relativePath because they came from outside a worktree.
await window.api.fs.authorizeExternalPath({ targetPath: filePath })
}
const key = inFlightReadKey(connectionId, filePath)
// Why: share the IPC round-trip across split-pane EditorPanels viewing
// the same file. The first caller starts the read and registers the
@ -368,22 +411,43 @@ function EditorPanelInner({
})
}
const result = await pending
delete fileLoadRetryAttemptsRef.current[id]
setFileContents((prev) => ({ ...prev, [id]: result }))
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setFileContents((prev) => ({
...prev,
[id]: { content: `Error loading file: ${err}`, isBinary: false }
[id]: { content: '', isBinary: false, loadError: message }
}))
}
},
[]
)
const reloadFileContent = useCallback(
(file: OpenFile): void => {
delete fileLoadRetryAttemptsRef.current[file.id]
setFileContents((prev) => {
if (!prev[file.id]) {
return prev
}
const next = { ...prev }
delete next[file.id]
return next
})
void loadFileContent(file.filePath, file.id, file.worktreeId)
},
[loadFileContent]
)
const loadDiffContent = useCallback(async (file: OpenFile | null): Promise<void> => {
if (!file) {
return
}
try {
if (file.mode === 'edit' && !canUseChangesModeForFile(file)) {
return
}
// Extract worktree path from absolute file path and relative path
const worktreePath = file.filePath.slice(
0,
@ -459,6 +523,49 @@ function EditorPanelInner({
}
}, [])
const activeFileLoadRetryId = activeFile?.id ?? null
const activeFileLoadError = activeFileLoadRetryId
? fileContents[activeFileLoadRetryId]?.loadError
: undefined
useEffect(() => {
if (
!activeFileLoadRetryId ||
!activeFileLoadError ||
!shouldRetryFileLoadError(activeFileLoadError)
) {
return
}
const retryCount = fileLoadRetryAttemptsRef.current[activeFileLoadRetryId] ?? 0
if (retryCount >= FILE_LOAD_RETRY_DELAYS_MS.length) {
return
}
const delayMs = FILE_LOAD_RETRY_DELAYS_MS[retryCount] ?? FILE_LOAD_RETRY_DELAYS_MS[0]
fileLoadRetryAttemptsRef.current[activeFileLoadRetryId] = retryCount + 1
// Why: restored tabs can race app/worktree startup and get a transient
// read failure. Retry briefly, but keep permanent filesystem errors quiet.
const timeoutId = window.setTimeout(() => {
const currentFile = openFilesRef.current.find((file) => file.id === activeFileLoadRetryId)
if (
!currentFile ||
(currentFile.mode !== 'edit' && currentFile.mode !== 'markdown-preview')
) {
return
}
setFileContents((prev) => {
if (prev[currentFile.id]?.loadError !== activeFileLoadError) {
return prev
}
const next = { ...prev }
delete next[currentFile.id]
return next
})
void loadFileContent(currentFile.filePath, currentFile.id, currentFile.worktreeId)
}, delayMs)
return () => window.clearTimeout(timeoutId)
}, [activeFileLoadRetryId, activeFileLoadError, loadFileContent])
// Why: refetch the HEAD-side blob for Changes mode when the worktree's git
// status array identity changes. A commit, pull, or rebase updates the
// status poll result, which is the cheapest signal we have that HEAD moved
@ -673,6 +780,11 @@ function EditorPanelInner({
useEffect(() => {
const openIds = new Set(openFiles.map((f) => f.id))
for (const fileId of Object.keys(fileLoadRetryAttemptsRef.current)) {
if (!openIds.has(fileId)) {
delete fileLoadRetryAttemptsRef.current[fileId]
}
}
setFileContents((prev) => {
const next: Record<string, FileContent> = {}
for (const [k, v] of Object.entries(prev)) {
@ -947,6 +1059,7 @@ function EditorPanelInner({
const isMarkdown = resolvedLanguage === 'markdown'
const isMermaid = resolvedLanguage === 'mermaid'
const isCsv = resolvedLanguage === 'csv' || resolvedLanguage === 'tsv'
const isNotebook = resolvedLanguage === 'notebook'
// Why: "Open Preview to the Side" only applies to edit-mode tabs whose
// language has a registered renderer. Diff tabs already have their own
// toggle set and there is no clear semantic for previewing a diff.
@ -998,12 +1111,14 @@ function EditorPanelInner({
})
const isBinaryEditSurface =
activeFile.mode === 'edit' && fileContents[activeFile.id]?.isBinary === true
const canUseChangesMode = canUseChangesModeForFile(activeFile)
// Why: edit-mode binary/image tabs already have their own dedicated renderers
// and cannot enter the Changes diff surface. Hide that segment rather than
// offering a toggle state the renderer will immediately ignore.
const availableEditorToggleModes = isBinaryEditSurface
? editorToggleModes.filter((mode) => mode !== 'changes')
: editorToggleModes
// and external files have no repo-relative path for git diff. Hide Changes
// rather than offering a segment the renderer will immediately ignore.
const availableEditorToggleModes =
isBinaryEditSurface || !canUseChangesMode
? editorToggleModes.filter((mode) => mode !== 'changes')
: editorToggleModes
// Why: a toggle with a single option is just a decorative pill with nothing
// to switch to. Binary plain-code tabs end up here after 'changes' is
// stripped — on main they had no header toggle at all, so requiring >1 mode
@ -1188,7 +1303,13 @@ function EditorPanelInner({
value={effectiveToggleValue}
modes={availableEditorToggleModes}
onChange={handleEditorToggleChange}
metadataOverride={isCsv ? CSV_VIEW_MODE_METADATA : undefined}
metadataOverride={
isCsv
? CSV_VIEW_MODE_METADATA
: isNotebook
? NOTEBOOK_VIEW_MODE_METADATA
: undefined
}
/>
)}
{hasViewModeToggle && isMarkdown && (
@ -1236,6 +1357,7 @@ function EditorPanelInner({
isMarkdown={isMarkdown}
isMermaid={isMermaid}
isCsv={isCsv}
isNotebook={isNotebook}
mdViewMode={mdViewMode}
isChangesMode={isChangesMode}
sideBySide={sideBySide}
@ -1243,6 +1365,7 @@ function EditorPanelInner({
handleContentChange={handleContentChange}
handleDirtyStateHint={handleDirtyStateHint}
handleSave={handleSave}
reloadFileContent={reloadFileContent}
/>
</Suspense>
<UntitledFileRenameDialog

View File

@ -4,6 +4,7 @@ import {
Eye,
FileText,
GitCompareArrows,
NotebookText,
Pencil,
Table as TableIcon,
type LucideIcon
@ -60,6 +61,13 @@ export const CSV_VIEW_MODE_METADATA: Partial<Record<MarkdownViewMode, ViewModeMe
}
}
export const NOTEBOOK_VIEW_MODE_METADATA: Partial<Record<MarkdownViewMode, ViewModeMetadata>> = {
rich: {
label: 'Notebook',
icon: NotebookText
}
}
type EditorViewToggleProps = {
value: EditorToggleValue
modes: readonly EditorToggleValue[]

View File

@ -0,0 +1,864 @@
/* eslint-disable max-lines -- Why: notebook editing, output rendering, and cell
controls share one parsed document/update path for this first notebook editor
slice; splitting before the model stabilizes would make save/run mutations
harder to audit. */
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import Editor, { type OnMount } from '@monaco-editor/react'
import DOMPurify from 'dompurify'
import Markdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import {
AlertCircle,
ArrowDownToLine,
ArrowUpToLine,
Braces,
FileCode2,
Loader2,
MoveDown,
MoveUp,
Play,
Save,
Trash2
} from 'lucide-react'
import { monaco } from '@/lib/monaco-setup'
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
import { getConnectionId } from '@/lib/connection-context'
import { resolveDocumentTheme } from '@/lib/document-theme'
import { useAppStore } from '@/store'
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { registerPendingEditorFlush } from './editor-pending-flush'
import MonacoCodeExcerpt from './MonacoCodeExcerpt'
import {
deleteIpynbCell,
insertIpynbCell,
moveIpynbCell,
parseIpynb,
updateIpynbCellKind,
updateIpynbCellOutputs,
updateIpynbCellSources,
type IpynbCell,
type IpynbCellKind,
type IpynbOutputItem
} from './ipynb-parse'
type IpynbViewerProps = {
content: string
fileId: string
filePath: string
worktreeId: string
scrollCacheKey: string
onContentChange: (content: string) => void
onDirtyStateHint: (dirty: boolean) => void
onSave: (content: string) => Promise<void>
}
const NOTEBOOK_SOURCE_COMMIT_DELAY_MS = 400
function valueToText(value: unknown): string {
if (Array.isArray(value)) {
return value.map((item) => String(item ?? '')).join('')
}
if (typeof value === 'string') {
return value
}
if (value === undefined || value === null) {
return ''
}
return typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)
}
function dataUriForImage(item: IpynbOutputItem): string | null {
const value = valueToText(item.value).replace(/\s/g, '')
if (!value) {
return null
}
if (item.mime === 'image/svg+xml') {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(valueToText(item.value))}`
}
return `data:${item.mime};base64,${value}`
}
function NotebookCellHeader({
cell,
index,
running,
canMoveUp,
canMoveDown,
onRun,
onKindChange,
onInsertAbove,
onInsertBelow,
onMoveUp,
onMoveDown,
onDelete
}: {
cell: IpynbCell
index: number
running: boolean
canMoveUp: boolean
canMoveDown: boolean
onRun: () => void
onKindChange: (kind: IpynbCellKind) => void
onInsertAbove: (kind: IpynbCellKind) => void
onInsertBelow: (kind: IpynbCellKind) => void
onMoveUp: () => void
onMoveDown: () => void
onDelete: () => void
}): React.JSX.Element {
const Icon = cell.kind === 'code' ? Play : cell.kind === 'markdown' ? FileCode2 : Braces
const executionLabel = cell.kind === 'code' ? `In [${cell.executionCount ?? ' '}]:` : cell.kind
return (
<div className="flex items-center gap-2 border-b border-border/50 bg-muted/20 px-3 py-1.5 text-xs text-muted-foreground">
<Icon className="size-3.5" />
<span className="font-mono">{executionLabel}</span>
<select
value={cell.kind}
onChange={(event) => onKindChange(event.target.value as IpynbCellKind)}
className="h-7 rounded-md border border-input bg-background px-2 text-xs text-foreground"
>
<option value="code">Code</option>
<option value="markdown">Markdown</option>
<option value="raw">Raw</option>
</select>
{cell.kind === 'code' ? (
<NotebookHeaderButton label="Run cell" disabled={running} onClick={onRun}>
{running ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
</NotebookHeaderButton>
) : null}
<NotebookHeaderButton label="Move cell up" disabled={!canMoveUp} onClick={onMoveUp}>
<MoveUp className="size-3.5" />
</NotebookHeaderButton>
<NotebookHeaderButton label="Move cell down" disabled={!canMoveDown} onClick={onMoveDown}>
<MoveDown className="size-3.5" />
</NotebookHeaderButton>
<NotebookHeaderButton label="Insert code cell above" onClick={() => onInsertAbove('code')}>
<ArrowUpToLine className="size-3.5" />
</NotebookHeaderButton>
<NotebookHeaderButton label="Insert code cell below" onClick={() => onInsertBelow('code')}>
<ArrowDownToLine className="size-3.5" />
</NotebookHeaderButton>
<NotebookHeaderButton
label="Insert markdown cell above"
onClick={() => onInsertAbove('markdown')}
>
<span className="relative size-4">
<FileCode2 className="absolute left-0.5 top-0.5 size-3" />
<MoveUp className="absolute -right-0.5 -top-0.5 size-2.5" />
</span>
</NotebookHeaderButton>
<NotebookHeaderButton
label="Insert markdown cell below"
onClick={() => onInsertBelow('markdown')}
>
<span className="relative size-4">
<FileCode2 className="absolute left-0.5 top-0.5 size-3" />
<MoveDown className="absolute -bottom-0.5 -right-0.5 size-2.5" />
</span>
</NotebookHeaderButton>
<NotebookHeaderButton label="Delete cell" onClick={onDelete}>
<Trash2 className="size-3.5" />
</NotebookHeaderButton>
<span className="ml-auto font-mono">#{index + 1}</span>
</div>
)
}
function NotebookHeaderButton({
label,
disabled = false,
shortcutKeys,
onClick,
children
}: {
label: string
disabled?: boolean
shortcutKeys?: string[]
onClick: () => void
children: React.ReactNode
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label={label}
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>
<span className="flex items-center gap-2">
<span>{label}</span>
{shortcutKeys ? <ShortcutKeyCombo keys={shortcutKeys} /> : null}
</span>
</TooltipContent>
</Tooltip>
)
}
function MarkdownCell({ source }: { source: string }): React.JSX.Element {
return (
<div className="markdown-preview-body px-4 py-3 text-sm">
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, rehypeSanitize]}>
{source || '\u00a0'}
</Markdown>
</div>
)
}
function CodeCell({
cell,
source,
active,
onActivate,
onDeactivate,
onChange,
onSaveRequest
}: {
cell: IpynbCell
source: string
active: boolean
onActivate: () => void
onDeactivate: () => void
onChange: (source: string) => void
onSaveRequest: () => Promise<void>
}): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
const onDeactivateRef = useRef(onDeactivate)
const onSaveRequestRef = useRef(onSaveRequest)
const fontSize = computeEditorFontSize(settings?.terminalFontSize ?? 13, editorFontZoomLevel)
const lineCount = Math.max(3, source.split('\n').length + 1)
const editorHeight = Math.min(520, Math.max(96, lineCount * (fontSize + 8)))
const isDark = resolveDocumentTheme(settings?.theme ?? 'system')
const lines = useMemo(
() => (source.length > 0 ? source.replace(/\n$/, '').split('\n') : ['']),
[source]
)
const handleMount: OnMount = useCallback((editorInstance, monacoInstance) => {
editorInstance.focus()
editorInstance.addCommand(monacoInstance.KeyMod.CtrlCmd | monacoInstance.KeyCode.KeyS, () => {
void onSaveRequestRef.current()
})
editorInstance.addCommand(monacoInstance.KeyCode.Escape, () => {
onDeactivateRef.current()
})
editorInstance.onDidBlurEditorWidget(() => {
onDeactivateRef.current()
})
}, [])
useEffect(() => {
onDeactivateRef.current = onDeactivate
onSaveRequestRef.current = onSaveRequest
}, [onDeactivate, onSaveRequest])
useEffect(() => {
monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs')
}, [isDark])
if (!active) {
return (
<div
role="button"
tabIndex={0}
className="block w-full cursor-text bg-editor-surface text-left"
onClick={onActivate}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onActivate()
}
}}
>
<MonacoCodeExcerpt
lines={lines}
firstLineNumber={1}
highlightedStartLine={-1}
highlightedEndLine={-1}
language={cell.language}
/>
</div>
)
}
return (
<div className="bg-editor-surface focus-within:ring-1 focus-within:ring-ring">
<Editor
height={editorHeight}
defaultLanguage={cell.language}
language={cell.language}
theme={isDark ? 'vs-dark' : 'vs'}
value={source}
onMount={handleMount}
onChange={(value) => onChange(value ?? '')}
options={{
automaticLayout: true,
fontFamily: settings?.terminalFontFamily || 'monospace',
fontSize,
glyphMargin: false,
lineNumbersMinChars: 3,
minimap: { enabled: false },
overviewRulerLanes: 0,
renderLineHighlight: 'none',
scrollBeyondLastLine: false,
wordWrap: 'off'
}}
/>
</div>
)
}
const MemoizedCodeCell = React.memo(CodeCell)
function getCellKey(cell: IpynbCell, index: number): string {
return cell.id ?? `${index}:${cell.kind}`
}
function hasOwnDraft(drafts: Record<string, string>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(drafts, key)
}
function EditableTextCell({
source,
onChange
}: {
source: string
onChange: (source: string) => void
}): React.JSX.Element {
return (
<textarea
value={source}
onChange={(event) => onChange(event.target.value)}
className="block min-h-24 w-full resize-y border-0 bg-background px-4 py-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
/>
)
}
function PreformattedOutput({
text,
error = false
}: {
text: string
error?: boolean
}): React.JSX.Element {
return (
<pre
className={cn(
'max-h-[420px] overflow-auto whitespace-pre-wrap px-3 py-2 font-mono text-xs leading-5',
error ? 'text-destructive' : 'text-foreground'
)}
>
{text}
</pre>
)
}
function OutputItem({ item }: { item: IpynbOutputItem }): React.JSX.Element | null {
if (item.mime === 'text/html') {
const html = DOMPurify.sanitize(valueToText(item.value), {
USE_PROFILES: { html: true, svg: true, svgFilters: true }
})
return (
<iframe
title="Notebook HTML output"
sandbox=""
referrerPolicy="no-referrer"
loading="lazy"
className="block h-80 w-full border-0 bg-background"
srcDoc={html}
/>
)
}
if (item.mime.startsWith('image/')) {
const uri = dataUriForImage(item)
if (!uri) {
return null
}
return (
<div className="flex max-w-full overflow-auto p-3">
<img src={uri} alt={item.mime} className="max-h-[520px] max-w-full object-contain" />
</div>
)
}
if (item.mime === 'application/json' || item.mime.endsWith('+json')) {
const text =
typeof item.value === 'string' ? item.value : JSON.stringify(item.value ?? null, null, 2)
return <PreformattedOutput text={text} />
}
if (item.mime === 'text/markdown') {
return <MarkdownCell source={valueToText(item.value)} />
}
if (item.mime.startsWith('text/') || item.mime === 'application/javascript') {
return <PreformattedOutput text={valueToText(item.value)} />
}
return null
}
function CellOutputs({ cell }: { cell: IpynbCell }): React.JSX.Element | null {
if (cell.outputs.length === 0) {
return null
}
return (
<div className="border-t border-border/50 bg-background">
{cell.outputs.map((output, index) => {
if (output.kind === 'stream') {
return <PreformattedOutput key={index} text={output.text} />
}
if (output.kind === 'error') {
return (
<div key={index} className="border-l-2 border-destructive">
<PreformattedOutput
error
text={[output.name, output.message, output.traceback].filter(Boolean).join('\n')}
/>
</div>
)
}
const renderedItems = output.items
.map((item, itemIndex) => <OutputItem key={`${item.mime}-${itemIndex}`} item={item} />)
.filter(Boolean)
if (renderedItems.length === 0) {
return null
}
return (
<div key={index} className="border-b border-border/40 last:border-b-0">
{renderedItems}
</div>
)
})}
</div>
)
}
export default function IpynbViewer({
content,
fileId,
filePath,
worktreeId,
scrollCacheKey,
onContentChange,
onDirtyStateHint,
onSave
}: IpynbViewerProps): React.JSX.Element {
const rootRef = useRef<HTMLDivElement>(null)
const settings = useAppStore((s) => s.settings)
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
const [runningCellIndex, setRunningCellIndex] = useState<number | null>(null)
const [runError, setRunError] = useState<string | null>(null)
const [editingCellKey, setEditingCellKey] = useState<string | null>(null)
const [executionTrustedForFile, setExecutionTrustedForFile] = useState(false)
const [pendingRunCellIndex, setPendingRunCellIndex] = useState<number | null>(null)
const [sourceDrafts, setSourceDrafts] = useState<Record<string, string>>({})
const sourceDraftsRef = useRef(sourceDrafts)
const contentRef = useRef(content)
const notebookRef = useRef<ReturnType<typeof parseIpynb> | null>(null)
const onContentChangeRef = useRef(onContentChange)
const onDirtyStateHintRef = useRef(onDirtyStateHint)
const sourceCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fontSize = computeEditorFontSize(13, editorFontZoomLevel)
const parsed = useMemo(() => {
try {
return { notebook: parseIpynb(content), error: null as string | null }
} catch (error) {
return {
notebook: null,
error: error instanceof Error ? error.message : 'Invalid notebook'
}
}
}, [content])
contentRef.current = content
notebookRef.current = parsed.notebook
onContentChangeRef.current = onContentChange
onDirtyStateHintRef.current = onDirtyStateHint
const materializeSourceDrafts = useCallback((): string => {
const notebook = notebookRef.current
const drafts = sourceDraftsRef.current
if (!notebook || Object.keys(drafts).length === 0) {
return contentRef.current
}
const updates = notebook.cells
.map((cell, index) => {
const key = getCellKey(cell, index)
return hasOwnDraft(drafts, key) ? { index, source: drafts[key] ?? '' } : null
})
.filter((update): update is { index: number; source: string } => update !== null)
return updateIpynbCellSources(contentRef.current, updates)
}, [])
const flushSourceDrafts = useCallback((): string => {
if (sourceCommitTimerRef.current !== null) {
clearTimeout(sourceCommitTimerRef.current)
sourceCommitTimerRef.current = null
}
const nextContent = materializeSourceDrafts()
if (nextContent !== contentRef.current) {
contentRef.current = nextContent
onContentChangeRef.current(nextContent)
}
return nextContent
}, [materializeSourceDrafts])
const queueSourceDraftCommit = useCallback((): void => {
if (sourceCommitTimerRef.current !== null) {
clearTimeout(sourceCommitTimerRef.current)
}
sourceCommitTimerRef.current = setTimeout(() => {
void flushSourceDrafts()
}, NOTEBOOK_SOURCE_COMMIT_DELAY_MS)
}, [flushSourceDrafts])
useEffect(() => {
return registerPendingEditorFlush(fileId, flushSourceDrafts)
}, [fileId, flushSourceDrafts])
useEffect(() => {
setExecutionTrustedForFile(false)
setPendingRunCellIndex(null)
}, [filePath])
useEffect(() => {
return () => {
void flushSourceDrafts()
}
}, [flushSourceDrafts])
useEffect(() => {
if (!parsed.notebook || Object.keys(sourceDraftsRef.current).length === 0) {
return
}
const nextDrafts = { ...sourceDraftsRef.current }
let changed = false
parsed.notebook.cells.forEach((cell, index) => {
const key = getCellKey(cell, index)
if (hasOwnDraft(nextDrafts, key) && nextDrafts[key] === cell.source) {
delete nextDrafts[key]
changed = true
}
})
if (changed) {
sourceDraftsRef.current = nextDrafts
setSourceDrafts(nextDrafts)
}
}, [parsed.notebook])
useLayoutEffect(() => {
const container = rootRef.current
if (!container) {
return
}
let throttleTimer: ReturnType<typeof setTimeout> | null = null
const onScroll = (): void => {
if (throttleTimer !== null) {
clearTimeout(throttleTimer)
}
throttleTimer = setTimeout(() => {
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
throttleTimer = null
}, 150)
}
container.addEventListener('scroll', onScroll, { passive: true })
return () => {
if (container.scrollHeight > container.clientHeight || container.scrollTop > 0) {
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
}
if (throttleTimer !== null) {
clearTimeout(throttleTimer)
}
container.removeEventListener('scroll', onScroll)
}
}, [scrollCacheKey])
useLayoutEffect(() => {
const container = rootRef.current
const targetScrollTop = scrollTopCache.get(scrollCacheKey)
if (!container || targetScrollTop === undefined) {
return
}
container.scrollTop = targetScrollTop
}, [scrollCacheKey, content])
const saveNotebook = useCallback(async (): Promise<void> => {
const latestContent = flushSourceDrafts()
await onSave(latestContent)
}, [flushSourceDrafts, onSave])
const handleNotebookKeyDownCapture = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>): void => {
const isMac = navigator.userAgent.includes('Mac')
const hasSaveModifier = isMac ? event.metaKey : event.ctrlKey
if (!hasSaveModifier || event.shiftKey || event.repeat || event.key.toLowerCase() !== 's') {
return
}
event.preventDefault()
event.stopPropagation()
void saveNotebook()
},
[saveNotebook]
)
const handleNotebookPointerDownCapture = useCallback(
(event: React.PointerEvent<HTMLDivElement>): void => {
if (editingCellKey === null) {
return
}
const target = event.target instanceof Element ? event.target : null
if (target?.closest('.monaco-editor')) {
return
}
setEditingCellKey(null)
},
[editingCellKey]
)
if (parsed.error || !parsed.notebook) {
return (
<div className="flex h-full items-center justify-center bg-editor-surface p-6 text-sm text-muted-foreground">
<div className="flex max-w-md items-start gap-3 rounded-md border border-border bg-background p-4">
<AlertCircle className="mt-0.5 size-4 text-destructive" />
<div>
<div className="font-medium text-foreground">Unable to render notebook</div>
<div className="mt-1">{parsed.error}</div>
</div>
</div>
</div>
)
}
const { notebook } = parsed
const shortcutModifier = navigator.userAgent.includes('Mac') ? '⌘' : 'Ctrl'
const applyContent = (nextContent: string): void => {
contentRef.current = nextContent
onContentChange(nextContent)
}
const updateCellSource = (index: number, source: string): void => {
const cell = notebook.cells[index]
if (!cell) {
return
}
const key = getCellKey(cell, index)
const nextDrafts = { ...sourceDraftsRef.current, [key]: source }
sourceDraftsRef.current = nextDrafts
setSourceDrafts(nextDrafts)
onDirtyStateHintRef.current(true)
queueSourceDraftCommit()
}
const applyStructuralContentChange = (
getNextContent: (latestContent: string) => string
): void => {
const latestContent = flushSourceDrafts()
// Why: Monaco can still have a render frame queued for the active cell.
// Exit edit mode first, then reorder/replace cells on the next frame so
// structural notebook actions do not dispose an editor mid-render.
setEditingCellKey(null)
requestAnimationFrame(() => {
applyContent(getNextContent(latestContent))
})
}
const updateCellKind = (index: number, kind: IpynbCellKind): void => {
applyStructuralContentChange((latestContent) =>
updateIpynbCellKind(latestContent, index, kind, notebook.language)
)
}
const insertCell = (index: number, kind: IpynbCellKind): void => {
applyStructuralContentChange((latestContent) =>
insertIpynbCell(latestContent, index, kind, notebook.language)
)
}
const moveCell = (index: number, direction: -1 | 1): void => {
applyStructuralContentChange((latestContent) => moveIpynbCell(latestContent, index, direction))
}
const deleteCell = (index: number): void => {
applyStructuralContentChange((latestContent) => deleteIpynbCell(latestContent, index))
}
const runCell = async (
index: number,
options: { skipTrustPrompt?: boolean } = {}
): Promise<void> => {
const latestContent = flushSourceDrafts()
const latestNotebook = parseIpynb(latestContent)
const cell = latestNotebook.cells[index]
if (!cell || cell.kind !== 'code' || runningCellIndex !== null) {
return
}
if (!executionTrustedForFile && !options.skipTrustPrompt) {
setPendingRunCellIndex(index)
return
}
setRunError(null)
setRunningCellIndex(index)
try {
await onSave(latestContent)
const result = await window.api.notebook.runPythonCell({
filePath,
code: cell.source,
preamble: latestNotebook.cells
.slice(0, index)
.filter((previousCell) => previousCell.kind === 'code')
.map((previousCell) => previousCell.source)
.join('\n\n'),
connectionId: getConnectionId(worktreeId) ?? undefined
})
applyContent(updateIpynbCellOutputs(latestContent, index, result))
} catch (error) {
setRunError(error instanceof Error ? error.message : String(error))
} finally {
setRunningCellIndex(null)
}
}
const cancelPendingRun = (): void => setPendingRunCellIndex(null)
const confirmPendingRun = (): void => {
const index = pendingRunCellIndex
setPendingRunCellIndex(null)
setExecutionTrustedForFile(true)
if (index !== null) {
void runCell(index, { skipTrustPrompt: true })
}
}
return (
<div
ref={rootRef}
className="h-full min-h-0 overflow-auto bg-editor-surface scrollbar-editor"
style={{ fontSize, fontFamily: settings?.terminalFontFamily || undefined }}
onKeyDownCapture={handleNotebookKeyDownCapture}
onPointerDownCapture={handleNotebookPointerDownCapture}
>
<div className="sticky top-0 z-10 flex items-center gap-3 border-b border-border/60 bg-background/95 px-4 py-2 text-xs text-muted-foreground backdrop-blur">
<span className="font-medium text-foreground">{filePath.split(/[/\\]/).pop()}</span>
<span>{notebook.cells.length} cells</span>
<span>{notebook.language}</span>
{notebook.kernelName ? <span>{notebook.kernelName}</span> : null}
{runError ? <span className="text-destructive">{runError}</span> : null}
<div className="ml-auto flex items-center gap-2">
<NotebookHeaderButton
label="Save notebook"
shortcutKeys={[shortcutModifier, 'S']}
onClick={() => void saveNotebook()}
>
<Save className="size-3.5" />
</NotebookHeaderButton>
<span className="rounded-sm border border-border bg-muted px-1.5 py-0.5 font-medium text-muted-foreground">
BETA
</span>
<span className="font-mono">nbformat {notebook.nbformat}</span>
</div>
</div>
<div className="mx-auto flex max-w-[980px] flex-col gap-3 px-5 py-5">
{notebook.cells.length === 0 ? (
<div className="flex items-center justify-center rounded-md border border-border bg-background p-8 text-sm text-muted-foreground">
Empty notebook
</div>
) : (
notebook.cells.map((cell, index) => {
const cellKey = getCellKey(cell, index)
const source = hasOwnDraft(sourceDrafts, cellKey)
? (sourceDrafts[cellKey] ?? '')
: cell.source
return (
<section
key={cellKey}
className="overflow-hidden rounded-md border border-border bg-background"
>
<NotebookCellHeader
cell={cell}
index={index}
running={runningCellIndex === index}
canMoveUp={index > 0}
canMoveDown={index < notebook.cells.length - 1}
onRun={() => void runCell(index)}
onKindChange={(kind) => updateCellKind(index, kind)}
onInsertAbove={(kind) => insertCell(index, kind)}
onInsertBelow={(kind) => insertCell(index + 1, kind)}
onMoveUp={() => moveCell(index, -1)}
onMoveDown={() => moveCell(index, 1)}
onDelete={() => deleteCell(index)}
/>
{cell.kind === 'markdown' ? (
<div className="grid gap-0 lg:grid-cols-2">
<EditableTextCell
source={source}
onChange={(nextSource) => updateCellSource(index, nextSource)}
/>
<div className="border-t border-border/50 lg:border-l lg:border-t-0">
<MarkdownCell source={source} />
</div>
</div>
) : cell.kind === 'code' ? (
<MemoizedCodeCell
cell={cell}
source={source}
active={editingCellKey === cellKey}
onActivate={() => setEditingCellKey(cellKey)}
onDeactivate={() =>
setEditingCellKey((current) => (current === cellKey ? null : current))
}
onChange={(nextSource) => updateCellSource(index, nextSource)}
onSaveRequest={saveNotebook}
/>
) : (
<EditableTextCell
source={source}
onChange={(nextSource) => updateCellSource(index, nextSource)}
/>
)}
<CellOutputs cell={cell} />
</section>
)
})
)}
</div>
<Dialog
open={pendingRunCellIndex !== null}
onOpenChange={(open) => {
if (!open) {
cancelPendingRun()
}
}}
>
<DialogContent className="max-w-md sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">Run Notebook Code?</DialogTitle>
<DialogDescription className="text-xs">
Notebook cells execute local Python on this machine from the notebook folder. Only run
cells from files you trust.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" size="sm" onClick={cancelPendingRun}>
Cancel
</Button>
<Button type="button" size="sm" autoFocus onClick={confirmPendingRun}>
Run cell
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -1,9 +1,35 @@
import React, { useEffect, useMemo, useState } from 'react'
import { monaco } from '@/lib/monaco-setup'
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
import { resolveDocumentTheme } from '@/lib/document-theme'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
let pythonLanguageRegistrationPromise: Promise<void> | null = null
async function ensureColorizationLanguage(language: string): Promise<void> {
if (language !== 'python') {
return
}
pythonLanguageRegistrationPromise ??=
import('monaco-editor/esm/vs/basic-languages/python/python.js').then(
({ conf, language: pythonTokens }) => {
// Why: notebook excerpts colorize without mounting Monaco editors. Load
// Python tokens only on demand so non-notebook users do not pay at startup.
if (!monaco.languages.getLanguages().some((item) => item.id === 'python')) {
monaco.languages.register({
id: 'python',
extensions: ['.py', '.pyw'],
aliases: ['Python', 'py']
})
}
monaco.languages.setLanguageConfiguration('python', conf)
monaco.languages.setMonarchTokensProvider('python', pythonTokens)
}
)
await pythonLanguageRegistrationPromise
}
type MonacoCodeExcerptProps = {
lines: string[]
firstLineNumber: number
@ -26,9 +52,7 @@ export default function MonacoCodeExcerpt({
editorFontZoomLevel
)
const fontFamily = settings?.terminalFontFamily || 'monospace'
const isDark =
settings?.theme === 'dark' ||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const isDark = resolveDocumentTheme(settings?.theme ?? 'system')
const code = useMemo(() => lines.join('\n'), [lines])
const [htmlLines, setHtmlLines] = useState<string[]>(() => lines.map(() => ''))
@ -42,12 +66,24 @@ export default function MonacoCodeExcerpt({
return
}
// Why: colorizeModelLine gives the comment excerpt Monaco's token colors
// without mounting a full editor instance for every visible PR comment.
const model = monaco.editor.createModel(code, language)
const nextLines = lines.map((_, index) => monaco.editor.colorizeModelLine(model, index + 1, 2))
model.dispose()
setHtmlLines(nextLines)
let cancelled = false
// Why: notebook languages like Python are loaded lazily by Monaco. The
// async colorizer waits for that tokenizer; colorizeModelLine can render
// only default-token spans if called before the contribution finishes.
void ensureColorizationLanguage(language)
.catch(() => undefined)
.then(() => monaco.editor.colorize(code, language, { tabSize: 2 }))
.then((html) => {
if (cancelled) {
return
}
const nextLines = html.split('<br/>').slice(0, lines.length)
setHtmlLines(nextLines)
})
return () => {
cancelled = true
}
}, [code, language, lines])
return (

View File

@ -0,0 +1,197 @@
import { describe, expect, it } from 'vitest'
import {
concatIpynbMultilineString,
deleteIpynbCell,
insertIpynbCell,
moveIpynbCell,
parseIpynb,
translateKernelLanguageToMonaco,
updateIpynbCellKind,
updateIpynbCellOutputs,
updateIpynbCellSource,
updateIpynbCellSources
} from './ipynb-parse'
describe('ipynb parsing', () => {
it('normalizes multiline strings like VS Code notebooks', () => {
expect(concatIpynbMultilineString(['a', 'b\n', 'c\r\n'])).toBe('a\nb\nc\n')
})
it('maps Jupyter kernel language names to Monaco language ids', () => {
expect(translateKernelLanguageToMonaco('c#')).toBe('csharp')
expect(translateKernelLanguageToMonaco('c++11')).toBe('cpp')
expect(translateKernelLanguageToMonaco('python')).toBe('python')
})
it('parses cells, metadata, and common output types', () => {
const notebook = parseIpynb(
JSON.stringify({
nbformat: 4,
nbformat_minor: 5,
metadata: {
kernelspec: { display_name: 'Python 3', language: 'python', name: 'python3' },
language_info: { name: 'python' }
},
cells: [
{
id: 'intro',
cell_type: 'markdown',
source: ['# Hello', ' notebook'],
metadata: {}
},
{
id: 'code',
cell_type: 'code',
execution_count: 7,
source: ['print("hi")\n'],
metadata: { vscode: { languageId: 'python' } },
outputs: [
{ output_type: 'stream', name: 'stdout', text: ['hi\n'] },
{
output_type: 'execute_result',
execution_count: 7,
data: { 'text/plain': '7', 'text/html': '<b>7</b>' },
metadata: {}
}
]
}
]
})
)
expect(notebook.nbformat).toBe('4.5')
expect(notebook.kernelName).toBe('Python 3')
expect(notebook.cells).toHaveLength(2)
expect(notebook.cells[0]).toMatchObject({
id: 'intro',
kind: 'markdown',
source: '# Hello\n notebook'
})
expect(notebook.cells[1]).toMatchObject({
id: 'code',
kind: 'code',
executionCount: 7,
language: 'python'
})
expect(notebook.cells[1]?.outputs[0]).toMatchObject({ kind: 'stream', text: 'hi\n' })
expect(notebook.cells[1]?.outputs[1]).toMatchObject({
kind: 'display',
items: [{ mime: 'text/html' }, { mime: 'text/plain' }]
})
})
it('rejects invalid notebook roots', () => {
expect(() => parseIpynb('[]')).toThrow('Notebook root must be a JSON object')
expect(() => parseIpynb('{}')).toThrow('Notebook is missing a cells array')
})
it('serializes cell source edits while preserving notebook metadata', () => {
const content = JSON.stringify({
nbformat: 4,
nbformat_minor: 5,
metadata: { custom: true },
cells: [{ cell_type: 'code', metadata: {}, execution_count: null, outputs: [], source: [] }]
})
const updated = JSON.parse(updateIpynbCellSource(content, 0, 'print("hi")\nprint("bye")'))
expect(updated.metadata).toEqual({ custom: true })
expect(updated.cells[0].source).toEqual(['print("hi")\n', 'print("bye")'])
})
it('serializes batched source edits with one notebook mutation', () => {
const content = JSON.stringify({
nbformat: 4,
nbformat_minor: 5,
metadata: { custom: true },
cells: [
{ cell_type: 'code', metadata: {}, execution_count: null, outputs: [], source: [] },
{ cell_type: 'code', metadata: {}, execution_count: null, outputs: [], source: [] }
]
})
const updated = JSON.parse(
updateIpynbCellSources(content, [
{ index: 0, source: 'x = 41' },
{ index: 1, source: 'print(x + 1)' }
])
)
expect(updated.metadata).toEqual({ custom: true })
expect(updated.cells[0].source).toEqual(['x = 41'])
expect(updated.cells[1].source).toEqual(['print(x + 1)'])
})
it('inserts, deletes, and changes cell kinds', () => {
const content = JSON.stringify({
nbformat: 4,
nbformat_minor: 5,
metadata: {},
cells: [{ cell_type: 'markdown', metadata: {}, source: ['# Title'] }]
})
const inserted = JSON.parse(insertIpynbCell(content, 1, 'code', 'python'))
expect(inserted.cells).toHaveLength(2)
expect(inserted.cells[1]).toMatchObject({
cell_type: 'code',
execution_count: null,
outputs: [],
metadata: { vscode: { languageId: 'python' } }
})
const changed = JSON.parse(updateIpynbCellKind(JSON.stringify(inserted), 0, 'code', 'python'))
expect(changed.cells[0]).toMatchObject({ cell_type: 'code', outputs: [] })
const deleted = JSON.parse(deleteIpynbCell(JSON.stringify(changed), 1))
expect(deleted.cells).toHaveLength(1)
})
it('moves cells up and down while preserving cell data', () => {
const content = JSON.stringify({
nbformat: 4,
nbformat_minor: 5,
metadata: {},
cells: [
{
id: 'a',
cell_type: 'code',
metadata: {},
execution_count: 1,
outputs: [],
source: ['a']
},
{ id: 'b', cell_type: 'markdown', metadata: { keep: true }, source: ['b'] },
{ id: 'c', cell_type: 'code', metadata: {}, execution_count: 2, outputs: [], source: ['c'] }
]
})
const movedDown = JSON.parse(moveIpynbCell(content, 0, 1))
expect(movedDown.cells.map((cell: { id: string }) => cell.id)).toEqual(['b', 'a', 'c'])
expect(movedDown.cells[0].metadata).toEqual({ keep: true })
const movedUp = JSON.parse(moveIpynbCell(JSON.stringify(movedDown), 2, -1))
expect(movedUp.cells.map((cell: { id: string }) => cell.id)).toEqual(['b', 'c', 'a'])
expect(moveIpynbCell(content, 0, -1)).toBe(content)
expect(moveIpynbCell(content, 2, 1)).toBe(content)
})
it('writes Python run results as notebook outputs', () => {
const content = JSON.stringify({
nbformat: 4,
nbformat_minor: 5,
metadata: {},
cells: [{ cell_type: 'code', metadata: {}, execution_count: null, outputs: [], source: [] }]
})
const updated = JSON.parse(
updateIpynbCellOutputs(content, 0, {
stdout: 'hello\n',
stderr: '',
exitCode: 0
})
)
expect(updated.cells[0].execution_count).toBe(1)
expect(updated.cells[0].outputs).toEqual([
{ output_type: 'stream', name: 'stdout', text: ['hello\n'] }
])
})
})

View File

@ -0,0 +1,360 @@
/* eslint-disable max-lines -- Why: keeping notebook parse and mutation helpers
in one module makes nbformat preservation easier to audit while the notebook
editor model is still small. */
export type IpynbCellKind = 'code' | 'markdown' | 'raw'
export type IpynbOutput =
| { kind: 'stream'; name: string; text: string }
| { kind: 'error'; name: string; message: string; traceback: string }
| { kind: 'display'; outputType: string; executionCount: number | null; items: IpynbOutputItem[] }
export type IpynbOutputItem = {
mime: string
value: unknown
}
export type IpynbCell = {
id: string | null
kind: IpynbCellKind
language: string
source: string
executionCount: number | null
outputs: IpynbOutput[]
}
export type ParsedIpynb = {
language: string
kernelName: string | null
nbformat: string
cells: IpynbCell[]
}
export type IpynbRunResult = {
stdout: string
stderr: string
exitCode: number | null
error?: string
}
const DISPLAY_MIME_ORDER = [
'text/html',
'image/png',
'image/jpeg',
'image/jpg',
'image/svg+xml',
'application/json',
'text/markdown',
'text/plain'
] as const
const JUPYTER_LANGUAGE_TO_MONACO_LANGUAGE: Record<string, string> = {
'c#': 'csharp',
'f#': 'fsharp',
'q#': 'qsharp',
'c++11': 'cpp',
'c++12': 'cpp',
'c++14': 'cpp',
'c++': 'cpp'
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function concatIpynbMultilineString(value: unknown): string {
if (Array.isArray(value)) {
let result = ''
for (let i = 0; i < value.length; i += 1) {
const item = String(value[i] ?? '')
result += i < value.length - 1 && !item.endsWith('\n') ? `${item}\n` : item
}
return result.replace(/\r\n/g, '\n')
}
return String(value ?? '').replace(/\r\n/g, '\n')
}
export function translateKernelLanguageToMonaco(language: string | null | undefined): string {
const normalized = (language ?? 'python').toLowerCase()
if (normalized.length === 2 && normalized.endsWith('#')) {
return `${normalized.slice(0, 1)}sharp`
}
return JUPYTER_LANGUAGE_TO_MONACO_LANGUAGE[normalized] ?? normalized
}
function getPreferredLanguage(content: Record<string, unknown>): string {
const metadata = isRecord(content.metadata) ? content.metadata : {}
const languageInfo = isRecord(metadata.language_info) ? metadata.language_info : {}
const kernelSpec = isRecord(metadata.kernelspec) ? metadata.kernelspec : {}
const language =
typeof languageInfo.name === 'string'
? languageInfo.name
: typeof kernelSpec.language === 'string'
? kernelSpec.language
: 'python'
return translateKernelLanguageToMonaco(language)
}
function getKernelName(content: Record<string, unknown>): string | null {
const metadata = isRecord(content.metadata) ? content.metadata : {}
const kernelSpec = isRecord(metadata.kernelspec) ? metadata.kernelspec : {}
return typeof kernelSpec.display_name === 'string'
? kernelSpec.display_name
: typeof kernelSpec.name === 'string'
? kernelSpec.name
: null
}
function getCellLanguage(cell: Record<string, unknown>, fallback: string): string {
const metadata = isRecord(cell.metadata) ? cell.metadata : {}
const vscode = isRecord(metadata.vscode) ? metadata.vscode : {}
return typeof vscode.languageId === 'string' ? vscode.languageId : fallback
}
function parseDisplayItems(data: unknown): IpynbOutputItem[] {
if (!isRecord(data)) {
return []
}
return Object.entries(data)
.map(([mime, value]) => ({ mime, value }))
.sort((a, b) => {
const aIndex = DISPLAY_MIME_ORDER.indexOf(a.mime as (typeof DISPLAY_MIME_ORDER)[number])
const bIndex = DISPLAY_MIME_ORDER.indexOf(b.mime as (typeof DISPLAY_MIME_ORDER)[number])
return (aIndex === -1 ? 100 : aIndex) - (bIndex === -1 ? 100 : bIndex)
})
}
function parseOutput(rawOutput: unknown): IpynbOutput | null {
if (!isRecord(rawOutput) || typeof rawOutput.output_type !== 'string') {
return null
}
if (rawOutput.output_type === 'stream') {
return {
kind: 'stream',
name: typeof rawOutput.name === 'string' ? rawOutput.name : 'stdout',
text: concatIpynbMultilineString(rawOutput.text)
}
}
if (rawOutput.output_type === 'error') {
return {
kind: 'error',
name: typeof rawOutput.ename === 'string' ? rawOutput.ename : '',
message: typeof rawOutput.evalue === 'string' ? rawOutput.evalue : '',
traceback: concatIpynbMultilineString(rawOutput.traceback)
}
}
return {
kind: 'display',
outputType: rawOutput.output_type,
executionCount:
typeof rawOutput.execution_count === 'number' ? rawOutput.execution_count : null,
items: parseDisplayItems(rawOutput.data)
}
}
function parseCell(rawCell: unknown, fallbackLanguage: string): IpynbCell | null {
if (!isRecord(rawCell)) {
return null
}
const kind =
rawCell.cell_type === 'markdown' || rawCell.cell_type === 'raw' || rawCell.cell_type === 'code'
? rawCell.cell_type
: null
if (kind === null) {
return null
}
const outputs = Array.isArray(rawCell.outputs)
? rawCell.outputs.map(parseOutput).filter((output): output is IpynbOutput => output !== null)
: []
return {
id: typeof rawCell.id === 'string' ? rawCell.id : null,
kind,
language: kind === 'code' ? getCellLanguage(rawCell, fallbackLanguage) : kind,
source: concatIpynbMultilineString(rawCell.source),
executionCount: typeof rawCell.execution_count === 'number' ? rawCell.execution_count : null,
outputs
}
}
export function parseIpynb(content: string): ParsedIpynb {
const parsed = JSON.parse(content) as unknown
if (!isRecord(parsed)) {
throw new Error('Notebook root must be a JSON object')
}
if (!Array.isArray(parsed.cells)) {
throw new Error('Notebook is missing a cells array')
}
const language = getPreferredLanguage(parsed)
const cells = parsed.cells
.map((cell) => parseCell(cell, language))
.filter((cell): cell is IpynbCell => cell !== null)
return {
language,
kernelName: getKernelName(parsed),
nbformat:
typeof parsed.nbformat === 'number'
? `${parsed.nbformat}.${typeof parsed.nbformat_minor === 'number' ? parsed.nbformat_minor : 0}`
: 'unknown',
cells
}
}
function splitIpynbSource(source: string): string[] {
if (!source) {
return []
}
return source.endsWith('\n') ? (source.match(/[^\n]*\n/g) ?? []) : source.split(/(?<=\n)/)
}
function parseNotebookRoot(content: string): Record<string, unknown> {
const parsed = JSON.parse(content) as unknown
if (!isRecord(parsed)) {
throw new Error('Notebook root must be a JSON object')
}
if (!Array.isArray(parsed.cells)) {
throw new Error('Notebook is missing a cells array')
}
return parsed
}
function ensureCell(root: Record<string, unknown>, index: number): Record<string, unknown> {
const cells = root.cells
if (!Array.isArray(cells) || !isRecord(cells[index])) {
throw new Error('Notebook cell no longer exists')
}
return cells[index]
}
function serializeNotebook(root: Record<string, unknown>): string {
return `${JSON.stringify(root, null, 1)}\n`
}
export function updateIpynbCellSource(content: string, index: number, source: string): string {
const root = parseNotebookRoot(content)
ensureCell(root, index).source = splitIpynbSource(source)
return serializeNotebook(root)
}
export function updateIpynbCellSources(
content: string,
updates: { index: number; source: string }[]
): string {
if (updates.length === 0) {
return content
}
const root = parseNotebookRoot(content)
for (const update of updates) {
ensureCell(root, update.index).source = splitIpynbSource(update.source)
}
return serializeNotebook(root)
}
export function updateIpynbCellKind(
content: string,
index: number,
kind: IpynbCellKind,
fallbackLanguage: string
): string {
const root = parseNotebookRoot(content)
const cell = ensureCell(root, index)
cell.cell_type = kind
if (kind === 'code') {
cell.outputs = Array.isArray(cell.outputs) ? cell.outputs : []
cell.execution_count = typeof cell.execution_count === 'number' ? cell.execution_count : null
cell.metadata = isRecord(cell.metadata) ? cell.metadata : {}
const metadata = cell.metadata as Record<string, unknown>
const vscode = isRecord(metadata.vscode) ? metadata.vscode : {}
metadata.vscode = { ...vscode, languageId: fallbackLanguage }
} else {
delete cell.outputs
delete cell.execution_count
}
return serializeNotebook(root)
}
export function insertIpynbCell(
content: string,
index: number,
kind: IpynbCellKind,
language: string
): string {
const root = parseNotebookRoot(content)
const cells = root.cells as unknown[]
const nextCell: Record<string, unknown> = {
cell_type: kind,
id: crypto.randomUUID?.() ?? `cell-${Date.now()}`,
metadata: {},
source: []
}
if (kind === 'code') {
nextCell.execution_count = null
nextCell.outputs = []
nextCell.metadata = { vscode: { languageId: language } }
}
cells.splice(Math.min(Math.max(index, 0), cells.length), 0, nextCell)
return serializeNotebook(root)
}
export function deleteIpynbCell(content: string, index: number): string {
const root = parseNotebookRoot(content)
const cells = root.cells as unknown[]
if (cells.length <= 1) {
cells.splice(0, cells.length, {
cell_type: 'code',
id: crypto.randomUUID?.() ?? `cell-${Date.now()}`,
metadata: {},
execution_count: null,
outputs: [],
source: []
})
} else {
cells.splice(index, 1)
}
return serializeNotebook(root)
}
export function moveIpynbCell(content: string, index: number, direction: -1 | 1): string {
const root = parseNotebookRoot(content)
const cells = root.cells as unknown[]
const nextIndex = index + direction
if (index < 0 || index >= cells.length || nextIndex < 0 || nextIndex >= cells.length) {
return content
}
const [cell] = cells.splice(index, 1)
cells.splice(nextIndex, 0, cell)
return serializeNotebook(root)
}
export function updateIpynbCellOutputs(
content: string,
index: number,
result: IpynbRunResult
): string {
const root = parseNotebookRoot(content)
const cell = ensureCell(root, index)
const outputs: Record<string, unknown>[] = []
if (result.stdout) {
outputs.push({ output_type: 'stream', name: 'stdout', text: splitIpynbSource(result.stdout) })
}
if (result.stderr && result.exitCode === 0 && !result.error) {
outputs.push({ output_type: 'stream', name: 'stderr', text: splitIpynbSource(result.stderr) })
}
if (result.error || (result.exitCode ?? 0) !== 0) {
const message = result.error || result.stderr || `Process exited with code ${result.exitCode}`
outputs.push({
output_type: 'error',
ename: 'PythonError',
evalue: message,
traceback: splitIpynbSource(result.stderr || message)
})
}
cell.outputs = outputs
cell.execution_count = typeof cell.execution_count === 'number' ? cell.execution_count + 1 : 1
return serializeNotebook(root)
}

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
canOpenMarkdownPreview,
getDefaultMarkdownViewMode,
getEditorToggleModes,
getMarkdownPreviewShortcutLabel,
getMarkdownViewModes,
isMarkdownPreviewShortcut
@ -35,6 +36,15 @@ describe('getMarkdownViewModes', () => {
})
).toEqual(['source', 'rich'])
})
it('keeps notebook toggles to source and rich without Changes', () => {
expect(
getEditorToggleModes({
language: 'notebook',
mode: 'edit'
})
).toEqual(['source', 'rich'])
})
})
describe('markdown preview helpers', () => {

View File

@ -12,6 +12,7 @@ const MARKDOWN_DIFF_VIEW_MODES = [
] as const satisfies readonly MarkdownViewMode[]
const MERMAID_VIEW_MODES = ['source', 'rich'] as const satisfies readonly MarkdownViewMode[]
const CSV_VIEW_MODES = ['source', 'rich'] as const satisfies readonly MarkdownViewMode[]
const NOTEBOOK_VIEW_MODES = ['source', 'rich'] as const satisfies readonly MarkdownViewMode[]
const NO_VIEW_MODES = [] as const satisfies readonly MarkdownViewMode[]
// Why: every editable file (markdown, mermaid, or plain code) can flip into
@ -26,6 +27,11 @@ export function getEditorToggleModes(target: MarkdownPreviewTarget): readonly Ed
if (target.mode !== 'edit') {
return getMarkdownViewModes(target)
}
if (target.language === 'notebook') {
// Why: notebook source mode is raw JSON and Changes would diff that JSON,
// which is noisy and currently invalid for restored external notebooks.
return NOTEBOOK_VIEW_MODES
}
const languageModes = getMarkdownViewModes(target)
if (languageModes.length > 0) {
return [...languageModes, 'changes']
@ -55,6 +61,10 @@ export function getMarkdownViewModes(target: MarkdownPreviewTarget): readonly Ma
return CSV_VIEW_MODES
}
if (target.language === 'notebook' && target.mode === 'edit') {
return NOTEBOOK_VIEW_MODES
}
return NO_VIEW_MODES
}

View File

@ -1,6 +1,12 @@
/// <reference types="vite/client" />
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { languages } from 'monaco-editor'
declare module 'monaco-editor/esm/vs/basic-languages/python/python.js' {
export const conf: languages.LanguageConfiguration
export const language: languages.IMonarchLanguage
}
declare global {
var MonacoEnvironment:

View File

@ -20,6 +20,7 @@ const EXT_TO_LANGUAGE: Record<string, string> = {
'.cjs': 'javascript',
'.json': 'json',
'.jsonc': 'json',
'.ipynb': 'notebook',
'.md': 'markdown',
'.mdx': 'markdown',
'.mmd': 'mermaid',

View File

@ -108,6 +108,33 @@ describe('createEditorSlice openDiff', () => {
})
describe('createEditorSlice markdown view state', () => {
it('updates stale language metadata when reopening an existing file', () => {
const store = createEditorStore()
store.getState().openFile({
filePath: '/repo/notebooks/example.ipynb',
relativePath: 'notebooks/example.ipynb',
worktreeId: 'wt-1',
language: 'json',
mode: 'edit'
})
store.getState().openFile({
filePath: '/repo/notebooks/example.ipynb',
relativePath: 'notebooks/example.ipynb',
worktreeId: 'wt-1',
language: 'notebook',
mode: 'edit'
})
expect(store.getState().openFiles).toEqual([
expect.objectContaining({
filePath: '/repo/notebooks/example.ipynb',
language: 'notebook'
})
])
})
it('drops markdown view mode for a replaced preview tab', () => {
const store = createEditorStore()

View File

@ -630,16 +630,19 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
if (existing) {
// If opening as non-preview, also pin the existing tab
const updatedPreview = isPreview ? existing.isPreview : false
if (
existing.mode === file.mode &&
existing.diffSource === file.diffSource &&
existing.branchCompare?.compareVersion === file.branchCompare?.compareVersion &&
existing.conflict?.kind === file.conflict?.kind &&
existing.conflict?.conflictKind === file.conflict?.conflictKind &&
existing.conflict?.conflictStatus === file.conflict?.conflictStatus &&
existing.conflictReview?.snapshotTimestamp === file.conflictReview?.snapshotTimestamp &&
existing.isPreview === updatedPreview
) {
const needsExistingUpdate =
existing.mode !== file.mode ||
existing.diffSource !== file.diffSource ||
existing.branchCompare?.compareVersion !== file.branchCompare?.compareVersion ||
existing.conflict?.kind !== file.conflict?.kind ||
existing.conflict?.conflictKind !== file.conflict?.conflictKind ||
existing.conflict?.conflictStatus !== file.conflict?.conflictStatus ||
existing.conflictReview?.snapshotTimestamp !== file.conflictReview?.snapshotTimestamp ||
existing.isPreview !== updatedPreview ||
existing.language !== file.language ||
existing.relativePath !== file.relativePath ||
existing.worktreeId !== file.worktreeId
if (!needsExistingUpdate) {
return activeResult
}
return {
@ -647,6 +650,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
f.id === id
? {
...f,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
language: file.language,
mode: file.mode,
diffSource: file.diffSource,
branchCompare: file.branchCompare,
@ -2254,7 +2260,10 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
filePath: pf.filePath,
relativePath: pf.relativePath,
worktreeId,
language: pf.language,
// Why: sessions can contain language ids from older Orca builds.
// Re-detect on hydrate so newly-supported extensions like .ipynb
// stop reopening as raw JSON/plain text after the upgrade.
language: detectLanguage(pf.relativePath || pf.filePath),
isDirty: false,
isPreview: pf.isPreview,
mode: 'edit'

View File

@ -1365,6 +1365,48 @@ describe('hydrateEditorSession', () => {
expect(s.activeTabType).toBe('editor')
})
it('re-detects restored file languages instead of trusting stale session data', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
activeWorktreeId: wt
})
store.getState().hydrateEditorSession({
activeRepoId: 'repo1',
activeWorktreeId: wt,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
openFilesByWorktree: {
[wt]: [
{
filePath: '/path/wt1/notebooks/example.ipynb',
relativePath: 'notebooks/example.ipynb',
worktreeId: wt,
language: 'json'
}
]
},
activeFileIdByWorktree: { [wt]: '/path/wt1/notebooks/example.ipynb' },
activeTabTypeByWorktree: { [wt]: 'editor' }
})
expect(store.getState().openFiles[0]).toEqual(
expect.objectContaining({
filePath: '/path/wt1/notebooks/example.ipynb',
language: 'notebook'
})
)
})
it('does nothing when no editor files are persisted', () => {
const store = createTestStore()

View File

@ -35,6 +35,15 @@ describe('browser-url helpers', () => {
)
})
it('normalizes pasted absolute local paths to file URLs', () => {
expect(normalizeBrowserNavigationUrl('/Users/me/Downloads/Example.ipynb')).toBe(
'file:///Users/me/Downloads/Example.ipynb'
)
expect(normalizeBrowserNavigationUrl('C:\\Users\\me\\Downloads\\Example.ipynb')).toBe(
'file:///C:/Users/me/Downloads/Example.ipynb'
)
})
// Why: in-app preview is fine (sandboxed webview), but handing file:// to
// shell.openExternal would let a remote page drive Finder/Explorer to
// arbitrary paths. External-open paths must still refuse file://.

View File

@ -8,6 +8,8 @@ const LOCAL_ADDRESS_PATTERN =
// A single-word input containing a dot with a valid TLD-like suffix is treated as
// a URL attempt, not a search query.
const LOOKS_LIKE_URL_PATTERN = /^[^\s]+\.[a-z]{2,}(\/.*)?$/i
const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[\\/][^\s]*$/
const UNIX_ABSOLUTE_PATH_PATTERN = /^\/[^\s]*$/
export type SearchEngine = 'google' | 'duckduckgo' | 'bing' | 'kagi'
@ -131,6 +133,19 @@ export function looksLikeSearchQuery(input: string): boolean {
return true
}
function absolutePathToFileUrl(filePath: string): string {
const normalizedPath = filePath.replaceAll('\\', '/')
const segments = normalizedPath.split('/').map((segment, index) => {
if (index === 0 && /^[A-Za-z]:$/.test(segment)) {
return segment
}
return encodeURIComponent(segment)
})
return normalizedPath.startsWith('/')
? `file://${segments.join('/')}`
: `file:///${segments.join('/')}`
}
export function normalizeBrowserNavigationUrl(
rawUrl: string,
searchEngine?: SearchEngine | null,
@ -149,6 +164,10 @@ export function normalizeBrowserNavigationUrl(
}
}
if (UNIX_ABSOLUTE_PATH_PATTERN.test(trimmed) || WINDOWS_ABSOLUTE_PATH_PATTERN.test(trimmed)) {
return absolutePathToFileUrl(trimmed)
}
try {
const parsed = new URL(trimmed)
// Why: file:// is allowed so the browser pane can render local files the