返回 DeepSeek-Reasonix
DockLauncher.tsx
根目录 / desktop / frontend / src / components / DockLauncher.tsx
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
180 lines Plain Text