[tab] src/capture/viewportCapture.ts (typescript)
/**
 * Viewport Capture Module
 * Captures the current visible editor state (viewport content, cursor position, line ranges)
 */

import * as vscode from 'vscode'
import * as crypto from 'crypto'
import type { ViewportState, Observation, TerminalState } from '../types'
import { getTerminalStates } from './terminalCapture'

// Throttle configuration for scroll events
const SCROLL_THROTTLE_MS = 100 // 10Hz

let lastScrollObservationTime = 0
let lastObservationHash: string | null = null

// Callback for when observations should be logged
let onObservationCallback: ((observation: Observation) => void) | null = null

// Disposable for the visible ranges subscription
let visibleRangesDisposable: vscode.Disposable | null = null

/**
 * Compute a hash of the viewport state for deduplication
 */
function computeObservationHash(observation: Observation): string {
	const data = JSON.stringify({
		viewport: observation.viewport,
		// Don't include terminal history in hash - it changes frequently
		terminalIds: observation.activeTerminals.map(t => t.id)
	})
	return crypto.createHash('md5').update(data).digest('hex')
}

/**
 * Capture the current viewport state from the active editor
 */
export function captureViewportState(): ViewportState | null {
	const editor = vscode.window.activeTextEditor
	if (!editor) {
		return null
	}

	const document = editor.document
	const visibleRanges = editor.visibleRanges

	if (visibleRanges.length === 0) {
		return null
	}

	// Get the union of all visible ranges
	const firstRange = visibleRanges[0]
	const lastRange = visibleRanges[visibleRanges.length - 1]
	
	const startLine = firstRange.start.line
	const endLine = lastRange.end.line

	// Create a range that covers all visible lines
	const fullVisibleRange = new vscode.Range(
		new vscode.Position(startLine, 0),
		new vscode.Position(endLine, document.lineAt(endLine).text.length)
	)

	// Get the viewport content
	const content = document.getText(fullVisibleRange)

	// Get cursor position
	const selection = editor.selection
	const cursorPosition = selection ? {
		line: selection.active.line,
		character: selection.active.character
	} : null

	// Get relative file path
	const file = vscode.workspace.asRelativePath(document.fileName)

	return {
		file,
		startLine: startLine + 1, // Convert to 1-based line numbers
		endLine: endLine + 1,
		content,
		cursorPosition
	}
}

/**
 * Capture a full observation (viewport + terminal states)
 */
export function captureObservation(): Observation {
	const viewport = captureViewportState()
	const activeTerminals = getTerminalStates()

	return {
		viewport,
		activeTerminals
	}
}

/**
 * Capture and log an observation immediately (called after user actions)
 * This bypasses throttling.
 */
export function captureNow(): Observation | null {
	if (!onObservationCallback) {
		return null
	}

	const observation = captureObservation()
	const hash = computeObservationHash(observation)

	// Skip if observation is identical to last one
	if (hash === lastObservationHash) {
		return null
	}

	lastObservationHash = hash
	onObservationCallback(observation)
	return observation
}

/**
 * Handle visible range changes (scroll/resize) with throttling
 */
function handleVisibleRangesChange(): void {
	if (!onObservationCallback) {
		return
	}

	const now = Date.now()
	if (now - lastScrollObservationTime < SCROLL_THROTTLE_MS) {
		return // Throttled
	}

	lastScrollObservationTime = now

	const observation = captureObservation()
	const hash = computeObservationHash(observation)

	// Skip if observation is identical to last one
	if (hash === lastObservationHash) {
		return
	}

	lastObservationHash = hash
	onObservationCallback(observation)
}

/**
 * Initialize the viewport capture module and subscribe to visible range changes
 */
export function initializeViewportCapture(
	context: vscode.ExtensionContext,
	onScrollObservation: (observation: Observation) => void
): void {
	onObservationCallback = onScrollObservation

	visibleRangesDisposable = vscode.window.onDidChangeTextEditorVisibleRanges(() => {
		handleVisibleRangesChange()
	})
	
	context.subscriptions.push(visibleRangesDisposable)
}

/**
 * Cleanup the viewport capture module
 */
export function cleanupViewportCapture(): void {
	if (visibleRangesDisposable) {
		visibleRangesDisposable.dispose()
		visibleRangesDisposable = null
	}
	lastObservationHash = null
	lastScrollObservationTime = 0
}

/**
 * Reset the observation hash (useful when switching recordings)
 */
export function resetObservationState(): void {
	lastObservationHash = null
	lastScrollObservationTime = 0
}