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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ coverage/

# TypeScript
*.tsbuildinfo

# Local monorepo fixture for testing the workspace feature
test-monorepo/
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/njevric/packy-dev.git"
"url": "git+https://github.com/NJevric/packy-dev.git"
},
"bugs": {
"url": "https://github.com/njevric/packy-dev/issues"
Expand Down
2 changes: 2 additions & 0 deletions src/client/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from 'radix-vue'
import { useTheme } from '@/composables/useTheme'
import { Button } from '@/components/ui/button'
import WorkspaceSwitcher from '@/components/WorkspaceSwitcher.vue'

const { theme, setTheme } = useTheme()

Expand All @@ -31,6 +32,7 @@ const themeOptions = [
<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>
<WorkspaceSwitcher />
</div>
<nav class="flex items-center gap-1">
<RouterLink
Expand Down
94 changes: 94 additions & 0 deletions src/client/components/WorkspaceSwitcher.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { ChevronsUpDown, Check, FolderTree, Home } from 'lucide-vue-next'
import {
DropdownMenuRoot,
DropdownMenuTrigger,
DropdownMenuPortal,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
} from 'radix-vue'
import { useProject } from '@/composables/useProject'
import { useWorkspaceStore } from '@/stores/workspace'
import { Button } from '@/components/ui/button'

const { data: project } = useProject()
const ws = useWorkspaceStore()

const workspaces = computed(() => project.value?.workspaces ?? [])
const isMonorepo = computed(() => project.value?.isMonorepo ?? false)
const children = computed(() => workspaces.value.filter((w) => !w.isRoot))
const rootWorkspace = computed(() => workspaces.value.find((w) => w.isRoot))

// Drop a persisted selection that does not belong to the current project
watch(
workspaces,
(list) => {
if (ws.selected && !list.some((w) => !w.isRoot && w.name === ws.selected)) {
ws.select(null)
}
},
{ immediate: true }
)

const currentLabel = computed(() => {
if (ws.selected) return ws.selected
return rootWorkspace.value?.name ?? 'Root'
})
</script>

<template>
<DropdownMenuRoot v-if="isMonorepo">
<DropdownMenuTrigger as-child>
<Button
variant="outline"
size="sm"
class="h-8 gap-1.5 max-w-[200px]"
>
<FolderTree class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="truncate text-xs font-medium">{{ currentLabel }}</span>
<ChevronsUpDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
align="start"
:side-offset="6"
class="z-50 min-w-[220px] max-h-[70vh] overflow-y-auto 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"
>
<DropdownMenuLabel class="px-2 py-1.5 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
Workspace
</DropdownMenuLabel>

<DropdownMenuItem
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="ws.select(null)"
>
<Home class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ rootWorkspace?.name ?? 'Root' }}</span>
<span class="text-[10px] text-muted-foreground">root</span>
<Check
v-if="!ws.selected"
class="h-3.5 w-3.5 shrink-0"
/>
</DropdownMenuItem>

<DropdownMenuItem
v-for="child in children"
:key="child.relativePath"
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="ws.select(child.name)"
>
<FolderTree class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ child.name }}</span>
<span class="text-[10px] text-muted-foreground truncate max-w-[90px]">{{ child.relativePath }}</span>
<Check
v-if="ws.selected === child.name"
class="h-3.5 w-3.5 shrink-0"
/>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>
150 changes: 150 additions & 0 deletions src/client/components/dashboard/DuplicatesCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Copy, CheckCircle2 } from 'lucide-vue-next'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { useDuplicates } from '@/composables/useDuplicates'
import type { DuplicateDependency } from '@shared/types'

const { data, isLoading } = useDuplicates()

const duplicates = computed(() => data.value ?? [])

// Versions arrive sorted newest → oldest. Newest is the alignment target;
// older versions ramp through warning colors to signal how far they have drifted.
const DOT_COLORS = ['bg-green-500', 'bg-yellow-500', 'bg-orange-500', 'bg-red-500']
const TEXT_COLORS = ['text-green-600', 'text-yellow-600', 'text-orange-600', 'text-red-600']

function dotColor(index: number): string {
return DOT_COLORS[Math.min(index, DOT_COLORS.length - 1)]
}

function textColor(index: number): string {
return TEXT_COLORS[Math.min(index, TEXT_COLORS.length - 1)]
}

function totalUses(dep: DuplicateDependency): number {
return dep.versions.reduce((sum, v) => sum + v.workspaces.length, 0)
}

function barWidth(count: number, dep: DuplicateDependency): string {
const total = totalUses(dep)
return total === 0 ? '0%' : `${(count / total) * 100}%`
}
</script>

<template>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Copy class="h-4 w-4 text-muted-foreground" />
Duplicate Versions
<Badge
v-if="duplicates.length > 0"
variant="secondary"
>
{{ duplicates.length }}
</Badge>
</CardTitle>
<CardDescription>
Dependencies pinned to different versions across workspaces
</CardDescription>
</CardHeader>
<CardContent>
<!-- Loading -->
<div
v-if="isLoading"
class="divide-y"
>
<Skeleton
v-for="i in 3"
:key="i"
class="my-4 h-20 w-full first:mt-0 last:mb-0"
/>
</div>

<!-- Empty -->
<div
v-else-if="duplicates.length === 0"
class="flex items-center gap-2 py-2 text-sm text-muted-foreground"
>
<CheckCircle2 class="h-4 w-4 text-green-500" />
Every workspace agrees on its dependency versions
</div>

<!-- Conflicts -->
<div
v-else
class="divide-y"
>
<div
v-for="dep in duplicates"
:key="dep.name"
class="py-4 first:pt-0 last:pb-0"
>
<!-- Package name + version count -->
<div class="flex items-center justify-between gap-2">
<span class="truncate font-mono text-sm font-semibold">
{{ dep.name }}
</span>
<span class="shrink-0 text-[11px] font-medium text-muted-foreground">
{{ dep.versions.length }} versions
</span>
</div>

<!-- Distribution bar -->
<div class="mt-2.5 flex h-1.5 gap-px overflow-hidden rounded-full">
<div
v-for="(entry, i) in dep.versions"
:key="entry.version"
class="first:rounded-l-full last:rounded-r-full"
:class="dotColor(i)"
:style="{ width: barWidth(entry.workspaces.length, dep) }"
/>
</div>

<!-- Version lanes -->
<div class="mt-3 space-y-2.5">
<div
v-for="(entry, i) in dep.versions"
:key="entry.version"
>
<div class="flex items-center gap-2">
<span
class="h-2 w-2 shrink-0 rounded-full"
:class="dotColor(i)"
/>
<span
class="font-mono text-xs font-semibold"
:class="textColor(i)"
>
{{ entry.version }}
</span>
<span
v-if="i === 0"
class="rounded border bg-background px-1 py-px text-[10px] font-medium uppercase tracking-wide text-muted-foreground"
>
newest
</span>
<span class="ml-auto shrink-0 text-[11px] text-muted-foreground">
{{ entry.workspaces.length }}
{{ entry.workspaces.length === 1 ? 'workspace' : 'workspaces' }}
</span>
</div>
<div class="mt-1.5 flex flex-wrap gap-1 pl-4">
<span
v-for="workspace in entry.workspaces"
:key="workspace"
class="rounded border bg-background px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
>
{{ workspace }}
</span>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</template>
Loading
Loading