@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.
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +39 -2
- package/dist/claude.js.map +1 -1
- package/dist/cli/ui/components/App.d.ts.map +1 -1
- package/dist/cli/ui/components/App.js +12 -61
- package/dist/cli/ui/components/App.js.map +1 -1
- package/dist/cli/ui/components/common/SpinnerIcon.d.ts +20 -0
- package/dist/cli/ui/components/common/SpinnerIcon.d.ts.map +1 -0
- package/dist/cli/ui/components/common/SpinnerIcon.js +61 -0
- package/dist/cli/ui/components/common/SpinnerIcon.js.map +1 -0
- package/dist/cli/ui/components/parts/Stats.d.ts +2 -5
- package/dist/cli/ui/components/parts/Stats.d.ts.map +1 -1
- package/dist/cli/ui/components/parts/Stats.js +16 -3
- package/dist/cli/ui/components/parts/Stats.js.map +1 -1
- package/dist/cli/ui/components/screens/BranchListScreen.d.ts +6 -2
- package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
- package/dist/cli/ui/components/screens/BranchListScreen.js +95 -42
- package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
- package/dist/cli/ui/hooks/useAppInput.d.ts +1 -0
- package/dist/cli/ui/hooks/useAppInput.d.ts.map +1 -1
- package/dist/cli/ui/hooks/useAppInput.js +2 -1
- package/dist/cli/ui/hooks/useAppInput.js.map +1 -1
- package/dist/cli/ui/hooks/useGitData.d.ts +1 -0
- package/dist/cli/ui/hooks/useGitData.d.ts.map +1 -1
- package/dist/cli/ui/hooks/useGitData.js +43 -15
- package/dist/cli/ui/hooks/useGitData.js.map +1 -1
- package/dist/cli/ui/types.d.ts +4 -0
- package/dist/cli/ui/types.d.ts.map +1 -1
- package/dist/git.d.ts +2 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +38 -14
- package/dist/git.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/claude.ts +45 -2
- package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +208 -0
- package/src/cli/ui/__tests__/hooks/useGitData.nonblocking.test.tsx +158 -0
- package/src/cli/ui/components/App.tsx +22 -77
- package/src/cli/ui/components/common/SpinnerIcon.tsx +86 -0
- package/src/cli/ui/components/parts/Stats.tsx +24 -3
- package/src/cli/ui/components/screens/BranchListScreen.tsx +117 -45
- package/src/cli/ui/hooks/useAppInput.ts +2 -1
- package/src/cli/ui/hooks/useGitData.ts +101 -18
- package/src/cli/ui/screens/__tests__/BranchActionSelectorScreen.test.tsx +46 -1
- package/src/cli/ui/types.ts +5 -0
- package/src/git.ts +48 -17
- 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
|
-
{
|
|
147
|
+
{
|
|
148
|
+
icon: string;
|
|
149
|
+
isSpinning?: boolean;
|
|
150
|
+
color?: "cyan" | "green" | "yellow" | "red";
|
|
151
|
+
}
|
|
157
152
|
>
|
|
158
153
|
>({});
|
|
159
|
-
const [
|
|
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:
|
|
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
|
-
{
|
|
890
|
+
{
|
|
891
|
+
icon: string;
|
|
892
|
+
isSpinning?: boolean;
|
|
893
|
+
color?: "cyan" | "green" | "yellow" | "red";
|
|
894
|
+
}
|
|
948
895
|
>
|
|
949
896
|
>((acc, target, index) => {
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
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>
|