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