@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
|
@@ -93,6 +93,7 @@ export interface BranchListScreenProps {
|
|
|
93
93
|
onCleanupCommand?: () => void;
|
|
94
94
|
onRefresh?: () => void;
|
|
95
95
|
onOpenProfiles?: () => void;
|
|
96
|
+
onOpenLogs?: () => void;
|
|
96
97
|
loading?: boolean;
|
|
97
98
|
error?: Error | null;
|
|
98
99
|
lastUpdated?: Date | null;
|
|
@@ -128,6 +129,7 @@ export const BranchListScreen = React.memo(function BranchListScreen({
|
|
|
128
129
|
onCleanupCommand,
|
|
129
130
|
onRefresh,
|
|
130
131
|
onOpenProfiles,
|
|
132
|
+
onOpenLogs,
|
|
131
133
|
loading = false,
|
|
132
134
|
error = null,
|
|
133
135
|
lastUpdated = null,
|
|
@@ -273,6 +275,8 @@ export const BranchListScreen = React.memo(function BranchListScreen({
|
|
|
273
275
|
onRefresh();
|
|
274
276
|
} else if (input === "p" && onOpenProfiles) {
|
|
275
277
|
onOpenProfiles();
|
|
278
|
+
} else if (input === "l" && onOpenLogs) {
|
|
279
|
+
onOpenLogs();
|
|
276
280
|
}
|
|
277
281
|
});
|
|
278
282
|
|
|
@@ -346,6 +350,7 @@ export const BranchListScreen = React.memo(function BranchListScreen({
|
|
|
346
350
|
{ key: "r", description: "Refresh" },
|
|
347
351
|
{ key: "c", description: "Cleanup" },
|
|
348
352
|
{ key: "p", description: "Profiles" },
|
|
353
|
+
{ key: "l", description: "Logs" },
|
|
349
354
|
];
|
|
350
355
|
|
|
351
356
|
const formatLatestCommit = useCallback((timestamp?: number) => {
|
|
@@ -622,7 +627,7 @@ export const BranchListScreen = React.memo(function BranchListScreen({
|
|
|
622
627
|
onChange={setFilterQuery}
|
|
623
628
|
onSubmit={() => {}} // No-op: filter is applied in real-time
|
|
624
629
|
placeholder="Type to search..."
|
|
625
|
-
blockKeys={["c", "r", "f"]} // Block shortcuts while typing
|
|
630
|
+
blockKeys={["c", "r", "f", "l"]} // Block shortcuts while typing
|
|
626
631
|
/>
|
|
627
632
|
) : (
|
|
628
633
|
<Text dimColor>{filterQuery || "(press f to filter)"}</Text>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { Header } from "../parts/Header.js";
|
|
4
|
+
import { Footer } from "../parts/Footer.js";
|
|
5
|
+
import { Select } from "../common/Select.js";
|
|
6
|
+
import { useAppInput } from "../../hooks/useAppInput.js";
|
|
7
|
+
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
8
|
+
import type { LogFileInfo } from "../../../../logging/reader.js";
|
|
9
|
+
|
|
10
|
+
interface DateItem {
|
|
11
|
+
label: string;
|
|
12
|
+
value: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface LogDatePickerScreenProps {
|
|
16
|
+
dates: LogFileInfo[];
|
|
17
|
+
onBack: () => void;
|
|
18
|
+
onSelect: (date: string) => void;
|
|
19
|
+
version?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function LogDatePickerScreen({
|
|
23
|
+
dates,
|
|
24
|
+
onBack,
|
|
25
|
+
onSelect,
|
|
26
|
+
version,
|
|
27
|
+
}: LogDatePickerScreenProps) {
|
|
28
|
+
const { rows } = useTerminalSize();
|
|
29
|
+
|
|
30
|
+
useAppInput((input, key) => {
|
|
31
|
+
if (key.escape || input === "q") {
|
|
32
|
+
onBack();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const items: DateItem[] = dates.map((date) => ({
|
|
37
|
+
label: date.date,
|
|
38
|
+
value: date.date,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const handleSelect = (item: DateItem) => {
|
|
42
|
+
onSelect(item.value);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const headerLines = 2;
|
|
46
|
+
const statsLines = 1;
|
|
47
|
+
const emptyLine = 1;
|
|
48
|
+
const footerLines = 1;
|
|
49
|
+
const fixedLines = headerLines + statsLines + emptyLine + footerLines;
|
|
50
|
+
const contentHeight = rows - fixedLines;
|
|
51
|
+
const limit = Math.max(5, contentHeight);
|
|
52
|
+
|
|
53
|
+
const footerActions = [
|
|
54
|
+
{ key: "enter", description: "Select" },
|
|
55
|
+
{ key: "esc", description: "Back" },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Box flexDirection="column" height={rows}>
|
|
60
|
+
<Header title="gwt - Log Date" titleColor="cyan" version={version} />
|
|
61
|
+
|
|
62
|
+
<Box marginTop={1}>
|
|
63
|
+
<Text>
|
|
64
|
+
Total: <Text bold>{dates.length}</Text>
|
|
65
|
+
</Text>
|
|
66
|
+
</Box>
|
|
67
|
+
|
|
68
|
+
<Box height={1} />
|
|
69
|
+
|
|
70
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
71
|
+
{dates.length === 0 ? (
|
|
72
|
+
<Box>
|
|
73
|
+
<Text dimColor>ログがありません</Text>
|
|
74
|
+
</Box>
|
|
75
|
+
) : (
|
|
76
|
+
<Select items={items} onSelect={handleSelect} limit={limit} />
|
|
77
|
+
)}
|
|
78
|
+
</Box>
|
|
79
|
+
|
|
80
|
+
<Footer actions={footerActions} />
|
|
81
|
+
</Box>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { Header } from "../parts/Header.js";
|
|
4
|
+
import { Footer } from "../parts/Footer.js";
|
|
5
|
+
import { useAppInput } from "../../hooks/useAppInput.js";
|
|
6
|
+
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
7
|
+
import type { FormattedLogEntry } from "../../../../logging/formatter.js";
|
|
8
|
+
|
|
9
|
+
export interface LogDetailScreenProps {
|
|
10
|
+
entry: FormattedLogEntry | null;
|
|
11
|
+
onBack: () => void;
|
|
12
|
+
onCopy: (entry: FormattedLogEntry) => void;
|
|
13
|
+
notification?: { message: string; tone: "success" | "error" } | null;
|
|
14
|
+
version?: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function LogDetailScreen({
|
|
18
|
+
entry,
|
|
19
|
+
onBack,
|
|
20
|
+
onCopy,
|
|
21
|
+
notification,
|
|
22
|
+
version,
|
|
23
|
+
}: LogDetailScreenProps) {
|
|
24
|
+
const { rows } = useTerminalSize();
|
|
25
|
+
|
|
26
|
+
useAppInput((input, key) => {
|
|
27
|
+
if (key.escape || input === "q") {
|
|
28
|
+
onBack();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (input === "c" && entry) {
|
|
33
|
+
onCopy(entry);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const jsonLines = useMemo<string[]>(() => {
|
|
38
|
+
if (!entry) return ["ログがありません"]; // fallback
|
|
39
|
+
return entry.json.split("\n");
|
|
40
|
+
}, [entry]);
|
|
41
|
+
|
|
42
|
+
const footerActions = [
|
|
43
|
+
{ key: "c", description: "Copy" },
|
|
44
|
+
{ key: "esc", description: "Back" },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Box flexDirection="column" height={rows}>
|
|
49
|
+
<Header title="gwt - Log Detail" titleColor="cyan" version={version} />
|
|
50
|
+
|
|
51
|
+
{notification ? (
|
|
52
|
+
<Box marginTop={1}>
|
|
53
|
+
<Text color={notification.tone === "error" ? "red" : "green"}>
|
|
54
|
+
{notification.message}
|
|
55
|
+
</Text>
|
|
56
|
+
</Box>
|
|
57
|
+
) : null}
|
|
58
|
+
<Box flexDirection="column" flexGrow={1} marginTop={1}>
|
|
59
|
+
{jsonLines.map((line, index) => (
|
|
60
|
+
<Text key={`${line}-${index}`}>{line}</Text>
|
|
61
|
+
))}
|
|
62
|
+
</Box>
|
|
63
|
+
|
|
64
|
+
<Footer actions={footerActions} />
|
|
65
|
+
</Box>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useState } from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import stringWidth from "string-width";
|
|
4
|
+
import { Header } from "../parts/Header.js";
|
|
5
|
+
import { Footer } from "../parts/Footer.js";
|
|
6
|
+
import { Select } from "../common/Select.js";
|
|
7
|
+
import { useAppInput } from "../../hooks/useAppInput.js";
|
|
8
|
+
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
9
|
+
import type { FormattedLogEntry } from "../../../../logging/formatter.js";
|
|
10
|
+
|
|
11
|
+
interface LogListItem {
|
|
12
|
+
label: string;
|
|
13
|
+
value: string;
|
|
14
|
+
entry: FormattedLogEntry;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LogListScreenProps {
|
|
18
|
+
entries: FormattedLogEntry[];
|
|
19
|
+
loading?: boolean;
|
|
20
|
+
error?: string | null;
|
|
21
|
+
onBack: () => void;
|
|
22
|
+
onSelect: (entry: FormattedLogEntry) => void;
|
|
23
|
+
onCopy: (entry: FormattedLogEntry) => void;
|
|
24
|
+
onPickDate?: () => void;
|
|
25
|
+
notification?: { message: string; tone: "success" | "error" } | null;
|
|
26
|
+
version?: string | null;
|
|
27
|
+
selectedDate?: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const truncateToWidth = (value: string, maxWidth: number): string => {
|
|
31
|
+
if (maxWidth <= 0) return "";
|
|
32
|
+
if (stringWidth(value) <= maxWidth) return value;
|
|
33
|
+
const ellipsis = "…";
|
|
34
|
+
const ellipsisWidth = stringWidth(ellipsis);
|
|
35
|
+
if (ellipsisWidth >= maxWidth) return ellipsis;
|
|
36
|
+
|
|
37
|
+
let result = "";
|
|
38
|
+
for (const char of Array.from(value)) {
|
|
39
|
+
if (stringWidth(result + char) + ellipsisWidth > maxWidth) {
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
result += char;
|
|
43
|
+
}
|
|
44
|
+
return result + ellipsis;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const padToWidth = (value: string, width: number): string => {
|
|
48
|
+
if (width <= 0) return "";
|
|
49
|
+
if (stringWidth(value) >= width) return value;
|
|
50
|
+
return value + " ".repeat(width - stringWidth(value));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function LogListScreen({
|
|
54
|
+
entries,
|
|
55
|
+
loading = false,
|
|
56
|
+
error = null,
|
|
57
|
+
onBack,
|
|
58
|
+
onSelect,
|
|
59
|
+
onCopy,
|
|
60
|
+
onPickDate,
|
|
61
|
+
notification,
|
|
62
|
+
version,
|
|
63
|
+
selectedDate,
|
|
64
|
+
}: LogListScreenProps) {
|
|
65
|
+
const { rows, columns } = useTerminalSize();
|
|
66
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
67
|
+
|
|
68
|
+
const maxLabelWidth = Math.max(10, columns - 2);
|
|
69
|
+
|
|
70
|
+
const items = useMemo<LogListItem[]>(
|
|
71
|
+
() =>
|
|
72
|
+
entries.map((entry) => ({
|
|
73
|
+
label: truncateToWidth(entry.summary, maxLabelWidth),
|
|
74
|
+
value: entry.id,
|
|
75
|
+
entry,
|
|
76
|
+
})),
|
|
77
|
+
[entries, maxLabelWidth],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const handleSelect = useCallback(
|
|
81
|
+
(item: LogListItem) => {
|
|
82
|
+
onSelect(item.entry);
|
|
83
|
+
},
|
|
84
|
+
[onSelect],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const handleCopy = useCallback(() => {
|
|
88
|
+
const entry = entries[selectedIndex];
|
|
89
|
+
if (entry) {
|
|
90
|
+
onCopy(entry);
|
|
91
|
+
}
|
|
92
|
+
}, [entries, selectedIndex, onCopy]);
|
|
93
|
+
|
|
94
|
+
useAppInput((input, key) => {
|
|
95
|
+
if (key.escape || input === "q") {
|
|
96
|
+
onBack();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (input === "c") {
|
|
101
|
+
handleCopy();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (input === "d" && onPickDate) {
|
|
106
|
+
onPickDate();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const renderItem = useCallback(
|
|
111
|
+
(item: LogListItem, isSelected: boolean) => {
|
|
112
|
+
if (!isSelected) {
|
|
113
|
+
return <Text>{item.label}</Text>;
|
|
114
|
+
}
|
|
115
|
+
const padded = padToWidth(item.label, maxLabelWidth);
|
|
116
|
+
const output = `\u001b[46m\u001b[30m${padded}\u001b[0m`;
|
|
117
|
+
return <Text>{output}</Text>;
|
|
118
|
+
},
|
|
119
|
+
[maxLabelWidth],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const headerLines = 2;
|
|
123
|
+
const statsLines = 1;
|
|
124
|
+
const emptyLine = 1;
|
|
125
|
+
const footerLines = 1;
|
|
126
|
+
const fixedLines = headerLines + statsLines + emptyLine + footerLines;
|
|
127
|
+
const contentHeight = rows - fixedLines;
|
|
128
|
+
const limit = Math.max(5, contentHeight);
|
|
129
|
+
|
|
130
|
+
const footerActions = [
|
|
131
|
+
{ key: "enter", description: "Detail" },
|
|
132
|
+
{ key: "c", description: "Copy" },
|
|
133
|
+
{ key: "d", description: "Date" },
|
|
134
|
+
{ key: "esc", description: "Back" },
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<Box flexDirection="column" height={rows}>
|
|
139
|
+
<Header title="gwt - Log Viewer" titleColor="cyan" version={version} />
|
|
140
|
+
|
|
141
|
+
{notification ? (
|
|
142
|
+
<Box marginTop={1}>
|
|
143
|
+
<Text color={notification.tone === "error" ? "red" : "green"}>
|
|
144
|
+
{notification.message}
|
|
145
|
+
</Text>
|
|
146
|
+
</Box>
|
|
147
|
+
) : null}
|
|
148
|
+
|
|
149
|
+
<Box marginTop={1}>
|
|
150
|
+
<Box marginRight={2}>
|
|
151
|
+
<Text>
|
|
152
|
+
Date: <Text bold>{selectedDate ?? "---"}</Text>
|
|
153
|
+
</Text>
|
|
154
|
+
</Box>
|
|
155
|
+
<Text>
|
|
156
|
+
Total: <Text bold>{entries.length}</Text>
|
|
157
|
+
</Text>
|
|
158
|
+
</Box>
|
|
159
|
+
|
|
160
|
+
<Box height={1} />
|
|
161
|
+
|
|
162
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
163
|
+
{loading ? (
|
|
164
|
+
<Box>
|
|
165
|
+
<Text dimColor>Loading logs...</Text>
|
|
166
|
+
</Box>
|
|
167
|
+
) : entries.length === 0 ? (
|
|
168
|
+
<Box>
|
|
169
|
+
<Text dimColor>ログがありません</Text>
|
|
170
|
+
</Box>
|
|
171
|
+
) : (
|
|
172
|
+
<Select
|
|
173
|
+
items={items}
|
|
174
|
+
onSelect={handleSelect}
|
|
175
|
+
limit={limit}
|
|
176
|
+
selectedIndex={selectedIndex}
|
|
177
|
+
onSelectedIndexChange={setSelectedIndex}
|
|
178
|
+
renderItem={renderItem}
|
|
179
|
+
/>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{error ? (
|
|
183
|
+
<Box marginTop={1}>
|
|
184
|
+
<Text color="red">{error}</Text>
|
|
185
|
+
</Box>
|
|
186
|
+
) : null}
|
|
187
|
+
</Box>
|
|
188
|
+
|
|
189
|
+
<Footer actions={footerActions} />
|
|
190
|
+
</Box>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from "react";
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
import {
|
|
3
3
|
getAllBranches,
|
|
4
4
|
hasUnpushedCommitsInRepo,
|
|
@@ -80,7 +80,15 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
|
|
|
80
80
|
const [error, setError] = useState<Error | null>(null);
|
|
81
81
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
// キャッシュ機構: 初回ロード完了を追跡(useRef で再レンダリングを防ぐ)
|
|
84
|
+
const hasLoadedOnceRef = useRef(false);
|
|
85
|
+
|
|
86
|
+
const loadData = useCallback(async (forceRefresh = false) => {
|
|
87
|
+
// キャッシュがあり、強制リフレッシュでなければスキップ
|
|
88
|
+
if (hasLoadedOnceRef.current && !forceRefresh) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
setLoading(true);
|
|
85
93
|
setError(null);
|
|
86
94
|
|
|
@@ -295,6 +303,7 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
|
|
|
295
303
|
|
|
296
304
|
setBranches(enrichedBranches);
|
|
297
305
|
setLastUpdated(new Date());
|
|
306
|
+
hasLoadedOnceRef.current = true; // 初回ロード完了をマーク
|
|
298
307
|
} catch (err) {
|
|
299
308
|
setError(err instanceof Error ? err : new Error(String(err)));
|
|
300
309
|
setBranches([]);
|
|
@@ -305,7 +314,7 @@ export function useGitData(options?: UseGitDataOptions): UseGitDataResult {
|
|
|
305
314
|
}, []);
|
|
306
315
|
|
|
307
316
|
const refresh = useCallback(() => {
|
|
308
|
-
loadData();
|
|
317
|
+
loadData(true); // forceRefresh = true で強制的にデータを再取得
|
|
309
318
|
}, [loadData]);
|
|
310
319
|
|
|
311
320
|
useEffect(() => {
|
package/src/cli/ui/types.ts
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
|
|
3
|
+
export interface ClipboardOptions {
|
|
4
|
+
platform?: NodeJS.Platform;
|
|
5
|
+
execa?: typeof execa;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function copyToClipboard(
|
|
9
|
+
text: string,
|
|
10
|
+
options: ClipboardOptions = {},
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
const runner = options.execa ?? execa;
|
|
13
|
+
const platform = options.platform ?? process.platform;
|
|
14
|
+
|
|
15
|
+
if (platform === "win32") {
|
|
16
|
+
await runner("cmd", ["/c", "clip"], { input: text, windowsHide: true });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (platform === "darwin") {
|
|
21
|
+
await runner("pbcopy", [], { input: text });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
await runner("xclip", ["-selection", "clipboard"], { input: text });
|
|
27
|
+
return;
|
|
28
|
+
} catch {
|
|
29
|
+
await runner("xsel", ["--clipboard", "--input"], { input: text });
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/gemini.ts
CHANGED
|
@@ -201,7 +201,12 @@ export async function launchGeminiCLI(
|
|
|
201
201
|
);
|
|
202
202
|
console.log(chalk.yellow(" npm install -g @google/gemini-cli"));
|
|
203
203
|
console.log("");
|
|
204
|
-
|
|
204
|
+
const shouldSkipDelay =
|
|
205
|
+
typeof process !== "undefined" &&
|
|
206
|
+
(process.env?.NODE_ENV === "test" || Boolean(process.env?.VITEST));
|
|
207
|
+
if (!shouldSkipDelay) {
|
|
208
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
209
|
+
}
|
|
205
210
|
return await run("bunx", [GEMINI_CLI_PACKAGE, ...runArgs]);
|
|
206
211
|
};
|
|
207
212
|
|
package/src/index.ts
CHANGED
|
@@ -768,11 +768,13 @@ export async function handleAIToolWorkflow(
|
|
|
768
768
|
lastSessionId: finalSessionId,
|
|
769
769
|
});
|
|
770
770
|
|
|
771
|
+
let uncommittedExists = false;
|
|
771
772
|
try {
|
|
772
773
|
const [hasUncommitted, hasUnpushed] = await Promise.all([
|
|
773
774
|
hasUncommittedChanges(worktreePath),
|
|
774
775
|
hasUnpushedCommits(worktreePath, branch),
|
|
775
776
|
]);
|
|
777
|
+
uncommittedExists = hasUncommitted;
|
|
776
778
|
|
|
777
779
|
if (hasUncommitted) {
|
|
778
780
|
const uncommittedCount = await getUncommittedChangesCount(worktreePath);
|
|
@@ -807,8 +809,15 @@ export async function handleAIToolWorkflow(
|
|
|
807
809
|
const details = error instanceof Error ? error.message : String(error);
|
|
808
810
|
printWarning(`Failed to check git status after session: ${details}`);
|
|
809
811
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
+
|
|
813
|
+
if (uncommittedExists) {
|
|
814
|
+
await waitForEnter("Press Enter to return to the main menu...");
|
|
815
|
+
} else {
|
|
816
|
+
// Small buffer before returning to branch list to avoid abrupt screen swap
|
|
817
|
+
await new Promise((resolve) =>
|
|
818
|
+
setTimeout(resolve, POST_SESSION_DELAY_MS),
|
|
819
|
+
);
|
|
820
|
+
}
|
|
812
821
|
printInfo("Session completed successfully. Returning to main menu...");
|
|
813
822
|
return;
|
|
814
823
|
} catch (error) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export interface FormattedLogEntry {
|
|
2
|
+
id: string;
|
|
3
|
+
raw: Record<string, unknown>;
|
|
4
|
+
timestamp: number | null;
|
|
5
|
+
timeLabel: string;
|
|
6
|
+
levelLabel: string;
|
|
7
|
+
category: string;
|
|
8
|
+
message: string;
|
|
9
|
+
summary: string;
|
|
10
|
+
json: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const LEVEL_LABELS: Record<number, string> = {
|
|
14
|
+
10: "TRACE",
|
|
15
|
+
20: "DEBUG",
|
|
16
|
+
30: "INFO",
|
|
17
|
+
40: "WARN",
|
|
18
|
+
50: "ERROR",
|
|
19
|
+
60: "FATAL",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const formatTimeLabel = (
|
|
23
|
+
value: unknown,
|
|
24
|
+
): { label: string; timestamp: number | null } => {
|
|
25
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
26
|
+
const date = new Date(value);
|
|
27
|
+
if (!Number.isNaN(date.getTime())) {
|
|
28
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
29
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
30
|
+
const seconds = String(date.getSeconds()).padStart(2, "0");
|
|
31
|
+
return {
|
|
32
|
+
label: `${hours}:${minutes}:${seconds}`,
|
|
33
|
+
timestamp: date.getTime(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { label: "--:--:--", timestamp: null };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const formatLevelLabel = (value: unknown): string => {
|
|
42
|
+
if (typeof value === "number") {
|
|
43
|
+
return LEVEL_LABELS[value] ?? `LEVEL-${value}`;
|
|
44
|
+
}
|
|
45
|
+
if (typeof value === "string") {
|
|
46
|
+
return value.toUpperCase();
|
|
47
|
+
}
|
|
48
|
+
return "UNKNOWN";
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const resolveMessage = (entry: Record<string, unknown>): string => {
|
|
52
|
+
if (typeof entry.msg === "string") {
|
|
53
|
+
return entry.msg;
|
|
54
|
+
}
|
|
55
|
+
if (typeof entry.message === "string") {
|
|
56
|
+
return entry.message;
|
|
57
|
+
}
|
|
58
|
+
if (entry.msg !== undefined) {
|
|
59
|
+
return String(entry.msg);
|
|
60
|
+
}
|
|
61
|
+
return "";
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function parseLogLines(
|
|
65
|
+
lines: string[],
|
|
66
|
+
options: { limit?: number } = {},
|
|
67
|
+
): FormattedLogEntry[] {
|
|
68
|
+
const entries: FormattedLogEntry[] = [];
|
|
69
|
+
|
|
70
|
+
lines.forEach((line, index) => {
|
|
71
|
+
if (!line.trim()) return;
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(line) as Record<string, unknown>;
|
|
74
|
+
const { label: timeLabel, timestamp } = formatTimeLabel(parsed.time);
|
|
75
|
+
const levelLabel = formatLevelLabel(parsed.level);
|
|
76
|
+
const category =
|
|
77
|
+
typeof parsed.category === "string" ? parsed.category : "unknown";
|
|
78
|
+
const message = resolveMessage(parsed);
|
|
79
|
+
const summary =
|
|
80
|
+
`[${timeLabel}] [${levelLabel}] [${category}] ${message}`.trim();
|
|
81
|
+
const json = JSON.stringify(parsed, null, 2);
|
|
82
|
+
const id = `${timestamp ?? "unknown"}-${index}`;
|
|
83
|
+
|
|
84
|
+
entries.push({
|
|
85
|
+
id,
|
|
86
|
+
raw: parsed,
|
|
87
|
+
timestamp,
|
|
88
|
+
timeLabel,
|
|
89
|
+
levelLabel,
|
|
90
|
+
category,
|
|
91
|
+
message,
|
|
92
|
+
summary,
|
|
93
|
+
json,
|
|
94
|
+
});
|
|
95
|
+
} catch {
|
|
96
|
+
// Skip malformed JSON lines
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const limit = options.limit ?? 100;
|
|
101
|
+
if (entries.length <= limit) {
|
|
102
|
+
return [...entries].reverse();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return entries.slice(-limit).reverse();
|
|
106
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { formatDate } from "./logger.js";
|
|
5
|
+
|
|
6
|
+
export interface LogFileInfo {
|
|
7
|
+
date: string;
|
|
8
|
+
path: string;
|
|
9
|
+
mtimeMs: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const LOG_FILENAME_PATTERN = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
13
|
+
|
|
14
|
+
export function resolveLogDir(cwd: string = process.cwd()): string {
|
|
15
|
+
const cwdBase = path.basename(cwd) || "workspace";
|
|
16
|
+
return path.join(os.homedir(), ".gwt", "logs", cwdBase);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildLogFilePath(logDir: string, date: string): string {
|
|
20
|
+
return path.join(logDir, `${date}.jsonl`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getTodayLogDate(): string {
|
|
24
|
+
return formatDate(new Date());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function readLogFileLines(filePath: string): Promise<string[]> {
|
|
28
|
+
try {
|
|
29
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
30
|
+
return content.split("\n").filter(Boolean);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
const err = error as NodeJS.ErrnoException;
|
|
33
|
+
if (err.code === "ENOENT") {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function listLogFiles(logDir: string): Promise<LogFileInfo[]> {
|
|
41
|
+
try {
|
|
42
|
+
const entries = await fs.readdir(logDir, { withFileTypes: true });
|
|
43
|
+
const files: LogFileInfo[] = [];
|
|
44
|
+
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (!entry.isFile()) continue;
|
|
47
|
+
if (!LOG_FILENAME_PATTERN.test(entry.name)) continue;
|
|
48
|
+
|
|
49
|
+
const date = entry.name.replace(/\.jsonl$/, "");
|
|
50
|
+
const fullPath = path.join(logDir, entry.name);
|
|
51
|
+
try {
|
|
52
|
+
const stat = await fs.stat(fullPath);
|
|
53
|
+
files.push({ date, path: fullPath, mtimeMs: stat.mtimeMs });
|
|
54
|
+
} catch {
|
|
55
|
+
// Ignore stat errors per-file
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return files.sort((a, b) => b.date.localeCompare(a.date));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const err = error as NodeJS.ErrnoException;
|
|
62
|
+
if (err.code === "ENOENT") {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function listRecentLogFiles(
|
|
70
|
+
logDir: string,
|
|
71
|
+
days = 7,
|
|
72
|
+
): Promise<LogFileInfo[]> {
|
|
73
|
+
const files = await listLogFiles(logDir);
|
|
74
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
75
|
+
return files.filter((file) => file.mtimeMs >= cutoff);
|
|
76
|
+
}
|