@akiojin/gwt 4.6.1 → 4.7.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 +5 -1
- 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 +102 -1
- package/dist/cli/ui/components/App.js.map +1 -1
- package/dist/cli/ui/components/screens/BranchListScreen.d.ts +1 -0
- package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
- package/dist/cli/ui/components/screens/BranchListScreen.js +6 -2
- package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
- package/dist/cli/ui/components/screens/LogDatePickerScreen.d.ts +10 -0
- package/dist/cli/ui/components/screens/LogDatePickerScreen.d.ts.map +1 -0
- package/dist/cli/ui/components/screens/LogDatePickerScreen.js +44 -0
- package/dist/cli/ui/components/screens/LogDatePickerScreen.js.map +1 -0
- package/dist/cli/ui/components/screens/LogDetailScreen.d.ts +14 -0
- package/dist/cli/ui/components/screens/LogDetailScreen.d.ts.map +1 -0
- package/dist/cli/ui/components/screens/LogDetailScreen.js +34 -0
- package/dist/cli/ui/components/screens/LogDetailScreen.js.map +1 -0
- package/dist/cli/ui/components/screens/LogListScreen.d.ts +19 -0
- package/dist/cli/ui/components/screens/LogListScreen.d.ts.map +1 -0
- package/dist/cli/ui/components/screens/LogListScreen.js +107 -0
- package/dist/cli/ui/components/screens/LogListScreen.js.map +1 -0
- package/dist/cli/ui/hooks/useGitData.d.ts.map +1 -1
- package/dist/cli/ui/hooks/useGitData.js +10 -3
- package/dist/cli/ui/hooks/useGitData.js.map +1 -1
- package/dist/cli/ui/types.d.ts +1 -1
- package/dist/cli/ui/types.d.ts.map +1 -1
- package/dist/cli/ui/utils/clipboard.d.ts +7 -0
- package/dist/cli/ui/utils/clipboard.d.ts.map +1 -0
- package/dist/cli/ui/utils/clipboard.js +21 -0
- package/dist/cli/ui/utils/clipboard.js.map +1 -0
- package/dist/gemini.d.ts.map +1 -1
- package/dist/gemini.js +5 -1
- package/dist/gemini.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/logging/formatter.d.ts +15 -0
- package/dist/logging/formatter.d.ts.map +1 -0
- package/dist/logging/formatter.js +81 -0
- package/dist/logging/formatter.js.map +1 -0
- package/dist/logging/reader.d.ts +12 -0
- package/dist/logging/reader.d.ts.map +1 -0
- package/dist/logging/reader.js +63 -0
- package/dist/logging/reader.js.map +1 -0
- package/dist/worktree.d.ts.map +1 -1
- package/dist/worktree.js +57 -0
- package/dist/worktree.js.map +1 -1
- package/package.json +1 -1
- package/src/claude.ts +6 -1
- package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +7 -3
- package/src/cli/ui/__tests__/components/common/Select.test.tsx +17 -11
- package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +28 -2
- package/src/cli/ui/__tests__/components/screens/LogDetailScreen.test.tsx +57 -0
- package/src/cli/ui/__tests__/components/screens/LogListScreen.test.tsx +102 -0
- package/src/cli/ui/__tests__/hooks/useGitData.test.ts +197 -0
- package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +19 -9
- package/src/cli/ui/__tests__/utils/clipboard.test.ts +65 -0
- package/src/cli/ui/components/App.tsx +177 -0
- package/src/cli/ui/components/screens/BranchListScreen.tsx +6 -1
- package/src/cli/ui/components/screens/LogDatePickerScreen.tsx +83 -0
- package/src/cli/ui/components/screens/LogDetailScreen.tsx +67 -0
- package/src/cli/ui/components/screens/LogListScreen.tsx +192 -0
- package/src/cli/ui/hooks/useGitData.ts +12 -3
- package/src/cli/ui/types.ts +3 -0
- package/src/cli/ui/utils/clipboard.ts +31 -0
- package/src/gemini.ts +6 -1
- package/src/index.ts +11 -2
- package/src/logging/formatter.ts +106 -0
- package/src/logging/reader.ts +76 -0
- package/src/worktree.ts +77 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from "vitest";
|
|
5
|
+
import { act } from "@testing-library/react";
|
|
6
|
+
import { render as inkRender } from "ink-testing-library";
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { LogListScreen } from "../../../components/screens/LogListScreen.js";
|
|
9
|
+
import type { FormattedLogEntry } from "../../../../../logging/formatter.js";
|
|
10
|
+
|
|
11
|
+
const buildEntry = (
|
|
12
|
+
overrides: Partial<FormattedLogEntry>,
|
|
13
|
+
): FormattedLogEntry => ({
|
|
14
|
+
id: "entry-1",
|
|
15
|
+
raw: {
|
|
16
|
+
time: "2025-12-25T10:00:00.000Z",
|
|
17
|
+
level: 30,
|
|
18
|
+
category: "cli",
|
|
19
|
+
msg: "hello",
|
|
20
|
+
},
|
|
21
|
+
timestamp: 1_767_015_200_000,
|
|
22
|
+
timeLabel: "10:00:00",
|
|
23
|
+
levelLabel: "INFO",
|
|
24
|
+
category: "cli",
|
|
25
|
+
message: "hello",
|
|
26
|
+
summary: "[10:00:00] [INFO] [cli] hello",
|
|
27
|
+
json: '{\n "msg": "hello"\n}',
|
|
28
|
+
...overrides,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("LogListScreen", () => {
|
|
32
|
+
it("renders log entries and handles shortcuts", () => {
|
|
33
|
+
const entries: FormattedLogEntry[] = [
|
|
34
|
+
buildEntry({ id: "entry-1" }),
|
|
35
|
+
buildEntry({
|
|
36
|
+
id: "entry-2",
|
|
37
|
+
summary: "[10:01:00] [WARN] [server] warn",
|
|
38
|
+
levelLabel: "WARN",
|
|
39
|
+
category: "server",
|
|
40
|
+
message: "warn",
|
|
41
|
+
}),
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const onSelect = vi.fn();
|
|
45
|
+
const onBack = vi.fn();
|
|
46
|
+
const onCopy = vi.fn();
|
|
47
|
+
const onPickDate = vi.fn();
|
|
48
|
+
|
|
49
|
+
const { stdin, lastFrame } = inkRender(
|
|
50
|
+
<LogListScreen
|
|
51
|
+
entries={entries}
|
|
52
|
+
loading={false}
|
|
53
|
+
error={null}
|
|
54
|
+
onBack={onBack}
|
|
55
|
+
onSelect={onSelect}
|
|
56
|
+
onCopy={onCopy}
|
|
57
|
+
onPickDate={onPickDate}
|
|
58
|
+
selectedDate="2025-12-25"
|
|
59
|
+
/>,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const frame = lastFrame();
|
|
63
|
+
expect(frame).toContain("[10:00:00] [INFO] [cli] hello");
|
|
64
|
+
expect(frame).toContain("[10:01:00] [WARN] [server] warn");
|
|
65
|
+
|
|
66
|
+
act(() => {
|
|
67
|
+
stdin.write("\r");
|
|
68
|
+
});
|
|
69
|
+
expect(onSelect).toHaveBeenCalledWith(entries[0]);
|
|
70
|
+
|
|
71
|
+
act(() => {
|
|
72
|
+
stdin.write("c");
|
|
73
|
+
});
|
|
74
|
+
expect(onCopy).toHaveBeenCalledWith(entries[0]);
|
|
75
|
+
|
|
76
|
+
act(() => {
|
|
77
|
+
stdin.write("d");
|
|
78
|
+
});
|
|
79
|
+
expect(onPickDate).toHaveBeenCalled();
|
|
80
|
+
|
|
81
|
+
act(() => {
|
|
82
|
+
stdin.write("q");
|
|
83
|
+
});
|
|
84
|
+
expect(onBack).toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("shows empty message when no logs", () => {
|
|
88
|
+
const { lastFrame } = inkRender(
|
|
89
|
+
<LogListScreen
|
|
90
|
+
entries={[]}
|
|
91
|
+
loading={false}
|
|
92
|
+
error={null}
|
|
93
|
+
onBack={vi.fn()}
|
|
94
|
+
onSelect={vi.fn()}
|
|
95
|
+
onCopy={vi.fn()}
|
|
96
|
+
selectedDate="2025-12-25"
|
|
97
|
+
/>,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(lastFrame()).toContain("ログがありません");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
|
5
|
+
import { renderHook, act, waitFor } from "@testing-library/react";
|
|
6
|
+
import { Window } from "happy-dom";
|
|
7
|
+
|
|
8
|
+
// モジュールをモック
|
|
9
|
+
vi.mock("../../../../git.js", () => ({
|
|
10
|
+
getAllBranches: vi.fn(),
|
|
11
|
+
hasUnpushedCommitsInRepo: vi.fn(),
|
|
12
|
+
getRepositoryRoot: vi.fn(),
|
|
13
|
+
fetchAllRemotes: vi.fn(),
|
|
14
|
+
collectUpstreamMap: vi.fn(),
|
|
15
|
+
getBranchDivergenceStatuses: 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 { useGitData } from "../../hooks/useGitData.js";
|
|
32
|
+
import {
|
|
33
|
+
getAllBranches,
|
|
34
|
+
getRepositoryRoot,
|
|
35
|
+
fetchAllRemotes,
|
|
36
|
+
collectUpstreamMap,
|
|
37
|
+
getBranchDivergenceStatuses,
|
|
38
|
+
} from "../../../../git.js";
|
|
39
|
+
import { listAdditionalWorktrees } from "../../../../worktree.js";
|
|
40
|
+
import { getLastToolUsageMap } from "../../../../config/index.js";
|
|
41
|
+
|
|
42
|
+
const mockGetAllBranches = getAllBranches as ReturnType<typeof vi.fn>;
|
|
43
|
+
const mockGetRepositoryRoot = getRepositoryRoot as ReturnType<typeof vi.fn>;
|
|
44
|
+
const mockFetchAllRemotes = fetchAllRemotes as ReturnType<typeof vi.fn>;
|
|
45
|
+
const mockCollectUpstreamMap = collectUpstreamMap as ReturnType<typeof vi.fn>;
|
|
46
|
+
const mockGetBranchDivergenceStatuses =
|
|
47
|
+
getBranchDivergenceStatuses as ReturnType<typeof vi.fn>;
|
|
48
|
+
const mockListAdditionalWorktrees = listAdditionalWorktrees as ReturnType<
|
|
49
|
+
typeof vi.fn
|
|
50
|
+
>;
|
|
51
|
+
const mockGetLastToolUsageMap = getLastToolUsageMap as ReturnType<typeof vi.fn>;
|
|
52
|
+
|
|
53
|
+
describe("useGitData", () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
// Setup happy-dom
|
|
56
|
+
const window = new Window();
|
|
57
|
+
globalThis.window = window as unknown as typeof globalThis.window;
|
|
58
|
+
globalThis.document =
|
|
59
|
+
window.document as unknown as typeof globalThis.document;
|
|
60
|
+
|
|
61
|
+
// Reset all mocks
|
|
62
|
+
vi.clearAllMocks();
|
|
63
|
+
|
|
64
|
+
// Default mock implementations
|
|
65
|
+
mockGetRepositoryRoot.mockResolvedValue("/mock/repo");
|
|
66
|
+
mockFetchAllRemotes.mockResolvedValue(undefined);
|
|
67
|
+
mockGetAllBranches.mockResolvedValue([
|
|
68
|
+
{
|
|
69
|
+
name: "main",
|
|
70
|
+
type: "local",
|
|
71
|
+
isCurrent: true,
|
|
72
|
+
lastCommitDate: "2025-01-01",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: "develop",
|
|
76
|
+
type: "local",
|
|
77
|
+
isCurrent: false,
|
|
78
|
+
lastCommitDate: "2025-01-02",
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
mockListAdditionalWorktrees.mockResolvedValue([]);
|
|
82
|
+
mockCollectUpstreamMap.mockResolvedValue(new Map());
|
|
83
|
+
mockGetBranchDivergenceStatuses.mockResolvedValue([]);
|
|
84
|
+
mockGetLastToolUsageMap.mockResolvedValue(new Map());
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
vi.restoreAllMocks();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("キャッシュ機構", () => {
|
|
92
|
+
it("初回マウント時にGitデータを取得する", async () => {
|
|
93
|
+
const { result } = renderHook(() => useGitData());
|
|
94
|
+
|
|
95
|
+
// 初回ロード中
|
|
96
|
+
expect(result.current.loading).toBe(true);
|
|
97
|
+
|
|
98
|
+
await waitFor(() => {
|
|
99
|
+
expect(result.current.loading).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Gitデータが取得されていることを確認
|
|
103
|
+
expect(mockGetAllBranches).toHaveBeenCalledTimes(1);
|
|
104
|
+
expect(result.current.branches).toHaveLength(2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("refresh()を呼び出すとGitデータを再取得する", async () => {
|
|
108
|
+
const { result } = renderHook(() => useGitData());
|
|
109
|
+
|
|
110
|
+
await waitFor(() => {
|
|
111
|
+
expect(result.current.loading).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// 初回ロードで1回呼ばれている
|
|
115
|
+
expect(mockGetAllBranches).toHaveBeenCalledTimes(1);
|
|
116
|
+
|
|
117
|
+
// refresh()を呼び出す
|
|
118
|
+
await act(async () => {
|
|
119
|
+
result.current.refresh();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await waitFor(() => {
|
|
123
|
+
expect(result.current.loading).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// refresh後に再度呼ばれている(forceRefresh=true)
|
|
127
|
+
expect(mockGetAllBranches).toHaveBeenCalledTimes(2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("キャッシュ済みの場合、再マウント時にGitデータを再取得しない", async () => {
|
|
131
|
+
// 注: useGitData は内部で useRef を使ってキャッシュ状態を管理
|
|
132
|
+
// 同一コンポーネント内での再レンダリングではキャッシュが効く
|
|
133
|
+
const { result, rerender } = renderHook(() => useGitData());
|
|
134
|
+
|
|
135
|
+
await waitFor(() => {
|
|
136
|
+
expect(result.current.loading).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// 初回ロードで1回呼ばれている
|
|
140
|
+
expect(mockGetAllBranches).toHaveBeenCalledTimes(1);
|
|
141
|
+
|
|
142
|
+
// 再レンダリング
|
|
143
|
+
rerender();
|
|
144
|
+
|
|
145
|
+
// キャッシュされているため追加の呼び出しはない
|
|
146
|
+
expect(mockGetAllBranches).toHaveBeenCalledTimes(1);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("lastUpdated", () => {
|
|
151
|
+
it("データ取得成功後にlastUpdatedが更新される", async () => {
|
|
152
|
+
const { result } = renderHook(() => useGitData());
|
|
153
|
+
|
|
154
|
+
// 初期状態ではnull
|
|
155
|
+
expect(result.current.lastUpdated).toBeNull();
|
|
156
|
+
|
|
157
|
+
await waitFor(() => {
|
|
158
|
+
expect(result.current.loading).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ロード完了後にlastUpdatedが設定される
|
|
162
|
+
expect(result.current.lastUpdated).toBeInstanceOf(Date);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("エラーハンドリング", () => {
|
|
167
|
+
it("getAllBranchesがエラーを投げた場合、フォールバック値(空配列)が使用される", async () => {
|
|
168
|
+
// withTimeout がエラーをキャッチしてフォールバック値を返すため、
|
|
169
|
+
// エラー状態にはならず、空配列が設定される
|
|
170
|
+
mockGetAllBranches.mockRejectedValue(new Error("Git error"));
|
|
171
|
+
|
|
172
|
+
const { result } = renderHook(() => useGitData());
|
|
173
|
+
|
|
174
|
+
await waitFor(() => {
|
|
175
|
+
expect(result.current.loading).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// エラーはキャッチされ、フォールバック値が使用される
|
|
179
|
+
expect(result.current.error).toBeNull();
|
|
180
|
+
expect(result.current.branches).toHaveLength(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("fetchAllRemotesがエラーを投げてもローカル表示は継続される", async () => {
|
|
184
|
+
mockFetchAllRemotes.mockRejectedValue(new Error("Network error"));
|
|
185
|
+
|
|
186
|
+
const { result } = renderHook(() => useGitData());
|
|
187
|
+
|
|
188
|
+
await waitFor(() => {
|
|
189
|
+
expect(result.current.loading).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// リモート取得失敗でもローカルブランチは表示される
|
|
193
|
+
expect(result.current.branches).toHaveLength(2);
|
|
194
|
+
expect(result.current.error).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -13,7 +13,14 @@ import type { BranchInfo, BranchItem, Statistics } from "../../types.js";
|
|
|
13
13
|
const mockRefresh = vi.fn();
|
|
14
14
|
const branchListProps: BranchListScreenProps[] = [];
|
|
15
15
|
const useGitDataMock = vi.fn();
|
|
16
|
-
let App: typeof import("../../components/App.js").App;
|
|
16
|
+
let App: typeof import("../../components/App.js").App | null = null;
|
|
17
|
+
|
|
18
|
+
const loadApp = async () => {
|
|
19
|
+
if (!App) {
|
|
20
|
+
App = (await import("../../components/App.js")).App;
|
|
21
|
+
}
|
|
22
|
+
return App;
|
|
23
|
+
};
|
|
17
24
|
|
|
18
25
|
vi.mock("../../hooks/useGitData.js", () => ({
|
|
19
26
|
useGitData: (...args: unknown[]) => useGitDataMock(...args),
|
|
@@ -53,7 +60,6 @@ describe("Edge Cases Integration Tests", () => {
|
|
|
53
60
|
vi.clearAllMocks();
|
|
54
61
|
useGitDataMock.mockReset();
|
|
55
62
|
branchListProps.length = 0;
|
|
56
|
-
App = (await import("../../components/App.js")).App;
|
|
57
63
|
});
|
|
58
64
|
|
|
59
65
|
/**
|
|
@@ -237,13 +243,14 @@ describe("Edge Cases Integration Tests", () => {
|
|
|
237
243
|
});
|
|
238
244
|
|
|
239
245
|
const onExit = vi.fn();
|
|
240
|
-
const
|
|
246
|
+
const AppComponent = await loadApp();
|
|
247
|
+
const { container } = render(<AppComponent onExit={onExit} />);
|
|
241
248
|
|
|
242
249
|
// Initial render should work
|
|
243
250
|
expect(container).toBeDefined();
|
|
244
251
|
});
|
|
245
252
|
|
|
246
|
-
it("[T093] should display error message when data loading fails", () => {
|
|
253
|
+
it("[T093] should display error message when data loading fails", async () => {
|
|
247
254
|
const testError = new Error("Test error: Failed to load Git data");
|
|
248
255
|
useGitDataMock.mockReturnValue({
|
|
249
256
|
branches: [],
|
|
@@ -255,7 +262,8 @@ describe("Edge Cases Integration Tests", () => {
|
|
|
255
262
|
});
|
|
256
263
|
|
|
257
264
|
const onExit = vi.fn();
|
|
258
|
-
const
|
|
265
|
+
const AppComponent = await loadApp();
|
|
266
|
+
const { getByText } = render(<AppComponent onExit={onExit} />);
|
|
259
267
|
|
|
260
268
|
expect(branchListProps).not.toHaveLength(0);
|
|
261
269
|
expect(branchListProps.at(-1)?.error).toBe(testError);
|
|
@@ -264,7 +272,7 @@ describe("Edge Cases Integration Tests", () => {
|
|
|
264
272
|
expect(getByText(/Failed to load Git data/i)).toBeDefined();
|
|
265
273
|
});
|
|
266
274
|
|
|
267
|
-
it("[T093] should handle empty branches list gracefully", () => {
|
|
275
|
+
it("[T093] should handle empty branches list gracefully", async () => {
|
|
268
276
|
useGitDataMock.mockReturnValue({
|
|
269
277
|
branches: [],
|
|
270
278
|
worktrees: [],
|
|
@@ -275,7 +283,8 @@ describe("Edge Cases Integration Tests", () => {
|
|
|
275
283
|
});
|
|
276
284
|
|
|
277
285
|
const onExit = vi.fn();
|
|
278
|
-
const
|
|
286
|
+
const AppComponent = await loadApp();
|
|
287
|
+
const { container } = render(<AppComponent onExit={onExit} />);
|
|
279
288
|
|
|
280
289
|
// Should render without error even with no branches
|
|
281
290
|
expect(container).toBeDefined();
|
|
@@ -284,7 +293,7 @@ describe("Edge Cases Integration Tests", () => {
|
|
|
284
293
|
/**
|
|
285
294
|
* Additional edge cases
|
|
286
295
|
*/
|
|
287
|
-
it("should handle large number of worktrees", () => {
|
|
296
|
+
it("should handle large number of worktrees", async () => {
|
|
288
297
|
const mockBranches: BranchInfo[] = Array.from({ length: 50 }, (_, i) => ({
|
|
289
298
|
name: `feature/branch-${i}`,
|
|
290
299
|
type: "local" as const,
|
|
@@ -307,7 +316,8 @@ describe("Edge Cases Integration Tests", () => {
|
|
|
307
316
|
});
|
|
308
317
|
|
|
309
318
|
const onExit = vi.fn();
|
|
310
|
-
const
|
|
319
|
+
const AppComponent = await loadApp();
|
|
320
|
+
const { container } = render(<AppComponent onExit={onExit} />);
|
|
311
321
|
|
|
312
322
|
expect(container).toBeDefined();
|
|
313
323
|
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { copyToClipboard } from "../../utils/clipboard.js";
|
|
3
|
+
|
|
4
|
+
const execaMock = vi.fn();
|
|
5
|
+
|
|
6
|
+
vi.mock("execa", () => ({
|
|
7
|
+
execa: (...args: unknown[]) => execaMock(...args),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe("copyToClipboard", () => {
|
|
11
|
+
it("uses pbcopy on darwin", async () => {
|
|
12
|
+
execaMock.mockResolvedValue({ stdout: "" });
|
|
13
|
+
|
|
14
|
+
await copyToClipboard("hello", {
|
|
15
|
+
platform: "darwin",
|
|
16
|
+
execa: execaMock as unknown as typeof import("execa").execa,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
expect(execaMock).toHaveBeenCalledWith(
|
|
20
|
+
"pbcopy",
|
|
21
|
+
[],
|
|
22
|
+
expect.objectContaining({ input: "hello" }),
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("falls back to xsel when xclip fails", async () => {
|
|
27
|
+
execaMock.mockImplementation((command: string) => {
|
|
28
|
+
if (command === "xclip") {
|
|
29
|
+
return Promise.reject(new Error("missing"));
|
|
30
|
+
}
|
|
31
|
+
return Promise.resolve({ stdout: "" });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await copyToClipboard("hello", {
|
|
35
|
+
platform: "linux",
|
|
36
|
+
execa: execaMock as unknown as typeof import("execa").execa,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(execaMock).toHaveBeenCalledWith(
|
|
40
|
+
"xclip",
|
|
41
|
+
["-selection", "clipboard"],
|
|
42
|
+
expect.objectContaining({ input: "hello" }),
|
|
43
|
+
);
|
|
44
|
+
expect(execaMock).toHaveBeenCalledWith(
|
|
45
|
+
"xsel",
|
|
46
|
+
["--clipboard", "--input"],
|
|
47
|
+
expect.objectContaining({ input: "hello" }),
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("uses clip on windows", async () => {
|
|
52
|
+
execaMock.mockResolvedValue({ stdout: "" });
|
|
53
|
+
|
|
54
|
+
await copyToClipboard("hello", {
|
|
55
|
+
platform: "win32",
|
|
56
|
+
execa: execaMock as unknown as typeof import("execa").execa,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(execaMock).toHaveBeenCalledWith(
|
|
60
|
+
"cmd",
|
|
61
|
+
["/c", "clip"],
|
|
62
|
+
expect.objectContaining({ input: "hello" }),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
BranchListScreen,
|
|
12
12
|
type BranchListScreenProps,
|
|
13
13
|
} from "./screens/BranchListScreen.js";
|
|
14
|
+
import { LogListScreen } from "./screens/LogListScreen.js";
|
|
15
|
+
import { LogDetailScreen } from "./screens/LogDetailScreen.js";
|
|
16
|
+
import { LogDatePickerScreen } from "./screens/LogDatePickerScreen.js";
|
|
14
17
|
import { BranchCreatorScreen } from "./screens/BranchCreatorScreen.js";
|
|
15
18
|
import { BranchActionSelectorScreen } from "../screens/BranchActionSelectorScreen.js";
|
|
16
19
|
import { AIToolSelectorScreen } from "./screens/AIToolSelectorScreen.js";
|
|
@@ -29,6 +32,19 @@ import { useScreenState } from "../hooks/useScreenState.js";
|
|
|
29
32
|
import { useToolStatus } from "../hooks/useToolStatus.js";
|
|
30
33
|
import { formatBranchItems } from "../utils/branchFormatter.js";
|
|
31
34
|
import { calculateStatistics } from "../utils/statisticsCalculator.js";
|
|
35
|
+
import { copyToClipboard } from "../utils/clipboard.js";
|
|
36
|
+
import {
|
|
37
|
+
parseLogLines,
|
|
38
|
+
type FormattedLogEntry,
|
|
39
|
+
} from "../../../logging/formatter.js";
|
|
40
|
+
import {
|
|
41
|
+
buildLogFilePath,
|
|
42
|
+
getTodayLogDate,
|
|
43
|
+
listRecentLogFiles,
|
|
44
|
+
readLogFileLines,
|
|
45
|
+
resolveLogDir,
|
|
46
|
+
type LogFileInfo,
|
|
47
|
+
} from "../../../logging/reader.js";
|
|
32
48
|
import type {
|
|
33
49
|
AITool,
|
|
34
50
|
BranchInfo,
|
|
@@ -131,6 +147,26 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
|
|
|
131
147
|
>([]);
|
|
132
148
|
const [branchQuickStartLoading, setBranchQuickStartLoading] = useState(false);
|
|
133
149
|
|
|
150
|
+
// Log viewer state
|
|
151
|
+
const logDir = useMemo(
|
|
152
|
+
() => resolveLogDir(workingDirectory),
|
|
153
|
+
[workingDirectory],
|
|
154
|
+
);
|
|
155
|
+
const [logEntries, setLogEntries] = useState<FormattedLogEntry[]>([]);
|
|
156
|
+
const [logLoading, setLogLoading] = useState(false);
|
|
157
|
+
const [logError, setLogError] = useState<string | null>(null);
|
|
158
|
+
const [logSelectedDate, setLogSelectedDate] = useState<string | null>(
|
|
159
|
+
getTodayLogDate(),
|
|
160
|
+
);
|
|
161
|
+
const [logDates, setLogDates] = useState<LogFileInfo[]>([]);
|
|
162
|
+
const [logSelectedEntry, setLogSelectedEntry] =
|
|
163
|
+
useState<FormattedLogEntry | null>(null);
|
|
164
|
+
const [logNotification, setLogNotification] = useState<{
|
|
165
|
+
message: string;
|
|
166
|
+
tone: "success" | "error";
|
|
167
|
+
} | null>(null);
|
|
168
|
+
const logNotificationTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
169
|
+
|
|
134
170
|
// Selection state (for branch → tool → mode flow)
|
|
135
171
|
const [selectedBranch, setSelectedBranch] =
|
|
136
172
|
useState<SelectedBranchState | null>(null);
|
|
@@ -182,6 +218,70 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
|
|
|
182
218
|
.catch(() => setRepoRoot(null));
|
|
183
219
|
}, []);
|
|
184
220
|
|
|
221
|
+
const showLogNotification = useCallback(
|
|
222
|
+
(message: string, tone: "success" | "error" = "success") => {
|
|
223
|
+
setLogNotification({ message, tone });
|
|
224
|
+
if (logNotificationTimerRef.current) {
|
|
225
|
+
clearTimeout(logNotificationTimerRef.current);
|
|
226
|
+
}
|
|
227
|
+
logNotificationTimerRef.current = setTimeout(() => {
|
|
228
|
+
setLogNotification(null);
|
|
229
|
+
}, 2000);
|
|
230
|
+
},
|
|
231
|
+
[],
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
return () => {
|
|
236
|
+
if (logNotificationTimerRef.current) {
|
|
237
|
+
clearTimeout(logNotificationTimerRef.current);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}, []);
|
|
241
|
+
|
|
242
|
+
const loadLogEntries = useCallback(
|
|
243
|
+
async (date: string | null) => {
|
|
244
|
+
const targetDate = date ?? getTodayLogDate();
|
|
245
|
+
setLogLoading(true);
|
|
246
|
+
setLogError(null);
|
|
247
|
+
try {
|
|
248
|
+
const filePath = buildLogFilePath(logDir, targetDate);
|
|
249
|
+
const lines = await readLogFileLines(filePath);
|
|
250
|
+
const parsed = parseLogLines(lines, { limit: 100 });
|
|
251
|
+
setLogEntries(parsed);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
setLogEntries([]);
|
|
254
|
+
setLogError(
|
|
255
|
+
error instanceof Error ? error.message : "Failed to load logs",
|
|
256
|
+
);
|
|
257
|
+
} finally {
|
|
258
|
+
setLogLoading(false);
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
[logDir],
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const loadLogDates = useCallback(async () => {
|
|
265
|
+
try {
|
|
266
|
+
const files = await listRecentLogFiles(logDir, 7);
|
|
267
|
+
setLogDates(files);
|
|
268
|
+
} catch {
|
|
269
|
+
setLogDates([]);
|
|
270
|
+
}
|
|
271
|
+
}, [logDir]);
|
|
272
|
+
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (currentScreen === "log-list") {
|
|
275
|
+
void loadLogEntries(logSelectedDate);
|
|
276
|
+
}
|
|
277
|
+
}, [currentScreen, loadLogEntries, logSelectedDate]);
|
|
278
|
+
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
if (currentScreen === "log-date-picker") {
|
|
281
|
+
void loadLogDates();
|
|
282
|
+
}
|
|
283
|
+
}, [currentScreen, loadLogDates]);
|
|
284
|
+
|
|
185
285
|
useEffect(() => {
|
|
186
286
|
if (!hiddenBranches.length) {
|
|
187
287
|
return;
|
|
@@ -720,6 +820,45 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
|
|
|
720
820
|
exit();
|
|
721
821
|
}, [onExit, exit]);
|
|
722
822
|
|
|
823
|
+
const handleOpenLogs = useCallback(() => {
|
|
824
|
+
setLogSelectedDate(getTodayLogDate());
|
|
825
|
+
setLogSelectedEntry(null);
|
|
826
|
+
navigateTo("log-list");
|
|
827
|
+
}, [navigateTo]);
|
|
828
|
+
|
|
829
|
+
const handleSelectLogEntry = useCallback(
|
|
830
|
+
(entry: FormattedLogEntry) => {
|
|
831
|
+
setLogSelectedEntry(entry);
|
|
832
|
+
navigateTo("log-detail");
|
|
833
|
+
},
|
|
834
|
+
[navigateTo],
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
const handleCopyLogEntry = useCallback(
|
|
838
|
+
async (entry: FormattedLogEntry) => {
|
|
839
|
+
try {
|
|
840
|
+
await copyToClipboard(entry.json);
|
|
841
|
+
showLogNotification("Copied to clipboard.", "success");
|
|
842
|
+
} catch {
|
|
843
|
+
showLogNotification("Failed to copy to clipboard.", "error");
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
[showLogNotification],
|
|
847
|
+
);
|
|
848
|
+
|
|
849
|
+
const handleOpenLogDates = useCallback(() => {
|
|
850
|
+
navigateTo("log-date-picker");
|
|
851
|
+
}, [navigateTo]);
|
|
852
|
+
|
|
853
|
+
const handleSelectLogDate = useCallback(
|
|
854
|
+
(date: string) => {
|
|
855
|
+
setLogSelectedDate(date);
|
|
856
|
+
setLogSelectedEntry(null);
|
|
857
|
+
navigateTo("log-list");
|
|
858
|
+
},
|
|
859
|
+
[navigateTo],
|
|
860
|
+
);
|
|
861
|
+
|
|
723
862
|
// Handle branch creation
|
|
724
863
|
const handleCreate = useCallback(
|
|
725
864
|
async (branchName: string) => {
|
|
@@ -1150,6 +1289,7 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
|
|
|
1150
1289
|
workingDirectory={workingDirectory}
|
|
1151
1290
|
activeProfile={activeProfileName}
|
|
1152
1291
|
onOpenProfiles={() => navigateTo("environment-profile")}
|
|
1292
|
+
onOpenLogs={handleOpenLogs}
|
|
1153
1293
|
toolStatuses={toolStatuses}
|
|
1154
1294
|
{...additionalProps}
|
|
1155
1295
|
/>
|
|
@@ -1168,6 +1308,43 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
|
|
|
1168
1308
|
onToggleSelect: toggleBranchSelection,
|
|
1169
1309
|
});
|
|
1170
1310
|
|
|
1311
|
+
case "log-list":
|
|
1312
|
+
return (
|
|
1313
|
+
<LogListScreen
|
|
1314
|
+
entries={logEntries}
|
|
1315
|
+
loading={logLoading}
|
|
1316
|
+
error={logError}
|
|
1317
|
+
onBack={goBack}
|
|
1318
|
+
onSelect={handleSelectLogEntry}
|
|
1319
|
+
onCopy={handleCopyLogEntry}
|
|
1320
|
+
onPickDate={handleOpenLogDates}
|
|
1321
|
+
notification={logNotification}
|
|
1322
|
+
version={version}
|
|
1323
|
+
selectedDate={logSelectedDate}
|
|
1324
|
+
/>
|
|
1325
|
+
);
|
|
1326
|
+
|
|
1327
|
+
case "log-detail":
|
|
1328
|
+
return (
|
|
1329
|
+
<LogDetailScreen
|
|
1330
|
+
entry={logSelectedEntry}
|
|
1331
|
+
onBack={goBack}
|
|
1332
|
+
onCopy={handleCopyLogEntry}
|
|
1333
|
+
notification={logNotification}
|
|
1334
|
+
version={version}
|
|
1335
|
+
/>
|
|
1336
|
+
);
|
|
1337
|
+
|
|
1338
|
+
case "log-date-picker":
|
|
1339
|
+
return (
|
|
1340
|
+
<LogDatePickerScreen
|
|
1341
|
+
dates={logDates}
|
|
1342
|
+
onBack={goBack}
|
|
1343
|
+
onSelect={handleSelectLogDate}
|
|
1344
|
+
version={version}
|
|
1345
|
+
/>
|
|
1346
|
+
);
|
|
1347
|
+
|
|
1171
1348
|
case "branch-creator":
|
|
1172
1349
|
return (
|
|
1173
1350
|
<BranchCreatorScreen
|