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
839 changes: 800 additions & 39 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
},
"dependencies": {
"cors": "^2.8.6",
"depcheck": "^1.4.7",
"express": "^5.2.1",
"get-port": "^7.2.0",
"open": "^11.0.0",
Expand All @@ -80,6 +81,7 @@
"concurrently": "^9.2.1",
"eslint": "^10.3.0",
"eslint-plugin-vue": "^10.9.1",
"vue-eslint-parser": "^10.3.0",
"jsdom": "^29.1.1",
"lucide-vue-next": "^1.0.0",
"pinia": "^3.0.4",
Expand Down
81 changes: 75 additions & 6 deletions src/client/App.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterView, RouterLink } from 'vue-router'
import { Package, ShieldAlert, Layers, Activity } from 'lucide-vue-next'
import { Package, ShieldAlert, Layers, Activity, Terminal, Sun, Moon, Monitor, Check } from 'lucide-vue-next'
import {
DropdownMenuRoot,
DropdownMenuTrigger,
DropdownMenuPortal,
DropdownMenuContent,
DropdownMenuItem,
} from 'radix-vue'
import { useTheme } from '@/composables/useTheme'
import { Button } from '@/components/ui/button'

const { theme, setTheme } = useTheme()

const ThemeIcon = computed(() =>
theme.value === 'dark' ? Moon : theme.value === 'light' ? Sun : Monitor
)

const themeOptions = [
{ value: 'light', label: 'Light', icon: Sun },
{ value: 'dark', label: 'Dark', icon: Moon },
{ value: 'system', label: 'System', icon: Monitor },
] as const
</script>

<template>
<div
class="min-h-screen"
style="background-color: #fafbfc"
>
<header class="border-b">
<div class="min-h-screen bg-background text-foreground">
<header class="border-b bg-background">
<div class="container mx-auto flex h-14 items-center justify-between px-4 sm:px-6">
<div class="flex items-center gap-2.5">
<span class="text-xl font-bold tracking-tight">Packy</span>
Expand All @@ -30,6 +49,14 @@ import { Package, ShieldAlert, Layers, Activity } from 'lucide-vue-next'
<Layers class="h-4 w-4" />
<span class="hidden sm:inline">Dependencies</span>
</RouterLink>
<RouterLink
to="/scripts"
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-muted"
active-class="!text-foreground bg-muted"
>
<Terminal class="h-4 w-4" />
<span class="hidden sm:inline">Scripts</span>
</RouterLink>
<RouterLink
to="/security"
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-muted"
Expand All @@ -46,9 +73,51 @@ import { Package, ShieldAlert, Layers, Activity } from 'lucide-vue-next'
<Activity class="h-4 w-4" />
<span class="hidden sm:inline">Activity</span>
</RouterLink>

<div class="w-px h-4 bg-border mx-1 shrink-0" />

<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
>
<component
:is="ThemeIcon"
class="h-4 w-4"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
:side-offset="6"
class="z-50 min-w-[140px] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 p-1"
>
<DropdownMenuItem
v-for="opt in themeOptions"
:key="opt.value"
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none cursor-default select-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
@click="setTheme(opt.value)"
>
<component
:is="opt.icon"
class="h-4 w-4 text-muted-foreground"
/>
<span class="flex-1">{{ opt.label }}</span>
<Check
v-if="theme === opt.value"
class="h-3.5 w-3.5"
/>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</nav>
</div>
</header>

<main class="container mx-auto py-6 px-4 sm:px-6">
<RouterView />
</main>
Expand Down
225 changes: 225 additions & 0 deletions src/client/components/packages/DepcheckPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useDepcheck } from '@/composables/useDepcheck'

const emit = defineEmits<{
remove: [name: string]
}>()

const { result, isLoading, error, run, refresh } = useDepcheck()

const unusedCount = computed(() => {
if (!result.value) return 0
return result.value.dependencies.length + result.value.devDependencies.length
})

const missingCount = computed(() => {
if (!result.value) return 0
return Object.keys(result.value.missing).length
})

const missingEntries = computed(() => {
if (!result.value) return []
return Object.entries(result.value.missing).map(([name, files]) => ({ name, files }))
})
</script>

