@akiojin/gwt 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/claude.d.ts.map +1 -1
  2. package/dist/claude.js +39 -2
  3. package/dist/claude.js.map +1 -1
  4. package/dist/cli/ui/components/App.d.ts.map +1 -1
  5. package/dist/cli/ui/components/App.js +12 -61
  6. package/dist/cli/ui/components/App.js.map +1 -1
  7. package/dist/cli/ui/components/common/SpinnerIcon.d.ts +20 -0
  8. package/dist/cli/ui/components/common/SpinnerIcon.d.ts.map +1 -0
  9. package/dist/cli/ui/components/common/SpinnerIcon.js +61 -0
  10. package/dist/cli/ui/components/common/SpinnerIcon.js.map +1 -0
  11. package/dist/cli/ui/components/parts/Stats.d.ts +2 -5
  12. package/dist/cli/ui/components/parts/Stats.d.ts.map +1 -1
  13. package/dist/cli/ui/components/parts/Stats.js +16 -3
  14. package/dist/cli/ui/components/parts/Stats.js.map +1 -1
  15. package/dist/cli/ui/components/screens/BranchListScreen.d.ts +6 -2
  16. package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
  17. package/dist/cli/ui/components/screens/BranchListScreen.js +95 -42
  18. package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
  19. package/dist/cli/ui/hooks/useAppInput.d.ts +1 -0
  20. package/dist/cli/ui/hooks/useAppInput.d.ts.map +1 -1
  21. package/dist/cli/ui/hooks/useAppInput.js +2 -1
  22. package/dist/cli/ui/hooks/useAppInput.js.map +1 -1
  23. package/dist/cli/ui/hooks/useGitData.d.ts +1 -0
  24. package/dist/cli/ui/hooks/useGitData.d.ts.map +1 -1
  25. package/dist/cli/ui/hooks/useGitData.js +43 -15
  26. package/dist/cli/ui/hooks/useGitData.js.map +1 -1
  27. package/dist/cli/ui/types.d.ts +4 -0
  28. package/dist/cli/ui/types.d.ts.map +1 -1
  29. package/dist/git.d.ts +2 -0
  30. package/dist/git.d.ts.map +1 -1
  31. package/dist/git.js +38 -14
  32. package/dist/git.js.map +1 -1
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +14 -1
  36. package/dist/index.js.map +1 -1
  37. package/package.json +4 -4
  38. package/src/claude.ts +45 -2
  39. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +208 -0
  40. package/src/cli/ui/__tests__/hooks/useGitData.nonblocking.test.tsx +158 -0
  41. package/src/cli/ui/components/App.tsx +22 -77
  42. package/src/cli/ui/components/common/SpinnerIcon.tsx +86 -0
  43. package/src/cli/ui/components/parts/Stats.tsx +24 -3
  44. package/src/cli/ui/components/screens/BranchListScreen.tsx +117 -45
  45. package/src/cli/ui/hooks/useAppInput.ts +2 -1
  46. package/src/cli/ui/hooks/useGitData.ts +101 -18
  47. package/src/cli/ui/screens/__tests__/BranchActionSelectorScreen.test.tsx +46 -1
  48. package/src/cli/ui/types.ts +5 -0
  49. package/src/git.ts +48 -17
  50. package/src/index.ts +14 -1
@@ -833,4 +833,212 @@ describe("BranchListScreen", () => {
833
833
  expect(container.textContent).toContain("feature/add-filter");
834
834
  });
835
835
  });
