mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
bell icon
This commit is contained in:
parent
d0ae4ef2a1
commit
4cadfaa031
6 changed files with 161 additions and 21 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue