feat(app): clear notifications action (#13668)

Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com>
This commit is contained in:
Adam
2026-02-14 19:33:22 -06:00
committed by GitHub
parent 460a87f359
commit 85b5f5b705
19 changed files with 126 additions and 65 deletions

View File

@@ -30,6 +30,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]` export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
export const projectClearNotificationsSelector = (slug: string) =>
`[data-action="project-clear-notifications"][data-project="${slug}"]`
export const projectWorkspacesToggleSelector = (slug: string) => export const projectWorkspacesToggleSelector = (slug: string) =>
`[data-action="project-workspaces-toggle"][data-project="${slug}"]` `[data-action="project-workspaces-toggle"][data-project="${slug}"]`

View File

@@ -509,6 +509,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.", "sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.",
"sidebar.project.recentSessions": "الجلسات الحديثة", "sidebar.project.recentSessions": "الجلسات الحديثة",
"sidebar.project.viewAllSessions": "عرض جميع الجلسات", "sidebar.project.viewAllSessions": "عرض جميع الجلسات",
"sidebar.project.clearNotifications": "مسح الإشعارات",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "سطح المكتب", "settings.section.desktop": "سطح المكتب",
"settings.section.server": "الخادم", "settings.section.server": "الخادم",

View File

@@ -515,6 +515,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Conecte qualquer provedor para usar modelos, incluindo Claude, GPT, Gemini etc.", "sidebar.gettingStarted.line2": "Conecte qualquer provedor para usar modelos, incluindo Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sessões recentes", "sidebar.project.recentSessions": "Sessões recentes",
"sidebar.project.viewAllSessions": "Ver todas as sessões", "sidebar.project.viewAllSessions": "Ver todas as sessões",
"sidebar.project.clearNotifications": "Limpar notificações",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop", "settings.section.desktop": "Desktop",
"settings.section.server": "Servidor", "settings.section.server": "Servidor",

View File

@@ -576,6 +576,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Poveži bilo kojeg provajdera da koristiš modele, npr. Claude, GPT, Gemini itd.", "sidebar.gettingStarted.line2": "Poveži bilo kojeg provajdera da koristiš modele, npr. Claude, GPT, Gemini itd.",
"sidebar.project.recentSessions": "Nedavne sesije", "sidebar.project.recentSessions": "Nedavne sesije",
"sidebar.project.viewAllSessions": "Prikaži sve sesije", "sidebar.project.viewAllSessions": "Prikaži sve sesije",
"sidebar.project.clearNotifications": "Očisti obavijesti",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",

View File

@@ -572,6 +572,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Forbind enhver udbyder for at bruge modeller, inkl. Claude, GPT, Gemini osv.", "sidebar.gettingStarted.line2": "Forbind enhver udbyder for at bruge modeller, inkl. Claude, GPT, Gemini osv.",
"sidebar.project.recentSessions": "Seneste sessioner", "sidebar.project.recentSessions": "Seneste sessioner",
"sidebar.project.viewAllSessions": "Vis alle sessioner", "sidebar.project.viewAllSessions": "Vis alle sessioner",
"sidebar.project.clearNotifications": "Ryd notifikationer",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop", "settings.section.desktop": "Desktop",

View File

@@ -524,6 +524,7 @@ export const dict = {
"Verbinden Sie einen beliebigen Anbieter, um Modelle wie Claude, GPT, Gemini usw. zu nutzen.", "Verbinden Sie einen beliebigen Anbieter, um Modelle wie Claude, GPT, Gemini usw. zu nutzen.",
"sidebar.project.recentSessions": "Letzte Sitzungen", "sidebar.project.recentSessions": "Letzte Sitzungen",
"sidebar.project.viewAllSessions": "Alle Sitzungen anzeigen", "sidebar.project.viewAllSessions": "Alle Sitzungen anzeigen",
"sidebar.project.clearNotifications": "Benachrichtigungen löschen",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop", "settings.section.desktop": "Desktop",
"settings.section.server": "Server", "settings.section.server": "Server",

View File

@@ -577,6 +577,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Connect any provider to use models, inc. Claude, GPT, Gemini etc.", "sidebar.gettingStarted.line2": "Connect any provider to use models, inc. Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Recent sessions", "sidebar.project.recentSessions": "Recent sessions",
"sidebar.project.viewAllSessions": "View all sessions", "sidebar.project.viewAllSessions": "View all sessions",
"sidebar.project.clearNotifications": "Clear notifications",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",

View File

@@ -579,6 +579,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Conecta cualquier proveedor para usar modelos, inc. Claude, GPT, Gemini etc.", "sidebar.gettingStarted.line2": "Conecta cualquier proveedor para usar modelos, inc. Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sesiones recientes", "sidebar.project.recentSessions": "Sesiones recientes",
"sidebar.project.viewAllSessions": "Ver todas las sesiones", "sidebar.project.viewAllSessions": "Ver todas las sesiones",
"sidebar.project.clearNotifications": "Borrar notificaciones",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",

View File

@@ -523,6 +523,7 @@ export const dict = {
"Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.", "Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sessions récentes", "sidebar.project.recentSessions": "Sessions récentes",
"sidebar.project.viewAllSessions": "Voir toutes les sessions", "sidebar.project.viewAllSessions": "Voir toutes les sessions",
"sidebar.project.clearNotifications": "Effacer les notifications",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Bureau", "settings.section.desktop": "Bureau",
"settings.section.server": "Serveur", "settings.section.server": "Serveur",

View File

@@ -513,6 +513,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "プロバイダーを接続して、Claude、GPT、Geminiなどのモデルを使用できます。", "sidebar.gettingStarted.line2": "プロバイダーを接続して、Claude、GPT、Geminiなどのモデルを使用できます。",
"sidebar.project.recentSessions": "最近のセッション", "sidebar.project.recentSessions": "最近のセッション",
"sidebar.project.viewAllSessions": "すべてのセッションを表示", "sidebar.project.viewAllSessions": "すべてのセッションを表示",
"sidebar.project.clearNotifications": "通知をクリア",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "デスクトップ", "settings.section.desktop": "デスクトップ",
"settings.section.server": "サーバー", "settings.section.server": "サーバー",

View File

@@ -514,6 +514,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Claude, GPT, Gemini 등을 포함한 모델을 사용하려면 공급자를 연결하세요.", "sidebar.gettingStarted.line2": "Claude, GPT, Gemini 등을 포함한 모델을 사용하려면 공급자를 연결하세요.",
"sidebar.project.recentSessions": "최근 세션", "sidebar.project.recentSessions": "최근 세션",
"sidebar.project.viewAllSessions": "모든 세션 보기", "sidebar.project.viewAllSessions": "모든 세션 보기",
"sidebar.project.clearNotifications": "알림 지우기",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "데스크톱", "settings.section.desktop": "데스크톱",
"settings.section.server": "서버", "settings.section.server": "서버",

View File

@@ -579,6 +579,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Koble til en leverandør for å bruke modeller, inkl. Claude, GPT, Gemini osv.", "sidebar.gettingStarted.line2": "Koble til en leverandør for å bruke modeller, inkl. Claude, GPT, Gemini osv.",
"sidebar.project.recentSessions": "Nylige sesjoner", "sidebar.project.recentSessions": "Nylige sesjoner",
"sidebar.project.viewAllSessions": "Vis alle sesjoner", "sidebar.project.viewAllSessions": "Vis alle sesjoner",
"sidebar.project.clearNotifications": "Fjern varsler",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",

View File

@@ -514,6 +514,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Połącz dowolnego dostawcę, aby używać modeli, w tym Claude, GPT, Gemini itp.", "sidebar.gettingStarted.line2": "Połącz dowolnego dostawcę, aby używać modeli, w tym Claude, GPT, Gemini itp.",
"sidebar.project.recentSessions": "Ostatnie sesje", "sidebar.project.recentSessions": "Ostatnie sesje",
"sidebar.project.viewAllSessions": "Zobacz wszystkie sesje", "sidebar.project.viewAllSessions": "Zobacz wszystkie sesje",
"sidebar.project.clearNotifications": "Wyczyść powiadomienia",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Pulpit", "settings.section.desktop": "Pulpit",
"settings.section.server": "Serwer", "settings.section.server": "Serwer",

View File

@@ -578,6 +578,7 @@ export const dict = {
"Подключите любого провайдера для использования моделей, включая Claude, GPT, Gemini и др.", "Подключите любого провайдера для использования моделей, включая Claude, GPT, Gemini и др.",
"sidebar.project.recentSessions": "Недавние сессии", "sidebar.project.recentSessions": "Недавние сессии",
"sidebar.project.viewAllSessions": "Посмотреть все сессии", "sidebar.project.viewAllSessions": "Посмотреть все сессии",
"sidebar.project.clearNotifications": "Очистить уведомления",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Приложение", "settings.section.desktop": "Приложение",

View File

@@ -571,6 +571,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "เชื่อมต่อผู้ให้บริการใด ๆ เพื่อใช้โมเดล รวมถึง Claude, GPT, Gemini ฯลฯ", "sidebar.gettingStarted.line2": "เชื่อมต่อผู้ให้บริการใด ๆ เพื่อใช้โมเดล รวมถึง Claude, GPT, Gemini ฯลฯ",
"sidebar.project.recentSessions": "เซสชันล่าสุด", "sidebar.project.recentSessions": "เซสชันล่าสุด",
"sidebar.project.viewAllSessions": "ดูเซสชันทั้งหมด", "sidebar.project.viewAllSessions": "ดูเซสชันทั้งหมด",
"sidebar.project.clearNotifications": "ล้างการแจ้งเตือน",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",

View File

@@ -569,6 +569,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "连接任意提供商即可使用更多模型,如 Claude、GPT、Gemini 等。", "sidebar.gettingStarted.line2": "连接任意提供商即可使用更多模型,如 Claude、GPT、Gemini 等。",
"sidebar.project.recentSessions": "最近会话", "sidebar.project.recentSessions": "最近会话",
"sidebar.project.viewAllSessions": "查看全部会话", "sidebar.project.viewAllSessions": "查看全部会话",
"sidebar.project.clearNotifications": "清除通知",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",

View File

@@ -567,6 +567,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "連線任意提供者即可使用更多模型,如 Claude、GPT、Gemini 等。", "sidebar.gettingStarted.line2": "連線任意提供者即可使用更多模型,如 Claude、GPT、Gemini 等。",
"sidebar.project.recentSessions": "最近工作階段", "sidebar.project.recentSessions": "最近工作階段",
"sidebar.project.viewAllSessions": "查看全部工作階段", "sidebar.project.viewAllSessions": "查看全部工作階段",
"sidebar.project.clearNotifications": "清除通知",
"app.name.desktop": "OpenCode Desktop", "app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "桌面", "settings.section.desktop": "桌面",

View File

@@ -1692,6 +1692,13 @@ export default function Layout(props: ParentProps) {
}) })
const projectId = createMemo(() => panelProps.project?.id ?? "") const projectId = createMemo(() => panelProps.project?.id ?? "")
const workspaces = createMemo(() => workspaceIds(panelProps.project)) const workspaces = createMemo(() => workspaceIds(panelProps.project))
const unseenCount = createMemo(() =>
workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
)
const clearNotifications = () =>
workspaces()
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
const workspacesEnabled = createMemo(() => { const workspacesEnabled = createMemo(() => {
const project = panelProps.project const project = panelProps.project
if (!project) return false if (!project) return false
@@ -1769,6 +1776,16 @@ export default function Layout(props: ParentProps) {
: language.t("sidebar.workspaces.enable")} : language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(p().worktree)}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
data-action="project-close-menu" data-action="project-close-menu"

View File

@@ -10,6 +10,7 @@ import { createSortable } from "@thisbeyond/solid-dnd"
import { type LocalProject } from "@/context/layout" import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items" import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
import { childMapByParent, displayName, sortedRootSessions } from "./helpers" import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
import { projectSelected, projectTileActive } from "./sidebar-project-helpers" import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
@@ -59,6 +60,7 @@ const ProjectTile = (props: {
selected: Accessor<boolean> selected: Accessor<boolean>
active: Accessor<boolean> active: Accessor<boolean>
overlay: Accessor<boolean> overlay: Accessor<boolean>
dirs: Accessor<string[]>
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void onProjectFocus: (worktree: string) => void
@@ -70,73 +72,94 @@ const ProjectTile = (props: {
setMenu: (value: boolean) => void setMenu: (value: boolean) => void
setOpen: (value: boolean) => void setOpen: (value: boolean) => void
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}): JSX.Element => ( }): JSX.Element => {
<ContextMenu const notification = useNotification()
modal={!props.sidebarHovering()} const unseenCount = createMemo(() =>
onOpenChange={(value) => { props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
props.setMenu(value) )
if (value) props.setOpen(false)
}} const clear = () =>
> props
<ContextMenu.Trigger .dirs()
as="button" .filter((directory) => notification.project.unseenCount(directory) > 0)
type="button" .forEach((directory) => notification.project.markViewed(directory))
aria-label={displayName(props.project)}
data-action="project-switch" return (
data-project={base64Encode(props.project.worktree)} <ContextMenu
classList={{ modal={!props.sidebarHovering()}
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true, onOpenChange={(value) => {
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(), props.setMenu(value)
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": if (value) props.setOpen(false)
!props.selected() && !props.active(),
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
}} }}
onMouseEnter={(event: MouseEvent) => {
if (!props.overlay()) return
props.onProjectMouseEnter(props.project.worktree, event)
}}
onMouseLeave={() => {
if (!props.overlay()) return
props.onProjectMouseLeave(props.project.worktree)
}}
onFocus={() => {
if (!props.overlay()) return
props.onProjectFocus(props.project.worktree)
}}
onClick={() => props.navigateToProject(props.project.worktree)}
onBlur={() => props.setOpen(false)}
> >
<ProjectIcon project={props.project} notify /> <ContextMenu.Trigger
</ContextMenu.Trigger> as="button"
<ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}> type="button"
<ContextMenu.Content> aria-label={displayName(props.project)}
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}> data-action="project-switch"
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel> data-project={base64Encode(props.project.worktree)}
</ContextMenu.Item> classList={{
<ContextMenu.Item "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
data-action="project-workspaces-toggle" "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
data-project={base64Encode(props.project.worktree)} "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)} !props.selected() && !props.active(),
onSelect={() => props.toggleProjectWorkspaces(props.project)} "bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
> }}
<ContextMenu.ItemLabel> onMouseEnter={(event: MouseEvent) => {
{props.workspacesEnabled(props.project) if (!props.overlay()) return
? props.language.t("sidebar.workspaces.disable") props.onProjectMouseEnter(props.project.worktree, event)
: props.language.t("sidebar.workspaces.enable")} }}
</ContextMenu.ItemLabel> onMouseLeave={() => {
</ContextMenu.Item> if (!props.overlay()) return
<ContextMenu.Separator /> props.onProjectMouseLeave(props.project.worktree)
<ContextMenu.Item }}
data-action="project-close-menu" onFocus={() => {
data-project={base64Encode(props.project.worktree)} if (!props.overlay()) return
onSelect={() => props.closeProject(props.project.worktree)} props.onProjectFocus(props.project.worktree)
> }}
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel> onClick={() => props.navigateToProject(props.project.worktree)}
</ContextMenu.Item> onBlur={() => props.setOpen(false)}
</ContextMenu.Content> >
</ContextMenu.Portal> <ProjectIcon project={props.project} notify />
</ContextMenu> </ContextMenu.Trigger>
) <ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)}
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
onSelect={() => props.toggleProjectWorkspaces(props.project)}
>
<ContextMenu.ItemLabel>
{props.workspacesEnabled(props.project)
? props.language.t("sidebar.workspaces.disable")
: props.language.t("sidebar.workspaces.enable")}
</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(props.project.worktree)}
disabled={unseenCount() === 0}
onSelect={clear}
>
<ContextMenu.ItemLabel>{props.language.t("sidebar.project.clearNotifications")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
data-action="project-close-menu"
data-project={base64Encode(props.project.worktree)}
onSelect={() => props.closeProject(props.project.worktree)}
>
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
}
const ProjectPreviewPanel = (props: { const ProjectPreviewPanel = (props: {
project: LocalProject project: LocalProject
@@ -254,6 +277,7 @@ export const SortableProject = (props: {
) )
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
const [open, setOpen] = createSignal(false) const [open, setOpen] = createSignal(false)
const [menu, setMenu] = createSignal(false) const [menu, setMenu] = createSignal(false)
@@ -304,6 +328,7 @@ export const SortableProject = (props: {
selected={selected} selected={selected}
active={active} active={active}
overlay={overlay} overlay={overlay}
dirs={dirs}
onProjectMouseEnter={props.ctx.onProjectMouseEnter} onProjectMouseEnter={props.ctx.onProjectMouseEnter}
onProjectMouseLeave={props.ctx.onProjectMouseLeave} onProjectMouseLeave={props.ctx.onProjectMouseLeave}
onProjectFocus={props.ctx.onProjectFocus} onProjectFocus={props.ctx.onProjectFocus}