Skip to content

Commit 9f4f82c

Browse files
committed
feat(settings): add portal-based plugin version dropdown
1 parent a83ec5e commit 9f4f82c

1 file changed

Lines changed: 135 additions & 31 deletions

File tree

src/pages/Settings.tsx

Lines changed: 135 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useState, useEffect, useCallback, type ReactNode } from "react";
1+
import { useState, useEffect, useCallback, useRef, type ReactNode } from "react";
2+
import { createPortal } from "react-dom";
23
import { useTranslation, Trans } from "react-i18next";
34
import { openUrl } from "@tauri-apps/plugin-opener";
45
import { invoke } from "@tauri-apps/api/core";
@@ -26,6 +27,7 @@ import {
2627
RotateCcw,
2728
ChevronDown,
2829
ChevronRight,
30+
Check,
2931
ExternalLink,
3032
Activity,
3133
Keyboard,
@@ -665,6 +667,120 @@ const ShortcutsTab = () => {
665667
);
666668
};
667669

670+
interface VersionOption {
671+
version: string;
672+
isInstalled: boolean;
673+
isLatest: boolean;
674+
}
675+
676+
const VersionDropdown = ({
677+
options,
678+
value,
679+
onChange,
680+
isDowngrade,
681+
label,
682+
}: {
683+
options: VersionOption[];
684+
value: string;
685+
onChange: (v: string) => void;
686+
isDowngrade: boolean;
687+
label: string;
688+
}) => {
689+
const [isOpen, setIsOpen] = useState(false);
690+
const [pos, setPos] = useState({ top: 0, left: 0, minWidth: 0 });
691+
const btnRef = useRef<HTMLButtonElement>(null);
692+
const dropRef = useRef<HTMLDivElement>(null);
693+
694+
const updatePos = () => {
695+
if (btnRef.current) {
696+
const r = btnRef.current.getBoundingClientRect();
697+
setPos({ top: r.bottom + 4, left: r.left, minWidth: Math.max(r.width, 160) });
698+
}
699+
};
700+
701+
useEffect(() => {
702+
if (!isOpen) return;
703+
const onMouseDown = (e: MouseEvent) => {
704+
if (!btnRef.current?.contains(e.target as Node) && !dropRef.current?.contains(e.target as Node)) {
705+
setIsOpen(false);
706+
}
707+
};
708+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setIsOpen(false); };
709+
document.addEventListener("mousedown", onMouseDown);
710+
document.addEventListener("keydown", onKey);
711+
return () => {
712+
document.removeEventListener("mousedown", onMouseDown);
713+
document.removeEventListener("keydown", onKey);
714+
};
715+
}, [isOpen]);
716+
717+
return (
718+
<>
719+
<button
720+
ref={btnRef}
721+
type="button"
722+
onClick={() => { updatePos(); setIsOpen((o) => !o); }}
723+
className={clsx(
724+
"flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-[11px] bg-surface-tertiary transition-colors cursor-pointer select-none",
725+
isDowngrade
726+
? "border-amber-500/30 text-amber-400/80 hover:border-amber-500/60 hover:text-amber-400"
727+
: isOpen
728+
? "border-blue-500/60 text-primary"
729+
: "border-surface-quaternary text-secondary hover:border-blue-500/50 hover:text-primary"
730+
)}
731+
>
732+
<RotateCcw size={9} />
733+
<span>{label}</span>
734+
<ChevronDown size={9} className={clsx("transition-transform duration-150", isOpen && "rotate-180")} />
735+
</button>
736+
737+
{isOpen && createPortal(
738+
<div
739+
ref={dropRef}
740+
style={{ top: pos.top, left: pos.left, minWidth: pos.minWidth }}
741+
className="fixed z-[200] bg-elevated border border-strong rounded-lg shadow-xl overflow-hidden"
742+
>
743+
{options.map((opt) => (
744+
<button
745+
key={opt.version}
746+
type="button"
747+
onClick={() => { onChange(opt.version); setIsOpen(false); }}
748+
className={clsx(
749+
"w-full flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors",
750+
opt.isInstalled
751+
? "bg-green-500/10 hover:bg-green-500/20"
752+
: opt.version === value
753+
? "bg-surface-secondary"
754+
: "hover:bg-surface-secondary"
755+
)}
756+
>
757+
<span className="w-3 shrink-0 flex items-center justify-center">
758+
{opt.isInstalled && <Check size={10} className="text-green-400" />}
759+
</span>
760+
<span className={clsx("font-mono", opt.isInstalled ? "text-green-300" : "text-primary")}>
761+
v{opt.version}
762+
</span>
763+
<span className="ml-auto flex items-center gap-1">
764+
{opt.isInstalled && (
765+
<span className="text-[9px] font-medium bg-green-500/20 text-green-400 px-1.5 py-px rounded">
766+
installed
767+
</span>
768+
)}
769+
{opt.isLatest && (
770+
<span className="text-[9px] font-medium bg-blue-500/20 text-blue-400 px-1.5 py-px rounded">
771+
latest
772+
</span>
773+
)}
774+
</span>
775+
</button>
776+
))}
777+
</div>,
778+
document.body
779+
)}
780+
</>
781+
);
782+
};
783+
668784
export const Settings = () => {
669785
const { t } = useTranslation();
670786
const { settings, updateSetting } = useSettings();
@@ -1759,37 +1875,25 @@ export const Settings = () => {
17591875
)}
17601876

