| 1 | // DockLauncher is the floating card over the transcript's top-right corner. It |
| 2 | // lists the dock's entry points (overview / files / changed) and the active |
| 3 | // git branch; clicking an entry expands the dock to that tab. Its own toggle |
| 4 | // owns the card only — the dock panel has a separate button — so the card can |
| 5 | // be summoned whether or not the panel is open; over an open panel it overlays |
| 6 | // the transcript instead of taking layout space from it. |
| 7 | // |
| 8 | // The interaction logic lives in lib/ hooks — useDockLauncherSpace (space |
| 9 | // yield), useWorkspaceDiffStats (changed-row totals) and useBranchSwitcher |
| 10 | // (branch row, list, checkout, create) — leaving this file as the view. |
| 11 | // The branch row opens a switcher modelled on the ChatGPT reference: a search |
| 12 | // input filters the branch list, and a pinned bottom action creates and checks |
| 13 | // out a new branch from whatever is typed. |
| 14 | |
| 15 | import { useRef } from "react"; |
| 16 | import { Check, ChevronRight, GitBranch, Plus, Search } from "lucide-react"; |
| 17 | import { useT } from "../lib/i18n"; |
| 18 | import { availableDockEntries } from "../lib/dockEntries"; |
| 19 | import { DOCK_ENTRY_ICONS } from "./dockEntryIcons"; |
| 20 | import { desktopHost } from "../lib/desktopHost"; |
| 21 | import type { SpaceMode } from "../lib/launcherCardState"; |
| 22 | import { useBranchSwitcher } from "../lib/useBranchSwitcher"; |
| 23 | import { useDockLauncherSpace } from "../lib/useDockLauncherSpace"; |
| 24 | import { useWorkspaceDiffStats } from "../lib/useWorkspaceDiffStats"; |
| 25 | interface DockLauncherProps { |
| 26 | tabId: string; |
| 27 | scopeKey: string; |
| 28 | workspaceRoot: string; |
| 29 | visible: boolean; |
| 30 | onSelect: (entryId: string) => void; |
| 31 | /** Current git branch for the active workspace; omitted when unknown. */ |
| 32 | gitBranch?: string; |
| 33 | /** Reports the space-yield mode whenever it changes, so the App-level |
| 34 | * launcher toggle can mirror whether the card is actually on screen. */ |
| 35 | onSpaceModeChange?: (mode: SpaceMode) => void; |
| 36 | /** True while the dock panel is open: the card then overlays the transcript |
| 37 | * rather than competing for the chat column, so the yield rule is skipped. */ |
| 38 | overlay?: boolean; |
| 39 | } |
| 40 | |
| 41 | export function DockLauncher({ tabId, scopeKey, workspaceRoot, visible, onSelect, gitBranch, onSpaceModeChange, overlay }: DockLauncherProps) { |
| 42 | const t = useT(); |
| 43 | const rootRef = useRef<HTMLDivElement | null>(null); |
| 44 | const spaceMode = useDockLauncherSpace(rootRef, onSpaceModeChange); |
| 45 | const enabled = visible && Boolean(gitBranch) && (overlay === true || spaceMode !== "hidden"); |
| 46 | const { diffStats, reloadDiffStats } = useWorkspaceDiffStats(tabId, scopeKey, workspaceRoot, enabled); |
| 47 | const branch = useBranchSwitcher({ tabId, scopeKey, workspaceRoot, gitBranch, rootRef, onBranchChanged: reloadDiffStats }); |
| 48 | |
| 49 | // The changed entry is git-derived (git status / diff), so it is only shown |
| 50 | // when the active workspace is a git repo; the branch row below is gated the |
| 51 | // same way via activeBranch. |
| 52 | const isGitProject = Boolean(gitBranch); |
| 53 | const entries = availableDockEntries(desktopHost().browser !== undefined) |
| 54 | .filter((entry) => entry.id !== "changed" || isGitProject); |
| 55 | const showDiffStats = diffStats?.incomplete || (diffStats?.added ?? 0) + (diffStats?.removed ?? 0) > 0; |
| 56 | |
| 57 | if (!overlay && spaceMode === "hidden") return null; |
| 58 | |
| 59 | return ( |
| 60 | <div |
| 61 | ref={rootRef} |
| 62 | className="dock-launcher" |
| 63 | role="toolbar" |
| 64 | aria-label={t("rightDock.launcher")} |
| 65 | > |
| 66 | <div className="dock-launcher__header">{t("rightDock.launcherTitle")}</div> |
| 67 | {entries.map((entry) => { |
| 68 | const Icon = DOCK_ENTRY_ICONS[entry.defaultTab]; |
| 69 | const isChanged = entry.id === "changed"; |
| 70 | return ( |
| 71 | <button |
| 72 | key={entry.id} |
| 73 | type="button" |
| 74 | className="dock-launcher__entry" |
| 75 | aria-label={t(entry.labelKey as never)} |
| 76 | onClick={() => onSelect(entry.id)} |
| 77 | > |
| 78 | <Icon size={16} /> |
| 79 | <span className="dock-launcher__entry-label">{t(entry.labelKey as never)}</span> |
| 80 | {isChanged && diffStats && showDiffStats ? ( |
| 81 | <span className="dock-launcher__entry-stats" title={diffStats.incomplete ? t("rightDock.partialStats") : undefined}> |
| 82 | {diffStats.incomplete ? <span aria-label={t("rightDock.partialStats")}>~</span> : null} |
| 83 | <span className="dock-launcher__entry-stats-added">+{diffStats.added.toLocaleString()}</span> |
| 84 | <span className="dock-launcher__entry-stats-removed">-{diffStats.removed.toLocaleString()}</span> |
| 85 | </span> |
| 86 | ) : null} |
| 87 | <ChevronRight size={14} className="dock-launcher__entry-chevron" /> |
| 88 | </button> |
| 89 | ); |
| 90 | })} |
| 91 | {branch.activeBranch ? ( |
| 92 | <div className="dock-launcher__branch-wrap"> |
| 93 | <button |
| 94 | type="button" |
| 95 | className={`dock-launcher__entry${branch.branchMenuOpen ? " dock-launcher__entry--open" : ""}`} |
| 96 | aria-label={`${t("status.gitBranchTitle")}: ${branch.activeBranch}`} |
| 97 | aria-expanded={branch.branchMenuOpen} |
| 98 | title={`${t("status.gitBranchTitle")}: ${branch.activeBranch}`} |
| 99 | onClick={branch.toggleBranchMenu} |
| 100 | > |
| 101 | <GitBranch size={16} /> |
| 102 | <span className="dock-launcher__entry-label">{branch.activeBranch}</span> |
| 103 | <ChevronRight size={14} className="dock-launcher__entry-chevron dock-launcher__entry-chevron--down" /> |
| 104 | </button> |
| 105 | {branch.branchMenuOpen ? ( |
| 106 | <div className="dock-launcher__branch-menu" role="menu" aria-label={t("rightDock.switchBranch")}> |
| 107 | <div className="dock-launcher__branch-search"> |
| 108 | <Search size={13} /> |
| 109 | <input |
| 110 | ref={branch.branchSearchRef} |
| 111 | type="text" |
| 112 | value={branch.branchQuery} |
| 113 | placeholder={t("rightDock.branchSearchPlaceholder")} |
| 114 | onChange={(event) => { |
| 115 | branch.setBranchQuery(event.target.value); |
| 116 | branch.setBranchSwitchErr(""); |
| 117 | }} |
| 118 | onKeyDown={(event) => { |
| 119 | if (event.key !== "Enter") return; |
| 120 | if (branch.exactMatch) void branch.checkoutBranch(branch.trimmedQuery); |
| 121 | else if (branch.canCreate) void branch.createBranch(branch.trimmedQuery); |
| 122 | }} |
| 123 | /> |
| 124 | </div> |
| 125 | <div className="dock-launcher__branch-section">{t("rightDock.branchSection")}</div> |
| 126 | <div className="dock-launcher__branch-list"> |
| 127 | {branch.branchesLoading ? <div className="dock-launcher__branch-menu-note">{t("rightDock.branchMenuLoading")}</div> : null} |
| 128 | {!branch.branchesLoading && branch.branchesErr ? <div className="dock-launcher__branch-menu-note dock-launcher__branch-menu-note--err">{branch.branchesErr}</div> : null} |
| 129 | {!branch.branchesLoading && !branch.branchesErr && branch.filteredBranches.length === 0 ? ( |
| 130 | <div className="dock-launcher__branch-menu-note">{t("rightDock.branchNoMatch")}</div> |
| 131 | ) : null} |
| 132 | {branch.filteredBranches.map((name) => ( |
| 133 | <button |
| 134 | key={name} |
| 135 | type="button" |
| 136 | role="menuitem" |
| 137 | className={`dock-launcher__branch-item${name === branch.activeBranch ? " dock-launcher__branch-item--active" : ""}`} |
| 138 | title={name} |
| 139 | disabled={branch.switchingBranch !== ""} |
| 140 | onClick={() => void branch.checkoutBranch(name)} |
| 141 | > |
| 142 | <GitBranch size={14} /> |
| 143 | <span className="dock-launcher__branch-item-name">{name}</span> |
| 144 | {branch.switchingBranch === name ? ( |
| 145 | <span className="dock-launcher__branch-menu-spinner" aria-hidden="true" /> |
| 146 | ) : name === branch.activeBranch ? ( |
| 147 | <Check size={14} /> |
| 148 | ) : null} |
| 149 | </button> |
| 150 | ))} |
| 151 | </div> |
| 152 | {branch.branchSwitchErr ? <div className="dock-launcher__branch-menu-note dock-launcher__branch-menu-note--err">{branch.branchSwitchErr}</div> : null} |
| 153 | <div className="dock-launcher__branch-create"> |
| 154 | <button |
| 155 | type="button" |
| 156 | role="menuitem" |
| 157 | className="dock-launcher__branch-item dock-launcher__branch-item--create" |
| 158 | disabled={!branch.canCreate || branch.switchingBranch !== ""} |
| 159 | title={branch.canCreate ? branch.trimmedQuery : undefined} |
| 160 | onClick={() => void branch.createBranch(branch.trimmedQuery)} |
| 161 | > |
| 162 | {branch.switchingBranch === branch.trimmedQuery ? ( |
| 163 | <span className="dock-launcher__branch-menu-spinner" aria-hidden="true" /> |
| 164 | ) : ( |
| 165 | <Plus size={14} /> |
| 166 | )} |
| 167 | <span className="dock-launcher__branch-item-name"> |
| 168 | {t("rightDock.branchCreate")} |
| 169 | {branch.trimmedQuery ? ` ${branch.trimmedQuery}` : ""} |
| 170 | </span> |
| 171 | </button> |
| 172 | </div> |
| 173 | </div> |
| 174 | ) : null} |
| 175 | </div> |
| 176 | ) : null} |
| 177 | </div> |
| 178 | ); |
| 179 | } |
| 180 |