feat(settings): data-safe editors, deep-linked search, Data/About/Cost pages
- EditorModal loads once per path (a parent re-render no longer reloads the file over the user's typing), refuses Save after a failed load, opens a truncated (>256 KB) file read-only, and asks before overwriting a file that changed on disk or could not be re-read. - Settings/Hooks editors unset only keys from the snapshot taken at Edit, warn when the file changed underneath, keep a renamed key's value, and refuse a new key named like a redacted one (env) or a read-only hook event, which used to replace the secret/hooks. - Project scope shows its resolved folder and is disabled inside the plugin cache. Account-profile, pricing-row and pet deletes confirm; two-click actions disarm after 3 s; unsaved drafts ask before closing. - Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and scrolls to + highlights the row; the active config tab is in the URL. - General splits into General / Data / About; Pricing moves to a Cost group with one shared load; config tabs group as Content / Extensions / Config with arrow-key roving; Promise.allSettled per tab. - Data: browser-preferences export/import (allowlisted keys), per-section reset, housekeeping dry-run first, clear-all behind a typed phrase. Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and dry_run returns exactly what the real run would delete. - Alerts can be edited; webhooks/alerts/remote-source failures toast; copies go through copyText; modals get dialog roles and useDialog. - Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of row controls, arrow keys move it, reduced motion honoured; the dead /run launch link goes to the workspace.
This commit is contained in:
@@ -16,7 +16,9 @@
|
||||
* — and the poll waits for the old server to actually go DOWN first, because it
|
||||
* keeps answering while the chain installs. New skills/slash commands still
|
||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||
* background process can do for it.
|
||||
* background process can do for it. Escape, the backdrop and "Not now" close it
|
||||
* for this page load only (never while applying); "Skip this version" is the
|
||||
* one persistent, per-version dismiss.
|
||||
*
|
||||
* ## State sources
|
||||
* - Initial fetch via `api.updates.status()` on mount.
|
||||
@@ -76,11 +78,13 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
|
||||
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
||||
@@ -125,6 +129,10 @@ export function UpdateNotifier() {
|
||||
const { t } = useTranslation("updates");
|
||||
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
||||
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
|
||||
// Closed for THIS page load only (Escape, backdrop, ×, "Not now") — the
|
||||
// persistent per-version dismiss is the explicit "Skip this version".
|
||||
const [closedKey, setClosedKey] = useState<string | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
@@ -192,36 +200,37 @@ export function UpdateNotifier() {
|
||||
status?.latest_release ||
|
||||
status?.remote_sha ||
|
||||
(installed ? `installed:${installed.root}` : null);
|
||||
const show = Boolean(status?.update_available && dismissKey && dismissedSha !== dismissKey);
|
||||
const show = Boolean(
|
||||
status?.update_available &&
|
||||
dismissKey &&
|
||||
dismissedSha !== dismissKey &&
|
||||
closedKey !== dismissKey
|
||||
);
|
||||
const busy = applying || restarting;
|
||||
|
||||
const closeForNow = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
setClosedKey(dismissKey);
|
||||
}, [busy, dismissKey]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (restarting || !dismissKey) return;
|
||||
if (busy || !dismissKey) return;
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, dismissKey);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDismissedSha(dismissKey);
|
||||
}, [restarting, dismissKey]);
|
||||
}, [busy, dismissKey]);
|
||||
|
||||
// Escape to dismiss - standard modal affordance.
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") dismiss();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [show, dismiss]);
|
||||
// Escape closes for now — never while an update is applying.
|
||||
useDialog(show, panelRef, busy ? undefined : closeForNow);
|
||||
|
||||
const copyCmd = async () => {
|
||||
if (!status?.manual_command) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(status.manual_command);
|
||||
if (await copyText(status.manual_command, true)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
@@ -304,17 +313,20 @@ export function UpdateNotifier() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="update-notifier-title"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) dismiss();
|
||||
if (e.target === e.currentTarget) closeForNow();
|
||||
}}
|
||||
>
|
||||
{/* max-w-2xl, not lg: the footer carries up to four buttons and the
|
||||
longest label is a whole sentence ("restarting the dashboard…"). */}
|
||||
<div className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden">
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
@@ -343,8 +355,9 @@ export function UpdateNotifier() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label={t("dismiss")}
|
||||
onClick={closeForNow}
|
||||
disabled={busy}
|
||||
aria-label={t("later")}
|
||||
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
@@ -460,10 +473,19 @@ export function UpdateNotifier() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
disabled={restarting}
|
||||
disabled={busy}
|
||||
title={t("skipVersionHint")}
|
||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("dismiss")}
|
||||
{t("skipVersion")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForNow}
|
||||
disabled={busy}
|
||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("later")}
|
||||
</button>
|
||||
{status.manual_command ? (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user