feat(nix): show unfree packages warning in UI ref #35

This commit is contained in:
N1C4T
2026-01-13 02:16:29 +04:00
parent 02ac2cf5f9
commit 1cbbdbb08d
4 changed files with 84 additions and 23 deletions

View File

@@ -38,7 +38,9 @@ export default function Home() {
aurAppNames,
isHydrated,
selectedHelper,
setSelectedHelper
setSelectedHelper,
hasUnfreePackages,
unfreeAppNames,
} = useLinuxInit();
// Search state
@@ -349,6 +351,8 @@ export default function Home() {
clearAll={clearAll}
selectedHelper={selectedHelper}
setSelectedHelper={setSelectedHelper}
hasUnfreePackages={hasUnfreePackages}
unfreeAppNames={unfreeAppNames}
/>
</div>
);

View File

@@ -1,8 +1,9 @@
'use client';
import { useState, useRef } from 'react';
import { Check, Copy, X, Download } from 'lucide-react';
import { Check, Copy, X, Download, AlertTriangle } from 'lucide-react';
import { AurDrawerSettings } from './AurDrawerSettings';
import type { DistroId } from '@/lib/data';
interface CommandDrawerProps {
isOpen: boolean;
@@ -21,13 +22,14 @@ interface CommandDrawerProps {
selectedHelper: 'yay' | 'paru';
setSelectedHelper: (helper: 'yay' | 'paru') => void;
distroColor: string;
distroId: DistroId;
// Nix unfree warning
hasUnfreePackages?: boolean;
unfreeAppNames?: string[];
}
/**
* Command drawer that shows the generated install command.
* Acts as a bottom sheet on mobile (swipe to dismiss) and a centered modal on desktop.
* If you're reading this, yes, I did spend way too much time on the animations.
*/
// Command drawer - bottom sheet on mobile, modal on desktop.
// Nix gets special treatment: shows config file instead of terminal command.
export function CommandDrawer({
isOpen,
isClosing,
@@ -44,7 +46,11 @@ export function CommandDrawer({
selectedHelper,
setSelectedHelper,
distroColor,
distroId,
hasUnfreePackages = false,
unfreeAppNames = [],
}: CommandDrawerProps) {
const isNix = distroId === 'nix';
// Swipe-to-dismiss for mobile users who hate tapping tiny X buttons
const [dragOffset, setDragOffset] = useState(0);
const [isDragging, setIsDragging] = useState(false);
@@ -126,7 +132,9 @@ export function CommandDrawer({
<div className="flex items-center gap-2">
<div className="w-1 h-5 rounded-full" style={{ backgroundColor: distroColor }}></div>
<div>
<h3 id="drawer-title" className="text-sm font-bold uppercase tracking-wider text-[var(--text-secondary)]">Terminal Preview</h3>
<h3 id="drawer-title" className="text-sm font-bold uppercase tracking-wider text-[var(--text-secondary)]">
{isNix ? 'Configuration Preview' : 'Terminal Preview'}
</h3>
<p className="text-xs text-[var(--text-muted)] mt-0.5">
{selectedCount} apps selected
</p>
@@ -154,10 +162,25 @@ export function CommandDrawer({
/>
)}
{/* Nix unfree packages warning */}
{isNix && hasUnfreePackages && (
<div className="mb-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" />
<div className="text-sm">
<p className="font-medium text-amber-500">Unfree packages</p>
<p className="text-[var(--text-muted)] mt-1">
{unfreeAppNames.join(', ')} require <code className="px-1 py-0.5 rounded bg-[var(--bg-tertiary)] text-xs">nixpkgs.config.allowUnfree = true</code>
</p>
</div>
</div>
</div>
)}
{/* Terminal preview - where the magic gets displayed */}
<div className="bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] overflow-hidden shadow-sm">
<div className="flex items-center justify-between px-4 py-2 bg-[var(--bg-tertiary)] border-b border-[var(--border-primary)]">
<span className="text-xs font-mono text-[var(--text-muted)]">bash</span>
<span className="text-xs font-mono text-[var(--text-muted)]">{isNix ? 'nix' : 'bash'}</span>
{/* Desktop action buttons */}
<div className="hidden md:flex items-center gap-2">
@@ -166,7 +189,7 @@ export function CommandDrawer({
className="h-8 px-4 flex items-center gap-2 rounded-md hover:bg-[var(--bg-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all text-xs font-medium"
>
<Download className="w-4 h-4" />
<span>Script</span>
<span>{isNix ? 'configuration.nix' : 'Script'}</span>
</button>
<button
onClick={handleCopyAndClose}
@@ -187,7 +210,7 @@ export function CommandDrawer({
<div className="p-4 font-mono text-sm overflow-x-auto bg-[var(--bg-secondary)]">
<div className="flex gap-3">
<span className="select-none shrink-0 font-bold" style={{ color: distroColor }}>$</span>
{!isNix && <span className="select-none shrink-0 font-bold" style={{ color: distroColor }}>$</span>}
<code
className="text-[var(--text-primary)] break-all whitespace-pre-wrap select-text"
style={{

View File

@@ -25,13 +25,12 @@ interface CommandFooterProps {
clearAll: () => void;
selectedHelper: 'yay' | 'paru';
setSelectedHelper: (helper: 'yay' | 'paru') => void;
// Nix unfree
hasUnfreePackages?: boolean;
unfreeAppNames?: string[];
}
/**
* The sticky footer that shows the generated command and action buttons.
* Contains more state than I'd like, but hey, it works.
* Keyboard shortcuts are vim-style because we're not savages.
*/
export function CommandFooter({
command,
selectedCount,
@@ -47,6 +46,8 @@ export function CommandFooter({
clearAll,
selectedHelper,
setSelectedHelper,
hasUnfreePackages,
unfreeAppNames,
}: CommandFooterProps) {
const [copied, setCopied] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -56,7 +57,7 @@ export function CommandFooter({
const { toggle: toggleTheme } = useTheme();
// Track if user has actually interacted - we hide the bar until then.
// Track if user has actually interacted - hide the bar until then.
// Otherwise it just sits there looking sad with "No apps selected".
useEffect(() => {
if (selectedCount !== initialCountRef.current && !hasEverHadSelection) {
@@ -103,11 +104,14 @@ export function CommandFooter({
selectedAppIds: selectedApps,
helper: selectedHelper,
});
const blob = new Blob([script], { type: 'text/x-shellscript' });
const isNix = selectedDistro === 'nix';
const ext = isNix ? 'nix' : 'sh';
const mimeType = isNix ? 'text/plain' : 'text/x-shellscript';
const blob = new Blob([script], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tuxmate-${selectedDistro}.sh`;
a.download = isNix ? 'configuration.nix' : `tuxmate-${selectedDistro}.sh`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
const distroName = distros.find(d => d.id === selectedDistro)?.name || selectedDistro;
@@ -182,6 +186,9 @@ export function CommandFooter({
selectedHelper={selectedHelper}
setSelectedHelper={setSelectedHelper}
distroColor={distroColor}
distroId={selectedDistro}
hasUnfreePackages={hasUnfreePackages}
unfreeAppNames={unfreeAppNames}
/>
{/* Animated footer container - only shows after first selection */}

View File

@@ -3,6 +3,7 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { distros, apps, type DistroId } from '@/lib/data';
import { isAurPackage } from '@/lib/aur';
import { isUnfreePackage } from '@/lib/nixUnfree';
// Re-export for backwards compatibility
export { isAurPackage, AUR_PATTERNS, KNOWN_AUR_PACKAGES } from '@/lib/aur';
@@ -29,6 +30,9 @@ export interface UseLinuxInitReturn {
hasAurPackages: boolean;
aurPackageNames: string[];
aurAppNames: string[];
// Nix unfree specific
hasUnfreePackages: boolean;
unfreeAppNames: string[];
// Hydration state
isHydrated: boolean;
}
@@ -118,6 +122,26 @@ export function useLinuxInit(): UseLinuxInitReturn {
return { hasAur: aurPkgs.length > 0, packages: aurPkgs, appNames: aurAppNames };
}, [selectedDistro, selectedApps]);
// Compute unfree package info for Nix
const unfreePackageInfo = useMemo(() => {
if (selectedDistro !== 'nix') {
return { hasUnfree: false, appNames: [] as string[] };
}
const unfreeAppNames: string[] = [];
selectedApps.forEach(appId => {
const app = apps.find(a => a.id === appId);
if (app) {
const pkg = app.targets['nix'];
if (pkg && isUnfreePackage(pkg)) {
unfreeAppNames.push(app.name);
}
}
});
return { hasUnfree: unfreeAppNames.length > 0, appNames: unfreeAppNames };
}, [selectedDistro, selectedApps]);
const isAppAvailable = useCallback((appId: string): boolean => {
const app = apps.find(a => a.id === appId);
if (!app) return false;
@@ -206,11 +230,11 @@ export function useLinuxInit(): UseLinuxInitReturn {
if (packageNames.length === 0) return '# No packages selected';
// Handle special cases for Nix and Snap
// Nix: show declarative config (no unfree warning in preview - that's in download)
if (selectedDistro === 'nix') {
// installPrefix already ends with 'nixpkgs.' so just join packages with ' nixpkgs.'
const filteredPkgs = packageNames.filter(p => p.trim());
return `${distro.installPrefix}${filteredPkgs.join(' nixpkgs.')}`;
const sortedPkgs = packageNames.filter(p => p.trim()).sort();
const pkgList = sortedPkgs.map(p => ` ${p}`).join('\n');
return `environment.systemPackages = with pkgs; [\n${pkgList}\n];`;
}
if (selectedDistro === 'snap') {
@@ -280,6 +304,9 @@ export function useLinuxInit(): UseLinuxInitReturn {
hasAurPackages: aurPackageInfo.hasAur,
aurPackageNames: aurPackageInfo.packages,
aurAppNames: aurPackageInfo.appNames,
// Nix unfree specific
hasUnfreePackages: unfreePackageInfo.hasUnfree,
unfreeAppNames: unfreePackageInfo.appNames,
// Hydration state
isHydrated: hydrated,
};