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
2 changes: 1 addition & 1 deletion tools/cli/Cargo.lock

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

42 changes: 26 additions & 16 deletions tools/web/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { Feature } from '@/models/feature'
import { AppSidebarSearchInput } from './app-sidebar-search-input'
import { ModeToggle } from './mode-toggle'
import { NavFeatures } from './nav-features'
import { OwnerDot } from './owner-dot'
import { VersionIndicator } from './version-indicator'

interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
Expand Down Expand Up @@ -118,22 +119,31 @@ export function AppSidebar({
</p>
</div>
) : (
filteredFeatures.map((feature) => (
<SidebarMenuItem key={feature.path}>
<SidebarMenuButton
asChild
isActive={activeFeature?.path === feature.path}
onClick={() => onFeatureClick(feature)}
title={formatFeatureName(feature.name)}
>
<div className="flex flex-col items-start gap-0.5">
<span className="font-medium truncate cursor-pointer text-ellipsis">
{formatFeatureName(feature.name)}
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
))
filteredFeatures.map((feature) => {
// Only show owner dot if the owner differs from parent
const shouldShowOwnerDot =
!feature.parent || feature.owner !== feature.parent.owner

return (
<SidebarMenuItem key={feature.path}>
<SidebarMenuButton
asChild
isActive={activeFeature?.path === feature.path}
onClick={() => onFeatureClick(feature)}
title={formatFeatureName(feature.name)}
>
<div className="flex items-center justify-between w-full gap-2">
<span className="font-medium truncate cursor-pointer text-ellipsis flex-1">
{formatFeatureName(feature.name)}
</span>
{shouldShowOwnerDot && (
<OwnerDot owner={feature.owner} />
)}
</div>
</SidebarMenuButton>
</SidebarMenuItem>
)
})
)}
</SidebarMenu>
</SidebarGroup>
Expand Down
40 changes: 33 additions & 7 deletions tools/web/src/components/nav-features.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { formatFeatureName } from '@/lib/format-feature-name'
import { cn } from '@/lib/utils'
import type { Feature } from '@/models/feature'
import { HelpButton } from './help-button'
import { OwnerDot } from './owner-dot'

