diff --git a/CHANGELOG.md b/CHANGELOG.md index 083eb39..eaf5425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.16.0] — 2026-05-13 + +### Features + +- Tag your projects to group related ones together, then filter the + projects sidebar with one click on a tag chip. Right-click a project + → **Tags…** to add, remove, or create tags. +- Pick a color for each tag from an 8-swatch palette (or let DPlex + assign one automatically). Tag colors are shared across the sidebar + and command palette, so the same tag always looks the same. +- Global search (⌘P) now matches projects by tag — type `#infra` to + filter, or just type a tag name. Each project result shows its + avatar and tag pills so you can see why it matched. +- New **Search** button in the status bar opens the command palette, + with its ⌘P shortcut shown right on the button. + +### Improvements + +- Filtering projects by tag keeps a parent's worktree branches visible + underneath it, so a tag on the origin pulls the whole tree along. +- Project rows fit as many tag pills as actually have room, then + surface the rest as a `+N` chip whose tooltip lists what's hidden — + nothing is silently clipped. + ## [0.15.0] — 2026-05-12 ### Features @@ -445,7 +469,8 @@ AI-assisted development. - Eight built-in themes (dark and light variants). - Keyboard shortcuts for tabs, splits, sidebar, and settings. -[Unreleased]: https://github.com/Ron537/DPlex/compare/v0.15.0...HEAD +[Unreleased]: https://github.com/Ron537/DPlex/compare/v0.16.0...HEAD +[0.16.0]: https://github.com/Ron537/DPlex/compare/v0.15.0...v0.16.0 [0.15.0]: https://github.com/Ron537/DPlex/compare/v0.14.2...v0.15.0 [0.14.2]: https://github.com/Ron537/DPlex/compare/v0.14.1...v0.14.2 [0.14.1]: https://github.com/Ron537/DPlex/compare/v0.14.0...v0.14.1 diff --git a/package.json b/package.json index bf51ae5..f6a5ebf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dplex", - "version": "0.15.0", + "version": "0.16.0", "description": "A terminal multiplexer built for AI-assisted development. Manage multiple AI CLI sessions (Copilot, Claude, and more) alongside regular terminals in one window.", "main": "./out/main/index.js", "author": { diff --git a/src/renderer/src/components/layout/SidePanel.tsx b/src/renderer/src/components/layout/SidePanel.tsx index 6f47362..62fb64d 100644 --- a/src/renderer/src/components/layout/SidePanel.tsx +++ b/src/renderer/src/components/layout/SidePanel.tsx @@ -15,6 +15,7 @@ import { useProvidersStore } from '../../stores/providersStore' import { useProjectStore } from '../../stores/projectStore' import { SessionList } from '../sessions/SessionList' import { ProjectList } from '../projects/ProjectList' +import { TagFilterBar } from '../projects/TagFilterBar' import { ProjectPanelFooter } from '../projects/ProjectPanelFooter' import { SessionPanelFooter } from '../sessions/SessionPanelFooter' import { GitSidePanelView } from '../git/GitSidePanelView' @@ -54,6 +55,7 @@ export function SidePanel(): React.JSX.Element | null { const [showFilterMenu, setShowFilterMenu] = useState(false) const [projectSearchQuery, setProjectSearchQuery] = useState('') const [projectActiveOnly, setProjectActiveOnly] = useState(false) + const [projectTagFilter, setProjectTagFilter] = useState(null) const [showProjectFilterMenu, setShowProjectFilterMenu] = useState(false) // Collapse-all signal for SessionList groups. The nonce bumps each time // the user clicks the toolbar button so each can react @@ -460,7 +462,14 @@ export function SidePanel(): React.JSX.Element | null {
{activeTab === 'projects' ? ( - + <> + + + ) : ( {panelCollapsed ? : } +
{activeSessionCount > 0 && ( diff --git a/src/renderer/src/components/projects/InlineTagList.tsx b/src/renderer/src/components/projects/InlineTagList.tsx new file mode 100644 index 0000000..fe5708d --- /dev/null +++ b/src/renderer/src/components/projects/InlineTagList.tsx @@ -0,0 +1,150 @@ +import { useLayoutEffect, useRef, useState } from 'react' +import { TagPill } from './TagPill' + +interface InlineTagListProps { + tags: readonly string[] +} + +/** Gap between tag pills, in px. Mirrors the Tailwind `gap-1` we apply to + * the row so width math matches what flex actually lays out. */ +const GAP_PX = 4 + +/** + * Renders project tag pills inline, fitting as many as actually fit in the + * available row width. Anything that doesn't fit is rolled into a neutral + * `+N` chip with a tooltip listing the hidden tags. + * + * Strategy: + * 1. Render an invisible measurement layer containing every tag pill plus + * a `+N` sample chip. The layer is absolutely-positioned inside the + * container so it doesn't influence layout but its children still get + * real widths from the browser. + * 2. After layout, compute the largest prefix that fits (including the + * `+N` chip's width whenever there will be overflow). + * 3. Render the visible row from that prefix; rerun on container resize. + * + * Two renders per resize is fine — the second one is cheap (same DOM, just + * fewer visible nodes) and only happens when the container actually changes + * size. The measurement layer is keyed on the tag list so adding/removing a + * tag re-runs measurement automatically. + */ +export function InlineTagList({ tags }: InlineTagListProps): React.JSX.Element | null { + const containerRef = useRef(null) + const measureRef = useRef(null) + const [visibleCount, setVisibleCount] = useState(tags.length) + + useLayoutEffect(() => { + const container = containerRef.current + const measure = measureRef.current + if (!container || !measure) return + + const recompute = (): void => { + const available = container.clientWidth + const measureChildren = Array.from( + measure.querySelectorAll('[data-tag-measure]') + ) + const plusEl = measure.querySelector('[data-tag-plus]') + if (measureChildren.length !== tags.length) return + const widths = measureChildren.map((el) => el.offsetWidth) + const plusWidth = plusEl?.offsetWidth ?? 0 + + // Fast path: does the entire list fit? + const totalAll = + widths.reduce((s, w) => s + w, 0) + Math.max(0, widths.length - 1) * GAP_PX + if (totalAll <= available) { + setVisibleCount((prev) => (prev === tags.length ? prev : tags.length)) + return + } + + // Otherwise find the largest k such that the first k pills plus a + // `+N` chip fit. The `+N` chip itself needs space, so we always + // account for its width + a gap before it. + let acc = 0 + let k = 0 + for (let i = 0; i < widths.length; i++) { + const candidate = acc + (k > 0 ? GAP_PX : 0) + widths[i] + GAP_PX + plusWidth + if (candidate > available) break + acc += (k > 0 ? GAP_PX : 0) + widths[i] + k++ + } + setVisibleCount((prev) => (prev === k ? prev : k)) + } + + recompute() + const ro = new ResizeObserver(recompute) + ro.observe(container) + return () => ro.disconnect() + }, [tags]) + + if (tags.length === 0) return null + + const hiddenCount = Math.max(0, tags.length - visibleCount) + const visibleTags = tags.slice(0, visibleCount) + const hiddenTags = tags.slice(visibleCount) + + return ( +
+ {/* Invisible measurement layer — same pills + a sample `+N` chip used + only for width readings. Absolute so it doesn't affect layout. */} +
+ {tags.map((t) => ( + + + + ))} + + + +
+ + {/* Visible row */} + {visibleTags.map((t) => ( + + ))} + {hiddenCount > 0 && ( + + )} +
+ ) +} + +function PlusBadge({ + count, + tooltipTags +}: { + count: number + tooltipTags?: readonly string[] +}): React.JSX.Element { + return ( + `#${t}`).join(', ') : undefined} + className="inline-flex items-center rounded-full font-medium leading-none whitespace-nowrap" + style={{ + fontSize: 9.5, + padding: '1px 5px', + backgroundColor: 'var(--dplex-bg-input)', + color: 'var(--dplex-text-muted)', + border: '1px solid var(--dplex-border)', + userSelect: 'none', + flexShrink: 0 + }} + > + +{count} + + ) +} diff --git a/src/renderer/src/components/projects/ProjectAvatar.tsx b/src/renderer/src/components/projects/ProjectAvatar.tsx new file mode 100644 index 0000000..7616894 --- /dev/null +++ b/src/renderer/src/components/projects/ProjectAvatar.tsx @@ -0,0 +1,42 @@ +import { memo } from 'react' +import { getAvatarColor, getAvatarInitials } from '../../utils/projectStatus' + +interface ProjectAvatarProps { + /** Stable id used to derive the deterministic avatar color. */ + projectId: string + /** Project name used to derive the 1-2 letter glyph. */ + name: string + /** Square size in px. Defaults to 22 — the size used in the command palette. */ + size?: number +} + +/** + * Small square project avatar — deterministic color + initials glyph derived + * from the project's id/name. Used in surfaces that aren't `ProjectItem` + * (e.g. the command palette) so they share the same visual identity as the + * sidebar without duplicating the styling logic. + */ +export const ProjectAvatar = memo(function ProjectAvatar({ + projectId, + name, + size = 22 +}: ProjectAvatarProps): React.JSX.Element { + const color = getAvatarColor(projectId) + const initials = getAvatarInitials(name) + return ( + + {initials} + + ) +}) diff --git a/src/renderer/src/components/projects/ProjectItem.tsx b/src/renderer/src/components/projects/ProjectItem.tsx index 1e097fe..e6e2fe0 100644 --- a/src/renderer/src/components/projects/ProjectItem.tsx +++ b/src/renderer/src/components/projects/ProjectItem.tsx @@ -14,7 +14,8 @@ import { PinOff, ArrowUp, ArrowDown, - GitCompare + GitCompare, + Tag as TagIcon } from 'lucide-react' import type { Project, AISession, ProviderInfo, WorktreeDefaults } from '../../types' import { useProjectStore } from '../../stores/projectStore' @@ -29,6 +30,8 @@ import { isMixedProviderList } from '../../utils/providerHelpers' import { aggregateVisual } from '../../utils/aggregateVisual' import { PromptsDialog } from '../sessions/PromptsDialog' import { ProjectSessionList, selectRecentSessions } from './ProjectSessionList' +import { InlineTagList } from './InlineTagList' +import { TagPickerPopover } from './TagPickerPopover' import { WorktreeSection } from './WorktreeSection' import type { ProjectActivity } from '../../hooks/useProjectSessions' import { focusFirstTabForPaths } from '../../utils/sessionTabs' @@ -52,6 +55,10 @@ interface ProjectItemProps { moveUpTargetId?: string | null /** Id of the next top-level sibling in the same pinned group (for "Move down"). */ moveDownTargetId?: string | null + /** Render the expanded body regardless of the user's persisted expansion + * state. Used by `ProjectList` in filter mode so a matched parent shows + * its full worktree subtree even when the user had it collapsed. */ + forceExpanded?: boolean } /** @@ -78,7 +85,8 @@ export function ProjectItem({ childProjects, getActivity, moveUpTargetId = null, - moveDownTargetId = null + moveDownTargetId = null, + forceExpanded = false }: ProjectItemProps): React.JSX.Element { const expandedIds = useProjectStore((s) => s.expandedProjectIds) const toggleExpanded = useProjectStore((s) => s.toggleExpanded) @@ -107,6 +115,8 @@ export function ProjectItem({ const [manageOpen, setManageOpen] = useState(false) const [defaultsOpen, setDefaultsOpen] = useState(false) const [removeWtOpen, setRemoveWtOpen] = useState(false) + const [tagPickerOpen, setTagPickerOpen] = useState(false) + const tagPickerAnchorRef = useRef(null) const menuAnchorRef = useRef(null) // Virtual anchor for right-click context menu — positioned at the cursor // so the menu opens where the user clicked (not next to the ⋯ button). @@ -129,7 +139,7 @@ export function ProjectItem({ const watchPath = isWorktreeProject ? (project.parentRepoPath ?? project.path) : project.path const { repoRoot } = useWorktrees(needWtWatch ? watchPath : undefined) - const isExpanded = expandedIds.has(project.id) + const isExpanded = forceExpanded || expandedIds.has(project.id) // The project row reads as "active" when it's the directly-active project // OR when one of its worktree-child projects is active. The latter case // is the "ambient parent highlight" — selecting a worktree section under @@ -205,6 +215,7 @@ export function ProjectItem({ alone, and the expanded body hangs below with an indent + dashed guide line (matches the mockup's `.proj-children`). */}
+ {/* Line 3 (tags) — rendered on its own row so the hover-revealed + action buttons (absolute, vertically-centered) only mask the + branch+time line and leave tags fully visible. `InlineTagList` + measures actual pill widths and surfaces overflow as a `+N` + chip whose tooltip lists the hidden tags. */} + {project.tags && project.tags.length > 0 && ( + + )}
{/* Chevron — right-aligned, grey. Clicking always toggles expansion, @@ -561,6 +578,20 @@ export function ProjectItem({ )} +
+ + {canPin && ( <>
@@ -627,6 +658,13 @@ export function ProjectItem({ {isWorktreeProject ? 'Remove worktree…' : 'Remove Project'} + + setTagPickerOpen(false)} + anchorRef={tagPickerAnchorRef} + />
{/* Expanded body — nested under the project row with a dashed left diff --git a/src/renderer/src/components/projects/ProjectList.tsx b/src/renderer/src/components/projects/ProjectList.tsx index 0ac4799..54700e2 100644 --- a/src/renderer/src/components/projects/ProjectList.tsx +++ b/src/renderer/src/components/projects/ProjectList.tsx @@ -26,6 +26,9 @@ const EMPTY_ATTENTION: AttentionEvent[] = [] interface ProjectListProps { searchQuery?: string activeOnly?: boolean + /** Single tag to filter by. A project matches if it carries this tag OR + * has a worktree child that carries it. Null/undefined means no tag filter. */ + tagFilter?: string | null /** Render the rail-style avatar-only column instead of full rows. */ compact?: boolean } @@ -38,6 +41,7 @@ interface ProjectEntry { export function ProjectList({ searchQuery, activeOnly, + tagFilter, compact }: ProjectListProps): React.JSX.Element { const projects = useProjectStore((s) => s.projects) @@ -104,7 +108,7 @@ export function ProjectList({ return m }, [projects]) - const hasFilter = Boolean(searchQuery) || Boolean(activeOnly) + const hasFilter = Boolean(searchQuery) || Boolean(activeOnly) || Boolean(tagFilter) // Render-order projects. Only top-level origins render directly here — their // worktree children render INSIDE the parent's expanded body (so they share @@ -119,6 +123,7 @@ export function ProjectList({ const matchesFilter = (p: Project): boolean => { if (q && !p.name.toLowerCase().includes(q)) return false if (activeOnly && !sessionIndex.get(p.path)?.hasActive) return false + if (tagFilter && !(p.tags?.includes(tagFilter) ?? false)) return false return true } @@ -134,13 +139,22 @@ export function ProjectList({ if (!isTopLevel(p)) continue const kids = childrenByParent.get(p.id) ?? [] - const visibleKids = kids.filter(matchesFilter) const parentMatches = matchesFilter(p) if (hasFilter) { - // Flat match list while filtering — pinning ignored for clarity. - if (parentMatches) restOut.push({ project: p }) - for (const kid of visibleKids) restOut.push({ project: kid }) + if (parentMatches) { + // Parent matches → render with the FULL worktree subtree, even if + // individual worktrees don't satisfy the filter. A matching parent + // is a strong "I want this project" signal, and worktrees inherit + // that context (a tag like `#client-acme` on the origin clearly + // applies to its branches too). + restOut.push({ project: p, children: kids }) + } else { + // Parent doesn't match → surface any worktree children that DO + // match as top-level rows so a matching branch isn't hidden + // behind a non-matching origin. + for (const kid of kids.filter(matchesFilter)) restOut.push({ project: kid }) + } } else { const entry: ProjectEntry = { project: p, children: kids } if (p.pinned) pinnedOut.push(entry) @@ -148,7 +162,7 @@ export function ProjectList({ } } return { pinned: pinnedOut, rest: restOut } - }, [projects, childrenByParent, searchQuery, activeOnly, sessionIndex, hasFilter]) + }, [projects, childrenByParent, searchQuery, activeOnly, tagFilter, sessionIndex, hasFilter]) const totalVisible = pinned.length + rest.length @@ -206,6 +220,7 @@ export function ProjectList({ providers={providers} moveUpTargetId={index > 0 ? list[index - 1].project.id : null} moveDownTargetId={index < list.length - 1 ? list[index + 1].project.id : null} + forceExpanded={hasFilter && children !== undefined && children.length > 0} /> ) diff --git a/src/renderer/src/components/projects/TagFilterBar.tsx b/src/renderer/src/components/projects/TagFilterBar.tsx new file mode 100644 index 0000000..b35a1c7 --- /dev/null +++ b/src/renderer/src/components/projects/TagFilterBar.tsx @@ -0,0 +1,75 @@ +import { useEffect, useMemo } from 'react' +import { useProjectStore } from '../../stores/projectStore' +import { collectTagCounts } from '../../utils/projectTags' +import { TagPill } from './TagPill' + +interface TagFilterBarProps { + /** Currently selected tag, or null for "All". */ + value: string | null + onChange: (next: string | null) => void +} + +/** + * Horizontal strip of tag-filter chips rendered between the projects search + * input and the project list. Renders nothing when no project has any tag — + * keeps the panel uncluttered for users who don't use tags. + * + * Single-select for now: clicking a tag activates it; clicking it again (or + * clicking "All") clears. Multi-select / AND-composition is a future slice. + */ +export function TagFilterBar({ value, onChange }: TagFilterBarProps): React.JSX.Element | null { + const projects = useProjectStore((s) => s.projects) + const tagCounts = useMemo(() => collectTagCounts(projects), [projects]) + + // Self-heal a stale filter: if the active tag no longer exists on any + // project (last project carrying it was removed or untagged), clear the + // filter so the user isn't left with an empty list and no visible reset + // affordance. Done in an effect so the parent owns the state. + const tagExists = !value || tagCounts.some((t) => t.tag === value) + useEffect(() => { + if (!tagExists) onChange(null) + }, [tagExists, onChange]) + + if (tagCounts.length === 0) return null + + const totalCount = projects.length + + return ( +
+ + {tagCounts.map(({ tag, count }) => ( + onChange(value === tag ? null : tag)} + /> + ))} +
+ ) +} diff --git a/src/renderer/src/components/projects/TagPickerPopover.tsx b/src/renderer/src/components/projects/TagPickerPopover.tsx new file mode 100644 index 0000000..3f456b9 --- /dev/null +++ b/src/renderer/src/components/projects/TagPickerPopover.tsx @@ -0,0 +1,369 @@ +import { useMemo, useRef, useState, useEffect, type RefObject } from 'react' +import { Check, Plus, Tag, X } from 'lucide-react' +import { PopoverMenu } from '../common/PopoverMenu' +import { useProjectStore } from '../../stores/projectStore' +import { useSettingsStore } from '../../stores/settingsStore' +import { normalizeTag, collectTagCounts, TAG_PALETTE, getTagColor } from '../../utils/projectTags' +import { TagPill } from './TagPill' + +interface TagPickerPopoverProps { + projectId: string + open: boolean + onClose: () => void + anchorRef: RefObject +} + +/** + * Multi-select tag editor for a single project. Opens from the project's + * context menu and lets the user toggle existing tags or create new ones. + * + * Suggestions come from the union of all tags across all projects so users + * can pick from a shared vocabulary without retyping. The input doubles as + * a filter for that list and as a "create tag" affordance — pressing Enter + * commits the normalized input as a new tag on this project. + */ +export function TagPickerPopover({ + projectId, + open, + onClose, + anchorRef +}: TagPickerPopoverProps): React.JSX.Element | null { + const projects = useProjectStore((s) => s.projects) + const addProjectTag = useProjectStore((s) => s.addProjectTag) + const removeProjectTag = useProjectStore((s) => s.removeProjectTag) + const tagColors = useSettingsStore((s) => s.settings.tagColors) + const updateSettings = useSettingsStore((s) => s.updateSettings) + const project = projects.find((p) => p.id === projectId) + + const [input, setInput] = useState('') + /** Which tag currently has its swatch row expanded. Single-target so the + * popover height stays predictable; clicking another tag's swatch swap + * closes the previous one. */ + const [colorTarget, setColorTarget] = useState(null) + /** Color the user has picked for the tag they're about to create. While + * typing, the create preview pill is locked to this color so it doesn't + * shimmer between palette entries as the hash of the input changes on + * every keystroke. Defaults to the first palette entry; resets when the + * popover closes or after a successful commit. */ + const [draftColor, setDraftColor] = useState(TAG_PALETTE[0].id) + const inputRef = useRef(null) + + // Reset & focus when the popover opens; on close, reset transient picker + // state. Both branches defer their setState calls through a timeout so the + // effect body itself stays free of synchronous setState (satisfies + // react-hooks/set-state-in-effect) and so the close-time resets run after + // the PopoverMenu has unmounted. + useEffect(() => { + if (!open) { + const t0 = setTimeout(() => { + setColorTarget(null) + setDraftColor(TAG_PALETTE[0].id) + }, 0) + return () => clearTimeout(t0) + } + const t = setTimeout(() => { + setInput('') + inputRef.current?.focus() + }, 30) + return () => clearTimeout(t) + }, [open]) + + const allTags = useMemo(() => collectTagCounts(projects).map((c) => c.tag), [projects]) + + const currentTags = useMemo(() => project?.tags ?? [], [project?.tags]) + const currentSet = useMemo(() => new Set(currentTags), [currentTags]) + + const normalizedInput = normalizeTag(input) + // Filter using the normalized input so typing `#infra` matches the stored + // tag `infra`. Falling back to the raw lowercased input would hide every + // existing suggestion the moment the user types `#`. + const filterKey = normalizedInput ?? input.trim().toLowerCase() + const filtered = useMemo(() => { + if (!filterKey) return allTags + return allTags.filter((t) => t.includes(filterKey)) + }, [allTags, filterKey]) + + const canCreate = normalizedInput !== null && !allTags.includes(normalizedInput) + + const commit = (tag: string): void => { + addProjectTag(projectId, tag) + // Only persist a color when creating a brand-new tag — pressing Enter + // on an existing tag (whose Create row isn't shown) must not silently + // overwrite its globally-shared color. + if (canCreate) { + const autoId = getTagColor(tag).id + if (draftColor !== autoId) { + setTagColor(tag, draftColor) + } + } + setInput('') + setDraftColor(TAG_PALETTE[0].id) + inputRef.current?.focus() + } + + // Color picker writes to the shared `tagColors` settings record (a tag's + // color is global, not per-project). Passing `null` removes the override + // and the tag falls back to the deterministic hash color. + const setTagColor = (tag: string, colorId: string | null): void => { + const next: Record = { ...(tagColors ?? {}) } + if (colorId === null) delete next[tag] + else next[tag] = colorId + void updateSettings({ tagColors: next }) + } + + if (!project) return null + + return ( + +
+
+ + Tags for {project.name} +
+ + {currentTags.length > 0 && ( +
+ {currentTags.map((t) => ( + { + e.stopPropagation() + removeProjectTag(projectId, t) + }} + title={`Remove #${t}`} + /> + ))} +
+ )} + + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + if (normalizedInput) commit(normalizedInput) + } else if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + }} + className="w-full text-[12px] outline-none" + style={{ + backgroundColor: 'var(--dplex-bg-input)', + border: '1px solid var(--dplex-border)', + borderRadius: 6, + color: 'var(--dplex-text)', + padding: '5px 8px', + fontFamily: 'inherit' + }} + /> +
+ +
+ {filtered.length === 0 && !canCreate && ( +
+ No tags yet. Type to create one. +
+ )} + {filtered.map((tag) => { + const isOn = currentSet.has(tag) + const isColorOpen = colorTarget === tag + const currentColorId = tagColors?.[tag] ?? null + return ( +
+
+ + +
+ {isColorOpen && ( +
+ {TAG_PALETTE.map((c) => { + const selected = currentColorId === c.id + return ( + + ) + })} + +
+ )} +
+ ) + })} + {canCreate && normalizedInput && ( +
0 ? '1px solid var(--dplex-border)' : 'none' + }} + > + +
+ {TAG_PALETTE.map((c) => { + const selected = draftColor === c.id + return ( + + ) + })} +
+
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/projects/TagPill.tsx b/src/renderer/src/components/projects/TagPill.tsx new file mode 100644 index 0000000..c166fd5 --- /dev/null +++ b/src/renderer/src/components/projects/TagPill.tsx @@ -0,0 +1,80 @@ +import { memo } from 'react' +import { useSettingsStore } from '../../stores/settingsStore' +import { getTagColor } from '../../utils/projectTags' + +interface TagPillProps { + tag: string + /** When true, render in selected/filter-active style (accent ring). */ + active?: boolean + /** Optional count rendered as a small suffix — used by the filter strip. */ + count?: number + /** Render the leading `#` so chips read as `#infra`. Default true. */ + showHash?: boolean + /** Click handler — when present, the chip is rendered as a real button + * with hover/focus affordances. */ + onClick?: (e: React.MouseEvent) => void + /** Optional title for hover/accessibility. */ + title?: string + /** Compact mode shrinks padding & font for inline rendering on project rows. */ + compact?: boolean + /** Force a specific palette colour. When omitted, the user's saved override + * is used; if no override exists, falls back to a hash of the tag name. */ + colorOverride?: string | null +} + +/** + * Pill used both on project rows (compact) and in the sidebar filter strip + * (regular). Colour comes from the shared TAG_PALETTE so it reads on both + * light and dark themes; users can change it via the tag picker. + */ +export const TagPill = memo(function TagPill({ + tag, + active, + count, + showHash = true, + onClick, + title, + compact, + colorOverride +}: TagPillProps): React.JSX.Element { + const savedOverride = useSettingsStore((s) => s.settings.tagColors?.[tag]) + const effectiveOverride = colorOverride !== undefined ? colorOverride : savedOverride + const { bg, fg } = getTagColor(tag, effectiveOverride) + const fontSize = compact ? 9.5 : 11 + const padding = compact ? '1px 5px' : '2px 8px' + const Cmp = (onClick ? 'button' : 'span') as 'button' | 'span' + return ( + + + {showHash ? '#' : ''} + {tag} + + {typeof count === 'number' && ( + {count} + )} + + ) +}) diff --git a/src/renderer/src/components/search/CommandPalette.tsx b/src/renderer/src/components/search/CommandPalette.tsx index 3ae6244..6bd50d2 100644 --- a/src/renderer/src/components/search/CommandPalette.tsx +++ b/src/renderer/src/components/search/CommandPalette.tsx @@ -70,7 +70,9 @@ export function CommandPalette(): React.JSX.Element | null { } const placeholder = - mode === 'commands' ? 'Type a command…' : 'Search projects, sessions, settings…' + mode === 'commands' + ? 'Type a command…' + : 'Search projects, sessions, settings… (try #tag)' // Compute the active descendant id for the input. let active: string | undefined diff --git a/src/renderer/src/components/search/SearchResultsList.tsx b/src/renderer/src/components/search/SearchResultsList.tsx index 01a220f..188cee0 100644 --- a/src/renderer/src/components/search/SearchResultsList.tsx +++ b/src/renderer/src/components/search/SearchResultsList.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react' import type { MatchRange, RankedItem, SearchResultGroup } from '../../services/search/types' +import { TagPill } from '../projects/TagPill' interface SearchResultsListProps { groups: SearchResultGroup[] @@ -120,6 +121,9 @@ export function SearchResultsList({ data-testid="search-result" data-search-item-id={ranked.item.id} > + {ranked.item.icon && ( + {ranked.item.icon} + )}
@@ -132,6 +136,13 @@ export function SearchResultsList({ {ranked.item.description}
)} + {ranked.item.tags && ranked.item.tags.length > 0 && ( +
+ {ranked.item.tags.map((t) => ( + + ))} +
+ )}
{ranked.item.hint && ( { const isWorktree = p.parentProjectId !== undefined const description = isWorktree ? `Worktree · ${p.path}` : p.path + // Tag keywords are emitted both bare (`infra`) and with a leading `#` + // so users can type `#infra` to filter the palette and have it match. + const tagKeywords = + p.tags && p.tags.length > 0 ? [...p.tags, ...p.tags.map((t) => `#${t}`)] : [] const item: SearchItem = { id: `project:${p.id}`, category: 'projects', label: p.name, description, - keywords: [pathBasename(p.path), p.path, ...(p.pinned ? ['pinned'] : [])], + keywords: [pathBasename(p.path), p.path, ...(p.pinned ? ['pinned'] : []), ...tagKeywords], + ...(p.tags && p.tags.length > 0 ? { tags: [...p.tags] } : {}), + icon: createElement(ProjectAvatar, { projectId: p.id, name: p.name }), run: () => openProject(p.id) } if (p.parentRepoName) { diff --git a/src/renderer/src/services/search/types.ts b/src/renderer/src/services/search/types.ts index a98cec3..23d9ad8 100644 --- a/src/renderer/src/services/search/types.ts +++ b/src/renderer/src/services/search/types.ts @@ -54,6 +54,13 @@ export interface SearchItem { /** Optional keywords that the matcher considers in addition to `label`. * Used to make settings findable by synonyms (e.g. "color" → theme). */ keywords?: string[] + /** Optional list of tag names to render as colored pills next to the + * result row. Used for project items so users can see which tags caused + * a match (and so the palette doubles as a tag browser). */ + tags?: string[] + /** Optional leading icon node (e.g. project avatar, lucide icon). Sources + * pass arbitrary JSX so they aren't constrained to a single icon shape. */ + icon?: React.ReactNode /** Action invoked when the user picks this item. May be async. */ run: () => void | Promise } diff --git a/src/renderer/src/stores/projectStore.ts b/src/renderer/src/stores/projectStore.ts index 5d7808d..5a598cf 100644 --- a/src/renderer/src/stores/projectStore.ts +++ b/src/renderer/src/stores/projectStore.ts @@ -3,6 +3,7 @@ import type { Project, ProjectGitPanelState, ProjectWorktreeOverrides } from '.. import { useSettingsStore } from './settingsStore' import { useTerminalStore } from './terminalStore' import { normalizePath } from '../hooks/useProjectSessions' +import { normalizeTag, normalizeTags } from '../utils/projectTags' function generateId(): string { return `proj-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` @@ -57,6 +58,14 @@ interface ProjectState { setActiveProject: (id: string | null) => void setProjectGitState: (id: string, patch: ProjectGitPanelState) => void togglePin: (id: string) => void + /** Replace this project's tags with the given list (will be normalized). + * Use this from the tag picker after committing edits. */ + setProjectTags: (id: string, tags: readonly string[]) => void + /** Convenience: add a single tag (idempotent). Returns nothing; callers + * read the next render. */ + addProjectTag: (id: string, tag: string) => void + /** Convenience: remove a single tag. No-op if not present. */ + removeProjectTag: (id: string, tag: string) => void startAISession: (project: Project, providerId?: string) => Promise updateProjectWorktreeOverrides: ( projectId: string, @@ -263,6 +272,49 @@ export const useProjectStore = create((set, get) => ({ get().persistProjects() }, + setProjectTags: (id, tags) => { + const normalized = normalizeTags(tags) + let changed = false + set((state) => { + const idx = state.projects.findIndex((p) => p.id === id) + if (idx === -1) return state + const current = state.projects[idx] + const before = current.tags ?? [] + if (before.length === normalized.length && before.every((t, i) => t === normalized[i])) { + return state + } + changed = true + const next: Project = + normalized.length === 0 ? { ...current, tags: undefined } : { ...current, tags: normalized } + const newProjects = [...state.projects] + newProjects[idx] = next + return { projects: newProjects } + }) + if (changed) get().persistProjects() + }, + + addProjectTag: (id, tag) => { + const t = normalizeTag(tag) + if (!t) return + const { projects, setProjectTags } = get() + const current = projects.find((p) => p.id === id) + if (!current) return + if (current.tags?.includes(t)) return + setProjectTags(id, [...(current.tags ?? []), t]) + }, + + removeProjectTag: (id, tag) => { + const t = normalizeTag(tag) + if (!t) return + const { projects, setProjectTags } = get() + const current = projects.find((p) => p.id === id) + if (!current || !current.tags?.includes(t)) return + setProjectTags( + id, + current.tags.filter((x) => x !== t) + ) + }, + reorderProject: (draggedId, targetId, position) => { if (draggedId === targetId) return const { projects } = get() diff --git a/src/renderer/src/stores/settingsStore.ts b/src/renderer/src/stores/settingsStore.ts index f5f949b..c8322be 100644 --- a/src/renderer/src/stores/settingsStore.ts +++ b/src/renderer/src/stores/settingsStore.ts @@ -150,6 +150,7 @@ const DEFAULT_SETTINGS: AppSettings = { attentionClickClearsWaiting: false, worktreeDefaults: DEFAULT_WORKTREE_DEFAULTS, projectPanelShowFooter: true, + tagColors: {}, gitPanel: { open: false, width: 300, diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 58d24b2..60066cb 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -147,6 +147,11 @@ export interface AppSettings { worktreeDefaults: WorktreeDefaults /** Show the health footer bar at the bottom of the Projects panel. */ projectPanelShowFooter: boolean + /** Per-tag color overrides keyed by the normalized tag name. Value is a + * `TAG_PALETTE` token id (e.g. `"violet"`). Tags without an entry fall + * back to a deterministic hash of the tag name, so existing tags keep + * their visual identity until the user picks an explicit color. */ + tagColors?: Record /** Right-side Git panel UI state. */ gitPanel: GitPanelSettings /** @@ -203,6 +208,10 @@ export interface Project { createdByDplexWorktree?: boolean /** Pinned projects render in a dedicated section at the top of the panel. */ pinned?: boolean + /** Free-form user-defined tags. Normalized lowercase, leading `#` stripped, + * deduped. Used for filtering the projects sidebar and for fuzzy matching + * in the command palette. Absent / empty array both mean "no tags". */ + tags?: string[] /** Git panel UI state scoped to this project. Persists across sessions * so the user lands back on the file they last opened. Validated on * each refresh — stale paths fall back to the first changed file. */ diff --git a/src/renderer/src/utils/projectTags.ts b/src/renderer/src/utils/projectTags.ts new file mode 100644 index 0000000..3a1ebae --- /dev/null +++ b/src/renderer/src/utils/projectTags.ts @@ -0,0 +1,120 @@ +import type { Project } from '../types' + +/** Maximum length of a single normalized tag. Keeps storage bounded and + * prevents pathological UI overflow from pasted text. */ +export const MAX_TAG_LENGTH = 32 + +/** + * Theme-safe palette used by tag pills. Same pattern as `AVATAR_PALETTE` in + * `projectStatus.ts` — translucent background so the pill tints any panel + * color cleanly, plus a saturated foreground that reads on both light and + * dark themes. Each entry's `id` is what gets persisted in `tagColors`. + * + * Order is the order shown in the color picker swatch grid; keep it stable + * because users will memorize positions ("third swatch = my client tag"). + */ +export interface TagColorToken { + id: string + label: string + bg: string + fg: string +} + +export const TAG_PALETTE: readonly TagColorToken[] = [ + { id: 'blue', label: 'Blue', bg: 'rgba(124,156,255,0.20)', fg: '#7c9cff' }, + { id: 'violet', label: 'Violet', bg: 'rgba(167,139,250,0.20)', fg: '#a78bfa' }, + { id: 'green', label: 'Green', bg: 'rgba(60,207,145,0.20)', fg: '#3ccf91' }, + { id: 'amber', label: 'Amber', bg: 'rgba(240,179,90,0.22)', fg: '#d9921f' }, + { id: 'red', label: 'Red', bg: 'rgba(239,106,106,0.20)', fg: '#ef6a6a' }, + { id: 'teal', label: 'Teal', bg: 'rgba(93,209,206,0.20)', fg: '#2db8b4' }, + { id: 'pink', label: 'Pink', bg: 'rgba(236,112,176,0.20)', fg: '#ec70b0' }, + { id: 'lime', label: 'Lime', bg: 'rgba(176,196,120,0.22)', fg: '#7c9f30' } +] + +const TAG_PALETTE_BY_ID = new Map(TAG_PALETTE.map((c) => [c.id, c])) + +function hashString(s: string): number { + let h = 0 + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0 + return Math.abs(h) +} + +/** + * Resolve the rendered colors for a tag. + * + * - If the user explicitly chose a palette entry (via the color picker) + * `overrideId` will be the palette token id and we return that entry. + * - Otherwise we deterministically hash the tag name to a palette entry, + * so the same tag string always renders with the same colour without + * the user having to set one. + * + * Unknown override ids (from older builds or hand-edited settings) fall + * back to the hashed default. + */ +export function getTagColor(tag: string, overrideId?: string | null): TagColorToken { + if (overrideId) { + const hit = TAG_PALETTE_BY_ID.get(overrideId) + if (hit) return hit + } + return TAG_PALETTE[hashString(tag) % TAG_PALETTE.length] +} + +/** + * Normalize a single user-typed tag into the canonical stored form. + * + * - Strips any leading `#` characters (users can type `#infra` or `infra`). + * - Replaces whitespace with `-` so multi-word tags survive copy/paste. + * - Lowercases. + * - Removes characters outside `[a-z0-9._-]`. + * - Trims leading/trailing separators. + * - Truncates to {@link MAX_TAG_LENGTH}. + * + * Returns `null` for inputs that normalize to empty — callers should treat + * `null` as "skip / not a valid tag" rather than storing it. + */ +export function normalizeTag(raw: string): string | null { + if (typeof raw !== 'string') return null + let s = raw.trim().toLowerCase() + while (s.startsWith('#')) s = s.slice(1) + s = s.replace(/\s+/g, '-') + s = s.replace(/[^a-z0-9._-]/g, '') + s = s.replace(/^[-._]+|[-._]+$/g, '') + if (s.length === 0) return null + if (s.length > MAX_TAG_LENGTH) s = s.slice(0, MAX_TAG_LENGTH) + return s +} + +/** + * Normalize an arbitrary list of raw tags into a deduped, sorted array of + * canonical tags. Order is alphabetical so persisted JSON is stable. + */ +export function normalizeTags(raw: readonly string[] | undefined | null): string[] { + if (!raw || raw.length === 0) return [] + const out = new Set() + for (const r of raw) { + const t = normalizeTag(r) + if (t) out.add(t) + } + return [...out].sort() +} + +/** Aggregate tag → usage count across the given projects, sorted by count + * desc then alphabetical. Used to drive the sidebar filter strip. */ +export function collectTagCounts(projects: readonly Project[]): { tag: string; count: number }[] { + const counts = new Map() + for (const p of projects) { + const tags = p.tags + if (!tags || tags.length === 0) continue + for (const t of tags) { + counts.set(t, (counts.get(t) ?? 0) + 1) + } + } + const out = [...counts.entries()].map(([tag, count]) => ({ tag, count })) + out.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)) + return out +} + +/** True when `project` carries `tag` exactly (already normalized). */ +export function projectHasTag(project: Project, tag: string): boolean { + return Array.isArray(project.tags) && project.tags.includes(tag) +} diff --git a/tests/unit/project-tags.test.ts b/tests/unit/project-tags.test.ts new file mode 100644 index 0000000..78ffe15 --- /dev/null +++ b/tests/unit/project-tags.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + MAX_TAG_LENGTH, + TAG_PALETTE, + collectTagCounts, + getTagColor, + normalizeTag, + normalizeTags, + projectHasTag +} from '../../src/renderer/src/utils/projectTags' +import { useProjectStore } from '../../src/renderer/src/stores/projectStore' +import type { Project } from '../../src/renderer/src/types' + +interface SettingsMock { + getAll: ReturnType + merge: ReturnType +} + +let settingsMock: SettingsMock + +function installWindow(): void { + settingsMock = { + getAll: vi.fn().mockResolvedValue({}), + merge: vi.fn().mockResolvedValue(undefined) + } + ;(globalThis as { window?: unknown }).window = { + dplex: { settings: settingsMock } + } +} + +function makeProject(id: string, tags?: string[]): Project { + return { + id, + name: id, + path: `/p/${id}`, + addedAt: new Date().toISOString(), + ...(tags ? { tags } : {}) + } as Project +} + +beforeEach(() => { + installWindow() + useProjectStore.setState({ projects: [], activeProjectId: null, loaded: false } as never) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('normalizeTag', () => { + it('strips leading `#`, lowercases, and trims', () => { + expect(normalizeTag(' #Infra ')).toBe('infra') + }) + + it('replaces whitespace with `-`', () => { + expect(normalizeTag('client acme')).toBe('client-acme') + }) + + it('drops disallowed characters', () => { + expect(normalizeTag('foo/bar!')).toBe('foobar') + }) + + it('keeps dots, underscores, dashes', () => { + expect(normalizeTag('node.js_v18-lts')).toBe('node.js_v18-lts') + }) + + it('returns null for empty and whitespace-only input', () => { + expect(normalizeTag('')).toBeNull() + expect(normalizeTag(' ')).toBeNull() + expect(normalizeTag('###')).toBeNull() + }) + + it('truncates to MAX_TAG_LENGTH', () => { + const long = 'a'.repeat(MAX_TAG_LENGTH + 20) + expect(normalizeTag(long)).toHaveLength(MAX_TAG_LENGTH) + }) + + it('handles non-string input gracefully', () => { + expect(normalizeTag(undefined as unknown as string)).toBeNull() + }) +}) + +describe('normalizeTags', () => { + it('dedupes and sorts alphabetically', () => { + expect(normalizeTags(['#B', 'a', 'A', 'b'])).toEqual(['a', 'b']) + }) + + it('returns [] for empty/undefined/null', () => { + expect(normalizeTags(undefined)).toEqual([]) + expect(normalizeTags(null)).toEqual([]) + expect(normalizeTags([])).toEqual([]) + }) + + it('skips entries that normalize to empty', () => { + expect(normalizeTags(['', '#', 'real'])).toEqual(['real']) + }) +}) + +describe('collectTagCounts', () => { + it('aggregates counts and sorts by frequency then alpha', () => { + const projects: Project[] = [ + makeProject('a', ['infra', 'backend']), + makeProject('b', ['infra']), + makeProject('c', ['frontend', 'infra']), + makeProject('d') + ] + expect(collectTagCounts(projects)).toEqual([ + { tag: 'infra', count: 3 }, + { tag: 'backend', count: 1 }, + { tag: 'frontend', count: 1 } + ]) + }) + + it('returns [] when no project has tags', () => { + expect(collectTagCounts([makeProject('a'), makeProject('b')])).toEqual([]) + }) +}) + +describe('projectHasTag', () => { + it('matches exact stored tag', () => { + expect(projectHasTag(makeProject('a', ['infra']), 'infra')).toBe(true) + expect(projectHasTag(makeProject('a', ['infra']), 'frontend')).toBe(false) + expect(projectHasTag(makeProject('a'), 'infra')).toBe(false) + }) +}) + +describe('getTagColor', () => { + it('returns the override entry when it exists in the palette', () => { + const violet = TAG_PALETTE.find((c) => c.id === 'violet')! + expect(getTagColor('anything', 'violet')).toEqual(violet) + }) + + it('falls back to a hashed default when no override is set', () => { + const a = getTagColor('infra') + const b = getTagColor('infra') + expect(a).toBe(b) + // Different tags should be free to hash to different swatches; the + // important invariant is that a tag is stable across calls (above). + expect(TAG_PALETTE).toContain(a) + }) + + it('ignores unknown override ids and falls back to default', () => { + const fallback = getTagColor('infra') + expect(getTagColor('infra', 'no-such-color')).toBe(fallback) + }) + + it('treats null/undefined override the same as no override', () => { + const def = getTagColor('infra') + expect(getTagColor('infra', null)).toBe(def) + expect(getTagColor('infra', undefined)).toBe(def) + }) +}) + +describe('projectStore tag actions', () => { + it('setProjectTags normalizes and persists', () => { + useProjectStore.setState({ + projects: [makeProject('p1')] + } as never) + + useProjectStore.getState().setProjectTags('p1', ['#Infra', 'backend', 'Infra']) + + const p = useProjectStore.getState().projects[0] + expect(p.tags).toEqual(['backend', 'infra']) + expect(settingsMock.merge).toHaveBeenCalledWith({ + projects: expect.arrayContaining([expect.objectContaining({ tags: ['backend', 'infra'] })]) + }) + }) + + it('setProjectTags with empty list clears the tags field', () => { + useProjectStore.setState({ + projects: [makeProject('p1', ['infra'])] + } as never) + + useProjectStore.getState().setProjectTags('p1', []) + + const p = useProjectStore.getState().projects[0] + expect(p.tags).toBeUndefined() + }) + + it('setProjectTags is a no-op when normalized result is unchanged', () => { + // Tags are already stored in the normalized (sorted) form that + // setProjectTags produces, so re-applying equivalent input should not + // touch the projects array or trigger a persist call. + useProjectStore.setState({ + projects: [makeProject('p1', ['backend', 'infra'])] + } as never) + const before = useProjectStore.getState().projects + + settingsMock.merge.mockClear() + useProjectStore.getState().setProjectTags('p1', ['Backend', '#infra']) + + expect(useProjectStore.getState().projects).toBe(before) + expect(settingsMock.merge).not.toHaveBeenCalled() + }) + + it('addProjectTag is idempotent', () => { + useProjectStore.setState({ + projects: [makeProject('p1', ['infra'])] + } as never) + + useProjectStore.getState().addProjectTag('p1', '#infra') + useProjectStore.getState().addProjectTag('p1', 'backend') + + expect(useProjectStore.getState().projects[0].tags).toEqual(['backend', 'infra']) + }) + + it('removeProjectTag removes a tag and clears the field if last', () => { + useProjectStore.setState({ + projects: [makeProject('p1', ['infra'])] + } as never) + + useProjectStore.getState().removeProjectTag('p1', '#Infra') + + expect(useProjectStore.getState().projects[0].tags).toBeUndefined() + }) + + it('addProjectTag with garbage input is a no-op', () => { + useProjectStore.setState({ + projects: [makeProject('p1')] + } as never) + + useProjectStore.getState().addProjectTag('p1', ' ') + + expect(useProjectStore.getState().projects[0].tags).toBeUndefined() + }) + + it('tag actions on a missing project are no-ops', () => { + useProjectStore.setState({ projects: [] } as never) + expect(() => useProjectStore.getState().setProjectTags('missing', ['x'])).not.toThrow() + expect(() => useProjectStore.getState().addProjectTag('missing', 'x')).not.toThrow() + expect(() => useProjectStore.getState().removeProjectTag('missing', 'x')).not.toThrow() + }) +})