From 2ac1917cf60de47a5632a6cc20d36aa3619b1568 Mon Sep 17 00:00:00 2001 From: TriptoAfsin Date: Wed, 15 Oct 2025 12:58:05 +0600 Subject: [PATCH 1/4] feat: implement pinned ports functionality in dashboard; enhance port management with migration from custom ports, improved filtering, and UI updates for better user experience --- .claude/settings.local.json | 6 +- src/App.tsx | 188 ++++++++++++++++------ src/components/dashboard/PortListItem.tsx | 12 +- src/components/dashboard/PortSettings.tsx | 56 ++++--- src/components/dashboard/SearchBar.tsx | 10 +- src/hooks/usePorts.ts | 27 ++-- 6 files changed, 214 insertions(+), 85 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a517571..0959ce3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -20,7 +20,11 @@ "Bash(git restore:*)", "Bash(git tag:*)", "Bash(git push:*)", - "Bash(git commit:*)" + "Bash(git commit:*)", + "Bash(npm run dev)", + "Bash(npm run tauri:*)", + "Bash(timeout:*)", + "mcp__ide__getDiagnostics" ], "deny": [], "ask": [] diff --git a/src/App.tsx b/src/App.tsx index f0e57eb..f67bd2a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,9 +9,10 @@ import { StatsCard } from './components/dashboard/StatsCard'; import { PortListItem } from './components/dashboard/PortListItem'; import { StatusFilter } from './components/dashboard/StatusFilter'; import { PortScanLoader } from './components/dashboard/PortScanLoader'; -import { useCommonPorts, useAllPorts, useRefreshPorts } from './hooks/usePorts'; +import { usePinnedPorts, useAllPorts, useRefreshPorts } from './hooks/usePorts'; import { killProcess, isElevated } from './lib/tauri'; import { Button } from './components/ui/button'; +import { Port } from './types/api'; const queryClient = new QueryClient(); @@ -23,13 +24,59 @@ function AppContent() { new Set(['free', 'occupied', 'system']) ); - const { data: commonPorts = [], isLoading: isLoadingCommon, isRefetching: isRefetchingCommon } = useCommonPorts(); + const { data: pinnedPorts = [], isLoading: isLoadingPinned, isRefetching: isRefetchingPinned } = usePinnedPorts(); const { data: allPorts = [], isLoading: isLoadingAll, isRefetching: isRefetchingAll } = useAllPorts(); const { refreshPorts } = useRefreshPorts(); - const ports = showAllPorts ? allPorts : commonPorts; - const isLoading = showAllPorts ? isLoadingAll : isLoadingCommon; - const isRefetching = showAllPorts ? isRefetchingAll : isRefetchingCommon; + // Get pinned port numbers for checking + const [pinnedPortNumbers, setPinnedPortNumbers] = useState>(new Set()); + + useEffect(() => { + // Migrate from old key if needed + const oldSaved = localStorage.getItem('porter-custom-ports'); + if (oldSaved && !localStorage.getItem('porter-pinned-ports')) { + localStorage.setItem('porter-pinned-ports', oldSaved); + localStorage.removeItem('porter-custom-ports'); + } + + const saved = localStorage.getItem('porter-pinned-ports'); + if (saved) { + try { + const ports = JSON.parse(saved); + setPinnedPortNumbers(new Set(ports)); + } catch (e) { + console.error('Failed to load pinned ports:', e); + } + } + + const handlePortsChange = (e: CustomEvent) => { + setPinnedPortNumbers(new Set(e.detail)); + }; + + window.addEventListener('pinned-ports-changed', handlePortsChange as EventListener); + return () => { + window.removeEventListener('pinned-ports-changed', handlePortsChange as EventListener); + }; + }, []); + + // Separate pinned and other ports from all ports + const { pinnedPortsList, otherPortsList } = useMemo(() => { + const pinned: Port[] = []; + const other: Port[] = []; + + allPorts.forEach(port => { + if (pinnedPortNumbers.has(port.port)) { + pinned.push(port); + } else { + other.push(port); + } + }); + + return { pinnedPortsList: pinned, otherPortsList: other }; + }, [allPorts, pinnedPortNumbers]); + + const isLoading = isLoadingPinned || isLoadingAll; + const isRefetching = isRefetchingPinned || isRefetchingAll; const handleStatusToggle = (status: string) => { setSelectedStatuses((prev) => { @@ -57,26 +104,29 @@ function AppContent() { checkElevation(); }, []); - const filteredPorts = useMemo(() => { - let filtered = ports; - - // Filter by status - filtered = filtered.filter((port) => selectedStatuses.has(port.status)); - - // Filter by search query - if (searchQuery) { - const query = searchQuery.toLowerCase(); - filtered = filtered.filter((port) => { - return ( - port.port.toString().includes(query) || - port.status.toLowerCase().includes(query) || - port.process?.name.toLowerCase().includes(query) - ); - }); - } + // Filter pinned and other ports separately + const { filteredPinnedPorts, filteredOtherPorts } = useMemo(() => { + const applyFilters = (ports: Port[]) => { + let filtered = ports; + + // Filter by status + filtered = filtered.filter((port) => selectedStatuses.has(port.status)); - return filtered; - }, [ports, searchQuery, selectedStatuses]); + // Filter by search query + if (searchQuery) { + filtered = filtered.filter((port) => { + return port.port.toString().includes(searchQuery); + }); + } + + return filtered; + }; + + return { + filteredPinnedPorts: applyFilters(pinnedPortsList), + filteredOtherPorts: applyFilters(otherPortsList) + }; + }, [pinnedPortsList, otherPortsList, searchQuery, selectedStatuses]); const handleKillProcess = async (pid: number) => { if (!isAdmin) { @@ -102,21 +152,22 @@ function AppContent() { }; const stats = useMemo(() => { - const free = ports.filter(p => p.status === 'free').length; - const occupied = ports.filter(p => p.status === 'occupied').length; - const system = ports.filter(p => p.status === 'system').length; + const allDisplayedPorts = [...pinnedPortsList, ...otherPortsList]; + const free = allDisplayedPorts.filter(p => p.status === 'free').length; + const occupied = allDisplayedPorts.filter(p => p.status === 'occupied').length; + const system = allDisplayedPorts.filter(p => p.status === 'system').length; return { free, occupied, system }; - }, [ports]); + }, [pinnedPortsList, otherPortsList]); return ( -
+
{/* Sticky Header */}
-
-
+
+
{/* Fixed Top Section */} -
+
{/* Admin Warning */} {!isAdmin && } @@ -143,34 +194,69 @@ function AppContent() { {/* Port List Header */}

- {showAllPorts ? 'All Running Ports' : 'Common Developer Ports'} - {searchQuery && ` (${filteredPorts.length} results)`} + 📌 Pinned Ports + {searchQuery && ` (${filteredPinnedPorts.length + filteredOtherPorts.length} results)`}

-
{/* Scrollable Port List */} -
+
{isLoading ? ( ) : ( -
- {filteredPorts.map((port) => ( - - ))} +
+ {/* When searching, show all results together */} + {searchQuery ? ( + <> + {[...filteredPinnedPorts, ...filteredOtherPorts].map((port) => ( + + ))} + + ) : ( + <> + {/* Pinned Ports Section */} + {filteredPinnedPorts.map((port) => ( + + ))} + + {/* Divider and Show Other Ports Button */} + {!searchQuery && filteredOtherPorts.length > 0 && ( +
+
+ +
+ )} + + {/* Other Ports Section (shown when button clicked) */} + {showAllPorts && filteredOtherPorts.map((port) => ( + + ))} + + )}
)} diff --git a/src/components/dashboard/PortListItem.tsx b/src/components/dashboard/PortListItem.tsx index 47e6548..6d79b77 100644 --- a/src/components/dashboard/PortListItem.tsx +++ b/src/components/dashboard/PortListItem.tsx @@ -1,4 +1,4 @@ -import { Trash2 } from 'lucide-react'; +import { Trash2, Pin } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Port } from '@/types/api'; @@ -18,9 +18,10 @@ import { interface PortListItemProps { port: Port; onKill: (pid: number) => void; + isPinned?: boolean; } -export function PortListItem({ port, onKill }: PortListItemProps) { +export function PortListItem({ port, onKill, isPinned = false }: PortListItemProps) { const isOccupied = port.status === 'occupied'; const portType = getPortTypeInfo(port.port); const PortIcon = portType.icon; @@ -48,6 +49,13 @@ export function PortListItem({ port, onKill }: PortListItemProps) { className={`rounded-lg border ${statusColors[port.status]} p-3 flex items-center justify-between hover:bg-accent/5 transition-colors`} >
+ {/* Pin Indicator */} + {isPinned && ( +
+ +
+ )} + {/* Port Icon */}
diff --git a/src/components/dashboard/PortSettings.tsx b/src/components/dashboard/PortSettings.tsx index 9a51149..4dc46bb 100644 --- a/src/components/dashboard/PortSettings.tsx +++ b/src/components/dashboard/PortSettings.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Settings, X, Plus } from 'lucide-react'; +import { Settings, X, Plus, Pin } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -7,48 +7,60 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -const DEFAULT_PORTS = [ - 3000, 3001, 4200, 5000, 5173, 8000, 8080, 8888, 9000, 9090, - 80, 443, 5432, 3306, 6379, 27017, 5672, 15672, 11211, 5984 +const DEFAULT_PINNED_PORTS = [ + 3000, 5173, 8080, 5432, 27017 ]; +const MAX_PINNED_PORTS = 10; + export function PortSettings() { - const [ports, setPorts] = useState(DEFAULT_PORTS); + const [pinnedPorts, setPinnedPorts] = useState(DEFAULT_PINNED_PORTS); const [newPort, setNewPort] = useState(''); const [isOpen, setIsOpen] = useState(false); useEffect(() => { - const saved = localStorage.getItem('porter-custom-ports'); + // Migrate from old key if needed + const oldSaved = localStorage.getItem('porter-custom-ports'); + if (oldSaved && !localStorage.getItem('porter-pinned-ports')) { + localStorage.setItem('porter-pinned-ports', oldSaved); + localStorage.removeItem('porter-custom-ports'); + } + + const saved = localStorage.getItem('porter-pinned-ports'); if (saved) { try { - setPorts(JSON.parse(saved)); + setPinnedPorts(JSON.parse(saved)); } catch (e) { - console.error('Failed to load custom ports:', e); + console.error('Failed to load pinned ports:', e); } } }, []); - const savePorts = (newPorts: number[]) => { - setPorts(newPorts); - localStorage.setItem('porter-custom-ports', JSON.stringify(newPorts)); + const savePinnedPorts = (newPorts: number[]) => { + setPinnedPorts(newPorts); + localStorage.setItem('porter-pinned-ports', JSON.stringify(newPorts)); // Trigger a custom event to notify the app - window.dispatchEvent(new CustomEvent('ports-config-changed', { detail: newPorts })); + window.dispatchEvent(new CustomEvent('pinned-ports-changed', { detail: newPorts })); }; const addPort = () => { const port = parseInt(newPort); - if (!isNaN(port) && port > 0 && port < 65536 && !ports.includes(port)) { - savePorts([...ports, port].sort((a, b) => a - b)); + if (!isNaN(port) && port > 0 && port < 65536 && !pinnedPorts.includes(port)) { + if (pinnedPorts.length >= MAX_PINNED_PORTS) { + alert(`You can only pin up to ${MAX_PINNED_PORTS} ports.`); + return; + } + savePinnedPorts([...pinnedPorts, port].sort((a, b) => a - b)); setNewPort(''); } }; const removePort = (port: number) => { - savePorts(ports.filter(p => p !== port)); + savePinnedPorts(pinnedPorts.filter(p => p !== port)); }; const resetToDefaults = () => { - savePorts(DEFAULT_PORTS); + savePinnedPorts(DEFAULT_PINNED_PORTS); }; return ( @@ -61,7 +73,10 @@ export function PortSettings() {
-

Port Configuration

+
+ +

Pinned Ports

+

- {ports.length} port{ports.length !== 1 ? 's' : ''} configured + {pinnedPorts.length} / {MAX_PINNED_PORTS} port{pinnedPorts.length !== 1 ? 's' : ''} pinned

diff --git a/src/components/dashboard/SearchBar.tsx b/src/components/dashboard/SearchBar.tsx index f25630e..29558dc 100644 --- a/src/components/dashboard/SearchBar.tsx +++ b/src/components/dashboard/SearchBar.tsx @@ -8,6 +8,14 @@ interface SearchBarProps { } export function SearchBar({ value, onChange, placeholder = 'Search ports...' }: SearchBarProps) { + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + // Only allow numbers + if (newValue === '' || /^\d*$/.test(newValue)) { + onChange(newValue); + } + }; + return (
@@ -15,7 +23,7 @@ export function SearchBar({ value, onChange, placeholder = 'Search ports...' }: type="text" placeholder={placeholder} value={value} - onChange={(e) => onChange(e.target.value)} + onChange={handleChange} className="pl-10" />
diff --git a/src/hooks/usePorts.ts b/src/hooks/usePorts.ts index fa11b0a..6f91e13 100644 --- a/src/hooks/usePorts.ts +++ b/src/hooks/usePorts.ts @@ -2,17 +2,24 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useState, useEffect } from 'react'; import { getCommonPorts, getActivePorts } from '@/lib/tauri'; -export function useCommonPorts(refreshInterval: number = 2000) { - const [customPorts, setCustomPorts] = useState(undefined); +export function usePinnedPorts(refreshInterval: number = 2000) { + const [pinnedPorts, setPinnedPorts] = useState(undefined); useEffect(() => { const loadPorts = () => { - const saved = localStorage.getItem('porter-custom-ports'); + // Migrate from old key if needed + const oldSaved = localStorage.getItem('porter-custom-ports'); + if (oldSaved && !localStorage.getItem('porter-pinned-ports')) { + localStorage.setItem('porter-pinned-ports', oldSaved); + localStorage.removeItem('porter-custom-ports'); + } + + const saved = localStorage.getItem('porter-pinned-ports'); if (saved) { try { - setCustomPorts(JSON.parse(saved)); + setPinnedPorts(JSON.parse(saved)); } catch (e) { - console.error('Failed to load custom ports:', e); + console.error('Failed to load pinned ports:', e); } } }; @@ -20,18 +27,18 @@ export function useCommonPorts(refreshInterval: number = 2000) { loadPorts(); const handlePortsChange = (e: CustomEvent) => { - setCustomPorts(e.detail); + setPinnedPorts(e.detail); }; - window.addEventListener('ports-config-changed', handlePortsChange as EventListener); + window.addEventListener('pinned-ports-changed', handlePortsChange as EventListener); return () => { - window.removeEventListener('ports-config-changed', handlePortsChange as EventListener); + window.removeEventListener('pinned-ports-changed', handlePortsChange as EventListener); }; }, []); return useQuery({ - queryKey: ['ports', 'common', customPorts], - queryFn: () => getCommonPorts(customPorts), + queryKey: ['ports', 'pinned', pinnedPorts], + queryFn: () => getCommonPorts(pinnedPorts), refetchInterval: refreshInterval, refetchIntervalInBackground: true, }); From 3eca3a247baf99895c9cceda0691513b1a8e67bc Mon Sep 17 00:00:00 2001 From: TriptoAfsin Date: Wed, 15 Oct 2025 13:11:13 +0600 Subject: [PATCH 2/4] feat: add toast notifications for user feedback in process management; integrate toast functionality in App and PortSettings components for improved user experience --- package-lock.json | 58 +++++++ package.json | 1 + src/App.tsx | 36 ++-- src/components/dashboard/PortSettings.tsx | 12 +- src/components/ui/toast.tsx | 127 ++++++++++++++ src/components/ui/toaster.tsx | 33 ++++ src/hooks/use-toast.ts | 194 ++++++++++++++++++++++ 7 files changed, 447 insertions(+), 14 deletions(-) create mode 100644 src/components/ui/toast.tsx create mode 100644 src/components/ui/toaster.tsx create mode 100644 src/hooks/use-toast.ts diff --git a/package-lock.json b/package-lock.json index e6df0eb..c1913c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-toast": "^1.2.15", "@tanstack/react-query": "^5.17.0", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0", @@ -1338,6 +1339,40 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -1459,6 +1494,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", diff --git a/package.json b/package.json index 9c55779..71f04f3 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-toast": "^1.2.15", "@tanstack/react-query": "^5.17.0", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0", diff --git a/src/App.tsx b/src/App.tsx index f67bd2a..cf8c9ed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,10 +9,12 @@ import { StatsCard } from './components/dashboard/StatsCard'; import { PortListItem } from './components/dashboard/PortListItem'; import { StatusFilter } from './components/dashboard/StatusFilter'; import { PortScanLoader } from './components/dashboard/PortScanLoader'; -import { usePinnedPorts, useAllPorts, useRefreshPorts } from './hooks/usePorts'; +import { useAllPorts, useRefreshPorts } from './hooks/usePorts'; import { killProcess, isElevated } from './lib/tauri'; import { Button } from './components/ui/button'; import { Port } from './types/api'; +import { Toaster } from './components/ui/toaster'; +import { useToast } from './hooks/use-toast'; const queryClient = new QueryClient(); @@ -23,8 +25,8 @@ function AppContent() { const [selectedStatuses, setSelectedStatuses] = useState>( new Set(['free', 'occupied', 'system']) ); + const { toast } = useToast(); - const { data: pinnedPorts = [], isLoading: isLoadingPinned, isRefetching: isRefetchingPinned } = usePinnedPorts(); const { data: allPorts = [], isLoading: isLoadingAll, isRefetching: isRefetchingAll } = useAllPorts(); const { refreshPorts } = useRefreshPorts(); @@ -60,6 +62,8 @@ function AppContent() { }, []); // Separate pinned and other ports from all ports + // Note: Only shows ports that are actively detected by the system scan + // If a pinned port isn't running or accessible, it won't appear in the list const { pinnedPortsList, otherPortsList } = useMemo(() => { const pinned: Port[] = []; const other: Port[] = []; @@ -75,8 +79,8 @@ function AppContent() { return { pinnedPortsList: pinned, otherPortsList: other }; }, [allPorts, pinnedPortNumbers]); - const isLoading = isLoadingPinned || isLoadingAll; - const isRefetching = isRefetchingPinned || isRefetchingAll; + const isLoading = isLoadingAll; + const isRefetching = isRefetchingAll; const handleStatusToggle = (status: string) => { setSelectedStatuses((prev) => { @@ -130,24 +134,29 @@ function AppContent() { const handleKillProcess = async (pid: number) => { if (!isAdmin) { - alert( - 'Cannot kill process: Administrator privileges required.\n\n' + - 'Please restart Porter as Administrator:\n' + - '1. Close Porter\n' + - '2. Right-click on Porter\n' + - '3. Select "Run as administrator"' - ); + toast({ + variant: "destructive", + title: "Administrator privileges required", + description: "Please restart Porter as Administrator to kill processes.", + }); return; } try { await killProcess(pid); refreshPorts(); - alert('Process terminated successfully'); + toast({ + title: "Process terminated", + description: "The process was successfully terminated.", + }); } catch (error) { console.error('Failed to kill process:', error); const errorMessage = error instanceof Error ? error.message : String(error); - alert(`Failed to kill process:\n\n${errorMessage}`); + toast({ + variant: "destructive", + title: "Failed to kill process", + description: errorMessage, + }); } }; @@ -161,6 +170,7 @@ function AppContent() { return (
+ {/* Sticky Header */}
diff --git a/src/components/dashboard/PortSettings.tsx b/src/components/dashboard/PortSettings.tsx index 4dc46bb..0b3df2d 100644 --- a/src/components/dashboard/PortSettings.tsx +++ b/src/components/dashboard/PortSettings.tsx @@ -6,6 +6,7 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { useToast } from '@/hooks/use-toast'; const DEFAULT_PINNED_PORTS = [ 3000, 5173, 8080, 5432, 27017 @@ -17,6 +18,7 @@ export function PortSettings() { const [pinnedPorts, setPinnedPorts] = useState(DEFAULT_PINNED_PORTS); const [newPort, setNewPort] = useState(''); const [isOpen, setIsOpen] = useState(false); + const { toast } = useToast(); useEffect(() => { // Migrate from old key if needed @@ -47,11 +49,19 @@ export function PortSettings() { const port = parseInt(newPort); if (!isNaN(port) && port > 0 && port < 65536 && !pinnedPorts.includes(port)) { if (pinnedPorts.length >= MAX_PINNED_PORTS) { - alert(`You can only pin up to ${MAX_PINNED_PORTS} ports.`); + toast({ + variant: "destructive", + title: "Maximum ports reached", + description: `You can only pin up to ${MAX_PINNED_PORTS} ports.`, + }); return; } savePinnedPorts([...pinnedPorts, port].sort((a, b) => a - b)); setNewPort(''); + toast({ + title: "Port pinned", + description: `Port ${port} has been added to your pinned ports.`, + }); } }; diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000..c4ea3fb --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 0000000..6c67edf --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -0,0 +1,33 @@ +import { useToast } from "@/hooks/use-toast" +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts new file mode 100644 index 0000000..02e111d --- /dev/null +++ b/src/hooks/use-toast.ts @@ -0,0 +1,194 @@ +"use client" + +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } From a86a6be78202407830fe7ff2fecc16233cd98a69 Mon Sep 17 00:00:00 2001 From: TriptoAfsin Date: Wed, 15 Oct 2025 13:14:10 +0600 Subject: [PATCH 3/4] feat: enhance pinned ports functionality by creating placeholder objects for non-running ports; improve sorting and organization of pinned and other ports in the App component --- src/App.tsx | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index cf8c9ed..06cafd1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -62,16 +62,33 @@ function AppContent() { }, []); // Separate pinned and other ports from all ports - // Note: Only shows ports that are actively detected by the system scan - // If a pinned port isn't running or accessible, it won't appear in the list + // Create Port objects for all pinned ports, even if not currently running const { pinnedPortsList, otherPortsList } = useMemo(() => { const pinned: Port[] = []; const other: Port[] = []; + const allPortsMap = new Map(allPorts.map(p => [p.port, p])); - allPorts.forEach(port => { - if (pinnedPortNumbers.has(port.port)) { - pinned.push(port); + // Add all pinned ports (create placeholder for non-running ones) + pinnedPortNumbers.forEach(portNum => { + const existingPort = allPortsMap.get(portNum); + if (existingPort) { + pinned.push(existingPort); } else { + // Create a placeholder port object for non-running pinned ports + pinned.push({ + port: portNum, + status: 'free', + process: null + }); + } + }); + + // Sort pinned ports by port number + pinned.sort((a, b) => a.port - b.port); + + // Add remaining ports to other list + allPorts.forEach(port => { + if (!pinnedPortNumbers.has(port.port)) { other.push(port); } }); @@ -204,7 +221,7 @@ function AppContent() { {/* Port List Header */}

- 📌 Pinned Ports + {showAllPorts ? '🌐 All Ports' : '📌 Pinned Ports'} {searchQuery && ` (${filteredPinnedPorts.length + filteredOtherPorts.length} results)`}

From b62da88408fb36fb2f1cab28544153d9612e5fac Mon Sep 17 00:00:00 2001 From: TriptoAfsin Date: Wed, 15 Oct 2025 13:15:48 +0600 Subject: [PATCH 4/4] feat: refactor AboutDialog component to utilize aboutConfig for dynamic content; add about configuration file for centralized app information management --- src/components/dashboard/AboutDialog.tsx | 25 +++++++++++++----------- src/config/about.ts | 23 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 src/config/about.ts diff --git a/src/components/dashboard/AboutDialog.tsx b/src/components/dashboard/AboutDialog.tsx index 1c92f33..0cdb828 100644 --- a/src/components/dashboard/AboutDialog.tsx +++ b/src/components/dashboard/AboutDialog.tsx @@ -8,6 +8,7 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; +import { aboutConfig } from '@/config/about'; export function AboutDialog() { return ( @@ -19,47 +20,49 @@ export function AboutDialog() { - About Porter + About {aboutConfig.app.name} - A sleek, fast, and secure desktop port monitoring application + {aboutConfig.app.description}

- Version 0.1.0 + Version {aboutConfig.app.version}

- {/*

- Built with Tauri, React, and TypeScript -

*/} + {aboutConfig.showTechStack && ( +

+ {aboutConfig.techStack} +

+ )}

- © 2025 Porter. Licensed under MIT License. + © {aboutConfig.license.year} {aboutConfig.license.holder}. Licensed under {aboutConfig.license.type}.

diff --git a/src/config/about.ts b/src/config/about.ts new file mode 100644 index 0000000..4424e85 --- /dev/null +++ b/src/config/about.ts @@ -0,0 +1,23 @@ +export const aboutConfig = { + app: { + name: 'Porter', + version: '0.2.0', + description: 'A sleek, fast, and secure desktop port monitoring application', + }, + organization: { + name: 't21.dev', + url: 'https://t21.dev', + }, + repository: { + url: 'https://github.com/t21dev/porter-app', + label: 'View on GitHub', + }, + license: { + type: 'MIT License', + year: 2025, + holder: 'Porter', + }, + // Optional: Built with tech stack (currently commented in the component) + techStack: 'Built with Tauri, React, and TypeScript', + showTechStack: false, +} as const;