mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 16:02:41 +00:00
* fix: retire closed editor models from the app shell * test(editor): use checked Monaco attachment calls * Preserve bounded editor view caches when retiring closed models * docs(editor): describe batched model retirement * fix(editor): preserve cleanup work across registry replacement * fix(editor): build editor model URIs with the file scheme Monaco keys its model registry by `uri.toString()`, and both `@monaco-editor/react` (via the `path` prop) and the closed-tab disposal path built that key with `Uri.parse`. On Windows a raw path such as `C:\repo\a.ts` parses as scheme `c`, which fails the scheme gate in `modelService._schemaShouldMaintainUndoRedoElements`, so closed-file undo history was dropped for every file at any size — not only the large files the tradeoff note covers. Add `toEditorModelUri`, the one filesystem-path -> model-key function, built on `Uri.file` so the result always carries the `file:` scheme and re-parses to itself. Route model creation, disposal lookup and the still-open ownership comparison through it so all three agree; a divergence there would dispose a model an open editor is still editing. --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Neil <neil@stably.ai>
234 lines
8.3 KiB
JavaScript
234 lines
8.3 KiB
JavaScript
import { writeFileSync } from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import { resolve } from 'node:path'
|
|
import React from 'react'
|
|
import { useStore } from 'zustand'
|
|
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
|
import * as monaco from 'monaco-editor'
|
|
import { afterAll, afterEach, expect, it, vi } from 'vitest'
|
|
import {
|
|
createTestStore,
|
|
makeWorktree
|
|
} from '../../../src/renderer/src/store/slices/store-test-helpers'
|
|
import { createStoreSessionMockApi } from '../../../src/renderer/src/store/slices/store-session-test-harness'
|
|
import { useClosedEditorTabCleanup } from '../../../src/renderer/src/components/editor/useClosedEditorTabCleanup'
|
|
import { TerminalLegacyEditorSurface } from '../../../src/renderer/src/components/TerminalLegacyEditorSurface'
|
|
|
|
const fixture = vi.hoisted(() => ({ store: null }))
|
|
const variant = process.env.ORCA_CLOSED_MODEL_VARIANT ?? 'fixed'
|
|
const fixed = variant === 'fixed'
|
|
const bridge = fixed
|
|
? (await import('../../../src/renderer/src/lib/editor-model-registry')).editorModelRegistry
|
|
: null
|
|
let unregisterRegistry = null
|
|
const observations = []
|
|
const require = createRequire(resolve('package.json'))
|
|
const { loadSources, readText, sha256 } = require(
|
|
resolve('docs/audits/closed-editor-model-lifetime/sources.cjs')
|
|
)
|
|
const workspace = 'audit-repo::/audit-model-workspace'
|
|
const domWindow = globalThis.window
|
|
|
|
vi.mock('@/store', () => ({
|
|
useAppStore: {
|
|
getState: () => fixture.store.getState(),
|
|
subscribe: (...args) => fixture.store.subscribe(...args)
|
|
}
|
|
}))
|
|
vi.mock('sonner', () => ({ toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() } }))
|
|
vi.mock('../../../src/renderer/src/components/editor/EditorPanel', async () => {
|
|
const { useClosedEditorTabCleanup } =
|
|
await import('../../../src/renderer/src/components/editor/useClosedEditorTabCleanup')
|
|
return {
|
|
default:
|
|
(process.env.ORCA_CLOSED_MODEL_VARIANT ?? 'fixed') === 'before'
|
|
? function PanelCleanupPort() {
|
|
const files = useStore(fixture.store, (state) => state.openFiles)
|
|
useClosedEditorTabCleanup(files)
|
|
return React.createElement('div', { 'data-testid': 'cleanup-panel' })
|
|
}
|
|
: function PanelPort() {
|
|
return React.createElement('div', { 'data-testid': 'cleanup-panel' })
|
|
}
|
|
}
|
|
})
|
|
|
|
function FixedShell(props) {
|
|
useClosedEditorTabCleanup()
|
|
return React.createElement(Surface, props)
|
|
}
|
|
const Shell = fixed ? FixedShell : Surface
|
|
|
|
function Surface({ visible = true }) {
|
|
const files = useStore(fixture.store, (state) => state.openFiles)
|
|
return React.createElement(TerminalLegacyEditorSurface, {
|
|
controller: {
|
|
activeTabType: visible ? 'editor' : 'terminal',
|
|
renderedActiveWorktreeId: workspace,
|
|
worktreeFiles: files
|
|
}
|
|
})
|
|
}
|
|
|
|
function setup() {
|
|
const api = createStoreSessionMockApi()
|
|
globalThis.window = domWindow
|
|
domWindow.api = api
|
|
unregisterRegistry = bridge?.register(monaco)
|
|
const store = createTestStore()
|
|
fixture.store = store
|
|
store.setState({
|
|
repos: [
|
|
{
|
|
id: 'audit-repo',
|
|
path: '/audit-model-workspace',
|
|
displayName: 'Audit',
|
|
badgeColor: 'gray',
|
|
addedAt: 0,
|
|
executionHostId: 'local'
|
|
}
|
|
],
|
|
worktreesByRepo: {
|
|
'audit-repo': [
|
|
makeWorktree({
|
|
id: workspace,
|
|
repoId: 'audit-repo',
|
|
path: '/audit-model-workspace',
|
|
hostId: 'local'
|
|
})
|
|
]
|
|
},
|
|
activeWorktreeId: workspace,
|
|
openFiles: [],
|
|
activeFileId: null
|
|
})
|
|
return store
|
|
}
|
|
|
|
function addFile(store, id) {
|
|
const file = {
|
|
id,
|
|
worktreeId: workspace,
|
|
filePath: `/audit-model-workspace/${id}.txt`,
|
|
relativePath: `${id}.txt`,
|
|
mode: 'edit',
|
|
language: 'plaintext',
|
|
isDirty: false,
|
|
runtimeEnvironmentId: null
|
|
}
|
|
const model = monaco.editor.createModel(
|
|
`${id}\n${'x'.repeat(256 * 1024)}`,
|
|
'plaintext',
|
|
monaco.Uri.parse(file.filePath)
|
|
)
|
|
act(() => store.setState({ openFiles: [...store.getState().openFiles, file], activeFileId: id }))
|
|
return model
|
|
}
|
|
|
|
async function mounted(view) {
|
|
await waitFor(() => expect(view.queryByTestId('cleanup-panel')).not.toBeNull())
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
unregisterRegistry?.()
|
|
unregisterRegistry = null
|
|
for (const model of monaco.editor.getModels()) {
|
|
model.dispose()
|
|
}
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('measures distinct last-file closes when the final editor removes the panel', async () => {
|
|
const store = setup()
|
|
const view = render(React.createElement(Shell))
|
|
for (let index = 0; index < 8; index += 1) {
|
|
addFile(store, `last-${index}`)
|
|
await mounted(view)
|
|
act(() => store.getState().closeFile(`last-${index}`))
|
|
expect(view.queryByTestId('cleanup-panel')).toBeNull()
|
|
expect(store.getState().openFiles).toHaveLength(0)
|
|
await Promise.resolve()
|
|
}
|
|
const models = monaco.editor.getModels()
|
|
expect(models).toHaveLength(fixed ? 0 : 8)
|
|
observations.push({
|
|
control: 'eight-last-file-closes',
|
|
openFiles: store.getState().openFiles.length,
|
|
retainedModels: models.length,
|
|
logicalCharacters: models.reduce((sum, model) => sum + model.getValueLength(), 0)
|
|
})
|
|
})
|
|
|
|
it('disposes a closed model while another file keeps the panel mounted', async () => {
|
|
const store = setup()
|
|
const closedModel = addFile(store, 'closed-with-sibling')
|
|
const liveModel = addFile(store, 'live-sibling')
|
|
const view = render(React.createElement(Shell))
|
|
await mounted(view)
|
|
act(() => store.getState().closeFile('closed-with-sibling'))
|
|
await Promise.resolve()
|
|
expect(closedModel.isDisposed()).toBe(true)
|
|
expect(liveModel.isDisposed()).toBe(false)
|
|
observations.push({
|
|
control: 'panel-stays-mounted',
|
|
closedDisposed: closedModel.isDisposed(),
|
|
liveDisposed: liveModel.isDisposed()
|
|
})
|
|
})
|
|
|
|
it('measures a close while hidden followed by another editor remount', async () => {
|
|
const store = setup()
|
|
const oldModel = addFile(store, 'hidden-close')
|
|
const view = render(React.createElement(Shell))
|
|
await mounted(view)
|
|
view.rerender(React.createElement(Shell, { visible: false }))
|
|
expect(oldModel.isDisposed()).toBe(false)
|
|
act(() => store.getState().closeFile('hidden-close'))
|
|
await Promise.resolve()
|
|
addFile(store, 'later-editor')
|
|
view.rerender(React.createElement(Shell))
|
|
await mounted(view)
|
|
expect(oldModel.isDisposed()).toBe(fixed)
|
|
expect(monaco.editor.getModels()).toHaveLength(fixed ? 1 : 2)
|
|
observations.push({
|
|
control: 'hidden-close-and-remount',
|
|
retainedClosedModel: !oldModel.isDisposed(),
|
|
openFiles: store.getState().openFiles.length,
|
|
models: monaco.editor.getModels().length
|
|
})
|
|
})
|
|
|
|
it('preserves a still-open model across a normal panel unmount and remount', async () => {
|
|
const store = setup()
|
|
const model = addFile(store, 'normal-switch')
|
|
const view = render(React.createElement(Shell))
|
|
await mounted(view)
|
|
view.rerender(React.createElement(Shell, { visible: false }))
|
|
expect(model.isDisposed()).toBe(false)
|
|
view.rerender(React.createElement(Shell))
|
|
await mounted(view)
|
|
expect(monaco.editor.getModel(model.uri)).toBe(model)
|
|
observations.push({ control: 'normal-switch', originalModelPreserved: true })
|
|
})
|
|
|
|
afterAll(() => {
|
|
const loaded = loadSources()
|
|
const sourceFence = {
|
|
sources: loaded.sources.size,
|
|
graphSha256: sha256(JSON.stringify(loaded.hashes)),
|
|
manifestSha256: sha256(
|
|
readText(resolve('docs/audits/closed-editor-model-lifetime/source-versions.json'))
|
|
),
|
|
scenarioSha256: sha256(
|
|
readText(resolve('docs/audits/closed-editor-model-lifetime/lifecycle.test.mjs'))
|
|
),
|
|
loaderSha256: sha256(readText(resolve('docs/audits/closed-editor-model-lifetime/sources.cjs')))
|
|
}
|
|
writeFileSync(
|
|
process.env.ORCA_CLOSED_MODEL_OUTPUT ??
|
|
`docs/audits/closed-editor-model-lifetime/${process.env.ORCA_CLOSED_MODEL_GRAPH ?? 'worktree'}-${variant}-${process.versions.electron ? 'electron' : 'node'}-results.json`,
|
|
`${JSON.stringify({ runtime: process.versions, variant, graph: process.env.ORCA_CLOSED_MODEL_GRAPH ?? 'worktree', sourceFence, observations, scope: 'Actual legacy surface, cleanup hook, disposal, Store close action and installed Monaco models. Shell/Panel bodies are controlled ports; files/models are seeded. Eight distinct fixture paths, 256 KiB characters each. No native editor widget, app window, field file sizes, heap bytes or incident allocation rate.' }, null, 2)}\n`
|
|
)
|
|
})
|