feat(FN-2648): merge fusion/fn-2648
This commit is contained in:
@@ -53,6 +53,7 @@ import { SECTION_ORDER } from "./state.js";
|
||||
import type { LogEntry } from "./log-ring-buffer.js";
|
||||
import { FUSION_LOGO_LINES, FUSION_LOGO_LARGE_LINES, FUSION_TAGLINE, FUSION_URL, FUSION_VERSION } from "./logo.js";
|
||||
import { useProjects, useTasks } from "./hooks/use-projects.js";
|
||||
import { copyToClipboard } from "./utils.js";
|
||||
|
||||
// ── Format helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -623,7 +624,7 @@ function LogsPanel({
|
||||
function ExpandedLog({ entry, index, total }: { entry: LogEntry; index: number; total: number }) {
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1} width="100%">
|
||||
<Text dimColor>Entry {index + 1}/{total} · [Enter/Esc] close</Text>
|
||||
<Text dimColor>Entry {index + 1}/{total} · [Enter/Esc] close · [c] copy</Text>
|
||||
<Box height={1} />
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text dimColor>Time:</Text>
|
||||
@@ -698,6 +699,7 @@ function HelpOverlay() {
|
||||
["[↑/↓/k/j]", "Navigate list / log entries"],
|
||||
["[Home / G]", "First / last log entry (Logs)"],
|
||||
["[Enter/Space]", "Expand log entry (Logs)"],
|
||||
["[c]", "Copy selected log entry to clipboard (Logs)"],
|
||||
["[w]", "Toggle word wrap (Logs / Files)"],
|
||||
["[f]", "Cycle severity filter (Main, any panel)"],
|
||||
["[Space]", "Toggle boolean (Settings)"],
|
||||
@@ -4049,6 +4051,26 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
||||
controller.setSelectedLogIndex(Math.max(0, filteredEntries.length - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (input === "c" || input === "C") {
|
||||
const target = filteredEntries[state.selectedLogIndex];
|
||||
if (target) {
|
||||
const ts = formatTimestamp(target.timestamp);
|
||||
const prefix = target.prefix ? `[${target.prefix}] ` : "";
|
||||
const text = `${ts} ${target.level.toUpperCase()} ${prefix}${target.message}`;
|
||||
void copyToClipboard(text).then((ok) => {
|
||||
if (ok) {
|
||||
controller.log("Log entry copied to clipboard.", "clipboard");
|
||||
} else {
|
||||
controller.warn(
|
||||
"Clipboard copy failed (no pbcopy/xclip/wl-copy/clip available).",
|
||||
"clipboard",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -342,11 +342,24 @@ export class DashboardTUI {
|
||||
// If the cursor was sitting on the most recent entry (or there were no
|
||||
// entries yet), keep it pinned to the new tail so live logs follow the
|
||||
// latest event — same behavior as `tail -f` or k9s.
|
||||
const beforeCount = this.getFilteredLogEntries().length;
|
||||
const beforeEntries = this.getFilteredLogEntries();
|
||||
const beforeCount = beforeEntries.length;
|
||||
const wasAtTail = beforeCount === 0 || this.selectedLogIndex === beforeCount - 1;
|
||||
// While the user is reading a single entry in expanded mode, pin the
|
||||
// cursor on that entry so streaming logs don't yank the view away.
|
||||
// Track by reference so ring-buffer eviction shifts the index correctly.
|
||||
const pinnedEntry = this.logsExpandedMode ? beforeEntries[this.selectedLogIndex] : undefined;
|
||||
this.logBuffer.push({ ...entry, timestamp: new Date() });
|
||||
const after = this.getFilteredLogEntries();
|
||||
if (wasAtTail) {
|
||||
if (pinnedEntry) {
|
||||
const newIdx = after.indexOf(pinnedEntry);
|
||||
if (newIdx >= 0) {
|
||||
this.selectedLogIndex = newIdx;
|
||||
} else {
|
||||
// Pinned entry was evicted from the ring buffer — fall back to oldest.
|
||||
this.selectedLogIndex = 0;
|
||||
}
|
||||
} else if (wasAtTail) {
|
||||
this.selectedLogIndex = Math.max(0, after.length - 1);
|
||||
} else {
|
||||
this.clampSelectedLogIndex(after);
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export function isTTYAvailable(): boolean {
|
||||
return Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
||||
}
|
||||
|
||||
// Cross-platform clipboard write. Tries the native helper for the current
|
||||
// platform; resolves false if no helper is available or the spawn fails so
|
||||
// callers can surface a sensible error to the user.
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
const candidates: Array<{ cmd: string; args: string[] }> =
|
||||
process.platform === "darwin"
|
||||
? [{ cmd: "pbcopy", args: [] }]
|
||||
: process.platform === "win32"
|
||||
? [{ cmd: "clip", args: [] }]
|
||||
: [
|
||||
{ cmd: "wl-copy", args: [] },
|
||||
{ cmd: "xclip", args: ["-selection", "clipboard"] },
|
||||
{ cmd: "xsel", args: ["--clipboard", "--input"] },
|
||||
];
|
||||
|
||||
for (const { cmd, args } of candidates) {
|
||||
const ok = await new Promise<boolean>((resolve) => {
|
||||
try {
|
||||
const child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
|
||||
child.once("error", () => resolve(false));
|
||||
child.once("close", (code) => resolve(code === 0));
|
||||
child.stdin.end(text);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
if (ok) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -416,6 +416,22 @@
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.settings-inline-link {
|
||||
color: var(--todo);
|
||||
text-decoration: none;
|
||||
transition: text-decoration var(--transition-fast);
|
||||
}
|
||||
|
||||
.settings-inline-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.settings-inline-link:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* === Memory Settings === */
|
||||
.memory-status-message {
|
||||
display: flex;
|
||||
|
||||
@@ -3181,7 +3181,12 @@ export function SettingsModal({
|
||||
/>
|
||||
<small>
|
||||
Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters).{" "}
|
||||
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
href="https://ntfy.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="settings-inline-link"
|
||||
>
|
||||
Learn more about ntfy.sh
|
||||
</a>
|
||||
</small>
|
||||
|
||||
@@ -151,11 +151,9 @@ function getDoneWorkflowRuntimeMs(task: Task): number | null {
|
||||
function formatElapsedDuration(elapsedMs: number): string {
|
||||
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return "";
|
||||
|
||||
if (elapsedMs < 1000) return `${Math.round(elapsedMs)}ms`;
|
||||
if (elapsedMs < 60_000) return "<1m";
|
||||
|
||||
const elapsedSeconds = elapsedMs / 1000;
|
||||
if (elapsedSeconds < 60) return `${elapsedSeconds.toFixed(1)}s`;
|
||||
|
||||
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
|
||||
if (elapsedMinutes < 60) {
|
||||
const remSeconds = Math.round(elapsedSeconds % 60);
|
||||
|
||||
Reference in New Issue
Block a user