diff --git a/FASTBuildMonitorVSCode/README.md b/FASTBuildMonitorVSCode/README.md index 65cf989..94f987c 100644 --- a/FASTBuildMonitorVSCode/README.md +++ b/FASTBuildMonitorVSCode/README.md @@ -20,7 +20,7 @@ - **Workers summary** — Monitor footer shows remote worker count, remote cores, and local cores. Core counts reflect peak concurrent job slots observed per host in the current build, matching the timeline, rather than hardware core counts. - **Build statistics** — Total, active, succeeded, cached, and failed counts - **Progress bar** — Overall build progress percentage with automatically computed black/white text colors for the filled and unfilled portions. The label follows the animated fill boundary and theme changes without an added background. -- **Recent jobs list** — Scrollable feed of the latest jobs with status and timing +- **Recent jobs list** — Scrollable feed of the latest jobs with status and timing. - **Theme-aware text colors** — Readable status, statistics, and elapsed-time colors for light and dark themes; high-contrast themes use the theme foreground. Chart colors stay unchanged, and `fbuildMonitor.useLegacyColors` retains the original palette. - **Status bar integration** — See build progress/status at a glance in the VS Code status bar - **Zoom & scroll** — Ctrl+wheel zoom, plus zoom in/out/reset buttons diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css index 8928f8b..d1840b6 100644 --- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css +++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/App.css @@ -424,11 +424,39 @@ body.vscode-dark:not(.legacy-colors) { flex-shrink: 0; } +/* Collapsed: keep only the header so the timeline can use the freed space. */ +.recent-jobs.collapsed { + height: auto; +} + +.recent-jobs-header { + display: flex; + align-items: center; + gap: 4px; + padding: 0 12px 0 4px; + background: var(--toolbar-bg); +} + .recent-jobs h3 { font-size: 13px; - padding: 6px 12px; - background: var(--toolbar-bg); + padding: 6px 0; + color: var(--text); +} + +.recent-jobs-toggle { + width: 20px; + height: 20px; + border: none; + border-radius: 3px; + background: transparent; color: var(--text); + font-size: 13px; + line-height: 20px; + cursor: pointer; +} + +.recent-jobs-toggle:hover { + background: var(--control-bg); } .recent-jobs-list { @@ -436,6 +464,17 @@ body.vscode-dark:not(.legacy-colors) { overflow-y: auto; } +/* Stays visible while scrolling and shares the row grid so columns align + exactly (it lives inside the scroll area, so the scrollbar affects both). */ +.job-header-group { + position: sticky; + top: 0; + z-index: 1; + background: var(--bg-alt); + border-bottom: 1px solid var(--border); +} + +.job-header-row, .job-row { display: grid; grid-template-columns: 1fr 140px 80px 70px; @@ -445,6 +484,38 @@ body.vscode-dark:not(.legacy-colors) { align-items: center; } +.job-sort-button { + display: inline-flex; + align-items: center; + gap: 3px; + max-width: 100%; + padding: 0; + border: none; + background: none; + color: var(--text-dim); + font: inherit; + font-weight: bold; + white-space: nowrap; + cursor: pointer; +} + +.job-sort-button:hover { + color: var(--text-bright); +} + +.job-header[aria-sort='ascending'] .job-sort-button, +.job-header[aria-sort='descending'] .job-sort-button { + color: var(--text); +} + +.job-sort-indicator { + font-size: 8px; +} + +.job-header-duration { + text-align: right; +} + .job-row:nth-child(even) { background: var(--even-row-bg); } diff --git a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/RecentJobs.tsx b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/RecentJobs.tsx index 80cb0ca..2066b57 100644 --- a/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/RecentJobs.tsx +++ b/FASTBuildMonitorVSCode/webview-ui/src/monitor_panel/components/RecentJobs.tsx @@ -1,33 +1,138 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { BuildSession, STATUS_TEXT_COLORS, getDisplayName, formatDuration } from '../types'; +import { useId, useState } from 'react'; +import { BuildJob, BuildSession, STATUS_TEXT_COLORS, getDisplayName, formatDuration } from '../types'; interface RecentJobsProps { session?: BuildSession; } +type SortKey = 'name' | 'host' | 'status' | 'duration'; +type SortDirection = 'ascending' | 'descending'; + +interface SortState { + key: SortKey; + direction: SortDirection; +} + +interface Column { + key: SortKey; + label: string; +} + +const COLUMNS: Column[] = [ + { key: 'name', label: 'File' }, + { key: 'host', label: 'Host' }, + { key: 'status', label: 'Status' }, + { key: 'duration', label: 'Duration' }, +]; + +const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); + +function getDuration(job: BuildJob, now: number): number { + return (job.endTime || now) - job.startTime; +} + +function compareJobs(a: BuildJob, b: BuildJob, key: SortKey, now: number): number { + switch (key) { + case 'name': + return collator.compare(getDisplayName(a.eventName), getDisplayName(b.eventName)); + case 'host': + return collator.compare(a.hostName, b.hostName); + case 'status': + return collator.compare(a.status, b.status); + case 'duration': + return getDuration(a, now) - getDuration(b, now); + } +} + +// Each click on the same column cycles ascending -> descending -> unsorted +// (newest first); clicking a different column starts again at ascending. +function nextSort(current: SortState | undefined, key: SortKey): SortState | undefined { + if (current?.key !== key) return { key, direction: 'ascending' }; + return current.direction === 'ascending' ? { key, direction: 'descending' } : undefined; +} + export default function RecentJobs({ session }: RecentJobsProps) { + const [expanded, setExpanded] = useState(true); + const [sort, setSort] = useState(); + const listId = useId(); + + const now = Date.now(); const recent = session ? session.jobs.slice(-100).reverse() : []; + if (sort) { + const sign = sort.direction === 'ascending' ? 1 : -1; + // Array.prototype.sort is stable, so ties keep newest-first order. + recent.sort((a, b) => sign * compareJobs(a, b, sort.key, now)); + } return ( -
-

Recent Jobs

-
- {recent.map((job, index) => { - const color = STATUS_TEXT_COLORS[job.status]; - const dur = formatDuration((job.endTime || Date.now()) - job.startTime); - return ( -
-
{getDisplayName(job.eventName)}
-
{job.hostName}
-
- {job.status} +
+
+ +

Recent Jobs

+
+
); diff --git a/FASTBuildMonitorVSCode/webview-ui/tests/components.test.tsx b/FASTBuildMonitorVSCode/webview-ui/tests/components.test.tsx index cc8e892..fcb3fb1 100644 --- a/FASTBuildMonitorVSCode/webview-ui/tests/components.test.tsx +++ b/FASTBuildMonitorVSCode/webview-ui/tests/components.test.tsx @@ -101,4 +101,72 @@ describe('recent jobs', () => { rerender(); expect(screen.getByText('6.5s')).toBeInTheDocument(); }); + + it('collapses and expands the list from the header arrow', () => { + render(); + const toggle = screen.getByRole('button', { name: 'Collapse Recent Jobs' }); + const table = screen.getByRole('table', { name: 'Recent Jobs' }); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(toggle).toHaveAttribute('aria-controls', table.id); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(toggle).toHaveAccessibleName('Expand Recent Jobs'); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Recent Jobs' })).toBeInTheDocument(); + fireEvent.click(toggle); + expect(screen.getByRole('table', { name: 'Recent Jobs' })).toBeVisible(); + }); + + function names(container: HTMLElement) { + return [...container.querySelectorAll('.job-row .job-name')].map(el => el.textContent); + } + + it('cycles each column through ascending, descending and newest-first order', () => { + vi.useFakeTimers(); + vi.setSystemTime(20000); + const jobs = [ + job({ eventName: 'src/b10.cpp', hostName: 'worker-2', status: BuildJobStatus.Success, startTime: 1000, endTime: 4000 }), + job({ eventName: 'src/a.cpp', hostName: 'Worker-10', status: BuildJobStatus.Failed, startTime: 2000, endTime: 3000 }), + job({ eventName: 'src/b9.cpp', hostName: 'local', status: BuildJobStatus.Building, startTime: 3000, endTime: undefined }), + ]; + const { container } = render(); + const header = (label: string) => screen.getByRole('columnheader', { name: new RegExp(label) }); + const newest = ['b9.cpp', 'a.cpp', 'b10.cpp']; + expect(names(container)).toEqual(newest); + expect(screen.getAllByRole('columnheader').map(h => h.textContent)) + .toEqual(['File', 'Host', 'Status', 'Duration']); + + fireEvent.click(screen.getByTitle('Sort by File')); + expect(header('File')).toHaveAttribute('aria-sort', 'ascending'); + expect(names(container)).toEqual(['a.cpp', 'b9.cpp', 'b10.cpp']); + fireEvent.click(screen.getByTitle('Sort by File')); + expect(header('File')).toHaveAttribute('aria-sort', 'descending'); + expect(names(container)).toEqual(['b10.cpp', 'b9.cpp', 'a.cpp']); + fireEvent.click(screen.getByTitle('Sort by File')); + expect(header('File')).toHaveAttribute('aria-sort', 'none'); + expect(names(container)).toEqual(newest); + + fireEvent.click(screen.getByTitle('Sort by Host')); + expect(names(container)).toEqual(['b9.cpp', 'b10.cpp', 'a.cpp']); + fireEvent.click(screen.getByTitle('Sort by Status')); + expect(header('Host')).toHaveAttribute('aria-sort', 'none'); + expect(header('Status')).toHaveAttribute('aria-sort', 'ascending'); + expect(names(container)).toEqual(['b9.cpp', 'a.cpp', 'b10.cpp']); + fireEvent.click(screen.getByTitle('Sort by Duration')); + expect(names(container)).toEqual(['a.cpp', 'b10.cpp', 'b9.cpp']); + fireEvent.click(screen.getByTitle('Sort by Duration')); + expect(names(container)).toEqual(['b9.cpp', 'b10.cpp', 'a.cpp']); + expect(jobs.map(j => j.eventName)).toEqual(['src/b10.cpp', 'src/a.cpp', 'src/b9.cpp']); + }); + + it('keeps newest-first order for ties and sorts only the 100 most recent jobs', () => { + const jobs = Array.from({ length: 101 }, (_, i) => job({ + eventName: `src/job-${i}.cpp`, hostName: i === 0 ? 'aaa' : 'same', startTime: 1000 + i, + })); + const { container } = render(); + fireEvent.click(screen.getByTitle('Sort by Host')); + expect(names(container)).toHaveLength(100); + expect(names(container).slice(0, 2)).toEqual(['job-100.cpp', 'job-99.cpp']); + expect(screen.queryByText('aaa')).not.toBeInTheDocument(); + }); });