Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/renderer/src/lib/pane-manager/pane-inline-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
setTerminalImageAddonLoadHandlers
} from './terminal-image-addon-loader'
import { buildInlineImageAddonOptions } from './terminal-inline-image-options'
import { repaintTerminalAfterImageAdded } from './terminal-image-added-repaint'

// Panes whose setting is on but that opened before the lazy addon chunk
// resolved. The loader's onLoaded handler drains them into a real attach.
Expand All @@ -19,6 +20,7 @@ const panesAwaitingImageAddon = new Set<ManagedPaneInternal>()
// Keyed on the terminal, not the pane: PTY connections hold a toPublicPane()
// wrapper, which does not carry imageAddon.
const terminalsRenderingInlineImages = new WeakSet<Terminal>()
const imageRepaintByPane = new WeakMap<ManagedPaneInternal, { dispose(): void }>()

/** True only once the addon is really attached — not while its chunk loads or after it fails. */
export function terminalRendersInlineImages(terminal: Terminal): boolean {
Expand Down Expand Up @@ -66,6 +68,10 @@ export function attachInlineImages(pane: ManagedPaneInternal): void {
imageAddon = new ImageAddonConstructor(options)
pane.terminal.loadAddon(imageAddon)
pane.imageAddon = imageAddon
const repaint = repaintTerminalAfterImageAdded(pane.terminal, imageAddon)
if (repaint) {
imageRepaintByPane.set(pane, repaint)
}
terminalsRenderingInlineImages.add(pane.terminal)
} catch (err) {
console.warn('[terminal] inline-image addon failed to attach for pane', pane.id, err)
Expand All @@ -85,6 +91,8 @@ export function detachInlineImages(pane: ManagedPaneInternal): void {
panesAwaitingImageAddon.delete(pane)
pane.imageAttachmentDeferred = false
terminalsRenderingInlineImages.delete(pane.terminal)
imageRepaintByPane.get(pane)?.dispose()
imageRepaintByPane.delete(pane)
if (pane.imageAddon) {
try {
pane.imageAddon.dispose()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from 'vitest'
import { repaintTerminalAfterImageAdded } from './terminal-image-added-repaint'

function makeAddon() {
const listeners = new Set<() => void>()
return {
fire: () => listeners.forEach((listener) => listener()),
onImageAdded: (listener: () => void) => {
listeners.add(listener)
return { dispose: () => listeners.delete(listener) }
}
}
}

describe('repaintTerminalAfterImageAdded', () => {
it('repaints the whole viewport synchronously once per burst of added images', async () => {
const coreRefresh = vi.fn()
const terminal = { rows: 24, refresh: vi.fn(), _core: { refresh: coreRefresh } }
const addon = makeAddon()
repaintTerminalAfterImageAdded(terminal, addon)

addon.fire()
addon.fire()
expect(coreRefresh).not.toHaveBeenCalled()
await Promise.resolve()

expect(coreRefresh).toHaveBeenCalledTimes(1)
expect(coreRefresh).toHaveBeenCalledWith(0, 23, true)
expect(terminal.refresh).not.toHaveBeenCalled()

addon.fire()
await Promise.resolve()
expect(coreRefresh).toHaveBeenCalledTimes(2)
})

it('falls back to the public refresh when the core refresh is unavailable', async () => {
const terminal = { rows: 10, refresh: vi.fn() }
const addon = makeAddon()
repaintTerminalAfterImageAdded(terminal, addon)

addon.fire()
await Promise.resolve()

expect(terminal.refresh).toHaveBeenCalledWith(0, 9)
})

it('stops repainting once disposed, including a repaint already queued', async () => {
const coreRefresh = vi.fn()
const terminal = { rows: 24, refresh: vi.fn(), _core: { refresh: coreRefresh } }
const addon = makeAddon()
const subscription = repaintTerminalAfterImageAdded(terminal, addon)

addon.fire()
subscription?.dispose()
addon.fire()
await Promise.resolve()

expect(coreRefresh).not.toHaveBeenCalled()
})

it('does nothing for an addon build without the image-added event', () => {
const terminal = { rows: 24, refresh: vi.fn() }
expect(repaintTerminalAfterImageAdded(terminal, {})).toBeNull()
})
})
59 changes: 59 additions & 0 deletions src/renderer/src/lib/pane-manager/terminal-image-added-repaint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
type RepaintableTerminal = {
rows: number
refresh(start: number, end: number): void
_core?: { refresh?(start: number, end: number, sync?: boolean): void }
}

type ImageAddedSource = {
onImageAdded?: (listener: () => void) => { dispose(): void }
}

/**
* Repaint the viewport once after the image addon registers an image.
*
* Why: an image's rows are dirtied in a write that Orca's synchronous per-write
* repair may not cover (async decoders even place tiles after that write's
* parse), so they reach only xterm's frame-debounced refresh. A throttled or
* hidden window may never run that frame, leaving every image row but the
* cursor's unpainted until something else repaints the grid.
*/
export function repaintTerminalAfterImageAdded(
terminal: RepaintableTerminal,
addon: ImageAddedSource
): { dispose(): void } | null {
if (typeof addon.onImageAdded !== 'function') {
return null
}
let scheduled = false
let disposed = false
const repaint = (): void => {
scheduled = false
if (disposed || terminal.rows < 1) {
return
}
try {
const end = terminal.rows - 1
if (typeof terminal._core?.refresh === 'function') {
terminal._core.refresh(0, end, true)
} else {
terminal.refresh(0, end)
}
} catch {
// A pane torn down between the image and this microtask has nothing to paint.
}
}
const subscription = addon.onImageAdded(() => {
if (scheduled || disposed) {
return
}
// Why a microtask: one repaint per decoded burst, outside the addon's addImage call.
scheduled = true
queueMicrotask(repaint)
})
return {
dispose: () => {
disposed = true
subscription.dispose()
}
}
}
Loading