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