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