diff --git a/src/main/index.ts b/src/main/index.ts index a0f3a86ca..1c57d73f8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 | 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 { + 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() */ diff --git a/src/main/ipc/notebook.test.ts b/src/main/ipc/notebook.test.ts new file mode 100644 index 000000000..b041e42ac --- /dev/null +++ b/src/main/ipc/notebook.test.ts @@ -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 Promise | 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 + + 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 + + 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') + } + }) +}) diff --git a/src/main/ipc/notebook.ts b/src/main/ipc/notebook.ts new file mode 100644 index 000000000..36b2bc97a --- /dev/null +++ b/src/main/ipc/notebook.ts @@ -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 | 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 { + 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 | 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 { + 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 => { + 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)) + } + ) +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 76d422abf..8ad0f613b 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -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() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index cf0a7467d..8e9434354 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -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() diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 4c4058b1d..21d534454 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 7e8193d52..9dab2c9f4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index adf1fe9bc..478b3e7fd 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -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 => { diff --git a/src/renderer/src/components/editor/EditorContent.test.tsx b/src/renderer/src/components/editor/EditorContent.test.tsx new file mode 100644 index 000000000..932e1601b --- /dev/null +++ b/src/renderer/src/components/editor/EditorContent.test.tsx @@ -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 { + 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( + + ) + + expect(html).toContain('Unable to load file') + expect(html).toContain('Access denied') + expect(html).not.toContain('Unable to render notebook') + }) +}) diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index c4038e6ac..c778ddcc2 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -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 ( +
+
+ +
+
Unable to load file
+
{message}
+ +
+
+
+ ) } 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 + 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({ ) } + if (fc.loadError) { + return ( + reloadFileContent(activeFile)} /> + ) + } if (fc.isBinary) { return (
@@ -337,6 +375,11 @@ export function EditorContent({
) } + if (fc.loadError) { + return ( + 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' ? ( + ) : ( 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} diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 805e5d44f..43af45fc9 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -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(null) + const fileLoadRetryAttemptsRef = useRef>({}) const deleteCacheEntriesByPrefix = useCallback((cache: Map, prefix: string) => { for (const key of cache.keys()) { @@ -347,6 +378,18 @@ function EditorPanelInner({ async (filePath: string, id: string, worktreeId?: string): Promise => { 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 => { 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 = {} 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} /> > = { + rich: { + label: 'Notebook', + icon: NotebookText + } +} + type EditorViewToggleProps = { value: EditorToggleValue modes: readonly EditorToggleValue[] diff --git a/src/renderer/src/components/editor/IpynbViewer.tsx b/src/renderer/src/components/editor/IpynbViewer.tsx new file mode 100644 index 000000000..116f957c6 --- /dev/null +++ b/src/renderer/src/components/editor/IpynbViewer.tsx @@ -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 +} + +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 ( +
+ + {executionLabel} + + {cell.kind === 'code' ? ( + + {running ? : } + + ) : null} + + + + + + + onInsertAbove('code')}> + + + onInsertBelow('code')}> + + + onInsertAbove('markdown')} + > + + + + + + onInsertBelow('markdown')} + > + + + + + + + + + #{index + 1} +
+ ) +} + +function NotebookHeaderButton({ + label, + disabled = false, + shortcutKeys, + onClick, + children +}: { + label: string + disabled?: boolean + shortcutKeys?: string[] + onClick: () => void + children: React.ReactNode +}): React.JSX.Element { + return ( + + + + + + + {label} + {shortcutKeys ? : null} + + + + ) +} + +function MarkdownCell({ source }: { source: string }): React.JSX.Element { + return ( +
+ + {source || '\u00a0'} + +
+ ) +} + +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 +}): 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 ( +
{ + if (event.key === 'Enter') { + onActivate() + } + }} + > + +
+ ) + } + + return ( +
+ 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' + }} + /> +
+ ) +} + +const MemoizedCodeCell = React.memo(CodeCell) + +function getCellKey(cell: IpynbCell, index: number): string { + return cell.id ?? `${index}:${cell.kind}` +} + +function hasOwnDraft(drafts: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(drafts, key) +} + +function EditableTextCell({ + source, + onChange +}: { + source: string + onChange: (source: string) => void +}): React.JSX.Element { + return ( +