Files
fusion/packages/dashboard/app/components/SettingsSyncLog.tsx
Fusion bbd92a1ae1 feat(FN-4227): add clickable file path links across dashboard
- Add reusable file path linkification utilities and FileBrowser context wiring across dashboard modals and log surfaces
- Linkify task detail, review, workflow, chat, activity, agent, dev server, and settings sync outputs so workspace paths open in the file browser
- Preserve inline code styling when wrapping detected paths and extend FileBrowserModal handling for linked navigation
- Cover the new linking behavior with dashboard tests and add a changeset plus AGENTS guidance for the reusable pattern

Fusion-Task-Id: FN-4227
2026-05-12 21:55:43 -07:00

212 lines
6.8 KiB
TypeScript

import React, { useCallback, useMemo, useState } from "react";
import { ChevronDown, Download, Upload } from "lucide-react";
import "./SettingsSyncLog.css";
import { linkifyFilePaths } from "../utils/filePathLinkify";
// ── Types ─────────────────────────────────────────────────────────────────────
export interface SyncLogEntry {
id: string;
timestamp: string;
direction: "push" | "pull";
result: "success" | "conflict" | "error";
nodeId: string;
nodeName: string;
details?: string;
}
interface SettingsSyncLogProps {
/** The node ID this log is for */
nodeId: string;
/** Sync history entries to display — provided by parent */
entries: SyncLogEntry[];
/** Show loading state */
loading?: boolean;
/** When true, hides the node name filter (used when showing log for a single node) */
singleNode?: boolean;
}
// ── Component ────────────────────────────────────────────────────────────────
/**
* Component that displays recent settings sync operations as a chronological list
* with filtering by direction and node name.
*/
export function SettingsSyncLog({
nodeId: _nodeId,
entries,
loading = false,
singleNode = false,
}: SettingsSyncLogProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [directionFilter, setDirectionFilter] = useState<"all" | "push" | "pull">("all");
const [nodeFilter, setNodeFilter] = useState<string>("all");
// Toggle expanded state
const handleToggle = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
// Extract unique node names for filter dropdown
const uniqueNodes = useMemo(() => {
const nodes = new Set<string>();
for (const entry of entries) {
nodes.add(entry.nodeName);
}
return Array.from(nodes).sort();
}, [entries]);
// Filter entries based on current filters
const filteredEntries = useMemo(() => {
let result = [...entries];
// Filter by direction
if (directionFilter !== "all") {
result = result.filter((entry) => entry.direction === directionFilter);
}
// Filter by node name
if (!singleNode && nodeFilter !== "all") {
result = result.filter((entry) => entry.nodeName === nodeFilter);
}
// Sort by timestamp descending (newest first)
result.sort((a, b) => {
const timeA = new Date(a.timestamp).getTime();
const timeB = new Date(b.timestamp).getTime();
return timeB - timeA;
});
return result;
}, [entries, directionFilter, nodeFilter, singleNode]);
// Format timestamp for display
const formatTimestamp = useCallback((isoTimestamp: string): string => {
const date = new Date(isoTimestamp);
return date.toLocaleString();
}, []);
// Get result badge class
const getResultBadgeClass = useCallback((result: SyncLogEntry["result"]): string => {
switch (result) {
case "success":
return "settings-sync-log__badge--success";
case "conflict":
return "settings-sync-log__badge--conflict";
case "error":
return "settings-sync-log__badge--error";
default:
return "";
}
}, []);
// Get result display text
const getResultText = useCallback((result: SyncLogEntry["result"]): string => {
switch (result) {
case "success":
return "Success";
case "conflict":
return "Conflict";
case "error":
return "Error";
default:
return result;
}
}, []);
return (
<div className="settings-sync-log">
<button
className="settings-sync-log__header"
type="button"
onClick={handleToggle}
aria-expanded={isExpanded}
data-testid="settings-sync-log-header"
>
<ChevronDown
size={16}
className={`settings-sync-log__chevron ${isExpanded ? "settings-sync-log__chevron--expanded" : ""}`}
/>
<span>
{entries.length} {entries.length === 1 ? "entry" : "entries"}
</span>
</button>
{isExpanded && (
<>
<div className="settings-sync-log__filters">
<label>
Direction:
<select
value={directionFilter}
onChange={(e) => setDirectionFilter(e.target.value as "all" | "push" | "pull")}
>
<option value="all">All</option>
<option value="push">Push</option>
<option value="pull">Pull</option>
</select>
</label>
{!singleNode && (
<label>
Node:
<select
value={nodeFilter}
onChange={(e) => setNodeFilter(e.target.value)}
>
<option value="all">All Nodes</option>
{uniqueNodes.map((nodeName) => (
<option key={nodeName} value={nodeName}>
{nodeName}
</option>
))}
</select>
</label>
)}
</div>
{loading && entries.length === 0 ? (
<div className="settings-sync-log__empty">Loading...</div>
) : filteredEntries.length === 0 ? (
<div className="settings-sync-log__empty">No sync history available</div>
) : (
<div className="settings-sync-log__list">
{filteredEntries.map((entry) => (
<div key={entry.id} className="settings-sync-log__entry">
<span className="settings-sync-log__entry-timestamp">
{formatTimestamp(entry.timestamp)}
</span>
<span className="settings-sync-log__entry-direction">
{entry.direction === "push" ? (
<Upload size={14} data-testid="upload-icon" />
) : (
<Download size={14} data-testid="download-icon" />
)}
</span>
<span
className={`settings-sync-log__entry-result ${getResultBadgeClass(entry.result)}`}
>
{getResultText(entry.result)}
</span>
{!singleNode && (
<span className="settings-sync-log__entry-node">{entry.nodeName}</span>
)}
{entry.details && (
<span className="settings-sync-log__entry-details" title={entry.details}>
{linkifyFilePaths(entry.details ?? "")}
</span>
)}
</div>
))}
</div>
)}
</>
)}
</div>
);
}