836
+
837
+ describe("Branch View Mode Toggle (TAB key)", () => {
838
+ const mixedBranches: BranchItem[] = [
839
+ {
840
+ name: "main",
841
+ type: "local",
842
+ branchType: "main",
843
+ isCurrent: true,
844
+ icons: ["⚡"],
845
+ hasChanges: false,
846
+ label: "⚡ main",
847
+ value: "main",
848
+ latestCommitTimestamp: 1_700_000_000,
849
+ },
850
+ {
851
+ name: "feature/test",
852
+ type: "local",
853
+ branchType: "feature",
854
+ isCurrent: false,
855
+ icons: ["✨"],
856
+ hasChanges: false,
857
+ label: "✨ feature/test",
858
+ value: "feature/test",
859
+ latestCommitTimestamp: 1_699_000_000,
860
+ },
861
+ {
862
+ name: "origin/main",
863
+ type: "remote",
864
+ branchType: "main",
865
+ isCurrent: false,
866
+ icons: ["🌐"],
867
+ hasChanges: false,
868
+ label: "🌐 origin/main",
869
+ value: "origin/main",
870
+ remoteName: "origin/main",
871
+ latestCommitTimestamp: 1_698_000_000,
872
+ },
873
+ {
874
+ name: "origin/feature/remote-test",
875
+ type: "remote",
876
+ branchType: "feature",
877
+ isCurrent: false,
878
+ icons: ["🌐"],
879
+ hasChanges: false,
880
+ label: "🌐 origin/feature/remote-test",
881
+ value: "origin/feature/remote-test",
882
+ remoteName: "origin/feature/remote-test",
883
+ latestCommitTimestamp: 1_697_000_000,
884
+ },
885
+ ];
886
+
887
+ it("should default to 'all' view mode and display Mode: All in stats", () => {
888
+ const onSelect = vi.fn();
889
+ const { container } = render(
890
+ <BranchListScreen
891
+ branches={mixedBranches}
892
+ stats={mockStats}
893
+ onSelect={onSelect}
894
+ />,
895
+ );
896
+
897
+ expect(container.textContent).toContain("Mode: All");
898
+ });
899
+
900
+ it("should filter to local branches only when view mode is 'local'", () => {
901
+ const onSelect = vi.fn();
902
+ const { container } = render(
903
+ <BranchListScreen
904
+ branches={mixedBranches}
905
+ stats={mockStats}
906
+ onSelect={onSelect}
907
+ testViewMode="local"
908
+ />,
909
+ );
910
+
911
+ expect(container.textContent).toContain("Mode: Local");
912
+ expect(container.textContent).toContain("main");
913
+ expect(container.textContent).toContain("feature/test");
914
+ expect(container.textContent).not.toContain("origin/main");
915
+ expect(container.textContent).not.toContain("origin/feature/remote-test");
916
+ });
917
+
918
+ it("should filter to remote branches only when view mode is 'remote'", () => {
919
+ const onSelect = vi.fn();
920
+ const { container } = render(
921
+ <BranchListScreen
922
+ branches={mixedBranches}
923
+ stats={mockStats}
924
+ onSelect={onSelect}
925
+ testViewMode="remote"
926
+ />,
927
+ );
928
+
929
+ expect(container.textContent).toContain("Mode: Remote");
930
+ expect(container.textContent).not.toContain("feature/test");
931
+ expect(container.textContent).toContain("origin/main");
932
+ expect(container.textContent).toContain("origin/feature/remote-test");
933
+ });
934
+
935
+ it("should toggle view mode from all to local when TAB is pressed", () => {
936
+ const onSelect = vi.fn();
937
+ const onViewModeChange = vi.fn();
938
+
939
+ const inkApp = inkRender(
940
+ <BranchListScreen
941
+ branches={mixedBranches}
942
+ stats={mockStats}
943
+ onSelect={onSelect}
944
+ testOnViewModeChange={onViewModeChange}
945
+ />,
946
+ );
947
+
948
+ act(() => {
949
+ inkApp.stdin.write("\t"); // TAB key
950
+ });
951
+
952
+ expect(onViewModeChange).toHaveBeenCalledWith("local");
953
+
954
+ inkApp.unmount();
955
+ });
956
+
957
+ it("should toggle view mode from local to remote when TAB is pressed", () => {
958
+ const onSelect = vi.fn();
959
+ const onViewModeChange = vi.fn();
960
+
961
+ const inkApp = inkRender(
962
+ <BranchListScreen
963
+ branches={mixedBranches}
964
+ stats={mockStats}
965
+ onSelect={onSelect}
966
+ testViewMode="local"
967
+ testOnViewModeChange={onViewModeChange}
968
+ />,
969
+ );
970
+
971
+ act(() => {
972
+ inkApp.stdin.write("\t"); // TAB key
973
+ });
974
+
975
+ expect(onViewModeChange).toHaveBeenCalledWith("remote");
976
+
977
+ inkApp.unmount();
978
+ });
979
+
980
+ it("should toggle view mode from remote to all when TAB is pressed", () => {
981
+ const onSelect = vi.fn();
982
+ const onViewModeChange = vi.fn();
983
+
984
+ const inkApp = inkRender(
985
+ <BranchListScreen
986
+ branches={mixedBranches}
987
+ stats={mockStats}
988
+ onSelect={onSelect}
989
+ testViewMode="remote"
990
+ testOnViewModeChange={onViewModeChange}
991
+ />,
992
+ );
993
+
994
+ act(() => {
995
+ inkApp.stdin.write("\t"); // TAB key
996
+ });
997
+
998
+ expect(onViewModeChange).toHaveBeenCalledWith("all");
999
+
1000
+ inkApp.unmount();
1001
+ });
1002
+
1003
+ it("should not toggle view mode when in filter mode", () => {
1004
+ const onSelect = vi.fn();
1005
+ const onViewModeChange = vi.fn();
1006
+
1007
+ const inkApp = inkRender(
1008
+ <BranchListScreen
1009
+ branches={mixedBranches}
1010
+ stats={mockStats}
1011
+ onSelect={onSelect}
1012
+ testFilterMode={true}
1013
+ testOnViewModeChange={onViewModeChange}
1014
+ />,
1015
+ );
1016
+
1017
+ act(() => {
1018
+ inkApp.stdin.write("\t"); // TAB key
1019
+ });
1020
+
1021
+ expect(onViewModeChange).not.toHaveBeenCalled();
1022
+
1023
+ inkApp.unmount();
1024
+ });
1025
+
1026
+ it("should combine view mode filter with search filter (AND condition)", () => {
1027
+ const onSelect = vi.fn();
1028
+ const { container } = render(
1029
+ <BranchListScreen
1030
+ branches={mixedBranches}
1031
+ stats={mockStats}
1032
+ onSelect={onSelect}
1033
+ testViewMode="local"
1034
+ testFilterQuery="feature"
1035
+ />,
1036
+ );
1037
+
1038
+ // Only local branches matching "feature"
1039
+ expect(container.textContent).toContain("feature/test");
1040
+ expect(container.textContent).not.toContain("main");
1041
+ expect(container.textContent).not.toContain("origin/feature/remote-test");
1042
+ });
1043
+ });
836
1044
  });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
