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:
@@ -5,7 +5,10 @@
|
||||
* keeps tracking even if the cursor outruns it), and on release snaps to the
|
||||
* nearest left/right edge, remembering its vertical offset (persisted as a
|
||||
* viewport fraction so it survives resizes). A small movement threshold tells
|
||||
* a drag apart from a tap so dragging never opens the panel.
|
||||
* a drag apart from a tap so dragging never opens the panel. The arrow keys
|
||||
* move a focused avatar too (WCAG 2.5.7): ←/→ dock it to an edge, ↑/↓ step it.
|
||||
* It never rests over the fixed bottom status bar; by default it docks
|
||||
* bottom-right just above it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -70,23 +73,28 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { sagiPrefs, type SagiPos } from "./prefs";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
// Avatar footprint + edge gap, in px. SIZE matches SagiAvatar's default size.
|
||||
export const SAGI_SIZE = 68;
|
||||
export const SAGI_MARGIN = 16;
|
||||
const DRAG_THRESHOLD = 5;
|
||||
// The fixed bottom StatusBar (`h-6`); the avatar never rests over it.
|
||||
const STATUS_BAR = 24;
|
||||
// One arrow-key step, in px.
|
||||
const KEY_STEP = 40;
|
||||
|
||||
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
|
||||
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
|
||||
|
||||
function defaultPos(): SagiPos {
|
||||
return { side: "right", y: 0.5 }; // right edge, vertically centered
|
||||
// Bottom-right, above the status bar: a centred dock covered row controls.
|
||||
return { side: "right", y: 1 };
|
||||
}
|
||||
|
||||
/** Resting top-left screen coords for a docked position. */
|
||||
function restingScreen(pos: SagiPos, size: number) {
|
||||
const avail = Math.max(0, vh() - size - 2 * SAGI_MARGIN);
|
||||
const avail = Math.max(0, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
|
||||
const left = pos.side === "left" ? SAGI_MARGIN : vw() - size - SAGI_MARGIN;
|
||||
const top = SAGI_MARGIN + pos.y * avail;
|
||||
return { left, top };
|
||||
@@ -106,6 +114,8 @@ export interface SagiPlacement {
|
||||
onPointerUp: (e: ReactPointerEvent) => void;
|
||||
/** Returns true (once) if a drag just ended, so the click handler can skip. */
|
||||
consumeDrag: () => boolean;
|
||||
/** Arrow keys on the focused avatar: ←/→ dock to an edge, ↑/↓ step. */
|
||||
onKeyDown: (e: ReactKeyboardEvent) => void;
|
||||
}
|
||||
|
||||
/** `size`: the avatar's current footprint (the pet size pref), in px. */
|
||||
@@ -156,7 +166,10 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||||
movedRef.current = true;
|
||||
const left = Math.min(vw() - size - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
|
||||
const top = Math.min(vh() - size - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.top + dy));
|
||||
const top = Math.min(
|
||||
vh() - size - SAGI_MARGIN - STATUS_BAR,
|
||||
Math.max(SAGI_MARGIN, start.top + dy)
|
||||
);
|
||||
liveRef.current = { left, top };
|
||||
setDrag({ left, top });
|
||||
},
|
||||
@@ -174,7 +187,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
if (live) {
|
||||
draggedRef.current = true;
|
||||
const side: "left" | "right" = live.left + size / 2 < vw() / 2 ? "left" : "right";
|
||||
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN);
|
||||
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
|
||||
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
|
||||
const next: SagiPos = { side, y };
|
||||
sagiPrefs.setPos(next);
|
||||
@@ -188,6 +201,25 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
[size]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: ReactKeyboardEvent) => {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
||||
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
|
||||
let next: SagiPos | null = null;
|
||||
if (e.key === "ArrowLeft") next = { ...pos, side: "left" };
|
||||
else if (e.key === "ArrowRight") next = { ...pos, side: "right" };
|
||||
else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
const dy = (e.key === "ArrowUp" ? -KEY_STEP : KEY_STEP) / avail;
|
||||
next = { ...pos, y: Math.min(1, Math.max(0, pos.y + dy)) };
|
||||
}
|
||||
if (!next) return;
|
||||
e.preventDefault();
|
||||
sagiPrefs.setPos(next);
|
||||
setPos(next);
|
||||
},
|
||||
[pos, size]
|
||||
);
|
||||
|
||||
const consumeDrag = useCallback(() => {
|
||||
const was = draggedRef.current;
|
||||
draggedRef.current = false;
|
||||
@@ -205,5 +237,6 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
consumeDrag,
|
||||
onKeyDown,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user