Add update check functionality
Implement version checking and update notifications in the GUI
This commit is contained in:
+19
-3
@@ -9,8 +9,8 @@ import { Settings } from "./Settings";
|
||||
import { EventCenter } from "./EventCenter";
|
||||
import { maybeNotify } from "./notify";
|
||||
import { COLORS, applyTheme, resolvePalette } from "./theme";
|
||||
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig } from "./socketBridge";
|
||||
import type { EventRecord, DaemonHealth, ConfigView } from "./socketBridge";
|
||||
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig, checkUpdate } from "./socketBridge";
|
||||
import type { EventRecord, DaemonHealth, ConfigView, UpdateInfo } from "./socketBridge";
|
||||
import { leafIds } from "./layoutTypes";
|
||||
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
||||
|
||||
@@ -36,6 +36,8 @@ export function App() {
|
||||
const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true));
|
||||
const [sidebarOpen, setSidebarOpen] = useState(() => loadFlag("spacesh.sidebarOpen", true));
|
||||
const [health, setHealth] = useState<DaemonHealth | null>(null);
|
||||
const [update, setUpdate] = useState<UpdateInfo | null>(null);
|
||||
const [updateChecking, setUpdateChecking] = useState(false);
|
||||
const [config, setConfigState] = useState<ConfigView | null>(null);
|
||||
// Bumped when the daemon connection is re-established; used to remount the
|
||||
// layout so terminals re-attach (snapshot + live stream) to the restarted daemon.
|
||||
@@ -77,6 +79,13 @@ export function App() {
|
||||
catch { setConnected(false); }
|
||||
}, []);
|
||||
|
||||
const runUpdateCheck = useCallback(async () => {
|
||||
setUpdateChecking(true);
|
||||
try { setUpdate(await checkUpdate()); }
|
||||
catch { /* offline / server unreachable — leave the last known result */ }
|
||||
finally { setUpdateChecking(false); }
|
||||
}, []);
|
||||
|
||||
const wsOf = (surfaceId: string): WorkspaceView | undefined =>
|
||||
wsRef.current.find((w) => surfaceId in w.surfaces);
|
||||
|
||||
@@ -142,6 +151,13 @@ export function App() {
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
// Update check: once on launch, then every 6h.
|
||||
useEffect(() => {
|
||||
void runUpdateCheck();
|
||||
const id = setInterval(() => { void runUpdateCheck(); }, 6 * 60 * 60 * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [runUpdateCheck]);
|
||||
|
||||
useEffect(() => { saveFlag("spacesh.eventsOpen", eventsOpen); }, [eventsOpen]);
|
||||
useEffect(() => { saveFlag("spacesh.sidebarOpen", sidebarOpen); }, [sidebarOpen]);
|
||||
|
||||
@@ -162,7 +178,7 @@ export function App() {
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
|
||||
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} />
|
||||
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} update={update} updateChecking={updateChecking} onCheckUpdate={() => { void runUpdateCheck(); }} />
|
||||
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
|
||||
<Sidebar railMode={!sidebarOpen} groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
|
||||
+88
-1
@@ -1,7 +1,10 @@
|
||||
import { FolderGit2, PanelLeft, PanelRight, Search, Bell, Settings, ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { FolderGit2, PanelLeft, PanelRight, Search, Bell, Settings, ChevronDown, RefreshCw, Download } from "lucide-react";
|
||||
import { COLORS, FONT } from "./theme";
|
||||
import type { WorkspaceView } from "./layoutTypes";
|
||||
import { leafIds } from "./layoutTypes";
|
||||
import { openExternal } from "./socketBridge";
|
||||
import type { UpdateInfo } from "./socketBridge";
|
||||
|
||||
/** Human-readable descriptor of the active workspace layout (mock until a real preset id is tracked). */
|
||||
function describeLayout(w: WorkspaceView | null): string {
|
||||
@@ -28,8 +31,83 @@ function IconBtn({ icon, onClick, active, title }: { icon: React.ReactNode; onCl
|
||||
);
|
||||
}
|
||||
|
||||
/** Update-check button (lights up when the server has a newer version) plus its popover. */
|
||||
function UpdateControl({ update, checking, onCheck }: { update: UpdateInfo | null; checking: boolean; onCheck: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasUpdate = !!update?.has_update;
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", display: "flex" }}>
|
||||
<button
|
||||
title={hasUpdate ? `Доступна версия ${update?.latest}` : "Проверить обновления"}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
width: 26, height: 26, borderRadius: 6,
|
||||
background: hasUpdate ? "rgba(52,211,194,0.15)" : open ? COLORS.bgElevated : "transparent",
|
||||
border: `1px solid ${hasUpdate ? COLORS.accent : open ? COLORS.borderSubtle : "transparent"}`,
|
||||
color: hasUpdate ? COLORS.accent : COLORS.textSecondary,
|
||||
boxShadow: hasUpdate ? `0 0 10px rgba(52,211,194,0.5)` : "none",
|
||||
animation: hasUpdate ? "spaceshPulse 2s ease-in-out infinite" : "none",
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={15} style={{ animation: checking ? "spaceshSpin 0.8s linear infinite" : "none" }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
{/* click-away backdrop */}
|
||||
<div onClick={() => setOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 200 }} />
|
||||
<div style={{
|
||||
position: "absolute", top: 32, right: 0, zIndex: 201, width: 240,
|
||||
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderSubtle}`, borderRadius: 8,
|
||||
padding: 12, boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
|
||||
fontFamily: FONT.ui, color: COLORS.textPrimary,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Обновление</div>
|
||||
<div style={{ fontSize: 12, color: COLORS.textSecondary, display: "flex", justifyContent: "space-between" }}>
|
||||
<span>Установлено</span><span style={{ fontFamily: FONT.mono }}>{update?.current ?? "—"}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: COLORS.textSecondary, display: "flex", justifyContent: "space-between", marginTop: 2 }}>
|
||||
<span>На сервере</span><span style={{ fontFamily: FONT.mono }}>{update?.latest || "—"}</span>
|
||||
</div>
|
||||
|
||||
{hasUpdate ? (
|
||||
<button
|
||||
onClick={() => { void openExternal(update!.url); setOpen(false); }}
|
||||
style={{
|
||||
marginTop: 10, width: "100%", height: 30, borderRadius: 6, border: "none", cursor: "pointer",
|
||||
background: COLORS.accent, color: COLORS.bgApp, fontFamily: FONT.ui, fontSize: 13, fontWeight: 600,
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
|
||||
}}
|
||||
>
|
||||
<Download size={14} /> Скачать {update?.latest}
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ marginTop: 10, fontSize: 12, color: COLORS.stDone }}>Установлена последняя версия</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={checking}
|
||||
style={{
|
||||
marginTop: 8, width: "100%", height: 28, borderRadius: 6, cursor: checking ? "default" : "pointer",
|
||||
background: "transparent", border: `1px solid ${COLORS.borderStrong}`, color: COLORS.textSecondary,
|
||||
fontFamily: FONT.ui, fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{checking ? "Проверяю…" : "Проверить снова"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
active, eventsOpen, onToggleEvents, onShowEvents, sidebarOpen, onToggleSidebar, unread, onOpenSettings,
|
||||
update, updateChecking, onCheckUpdate,
|
||||
}: {
|
||||
active: WorkspaceView | null;
|
||||
eventsOpen: boolean;
|
||||
@@ -39,6 +117,9 @@ export function TopBar({
|
||||
onToggleSidebar: () => void;
|
||||
unread: number;
|
||||
onOpenSettings: () => void;
|
||||
update: UpdateInfo | null;
|
||||
updateChecking: boolean;
|
||||
onCheckUpdate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -69,9 +150,15 @@ export function TopBar({
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<style>{`
|
||||
@keyframes spaceshSpin { to { transform: rotate(360deg); } }
|
||||
@keyframes spaceshPulse { 0%,100% { box-shadow: 0 0 6px rgba(52,211,194,0.35); } 50% { box-shadow: 0 0 14px rgba(52,211,194,0.7); } }
|
||||
`}</style>
|
||||
|
||||
{/* Right cluster */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<IconBtn icon={<Search size={16} />} title="Search (mock)" />
|
||||
<UpdateControl update={update} checking={updateChecking} onCheck={onCheckUpdate} />
|
||||
<div style={{ position: "relative", display: "flex" }}>
|
||||
<IconBtn icon={<Bell size={16} />} onClick={onShowEvents} active={eventsOpen} title="Open activity log" />
|
||||
{unread > 0 && (
|
||||
|
||||
@@ -189,6 +189,16 @@ export async function getHealth(): Promise<DaemonHealth> {
|
||||
return await invoke<DaemonHealth>("health");
|
||||
}
|
||||
|
||||
export interface UpdateInfo { current: string; latest: string; has_update: boolean; url: string }
|
||||
|
||||
export async function checkUpdate(): Promise<UpdateInfo> {
|
||||
return await invoke<UpdateInfo>("check_update");
|
||||
}
|
||||
|
||||
export async function openExternal(url: string): Promise<void> {
|
||||
await invoke("open_external", { url });
|
||||
}
|
||||
|
||||
export async function setZoom(workspaceId: string, surfaceId: string | null): Promise<void> {
|
||||
await invoke("set_zoom", { workspaceId, surfaceId });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user