Skip to content

Commit 2662060

Browse files
committed
Refine workspace git status actions
1 parent 6e94403 commit 2662060

7 files changed

Lines changed: 186 additions & 30 deletions

File tree

packages/web/src/features/workspace/index.test.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,60 @@ describe("WorkspacePage", () => {
169169
});
170170
});
171171

172+
it("does not render a duplicate worktree entry button in the desktop git header", async () => {
173+
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
174+
if (op === "git.status") {
175+
return {
176+
branch: "feature/refactor-ts",
177+
ahead: 0,
178+
behind: 0,
179+
staged: [],
180+
modified: [],
181+
deleted: [],
182+
untracked: [],
183+
};
184+
}
185+
186+
return [];
187+
});
188+
189+
const store = createStore();
190+
store.set(connectionStatusAtom, "connected");
191+
store.set(wsClientAtom, { sendCommand } as never);
192+
seedReadyWorkspaceState(store, {
193+
"ws-test": {
194+
id: "ws-test",
195+
path: "/home/spencer/workspace/coder-studio",
196+
targetRuntime: "native",
197+
openedAt: 1,
198+
lastActiveAt: 1,
199+
uiState: {
200+
leftPanelWidth: 280,
201+
bottomPanelHeight: 200,
202+
focusMode: false,
203+
},
204+
},
205+
});
206+
207+
render(
208+
<Provider store={store}>
209+
<MemoryRouter initialEntries={["/workspace"]}>
210+
<Routes>
211+
<Route path="/workspace" element={<WorkspaceDesktopView />} />
212+
</Routes>
213+
</MemoryRouter>
214+
</Provider>
215+
);
216+
217+
await screen.findByText("feature/refactor-ts");
218+
219+
expect(
220+
screen.queryByRole("button", {
221+
name: "查看工作树",
222+
})
223+
).not.toBeInTheDocument();
224+
});
225+
172226
it("writes the displayed workspace id on mount and clears it on unmount", async () => {
173227
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
174228
if (op === "git.status") {

packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import { FileTreePanel } from "../shared/file-tree-panel";
1414
import { GitDiffViewer } from "../shared/git-diff-viewer";
1515
import { GitPanel } from "../shared/git-panel";
1616
import { GitStatusBar } from "../shared/git-status-bar";
17-
import { WorktreeListButton } from "../shared/worktree-list-button";
1817

1918
export const WorkspaceDesktopView: FC = () => {
2019
const fullscreenRootRef = useRef<HTMLDivElement>(null);
@@ -100,8 +99,12 @@ export const WorkspaceDesktopView: FC = () => {
10099
{t("label.git")}
101100
</button>
102101
</div>
103-
<GitStatusBar workspaceId={workspace.id} gitState={gitState} inline />
104-
<WorktreeListButton workspaceId={workspace.id} />
102+
<GitStatusBar
103+
workspaceId={workspace.id}
104+
gitState={gitState}
105+
inline
106+
onRefresh={handleRefreshSidebarPanel}
107+
/>
105108
</div>
106109
</div>
107110

packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export function MobileFilesSheet({
3535
const gitState = useAtomValue(gitStateAtomFamily(workspaceId));
3636
const setBranchQuickPick = useSetAtom(branchQuickPickAtom);
3737
const [activeTab, setActiveTab] = useState<"files" | "git">("files");
38+
const [panelRefreshToken, setPanelRefreshToken] = useState(0);
3839
const { closePreview } = useGitDiffViewerActions(workspaceId);
3940
const branchName = gitState?.branch?.trim() || t("git.no_branch");
4041

@@ -63,6 +64,10 @@ export function MobileFilesSheet({
6364
});
6465
};
6566

67+
const handleRefreshPanel = () => {
68+
setPanelRefreshToken((previous) => previous + 1);
69+
};
70+
6671
if (route.kind === "editor") {
6772
return (
6873
<div className="mobile-files-sheet">
@@ -158,14 +163,27 @@ export function MobileFilesSheet({
158163
{t("label.git")}
159164
</button>
160165
</div>
161-
<GitStatusBar workspaceId={workspaceId} gitState={gitState} inline />
166+
<GitStatusBar
167+
workspaceId={workspaceId}
168+
gitState={gitState}
169+
inline
170+
onRefresh={handleRefreshPanel}
171+
/>
162172
</div>
163173

164174
<div className="mobile-files-sheet__content">
165175
{activeTab === "files" ? (
166-
<FileTreePanel workspaceId={workspaceId} onSelectFile={handleSelectFile} />
176+
<FileTreePanel
177+
workspaceId={workspaceId}
178+
refreshToken={panelRefreshToken}
179+
onSelectFile={handleSelectFile}
180+
/>
167181
) : (
168-
<GitPanel workspaceId={workspaceId} onPreviewOpen={handlePreviewChange} />
182+
<GitPanel
183+
workspaceId={workspaceId}
184+
refreshToken={panelRefreshToken}
185+
onPreviewOpen={handlePreviewChange}
186+
/>
169187
)}
170188
</div>
171189
</div>

packages/web/src/features/workspace/views/shared/git-panel.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ describe("GitPanel", () => {
561561
expect(screen.getAllByText("未跟踪").length).toBeGreaterThan(0);
562562
expect(screen.getAllByText("已删除").length).toBeGreaterThan(0);
563563
expect(screen.getByPlaceholderText("输入提交信息...")).toBeInTheDocument();
564-
expect(screen.getByTitle("刷新")).toBeInTheDocument();
564+
expect(screen.queryByTitle("刷新")).not.toBeInTheDocument();
565565
expect(screen.getByTitle("暂存全部")).toBeInTheDocument();
566566
expect(screen.getByTitle("放弃全部")).toBeInTheDocument();
567567

packages/web/src/features/workspace/views/shared/git-panel.tsx

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { GitFileChange } from "@coder-studio/core";
2-
import { AlertTriangle, ArrowUp, File, Minus, Plus, RefreshCw, RotateCcw, X } from "lucide-react";
2+
import { AlertTriangle, ArrowUp, File, Minus, Plus, RotateCcw, X } from "lucide-react";
33
import type { FC } from "react";
44
import { useMemo } from "react";
55
import { useTranslation } from "../../../../lib/i18n";
@@ -30,7 +30,6 @@ export const GitPanel: FC<GitPanelProps> = ({ workspaceId, refreshToken = 0, onP
3030
handleRequestDiscardSingle,
3131
handleStageAll,
3232
handleUnstageAll,
33-
loadGitStatus,
3433
openDiff,
3534
runGitMutation,
3635
} = useGitPanelActions({
@@ -43,16 +42,6 @@ export const GitPanel: FC<GitPanelProps> = ({ workspaceId, refreshToken = 0, onP
4342
<div className="git-panel">
4443
<div className="panel-toolbar git-panel-toolbar">
4544
<div className="git-toolbar-cluster">
46-
<button
47-
className="panel-toolbar-btn"
48-
onClick={() => void loadGitStatus()}
49-
disabled={isLoading}
50-
title={t("action.refresh")}
51-
type="button"
52-
>
53-
<RefreshCw size={14} className={isLoading ? "spin" : undefined} />
54-
</button>
55-
5645
{hasChanges && (
5746
<>
5847
<button

packages/web/src/features/workspace/views/shared/git-status-bar.test.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ function renderStatusBar({
4444
}
4545

4646
describe("GitStatusBar", () => {
47+
it("keeps fetch as the trailing action after change and sync counters", () => {
48+
renderStatusBar();
49+
50+
const toolbar = screen.getByRole("button", { name: "Fetch" }).closest(".git-status-bar");
51+
expect(toolbar).not.toBeNull();
52+
53+
const buttons = within(toolbar as HTMLElement).getAllByRole("button");
54+
expect(buttons.at(-1)).toHaveAccessibleName("Fetch");
55+
});
56+
4757
it("confirms and pushes local commits from the status bar", async () => {
4858
let resolvePush: (() => void) | null = null;
4959
const pushPromise = new Promise<void>((resolve) => {
@@ -580,6 +590,74 @@ describe("GitStatusBar", () => {
580590
nowSpy.mockRestore();
581591
});
582592

593+
it("runs local refresh after fetch succeeds", async () => {
594+
const onRefresh = vi.fn();
595+
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
596+
if (op === "git.fetch") {
597+
return { success: true, message: "Fetch completed successfully", updatedRefs: [] };
598+
}
599+
600+
if (op === "git.branches") {
601+
return {
602+
current: "main",
603+
branches: [{ name: "main", isRemote: false, isCurrent: true }],
604+
};
605+
}
606+
607+
if (op === "git.status") {
608+
return baseStatus;
609+
}
610+
611+
throw new Error(`Unexpected command: ${op}`);
612+
});
613+
614+
const store = createStore();
615+
store.set(localeAtom, "en");
616+
store.set(wsClientAtom, { sendCommand } as never);
617+
store.set(gitStateAtomFamily("ws-test"), baseStatus);
618+
619+
render(
620+
<Provider store={store}>
621+
<GitStatusBar workspaceId="ws-test" gitState={baseStatus} inline onRefresh={onRefresh} />
622+
</Provider>
623+
);
624+
625+
fireEvent.click(screen.getByRole("button", { name: "Fetch" }));
626+
627+
await waitFor(() => {
628+
expect(onRefresh).toHaveBeenCalledTimes(1);
629+
});
630+
});
631+
632+
it("does not run local refresh when fetch fails", async () => {
633+
const onRefresh = vi.fn();
634+
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
635+
if (op === "git.fetch") {
636+
throw new Error("boom");
637+
}
638+
639+
throw new Error(`Unexpected command: ${op}`);
640+
});
641+
642+
const store = createStore();
643+
store.set(localeAtom, "en");
644+
store.set(wsClientAtom, { sendCommand } as never);
645+
store.set(gitStateAtomFamily("ws-test"), baseStatus);
646+
647+
render(
648+
<Provider store={store}>
649+
<GitStatusBar workspaceId="ws-test" gitState={baseStatus} inline onRefresh={onRefresh} />
650+
</Provider>
651+
);
652+
653+
fireEvent.click(screen.getByRole("button", { name: "Fetch" }));
654+
655+
await waitFor(() => {
656+
expect(store.get(toastsAtom)[0]?.title).toBe("Fetch failed");
657+
});
658+
expect(onRefresh).not.toHaveBeenCalled();
659+
});
660+
583661
it("shows fetching state while a manual fetch is running", async () => {
584662
let resolveFetch: (() => void) | null = null;
585663
const fetchPromise = new Promise<void>((resolve) => {

packages/web/src/features/workspace/views/shared/git-status-bar.tsx

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface GitStatusBarProps {
1111
workspaceId: string;
1212
gitState: GitStatus | null;
1313
inline?: boolean;
14+
onRefresh?: () => void;
1415
}
1516

1617
type GitSyncIntent = "push" | "pull";
@@ -20,7 +21,12 @@ interface SyncDialogState {
2021
count: number;
2122
}
2223

23-
export const GitStatusBar: FC<GitStatusBarProps> = ({ workspaceId, gitState, inline = false }) => {
24+
export const GitStatusBar: FC<GitStatusBarProps> = ({
25+
workspaceId,
26+
gitState,
27+
inline = false,
28+
onRefresh,
29+
}) => {
2430
const t = useTranslation();
2531
const locale = useAtomValue(localeAtom) as LocaleCode;
2632
const fetchState = useAtomValue(gitFetchAtomFamily(workspaceId));
@@ -154,6 +160,14 @@ export const GitStatusBar: FC<GitStatusBarProps> = ({ workspaceId, gitState, inl
154160
if (success) {
155161
clearAuthPrompt();
156162
setPendingAction(null);
163+
onRefresh?.();
164+
}
165+
};
166+
167+
const refreshAfterFetch = async () => {
168+
const success = await handleFetch();
169+
if (success) {
170+
onRefresh?.();
157171
}
158172
};
159173

@@ -164,16 +178,6 @@ export const GitStatusBar: FC<GitStatusBarProps> = ({ workspaceId, gitState, inl
164178
<Diff size={13} aria-hidden="true" />
165179
<span className="git-status-bar__value">{changeCount}</span>
166180
</span>
167-
<button
168-
className="git-status-bar__item git-status-bar__item--actionable"
169-
title={fetchTitle}
170-
type="button"
171-
aria-label={fetchAriaLabel}
172-
disabled={isFetching}
173-
onClick={() => void handleFetch()}
174-
>
175-
<RefreshCw size={13} aria-hidden="true" className={isFetching ? "spin" : undefined} />
176-
</button>
177181
<button
178182
className="git-status-bar__item git-status-bar__item--actionable git-status-bar__item--ahead"
179183
title={t("git.statusbar.ahead")}
@@ -196,6 +200,16 @@ export const GitStatusBar: FC<GitStatusBarProps> = ({ workspaceId, gitState, inl
196200
<ArrowDownToLine size={13} aria-hidden="true" />
197201
<span className="git-status-bar__value">{behind}</span>
198202
</button>
203+
<button
204+
className="git-status-bar__item git-status-bar__item--actionable"
205+
title={fetchTitle}
206+
type="button"
207+
aria-label={fetchAriaLabel}
208+
disabled={isFetching}
209+
onClick={() => void refreshAfterFetch()}
210+
>
211+
<RefreshCw size={13} aria-hidden="true" className={isFetching ? "spin" : undefined} />
212+
</button>
199213
</div>
200214

201215
{pendingAction || authPrompt ? (

0 commit comments

Comments
 (0)