diff --git a/src/process-output-scope.test.ts b/src/process-output-scope.test.ts new file mode 100644 index 0000000..3dcb143 --- /dev/null +++ b/src/process-output-scope.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { aggregateProcessOutput, formatProcessOutputTitle } from "./process-output-scope"; + +describe("aggregateProcessOutput", () => { + test("sorts by timestamp and uses source order as a tie-breaker", () => { + expect( + aggregateProcessOutput([ + { + name: "api", + entries: [ + { timestamp: "2026-06-09T00:00:02.000Z", stream: "stdout", line: "second" }, + { timestamp: "2026-06-09T00:00:01.000Z", stream: "stdout", line: "api first" }, + ], + }, + { + name: "worker", + entries: [ + { timestamp: "2026-06-09T00:00:01.000Z", stream: "stderr", line: "worker first" }, + ], + }, + ]).map((entry) => entry.line), + ).toEqual(["[api] api first", "[worker] worker first", "[api] second"]); + }); + + test("preserves per-source order for identical timestamps", () => { + expect( + aggregateProcessOutput([ + { + name: "api", + entries: [ + { timestamp: "2026-06-09T00:00:01.000Z", stream: "stdout", line: "one" }, + { timestamp: "2026-06-09T00:00:01.000Z", stream: "stdout", line: "two" }, + ], + }, + ]).map((entry) => entry.line), + ).toEqual(["[api] one", "[api] two"]); + }); +}); + +describe("formatProcessOutputTitle", () => { + test("formats aggregate and scoped titles", () => { + expect(formatProcessOutputTitle(null)).toBe("Logs"); + expect(formatProcessOutputTitle("api")).toBe("Logs (api)"); + }); +}); diff --git a/src/process-output-scope.ts b/src/process-output-scope.ts new file mode 100644 index 0000000..bd1a874 --- /dev/null +++ b/src/process-output-scope.ts @@ -0,0 +1,29 @@ +import type { LogEntry } from "./types"; + +export interface ProcessOutputSource { + name: string; + entries: LogEntry[]; +} + +export const aggregateProcessOutput = (sources: ProcessOutputSource[]): LogEntry[] => + sources + .flatMap((source, sourceIndex) => + source.entries.map((entry, entryIndex) => ({ + entry: { + ...entry, + line: `[${source.name}] ${entry.line}`, + }, + sourceIndex, + entryIndex, + })), + ) + .sort((left, right) => { + const timestampOrder = left.entry.timestamp.localeCompare(right.entry.timestamp); + if (timestampOrder !== 0) return timestampOrder; + if (left.sourceIndex !== right.sourceIndex) return left.sourceIndex - right.sourceIndex; + return left.entryIndex - right.entryIndex; + }) + .map((item) => item.entry); + +export const formatProcessOutputTitle = (name: string | null): string => + name ? `Logs (${name})` : "Logs"; diff --git a/src/ui.ts b/src/ui.ts index b748d01..c7f9f1e 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -19,6 +19,7 @@ import { formatDuration, type ProcessMetricsSample, } from "./process-metrics"; +import { aggregateProcessOutput, formatProcessOutputTitle } from "./process-output-scope"; import { getRuntimeStatusView } from "./runtime-status"; import type { ServiceManager, ServiceView } from "./service-manager"; import { formatCommandSpec } from "./shared"; @@ -1500,15 +1501,9 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo }; } - const entries = manager - .getViews() - .flatMap((view) => - view.log.all().map((entry) => ({ - ...entry, - line: `[${view.name}] ${entry.line}`, - })), - ) - .sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + const entries = aggregateProcessOutput( + manager.getViews().map((view) => ({ name: view.name, entries: view.log.all() })), + ); return { entries, @@ -2081,7 +2076,7 @@ export const buildUi = (opts: UiOptions): { teardown: () => void; controls: UiCo logSource === "external" && externalRuntimeManager ? externalRuntimeManager.getSelectedService()?.name : manager.getSelectedView()?.name; - logPanelTitle.content = selectedLogName ? `Logs (${selectedLogName})` : "Logs"; + logPanelTitle.content = formatProcessOutputTitle(selectedLogName ?? null); logPanelTitle.fg = panelTitleColor("logs"); const logsBackground = panelBackgroundColor("logs"); servicePanel.backgroundColor = logsBackground;