<template>
<Card>
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<CardTitle class="text-base">
Unused Dependencies
</CardTitle>
<Button
v-if="result"
size="sm"
variant="ghost"
:disabled="isLoading"
class="h-7 px-2 text-xs"
@click="refresh"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
:class="isLoading ? 'animate-spin' : ''"
><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" /><path d="M8 16H3v5" /></svg>
Refresh
</Button>
</div>
</CardHeader>

<CardContent class="space-y-4">
<!-- Initial state -->
<template v-if="!result && !isLoading && !error">
<p class="text-sm text-muted-foreground">
Scan your project to detect packages that are declared in package.json but not used in your code.
</p>
<Button
class="w-full"
size="sm"
@click="run"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mr-1.5"
><circle
cx="11"
cy="11"
r="8"
/><path d="m21 21-4.3-4.3" /></svg>
Run Analysis
</Button>
</template>

<!-- Loading -->
<template v-else-if="isLoading">
<div class="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="animate-spin"
><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
Scanning project files...
</div>
</template>

<!-- Error -->
<template v-else-if="error">
<p class="text-sm text-destructive">
{{ error }}
</p>
<Button
size="sm"
variant="outline"
class="w-full"
@click="run"
>
Try Again
</Button>
</template>

<!-- Results -->
<template v-else-if="result">
<!-- All clean -->
<div
v-if="unusedCount === 0 && missingCount === 0"
class="text-center py-3"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mx-auto mb-2 text-green-500"
><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
<p class="text-sm text-muted-foreground">
No unused dependencies found.
</p>
</div>

<template v-else>
<!-- Unused production deps -->
<div
v-if="result.dependencies.length > 0"
class="space-y-1.5"
>
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Unused ({{ result.dependencies.length }})
</p>
<div
v-for="name in result.dependencies"
:key="name"
class="flex items-center justify-between py-1.5 px-2 rounded-md bg-muted/50 group"
>
<span class="text-sm font-mono">{{ name }}</span>
<Button
size="sm"
variant="ghost"
class="h-6 px-2 text-xs text-destructive hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
@click="emit('remove', name)"
>
Remove
</Button>
</div>
</div>

<!-- Unused dev deps -->
<div
v-if="result.devDependencies.length > 0"
class="space-y-1.5"
>
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Unused Dev ({{ result.devDependencies.length }})
</p>
<div
v-for="name in result.devDependencies"
:key="name"
class="flex items-center justify-between py-1.5 px-2 rounded-md bg-muted/50 group"
>
<span class="text-sm font-mono">{{ name }}</span>
<Button
size="sm"
variant="ghost"
class="h-6 px-2 text-xs text-destructive hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
@click="emit('remove', name)"
>
Remove
</Button>
</div>
</div>

<!-- Missing deps -->
<div
v-if="missingEntries.length > 0"
class="space-y-1.5"
>
<p class="text-xs font-medium text-amber-600 dark:text-amber-400 uppercase tracking-wide">
Missing ({{ missingEntries.length }})
</p>
<div
v-for="entry in missingEntries"
:key="entry.name"
class="py-1.5 px-2 rounded-md bg-amber-50 dark:bg-amber-950/20"
>
<span class="text-sm font-mono">{{ entry.name }}</span>
<p class="text-xs text-muted-foreground mt-0.5 truncate">
{{ entry.files[0] }}
<span v-if="entry.files.length > 1">+{{ entry.files.length - 1 }} more</span>
</p>
</div>
</div>
</template>
</template>
</CardContent>
</Card>
</template>
2 changes: 1 addition & 1 deletion src/client/components/packages/PackageItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const isVersionPickerOpen = ref(false)

<template>
<div
class="flex items-center justify-between p-4 rounded-lg border bg-white hover:bg-accent/50 transition-colors cursor-pointer gap-3"
class="flex items-center justify-between p-4 rounded-lg border bg-card text-card-foreground hover:bg-accent/50 transition-colors cursor-pointer gap-3"
@click="emit('select', package.name)"
>
<div class="flex-1 min-w-0">
Expand Down
Loading
Loading