17611877
{/* Version picker — for downgrades when at latest, or between multiple installable versions */}
1762-
{showVersionPicker && (
1763-
<div className="relative inline-flex items-center">
1764-
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-[11px] bg-surface-tertiary transition-colors pointer-events-none select-none ${
1765-
isDowngrade
1766-
? "border-amber-500/30 text-amber-400/80"
1767-
: "border-surface-quaternary text-secondary hover:border-blue-500/50 hover:text-primary"
1768-
}`}>
1769-
<RotateCcw size={9} />
1770-
<span>{isAtLatest && isSelectedInstalled ? t("settings.plugins.olderVersions") : `v${selectedVer}`}</span>
1771-
<ChevronDown size={9} />
1772-
</div>
1773-
<select
1878+
{showVersionPicker && (() => {
1879+
const dropdownOptions: VersionOption[] = [
1880+
...(isAtLatest ? [{ version: plugin.latest_version!, isInstalled: true, isLatest: true }] : []),
1881+
...[...installableReleases].reverse().map((r) => ({
1882+
version: r.version,
1883+
isInstalled: false,
1884+
isLatest: r.version === plugin.latest_version,
1885+
})),
1886+
];
1887+
return (
1888+
<VersionDropdown
1889+
options={dropdownOptions}
17741890
value={selectedVer}
1775-
onChange={(e) =>
1776-
setSelectedVersions((prev) => ({ ...prev, [plugin.id]: e.target.value }))
1777-
}
1778-
className="absolute inset-0 w-full opacity-0 cursor-pointer"
1779-
>
1780-
{isAtLatest && (
1781-
<option value={plugin.latest_version} className="bg-surface-secondary text-primary">
1782-
v{plugin.latest_version} (installed, latest)
1783-
</option>
1784-
)}
1785-
{[...installableReleases].reverse().map((r) => (
1786-
<option key={r.version} value={r.version} className="bg-surface-secondary text-primary">
1787-
v{r.version}{r.version === plugin.latest_version ? " (latest)" : ""}
1788-
</option>
1789-
))}
1790-
</select>
1791-
</div>
1792-
)}
1891+
onChange={(v) => setSelectedVersions((prev) => ({ ...prev, [plugin.id]: v }))}
1892+
isDowngrade={isDowngrade}
1893+
label={isAtLatest && isSelectedInstalled ? t("settings.plugins.olderVersions") : `v${selectedVer}`}
1894+
/>
1895+
);
1896+
})()}
17931897
</>
17941898
)
17951899
}

0 commit comments

Comments
 (0)