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:
parent
5e9f54808a
commit
75d5a65633
|
|
@ -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() */
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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'] }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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://.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue