|
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"; |
2 | 3 | import { useTranslation, Trans } from "react-i18next"; |
3 | 4 | import { openUrl } from "@tauri-apps/plugin-opener"; |
4 | 5 | import { invoke } from "@tauri-apps/api/core"; |
@@ -26,6 +27,7 @@ import { |
26 | 27 | RotateCcw, |
27 | 28 | ChevronDown, |
28 | 29 | ChevronRight, |
| 30 | + Check, |
29 | 31 | ExternalLink, |
30 | 32 | Activity, |
31 | 33 | Keyboard, |
@@ -665,6 +667,120 @@ const ShortcutsTab = () => { |
665 | 667 | ); |
666 | 668 | }; |
667 | 669 |
|
| 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 | + |
668 | 784 | export const Settings = () => { |
669 | 785 | const { t } = useTranslation(); |
670 | 786 | const { settings, updateSetting } = useSettings(); |
@@ -1759,37 +1875,25 @@ export const Settings = () => { |
1759 | 1875 | )} |
1760 | 1876 |
|
1761 | 1877 | {/* 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} |
1774 | 1890 | 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 | + })()} |
1793 | 1897 | </> |
1794 | 1898 | ) |
1795 | 1899 | } |
|
0 commit comments