| 1 | import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; |
| 2 | import { Check, ChevronDown, Cloud, Folder, FolderOpen, GitBranch, GitGraph, MessageCircle, Plus, RefreshCw, Search, X } from "lucide-react"; |
| 3 | import { asArray } from "../lib/array"; |
| 4 | import { app, onProjectTreeChanged } from "../lib/bridge"; |
| 5 | import { useT } from "../lib/i18n"; |
| 6 | import { useBranchSwitcher } from "../lib/useBranchSwitcher"; |
| 7 | import { useToast } from "../lib/toast"; |
| 8 | import type { GitCommitView, ProjectNode } from "../lib/types"; |
| 9 | import { useProjectCreation } from "./useProjectCreation"; |
| 10 | import "./ComposerWorkspaceContextBar.css"; |
| 11 | |
| 12 | export type ComposerWorkspaceContext = { |
| 13 | scope: "global" | "project"; |
| 14 | workspaceRoot: string; |
| 15 | workspaceName?: string; |
| 16 | gitBranch?: string; |
| 17 | tabId?: string; |
| 18 | scopeKey: string; |
| 19 | remote?: boolean; |
| 20 | onSwitchWorkspace: (path?: string) => Promise<unknown>; |
| 21 | onWorkWithoutProject: () => Promise<unknown>; |
| 22 | onRefreshProjects: () => Promise<unknown>; |
| 23 | }; |
| 24 | |
| 25 | function projectTitle(project: ProjectNode): string { |
| 26 | const explicit = (project.label ?? "").trim(); |
| 27 | if (explicit) return explicit; |
| 28 | const root = (project.root ?? "").replace(/[\\/]+$/, ""); |
| 29 | return root.split(/[\\/]/).filter(Boolean).pop() ?? root; |
| 30 | } |
| 31 | |
| 32 | function currentWorkspaceTitle(context: ComposerWorkspaceContext, noProject: string): string { |
| 33 | if (context.scope === "global" && !context.remote) return noProject; |
| 34 | const explicit = (context.workspaceName ?? "").trim(); |
| 35 | if (explicit) return explicit; |
| 36 | const root = context.workspaceRoot.replace(/[\\/]+$/, ""); |
| 37 | return root.split(/[\\/]/).filter(Boolean).pop() ?? noProject; |
| 38 | } |
| 39 | |
| 40 | function shortCommit(hash: string): string { |
| 41 | return hash.slice(0, 7); |
| 42 | } |
| 43 | |
| 44 | function commitDate(value: string): string { |
| 45 | const date = new Date(value); |
| 46 | if (Number.isNaN(date.getTime())) return value; |
| 47 | return new Intl.DateTimeFormat(undefined, { |
| 48 | month: "2-digit", |
| 49 | day: "2-digit", |
| 50 | hour: "2-digit", |
| 51 | minute: "2-digit", |
| 52 | }).format(date); |
| 53 | } |
| 54 | |
| 55 | function ComposerGitGraphDialog({ |
| 56 | tabId, |
| 57 | branch, |
| 58 | restoreFocusRef, |
| 59 | onClose, |
| 60 | }: { |
| 61 | tabId: string; |
| 62 | branch?: string; |
| 63 | restoreFocusRef: RefObject<HTMLButtonElement | null>; |
| 64 | onClose: () => void; |
| 65 | }) { |
| 66 | const t = useT(); |
| 67 | const dialogRef = useRef<HTMLElement | null>(null); |
| 68 | const [commits, setCommits] = useState<GitCommitView[]>([]); |
| 69 | const [loading, setLoading] = useState(true); |
| 70 | const [error, setError] = useState(""); |
| 71 | const generation = useRef(0); |
| 72 | |
| 73 | const load = useCallback(async () => { |
| 74 | const request = ++generation.current; |
| 75 | setLoading(true); |
| 76 | setError(""); |
| 77 | try { |
| 78 | const history = await app.WorkspaceGitHistory(tabId, ""); |
| 79 | if (generation.current === request) setCommits(asArray(history)); |
| 80 | } catch (reason) { |
| 81 | if (generation.current === request) setError(reason instanceof Error ? reason.message : String(reason)); |
| 82 | } finally { |
| 83 | if (generation.current === request) setLoading(false); |
| 84 | } |
| 85 | }, [tabId]); |
| 86 | |
| 87 | useEffect(() => { |
| 88 | void load(); |
| 89 | const previousFocus = document.activeElement instanceof HTMLElement && document.activeElement !== document.body |
| 90 | ? document.activeElement |
| 91 | : restoreFocusRef.current; |
| 92 | const onKeyDown = (event: KeyboardEvent) => { |
| 93 | if (event.key === "Escape") { |
| 94 | event.preventDefault(); |
| 95 | onClose(); |
| 96 | return; |
| 97 | } |
| 98 | if (event.key !== "Tab") return; |
| 99 | const focusable = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>("button:not(:disabled), [tabindex]:not([tabindex='-1'])") ?? []); |
| 100 | if (focusable.length === 0) return; |
| 101 | const first = focusable[0]; |
| 102 | const last = focusable[focusable.length - 1]; |
| 103 | if (event.shiftKey && document.activeElement === first) { |
| 104 | event.preventDefault(); |
| 105 | last.focus(); |
| 106 | } else if (!event.shiftKey && document.activeElement === last) { |
| 107 | event.preventDefault(); |
| 108 | first.focus(); |
| 109 | } |
| 110 | }; |
| 111 | window.addEventListener("keydown", onKeyDown); |
| 112 | requestAnimationFrame(() => dialogRef.current?.querySelector<HTMLElement>("button:not(:disabled)")?.focus()); |
| 113 | return () => { |
| 114 | generation.current += 1; |
| 115 | window.removeEventListener("keydown", onKeyDown); |
| 116 | previousFocus?.focus(); |
| 117 | }; |
| 118 | }, [load, onClose, restoreFocusRef]); |
| 119 | |
| 120 | return ( |
| 121 | <div className="modal-backdrop composer-git-graph-backdrop" data-app-overlay="" onMouseDown={(event) => { |
| 122 | if (event.target === event.currentTarget) onClose(); |
| 123 | }}> |
| 124 | <section ref={dialogRef} className="composer-git-graph" role="dialog" aria-modal="true" aria-labelledby="composer-git-graph-title"> |
| 125 | <header className="composer-git-graph__header"> |
| 126 | <div> |
| 127 | <h2 id="composer-git-graph-title"><GitGraph size={17} />{t("composer.workspace.gitGraph")}</h2> |
| 128 | {branch ? <span title={branch}>{branch}</span> : null} |
| 129 | </div> |
| 130 | <div className="composer-git-graph__actions"> |
| 131 | <button type="button" aria-label={t("composer.workspace.refreshHistory")} title={t("composer.workspace.refreshHistory")} disabled={loading} onClick={() => void load()}> |
| 132 | <RefreshCw size={15} className={loading ? "composer-git-graph__spin" : undefined} /> |
| 133 | </button> |
| 134 | <button type="button" aria-label={t("common.close")} title={t("common.close")} onClick={onClose}><X size={16} /></button> |
| 135 | </div> |
| 136 | </header> |
| 137 | <div className="composer-git-graph__columns" aria-hidden="true"> |
| 138 | <span>{t("composer.workspace.graph")}</span> |
| 139 | <span>{t("subagents.description")}</span> |
| 140 | <span>{t("composer.workspace.date")}</span> |
| 141 | <span>{t("settings.themeLibrary.fieldAuthor")}</span> |
| 142 | <span>{t("composer.workspace.commit")}</span> |
| 143 | </div> |
| 144 | <div className="composer-git-graph__body"> |
| 145 | {loading && commits.length === 0 ? <div className="composer-git-graph__note">{t("common.loading")}</div> : null} |
| 146 | {error ? <div className="composer-git-graph__note composer-git-graph__note--error">{error}</div> : null} |
| 147 | {!loading && !error && commits.length === 0 ? <div className="composer-git-graph__note">{t("composer.workspace.noHistory")}</div> : null} |
| 148 | {commits.map((commit, index) => ( |
| 149 | <div className="composer-git-graph__row" key={commit.hash}> |
| 150 | <span className="composer-git-graph__lane" aria-hidden="true"><i />{index < commits.length - 1 ? <b /> : null}</span> |
| 151 | <span className="composer-git-graph__message" title={commit.message}>{commit.message}</span> |
| 152 | <span title={commit.date}>{commitDate(commit.date)}</span> |
| 153 | <span title={commit.author}>{commit.author}</span> |
| 154 | <code title={commit.hash}>{shortCommit(commit.hash)}</code> |
| 155 | </div> |
| 156 | ))} |
| 157 | </div> |
| 158 | </section> |
| 159 | </div> |
| 160 | ); |
| 161 | } |
| 162 | |
| 163 | export function ComposerWorkspaceContextBar({ context }: { context: ComposerWorkspaceContext }) { |
| 164 | const t = useT(); |
| 165 | const { showToast } = useToast(); |
| 166 | const rootRef = useRef<HTMLDivElement | null>(null); |
| 167 | const projectSearchRef = useRef<HTMLInputElement | null>(null); |
| 168 | const branchTriggerRef = useRef<HTMLButtonElement | null>(null); |
| 169 | const projectRequest = useRef(0); |
| 170 | const [projectMenuOpen, setProjectMenuOpen] = useState(false); |
| 171 | const [projectQuery, setProjectQuery] = useState(""); |
| 172 | const [projects, setProjects] = useState<ProjectNode[]>([]); |
| 173 | const [projectsLoading, setProjectsLoading] = useState(false); |
| 174 | const [projectsError, setProjectsError] = useState(""); |
| 175 | const [switchingProject, setSwitchingProject] = useState(""); |
| 176 | const [branchCreateMode, setBranchCreateMode] = useState(false); |
| 177 | const [gitGraphOpen, setGitGraphOpen] = useState(false); |
| 178 | const closeGitGraph = useCallback(() => setGitGraphOpen(false), []); |
| 179 | |
| 180 | const branch = useBranchSwitcher({ |
| 181 | tabId: context.tabId ?? "", |
| 182 | scopeKey: context.scopeKey, |
| 183 | workspaceRoot: context.workspaceRoot, |
| 184 | gitBranch: context.remote ? undefined : context.gitBranch, |
| 185 | rootRef, |
| 186 | onBranchChanged: () => {}, |
| 187 | }); |
| 188 | |
| 189 | const loadProjects = useCallback(async () => { |
| 190 | const request = ++projectRequest.current; |
| 191 | setProjectsLoading(true); |
| 192 | setProjectsError(""); |
| 193 | try { |
| 194 | const snapshot = await app.GetProjectTreeSnapshot(); |
| 195 | if (projectRequest.current !== request) return; |
| 196 | setProjects(asArray(snapshot.projects).filter((project) => project.kind === "project" && Boolean(project.root))); |
| 197 | } catch (reason) { |
| 198 | if (projectRequest.current === request) setProjectsError(reason instanceof Error ? reason.message : String(reason)); |
| 199 | } finally { |
| 200 | if (projectRequest.current === request) setProjectsLoading(false); |
| 201 | } |
| 202 | }, []); |
| 203 | |
| 204 | const creation = useProjectCreation({ |
| 205 | onAddProject: async (path) => { await context.onSwitchWorkspace(path); }, |
| 206 | onRefresh: async () => { await context.onRefreshProjects(); }, |
| 207 | showToast, |
| 208 | }); |
| 209 | |
| 210 | useEffect(() => onProjectTreeChanged(() => { |
| 211 | projectRequest.current += 1; |
| 212 | if (projectMenuOpen) void loadProjects(); |
| 213 | }), [loadProjects, projectMenuOpen]); |
| 214 | |
| 215 | useEffect(() => { |
| 216 | if (!projectMenuOpen) return; |
| 217 | const onPointerDown = (event: PointerEvent) => { |
| 218 | if (rootRef.current && event.target instanceof Node && !rootRef.current.contains(event.target)) setProjectMenuOpen(false); |
| 219 | }; |
| 220 | const onKeyDown = (event: KeyboardEvent) => { |
| 221 | if (event.key === "Escape") setProjectMenuOpen(false); |
| 222 | }; |
| 223 | window.addEventListener("pointerdown", onPointerDown, true); |
| 224 | window.addEventListener("keydown", onKeyDown); |
| 225 | return () => { |
| 226 | window.removeEventListener("pointerdown", onPointerDown, true); |
| 227 | window.removeEventListener("keydown", onKeyDown); |
| 228 | }; |
| 229 | }, [projectMenuOpen]); |
| 230 | |
| 231 | useEffect(() => { |
| 232 | setProjectMenuOpen(false); |
| 233 | setProjectQuery(""); |
| 234 | setSwitchingProject(""); |
| 235 | }, [context.scope, context.workspaceRoot, context.remote]); |
| 236 | |
| 237 | const filteredProjects = useMemo(() => { |
| 238 | const query = projectQuery.trim().toLowerCase(); |
| 239 | if (!query) return projects; |
| 240 | return projects.filter((project) => [projectTitle(project), project.root ?? ""].some((value) => value.toLowerCase().includes(query))); |
| 241 | }, [projectQuery, projects]); |
| 242 | |
| 243 | const openProjectMenu = () => { |
| 244 | if (branch.branchMenuOpen) branch.toggleBranchMenu(); |
| 245 | const next = !projectMenuOpen; |
| 246 | setProjectMenuOpen(next); |
| 247 | setProjectQuery(""); |
| 248 | if (next) { |
| 249 | void loadProjects(); |
| 250 | window.setTimeout(() => projectSearchRef.current?.focus(), 0); |
| 251 | } |
| 252 | }; |
| 253 | |
| 254 | const switchProject = async (path: string) => { |
| 255 | if (switchingProject) return; |
| 256 | setSwitchingProject(path); |
| 257 | setProjectMenuOpen(false); |
| 258 | try { |
| 259 | await context.onSwitchWorkspace(path); |
| 260 | } catch (reason) { |
| 261 | showToast(reason instanceof Error ? reason.message : String(reason), "error"); |
| 262 | } finally { |
| 263 | setSwitchingProject(""); |
| 264 | } |
| 265 | }; |
| 266 | |
| 267 | const workWithoutProject = async () => { |
| 268 | setProjectMenuOpen(false); |
| 269 | try { |
| 270 | await context.onWorkWithoutProject(); |
| 271 | } catch (reason) { |
| 272 | showToast(reason instanceof Error ? reason.message : String(reason), "error"); |
| 273 | } |
| 274 | }; |
| 275 | |
| 276 | const workspaceTitle = currentWorkspaceTitle(context, t("composer.workspace.noProject")); |
| 277 | const projectSelected = context.scope === "project" || context.remote; |
| 278 | const workspaceTitleAttribute = projectSelected ? context.workspaceRoot || workspaceTitle : workspaceTitle; |
| 279 | const branchAvailable = Boolean(context.tabId && context.workspaceRoot && branch.activeBranch && !context.remote); |
| 280 | |
| 281 | return ( |
| 282 | <> |
| 283 | <div ref={rootRef} className="composer-workspace-bar" role="toolbar" aria-label={t("composer.workspace.context") }> |
| 284 | <div className="composer-workspace-bar__project-wrap"> |
| 285 | {projectSelected ? ( |
| 286 | <button type="button" className="composer-workspace-bar__clear" aria-label={t("composer.workspace.workWithoutProject")} title={t("composer.workspace.workWithoutProject")} onClick={() => void workWithoutProject()}> |
| 287 | <X size={14} /> |
| 288 | </button> |
| 289 | ) : null} |
| 290 | <button |
| 291 | type="button" |
| 292 | className={`composer-workspace-bar__choice${projectMenuOpen ? " composer-workspace-bar__choice--open" : ""}`} |
| 293 | aria-haspopup="menu" |
| 294 | aria-expanded={projectMenuOpen} |
| 295 | aria-label={`${t("history.filterProject")}: ${workspaceTitle}`} |
| 296 | title={workspaceTitleAttribute} |
| 297 | onClick={openProjectMenu} |
| 298 | > |
| 299 | {context.remote ? <Cloud size={15} /> : <Folder size={15} />} |
| 300 | <span>{workspaceTitle}</span> |
| 301 | <ChevronDown size={14} /> |
| 302 | </button> |
| 303 | {projectMenuOpen ? ( |
| 304 | <div className="composer-workspace-menu composer-workspace-menu--projects" role="menu" aria-label={t("composer.workspace.switchProject")}> |
| 305 | <label className="composer-workspace-menu__search"> |
| 306 | <Search size={15} /> |
| 307 | <input |
| 308 | ref={projectSearchRef} |
| 309 | value={projectQuery} |
| 310 | aria-label={t("composer.workspace.search")} |
| 311 | placeholder={t("composer.workspace.search")} |
| 312 | onChange={(event) => setProjectQuery(event.target.value)} |
| 313 | onKeyDown={(event) => { |
| 314 | if (event.key === "Enter" && filteredProjects.length === 1 && filteredProjects[0].root) void switchProject(filteredProjects[0].root); |
| 315 | }} |
| 316 | /> |
| 317 | </label> |
| 318 | <div className="composer-workspace-menu__list"> |
| 319 | {projectsLoading && projects.length === 0 ? <div className="composer-workspace-menu__note">{t("common.loading")}</div> : null} |
| 320 | {projectsError ? <div className="composer-workspace-menu__note composer-workspace-menu__note--error">{projectsError}</div> : null} |
| 321 | {!projectsLoading && !projectsError && filteredProjects.length === 0 ? <div className="composer-workspace-menu__note">{t("palette.empty")}</div> : null} |
| 322 | {filteredProjects.map((project) => { |
| 323 | const path = project.root ?? ""; |
| 324 | const active = context.scope === "project" && !context.remote && path === context.workspaceRoot; |
| 325 | return ( |
| 326 | <button key={project.key || path} type="button" role="menuitem" className={`composer-workspace-menu__item${active ? " composer-workspace-menu__item--active" : ""}`} disabled={Boolean(switchingProject)} title={path} onClick={() => { |
| 327 | if (active) setProjectMenuOpen(false); |
| 328 | else void switchProject(path); |
| 329 | }}> |
| 330 | <Folder size={16} /> |
| 331 | <span>{projectTitle(project)}</span> |
| 332 | {switchingProject === path ? <i className="composer-workspace-menu__spinner" /> : active ? <Check size={15} /> : null} |
| 333 | </button> |
| 334 | ); |
| 335 | })} |
| 336 | </div> |
| 337 | <div className="composer-workspace-menu__actions"> |
| 338 | <button type="button" role="menuitem" disabled={creation.addingProject} onClick={() => { setProjectMenuOpen(false); void creation.handleAddProject(); }}><FolderOpen size={16} /><span>{t("composer.workspace.openFolder")}</span></button> |
| 339 | <button type="button" role="menuitem" onClick={() => { setProjectMenuOpen(false); creation.openRemoteConnectFlow(); }}><Cloud size={16} /><span>{t("projectTree.remoteConnection")}</span></button> |
| 340 | <button type="button" role="menuitem" onClick={() => void workWithoutProject()}><MessageCircle size={16} /><span>{t("composer.workspace.workWithoutProject")}</span>{!projectSelected ? <Check size={15} /> : null}</button> |
| 341 | </div> |
| 342 | </div> |
| 343 | ) : null} |
| 344 | </div> |
| 345 | |
| 346 | {branchAvailable ? ( |
| 347 | <div className="composer-workspace-bar__branch-wrap"> |
| 348 | <button |
| 349 | ref={branchTriggerRef} |
| 350 | type="button" |
| 351 | className={`composer-workspace-bar__choice composer-workspace-bar__choice--branch${branch.branchMenuOpen ? " composer-workspace-bar__choice--open" : ""}`} |
| 352 | aria-haspopup="menu" |
| 353 | aria-expanded={branch.branchMenuOpen} |
| 354 | aria-label={`${t("status.gitBranchTitle")}: ${branch.activeBranch}`} |
| 355 | title={`${t("status.gitBranchTitle")}: ${branch.activeBranch}`} |
| 356 | onClick={() => { |
| 357 | setProjectMenuOpen(false); |
| 358 | setBranchCreateMode(false); |
| 359 | branch.toggleBranchMenu(); |
| 360 | }} |
| 361 | > |
| 362 | <GitBranch size={15} /> |
| 363 | <span>{branch.activeBranch}</span> |
| 364 | <ChevronDown size={14} /> |
| 365 | </button> |
| 366 | {branch.branchMenuOpen ? ( |
| 367 | <div className="composer-workspace-menu composer-workspace-menu--branches" role="menu" aria-label={t("rightDock.switchBranch")}> |
| 368 | <label className="composer-workspace-menu__search"> |
| 369 | <Search size={15} /> |
| 370 | <input |
| 371 | ref={branch.branchSearchRef} |
| 372 | value={branch.branchQuery} |
| 373 | aria-label={branchCreateMode ? t("composer.workspace.newBranchPlaceholder") : t("rightDock.branchSearchPlaceholder")} |
| 374 | placeholder={branchCreateMode ? t("composer.workspace.newBranchPlaceholder") : t("rightDock.branchSearchPlaceholder")} |
| 375 | onChange={(event) => { |
| 376 | branch.setBranchQuery(event.target.value); |
| 377 | branch.setBranchSwitchErr(""); |
| 378 | }} |
| 379 | onKeyDown={(event) => { |
| 380 | if (event.key !== "Enter") return; |
| 381 | if (branchCreateMode && branch.canCreate) void branch.createBranch(branch.trimmedQuery); |
| 382 | else if (branch.exactMatch) void branch.checkoutBranch(branch.trimmedQuery); |
| 383 | }} |
| 384 | /> |
| 385 | </label> |
| 386 | <div className="composer-workspace-menu__section">{t("rightDock.branchSection")}</div> |
| 387 | <div className="composer-workspace-menu__list composer-workspace-menu__list--branches"> |
| 388 | {branch.branchesLoading ? <div className="composer-workspace-menu__note">{t("rightDock.branchMenuLoading")}</div> : null} |
| 389 | {!branch.branchesLoading && branch.branchesErr ? <div className="composer-workspace-menu__note composer-workspace-menu__note--error">{branch.branchesErr}</div> : null} |
| 390 | {!branch.branchesLoading && !branch.branchesErr && branch.filteredBranches.length === 0 ? <div className="composer-workspace-menu__note">{t("rightDock.branchNoMatch")}</div> : null} |
| 391 | {branch.filteredBranches.map((name) => ( |
| 392 | <button key={name} type="button" role="menuitem" className={`composer-workspace-menu__item${name === branch.activeBranch ? " composer-workspace-menu__item--active" : ""}`} disabled={Boolean(branch.switchingBranch)} title={name} onClick={() => { |
| 393 | if (name === branch.activeBranch) branch.toggleBranchMenu(); |
| 394 | else void branch.checkoutBranch(name); |
| 395 | }}> |
| 396 | <GitBranch size={15} /> |
| 397 | <span>{name}</span> |
| 398 | {branch.switchingBranch === name ? <i className="composer-workspace-menu__spinner" /> : name === branch.activeBranch ? <Check size={15} /> : null} |
| 399 | </button> |
| 400 | ))} |
| 401 | </div> |
| 402 | {branch.branchSwitchErr ? <div className="composer-workspace-menu__note composer-workspace-menu__note--error">{branch.branchSwitchErr}</div> : null} |
| 403 | <div className="composer-workspace-menu__actions"> |
| 404 | <button type="button" role="menuitem" disabled={Boolean(branch.switchingBranch) || (branchCreateMode && !branch.canCreate)} onClick={() => { |
| 405 | if (branchCreateMode && branch.canCreate) void branch.createBranch(branch.trimmedQuery); |
| 406 | else { |
| 407 | setBranchCreateMode(true); |
| 408 | branch.setBranchQuery(""); |
| 409 | window.setTimeout(() => branch.branchSearchRef.current?.focus(), 0); |
| 410 | } |
| 411 | }}><Plus size={16} /><span>{branchCreateMode && branch.trimmedQuery ? `${t("rightDock.branchCreate")} ${branch.trimmedQuery}` : t("rightDock.branchCreate")}</span></button> |
| 412 | <button type="button" role="menuitem" onClick={() => { if (branch.branchMenuOpen) branch.toggleBranchMenu(); setGitGraphOpen(true); }}><GitGraph size={16} /><span>{t("composer.workspace.gitGraph")}</span></button> |
| 413 | </div> |
| 414 | </div> |
| 415 | ) : null} |
| 416 | </div> |
| 417 | ) : null} |
| 418 | </div> |
| 419 | {creation.remoteConnectFlow} |
| 420 | {gitGraphOpen && context.tabId ? ( |
| 421 | <ComposerGitGraphDialog |
| 422 | tabId={context.tabId} |
| 423 | branch={branch.activeBranch} |
| 424 | restoreFocusRef={branchTriggerRef} |
| 425 | onClose={closeGitGraph} |
| 426 | /> |
| 427 | ) : null} |
| 428 | </> |
| 429 | ); |
| 430 | } |
| 431 | |
| 432 | export default ComposerWorkspaceContextBar; |
| 433 |