5
+ import { renderHook, waitFor } from "@testing-library/react";
6
+ import { GIT_DATA_TIMEOUT_MS, useGitData } from "../../hooks/useGitData.js";
7
+ import { Window } from "happy-dom";
8
+
9
+ vi.mock("../../../git.js", () => ({
10
+ getAllBranches: vi.fn(),
11
+ fetchAllRemotes: vi.fn(),
12
+ getRepositoryRoot: vi.fn(),
13
+ collectUpstreamMap: vi.fn(),
14
+ getBranchDivergenceStatuses: vi.fn(),
15
+ hasUnpushedCommitsInRepo: vi.fn(),
16
+ hasUncommittedChanges: vi.fn(),
17
+ }));
18
+
19
+ vi.mock("../../../worktree.js", () => ({
20
+ listAdditionalWorktrees: vi.fn(),
21
+ }));
22
+
23
+ vi.mock("../../../github.js", () => ({
24
+ getPullRequestByBranch: vi.fn(),
25
+ }));
26
+
27
+ vi.mock("../../../config/index.js", () => ({
28
+ getLastToolUsageMap: vi.fn(),
29
+ }));
30
+
31
+ import {
32
+ getAllBranches,
33
+ fetchAllRemotes,
34
+ getRepositoryRoot,
35
+ collectUpstreamMap,
36
+ getBranchDivergenceStatuses,
37
+ hasUnpushedCommitsInRepo,
38
+ hasUncommittedChanges,
39
+ } from "../../../git.js";
40
+ import { listAdditionalWorktrees } from "../../../worktree.js";
41
+ import { getPullRequestByBranch } from "../../../github.js";
42
+ import { getLastToolUsageMap } from "../../../config/index.js";
43
+
44
+ const advanceTimersBy = async (ms: number) => {
45
+ if (typeof vi.advanceTimersByTimeAsync === "function") {
46
+ await vi.advanceTimersByTimeAsync(ms);
47
+ } else if (typeof vi.advanceTimersByTime === "function") {
48
+ vi.advanceTimersByTime(ms);
49
+ }
50
+ };
51
+
52
+ describe("useGitData non-blocking fetch", () => {
53
+ beforeEach(() => {
54
+ const window = new Window();
55
+ globalThis.window = window as unknown as typeof globalThis.window;
56
+ globalThis.document =
57
+ window.document as unknown as typeof globalThis.document;
58
+
59
+ (getAllBranches as ReturnType<typeof vi.fn>).mockReset();
60
+ (fetchAllRemotes as ReturnType<typeof vi.fn>).mockReset();
61
+ (getRepositoryRoot as ReturnType<typeof vi.fn>).mockReset();
62
+ (collectUpstreamMap as ReturnType<typeof vi.fn>).mockReset();
63
+ (getBranchDivergenceStatuses as ReturnType<typeof vi.fn>).mockReset();
64
+ (hasUnpushedCommitsInRepo as ReturnType<typeof vi.fn>).mockReset();
65
+ (hasUncommittedChanges as ReturnType<typeof vi.fn>).mockReset();
66
+ (listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockReset();
67
+ (getPullRequestByBranch as ReturnType<typeof vi.fn>).mockReset();
68
+ (getLastToolUsageMap as ReturnType<typeof vi.fn>).mockReset();
69
+ });
70
+
71
+ afterEach(() => {
72
+ if (typeof vi.useRealTimers === "function") {
73
+ vi.useRealTimers();
74
+ }
75
+ });
76
+
77
+ it("does not block loading on fetchAllRemotes", async () => {
78
+ const pending = new Promise<void>(() => {});
79
+
80
+ (getRepositoryRoot as ReturnType<typeof vi.fn>).mockResolvedValue("/repo");
81
+ (fetchAllRemotes as ReturnType<typeof vi.fn>).mockReturnValue(pending);
82
+ (getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue([]);
83
+ (listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
84
+ (getLastToolUsageMap as ReturnType<typeof vi.fn>).mockResolvedValue(
85
+ new Map(),
86
+ );
87
+ (collectUpstreamMap as ReturnType<typeof vi.fn>).mockResolvedValue(
88
+ new Map(),
89
+ );
90
+ (getBranchDivergenceStatuses as ReturnType<typeof vi.fn>).mockResolvedValue(
91
+ [],
92
+ );
93
+ (hasUnpushedCommitsInRepo as ReturnType<typeof vi.fn>).mockResolvedValue(
94
+ false,
95
+ );
96
+ (hasUncommittedChanges as ReturnType<typeof vi.fn>).mockResolvedValue(
97
+ false,
98
+ );
99
+ (getPullRequestByBranch as ReturnType<typeof vi.fn>).mockResolvedValue(
100
+ null,
101
+ );
102
+
103
+ const { result } = renderHook(() => useGitData());
104
+
105
+ await waitFor(
106
+ () => {
107
+ expect(result.current.loading).toBe(false);
108
+ },
109
+ { timeout: 1000 },
110
+ );
111
+
112
+ expect(fetchAllRemotes).toHaveBeenCalled();
113
+ });
114
+
115
+ it("releases loading state when branch fetch stalls", async () => {
116
+ if (typeof vi.useFakeTimers === "function") {
117
+ vi.useFakeTimers();
118
+ }
119
+
120
+ const pending = new Promise<void>(() => {});
121
+
122
+ (getRepositoryRoot as ReturnType<typeof vi.fn>).mockResolvedValue("/repo");
123
+ (fetchAllRemotes as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
124
+ (getAllBranches as ReturnType<typeof vi.fn>).mockReturnValue(
125
+ pending as unknown as Promise<unknown>,
126
+ );
127
+ (listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
128
+ (getLastToolUsageMap as ReturnType<typeof vi.fn>).mockResolvedValue(
129
+ new Map(),
130
+ );
131
+ (collectUpstreamMap as ReturnType<typeof vi.fn>).mockResolvedValue(
132
+ new Map(),
133
+ );
134
+ (getBranchDivergenceStatuses as ReturnType<typeof vi.fn>).mockResolvedValue(
135
+ [],
136
+ );
137
+ (hasUnpushedCommitsInRepo as ReturnType<typeof vi.fn>).mockResolvedValue(
138
+ false,
139
+ );
140
+ (hasUncommittedChanges as ReturnType<typeof vi.fn>).mockResolvedValue(
141
+ false,
142
+ );
143
+ (getPullRequestByBranch as ReturnType<typeof vi.fn>).mockResolvedValue(
144
+ null,
145
+ );
146
+
147
+ const { result } = renderHook(() => useGitData());
148
+
149
+ await advanceTimersBy(GIT_DATA_TIMEOUT_MS + 10);
150
+
151
+ await waitFor(
152
+ () => {
153
+ expect(result.current.loading).toBe(false);
154
+ },
155
+ { timeout: 1000 },
156
+ );
157
+ });
158
+ });
@@ -67,19 +67,10 @@ import {
67
67
  } from "../../../utils/session.js";
68
68
  import type { ToolSessionEntry } from "../../../config/index.js";
69
69
 
70
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
71
70
  const COMPLETION_HOLD_DURATION_MS = 3000;
72
71
  const PROTECTED_BRANCH_WARNING =
73
72
  "Root branches operate directly in the repository root. Create a new branch if you need a dedicated worktree.";
74
73
 
75
- const getSpinnerFrame = (index: number): string => {
76
- const frame = SPINNER_FRAMES[index];
77
- if (typeof frame === "string") {
78
- return frame;
79
- }
80
- return SPINNER_FRAMES[0] ?? "⠋";
81
- };
82
-
83
74
  export interface SelectionResult {
84
75
  branch: string; // Local branch name (without remote prefix)
85
76
  displayName: string; // Name that was selected in the UI (may include remote prefix)
@@ -153,21 +144,24 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
153
144
  const [cleanupIndicators, setCleanupIndicators] = useState<
154
145
  Record<
155
146
  string,
156
- { icon: string; color?: "cyan" | "green" | "yellow" | "red" }
147
+ {
148
+ icon: string;
149
+ isSpinning?: boolean;
150
+ color?: "cyan" | "green" | "yellow" | "red";
151
+ }
157
152
  >
158
153
  >({});
159
- const [cleanupProcessingBranch, setCleanupProcessingBranch] = useState<
154
+ const [_cleanupProcessingBranch, setCleanupProcessingBranch] = useState<
160
155
  string | null
161
156
  >(null);
162
157
  const [cleanupInputLocked, setCleanupInputLocked] = useState(false);
163
158
  const [cleanupFooterMessage, setCleanupFooterMessage] = useState<{
164
159
  text: string;
160
+ isSpinning?: boolean;
165
161
  color?: "cyan" | "green" | "yellow" | "red";
166
162
  } | null>(null);
167
163
  const [hiddenBranches, setHiddenBranches] = useState<string[]>([]);
168
164
  const [selectedBranches, setSelectedBranches] = useState<string[]>([]);
169
- const spinnerFrameIndexRef = useRef(0);
170
- const [spinnerFrameIndex, setSpinnerFrameIndex] = useState(0);
171
165
  const completionTimerRef = useRef<NodeJS.Timeout | null>(null);
172
166
 
173
167
  // Fetch version on mount
@@ -184,55 +178,6 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
184
178
  .catch(() => setRepoRoot(null));
185
179
  }, []);
186
180
 
187
- useEffect(() => {
188
- if (!cleanupInputLocked) {
189
- spinnerFrameIndexRef.current = 0;
190
- setSpinnerFrameIndex(0);
191
- return undefined;
192
- }
193
-
194
- const interval = setInterval(() => {
195
- spinnerFrameIndexRef.current =
196
- (spinnerFrameIndexRef.current + 1) % SPINNER_FRAMES.length;
197
- setSpinnerFrameIndex(spinnerFrameIndexRef.current);
198
- }, 120);
199
-
200
- return () => {
201
- clearInterval(interval);
202
- spinnerFrameIndexRef.current = 0;
203
- setSpinnerFrameIndex(0);
204
- };
205
- }, [cleanupInputLocked]);
206
-
207
- useEffect(() => {
208
- if (!cleanupInputLocked) {
209
- return;
210
- }
211
-
212
- const frame = getSpinnerFrame(spinnerFrameIndex);
213
-
214
- if (cleanupProcessingBranch) {
215
- setCleanupIndicators((prev) => {
216
- const current = prev[cleanupProcessingBranch];
217
- if (current && current.icon === frame && current.color === "cyan") {
218
- return prev;
219
- }
220
-
221
- const next: Record<
222
- string,
223
- { icon: string; color?: "cyan" | "green" | "yellow" | "red" }
224
- > = {
225
- ...prev,
226
- [cleanupProcessingBranch]: { icon: frame, color: "cyan" },
227
- };
228
-
229
- return next;
230
- });
231
- }
232
-
233
- setCleanupFooterMessage({ text: `Processing... ${frame}`, color: "cyan" });
234
- }, [cleanupInputLocked, cleanupProcessingBranch, spinnerFrameIndex]);
235
-
236
181
  useEffect(() => {
237
182
  if (!hiddenBranches.length) {
238
183
  return;
@@ -872,14 +817,12 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
872
817
  // Provide immediate feedback before fetching targets
873
818
  setCleanupInputLocked(true);
874
819
  setCleanupIndicators({});
875
- const initialFrame = getSpinnerFrame(0);
876
820
  setCleanupFooterMessage({
877
- text: `Processing... ${initialFrame}`,
821
+ text: "Processing...",
822
+ isSpinning: true,
878
823
  color: "cyan",
879
824
  });
880
825
  setCleanupProcessingBranch(null);
881
- spinnerFrameIndexRef.current = 0;
882
- setSpinnerFrameIndex(0);
883
826
 
884
827
  const branchMap = new Map(branches.map((branch) => [branch.name, branch]));
885
828
  const worktreeMap = new Map(
@@ -944,23 +887,27 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
944
887
  const initialIndicators = targets.reduce<
945
888
  Record<
946
889
  string,
947
- { icon: string; color?: "cyan" | "green" | "yellow" | "red" }
890
+ {
891
+ icon: string;
892
+ isSpinning?: boolean;
893
+ color?: "cyan" | "green" | "yellow" | "red";
894
+ }
948
895
  >
949
896
  >((acc, target, index) => {
950
- const icon = index === 0 ? getSpinnerFrame(0) : "⏳";
951
- const color: "cyan" | "green" | "yellow" | "red" =
952
- index === 0 ? "cyan" : "yellow";
953
- acc[target.branch] = { icon, color };
897
+ if (index === 0) {
898
+ acc[target.branch] = { icon: "", isSpinning: true, color: "cyan" };
899
+ } else {
900
+ acc[target.branch] = { icon: "⏳", color: "yellow" };
901
+ }
954
902
  return acc;
955
903
  }, {});
956
904
 
957
905
  setCleanupIndicators(initialIndicators);
958
906
  const firstTarget = targets.length > 0 ? targets[0] : undefined;
959
907
  setCleanupProcessingBranch(firstTarget ? firstTarget.branch : null);
960
- spinnerFrameIndexRef.current = 0;
961
- setSpinnerFrameIndex(0);
962
908
  setCleanupFooterMessage({
963
- text: `Processing... ${getSpinnerFrame(0)}`,
909
+ text: "Processing...",
910
+ isSpinning: true,
964
911
  color: "cyan",
965
912
  });
966
913
 
@@ -972,12 +919,10 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
972
919
  const target = currentTarget;
973
920
 
974
921
  setCleanupProcessingBranch(target.branch);
975
- spinnerFrameIndexRef.current = 0;
976
- setSpinnerFrameIndex(0);
977
922
 
978
923
  setCleanupIndicators((prev) => {
979
924
  const updated = { ...prev };
980
- updated[target.branch] = { icon: getSpinnerFrame(0), color: "cyan" };
925
+ updated[target.branch] = { icon: "", isSpinning: true, color: "cyan" };
981
926
  for (const pending of targets.slice(index + 1)) {
982
927
  const current = updated[pending.branch];
983
928
  if (!current || current.icon !== "⏳") {
@@ -0,0 +1,86 @@
1
+ import React, { useEffect, useState, useRef } from "react";
2
+ import { Text } from "ink";
3
+
4
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
5
+
6
+ export interface SpinnerIconProps {
7
+ /** true にするとスピナーを回転させる */
8
+ isSpinning: boolean;
9
+ /** スピナーの色 */
10
+ color?: "cyan" | "green" | "yellow" | "red";
11
+ /** スピナーの更新間隔 (ms)。デフォルトは 120ms */
12
+ interval?: number;
13
+ }
14
+
15
+ /**
16
+ * インラインで使用できるスピナーアイコンコンポーネント。
17
+ * 自身の内部で状態を管理するため、親コンポーネントの再レンダリングを引き起こさない。
18
+ */
19
+ export function SpinnerIcon({
20
+ isSpinning,
21
+ color = "cyan",
22
+ interval = 120,
23
+ }: SpinnerIconProps) {
24
+ const [frameIndex, setFrameIndex] = useState(0);
25
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
26
+
27
+ useEffect(() => {
28
+ if (isSpinning) {
29
+ intervalRef.current = setInterval(() => {
30
+ setFrameIndex((current) => (current + 1) % SPINNER_FRAMES.length);
31
+ }, interval);
32
+ } else {
33
+ setFrameIndex(0);
34
+ }
35
+
36
+ return () => {
37
+ if (intervalRef.current) {
38
+ clearInterval(intervalRef.current);
39
+ intervalRef.current = null;
40
+ }
41
+ };
42
+ }, [isSpinning, interval]);
43
+
44
+ if (!isSpinning) {
45
+ return null;
46
+ }
47
+
48
+ const frame = SPINNER_FRAMES[frameIndex] ?? SPINNER_FRAMES[0];
49
+
50
+ return <Text color={color}>{frame}</Text>;
51
+ }
52
+
53
+ /**
54
+ * スピナーを文字列として取得するためのフック。
55
+ * Textコンポーネントでラップせず、文字列として使用したい場合に使用。
56
+ */
57
+ export function useSpinnerFrame(
58
+ isSpinning: boolean,
59
+ interval = 120,
60
+ ): string | null {
61
+ const [frameIndex, setFrameIndex] = useState(0);
62
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
63
+
64
+ useEffect(() => {
65
+ if (isSpinning) {
66
+ intervalRef.current = setInterval(() => {
67
+ setFrameIndex((current) => (current + 1) % SPINNER_FRAMES.length);
68
+ }, interval);
69
+ } else {
70
+ setFrameIndex(0);
71
+ }
72
+
73
+ return () => {
74
+ if (intervalRef.current) {
75
+ clearInterval(intervalRef.current);
76
+ intervalRef.current = null;
77
+ }
78
+ };
79
+ }, [isSpinning, interval]);
80
+
81
+ if (!isSpinning) {
82
+ return null;
83
+ }
84
+
85
+ return SPINNER_FRAMES[frameIndex] ?? SPINNER_FRAMES[0] ?? null;
86
+ }
@@ -1,11 +1,12 @@
1
1
  import React from "react";
2
2
  import { Box, Text } from "ink";
3
- import type { Statistics } from "../../types.js";
3
+ import type { Statistics, BranchViewMode } from "../../types.js";
4
4
 
5
5
  export interface StatsProps {
6
6
  stats: Statistics;
7
7
  separator?: string;
8
8
  lastUpdated?: Date | null;
9
+ viewMode?: BranchViewMode;
9
10
  }
10
11
 
11
12
  /**
@@ -30,13 +31,24 @@ function formatRelativeTime(date: Date): string {
30
31
  }
31
32
 
32
33
  /**
33
- * Stats component - displays statistics in one line
34
- * Optimized with React.memo to prevent unnecessary re-renders
34
+ * Format view mode label for display
35
35
  */
36
+ function formatViewModeLabel(mode: BranchViewMode): string {
37
+ switch (mode) {
38
+ case "all":
39
+ return "All";
40
+ case "local":
41
+ return "Local";
42
+ case "remote":
43
+ return "Remote";
44
+ }
45
+ }
46
+
36
47
  export const Stats = React.memo(function Stats({
37
48
  stats,
38
49
  separator = " ",
39
50
  lastUpdated = null,
51
+ viewMode,
40
52
  }: StatsProps) {
41
53
  const items = [
42
54
  { label: "Local", value: stats.localCount, color: "cyan" },
@@ -47,6 +59,15 @@ export const Stats = React.memo(function Stats({
47
59
 
48
60
  return (
49
61
  <Box>
62
+ {viewMode && (
63
+ <Box>
64
+ <Text dimColor>Mode: </Text>
65
+ <Text bold color="white">
66
+ {formatViewModeLabel(viewMode)}
67
+ </Text>
68
+ <Text dimColor>{separator}</Text>
69
+ </Box>
70
+ )}
50
71
  {items.map((item) => (
51
72
  <Box key={item.label}>
52
73
  <Text dimColor>{item.label}: </Text>