diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css
index 91162a3..612b72b 100644
--- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css
+++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css
@@ -133,12 +133,59 @@
overflow: auto;
position: relative;
background: var(--bg);
+ display: grid;
+ grid-template-columns: 180px max-content;
+ grid-template-rows: 32px max-content;
+}
+
+.timeline-corner {
+ position: sticky;
+ top: 0;
+ left: 0;
+ z-index: 3;
+ grid-column: 1;
+ grid-row: 1;
+}
+
+.timeline-header {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ grid-column: 2;
+ grid-row: 1;
}
-canvas#timeline {
+.timeline-labels {
+ position: sticky;
+ left: 0;
+ z-index: 2;
+ grid-column: 1;
+ grid-row: 2;
+}
+
+.timeline-body {
+ grid-column: 2;
+ grid-row: 2;
+}
+
+.timeline-container canvas {
display: block;
}
+/*
+ * The header/body wrapper divs are sized (via inline style in Timeline.tsx) to
+ * the full content width so the browser exposes a horizontal scrollbar that
+ * spans the entire timeline. The canvases inside them are only as wide as the
+ * visible viewport and sticky-positioned at the labels column, so their
+ * backing-store dimensions never exceed the browser's maximum canvas size
+ * (~32767 px in Chromium) regardless of zoom level.
+ */
+.timeline-header canvas,
+.timeline-body canvas {
+ position: sticky;
+ left: 180px;
+}
+
/* Side panel */
.side-panel {
width: 280px;
diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx
index 12f2034..395e115 100644
--- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx
+++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.tsx
@@ -19,7 +19,6 @@ function App() {
workers: [],
});
const [timeScale, setTimeScale] = useState(20);
- const [horizontalOffset, setHorizontalOffset] = useState(0);
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
@@ -47,7 +46,6 @@ function App() {
const handleZoomReset = useCallback(() => {
setTimeScale(20);
- setHorizontalOffset(0);
}, []);
const session = snapshot.session;
@@ -72,8 +70,6 @@ function App() {
diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx
index 332e3ce..777f817 100644
--- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx
+++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/Timeline.tsx
@@ -8,12 +8,12 @@ const ROW_HEIGHT = 28;
const LABEL_WIDTH = 180;
const HEADER_HEIGHT = 32;
const PAD = 4;
+const MIN_BODY_HEIGHT = 200 - HEADER_HEIGHT;
+const RIGHT_PAD = 100;
interface TimelineProps {
session?: BuildSession;
timeScale: number;
- horizontalOffset: number;
- onHorizontalOffsetChange: (offset: number) => void;
onTimeScaleChange: (scale: number) => void;
}
@@ -131,22 +131,22 @@ function drawJob(
job: BuildJob,
startTime: number,
rowY: number,
- viewWidth: number,
- timeScale: number,
- horizontalOffset: number
+ visibleStart: number,
+ visibleEnd: number,
+ timeScale: number
) {
const jobStart = (job.startTime - startTime) / 1000;
const jobEnd = ((job.endTime || Date.now()) - startTime) / 1000;
let jobDuration = jobEnd - jobStart;
if (jobDuration < 0.1) jobDuration = 0.1;
- const x = LABEL_WIDTH + jobStart * timeScale - horizontalOffset;
+ const x = jobStart * timeScale;
const w = Math.max(jobDuration * timeScale, 2);
const y = rowY + PAD;
const h = ROW_HEIGHT - PAD * 2;
- // Clip to visible
- if (x + w < LABEL_WIDTH || x > viewWidth) return;
+ // Clip to the visible viewport range (content coordinates)
+ if (x + w < visibleStart || x > visibleEnd) return;
const color = STATUS_COLORS[job.status] || '#9E9E9E';
ctx.fillStyle = color;
@@ -169,69 +169,166 @@ function drawJob(
}
}
+function setupCanvas(
+ canvas: HTMLCanvasElement,
+ width: number,
+ height: number,
+ dpr: number
+): CanvasRenderingContext2D | undefined {
+ canvas.style.width = width + 'px';
+ canvas.style.height = height + 'px';
+ canvas.width = Math.max(1, Math.round(width * dpr));
+ canvas.height = Math.max(1, Math.round(height * dpr));
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return undefined;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ return ctx;
+}
+
export default function Timeline({
session,
timeScale,
- horizontalOffset,
- onHorizontalOffsetChange,
onTimeScaleChange,
}: TimelineProps) {
- const canvasRef = useRef(null);
const containerRef = useRef(null);
+ const cornerCanvasRef = useRef(null);
+ const headerCanvasRef = useRef(null);
+ const labelsCanvasRef = useRef(null);
+ const bodyCanvasRef = useRef(null);
+ // Parent wrappers that provide the horizontal scroll extent. Their width is
+ // set to the full content width while the canvases inside them are only as
+ // wide as the visible viewport (sticky-positioned).
+ const headerWrapperRef = useRef(null);
+ const bodyWrapperRef = useRef(null);
+
+ // True while the viewport is scrolled to (or near) the right edge, so new
+ // events keep coming into view automatically during a live build.
+ const autoScrollRef = useRef(true);
+ // Tracks the scrollLeft value the canvases were last drawn for, so we can
+ // skip redundant redraws when the scroll event was triggered by our own
+ // auto-scroll inside draw().
+ const lastDrawnScrollLeftRef = useRef(null);
const draw = useCallback(() => {
- const canvas = canvasRef.current;
const container = containerRef.current;
- if (!canvas || !container) return;
-
- const ctx = canvas.getContext('2d');
- if (!ctx) return;
+ const corner = cornerCanvasRef.current;
+ const header = headerCanvasRef.current;
+ const labels = labelsCanvasRef.current;
+ const body = bodyCanvasRef.current;
+ const headerWrapper = headerWrapperRef.current;
+ const bodyWrapper = bodyWrapperRef.current;
+ if (
+ !container ||
+ !corner ||
+ !header ||
+ !labels ||
+ !body ||
+ !headerWrapper ||
+ !bodyWrapper
+ ) {
+ return;
+ }
const dpr = window.devicePixelRatio || 1;
const containerWidth = container.clientWidth;
+ const viewportContentWidth = Math.max(1, containerWidth - LABEL_WIDTH);
const coreRows = session ? getCoreRows(session) : [];
- const canvasHeight = Math.max(200, HEADER_HEIGHT + coreRows.length * ROW_HEIGHT + PAD * 2);
-
- canvas.style.width = containerWidth + 'px';
- canvas.style.height = canvasHeight + 'px';
- canvas.width = containerWidth * dpr;
- canvas.height = canvasHeight * dpr;
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ const bodyHeight = Math.max(MIN_BODY_HEIGHT, coreRows.length * ROW_HEIGHT + PAD * 2);
- // Background
- ctx.fillStyle = '#1E1E1E';
- ctx.fillRect(0, 0, containerWidth, canvasHeight);
+ const startTime = session ? session.startTime : 0;
+ const maxTime = session ? getMaxTime(session) : 0;
+ let totalSeconds = session ? (maxTime - startTime) / 1000 : 1;
+ if (totalSeconds < 1) totalSeconds = 1;
+ const contentWidth = Math.max(
+ viewportContentWidth,
+ Math.ceil(totalSeconds * timeScale + RIGHT_PAD)
+ );
+
+ // The header/body wrappers occupy the full content width so the browser
+ // exposes a horizontal scrollbar that spans the entire timeline. The
+ // canvases inside are sticky and only as wide as the visible viewport,
+ // which keeps their backing-store dimensions well below the browser's
+ // maximum canvas size (~32767 px in Chromium) regardless of zoom.
+ headerWrapper.style.width = contentWidth + 'px';
+ bodyWrapper.style.width = contentWidth + 'px';
+
+ // Apply auto-scroll before reading scrollLeft so we draw the
+ // freshly-scrolled viewport in this same frame.
+ if (autoScrollRef.current) {
+ const desiredScrollLeft = Math.max(0, contentWidth - viewportContentWidth);
+ if (Math.abs(container.scrollLeft - desiredScrollLeft) > 1) {
+ container.scrollLeft = desiredScrollLeft;
+ }
+ }
+ const scrollLeft = container.scrollLeft;
+ const visibleStart = scrollLeft;
+ const visibleEnd = scrollLeft + viewportContentWidth;
+ lastDrawnScrollLeftRef.current = scrollLeft;
+
+ const cornerCtx = setupCanvas(corner, LABEL_WIDTH, HEADER_HEIGHT, dpr);
+ const headerCtx = setupCanvas(header, viewportContentWidth, HEADER_HEIGHT, dpr);
+ const labelsCtx = setupCanvas(labels, LABEL_WIDTH, bodyHeight, dpr);
+ const bodyCtx = setupCanvas(body, viewportContentWidth, bodyHeight, dpr);
+ if (!cornerCtx || !headerCtx || !labelsCtx || !bodyCtx) return;
+
+ // Draw using content coordinates by translating the visible viewport.
+ // Canvas (x = 0) corresponds to content (x = scrollLeft).
+ headerCtx.translate(-scrollLeft, 0);
+ bodyCtx.translate(-scrollLeft, 0);
+
+ // ---- Corner (top-left, fixed) ----
+ cornerCtx.fillStyle = '#2D2D30';
+ cornerCtx.fillRect(0, 0, LABEL_WIDTH, HEADER_HEIGHT);
+ cornerCtx.strokeStyle = '#444';
+ cornerCtx.lineWidth = 1;
+ cornerCtx.beginPath();
+ cornerCtx.moveTo(LABEL_WIDTH - 0.5, 0);
+ cornerCtx.lineTo(LABEL_WIDTH - 0.5, HEADER_HEIGHT);
+ cornerCtx.moveTo(0, HEADER_HEIGHT - 0.5);
+ cornerCtx.lineTo(LABEL_WIDTH, HEADER_HEIGHT - 0.5);
+ cornerCtx.stroke();
+
+ // ---- Header (time axis, sticky top) ----
+ headerCtx.fillStyle = '#2D2D30';
+ headerCtx.fillRect(visibleStart, 0, viewportContentWidth, HEADER_HEIGHT);
+ headerCtx.strokeStyle = '#444';
+ headerCtx.lineWidth = 1;
+ headerCtx.beginPath();
+ headerCtx.moveTo(visibleStart, HEADER_HEIGHT - 0.5);
+ headerCtx.lineTo(visibleEnd, HEADER_HEIGHT - 0.5);
+ headerCtx.stroke();
+
+ // ---- Labels column (sticky left) ----
+ labelsCtx.fillStyle = '#1E1E1E';
+ labelsCtx.fillRect(0, 0, LABEL_WIDTH, bodyHeight);
+
+ // ---- Body (visible viewport only; sticky left) ----
+ bodyCtx.fillStyle = '#1E1E1E';
+ bodyCtx.fillRect(visibleStart, 0, viewportContentWidth, bodyHeight);
if (!session || coreRows.length === 0) {
- ctx.fillStyle = '#888';
- ctx.font = '16px sans-serif';
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- ctx.fillText('Waiting for FASTBuild data...', containerWidth / 2, canvasHeight / 2);
+ bodyCtx.fillStyle = '#888';
+ bodyCtx.font = '16px sans-serif';
+ bodyCtx.textAlign = 'center';
+ bodyCtx.textBaseline = 'middle';
+ bodyCtx.fillText(
+ 'Waiting for FASTBuild data...',
+ visibleStart + viewportContentWidth / 2,
+ bodyHeight / 2
+ );
+ // Labels right border
+ labelsCtx.strokeStyle = '#444';
+ labelsCtx.beginPath();
+ labelsCtx.moveTo(LABEL_WIDTH - 0.5, 0);
+ labelsCtx.lineTo(LABEL_WIDTH - 0.5, bodyHeight);
+ labelsCtx.stroke();
return;
}
- const startTime = session.startTime;
- const maxTime = getMaxTime(session);
- let totalSeconds = (maxTime - startTime) / 1000;
- if (totalSeconds < 1) totalSeconds = 1;
-
- // Auto-scroll
- const totalWidth = LABEL_WIDTH + totalSeconds * timeScale + 100;
- if (totalWidth > containerWidth) {
- const newOffset = totalWidth - containerWidth + 100;
- if (Math.abs(newOffset - horizontalOffset) > 1) {
- onHorizontalOffsetChange(newOffset);
- }
- }
-
- // Time axis header
- ctx.fillStyle = '#2D2D30';
- ctx.fillRect(0, 0, containerWidth, HEADER_HEIGHT);
-
- ctx.font = '10px sans-serif';
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
+ // Time-axis ticks + vertical grid lines (only within visible range)
+ headerCtx.font = '10px sans-serif';
+ headerCtx.textAlign = 'center';
+ headerCtx.textBaseline = 'middle';
let tickInterval = 1;
if (timeScale < 5) tickInterval = 30;
@@ -239,78 +336,81 @@ export default function Timeline({
else if (timeScale < 20) tickInterval = 5;
else if (timeScale < 40) tickInterval = 2;
- for (let t = 0; t <= totalSeconds; t += tickInterval) {
- const x = LABEL_WIDTH + t * timeScale - horizontalOffset;
- if (x < LABEL_WIDTH || x > containerWidth) continue;
-
- ctx.strokeStyle = '#444';
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(x, HEADER_HEIGHT - 8);
- ctx.lineTo(x, HEADER_HEIGHT);
- ctx.stroke();
-
- ctx.fillStyle = '#999';
+ const firstVisibleTick = Math.max(
+ 0,
+ Math.floor(visibleStart / timeScale / tickInterval) * tickInterval
+ );
+ for (let t = firstVisibleTick; t <= totalSeconds; t += tickInterval) {
+ const x = t * timeScale;
+ if (x > visibleEnd) break;
+
+ headerCtx.strokeStyle = '#444';
+ headerCtx.lineWidth = 1;
+ headerCtx.beginPath();
+ headerCtx.moveTo(x, HEADER_HEIGHT - 8);
+ headerCtx.lineTo(x, HEADER_HEIGHT);
+ headerCtx.stroke();
+
+ headerCtx.fillStyle = '#999';
const label = t >= 3600 ? formatHMS(t) : formatMS(t);
- ctx.fillText(label, x, HEADER_HEIGHT / 2);
-
- // Vertical grid line
- ctx.strokeStyle = '#2A2A2A';
- ctx.beginPath();
- ctx.moveTo(x, HEADER_HEIGHT);
- ctx.lineTo(x, canvasHeight);
- ctx.stroke();
+ headerCtx.fillText(label, x, HEADER_HEIGHT / 2);
+
+ bodyCtx.strokeStyle = '#2A2A2A';
+ bodyCtx.lineWidth = 1;
+ bodyCtx.beginPath();
+ bodyCtx.moveTo(x, 0);
+ bodyCtx.lineTo(x, bodyHeight);
+ bodyCtx.stroke();
}
// Core rows (one row per virtual CPU core per host)
for (let i = 0; i < coreRows.length; i++) {
const row = coreRows[i];
- const y = HEADER_HEIGHT + i * ROW_HEIGHT;
+ const y = i * ROW_HEIGHT;
+ const isAlt = i % 2 === 0;
- // Alternate background
- if (i % 2 === 0) {
- ctx.fillStyle = '#252526';
- ctx.fillRect(0, y, containerWidth, ROW_HEIGHT);
+ // Body row background + separator (only the visible slice)
+ if (isAlt) {
+ bodyCtx.fillStyle = '#252526';
+ bodyCtx.fillRect(visibleStart, y, viewportContentWidth, ROW_HEIGHT);
}
+ bodyCtx.strokeStyle = '#333';
+ bodyCtx.lineWidth = 1;
+ bodyCtx.beginPath();
+ bodyCtx.moveTo(visibleStart, y + ROW_HEIGHT - 0.5);
+ bodyCtx.lineTo(visibleEnd, y + ROW_HEIGHT - 0.5);
+ bodyCtx.stroke();
- // Row separator
- ctx.strokeStyle = '#333';
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(0, y + ROW_HEIGHT);
- ctx.lineTo(containerWidth, y + ROW_HEIGHT);
- ctx.stroke();
-
- // Draw jobs scheduled on this core
for (const job of row.jobs) {
- drawJob(ctx, job, startTime, y, containerWidth, timeScale, horizontalOffset);
- }
-
- // Alternate background label
- if (i % 2 === 0) {
- ctx.fillStyle = '#252526';
- } else {
- ctx.fillStyle = '#1E1E1E';
+ drawJob(bodyCtx, job, startTime, y, visibleStart, visibleEnd, timeScale);
}
- ctx.fillRect(0, y, LABEL_WIDTH, ROW_HEIGHT);
- // Core label
- ctx.fillStyle = '#CCC';
- ctx.font = '11px sans-serif';
- ctx.textAlign = 'left';
- ctx.textBaseline = 'middle';
+ // Labels row background + separator + text
+ labelsCtx.fillStyle = isAlt ? '#252526' : '#1E1E1E';
+ labelsCtx.fillRect(0, y, LABEL_WIDTH, ROW_HEIGHT);
+ labelsCtx.strokeStyle = '#333';
+ labelsCtx.lineWidth = 1;
+ labelsCtx.beginPath();
+ labelsCtx.moveTo(0, y + ROW_HEIGHT - 0.5);
+ labelsCtx.lineTo(LABEL_WIDTH, y + ROW_HEIGHT - 0.5);
+ labelsCtx.stroke();
+
+ labelsCtx.fillStyle = '#CCC';
+ labelsCtx.font = '11px sans-serif';
+ labelsCtx.textAlign = 'left';
+ labelsCtx.textBaseline = 'middle';
const label = `${row.hostName} (Core # ${row.coreIndex})`;
- ctx.fillText(label, 4, y + ROW_HEIGHT / 2, LABEL_WIDTH - 8);
+ labelsCtx.fillText(label, 4, y + ROW_HEIGHT / 2, LABEL_WIDTH - 8);
}
- // Label column separator
- ctx.strokeStyle = '#444';
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(LABEL_WIDTH, 0);
- ctx.lineTo(LABEL_WIDTH, canvasHeight);
- ctx.stroke();
- }, [session, timeScale, horizontalOffset, onHorizontalOffsetChange]);
+ // Labels right border
+ labelsCtx.strokeStyle = '#444';
+ labelsCtx.lineWidth = 1;
+ labelsCtx.beginPath();
+ labelsCtx.moveTo(LABEL_WIDTH - 0.5, 0);
+ labelsCtx.lineTo(LABEL_WIDTH - 0.5, bodyHeight);
+ labelsCtx.stroke();
+ }, [session, timeScale]);
useEffect(() => {
draw();
@@ -319,13 +419,30 @@ export default function Timeline({
useEffect(() => {
const container = containerRef.current;
if (!container) return;
-
- // Resize handler
const observer = new ResizeObserver(() => draw());
observer.observe(container);
return () => observer.disconnect();
}, [draw]);
+ // Reset auto-scroll when a new build session starts.
+ useEffect(() => {
+ autoScrollRef.current = true;
+ }, [session?.processId, session?.startTime]);
+
+ const handleScroll = useCallback(
+ (e: React.UIEvent) => {
+ const c = e.currentTarget;
+ const maxScroll = c.scrollWidth - c.clientWidth;
+ autoScrollRef.current = maxScroll <= 0 || c.scrollLeft >= maxScroll - 15;
+ // Skip the redraw if this scroll event was triggered by our own
+ // auto-scroll inside draw() (lastDrawnScrollLeftRef matches).
+ if (lastDrawnScrollLeftRef.current !== c.scrollLeft) {
+ draw();
+ }
+ },
+ [draw]
+ );
+
// Allow mouse wheel zoom on the timeline
const handleWheel = useCallback(
(e: React.WheelEvent) => {
@@ -341,31 +458,36 @@ export default function Timeline({
[timeScale, onTimeScaleChange]
);
- // Tooltip on canvas hover
+ // Tooltip on body-canvas hover. Coordinates are local to the body canvas;
+ // the labels column lives in a separate canvas and is handled independently.
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
- const canvas = canvasRef.current;
- if (!canvas || !session || !session.jobs.length) return;
+ const canvas = bodyCanvasRef.current;
+ const container = containerRef.current;
+ if (!canvas || !container || !session || !session.jobs.length) return;
const rect = canvas.getBoundingClientRect();
- const dpr = window.devicePixelRatio || 1;
- const mx = (e.clientX - rect.left) * dpr;
- const my = (e.clientY - rect.top) * dpr;
+ // The body canvas is sticky and only viewport-wide, so its left edge
+ // is at LABEL_WIDTH from the container. Add scrollLeft to translate
+ // the local canvas x into content coordinates (the same space jobs
+ // are drawn in).
+ const mx = e.clientX - rect.left + container.scrollLeft;
+ const my = e.clientY - rect.top;
const coreRows = getCoreRows(session);
const startTime = session.startTime;
let tooltipJob: BuildJob | undefined = undefined;
for (let i = 0; i < coreRows.length; i++) {
- const yTop = (HEADER_HEIGHT + i * ROW_HEIGHT + PAD) * dpr;
- const yBot = (HEADER_HEIGHT + i * ROW_HEIGHT + ROW_HEIGHT - PAD) * dpr;
+ const yTop = i * ROW_HEIGHT + PAD;
+ const yBot = i * ROW_HEIGHT + ROW_HEIGHT - PAD;
if (my < yTop || my > yBot) continue;
for (const job of coreRows[i].jobs) {
const jobStart = (job.startTime - startTime) / 1000;
const jobEnd = ((job.endTime || Date.now()) - startTime) / 1000;
- const x = (LABEL_WIDTH + jobStart * timeScale - horizontalOffset) * dpr;
- const w = Math.max((jobEnd - jobStart) * timeScale * dpr, 2 * dpr);
+ const x = jobStart * timeScale;
+ const w = Math.max((jobEnd - jobStart) * timeScale, 2);
if (mx >= x && mx <= x + w) {
tooltipJob = job;
break;
@@ -382,12 +504,28 @@ export default function Timeline({
canvas.title = '';
}
},
- [session, timeScale, horizontalOffset]
+ [session, timeScale]
);
return (
-