daf5b7be92
- Changes panel: a working file opens in a CodeMirror editor with its diff inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the unchanged lines, Whole file shows them; side by side at 50/50 and full width; a Revert per block; original from HEAD or the branch's merge base (`mergeBase` on GET /git/changes?range=branch) - Every diff: green = line added whole, red = removed whole, blue = edited in place (--diff-mod) - History drawn as a commit graph (#58: `parents` on GET /git/log, --date-order; lib/commitGraph.ts) - Per-workspace never-stage list, drawn orange in the Files tree (--kept); closed Terminal rows past three deleted - Status bar plan quota for Claude (per account) and Codex (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that stays open, Refresh button with a spinner - Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies; compact sidebar navigation and short session times
269 lines
8.7 KiB
TypeScript
269 lines
8.7 KiB
TypeScript
/**
|
|
* @file PetMenu.tsx
|
|
* @description The mascot's right-click menu: hide it, size it, choose the
|
|
* pet it wears — Sagi, a featured pet (downloaded from codex-pets.net into
|
|
* this install on the first pick, `POST /api/pets/featured/:id`), or one of
|
|
* your own (deletable) — upload an image or import a `.codex-pet.zip`, browse
|
|
* codex-pets.net for more, and open the Sagi settings. The choice and the size
|
|
* are viewer preferences (`prefs.ts`); the pets themselves live on the server
|
|
* (`server/lib/pets.js`); the list and its actions are `usePets`, shared
|
|
* with Settings → Appearance.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Check,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Download,
|
|
EyeOff,
|
|
ExternalLink,
|
|
Loader2,
|
|
PackageOpen,
|
|
Settings2,
|
|
Trash2,
|
|
Upload,
|
|
} from "lucide-react";
|
|
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
|
|
import { usePets } from "./usePets";
|
|
import { toast } from "../../lib/toast";
|
|
|
|
const ITEM =
|
|
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary disabled:opacity-50";
|
|
|
|
export function PetMenu({
|
|
pet,
|
|
size,
|
|
onClose,
|
|
onSettings,
|
|
}: {
|
|
pet: PetChoice;
|
|
size: number;
|
|
onClose: () => void;
|
|
onSettings: () => void;
|
|
}) {
|
|
const { t } = useTranslation("settings");
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const imageInput = useRef<HTMLInputElement>(null);
|
|
const bundleInput = useRef<HTMLInputElement>(null);
|
|
const [choosing, setChoosing] = useState(false);
|
|
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
|
|
|
|
// Closes on a press outside the menu, or Escape.
|
|
useEffect(() => {
|
|
const onDown = (e: MouseEvent) => {
|
|
if (!ref.current?.contains(e.target as Node)) onClose();
|
|
};
|
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
|
document.addEventListener("mousedown", onDown);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onDown);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [onClose]);
|
|
|
|
const addFile = (file: File | undefined, bundle: boolean) => file && void add(file, bundle);
|
|
const mark = (id: string) =>
|
|
pet.id === id ? <Check className="ml-auto h-3.5 w-3.5 shrink-0 text-accent" /> : null;
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
role="menu"
|
|
data-testid="pet-menu"
|
|
className="w-72 rounded-xl border border-border bg-surface-2 p-1.5 text-sm shadow-lg"
|
|
>
|
|
<div className="px-2.5 pb-1 pt-0.5 text-xs font-medium text-fg-muted">
|
|
{t("sagi.pet.title")}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className={ITEM}
|
|
onClick={() => {
|
|
sagiPrefs.setEnabled(false);
|
|
toast.info(t("sagi.pet.hiddenToast"));
|
|
onClose();
|
|
}}
|
|
>
|
|
<EyeOff className="h-4 w-4" />
|
|
{t("sagi.pet.hide")}
|
|
</button>
|
|
<label className="block px-2.5 py-1.5">
|
|
<span className="flex items-center justify-between text-sm text-fg-secondary">
|
|
{t("sagi.pet.size")}
|
|
<span className="tabular-nums text-xs text-fg-muted">{size}px</span>
|
|
</span>
|
|
<input
|
|
type="range"
|
|
data-testid="pet-size"
|
|
min={PET_SIZE.min}
|
|
max={PET_SIZE.max}
|
|
value={size}
|
|
onChange={(e) => sagiPrefs.setSize(Number(e.target.value))}
|
|
className="mt-1 w-full accent-[rgb(var(--accent))]"
|
|
/>
|
|
</label>
|
|
<div className="my-1.5 border-t border-border" />
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
aria-expanded={choosing}
|
|
data-testid="pet-choose"
|
|
className={ITEM}
|
|
onClick={() => setChoosing((v) => !v)}
|
|
>
|
|
{choosing ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
{t("sagi.pet.choose")}
|
|
</button>
|
|
{choosing && (
|
|
<div className="max-h-80 overflow-y-auto pl-2">
|
|
<button
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked={pet.id === "sagi"}
|
|
className={ITEM}
|
|
onClick={() => sagiPrefs.setPet({ id: "sagi" })}
|
|
>
|
|
Sagi
|
|
{mark("sagi")}
|
|
</button>
|
|
{list?.featured.map((f) => {
|
|
const have = installed.get(f.id);
|
|
return (
|
|
<button
|
|
key={f.id}
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked={!!have && pet.id === have.id}
|
|
data-testid={`pet-featured-${f.id}`}
|
|
disabled={busy !== null}
|
|
title={t("sagi.pet.featuredBy", { by: f.by })}
|
|
className={ITEM}
|
|
onClick={() => (have ? wear(have) : void install(f.id))}
|
|
>
|
|
<span className="min-w-0 truncate">{f.displayName}</span>
|
|
{busy === f.id ? (
|
|
<Loader2 className="ml-auto h-3.5 w-3.5 shrink-0 animate-spin" />
|
|
) : have ? (
|
|
mark(have.id)
|
|
) : (
|
|
<Download
|
|
aria-label={t("sagi.pet.download")}
|
|
className="ml-auto h-3.5 w-3.5 shrink-0 text-fg-muted"
|
|
/>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
{own.length > 0 && <div className="my-1.5 border-t border-border" />}
|
|
{own.map((p) => (
|
|
<div key={p.id} className="group flex items-center">
|
|
<button
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked={pet.id === p.id}
|
|
data-testid={`pet-own-${p.id}`}
|
|
className={ITEM}
|
|
onClick={() => wear(p)}
|
|
>
|
|
<span className="min-w-0 truncate">{p.displayName}</span>
|
|
{mark(p.id)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid={`pet-delete-${p.id}`}
|
|
aria-label={t("sagi.pet.delete", { name: p.displayName })}
|
|
title={t("sagi.pet.delete", { name: p.displayName })}
|
|
disabled={busy !== null}
|
|
onClick={() => void remove(p)}
|
|
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<div className="my-1.5 border-t border-border" />
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
disabled={busy !== null}
|
|
className={ITEM}
|
|
onClick={() => imageInput.current?.click()}
|
|
>
|
|
{busy === "image" ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Upload className="h-4 w-4" />
|
|
)}
|
|
{t("sagi.pet.upload")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
disabled={busy !== null}
|
|
className={ITEM}
|
|
onClick={() => bundleInput.current?.click()}
|
|
>
|
|
{busy === "bundle" ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<PackageOpen className="h-4 w-4" />
|
|
)}
|
|
{t("sagi.pet.import")}
|
|
</button>
|
|
<a
|
|
href="https://codex-pets.net/"
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
className={`${ITEM} !text-fg-muted`}
|
|
>
|
|
<ExternalLink className="h-4 w-4" />
|
|
{t("sagi.pet.browse")}
|
|
</a>
|
|
</div>
|
|
)}
|
|
<input
|
|
ref={imageInput}
|
|
type="file"
|
|
hidden
|
|
data-testid="pet-image-input"
|
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
|
onChange={(e) => {
|
|
addFile(e.target.files?.[0], false);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
<input
|
|
ref={bundleInput}
|
|
type="file"
|
|
hidden
|
|
data-testid="pet-bundle-input"
|
|
accept=".zip,application/zip"
|
|
onChange={(e) => {
|
|
addFile(e.target.files?.[0], true);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
{error && (
|
|
<p role="alert" className="px-2.5 py-1 text-xs text-status-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
<div className="my-1.5 border-t border-border" />
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className={`${ITEM} !text-fg-muted`}
|
|
onClick={onSettings}
|
|
>
|
|
<Settings2 className="h-4 w-4" />
|
|
{t("sagi.pet.settings")}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|