Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
Expand Down
58 changes: 58 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
233 changes: 173 additions & 60 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@
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 { 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();

Expand All @@ -22,14 +25,79 @@
const [selectedStatuses, setSelectedStatuses] = useState<Set<string>>(
new Set(['free', 'occupied', 'system'])
);
const { toast } = useToast();

const { data: commonPorts = [], isLoading: isLoadingCommon, isRefetching: isRefetchingCommon } = useCommonPorts();
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<Set<number>>(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
// 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]));

// 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

Check failure on line 81 in src/App.tsx

View workflow job for this annotation

GitHub Actions / build (macos-latest)

Type 'null' is not assignable to type 'Process | undefined'.

Check failure on line 81 in src/App.tsx

View workflow job for this annotation

GitHub Actions / build (ubuntu-22.04)

Type 'null' is not assignable to type 'Process | undefined'.

Check failure on line 81 in src/App.tsx

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Type 'null' is not assignable to type 'Process | undefined'.
});
}
});

// 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);
}
});

return { pinnedPortsList: pinned, otherPortsList: other };
}, [allPorts, pinnedPortNumbers]);

const isLoading = isLoadingAll;
const isRefetching = isRefetchingAll;

const handleStatusToggle = (status: string) => {
setSelectedStatuses((prev) => {
Expand Down Expand Up @@ -57,66 +125,76 @@
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));

// Filter by search query
if (searchQuery) {
filtered = filtered.filter((port) => {
return port.port.toString().includes(searchQuery);
});
}

return filtered;
}, [ports, searchQuery, selectedStatuses]);
return filtered;
};

return {
filteredPinnedPorts: applyFilters(pinnedPortsList),
filteredOtherPorts: applyFilters(otherPortsList)
};
}, [pinnedPortsList, otherPortsList, searchQuery, selectedStatuses]);

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,
});
}
};

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 (
<div className="h-screen bg-background flex flex-col overflow-hidden">
<div className="flex overflow-hidden flex-col h-screen bg-background">
<Toaster />
{/* Sticky Header */}
<Header onRefresh={refreshPorts} isRefreshing={isRefetching} />

<main className="flex-1 overflow-hidden flex flex-col">
<div className="container px-4 mx-auto max-w-7xl flex flex-col h-full">
<main className="flex overflow-hidden flex-col flex-1">
<div className="container flex flex-col px-4 mx-auto max-w-7xl h-full">
{/* Fixed Top Section */}
<div className="py-4 space-y-4 flex-shrink-0">
<div className="flex-shrink-0 py-4 space-y-4">
{/* Admin Warning */}
{!isAdmin && <AdminWarning />}

Expand All @@ -143,34 +221,69 @@
{/* Port List Header */}
<div className="flex gap-2 items-center">
<h2 className="text-sm font-semibold text-foreground">
{showAllPorts ? 'All Running Ports' : 'Common Developer Ports'}
{searchQuery && ` (${filteredPorts.length} results)`}
{showAllPorts ? '🌐 All Ports' : '📌 Pinned Ports'}
{searchQuery && ` (${filteredPinnedPorts.length + filteredOtherPorts.length} results)`}
</h2>
<Button
variant="outline"
size="xs"
onClick={() => setShowAllPorts(!showAllPorts)}
className="text-[10px] h-6 px-2"
>
{showAllPorts ? 'Show Common' : 'Show All'}
</Button>
</div>
</div>

{/* Scrollable Port List */}
<div className="flex-1 overflow-hidden">
<div className="overflow-hidden flex-1">
<SimpleBar style={{ height: '100%' }}>
{isLoading ? (
<PortScanLoader />
) : (
<div className="space-y-2 pb-8 pr-2">
{filteredPorts.map((port) => (
<PortListItem
key={port.port}
port={port}
onKill={handleKillProcess}
/>
))}
<div className="pr-2 pb-8 space-y-2">
{/* When searching, show all results together */}
{searchQuery ? (
<>
{[...filteredPinnedPorts, ...filteredOtherPorts].map((port) => (
<PortListItem
key={port.port}
port={port}
onKill={handleKillProcess}
isPinned={pinnedPortNumbers.has(port.port)}
/>
))}
</>
) : (
<>
{/* Pinned Ports Section */}
{filteredPinnedPorts.map((port) => (
<PortListItem
key={port.port}
port={port}
onKill={handleKillProcess}
isPinned={true}
/>
))}

{/* Divider and Show Other Ports Button */}
{!searchQuery && filteredOtherPorts.length > 0 && (
<div className="py-3">
<div className="mb-3 border-t border-border"></div>
<Button
variant="outline"
size="sm"
onClick={() => setShowAllPorts(!showAllPorts)}
className="w-full text-xs"
>
{showAllPorts ? 'Hide Other Ports' : `Show Other Ports (${filteredOtherPorts.length})`}
</Button>
</div>
)}

{/* Other Ports Section (shown when button clicked) */}
{showAllPorts && filteredOtherPorts.map((port) => (
<PortListItem
key={port.port}
port={port}
onKill={handleKillProcess}
isPinned={false}
/>
))}
</>
)}
</div>
)}
</SimpleBar>
Expand Down
Loading
Loading