feat(dashboard,cli): update-check frequency, GitHub star button, more resizable modals
Update-check frequency (end-to-end) - Add `updateCheckFrequency: "manual" | "on-startup" | "daily" | "weekly"` to GlobalSettings (default: "daily"). - Backend `performUpdateCheck` honors the frequency: TTL = day/week, `manual` returns cache or empty without hitting npm, `on-startup` refreshes once per process lifetime then serves cache. `/refresh` route forces network regardless. - Settings → Updates surfaces a working `<select>` for the cadence, disabled when auto-checks are off entirely. - Tests: 4 new cases covering ttlForFrequency mapping, weekly window, manual semantics, on-startup once-per-process behavior. TUI update notice - Read cached update result synchronously on TUI startup. When an update is available, render a yellow notice line on the splash and a colored ● next to the version in the status bar. Settings modal header polish - Add "Star on GitHub" pill (icon + Star + cached star count from the GitHub API, 1h localStorage TTL) and "Help" button (opens project Discussions). Both link to the Runfusion/Fusion repo. - Star button auto-hides after the user clicks it (intent = star), tracked in localStorage `fusion:github-star-clicked`. - Settings → General gets a "Show Star on GitHub button" checkbox so users can hide it preemptively. New global setting `showGitHubStarButton: boolean` (default true) gates rendering. More resizable modals - Task Detail modal: 85vh default, resize: both, persisted via useModalResizePersist (key `fusion:task-detail-modal-size`). - Quick Chat FAB: full 8-direction resize (4 corners + 4 edges) via pointer-event handlers; persisted to `fusion:quick-chat-size-<projectId>`. Each handle has the right cursor + role="separator" for accessibility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,32 +66,97 @@
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
background: transparent;
|
||||
/* Faint accent line appears on hover to indicate resizability */
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Edge handles */
|
||||
.quick-chat-resize-handle[data-resize-direction="n"] {
|
||||
cursor: ns-resize;
|
||||
cursor: n-resize;
|
||||
top: 0;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.quick-chat-resize-handle[data-resize-direction="w"] {
|
||||
cursor: ew-resize;
|
||||
top: 6px;
|
||||
left: 0;
|
||||
bottom: 6px;
|
||||
.quick-chat-resize-handle[data-resize-direction="s"] {
|
||||
cursor: s-resize;
|
||||
bottom: 0;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.quick-chat-resize-handle[data-resize-direction="e"] {
|
||||
cursor: e-resize;
|
||||
top: 10px;
|
||||
right: 0;
|
||||
bottom: 10px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.quick-chat-resize-handle[data-resize-direction="w"] {
|
||||
cursor: w-resize;
|
||||
top: 10px;
|
||||
left: 0;
|
||||
bottom: 10px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
/* Corner handles */
|
||||
.quick-chat-resize-handle[data-resize-direction="nw"] {
|
||||
cursor: nwse-resize;
|
||||
cursor: nw-resize;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.quick-chat-resize-handle[data-resize-direction="ne"] {
|
||||
cursor: ne-resize;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.quick-chat-resize-handle[data-resize-direction="sw"] {
|
||||
cursor: sw-resize;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.quick-chat-resize-handle[data-resize-direction="se"] {
|
||||
cursor: se-resize;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* Subtle hover accent on edge handles */
|
||||
.quick-chat-resize-handle[data-resize-direction="n"]:hover,
|
||||
.quick-chat-resize-handle[data-resize-direction="s"]:hover {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 30%,
|
||||
color-mix(in srgb, var(--border) 60%, transparent) 50%,
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
.quick-chat-resize-handle[data-resize-direction="e"]:hover,
|
||||
.quick-chat-resize-handle[data-resize-direction="w"]:hover {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 30%,
|
||||
color-mix(in srgb, var(--border) 60%, transparent) 50%,
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
.quick-chat-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -252,7 +252,13 @@ interface PanelSize {
|
||||
height: number;
|
||||
}
|
||||
|
||||
type ResizeDirection = "n" | "w" | "nw";
|
||||
type ResizeDirection = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se";
|
||||
|
||||
/** Offset of the panel anchor relative to the FAB position (right/bottom deltas in px). */
|
||||
interface PanelAnchorOffset {
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
const QUICK_CHAT_DEFAULT_PANEL_SIZE: PanelSize = {
|
||||
width: 320,
|
||||
@@ -438,27 +444,28 @@ function useDraggable(projectId?: string, externalDidDragRef?: React.MutableRefO
|
||||
};
|
||||
}
|
||||
|
||||
function usePanelResize(projectId: string | undefined, panelRight: number, panelBottom: number) {
|
||||
const storageKey = `fusion-quick-chat-size-${projectId || "default"}`;
|
||||
function usePanelResize(projectId: string | undefined, fabRight: number, fabBottom: number) {
|
||||
const storageKey = `fusion:quick-chat-size-${projectId || "default"}`;
|
||||
|
||||
const isDesktopViewport = useCallback(
|
||||
() => typeof window !== "undefined" && window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT,
|
||||
[],
|
||||
);
|
||||
|
||||
/** Clamp width/height given the effective anchor point (right/bottom offsets from viewport edges). */
|
||||
const clampPanelSize = useCallback(
|
||||
(size: PanelSize): PanelSize => {
|
||||
(size: PanelSize, anchorRight: number, anchorBottom: number): PanelSize => {
|
||||
if (typeof window === "undefined") {
|
||||
return size;
|
||||
}
|
||||
|
||||
const maxWidth = Math.max(
|
||||
QUICK_CHAT_MIN_PANEL_SIZE.width,
|
||||
window.innerWidth - panelRight - QUICK_CHAT_VIEWPORT_PADDING,
|
||||
window.innerWidth - anchorRight - QUICK_CHAT_VIEWPORT_PADDING,
|
||||
);
|
||||
const maxHeight = Math.max(
|
||||
QUICK_CHAT_MIN_PANEL_SIZE.height,
|
||||
window.innerHeight - panelBottom - QUICK_CHAT_VIEWPORT_PADDING,
|
||||
window.innerHeight - anchorBottom - QUICK_CHAT_VIEWPORT_PADDING,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -466,121 +473,171 @@ function usePanelResize(projectId: string | undefined, panelRight: number, panel
|
||||
height: Math.max(QUICK_CHAT_MIN_PANEL_SIZE.height, Math.min(maxHeight, size.height)),
|
||||
};
|
||||
},
|
||||
[panelBottom, panelRight],
|
||||
[],
|
||||
);
|
||||
|
||||
const [panelSize, setPanelSize] = useState<PanelSize>(() => {
|
||||
const loadPersistedSize = useCallback((): PanelSize => {
|
||||
if (typeof window === "undefined" || window.innerWidth <= QUICK_CHAT_DESKTOP_BREAKPOINT) {
|
||||
return QUICK_CHAT_DEFAULT_PANEL_SIZE;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawSize = localStorage.getItem(storageKey);
|
||||
if (!rawSize) {
|
||||
return QUICK_CHAT_DEFAULT_PANEL_SIZE;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(rawSize) as Partial<PanelSize>;
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) return QUICK_CHAT_DEFAULT_PANEL_SIZE;
|
||||
const parsed = JSON.parse(raw) as Partial<PanelSize>;
|
||||
if (typeof parsed.width !== "number" || typeof parsed.height !== "number") {
|
||||
return QUICK_CHAT_DEFAULT_PANEL_SIZE;
|
||||
}
|
||||
|
||||
return {
|
||||
width: parsed.width,
|
||||
height: parsed.height,
|
||||
};
|
||||
return { width: parsed.width, height: parsed.height };
|
||||
} catch {
|
||||
return QUICK_CHAT_DEFAULT_PANEL_SIZE;
|
||||
}
|
||||
});
|
||||
}, [storageKey]);
|
||||
|
||||
const [panelSize, setPanelSize] = useState<PanelSize>(loadPersistedSize);
|
||||
|
||||
/**
|
||||
* Anchor offset relative to the FAB position.
|
||||
* When the user drags the south or east handle, we shift the anchor so the
|
||||
* panel top/left edge moves while the opposite edge stays fixed.
|
||||
*/
|
||||
const [anchorOffset, setAnchorOffset] = useState<PanelAnchorOffset>({ right: 0, bottom: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopViewport()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPanelSize((current) => clampPanelSize(current));
|
||||
}, [clampPanelSize, isDesktopViewport]);
|
||||
if (!isDesktopViewport()) return;
|
||||
const effective = { right: fabRight + anchorOffset.right, bottom: fabBottom + anchorOffset.bottom };
|
||||
setPanelSize((current) => clampPanelSize(current, effective.right, effective.bottom));
|
||||
}, [clampPanelSize, isDesktopViewport, fabRight, fabBottom, anchorOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopViewport()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDesktopViewport()) return;
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(panelSize));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
// Ignore storage errors (private mode / quota)
|
||||
}
|
||||
}, [isDesktopViewport, panelSize, storageKey]);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!isDesktopViewport()) {
|
||||
return;
|
||||
}
|
||||
if (!isDesktopViewport()) return;
|
||||
|
||||
const direction = event.currentTarget.dataset.resizeDirection as ResizeDirection | undefined;
|
||||
if (!direction) {
|
||||
return;
|
||||
}
|
||||
if (!direction) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const resizeHandle = event.currentTarget;
|
||||
|
||||
if (typeof resizeHandle.setPointerCapture === "function") {
|
||||
resizeHandle.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
const resizeStart = {
|
||||
const startState = {
|
||||
pointerX: event.clientX,
|
||||
pointerY: event.clientY,
|
||||
width: panelSize.width,
|
||||
height: panelSize.height,
|
||||
anchorRight: anchorOffset.right,
|
||||
anchorBottom: anchorOffset.bottom,
|
||||
};
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
let nextWidth = resizeStart.width;
|
||||
let nextHeight = resizeStart.height;
|
||||
const onPointerMove = (moveEvent: PointerEvent) => {
|
||||
const dx = moveEvent.clientX - startState.pointerX;
|
||||
const dy = moveEvent.clientY - startState.pointerY;
|
||||
|
||||
let nextWidth = startState.width;
|
||||
let nextHeight = startState.height;
|
||||
let nextAnchorRight = startState.anchorRight;
|
||||
let nextAnchorBottom = startState.anchorBottom;
|
||||
|
||||
// West handle: dragging left grows width (panel expands left).
|
||||
if (direction.includes("w")) {
|
||||
nextWidth = resizeStart.width + (resizeStart.pointerX - moveEvent.clientX);
|
||||
nextWidth = startState.width - dx;
|
||||
}
|
||||
|
||||
// East handle: dragging right grows width (panel expands right).
|
||||
// The right anchor must shift leftward (decrease) to keep left edge fixed.
|
||||
if (direction.includes("e")) {
|
||||
const widthDelta = dx;
|
||||
nextWidth = startState.width + widthDelta;
|
||||
nextAnchorRight = startState.anchorRight - widthDelta;
|
||||
}
|
||||
|
||||
// North handle: dragging up grows height (panel expands upward).
|
||||
if (direction.includes("n")) {
|
||||
nextHeight = resizeStart.height + (resizeStart.pointerY - moveEvent.clientY);
|
||||
nextHeight = startState.height - dy;
|
||||
}
|
||||
|
||||
setPanelSize(
|
||||
clampPanelSize({
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
}),
|
||||
// South handle: dragging down grows height (panel expands downward).
|
||||
// The bottom anchor must shift upward (decrease) to keep the top edge fixed.
|
||||
if (direction.includes("s")) {
|
||||
const heightDelta = dy;
|
||||
nextHeight = startState.height + heightDelta;
|
||||
nextAnchorBottom = startState.anchorBottom - heightDelta;
|
||||
}
|
||||
|
||||
// Clamp size against effective anchor position.
|
||||
const effectiveRight = fabRight + nextAnchorRight;
|
||||
const effectiveBottom = fabBottom + nextAnchorBottom;
|
||||
const clamped = clampPanelSize({ width: nextWidth, height: nextHeight }, effectiveRight, effectiveBottom);
|
||||
|
||||
// Also clamp the anchor offsets so the panel doesn't go off-screen.
|
||||
const clampedAnchorRight = Math.max(
|
||||
QUICK_CHAT_VIEWPORT_PADDING - fabRight,
|
||||
Math.min(
|
||||
window.innerWidth - fabRight - QUICK_CHAT_MIN_PANEL_SIZE.width - QUICK_CHAT_VIEWPORT_PADDING,
|
||||
nextAnchorRight,
|
||||
),
|
||||
);
|
||||
const clampedAnchorBottom = Math.max(
|
||||
QUICK_CHAT_VIEWPORT_PADDING - fabBottom,
|
||||
Math.min(
|
||||
window.innerHeight - fabBottom - QUICK_CHAT_MIN_PANEL_SIZE.height - QUICK_CHAT_VIEWPORT_PADDING,
|
||||
nextAnchorBottom,
|
||||
),
|
||||
);
|
||||
|
||||
setPanelSize(clamped);
|
||||
setAnchorOffset({ right: clampedAnchorRight, bottom: clampedAnchorBottom });
|
||||
};
|
||||
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
const onPointerUp = (upEvent: PointerEvent) => {
|
||||
if (typeof resizeHandle.releasePointerCapture === "function") {
|
||||
resizeHandle.releasePointerCapture(upEvent.pointerId);
|
||||
}
|
||||
|
||||
document.body.style.userSelect = "";
|
||||
document.removeEventListener("pointermove", handlePointerMove);
|
||||
document.removeEventListener("pointerup", handlePointerUp);
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
|
||||
// Persist final size.
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ width: panelSize.width, height: panelSize.height }));
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointermove", handlePointerMove);
|
||||
document.addEventListener("pointerup", handlePointerUp);
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
},
|
||||
[clampPanelSize, isDesktopViewport, panelSize.height, panelSize.width],
|
||||
[
|
||||
anchorOffset.bottom,
|
||||
anchorOffset.right,
|
||||
clampPanelSize,
|
||||
fabBottom,
|
||||
fabRight,
|
||||
isDesktopViewport,
|
||||
panelSize.height,
|
||||
panelSize.width,
|
||||
storageKey,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
panelSize,
|
||||
anchorOffset,
|
||||
handleResizeStart,
|
||||
};
|
||||
}
|
||||
@@ -671,7 +728,7 @@ export function QuickChatFAB({
|
||||
|
||||
// Panel stays 60px above FAB (FAB is 48px tall + 12px gap)
|
||||
const panelY = position.y + 60;
|
||||
const { panelSize, handleResizeStart } = usePanelResize(projectId, position.x, panelY);
|
||||
const { panelSize, anchorOffset, handleResizeStart } = usePanelResize(projectId, position.x, panelY);
|
||||
const shouldApplyDesktopPanelSize = typeof window !== "undefined" && window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT;
|
||||
|
||||
// Chat session hook
|
||||
@@ -1214,31 +1271,83 @@ export function QuickChatFAB({
|
||||
ref={panelRef}
|
||||
data-testid="quick-chat-panel"
|
||||
style={{
|
||||
right: position.x,
|
||||
bottom: panelY,
|
||||
right: position.x + anchorOffset.right,
|
||||
bottom: panelY + anchorOffset.bottom,
|
||||
...(shouldApplyDesktopPanelSize ? { width: panelSize.width, height: panelSize.height } : {}),
|
||||
...keyboardPanelStyle,
|
||||
}}
|
||||
>
|
||||
{shouldApplyDesktopPanelSize && (
|
||||
<>
|
||||
{/* Edge handles */}
|
||||
<div
|
||||
className="quick-chat-resize-handle"
|
||||
data-resize-direction="n"
|
||||
data-testid="quick-chat-resize-n"
|
||||
onPointerDown={handleResizeStart}
|
||||
aria-hidden="true"
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize panel from top"
|
||||
/>
|
||||
<div
|
||||
className="quick-chat-resize-handle"
|
||||
data-resize-direction="s"
|
||||
data-testid="quick-chat-resize-s"
|
||||
onPointerDown={handleResizeStart}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize panel from bottom"
|
||||
/>
|
||||
<div
|
||||
className="quick-chat-resize-handle"
|
||||
data-resize-direction="e"
|
||||
data-testid="quick-chat-resize-e"
|
||||
onPointerDown={handleResizeStart}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize panel from right"
|
||||
/>
|
||||
<div
|
||||
className="quick-chat-resize-handle"
|
||||
data-resize-direction="w"
|
||||
data-testid="quick-chat-resize-w"
|
||||
onPointerDown={handleResizeStart}
|
||||
aria-hidden="true"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize panel from left"
|
||||
/>
|
||||
{/* Corner handles */}
|
||||
<div
|
||||
className="quick-chat-resize-handle"
|
||||
data-resize-direction="nw"
|
||||
data-testid="quick-chat-resize-nw"
|
||||
onPointerDown={handleResizeStart}
|
||||
aria-hidden="true"
|
||||
role="separator"
|
||||
aria-label="Resize panel from top-left corner"
|
||||
/>
|
||||
<div
|
||||
className="quick-chat-resize-handle"
|
||||
data-resize-direction="ne"
|
||||
data-testid="quick-chat-resize-ne"
|
||||
onPointerDown={handleResizeStart}
|
||||
role="separator"
|
||||
aria-label="Resize panel from top-right corner"
|
||||
/>
|
||||
<div
|
||||
className="quick-chat-resize-handle"
|
||||
data-resize-direction="sw"
|
||||
data-testid="quick-chat-resize-sw"
|
||||
onPointerDown={handleResizeStart}
|
||||
role="separator"
|
||||
aria-label="Resize panel from bottom-left corner"
|
||||
/>
|
||||
<div
|
||||
className="quick-chat-resize-handle"
|
||||
data-resize-direction="se"
|
||||
data-testid="quick-chat-resize-se"
|
||||
onPointerDown={handleResizeStart}
|
||||
role="separator"
|
||||
aria-label="Resize panel from bottom-right corner"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,60 @@
|
||||
Extracted from styles.css as part of the Sweep-3 CSS extraction effort.
|
||||
Imported by SettingsModal.tsx (and MemoryView.tsx for shared classes). */
|
||||
|
||||
/* === Settings Modal header action buttons (Star on GitHub, Help) === */
|
||||
.settings-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-left: auto;
|
||||
margin-right: var(--space-sm);
|
||||
}
|
||||
|
||||
/* GitHub star button — split-pill layout: [Star half | Count half] */
|
||||
.settings-github-star-btn {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.settings-github-star-btn:hover {
|
||||
border-color: var(--text-muted);
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.settings-github-star-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
border-color: var(--todo);
|
||||
}
|
||||
|
||||
.settings-github-star-btn__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.settings-github-star-btn__count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 9px;
|
||||
border-left: var(--btn-border-width) solid var(--border);
|
||||
background: color-mix(in srgb, var(--surface) 60%, var(--card));
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* === Settings Modal: sizing + resizability === */
|
||||
.settings-modal {
|
||||
width: min(95vw, 1100px);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEvent } from "react";
|
||||
import { Globe, Folder, RefreshCw } from "lucide-react";
|
||||
import { Globe, Folder, RefreshCw, Star, HelpCircle } from "lucide-react";
|
||||
import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
|
||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, activateRemoteProvider, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
|
||||
@@ -24,6 +24,97 @@ import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPr
|
||||
import { appendTokenQuery } from "../auth";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub star count — fetched once per session, cached in localStorage (1 h).
|
||||
// ---------------------------------------------------------------------------
|
||||
const GITHUB_STAR_CACHE_KEY = "fusion_github_star_count";
|
||||
const GITHUB_STAR_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked";
|
||||
|
||||
/**
|
||||
* Has the user already clicked the "Star on GitHub" button at any point in
|
||||
* the past? Used to permanently hide the button afterward — clicking opens
|
||||
* the repo where the actual star happens, so we treat that click as intent
|
||||
* to star and stop nagging.
|
||||
*/
|
||||
function useStarClickedFlag(): [boolean, () => void] {
|
||||
const [clicked, setClicked] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(GITHUB_STAR_CLICKED_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const markClicked = useCallback(() => {
|
||||
setClicked(true);
|
||||
try {
|
||||
localStorage.setItem(GITHUB_STAR_CLICKED_KEY, "true");
|
||||
} catch {
|
||||
// quota / private mode — best-effort
|
||||
}
|
||||
}, []);
|
||||
return [clicked, markClicked];
|
||||
}
|
||||
|
||||
interface StarCache {
|
||||
count: number;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
function useGitHubStarCount(): number | null {
|
||||
const [count, setCount] = useState<number | null>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(GITHUB_STAR_CACHE_KEY);
|
||||
if (raw) {
|
||||
const parsed: StarCache = JSON.parse(raw) as StarCache;
|
||||
if (Date.now() - parsed.fetchedAt < GITHUB_STAR_CACHE_TTL_MS) {
|
||||
return parsed.count;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed cache
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// If we already have a fresh count from the initial state, skip the fetch.
|
||||
try {
|
||||
const raw = localStorage.getItem(GITHUB_STAR_CACHE_KEY);
|
||||
if (raw) {
|
||||
const parsed: StarCache = JSON.parse(raw) as StarCache;
|
||||
if (Date.now() - parsed.fetchedAt < GITHUB_STAR_CACHE_TTL_MS) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
fetch("https://api.github.com/repos/Runfusion/Fusion")
|
||||
.then((res) => {
|
||||
if (!res.ok) return;
|
||||
return res.json() as Promise<{ stargazers_count?: number }>;
|
||||
})
|
||||
.then((data) => {
|
||||
if (data && typeof data.stargazers_count === "number") {
|
||||
const cache: StarCache = { count: data.stargazers_count, fetchedAt: Date.now() };
|
||||
try {
|
||||
localStorage.setItem(GITHUB_STAR_CACHE_KEY, JSON.stringify(cache));
|
||||
} catch {
|
||||
// quota exceeded — just skip
|
||||
}
|
||||
setCount(data.stargazers_count);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Network failure — hide count gracefully, no update
|
||||
});
|
||||
}, []);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings sections configuration.
|
||||
*
|
||||
@@ -235,6 +326,8 @@ export function SettingsModal({
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null);
|
||||
const [updateCheckLoading, setUpdateCheckLoading] = useState(false);
|
||||
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null);
|
||||
const gitHubStarCount = useGitHubStarCount();
|
||||
const [starClicked, markStarClicked] = useStarClickedFlag();
|
||||
const [prefixError, setPrefixError] = useState<string | null>(null);
|
||||
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null);
|
||||
|
||||
@@ -1410,6 +1503,23 @@ export function SettingsModal({
|
||||
</label>
|
||||
<small>Show the floating chat button in the dashboard. Chat is still accessible from the Chat tab in the mobile navigation.</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="showGitHubStarButton" className="checkbox-label">
|
||||
<input
|
||||
id="showGitHubStarButton"
|
||||
type="checkbox"
|
||||
checked={form.showGitHubStarButton !== false}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, showGitHubStarButton: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Show "Star on GitHub" button in Settings header
|
||||
</label>
|
||||
<small>
|
||||
Once you click the Star button it's hidden automatically. Uncheck this to keep
|
||||
it hidden even before clicking.
|
||||
</small>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "global-models": {
|
||||
@@ -1609,8 +1719,37 @@ export function SettingsModal({
|
||||
Check for updates automatically
|
||||
</label>
|
||||
<small>
|
||||
When enabled, Fusion checks npm daily for new versions of{" "}
|
||||
When enabled, Fusion checks npm for new versions of{" "}
|
||||
<code>@runfusion/fusion</code> and shows update notices in the CLI and dashboard.
|
||||
Cadence is governed by the frequency below.
|
||||
</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="updateCheckFrequency">Frequency</label>
|
||||
<select
|
||||
id="updateCheckFrequency"
|
||||
value={form.updateCheckFrequency ?? "daily"}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
updateCheckFrequency: e.target.value as
|
||||
| "manual"
|
||||
| "on-startup"
|
||||
| "daily"
|
||||
| "weekly",
|
||||
}))
|
||||
}
|
||||
disabled={form.updateCheckEnabled === false}
|
||||
>
|
||||
<option value="manual">Manual only — never auto-check</option>
|
||||
<option value="on-startup">On startup — once per server launch</option>
|
||||
<option value="daily">Daily (recommended)</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
<small>
|
||||
Controls how often the dashboard re-fetches the npm registry. The
|
||||
"Check Now" button below always triggers an immediate fetch
|
||||
regardless of this setting.
|
||||
</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@@ -1648,11 +1787,6 @@ export function SettingsModal({
|
||||
</div>
|
||||
<small>Manually check for the latest version right now.</small>
|
||||
</div>
|
||||
<p className="settings-note">
|
||||
Update frequency control (on-startup / daily / weekly) is not yet configurable here
|
||||
— it requires a backend schema addition to <code>GlobalSettings</code>. The toggle
|
||||
above enables or disables the daily automatic check entirely.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4180,6 +4314,42 @@ export function SettingsModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-header-actions">
|
||||
{form.showGitHubStarButton !== false && !starClicked && (
|
||||
<a
|
||||
href="https://github.com/Runfusion/Fusion"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="settings-github-star-btn"
|
||||
aria-label="Star Fusion on GitHub"
|
||||
title="Star Fusion on GitHub"
|
||||
onClick={markStarClicked}
|
||||
>
|
||||
<span className="settings-github-star-btn__action">
|
||||
<Star size={13} aria-hidden="true" />
|
||||
Star
|
||||
</span>
|
||||
{gitHubStarCount !== null && (
|
||||
<span className="settings-github-star-btn__count" aria-label={`${gitHubStarCount.toLocaleString()} stars`}>
|
||||
{gitHubStarCount >= 1000
|
||||
? `${(gitHubStarCount / 1000).toFixed(1)}k`
|
||||
: gitHubStarCount.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/Runfusion/Fusion/discussions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-sm"
|
||||
aria-label="Help and discussions"
|
||||
title="Help and discussions"
|
||||
>
|
||||
<HelpCircle size={13} aria-hidden="true" />
|
||||
Help
|
||||
</a>
|
||||
</div>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
/* === Detail Modal === */
|
||||
.modal.task-detail-modal {
|
||||
width: min(95vw, 800px);
|
||||
max-width: 95vw;
|
||||
min-width: 480px;
|
||||
height: 85vh;
|
||||
min-height: 480px;
|
||||
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - 16px);
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.detail-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "./TaskDetailModal.css";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Bot, X, ChevronDown } from "lucide-react";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue } from "@fusion/core";
|
||||
@@ -371,6 +372,8 @@ export function TaskDetailModal({
|
||||
const [showActionsMenu, setShowActionsMenu] = useState(false);
|
||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||
const actionsMenuRef = useRef<HTMLDivElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useModalResizePersist(modalRef, true, "fusion:task-detail-modal-size");
|
||||
|
||||
// Plugin UI slots for task-detail-tab
|
||||
const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId);
|
||||
@@ -1277,7 +1280,7 @@ export function TaskDetailModal({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick} role="dialog" aria-modal="true">
|
||||
<div className="modal modal-lg" onDragOver={handleDragOver} onDrop={handleDrop}>
|
||||
<div className="modal modal-lg task-detail-modal" ref={modalRef} onDragOver={handleDragOver} onDrop={handleDrop}>
|
||||
<div className="modal-header">
|
||||
<div className="detail-title-row">
|
||||
<span className="detail-id">{task.id}</span>
|
||||
|
||||
@@ -1417,7 +1417,7 @@ describe("QuickChatFAB", () => {
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"fusion-quick-chat-size-proj-123",
|
||||
"fusion:quick-chat-size-proj-123",
|
||||
expect.stringContaining('"width":'),
|
||||
);
|
||||
expect(parseFloat(panel.style.width)).toBeGreaterThan(320);
|
||||
@@ -1425,7 +1425,7 @@ describe("QuickChatFAB", () => {
|
||||
});
|
||||
|
||||
it("restores panel size from localStorage on desktop mount", () => {
|
||||
localStorageMock.store["fusion-quick-chat-size-proj-123"] = JSON.stringify({ width: 470, height: 520 });
|
||||
localStorageMock.store["fusion:quick-chat-size-proj-123"] = JSON.stringify({ width: 470, height: 520 });
|
||||
|
||||
render(<QuickChatFAB addToast={addToast} projectId="proj-123" open={true} />);
|
||||
|
||||
|
||||
@@ -93,6 +93,8 @@ vi.mock("lucide-react", async (importOriginal) => {
|
||||
Globe: () => <span data-testid="icon-globe" />,
|
||||
Folder: () => <span data-testid="icon-folder" />,
|
||||
RefreshCw: ({ className }: { className?: string }) => <span data-testid="icon-refresh" className={className} />,
|
||||
Star: ({ size }: { size?: number }) => <span data-testid="icon-star" style={{ width: size, height: size }} />,
|
||||
HelpCircle: ({ size }: { size?: number }) => <span data-testid="icon-help-circle" style={{ width: size, height: size }} />,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
clearUpdateCheckCache,
|
||||
performUpdateCheck,
|
||||
readCachedUpdateCheck,
|
||||
ttlForFrequency,
|
||||
__resetStartupRefreshFlag,
|
||||
type UpdateCheckResult,
|
||||
} from "../update-check.js";
|
||||
|
||||
@@ -129,6 +131,113 @@ describe("update-check", () => {
|
||||
expect(readCachedUpdateCheck(fusionDir)).toEqual(value);
|
||||
});
|
||||
|
||||
describe("frequency", () => {
|
||||
beforeEach(() => {
|
||||
__resetStartupRefreshFlag();
|
||||
});
|
||||
|
||||
it("ttlForFrequency: maps frequencies to expected windows", () => {
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
expect(ttlForFrequency(undefined)).toBe(day);
|
||||
expect(ttlForFrequency("daily")).toBe(day);
|
||||
expect(ttlForFrequency("weekly")).toBe(7 * day);
|
||||
expect(ttlForFrequency("manual")).toBe(Number.POSITIVE_INFINITY);
|
||||
expect(ttlForFrequency("on-startup")).toBe(Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
it("weekly: serves cache for up to 7 days, refetches on day 8", async () => {
|
||||
// Day-6 cache: weekly should still serve it
|
||||
const cached: UpdateCheckResult = {
|
||||
currentVersion: "0.6.0",
|
||||
latestVersion: "0.7.0",
|
||||
updateAvailable: true,
|
||||
lastChecked: Date.now() - 6 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(cached), "utf-8");
|
||||
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
const result = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "weekly" });
|
||||
expect(result).toEqual(cached);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Day-8 cache: weekly should refetch
|
||||
await writeFile(
|
||||
join(fusionDir, "update-check.json"),
|
||||
JSON.stringify({ ...cached, lastChecked: Date.now() - 8 * 24 * 60 * 60 * 1000 }),
|
||||
"utf-8",
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
json: async () => ({ "dist-tags": { latest: "0.9.0" } }),
|
||||
});
|
||||
const refetched = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "weekly" });
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
expect(refetched.latestVersion).toBe("0.9.0");
|
||||
});
|
||||
|
||||
it("manual: never hits the network unless force=true, returns cached or empty", async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
// No cache → returns synthetic empty result without fetching.
|
||||
const empty = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "manual" });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(empty.latestVersion).toBeNull();
|
||||
expect(empty.updateAvailable).toBe(false);
|
||||
|
||||
// With cache → returns cache without fetching even if "stale" by daily standards.
|
||||
const cached: UpdateCheckResult = {
|
||||
currentVersion: "0.6.0",
|
||||
latestVersion: "0.7.0",
|
||||
updateAvailable: true,
|
||||
lastChecked: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(cached), "utf-8");
|
||||
const fromCache = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "manual" });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(fromCache).toEqual(cached);
|
||||
|
||||
// force=true (used by /update-check/refresh) overrides manual.
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
json: async () => ({ "dist-tags": { latest: "1.0.0" } }),
|
||||
});
|
||||
const forced = await performUpdateCheck(fusionDir, "0.6.0", {
|
||||
frequency: "manual",
|
||||
force: true,
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
expect(forced.latestVersion).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("on-startup: refreshes once per process, then serves cache", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
json: async () => ({ "dist-tags": { latest: "0.8.0" } }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
// First call within the process: hits the network, writes cache.
|
||||
const first = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "on-startup" });
|
||||
expect(first.latestVersion).toBe("0.8.0");
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
|
||||
// Subsequent call: serves the just-written cache, no network.
|
||||
const second = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "on-startup" });
|
||||
expect(second.latestVersion).toBe("0.8.0");
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
|
||||
// Reset the per-process flag (simulates a fresh server boot) → next
|
||||
// call refreshes again.
|
||||
__resetStartupRefreshFlag();
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
json: async () => ({ "dist-tags": { latest: "0.9.0" } }),
|
||||
});
|
||||
const afterReboot = await performUpdateCheck(fusionDir, "0.6.0", { frequency: "on-startup" });
|
||||
expect(afterReboot.latestVersion).toBe("0.9.0");
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("persists fetched results to the cache file", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -41,7 +41,9 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await performUpdateCheck(resolveGlobalDir(), CLI_PACKAGE_VERSION);
|
||||
const result = await performUpdateCheck(resolveGlobalDir(), CLI_PACKAGE_VERSION, {
|
||||
frequency: globalSettings.updateCheckFrequency,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to perform update check");
|
||||
@@ -52,7 +54,11 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
try {
|
||||
const fusionDir = resolveGlobalDir();
|
||||
await clearUpdateCheckCache(fusionDir);
|
||||
const result = await performUpdateCheck(fusionDir, CLI_PACKAGE_VERSION);
|
||||
// Explicit `force: true` so a "manual" frequency setting doesn't short
|
||||
// out the network fetch on the user's deliberate "Check now" click.
|
||||
const result = await performUpdateCheck(fusionDir, CLI_PACKAGE_VERSION, {
|
||||
force: true,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to refresh update check");
|
||||
|
||||
@@ -3,9 +3,13 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const CACHE_FILENAME = "update-check.json";
|
||||
const CHECK_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const REGISTRY_URL = "https://registry.npmjs.org/@runfusion%2Ffusion";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Allowed update-check cadences from GlobalSettings. */
|
||||
export type UpdateCheckFrequency = "manual" | "on-startup" | "daily" | "weekly";
|
||||
|
||||
export type UpdateCheckResult = {
|
||||
currentVersion: string;
|
||||
latestVersion: string | null;
|
||||
@@ -14,6 +18,24 @@ export type UpdateCheckResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cache TTL in ms for the given frequency. Frequencies that don't expire by
|
||||
* elapsed time (`manual`, `on-startup`) return Infinity — those modes rely on
|
||||
* external triggers (the `/refresh` endpoint or a server-startup hook).
|
||||
*/
|
||||
export function ttlForFrequency(frequency: UpdateCheckFrequency | undefined): number {
|
||||
switch (frequency) {
|
||||
case "manual":
|
||||
case "on-startup":
|
||||
return Number.POSITIVE_INFINITY;
|
||||
case "weekly":
|
||||
return 7 * DAY_MS;
|
||||
case "daily":
|
||||
default:
|
||||
return DAY_MS;
|
||||
}
|
||||
}
|
||||
|
||||
function getCachePath(fusionDir: string): string {
|
||||
return join(fusionDir, CACHE_FILENAME);
|
||||
}
|
||||
@@ -54,6 +76,19 @@ function isValidResult(value: unknown): value is UpdateCheckResult {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether we've refreshed the cache during the current process
|
||||
* lifetime. Used to implement `on-startup` frequency: the first /update-check
|
||||
* after server boot bypasses the cache; subsequent calls within the same
|
||||
* process return whatever was just written.
|
||||
*/
|
||||
let hasRefreshedThisProcess = false;
|
||||
|
||||
/** Test-only hook to reset the per-process startup flag. */
|
||||
export function __resetStartupRefreshFlag(): void {
|
||||
hasRefreshedThisProcess = false;
|
||||
}
|
||||
|
||||
export function readCachedUpdateCheck(fusionDir: string): UpdateCheckResult | null {
|
||||
try {
|
||||
const raw = readFileSync(getCachePath(fusionDir), "utf-8");
|
||||
@@ -68,14 +103,47 @@ export async function clearUpdateCheckCache(fusionDir: string): Promise<void> {
|
||||
await rm(getCachePath(fusionDir), { force: true });
|
||||
}
|
||||
|
||||
export async function performUpdateCheck(fusionDir: string, currentVersion: string): Promise<UpdateCheckResult> {
|
||||
export async function performUpdateCheck(
|
||||
fusionDir: string,
|
||||
currentVersion: string,
|
||||
options: { frequency?: UpdateCheckFrequency; force?: boolean } = {},
|
||||
): Promise<UpdateCheckResult> {
|
||||
const now = Date.now();
|
||||
const cached = readCachedUpdateCheck(fusionDir);
|
||||
const ttl = ttlForFrequency(options.frequency);
|
||||
const cacheStillFresh = cached && now - cached.lastChecked < ttl;
|
||||
|
||||
if (cached && now - cached.lastChecked < CHECK_TTL_MS) {
|
||||
// `on-startup`: refresh exactly once per process lifetime; afterwards
|
||||
// serve the freshly-written cache for the rest of the run.
|
||||
if (
|
||||
!options.force &&
|
||||
options.frequency === "on-startup" &&
|
||||
hasRefreshedThisProcess &&
|
||||
cached
|
||||
) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (!options.force && options.frequency !== "on-startup" && cacheStillFresh) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// For `manual`, never go to the network on a regular check — only the
|
||||
// `/update-check/refresh` endpoint (which sets `force: true`) should.
|
||||
// Return whatever's in the cache so the UI can still display the last
|
||||
// known result; if there's nothing cached, return a no-op disabled-style
|
||||
// payload.
|
||||
if (!options.force && options.frequency === "manual") {
|
||||
return (
|
||||
cached ?? {
|
||||
currentVersion,
|
||||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
lastChecked: now,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(REGISTRY_URL);
|
||||
const payload = (await response.json()) as {
|
||||
@@ -97,6 +165,7 @@ export async function performUpdateCheck(fusionDir: string, currentVersion: stri
|
||||
await mkdir(fusionDir, { recursive: true });
|
||||
await writeFile(getCachePath(fusionDir), JSON.stringify(result, null, 2), "utf-8");
|
||||
|
||||
hasRefreshedThisProcess = true;
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
Reference in New Issue
Block a user