interface NavFeaturesProps {
items: Feature[]
Expand All @@ -34,6 +35,7 @@ interface TreeNode {
isFolder: boolean
feature?: Feature
children: Map<string, TreeNode>
parentOwner?: string
}

function formatNodeName(name: string, isFeature: boolean) {
Expand Down Expand Up @@ -94,7 +96,11 @@ function buildPathTree(features: Feature[]): TreeNode {
}

// Add all features to the tree
function addFeatureToTree(feature: Feature, node: TreeNode) {
function addFeatureToTree(
feature: Feature,
node: TreeNode,
parentOwner?: string,
) {
const relativePath = commonAncestor
? feature.path.slice(commonAncestor.length + 1)
: feature.path
Expand All @@ -117,6 +123,7 @@ function buildPathTree(features: Feature[]): TreeNode {
path: folderPath,
isFolder: true,
children: new Map(),
parentOwner,
})
}
if (currentNode) {
Expand All @@ -132,6 +139,7 @@ function buildPathTree(features: Feature[]): TreeNode {
isFolder: false,
feature: feature,
children: new Map(),
parentOwner,
}

// Add nested features as children
Expand All @@ -146,26 +154,32 @@ function buildPathTree(features: Feature[]): TreeNode {
false,
feature: nestedFeature,
children: new Map(),
parentOwner: feature.owner,
}

// Recursively add nested features
if (nestedFeature.features && nestedFeature.features.length > 0) {
function addNested(features: Feature[], parent: TreeNode) {
function addNested(
features: Feature[],
parent: TreeNode,
parentOwner: string,
) {
for (const f of features) {
const child: TreeNode = {
name: f.name,
path: f.path,
isFolder: (f.features && f.features.length > 0) ?? false,
feature: f,
children: new Map(),
parentOwner,
}
parent.children.set(f.name, child)
if (f.features && f.features.length > 0) {
addNested(f.features, child)
addNested(f.features, child, f.owner)
}
}
}
addNested(nestedFeature.features, nestedNode)
addNested(nestedFeature.features, nestedNode, nestedFeature.owner)
}

featureNode.children.set(nestedFeature.name, nestedNode)
Expand Down Expand Up @@ -213,6 +227,10 @@ function TreeNodeItem({
const isActive = activeFeature?.path === node.path
const isFeature = !!node.feature

// Only show owner dot if the owner differs from parent
const shouldShowOwnerDot =
isFeature && node.feature && node.feature.owner !== node.parentOwner

const [isOpen, setIsOpen] = useState(() => {
// Auto-expand if this node is in the active path
if (activeFeature) {
Expand Down Expand Up @@ -275,14 +293,19 @@ function TreeNodeItem({
tooltip={formatNodeName(node.name, isFeature)}
title={formatNodeName(node.name, isFeature)}
>
<div>
<div className="flex items-center w-full gap-2">
<span
className="flex items-center gap-2 truncate cursor-pointer text-ellipsis"
className="flex items-center gap-2 truncate cursor-pointer text-ellipsis flex-1"
title={formatNodeName(node.name, isFeature)}
>
{!isFeature && <Folder className="h-4 w-4 opacity-60" />}
{formatNodeName(node.name, isFeature)}
</span>
{shouldShowOwnerDot && node.feature?.owner && (
<OwnerDot owner={node.feature.owner} />
)}
{/* Reserve space for chevron to align with collapsible items */}
<div className="w-4 shrink-0" />
</div>
</SidebarMenuButton>
</SidebarMenuItem>
Expand Down Expand Up @@ -314,7 +337,7 @@ function TreeNodeItem({
title={formatNodeName(node.name, isFeature)}
>
<span
className="flex items-center gap-2 truncate cursor-pointer"
className="flex items-center gap-2 truncate cursor-pointer flex-1"
title={formatNodeName(node.name, isFeature)}
>
{!isFeature &&
Expand All @@ -325,6 +348,9 @@ function TreeNodeItem({
))}
{formatNodeName(node.name, isFeature)}
</span>
{shouldShowOwnerDot && node.feature?.owner && (
<OwnerDot owner={node.feature.owner} />
)}
<ChevronRight
className={cn(
'ml-auto transition-transform duration-200',
Expand Down
29 changes: 29 additions & 0 deletions tools/web/src/components/owner-dot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { getOwnerColor } from '@/lib/owner-color'
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'

type OwnerDotProps = {
owner: string
}

export function OwnerDot({ owner }: OwnerDotProps) {
const color = getOwnerColor(owner)
const isEmpty = !owner || owner.trim() === ''

return (
<Tooltip>
<TooltipTrigger asChild>
<div
className="size-2 rounded-full shrink-0"
style={{ backgroundColor: color }}
>
<span className="sr-only">
{isEmpty ? 'No owner' : `Owner: ${owner}`}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{isEmpty ? 'No owner' : owner}
</TooltipContent>
</Tooltip>
)
}
22 changes: 22 additions & 0 deletions tools/web/src/lib/owner-color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Generate a consistent color from a string (owner name) using a hash function
*/
export function getOwnerColor(owner: string): string {
if (!owner || owner.trim() === '') {
return 'rgb(156, 163, 175)' // gray-400
}

// Simple hash function
let hash = 0
for (let i = 0; i < owner.length; i++) {
hash = owner.charCodeAt(i) + ((hash << 5) - hash)
hash = hash & hash // Convert to 32bit integer
}

// Generate HSL color with good saturation and lightness for visibility
const hue = Math.abs(hash) % 360
const saturation = 65 + (Math.abs(hash) % 20) // 65-85%
const lightness = 50 + (Math.abs(hash) % 15) // 50-65%

return `hsl(${hue}, ${saturation}%, ${lightness}%)`
}