/** * Copyright (c) 2024 Sailfish AI. All rights reserved. * @license MIT * * Sailfish Collapse Service - manages collapsible regions in the terminal buffer. * This enables native GPU-accelerated collapse/expand of command blocks. */ import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; import { ICollapseService, ICollapseRegion, IBufferService, IStickyHeaderInfo } from 'common/services/Services'; import { Emitter } from 'vs/base/common/event'; /** * Internal mutable version of ICollapseRegion */ class CollapseRegion implements ICollapseRegion { public startLine: number; public endLine: number; public headerLine: number; public collapsed: boolean = false; public visualOffset: number = 0; public invisible: boolean = false; // Sailfish Sticky Header metadata public startTimestamp?: number; public endTimestamp?: number; public exitCode?: number; public headerContent?: string; constructor( public readonly id: number, startLine: number, endLine: number, headerLine?: number, invisible: boolean = false ) { this.startLine = startLine; this.endLine = endLine; this.headerLine = headerLine ?? startLine; this.invisible = invisible; // Invisible regions are always collapsed if (invisible) { this.collapsed = true; } } /** * Get the number of lines hidden when this region is collapsed. * For invisible regions: ALL lines are hidden (including header). * For normal collapsed regions: lines between headerLine and endLine (the output lines). */ public get hiddenLineCount(): number { if (!this.collapsed) return 0; if (this.invisible) { // Invisible regions hide ALL lines, including the header return this.endLine - this.startLine + 1; } // Normal collapsed regions: hide everything except the header line return this.endLine - this.headerLine; } } export class CollapseService extends Disposable implements ICollapseService { public serviceBrand: any; private readonly _regions: Map = new Map(); private _nextRegionId: number = 1; private _totalHiddenLines: number = 0; // Sorted array of regions by startLine for efficient lookup private _sortedRegions: CollapseRegion[] = []; // Sailfish: Invisible region tracking private _invisibleRegionStartLine: number | null = null; private readonly _onRegionRegistered = this._register(new Emitter()); public readonly onRegionRegistered = this._onRegionRegistered.event; private readonly _onRegionRemoved = this._register(new Emitter()); public readonly onRegionRemoved = this._onRegionRemoved.event; private readonly _onCollapseStateChanged = this._register(new Emitter()); public readonly onCollapseStateChanged = this._onCollapseStateChanged.event; private readonly _onStickyHeadersChanged = this._register(new Emitter()); public readonly onStickyHeadersChanged = this._onStickyHeadersChanged.event; public get regions(): ReadonlyMap { return this._regions; } public get totalHiddenLines(): number { return this._totalHiddenLines; } public get isInvisibleRegionActive(): boolean { return this._invisibleRegionStartLine !== null; } constructor(@IBufferService private readonly _bufferService: IBufferService) { super(); this._register(toDisposable(() => this.reset())); // Listen for buffer scroll/trim events to adjust region line numbers this._register(this._bufferService.onScroll(() => { // Scroll events might indicate buffer trimming at the top // We'll need to adjust regions when lines are trimmed this._cleanupTrimmedRegions(); })); } public registerRegion(startLine: number, endLine: number, headerLine?: number): number { console.log(`[CollapseService] registerRegion(${startLine}, ${endLine}, ${headerLine})`); // Validate region if (startLine < 0 || endLine < startLine) { console.log(`[CollapseService] Invalid region: startLine=${startLine}, endLine=${endLine}`); return -1; } // Check for overlapping regions for (const region of this._regions.values()) { if (this._regionsOverlap(startLine, endLine, region.startLine, region.endLine)) { // Overlapping regions are not allowed console.log(`[CollapseService] Overlapping region detected with region ${region.id}`); return -1; } } const id = this._nextRegionId++; const region = new CollapseRegion(id, startLine, endLine, headerLine); this._regions.set(id, region); this._insertSorted(region); this._recalculateVisualOffsets(); // Update buffer line metadata this._updateBufferLineMetadata(region); console.log(`[CollapseService] Registered region ${id}: lines ${startLine}-${endLine}, header=${headerLine ?? startLine}`); this._onRegionRegistered.fire(region); return id; } public removeRegion(regionId: number): void { const region = this._regions.get(regionId); if (!region) return; // Clear buffer line metadata this._clearBufferLineMetadata(region); this._regions.delete(regionId); this._removeSorted(region); this._recalculateVisualOffsets(); this._onRegionRemoved.fire(region); } public collapse(regionId: number): void { const region = this._regions.get(regionId); if (!region || region.collapsed) return; region.collapsed = true; this._recalculateVisualOffsets(); this._updateBufferLineMetadata(region); this._onCollapseStateChanged.fire(region); } public expand(regionId: number): void { const region = this._regions.get(regionId); if (!region || !region.collapsed) return; region.collapsed = false; this._recalculateVisualOffsets(); this._updateBufferLineMetadata(region); this._onCollapseStateChanged.fire(region); } public toggle(regionId: number): void { const region = this._regions.get(regionId); if (!region) return; if (region.collapsed) { this.expand(regionId); } else { this.collapse(regionId); } } public collapseAll(): void { console.log(`[CollapseService] collapseAll() called, ${this._regions.size} regions`); for (const region of this._regions.values()) { if (!region.collapsed) { console.log(`[CollapseService] Collapsing region ${region.id}: lines ${region.startLine}-${region.endLine}`); region.collapsed = true; this._updateBufferLineMetadata(region); this._onCollapseStateChanged.fire(region); } } this._recalculateVisualOffsets(); console.log(`[CollapseService] After collapseAll: totalHiddenLines=${this.totalHiddenLines}`); } public expandAll(): void { for (const region of this._regions.values()) { if (region.collapsed) { region.collapsed = false; this._updateBufferLineMetadata(region); this._onCollapseStateChanged.fire(region); } } this._recalculateVisualOffsets(); } public getVisualLine(bufferLine: number): number { // Calculate how many lines are hidden before this buffer line let hiddenBefore = 0; for (const region of this._sortedRegions) { if (region.startLine >= bufferLine) break; if (region.collapsed) { if (bufferLine > region.endLine) { // This region is entirely before our line hiddenBefore += region.hiddenLineCount; } else if (region.invisible) { // Our line is inside an invisible region - ALL lines are hidden // Return the visual position of where this region starts return region.startLine - hiddenBefore; } else if (bufferLine > region.headerLine) { // Our line is inside this collapsed region (hidden) // Return the visual position of the header line return region.headerLine - hiddenBefore; } } } return bufferLine - hiddenBefore; } public getBufferLine(visualLine: number): number { // Walk through regions to find what buffer line corresponds to this visual line let currentVisualLine = 0; let currentBufferLine = 0; for (const region of this._sortedRegions) { // Lines before this region const linesBefore = region.startLine - currentBufferLine; if (currentVisualLine + linesBefore > visualLine) { // The target is before this region return currentBufferLine + (visualLine - currentVisualLine); } currentVisualLine += linesBefore; currentBufferLine = region.startLine; if (region.collapsed) { if (region.invisible) { // Invisible region: NO lines are visible, skip entirely currentBufferLine = region.endLine + 1; // currentVisualLine stays the same since no lines are visible } else { // Normal collapsed region: only the header line is visible const visibleLines = region.headerLine - region.startLine + 1; if (currentVisualLine + visibleLines > visualLine) { // Target is in the visible part before/at header return currentBufferLine + (visualLine - currentVisualLine); } currentVisualLine += visibleLines; currentBufferLine = region.endLine + 1; } } else { // Expanded region: all lines are visible const regionLines = region.endLine - region.startLine + 1; if (currentVisualLine + regionLines > visualLine) { return currentBufferLine + (visualLine - currentVisualLine); } currentVisualLine += regionLines; currentBufferLine = region.endLine + 1; } } // Past all regions return currentBufferLine + (visualLine - currentVisualLine); } public isLineHidden(bufferLine: number): boolean { for (const region of this._sortedRegions) { if (region.startLine > bufferLine) break; if (region.collapsed) { if (region.invisible) { // Invisible regions hide ALL lines including startLine if (bufferLine >= region.startLine && bufferLine <= region.endLine) { return true; } } else { // Normal collapsed regions hide lines after the header if (bufferLine > region.headerLine && bufferLine <= region.endLine) { return true; } } } } return false; } public getRegionAtLine(bufferLine: number): ICollapseRegion | undefined { for (const region of this._sortedRegions) { if (region.startLine > bufferLine) break; if (bufferLine >= region.startLine && bufferLine <= region.endLine) { return region; } } return undefined; } public reset(): void { // Clear all buffer line metadata for (const region of this._regions.values()) { this._clearBufferLineMetadata(region); } this._regions.clear(); this._sortedRegions = []; this._totalHiddenLines = 0; this._nextRegionId = 1; } public adjustForBufferChange(startLine: number, delta: number): void { // Adjust all region line numbers that are at or after startLine for (const region of this._regions.values()) { if (region.startLine >= startLine) { region.startLine += delta; region.headerLine += delta; region.endLine += delta; } else if (region.endLine >= startLine) { // Region straddles the change point region.endLine += delta; if (region.headerLine >= startLine) { region.headerLine += delta; } } } // Re-sort and recalculate this._sortedRegions.sort((a, b) => a.startLine - b.startLine); this._recalculateVisualOffsets(); } /** * Check if two regions overlap */ private _regionsOverlap( start1: number, end1: number, start2: number, end2: number ): boolean { return start1 <= end2 && start2 <= end1; } /** * Insert a region into the sorted array */ private _insertSorted(region: CollapseRegion): void { let insertIndex = this._sortedRegions.length; for (let i = 0; i < this._sortedRegions.length; i++) { if (this._sortedRegions[i].startLine > region.startLine) { insertIndex = i; break; } } this._sortedRegions.splice(insertIndex, 0, region); } /** * Remove a region from the sorted array */ private _removeSorted(region: CollapseRegion): void { const index = this._sortedRegions.indexOf(region); if (index !== -1) { this._sortedRegions.splice(index, 1); } } /** * Recalculate visual offsets for all regions and total hidden lines */ private _recalculateVisualOffsets(): void { let cumulativeHidden = 0; for (const region of this._sortedRegions) { region.visualOffset = cumulativeHidden; if (region.collapsed) { cumulativeHidden += region.hiddenLineCount; } } this._totalHiddenLines = cumulativeHidden; } /** * Update buffer line metadata for a region */ private _updateBufferLineMetadata(region: CollapseRegion): void { const buffer = this._bufferService.buffer; const startLine = region.startLine; // Get the buffer line at the start of the region const line = buffer.lines.get(startLine); if (line) { line.isCollapseStart = true; line.isCollapsed = region.collapsed; line.collapsedLineCount = region.collapsed ? region.hiddenLineCount : 0; line.collapseRegionId = region.id; } } /** * Clear buffer line metadata for a region */ private _clearBufferLineMetadata(region: CollapseRegion): void { const buffer = this._bufferService.buffer; const startLine = region.startLine; const line = buffer.lines.get(startLine); if (line) { line.isCollapseStart = false; line.isCollapsed = false; line.collapsedLineCount = 0; line.collapseRegionId = 0; } } /** * Clean up regions that have been trimmed from the buffer */ private _cleanupTrimmedRegions(): void { const minLine = 0; // Buffer lines start at 0 // Remove regions that are entirely before the visible buffer const toRemove: number[] = []; for (const region of this._regions.values()) { // If the region's end line is before what we can access, remove it if (region.endLine < minLine) { toRemove.push(region.id); } else if (region.startLine < minLine) { // Adjust region to start at minLine region.startLine = minLine; if (region.headerLine < minLine) { region.headerLine = minLine; } } } for (const id of toRemove) { this.removeRegion(id); } } // ========== Sailfish Sticky Header Support ========== /** * Get headers that should be "sticky" (pinned to viewport top) at current scroll position. * A header is sticky when we've scrolled past it but are still within its region's output. * @param ydisp Current scroll position (buffer line at viewport top) * @param viewportRows Number of rows in the viewport * @param maxHeaders Maximum number of sticky headers to return * @returns Array of sticky header info, ordered by stack position (topmost first) */ public getStickyHeaders(ydisp: number, viewportRows: number, maxHeaders: number): IStickyHeaderInfo[] { const stickyHeaders: IStickyHeaderInfo[] = []; // Walk through sorted regions to find headers that should be sticky for (const region of this._sortedRegions) { // Skip invisible regions - they have no visible header if (region.invisible) continue; // Calculate the visual position of the header line const headerVisualLine = this.getVisualLine(region.headerLine); // Calculate the visual position of the region end const endVisualLine = this.getVisualLine(region.endLine); // A header is sticky when: // 1. We've scrolled past the header (headerVisualLine < ydisp) // 2. We're still within the region's output (ydisp < endVisualLine) // 3. The region has content below the header (endLine > headerLine) if (headerVisualLine < ydisp && ydisp <= endVisualLine && region.endLine > region.headerLine) { // Calculate duration if we have both timestamps let duration: number | undefined; if (region.startTimestamp !== undefined && region.endTimestamp !== undefined) { duration = region.endTimestamp - region.startTimestamp; } stickyHeaders.push({ regionId: region.id, headerLine: region.headerLine, stickyIndex: stickyHeaders.length, commandText: region.headerContent ?? '', startTime: region.startTimestamp, duration, exitCode: region.exitCode, collapsed: region.collapsed }); // Limit the number of sticky headers if (stickyHeaders.length >= maxHeaders) break; } // Optimization: if the region starts after the viewport bottom, we can stop const regionStartVisual = this.getVisualLine(region.startLine); if (regionStartVisual > ydisp + viewportRows) break; } return stickyHeaders; } /** * Update metadata for a region (timing, exit code, header content). * @param regionId The region ID to update * @param metadata The metadata to set */ public setRegionMetadata(regionId: number, metadata: { startTimestamp?: number; endTimestamp?: number; exitCode?: number; headerContent?: string; }): void { const region = this._regions.get(regionId); if (!region) return; // Update only the provided fields if (metadata.startTimestamp !== undefined) { region.startTimestamp = metadata.startTimestamp; } if (metadata.endTimestamp !== undefined) { region.endTimestamp = metadata.endTimestamp; } if (metadata.exitCode !== undefined) { region.exitCode = metadata.exitCode; } if (metadata.headerContent !== undefined) { region.headerContent = metadata.headerContent; } // Fire the sticky headers changed event so renderers can update // (The metadata affects how sticky headers are displayed) this._onStickyHeadersChanged.fire(this.getStickyHeaders( this._bufferService.buffer.ydisp, this._bufferService.rows, 5 // Default max headers )); } // ========== Sailfish Invisible Region Support ========== /** * Start tracking an invisible region at the current buffer position. */ public startInvisibleRegion(): void { const buffer = this._bufferService.buffer; // Get current absolute buffer position this._invisibleRegionStartLine = buffer.ybase + buffer.y; console.log(`[CollapseService] startInvisibleRegion at line ${this._invisibleRegionStartLine}`); } /** * End the current invisible region and register it. */ public endInvisibleRegion(): number { if (this._invisibleRegionStartLine === null) { console.warn('[CollapseService] endInvisibleRegion called without startInvisibleRegion'); return -1; } const buffer = this._bufferService.buffer; const startLine = this._invisibleRegionStartLine; // Get current absolute buffer position (end line is the line BEFORE the current cursor) const endLine = buffer.ybase + buffer.y - 1; this._invisibleRegionStartLine = null; // If endLine < startLine, nothing was written (or just one line with cursor on it) if (endLine < startLine) { console.log(`[CollapseService] endInvisibleRegion: no lines to hide (start=${startLine}, end=${endLine})`); return -1; } console.log(`[CollapseService] endInvisibleRegion: creating invisible region ${startLine}-${endLine}`); return this.registerInvisibleRegion(startLine, endLine); } /** * Register an invisible region directly. */ public registerInvisibleRegion(startLine: number, endLine: number): number { console.log(`[CollapseService] registerInvisibleRegion(${startLine}, ${endLine})`); // Validate region if (startLine < 0 || endLine < startLine) { console.log(`[CollapseService] Invalid invisible region: startLine=${startLine}, endLine=${endLine}`); return -1; } // Check for overlapping regions for (const region of this._regions.values()) { if (this._regionsOverlap(startLine, endLine, region.startLine, region.endLine)) { console.log(`[CollapseService] Overlapping region detected with region ${region.id}`); return -1; } } const id = this._nextRegionId++; // Create invisible region: no header line matters since everything is hidden const region = new CollapseRegion(id, startLine, endLine, startLine, true /* invisible */); this._regions.set(id, region); this._insertSorted(region); this._recalculateVisualOffsets(); // Update buffer line metadata this._updateBufferLineMetadata(region); console.log(`[CollapseService] Registered invisible region ${id}: lines ${startLine}-${endLine} (${endLine - startLine + 1} lines hidden)`); this._onRegionRegistered.fire(region); return id; } }