bell icon

This commit is contained in:
Neil 2026-03-18 00:53:47 -07:00
parent d0ae4ef2a1
commit 4cadfaa031
6 changed files with 161 additions and 21 deletions

View file

@ -19,6 +19,8 @@ function App(): React.JSX.Element {
const fetchRepos = useAppStore((s) => s.fetchRepos)
const fetchSettings = useAppStore((s) => s.fetchSettings)
const initGitHubCache = useAppStore((s) => s.initGitHubCache)
const openModal = useAppStore((s) => s.openModal)
const repos = useAppStore((s) => s.repos)
// Subscribe to IPC push events
useIpcEvents()
@ -80,6 +82,20 @@ function App(): React.JSX.Element {
)
}
useEffect(() => {
const onKeyDown = (e: KeyboardEvent): void => {
if (e.repeat) return
if (!e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return
if (e.key.toLowerCase() !== 'n') return
if (repos.length === 0) return
e.preventDefault()
openModal('create-worktree')
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [openModal, repos.length])
return (
<div className="flex flex-col h-screen w-screen overflow-hidden">
<div className="titlebar">

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
DndContext,
closestCenter,
@ -22,6 +22,16 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { TerminalTab } from '../../../shared/types'
interface SortableTabProps {
@ -80,6 +90,29 @@ function SortableTab({
}
const [menuOpen, setMenuOpen] = useState(false)
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
const [renameOpen, setRenameOpen] = useState(false)
const [renameValue, setRenameValue] = useState('')
const renameInputRef = useRef<HTMLInputElement>(null)
const handleRenameOpen = useCallback(() => {
setRenameValue(tab.customTitle ?? tab.title)
setRenameOpen(true)
}, [tab.customTitle, tab.title])
const handleRenameSubmit = useCallback(() => {
const trimmed = renameValue.trim()
onSetCustomTitle(tab.id, trimmed.length > 0 ? trimmed : null)
setRenameOpen(false)
}, [renameValue, onSetCustomTitle, tab.id])
useEffect(() => {
if (!renameOpen) return
const frame = requestAnimationFrame(() => {
renameInputRef.current?.focus()
renameInputRef.current?.select()
})
return () => cancelAnimationFrame(frame)
}, [renameOpen])
useEffect(() => {
const closeMenu = (): void => setMenuOpen(false)
@ -181,16 +214,7 @@ function SortableTab({
Close Tabs To The Right
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
const next = window.prompt('Change tab title', tab.customTitle ?? tab.title)
if (next === null) return
const trimmed = next.trim()
onSetCustomTitle(tab.id, trimmed.length > 0 ? trimmed : null)
}}
>
Change Title
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleRenameOpen}>Change Title</DropdownMenuItem>
<div className="px-2 pt-1.5 pb-1">
<div className="text-xs font-medium text-muted-foreground mb-1.5">Tab Color</div>
<div className="flex flex-wrap gap-2">
@ -223,6 +247,44 @@ function SortableTab({
</div>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-sm">Change Tab Title</DialogTitle>
<DialogDescription className="text-xs">
Leave empty to reset to the default title.
</DialogDescription>
</DialogHeader>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault()
handleRenameSubmit()
}}
>
<Input
ref={renameInputRef}
value={renameValue}
onChange={(event) => setRenameValue(event.target.value)}
className="h-8 text-xs"
autoFocus
/>
<DialogFooter>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setRenameOpen(false)}
>
Cancel
</Button>
<Button type="submit" size="sm">
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -82,6 +82,13 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
}
}, [isOpen, repos, repoId])
// Safety guard: creating a worktree requires at least one repo.
React.useEffect(() => {
if (isOpen && repos.length === 0) {
handleOpenChange(false)
}
}, [isOpen, repos.length, handleOpenChange])
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">

View file

@ -6,6 +6,8 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip
const SidebarHeader = React.memo(function SidebarHeader() {
const openModal = useAppStore((s) => s.openModal)
const repos = useAppStore((s) => s.repos)
const canCreateWorktree = repos.length > 0
return (
<div className="flex items-center justify-between px-4 pt-3 pb-1">
@ -17,14 +19,18 @@ const SidebarHeader = React.memo(function SidebarHeader() {
<Button
variant="ghost"
size="icon-xs"
onClick={() => openModal('create-worktree')}
onClick={() => {
if (!canCreateWorktree) return
openModal('create-worktree')
}}
aria-label="Add worktree"
disabled={!canCreateWorktree}
>
<Plus className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
New worktree
{canCreateWorktree ? 'New worktree (⌘N)' : 'Add a repo to create worktrees'}
</TooltipContent>
</Tooltip>
</div>

View file

@ -2,6 +2,8 @@ import React, { useEffect, useMemo, useRef, useCallback } from 'react'
import { useAppStore } from '@/store'
import { Badge } from '@/components/ui/badge'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Bell } from 'lucide-react'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
import StatusIndicator from './StatusIndicator'
import WorktreeContextMenu from './WorktreeContextMenu'
@ -54,12 +56,26 @@ interface WorktreeCardProps {
isActive: boolean
}
function FilledBellIcon({ className }: { className?: string }): React.JSX.Element {
return (
<svg viewBox="0 0 24 24" aria-hidden className={className}>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M5.25 9A6.75 6.75 0 0 1 12 2.25 6.75 6.75 0 0 1 18.75 9v3.75c0 .526.214 1.03.594 1.407l.53.532a.75.75 0 0 1-.53 1.28H4.656a.75.75 0 0 1-.53-1.28l.53-.532A1.989 1.989 0 0 0 5.25 12.75V9Zm6.75 12a3 3 0 0 0 2.996-2.825.75.75 0 0 0-.748-.8h-4.5a.75.75 0 0 0-.748.8A3 3 0 0 0 12 21Z"
/>
</svg>
)
}
const WorktreeCard = React.memo(function WorktreeCard({
worktree,
repo,
isActive
}: WorktreeCardProps) {
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const fetchIssue = useAppStore((s) => s.fetchIssue)
@ -127,6 +143,19 @@ const WorktreeCard = React.memo(function WorktreeCard({
[worktree.id, setActiveWorktree]
)
const handleToggleUnreadQuick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
event.stopPropagation()
updateWorktreeMeta(worktree.id, { isUnread: !worktree.isUnread })
},
[worktree.id, worktree.isUnread, updateWorktreeMeta]
)
const unreadTooltip = worktree.isUnread
? 'Unread - click to mark read'
: 'Read - hover/click to mark unread'
return (
<WorktreeContextMenu worktree={worktree}>
<div
@ -136,12 +165,32 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
onClick={handleClick}
>
{/* Status + unread indicator */}
<div className="flex items-center pt-1 gap-1">
{/* Status + quick unread bell */}
<div className="flex flex-col items-center self-start pt-1 gap-1.5">
<StatusIndicator status={status} />
{worktree.isUnread && (
<span className="block size-1.5 rounded-full bg-foreground/70 shrink-0" />
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleToggleUnreadQuick}
className={cn(
'group/unread inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-[5px] transition-all',
'hover:bg-accent/70 active:scale-95',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1'
)}
aria-label={worktree.isUnread ? 'Mark as read' : 'Mark as unread'}
>
{worktree.isUnread ? (
<FilledBellIcon className="size-3 text-yellow-400 drop-shadow-[0_0_4px_rgba(250,204,21,0.55)]" />
) : (
<Bell className="size-3 text-muted-foreground/80 opacity-0 group-hover:opacity-100 group-hover/unread:opacity-100 transition-opacity" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<span>{unreadTooltip}</span>
</TooltipContent>
</Tooltip>
</div>
{/* Content */}

View file

@ -9,8 +9,8 @@ import {
import {
FolderOpen,
Copy,
Eye,
EyeOff,
Bell,
BellOff,
Link,
MessageSquare,
XCircle,
@ -114,7 +114,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleToggleRead}>
{worktree.isUnread ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
{worktree.isUnread ? <BellOff className="size-3.5" /> : <Bell className="size-3.5" />}
{worktree.isUnread ? 'Mark Read' : 'Mark Unread'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleLinkIssue}>